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