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 Version,
86
87 #[command(subcommand)]
89 Account(AccountCmd),
90
91 #[command(subcommand)]
93 Transaction(TransactionCmd),
94
95 #[command(subcommand)]
97 Commodity(CommodityCmd),
98
99 #[command(subcommand)]
101 Config(ConfigCmd),
102
103 #[command(subcommand)]
105 Sql(SqlCmd),
106
107 #[command(subcommand)]
109 Reports(ReportsCmd),
110
111 #[command(subcommand, name = "ssh-key")]
113 SshKey(SshKeyCmd),
114}
115
116#[derive(Subcommand, Debug)]
117enum SshKeyCmd {
118 Add {
120 #[arg(
122 long,
123 conflicts_with = "public_key",
124 required_unless_present = "public_key"
125 )]
126 key_file: Option<String>,
127 #[arg(
129 long,
130 conflicts_with = "key_file",
131 required_unless_present = "key_file"
132 )]
133 public_key: Option<String>,
134 #[arg(long)]
136 annotation: Option<String>,
137 },
138 List,
140 Remove {
142 #[arg(long)]
144 fingerprint: String,
145 },
146}
147
148#[derive(Subcommand, Debug)]
149enum AccountCmd {
150 List,
152 Balance {
154 #[arg(long)]
155 account: Uuid,
156 },
157 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 {
170 #[arg(long)]
171 account: Option<Uuid>,
172 },
173 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,
196 Create {
198 #[arg(long)]
199 symbol: String,
200 #[arg(long)]
201 name: String,
202 },
203}
204
205#[derive(Subcommand, Debug)]
206enum ConfigCmd {
207 Get {
209 #[arg(long)]
210 name: String,
211 },
212 Set {
214 #[arg(long)]
215 name: String,
216 #[arg(long)]
217 value: String,
218 },
219}
220
221#[derive(Subcommand, Debug)]
222enum SqlCmd {
223 Selcol {
225 #[arg(long)]
226 field: String,
227 #[arg(long)]
228 table: String,
229 },
230}
231
232#[derive(Subcommand, Debug)]
233enum ReportsCmd {
234 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 {
245 #[arg(long)]
246 from: String,
247 #[arg(long)]
248 to: String,
249 #[arg(long, default_value = "bar")]
250 chart: String,
251 },
252 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}