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::TaggedTransactions { entities, .. } => {
81            for (entity, tags, amount) in entities {
82                let name = entity_identifier(&entity);
83                let mut fields = tag_fields(&tags);
84                if let Some(amount) = amount {
85                    fields.push(format!("amount={amount}"));
86                }
87                if fields.is_empty() {
88                    println!("{name}");
89                } else {
90                    println!("{name}\t{}", fields.join("\t"));
91                }
92            }
93        }
94        CmdResult::Report(_) | CmdResult::Activity(_) | CmdResult::Breakdown(_) => {
95            eprintln!("(structured report; use `reports` subcommand for rendered output)");
96        }
97        CmdResult::Uuid(id) => println!("{id}"),
98        CmdResult::Bool(b) => println!("{b}"),
99        CmdResult::SshKeys(keys) => {
100            for k in keys {
101                let last_used = k
102                    .last_used_at
103                    .map_or_else(|| "never".to_string(), |d| d.to_rfc3339());
104                println!(
105                    "{}\t{}\t{}\t{}\t{}",
106                    k.fingerprint, k.key_type, k.annotation, k.created_at, last_used
107                );
108            }
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use server::command::CmdResult;
117    use std::fmt::Debug;
118    use std::future::Future;
119    use std::pin::Pin;
120
121    #[derive(Debug, Default)]
122    struct StubRunnable {
123        result_lines: Option<Vec<String>>,
124    }
125
126    impl CliRunnable for StubRunnable {
127        fn run<'a>(
128            &'a self,
129            _args: &'a HashMap<&str, &Argument>,
130        ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>>
131        {
132            let out = self.result_lines.clone().map(CmdResult::Lines);
133            Box::pin(async move { Ok(out) })
134        }
135    }
136
137    #[tokio::test]
138    async fn run_and_print_accepts_empty_result() {
139        let stub = StubRunnable { result_lines: None };
140        run_and_print(&stub, HashMap::new()).await.unwrap();
141    }
142
143    #[tokio::test]
144    async fn run_and_print_handles_lines_result() {
145        let stub = StubRunnable {
146            result_lines: Some(vec!["a".to_string(), "b".to_string()]),
147        };
148        run_and_print(&stub, HashMap::new()).await.unwrap();
149    }
150
151    #[tokio::test]
152    async fn run_and_print_forwards_runnable_error() {
153        #[derive(Debug)]
154        struct Failing;
155        impl CliRunnable for Failing {
156            fn run<'a>(
157                &'a self,
158                _args: &'a HashMap<&str, &Argument>,
159            ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>>
160            {
161                Box::pin(async move { Err(CommandError::Argument("boom".to_string())) })
162            }
163        }
164        let err = run_and_print(&Failing, HashMap::new())
165            .await
166            .expect_err("should bubble error");
167        assert!(matches!(err, CommandError::Argument(_)));
168    }
169}