1
use clap::{Parser, Subcommand};
2
use cli_core::ssh_keys::{parse_authorized_keys_line, parse_public_key_file};
3
use cli_core::{
4
    CliAccountBalance, CliAccountCreate, CliAccountList, CliCommodityCreate, CliCommodityList,
5
    CliGetConfig, CliReportsActivity, CliReportsBalance, CliReportsBreakdown, CliSelectColumn,
6
    CliSetConfig, CliSshKeyAdd, CliSshKeyList, CliSshKeyRemove, CliTransactionCreate,
7
    CliTransactionList, CliVersion, CommandError, start_server,
8
};
9
use exitfailure::ExitFailure;
10
use log::LevelFilter;
11
use num_rational::Rational64;
12
use server::command::Argument;
13
use sqlx::types::Uuid;
14
use std::collections::HashMap;
15
use std::str::FromStr;
16

            
17
mod dispatch;
18

            
19
use dispatch::run_and_print;
20

            
21
#[derive(Debug, Clone)]
22
struct FieldContentPair {
23
    field: String,
24
    content: String,
25
}
26

            
27
impl FromStr for FieldContentPair {
28
    type Err = String;
29

            
30
3
    fn from_str(s: &str) -> Result<Self, Self::Err> {
31
3
        let parts: Vec<&str> = s.splitn(2, '=').collect();
32
3
        if parts.len() == 2 {
33
2
            Ok(FieldContentPair {
34
2
                field: parts[0].to_string(),
35
2
                content: parts[1].to_string(),
36
2
            })
37
        } else {
38
1
            Err("Expected format `field=content`".to_string())
39
        }
40
3
    }
41
}
42

            
43
7
fn parse_rational(s: &str) -> Result<Rational64, String> {
44
7
    if let Some((num, denom)) = s.split_once('/') {
45
3
        let n: i64 = num
46
3
            .parse()
47
3
            .map_err(|e: std::num::ParseIntError| e.to_string())?;
48
3
        let d: i64 = denom
49
3
            .parse()
50
3
            .map_err(|e: std::num::ParseIntError| e.to_string())?;
51
3
        if d == 0 {
52
1
            return Err("denominator cannot be zero".to_string());
53
2
        }
54
2
        Ok(Rational64::new(n, d))
55
    } else {
56
4
        let n: i64 = s
57
4
            .parse()
58
4
            .map_err(|e: std::num::ParseIntError| e.to_string())?;
59
1
        Ok(Rational64::new(n, 1))
60
    }
61
7
}
62

            
63
#[derive(Parser, Debug)]
64
#[command(name = "nomisync", about = "Nomisync automation CLI")]
65
struct Cli {
66
    #[arg(short = 'u', long)]
67
    userid: Uuid,
68

            
69
    #[arg(short = 'd', long)]
70
    database: Option<String>,
71

            
72
    #[arg(long)]
73
    setopt: Option<FieldContentPair>,
74

            
75
    #[arg(long, default_value = "warn")]
76
    loglevel: LevelFilter,
77

            
78
    #[command(subcommand)]
79
    cmd: Command,
80
}
81

            
82
#[derive(Subcommand, Debug)]
83
enum Command {
84
    /// Print the software version
85
    Version,
86

            
87
    /// Access to accounts
88
    #[command(subcommand)]
89
    Account(AccountCmd),
90

            
91
    /// Access to transactions
92
    #[command(subcommand)]
93
    Transaction(TransactionCmd),
94

            
95
    /// Access to commodities
96
    #[command(subcommand)]
97
    Commodity(CommodityCmd),
98

            
99
    /// Access to configuration
100
    #[command(subcommand)]
101
    Config(ConfigCmd),
102

            
103
    /// Access to SQL database
104
    #[command(subcommand)]
105
    Sql(SqlCmd),
106

            
107
    /// Text-rendered report charts
108
    #[command(subcommand)]
109
    Reports(ReportsCmd),
110

            
111
    /// Manage SSH public keys for remote TUI access
112
    #[command(subcommand, name = "ssh-key")]
113
    SshKey(SshKeyCmd),
114
}
115

            
116
#[derive(Subcommand, Debug)]
117
enum SshKeyCmd {
118
    /// Register a public key for the current user
119
    Add {
120
        /// Path to a `.pub` OpenSSH public-key file
121
        #[arg(
122
            long,
123
            conflicts_with = "public_key",
124
            required_unless_present = "public_key"
125
        )]
126
        key_file: Option<String>,
127
        /// OpenSSH `authorized_keys` line passed inline
128
        #[arg(
129
            long,
130
            conflicts_with = "key_file",
131
            required_unless_present = "key_file"
132
        )]
133
        public_key: Option<String>,
134
        /// Optional human-readable label
135
        #[arg(long)]
136
        annotation: Option<String>,
137
    },
138
    /// List all keys for the current user
139
    List,
140
    /// Remove a key by its SHA-256 fingerprint
141
    Remove {
142
        /// Fingerprint, e.g. `SHA256:abc…`
143
        #[arg(long)]
144
        fingerprint: String,
145
    },
146
}
147

            
148
#[derive(Subcommand, Debug)]
149
enum AccountCmd {
150
    /// List all accounts
151
    List,
152
    /// Get the current balance and currency of an account
153
    Balance {
154
        #[arg(long)]
155
        account: Uuid,
156
    },
157
    /// Create new account
158
    Create {
159
        #[arg(long)]
160
        name: String,
161
        #[arg(long)]
162
        parent: Option<Uuid>,
163
    },
164
}
165

            
166
#[derive(Subcommand, Debug)]
167
enum TransactionCmd {
168
    /// List all transactions
169
    List {
170
        #[arg(long)]
171
        account: Option<Uuid>,
172
    },
173
    /// Create new transaction
174
    Create {
175
        #[arg(long)]
176
        from: Uuid,
177
        #[arg(long)]
178
        to: Uuid,
179
        #[arg(long)]
180
        from_currency: Uuid,
181
        #[arg(long)]
182
        to_currency: Uuid,
183
        #[arg(long, value_parser = parse_rational)]
184
        value: Rational64,
185
        #[arg(long, value_parser = parse_rational)]
186
        to_amount: Option<Rational64>,
187
        #[arg(long)]
188
        note: Option<String>,
189
    },
190
}
191

            
192
#[derive(Subcommand, Debug)]
193
enum CommodityCmd {
194
    /// List all commodities
195
    List,
196
    /// Create new commodity
197
    Create {
198
        #[arg(long)]
199
        symbol: String,
200
        #[arg(long)]
201
        name: String,
202
    },
203
}
204

            
205
#[derive(Subcommand, Debug)]
206
enum ConfigCmd {
207
    /// Print the value from config
208
    Get {
209
        #[arg(long)]
210
        name: String,
211
    },
212
    /// Set the value in config
213
    Set {
214
        #[arg(long)]
215
        name: String,
216
        #[arg(long)]
217
        value: String,
218
    },
219
}
220

            
221
#[derive(Subcommand, Debug)]
222
enum SqlCmd {
223
    /// Raw select of SQL table
224
    Selcol {
225
        #[arg(long)]
226
        field: String,
227
        #[arg(long)]
228
        table: String,
229
    },
230
}
231

            
232
#[derive(Subcommand, Debug)]
233
enum ReportsCmd {
234
    /// Balance chart (top-level accounts by magnitude)
235
    Balance {
236
        #[arg(long)]
237
        from: Option<String>,
238
        #[arg(long)]
239
        to: Option<String>,
240
        #[arg(long, default_value = "bar")]
241
        chart: String,
242
    },
243
    /// Activity chart (Income vs Expense over a period)
244
    Activity {
245
        #[arg(long)]
246
        from: String,
247
        #[arg(long)]
248
        to: String,
249
        #[arg(long, default_value = "bar")]
250
        chart: String,
251
    },
252
    /// Category breakdown chart (top-N tag values)
253
    Breakdown {
254
        #[arg(long)]
255
        from: String,
256
        #[arg(long)]
257
        to: String,
258
        #[arg(long)]
259
        tag: Option<String>,
260
        #[arg(long, default_value = "bar")]
261
        chart: String,
262
    },
263
}
264

            
265
#[tokio::main]
266
22
async fn main() -> Result<(), ExitFailure> {
267
22
    let cli = Cli::parse();
268

            
269
22
    env_logger::Builder::new()
270
22
        .filter_level(cli.loglevel)
271
22
        .target(env_logger::Target::Stderr)
272
22
        .init();
273

            
274
22
    let setopt = cli.setopt.map(|p| (p.field, p.content));
275
22
    start_server(cli.database, setopt).await?;
276

            
277
    let outcome = dispatch_command(cli.userid, cli.cmd).await;
278
22
    match outcome {
279
22
        Ok(()) => Ok(()),
280
22
        Err(err) => {
281
22
            eprintln!("Error: {err}");
282
22
            std::process::exit(1);
283
22
        }
284
22
    }
285
22
}
286

            
287
async fn dispatch_command(userid: Uuid, cmd: Command) -> Result<(), CommandError> {
288
    match cmd {
289
        Command::Version => run_and_print(&CliVersion, HashMap::new()).await,
290
        Command::Account(c) => run_account(userid, c).await,
291
        Command::Transaction(c) => run_transaction(userid, c).await,
292
        Command::Commodity(c) => run_commodity(userid, c).await,
293
        Command::Config(c) => run_config(c).await,
294
        Command::Sql(c) => run_sql(c).await,
295
        Command::Reports(c) => run_reports(userid, c).await,
296
        Command::SshKey(c) => run_ssh_key(userid, c).await,
297
    }
298
}
299

            
300
fn user_args(userid: Uuid) -> HashMap<&'static str, Argument> {
301
    let mut args = HashMap::new();
302
    args.insert("user_id", Argument::Uuid(userid));
303
    args
304
}
305

            
306
async fn run_account(userid: Uuid, cmd: AccountCmd) -> Result<(), CommandError> {
307
    match cmd {
308
        AccountCmd::List => run_and_print(&CliAccountList, user_args(userid)).await,
309
        AccountCmd::Balance { account } => {
310
            let mut args = user_args(userid);
311
            args.insert("account", Argument::Uuid(account));
312
            run_and_print(&CliAccountBalance, args).await
313
        }
314
        AccountCmd::Create { name, parent } => {
315
            let mut args = user_args(userid);
316
            args.insert("name", Argument::String(name));
317
            if let Some(p) = parent {
318
                args.insert("parent", Argument::Uuid(p));
319
            }
320
            run_and_print(&CliAccountCreate, args).await
321
        }
322
    }
323
}
324

            
325
async fn run_transaction(userid: Uuid, cmd: TransactionCmd) -> Result<(), CommandError> {
326
    match cmd {
327
        TransactionCmd::List { account } => {
328
            let mut args = user_args(userid);
329
            if let Some(a) = account {
330
                args.insert("account", Argument::Uuid(a));
331
            }
332
            run_and_print(&CliTransactionList, args).await
333
        }
334
        TransactionCmd::Create {
335
            from,
336
            to,
337
            from_currency,
338
            to_currency,
339
            value,
340
            to_amount,
341
            note,
342
        } => {
343
            let mut args = user_args(userid);
344
            args.insert("from", Argument::Uuid(from));
345
            args.insert("to", Argument::Uuid(to));
346
            args.insert("from_currency", Argument::Uuid(from_currency));
347
            args.insert("to_currency", Argument::Uuid(to_currency));
348
            args.insert("value", Argument::Rational(value));
349
            if let Some(t) = to_amount {
350
                args.insert("to_amount", Argument::Rational(t));
351
            }
352
            if let Some(n) = note {
353
                args.insert("note", Argument::String(n));
354
            }
355
            run_and_print(&CliTransactionCreate, args).await
356
        }
357
    }
358
}
359

            
360
async fn run_commodity(userid: Uuid, cmd: CommodityCmd) -> Result<(), CommandError> {
361
    match cmd {
362
        CommodityCmd::List => run_and_print(&CliCommodityList, user_args(userid)).await,
363
        CommodityCmd::Create { symbol, name } => {
364
            let mut args = user_args(userid);
365
            args.insert("symbol", Argument::String(symbol));
366
            args.insert("name", Argument::String(name));
367
            run_and_print(&CliCommodityCreate, args).await
368
        }
369
    }
370
}
371

            
372
async fn run_config(cmd: ConfigCmd) -> Result<(), CommandError> {
373
    match cmd {
374
        ConfigCmd::Get { name } => {
375
            let mut args: HashMap<&str, Argument> = HashMap::new();
376
            args.insert("name", Argument::String(name));
377
            run_and_print(&CliGetConfig, args).await
378
        }
379
        ConfigCmd::Set { name, value } => {
380
            let mut args: HashMap<&str, Argument> = HashMap::new();
381
            args.insert("name", Argument::String(name));
382
            args.insert("value", Argument::String(value));
383
            run_and_print(&CliSetConfig, args).await
384
        }
385
    }
386
}
387

            
388
async fn run_sql(cmd: SqlCmd) -> Result<(), CommandError> {
389
    match cmd {
390
        SqlCmd::Selcol { field, table } => {
391
            let mut args: HashMap<&str, Argument> = HashMap::new();
392
            args.insert("field", Argument::String(field));
393
            args.insert("table", Argument::String(table));
394
            run_and_print(&CliSelectColumn, args).await
395
        }
396
    }
397
}
398

            
399
async fn run_reports(userid: Uuid, cmd: ReportsCmd) -> Result<(), CommandError> {
400
    match cmd {
401
        ReportsCmd::Balance { from, to, chart } => {
402
            let mut args = user_args(userid);
403
            if let Some(s) = from {
404
                args.insert("from", Argument::String(s));
405
            }
406
            if let Some(s) = to {
407
                args.insert("to", Argument::String(s));
408
            }
409
            args.insert("chart", Argument::String(chart));
410
            run_and_print(&CliReportsBalance, args).await
411
        }
412
        ReportsCmd::Activity { from, to, chart } => {
413
            let mut args = user_args(userid);
414
            args.insert("from", Argument::String(from));
415
            args.insert("to", Argument::String(to));
416
            args.insert("chart", Argument::String(chart));
417
            run_and_print(&CliReportsActivity, args).await
418
        }
419
        ReportsCmd::Breakdown {
420
            from,
421
            to,
422
            tag,
423
            chart,
424
        } => {
425
            let mut args = user_args(userid);
426
            args.insert("from", Argument::String(from));
427
            args.insert("to", Argument::String(to));
428
            args.insert("chart", Argument::String(chart));
429
            if let Some(t) = tag {
430
                args.insert("tag", Argument::String(t));
431
            }
432
            run_and_print(&CliReportsBreakdown, args).await
433
        }
434
    }
435
}
436

            
437
async fn run_ssh_key(userid: Uuid, cmd: SshKeyCmd) -> Result<(), CommandError> {
438
    match cmd {
439
        SshKeyCmd::Add {
440
            key_file,
441
            public_key,
442
            annotation,
443
        } => {
444
            let parsed = if let Some(path) = key_file {
445
                parse_public_key_file(&path)
446
                    .map_err(|e| CommandError::Argument(format!("ssh-key parse: {e}")))?
447
            } else if let Some(line) = public_key {
448
                parse_authorized_keys_line(&line)
449
                    .map_err(|e| CommandError::Argument(format!("ssh-key parse: {e}")))?
450
            } else {
451
                return Err(CommandError::Argument(
452
                    "either --key-file or --public-key is required".to_string(),
453
                ));
454
            };
455
            let mut args = user_args(userid);
456
            args.insert("key_type", Argument::String(parsed.key_type));
457
            args.insert("key_blob", Argument::Data(parsed.key_blob));
458
            args.insert("fingerprint", Argument::String(parsed.fingerprint));
459
            let label = annotation.unwrap_or(parsed.comment);
460
            if !label.is_empty() {
461
                args.insert("annotation", Argument::String(label));
462
            }
463
            run_and_print(&CliSshKeyAdd, args).await
464
        }
465
        SshKeyCmd::List => run_and_print(&CliSshKeyList, user_args(userid)).await,
466
        SshKeyCmd::Remove { fingerprint } => {
467
            let mut args = user_args(userid);
468
            args.insert("fingerprint", Argument::String(fingerprint));
469
            run_and_print(&CliSshKeyRemove, args).await
470
        }
471
    }
472
}
473

            
474
#[cfg(test)]
475
mod tests {
476
    use super::*;
477
    use clap::Parser;
478

            
479
    #[test]
480
1
    fn field_content_pair_parses_key_value() {
481
1
        let p: FieldContentPair = "locale=en".parse().unwrap();
482
1
        assert_eq!(p.field, "locale");
483
1
        assert_eq!(p.content, "en");
484
1
    }
485

            
486
    #[test]
487
1
    fn field_content_pair_rejects_missing_equals() {
488
1
        assert!("locale".parse::<FieldContentPair>().is_err());
489
1
    }
490

            
491
    #[test]
492
1
    fn field_content_pair_handles_value_with_equals() {
493
1
        let p: FieldContentPair = "sql=SELECT 1=1".parse().unwrap();
494
1
        assert_eq!(p.field, "sql");
495
1
        assert_eq!(p.content, "SELECT 1=1");
496
1
    }
497

            
498
    #[test]
499
1
    fn parse_rational_handles_integer() {
500
1
        let r = parse_rational("42").unwrap();
501
1
        assert_eq!(r, Rational64::new(42, 1));
502
1
    }
503

            
504
    #[test]
505
1
    fn parse_rational_handles_fraction() {
506
1
        let r = parse_rational("3/4").unwrap();
507
1
        assert_eq!(r, Rational64::new(3, 4));
508
1
    }
509

            
510
    #[test]
511
1
    fn parse_rational_rejects_zero_denominator() {
512
1
        assert!(parse_rational("1/0").is_err());
513
1
    }
514

            
515
    #[test]
516
1
    fn parse_rational_rejects_non_numeric() {
517
1
        assert!(parse_rational("abc").is_err());
518
1
    }
519

            
520
    #[test]
521
1
    fn cli_parses_version_subcommand() {
522
1
        let uuid = Uuid::new_v4();
523
1
        let parsed =
524
1
            Cli::try_parse_from(["nomisync", "--userid", &uuid.to_string(), "version"]).unwrap();
525
1
        assert!(matches!(parsed.cmd, Command::Version));
526
1
    }
527

            
528
    #[test]
529
1
    fn cli_parses_reports_balance_with_flags() {
530
1
        let uuid = Uuid::new_v4();
531
1
        let parsed = Cli::try_parse_from([
532
1
            "nomisync",
533
1
            "--userid",
534
1
            &uuid.to_string(),
535
1
            "reports",
536
1
            "balance",
537
1
            "--from",
538
1
            "2026-01-01",
539
1
            "--to",
540
1
            "2026-04-30",
541
1
            "--chart",
542
1
            "line",
543
1
        ])
544
1
        .unwrap();
545
1
        let Command::Reports(ReportsCmd::Balance { from, to, chart }) = parsed.cmd else {
546
            panic!("expected reports balance");
547
        };
548
1
        assert_eq!(from.unwrap(), "2026-01-01");
549
1
        assert_eq!(to.unwrap(), "2026-04-30");
550
1
        assert_eq!(chart, "line");
551
1
    }
552

            
553
    #[test]
554
1
    fn cli_parses_account_create_with_optional_parent() {
555
1
        let uuid = Uuid::new_v4();
556
1
        let parsed = Cli::try_parse_from([
557
1
            "nomisync",
558
1
            "--userid",
559
1
            &uuid.to_string(),
560
1
            "account",
561
1
            "create",
562
1
            "--name",
563
1
            "Cash",
564
1
        ])
565
1
        .unwrap();
566
1
        let Command::Account(AccountCmd::Create { name, parent }) = parsed.cmd else {
567
            panic!("expected account create");
568
        };
569
1
        assert_eq!(name, "Cash");
570
1
        assert!(parent.is_none());
571
1
    }
572

            
573
    #[test]
574
1
    fn cli_parses_transaction_create_rational() {
575
1
        let uuid = Uuid::new_v4();
576
1
        let from = Uuid::new_v4();
577
1
        let to = Uuid::new_v4();
578
1
        let fc = Uuid::new_v4();
579
1
        let tc = Uuid::new_v4();
580
1
        let parsed = Cli::try_parse_from([
581
1
            "nomisync",
582
1
            "--userid",
583
1
            &uuid.to_string(),
584
1
            "transaction",
585
1
            "create",
586
1
            "--from",
587
1
            &from.to_string(),
588
1
            "--to",
589
1
            &to.to_string(),
590
1
            "--from-currency",
591
1
            &fc.to_string(),
592
1
            "--to-currency",
593
1
            &tc.to_string(),
594
1
            "--value",
595
1
            "100/1",
596
1
        ])
597
1
        .unwrap();
598
1
        let Command::Transaction(TransactionCmd::Create { value, .. }) = parsed.cmd else {
599
            panic!("expected transaction create");
600
        };
601
1
        assert_eq!(value, Rational64::new(100, 1));
602
1
    }
603

            
604
    #[test]
605
1
    fn cli_rejects_missing_userid() {
606
1
        let res = Cli::try_parse_from(["nomisync", "version"]);
607
1
        assert!(res.is_err());
608
1
    }
609
}