1
use clap::{Parser, Subcommand};
2
use cli_core::ssh_keys::{parse_authorized_keys_line, parse_public_key_file};
3
use cli_core::{CliSelectColumn, CliSshKeyAdd, CommandError, start_server};
4
use exitfailure::ExitFailure;
5
use log::LevelFilter;
6
use num_rational::Rational64;
7
use rpc::{ScriptCtx, Session};
8
use server::command::Argument;
9
use sqlx::types::Uuid;
10
use std::collections::HashMap;
11
use std::str::FromStr;
12

            
13
mod dispatch;
14
mod eval;
15

            
16
use cli_core::reports::coerce_date_arg;
17
use dispatch::run_and_print;
18
use eval::{RenderMode, build_create_transaction_form, escape_str, eval_print};
19

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
258
#[tokio::main]
259
22
async fn main() -> Result<(), ExitFailure> {
260
22
    let cli = Cli::parse();
261

            
262
22
    env_logger::Builder::new()
263
22
        .filter_level(cli.loglevel)
264
22
        .target(env_logger::Target::Stderr)
265
22
        .init();
266

            
267
22
    let setopt = cli.setopt.map(|p| (p.field, p.content));
268
22
    start_server(cli.database, setopt).await?;
269

            
270
    let session_result = Session::new(ScriptCtx::new(cli.userid));
271
    let mut session = match session_result {
272
        Ok(s) => s,
273
        Err(e) => {
274
            eprintln!("Error: session init failed: {e}");
275
            std::process::exit(1);
276
        }
277
    };
278

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

            
289
async fn dispatch_command(
290
    session: &mut Session,
291
    userid: Uuid,
292
    cmd: Command,
293
) -> Result<(), CommandError> {
294
    match cmd {
295
        Command::Version => eval_print(session, "(get-version)", RenderMode::Scalar).await,
296
        Command::Account(c) => run_account(session, userid, c).await,
297
        Command::Transaction(c) => run_transaction(session, c).await,
298
        Command::Commodity(c) => run_commodity(session, c).await,
299
        Command::Config(c) => run_config(session, c).await,
300
        Command::Sql(c) => run_sql(c).await,
301
        Command::Reports(c) => run_reports(session, c).await,
302
        Command::SshKey(c) => run_ssh_key(session, userid, c).await,
303
    }
304
}
305

            
306
fn user_args(userid: Uuid) -> HashMap<&'static str, Argument> {
307
    let mut args = HashMap::new();
308
    args.insert("user_id", Argument::Uuid(userid));
309
    args
310
}
311

            
312
async fn run_account(
313
    session: &mut Session,
314
    _userid: Uuid,
315
    cmd: AccountCmd,
316
) -> Result<(), CommandError> {
317
    match cmd {
318
        AccountCmd::List => eval_print(session, "(list-accounts)", RenderMode::AccountList).await,
319
        AccountCmd::Balance { account } => {
320
            let form = format!("(get-balances {})", escape_str(&account.to_string()));
321
            eval_print(session, &form, RenderMode::BalanceList).await
322
        }
323
        AccountCmd::Create { name, parent } => {
324
            let parent_str = parent.map_or_else(String::new, |p| p.to_string());
325
            let form = format!(
326
                "(create-account {} {})",
327
                escape_str(&name),
328
                escape_str(&parent_str)
329
            );
330
            eval_print(session, &form, RenderMode::Scalar).await
331
        }
332
    }
333
}
334

            
335
async fn run_transaction(session: &mut Session, cmd: TransactionCmd) -> Result<(), CommandError> {
336
    match cmd {
337
        TransactionCmd::List { account } => {
338
            let account_str = account.map_or_else(String::new, |a| a.to_string());
339
            let form = format!("(list-transactions {})", escape_str(&account_str));
340
            eval_print(session, &form, RenderMode::TransactionList).await
341
        }
342
        TransactionCmd::Create {
343
            from,
344
            to,
345
            from_currency,
346
            to_currency,
347
            value,
348
            to_amount,
349
            note,
350
        } => {
351
            let form = build_create_transaction_form(
352
                from,
353
                to,
354
                from_currency,
355
                to_currency,
356
                value,
357
                to_amount,
358
                note.as_deref(),
359
            )?;
360
            eval_print(session, &form, RenderMode::Scalar).await
361
        }
362
    }
363
}
364

            
365
async fn run_commodity(session: &mut Session, cmd: CommodityCmd) -> Result<(), CommandError> {
366
    match cmd {
367
        CommodityCmd::List => {
368
            eval_print(session, "(list-commodities)", RenderMode::CommodityList).await
369
        }
370
        CommodityCmd::Create { symbol, name } => {
371
            let form = format!(
372
                "(create-commodity {} {})",
373
                escape_str(&symbol),
374
                escape_str(&name)
375
            );
376
            eval_print(session, &form, RenderMode::Scalar).await
377
        }
378
    }
379
}
380

            
381
async fn run_config(session: &mut Session, cmd: ConfigCmd) -> Result<(), CommandError> {
382
    match cmd {
383
        ConfigCmd::Get { name } => {
384
            let form = format!("(get-config {})", escape_str(&name));
385
            eval_print(session, &form, RenderMode::ConfigValue).await
386
        }
387
        ConfigCmd::Set { name, value } => {
388
            let form = format!("(set-config {} {})", escape_str(&name), escape_str(&value));
389
            eval_print(session, &form, RenderMode::Silent).await
390
        }
391
    }
392
}
393

            
394
async fn run_sql(cmd: SqlCmd) -> Result<(), CommandError> {
395
    match cmd {
396
        SqlCmd::Selcol { field, table } => {
397
            let mut args: HashMap<&str, Argument> = HashMap::new();
398
            args.insert("field", Argument::String(field));
399
            args.insert("table", Argument::String(table));
400
            run_and_print(&CliSelectColumn, args).await
401
        }
402
    }
403
}
404

            
405
async fn run_reports(session: &mut Session, cmd: ReportsCmd) -> Result<(), CommandError> {
406
    match cmd {
407
        ReportsCmd::Balance { chart } => {
408
            eval_print(
409
                session,
410
                "(balance-report)",
411
                RenderMode::ReportBalance { chart },
412
            )
413
            .await
414
        }
415
        ReportsCmd::Activity { from, to, chart } => {
416
            let from = coerce_date_arg(&from, false)?;
417
            let to = coerce_date_arg(&to, true)?;
418
            let form = format!(
419
                "(activity-report {} {})",
420
                escape_str(&from),
421
                escape_str(&to)
422
            );
423
            eval_print(session, &form, RenderMode::ReportActivity { chart }).await
424
        }
425
        ReportsCmd::Breakdown { from, to, chart } => {
426
            let from = coerce_date_arg(&from, false)?;
427
            let to = coerce_date_arg(&to, true)?;
428
            let form = format!(
429
                "(category-breakdown {} {})",
430
                escape_str(&from),
431
                escape_str(&to)
432
            );
433
            eval_print(session, &form, RenderMode::ReportBreakdown { chart }).await
434
        }
435
    }
436
}
437

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

            
478
#[cfg(test)]
479
mod tests {
480
    use super::*;
481
    use clap::Parser;
482

            
483
    #[test]
484
1
    fn field_content_pair_parses_key_value() {
485
1
        let p: FieldContentPair = "locale=en".parse().unwrap();
486
1
        assert_eq!(p.field, "locale");
487
1
        assert_eq!(p.content, "en");
488
1
    }
489

            
490
    #[test]
491
1
    fn field_content_pair_rejects_missing_equals() {
492
1
        assert!("locale".parse::<FieldContentPair>().is_err());
493
1
    }
494

            
495
    #[test]
496
1
    fn field_content_pair_handles_value_with_equals() {
497
1
        let p: FieldContentPair = "sql=SELECT 1=1".parse().unwrap();
498
1
        assert_eq!(p.field, "sql");
499
1
        assert_eq!(p.content, "SELECT 1=1");
500
1
    }
501

            
502
    #[test]
503
1
    fn parse_rational_handles_integer() {
504
1
        let r = parse_rational("42").unwrap();
505
1
        assert_eq!(r, Rational64::new(42, 1));
506
1
    }
507

            
508
    #[test]
509
1
    fn parse_rational_handles_fraction() {
510
1
        let r = parse_rational("3/4").unwrap();
511
1
        assert_eq!(r, Rational64::new(3, 4));
512
1
    }
513

            
514
    #[test]
515
1
    fn parse_rational_rejects_zero_denominator() {
516
1
        assert!(parse_rational("1/0").is_err());
517
1
    }
518

            
519
    #[test]
520
1
    fn parse_rational_rejects_non_numeric() {
521
1
        assert!(parse_rational("abc").is_err());
522
1
    }
523

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

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

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

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

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