Skip to main content

cli/
dispatch.rs

1use cli_core::{CliRunnable, CommandError};
2use server::command::{Argument, CmdResult, FinanceEntity};
3use std::collections::HashMap;
4
5/// Run a `CliRunnable` with the given argument map and render its result
6/// to stdout in a script-friendly format. Returns the same `CommandError`
7/// the runnable raised; the caller decides the exit code.
8pub async fn run_and_print(
9    runnable: &dyn CliRunnable,
10    args: HashMap<&str, Argument>,
11) -> Result<(), CommandError> {
12    let args_ref: HashMap<&str, &Argument> = args.iter().map(|(k, v)| (*k, v)).collect();
13    let result = runnable.run(&args_ref).await?;
14    if let Some(r) = result {
15        print_result(r);
16    }
17    Ok(())
18}
19
20fn entity_identifier(entity: &FinanceEntity) -> String {
21    match entity {
22        FinanceEntity::Tag(t) => t.tag_value.clone(),
23        FinanceEntity::Commodity(c) => c.id.to_string(),
24        FinanceEntity::Account(a) => a.id.to_string(),
25        FinanceEntity::Transaction(t) => t.id.to_string(),
26        FinanceEntity::Split(s) => s.id.to_string(),
27        FinanceEntity::Price(p) => p.id.to_string(),
28    }
29}
30
31fn tag_fields(tags: &HashMap<String, FinanceEntity>) -> Vec<String> {
32    tags.iter()
33        .filter_map(|(k, v)| match v {
34            FinanceEntity::Tag(t) => Some(format!("{k}={}", t.tag_value)),
35            _ => None,
36        })
37        .collect()
38}
39
40fn print_result(result: CmdResult) {
41    match result {
42        CmdResult::Lines(lines) => {
43            for line in lines {
44                println!("{line}");
45            }
46        }
47        CmdResult::String(s) => println!("{s}"),
48        CmdResult::Rational(r) => println!("{r}"),
49        CmdResult::Data(bytes) => {
50            use std::io::Write;
51            let _ = std::io::stdout().write_all(&bytes);
52        }
53        CmdResult::Entity(entity) => println!("{}", entity_identifier(&entity)),
54        CmdResult::Entities(entities) => {
55            for entity in entities {
56                println!("{}", entity_identifier(&entity));
57            }
58        }
59        CmdResult::MultiCurrencyBalance(items) => {
60            for (commodity, balance) in items {
61                println!("{balance} {}", commodity.id);
62            }
63        }
64        CmdResult::CommodityInfoList(items) => {
65            for info in items {
66                println!("{}\t{}\t{}", info.commodity_id, info.symbol, info.name);
67            }
68        }
69        CmdResult::TaggedEntities { entities, .. } => {
70            for (entity, tags) in entities {
71                let name = entity_identifier(&entity);
72                let fields = tag_fields(&tags);
73                if fields.is_empty() {
74                    println!("{name}");
75                } else {
76                    println!("{name}\t{}", fields.join("\t"));
77                }
78            }
79        }
80        CmdResult::Report(_) | CmdResult::Activity(_) | CmdResult::Breakdown(_) => {
81            eprintln!("(structured report; use `reports` subcommand for rendered output)");
82        }
83        CmdResult::Uuid(id) => println!("{id}"),
84        CmdResult::Bool(b) => println!("{b}"),
85        CmdResult::SshKeys(keys) => {
86            for k in keys {
87                let last_used = k
88                    .last_used_at
89                    .map_or_else(|| "never".to_string(), |d| d.to_rfc3339());
90                println!(
91                    "{}\t{}\t{}\t{}\t{}",
92                    k.fingerprint, k.key_type, k.annotation, k.created_at, last_used
93                );
94            }
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use server::command::CmdResult;
103    use std::fmt::Debug;
104    use std::future::Future;
105    use std::pin::Pin;
106
107    #[derive(Debug, Default)]
108    struct StubRunnable {
109        result_lines: Option<Vec<String>>,
110    }
111
112    impl CliRunnable for StubRunnable {
113        fn run<'a>(
114            &'a self,
115            _args: &'a HashMap<&str, &Argument>,
116        ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>>
117        {
118            let out = self.result_lines.clone().map(CmdResult::Lines);
119            Box::pin(async move { Ok(out) })
120        }
121    }
122
123    #[tokio::test]
124    async fn run_and_print_accepts_empty_result() {
125        let stub = StubRunnable { result_lines: None };
126        run_and_print(&stub, HashMap::new()).await.unwrap();
127    }
128
129    #[tokio::test]
130    async fn run_and_print_handles_lines_result() {
131        let stub = StubRunnable {
132            result_lines: Some(vec!["a".to_string(), "b".to_string()]),
133        };
134        run_and_print(&stub, HashMap::new()).await.unwrap();
135    }
136
137    #[tokio::test]
138    async fn run_and_print_forwards_runnable_error() {
139        #[derive(Debug)]
140        struct Failing;
141        impl CliRunnable for Failing {
142            fn run<'a>(
143                &'a self,
144                _args: &'a HashMap<&str, &Argument>,
145            ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>>
146            {
147                Box::pin(async move { Err(CommandError::Argument("boom".to_string())) })
148            }
149        }
150        let err = run_and_print(&Failing, HashMap::new())
151            .await
152            .expect_err("should bubble error");
153        assert!(matches!(err, CommandError::Argument(_)));
154    }
155}