Skip to main content

cli/
main.rs

1use clap::{Parser, Subcommand};
2use cli_core::ssh_keys::{parse_authorized_keys_line, parse_public_key_file};
3use 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};
9use exitfailure::ExitFailure;
10use log::LevelFilter;
11use num_rational::Rational64;
12use server::command::Argument;
13use sqlx::types::Uuid;
14use std::collections::HashMap;
15use std::str::FromStr;
16
17mod dispatch;
18
19use dispatch::run_and_print;
20
21#[derive(Debug, Clone)]
22struct FieldContentPair {
23    field: String,
24    content: String,
25}
26
27impl FromStr for FieldContentPair {
28    type Err = String;
29
30    fn from_str(s: &str) -> Result<Self, Self::Err> {
31        let parts: Vec<&str> = s.splitn(2, '=').collect();
32        if parts.len() == 2 {
33            Ok(FieldContentPair {
34                field: parts[0].to_string(),
35                content: parts[1].to_string(),
36            })
37        } else {
38            Err("Expected format `field=content`".to_string())
39        }
40    }
41}
42
43fn parse_rational(s: &str) -> Result<Rational64, String> {
44    if let Some((num, denom)) = s.split_once('/') {
45        let n: i64 = num
46            .parse()
47            .map_err(|e: std::num::ParseIntError| e.to_string())?;
48        let d: i64 = denom
49            .parse()
50            .map_err(|e: std::num::ParseIntError| e.to_string())?;
51        if d == 0 {
52            return Err("denominator cannot be zero".to_string());
53        }
54        Ok(Rational64::new(n, d))
55    } else {
56        let n: i64 = s
57            .parse()
58            .map_err(|e: std::num::ParseIntError| e.to_string())?;
59        Ok(Rational64::new(n, 1))
60    }
61}
62
63#[derive(Parser, Debug)]
64#[command(name = "nomisync", about = "Nomisync automation CLI")]
65struct 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)]
83enum 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)]
117enum 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)]
149enum 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)]
167enum 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)]
193enum 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)]
206enum 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)]
222enum 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)]
233enum 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]
266async fn main() -> Result<(), ExitFailure> {
267    let cli = Cli::parse();
268
269    env_logger::Builder::new()
270        .filter_level(cli.loglevel)
271        .target(env_logger::Target::Stderr)
272        .init();
273
274    let setopt = cli.setopt.map(|p| (p.field, p.content));
275    start_server(cli.database, setopt).await?;
276
277    let outcome = dispatch_command(cli.userid, cli.cmd).await;
278    match outcome {
279        Ok(()) => Ok(()),
280        Err(err) => {
281            eprintln!("Error: {err}");
282            std::process::exit(1);
283        }
284    }
285}
286
287async 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
300fn 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
306async 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
325async 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
360async 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
372async 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
388async 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
399async 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
437async 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)]
475mod tests {
476    use super::*;
477    use clap::Parser;
478
479    #[test]
480    fn field_content_pair_parses_key_value() {
481        let p: FieldContentPair = "locale=en".parse().unwrap();
482        assert_eq!(p.field, "locale");
483        assert_eq!(p.content, "en");
484    }
485
486    #[test]
487    fn field_content_pair_rejects_missing_equals() {
488        assert!("locale".parse::<FieldContentPair>().is_err());
489    }
490
491    #[test]
492    fn field_content_pair_handles_value_with_equals() {
493        let p: FieldContentPair = "sql=SELECT 1=1".parse().unwrap();
494        assert_eq!(p.field, "sql");
495        assert_eq!(p.content, "SELECT 1=1");
496    }
497
498    #[test]
499    fn parse_rational_handles_integer() {
500        let r = parse_rational("42").unwrap();
501        assert_eq!(r, Rational64::new(42, 1));
502    }
503
504    #[test]
505    fn parse_rational_handles_fraction() {
506        let r = parse_rational("3/4").unwrap();
507        assert_eq!(r, Rational64::new(3, 4));
508    }
509
510    #[test]
511    fn parse_rational_rejects_zero_denominator() {
512        assert!(parse_rational("1/0").is_err());
513    }
514
515    #[test]
516    fn parse_rational_rejects_non_numeric() {
517        assert!(parse_rational("abc").is_err());
518    }
519
520    #[test]
521    fn cli_parses_version_subcommand() {
522        let uuid = Uuid::new_v4();
523        let parsed =
524            Cli::try_parse_from(["nomisync", "--userid", &uuid.to_string(), "version"]).unwrap();
525        assert!(matches!(parsed.cmd, Command::Version));
526    }
527
528    #[test]
529    fn cli_parses_reports_balance_with_flags() {
530        let uuid = Uuid::new_v4();
531        let parsed = Cli::try_parse_from([
532            "nomisync",
533            "--userid",
534            &uuid.to_string(),
535            "reports",
536            "balance",
537            "--from",
538            "2026-01-01",
539            "--to",
540            "2026-04-30",
541            "--chart",
542            "line",
543        ])
544        .unwrap();
545        let Command::Reports(ReportsCmd::Balance { from, to, chart }) = parsed.cmd else {
546            panic!("expected reports balance");
547        };
548        assert_eq!(from.unwrap(), "2026-01-01");
549        assert_eq!(to.unwrap(), "2026-04-30");
550        assert_eq!(chart, "line");
551    }
552
553    #[test]
554    fn cli_parses_account_create_with_optional_parent() {
555        let uuid = Uuid::new_v4();
556        let parsed = Cli::try_parse_from([
557            "nomisync",
558            "--userid",
559            &uuid.to_string(),
560            "account",
561            "create",
562            "--name",
563            "Cash",
564        ])
565        .unwrap();
566        let Command::Account(AccountCmd::Create { name, parent }) = parsed.cmd else {
567            panic!("expected account create");
568        };
569        assert_eq!(name, "Cash");
570        assert!(parent.is_none());
571    }
572
573    #[test]
574    fn cli_parses_transaction_create_rational() {
575        let uuid = Uuid::new_v4();
576        let from = Uuid::new_v4();
577        let to = Uuid::new_v4();
578        let fc = Uuid::new_v4();
579        let tc = Uuid::new_v4();
580        let parsed = Cli::try_parse_from([
581            "nomisync",
582            "--userid",
583            &uuid.to_string(),
584            "transaction",
585            "create",
586            "--from",
587            &from.to_string(),
588            "--to",
589            &to.to_string(),
590            "--from-currency",
591            &fc.to_string(),
592            "--to-currency",
593            &tc.to_string(),
594            "--value",
595            "100/1",
596        ])
597        .unwrap();
598        let Command::Transaction(TransactionCmd::Create { value, .. }) = parsed.cmd else {
599            panic!("expected transaction create");
600        };
601        assert_eq!(value, Rational64::new(100, 1));
602    }
603
604    #[test]
605    fn cli_rejects_missing_userid() {
606        let res = Cli::try_parse_from(["nomisync", "version"]);
607        assert!(res.is_err());
608    }
609}