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 Version,
85
86 #[command(subcommand)]
88 Account(AccountCmd),
89
90 #[command(subcommand)]
92 Transaction(TransactionCmd),
93
94 #[command(subcommand)]
96 Commodity(CommodityCmd),
97
98 #[command(subcommand)]
100 Config(ConfigCmd),
101
102 #[command(subcommand)]
104 Sql(SqlCmd),
105
106 #[command(subcommand)]
108 Reports(ReportsCmd),
109
110 #[command(subcommand, name = "ssh-key")]
112 SshKey(SshKeyCmd),
113}
114
115#[derive(Subcommand, Debug)]
116enum SshKeyCmd {
117 Add {
119 #[arg(
121 long,
122 conflicts_with = "public_key",
123 required_unless_present = "public_key"
124 )]
125 key_file: Option<String>,
126 #[arg(
128 long,
129 conflicts_with = "key_file",
130 required_unless_present = "key_file"
131 )]
132 public_key: Option<String>,
133 #[arg(long)]
135 annotation: Option<String>,
136 },
137 List,
139 Remove {
141 #[arg(long)]
143 fingerprint: String,
144 },
145}
146
147#[derive(Subcommand, Debug)]
148enum AccountCmd {
149 List,
151 Balance {
153 #[arg(long)]
154 account: Uuid,
155 },
156 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 {
169 #[arg(long)]
170 account: Option<Uuid>,
171 },
172 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,
195 Create {
197 #[arg(long)]
198 symbol: String,
199 #[arg(long)]
200 name: String,
201 },
202}
203
204#[derive(Subcommand, Debug)]
205enum ConfigCmd {
206 Get {
208 #[arg(long)]
209 name: String,
210 },
211 Set {
213 #[arg(long)]
214 name: String,
215 #[arg(long)]
216 value: String,
217 },
218}
219
220#[derive(Subcommand, Debug)]
221enum SqlCmd {
222 Selcol {
224 #[arg(long)]
225 field: String,
226 #[arg(long)]
227 table: String,
228 },
229}
230
231#[derive(Subcommand, Debug)]
232enum ReportsCmd {
233 Balance {
235 #[arg(long, default_value = "bar")]
236 chart: String,
237 },
238 Activity {
240 #[arg(long)]
241 from: String,
242 #[arg(long)]
243 to: String,
244 #[arg(long, default_value = "bar")]
245 chart: String,
246 },
247 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}