1
use cli_core::{CliRunnable, CommandError};
2
use server::command::{Argument, CmdResult, FinanceEntity};
3
use 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.
8
3
pub async fn run_and_print(
9
3
    runnable: &dyn CliRunnable,
10
3
    args: HashMap<&str, Argument>,
11
3
) -> Result<(), CommandError> {
12
3
    let args_ref: HashMap<&str, &Argument> = args.iter().map(|(k, v)| (*k, v)).collect();
13
3
    let result = runnable.run(&args_ref).await?;
14
2
    if let Some(r) = result {
15
1
        print_result(r);
16
1
    }
17
2
    Ok(())
18
3
}
19

            
20
fn 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

            
31
fn 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

            
40
1
fn print_result(result: CmdResult) {
41
1
    match result {
42
1
        CmdResult::Lines(lines) => {
43
2
            for line in lines {
44
2
                println!("{line}");
45
2
            }
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
1
}
112

            
113
#[cfg(test)]
114
mod 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
2
        fn run<'a>(
128
2
            &'a self,
129
2
            _args: &'a HashMap<&str, &Argument>,
130
2
        ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>>
131
        {
132
2
            let out = self.result_lines.clone().map(CmdResult::Lines);
133
2
            Box::pin(async move { Ok(out) })
134
2
        }
135
    }
136

            
137
    #[tokio::test]
138
1
    async fn run_and_print_accepts_empty_result() {
139
1
        let stub = StubRunnable { result_lines: None };
140
1
        run_and_print(&stub, HashMap::new()).await.unwrap();
141
1
    }
142

            
143
    #[tokio::test]
144
1
    async fn run_and_print_handles_lines_result() {
145
1
        let stub = StubRunnable {
146
1
            result_lines: Some(vec!["a".to_string(), "b".to_string()]),
147
1
        };
148
1
        run_and_print(&stub, HashMap::new()).await.unwrap();
149
1
    }
150

            
151
    #[tokio::test]
152
1
    async fn run_and_print_forwards_runnable_error() {
153
        #[derive(Debug)]
154
        struct Failing;
155
        impl CliRunnable for Failing {
156
1
            fn run<'a>(
157
1
                &'a self,
158
1
                _args: &'a HashMap<&str, &Argument>,
159
1
            ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>>
160
            {
161
1
                Box::pin(async move { Err(CommandError::Argument("boom".to_string())) })
162
1
            }
163
        }
164
1
        let err = run_and_print(&Failing, HashMap::new())
165
1
            .await
166
1
            .expect_err("should bubble error");
167
1
        assert!(matches!(err, CommandError::Argument(_)));
168
1
    }
169
}