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::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
1
}
98

            
99
#[cfg(test)]
100
mod 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
2
        fn run<'a>(
114
2
            &'a self,
115
2
            _args: &'a HashMap<&str, &Argument>,
116
2
        ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>>
117
        {
118
2
            let out = self.result_lines.clone().map(CmdResult::Lines);
119
2
            Box::pin(async move { Ok(out) })
120
2
        }
121
    }
122

            
123
    #[tokio::test]
124
1
    async fn run_and_print_accepts_empty_result() {
125
1
        let stub = StubRunnable { result_lines: None };
126
1
        run_and_print(&stub, HashMap::new()).await.unwrap();
127
1
    }
128

            
129
    #[tokio::test]
130
1
    async fn run_and_print_handles_lines_result() {
131
1
        let stub = StubRunnable {
132
1
            result_lines: Some(vec!["a".to_string(), "b".to_string()]),
133
1
        };
134
1
        run_and_print(&stub, HashMap::new()).await.unwrap();
135
1
    }
136

            
137
    #[tokio::test]
138
1
    async fn run_and_print_forwards_runnable_error() {
139
        #[derive(Debug)]
140
        struct Failing;
141
        impl CliRunnable for Failing {
142
1
            fn run<'a>(
143
1
                &'a self,
144
1
                _args: &'a HashMap<&str, &Argument>,
145
1
            ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>>
146
            {
147
1
                Box::pin(async move { Err(CommandError::Argument("boom".to_string())) })
148
1
            }
149
        }
150
1
        let err = run_and_print(&Failing, HashMap::new())
151
1
            .await
152
1
            .expect_err("should bubble error");
153
1
        assert!(matches!(err, CommandError::Argument(_)));
154
1
    }
155
}