Skip to main content

cli_core/
commands.rs

1use finance::price::Price;
2use finance::split::Split;
3use num_rational::Rational64;
4use plotting::{
5    ChartKind,
6    adapters::{
7        ActivityChartOpts, BalanceChartOpts, BreakdownChartOpts, SortOrder, activity_chart,
8        balance_chart, breakdown_chart,
9    },
10    text::render_text_default,
11};
12use server::command::{
13    Argument, CmdError, CmdResult, FinanceEntity,
14    account::CreateAccount,
15    account::GetAccountCommodities,
16    account::GetBalance,
17    account::ListAccounts,
18    commodity::CreateCommodity,
19    commodity::ListCommodities,
20    config::GetConfig,
21    config::GetVersion,
22    config::SelectColumn,
23    config::SetConfig,
24    report::{
25        ActivityReport, BalanceReport, CategoryBreakdown,
26        view::{flatten_activity_data, flatten_breakdown_data, flatten_report_data},
27    },
28    transaction::CreateTransaction,
29    transaction::ListTransactions,
30};
31use sqlx::types::Uuid;
32use sqlx::types::chrono::{DateTime, NaiveDate, Utc};
33use std::collections::HashMap;
34use std::fmt::Debug;
35use std::future::Future;
36use std::pin::Pin;
37use thiserror::Error;
38
39// Single trait that returns a Future
40pub trait CliRunnable: Debug + Send {
41    fn run<'a>(
42        &'a self,
43        args: &'a HashMap<&str, &Argument>,
44    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>>;
45}
46
47#[derive(Debug)]
48pub struct ArgumentNode {
49    pub name: String,
50    pub comment: String,
51    pub completions: Option<Box<dyn CliRunnable>>,
52}
53
54#[derive(Debug)]
55pub struct CommandNode {
56    pub name: String,
57    pub comment: String,
58    pub command: Option<Box<dyn CliRunnable>>,
59    pub subcommands: Vec<CommandNode>,
60    pub arguments: Vec<ArgumentNode>,
61}
62
63#[derive(Debug, Error)]
64pub enum CommandError {
65    #[error("No such command: {0}")]
66    Command(String),
67    #[error("Arguments error: {0}")]
68    Argument(String),
69    #[error("Execution: {0}")]
70    Execution(#[from] CmdError),
71}
72
73pub trait CliCommand: Debug + Send {
74    fn node() -> CommandNode;
75}
76
77#[derive(Debug)]
78pub struct CliGetConfig;
79
80impl CliRunnable for CliGetConfig {
81    fn run<'a>(
82        &'a self,
83        args: &'a HashMap<&str, &Argument>,
84    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
85        log::trace!("Running get with {args:?}");
86        Box::pin(async move {
87            match (args.get("name"), args.get("user_id")) {
88                (Some(Argument::String(name)), Some(Argument::Uuid(user_id))) => {
89                    Ok(GetConfig::new()
90                        .user_id(*user_id)
91                        .name(name.clone())
92                        .run()
93                        .await?)
94                }
95                _ => Err(CommandError::Argument(
96                    "Provide name and user_id".to_string(),
97                )),
98            }
99        })
100    }
101}
102
103impl CliCommand for CliGetConfig {
104    fn node() -> CommandNode {
105        CommandNode {
106            name: "get".to_string(),
107            command: Some(Box::new(CliGetConfig)),
108            comment: "Print the value from config".to_string(),
109            subcommands: vec![],
110            arguments: vec![
111                ArgumentNode {
112                    name: "name".to_string(),
113                    comment: "Variable name".to_string(),
114                    completions: None,
115                },
116                ArgumentNode {
117                    name: "print".to_string(),
118                    comment: "Print return value".to_string(),
119                    completions: None,
120                },
121            ],
122        }
123    }
124}
125
126#[derive(Debug)]
127pub struct CliSetConfig;
128
129impl CliRunnable for CliSetConfig {
130    fn run<'a>(
131        &'a self,
132        args: &'a HashMap<&str, &Argument>,
133    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
134        log::debug!("Running set with {args:?}");
135        Box::pin(async move {
136            match (args.get("name"), args.get("value"), args.get("user_id")) {
137                (
138                    Some(Argument::String(name)),
139                    Some(Argument::String(value)),
140                    Some(Argument::Uuid(user_id)),
141                ) => Ok(SetConfig::new()
142                    .user_id(*user_id)
143                    .name(name.clone())
144                    .value(value.clone())
145                    .run()
146                    .await?),
147                _ => Err(CommandError::Argument(
148                    "Provide name, value and user_id".to_string(),
149                )),
150            }
151        })
152    }
153}
154
155impl CliCommand for CliSetConfig {
156    fn node() -> CommandNode {
157        CommandNode {
158            name: "set".to_string(),
159            command: Some(Box::new(CliSetConfig)),
160            comment: "Set the value in config".to_string(),
161            subcommands: vec![],
162            arguments: vec![
163                ArgumentNode {
164                    name: "name".to_string(),
165                    comment: "Variable name".to_string(),
166                    completions: None,
167                },
168                ArgumentNode {
169                    name: "value".to_string(),
170                    comment: "Value to set".to_string(),
171                    completions: None,
172                },
173            ],
174        }
175    }
176}
177
178#[derive(Debug)]
179pub struct CliVersion;
180
181impl CliRunnable for CliVersion {
182    fn run<'a>(
183        &'a self,
184        _args: &'a HashMap<&str, &Argument>,
185    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
186        Box::pin(async move { Ok(GetVersion::new().run().await?) })
187    }
188}
189
190impl CliCommand for CliVersion {
191    fn node() -> CommandNode {
192        CommandNode {
193            name: "version".to_string(),
194            command: Some(Box::new(CliVersion)),
195            comment: "Print the software version".to_string(),
196            subcommands: vec![],
197            arguments: vec![],
198        }
199    }
200}
201
202#[derive(Debug)]
203pub struct CliSelectColumn;
204
205impl CliRunnable for CliSelectColumn {
206    fn run<'a>(
207        &'a self,
208        args: &'a HashMap<&str, &Argument>,
209    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
210        Box::pin(async move {
211            match (args.get("field"), args.get("table")) {
212                (Some(Argument::String(field)), Some(Argument::String(table))) => {
213                    Ok(SelectColumn::new()
214                        .field(field.clone())
215                        .table(table.clone())
216                        .run()
217                        .await?)
218                }
219                _ => Err(CommandError::Argument(
220                    "No column or table provided".to_string(),
221                )),
222            }
223        })
224    }
225}
226
227impl CliCommand for CliSelectColumn {
228    fn node() -> CommandNode {
229        CommandNode {
230            name: "selcol".to_string(),
231            command: Some(Box::new(CliSelectColumn)),
232            comment: "Raw select of SQL table".to_string(),
233            subcommands: vec![],
234            arguments: vec![
235                ArgumentNode {
236                    name: "field".to_string(),
237                    comment: "Field name".to_string(),
238                    completions: None,
239                },
240                ArgumentNode {
241                    name: "table".to_string(),
242                    comment: "Table name".to_string(),
243                    completions: None,
244                },
245            ],
246        }
247    }
248}
249
250#[derive(Debug)]
251pub struct CliCommodityCreate;
252
253impl CliRunnable for CliCommodityCreate {
254    fn run<'a>(
255        &'a self,
256        args: &'a HashMap<&str, &Argument>,
257    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
258        Box::pin(async move {
259            match (args.get("symbol"), args.get("name"), args.get("user_id")) {
260                (
261                    Some(Argument::String(symbol)),
262                    Some(Argument::String(name)),
263                    Some(Argument::Uuid(user_id)),
264                ) => Ok(CreateCommodity::new()
265                    .symbol(symbol.clone())
266                    .name(name.clone())
267                    .user_id(*user_id)
268                    .run()
269                    .await?),
270                _ => Err(CommandError::Argument(
271                    "Provide symbol, name, user_id".to_string(),
272                )),
273            }
274        })
275    }
276}
277
278impl CliCommand for CliCommodityCreate {
279    fn node() -> CommandNode {
280        CommandNode {
281            name: "create".to_string(),
282            command: Some(Box::new(CliCommodityCreate)),
283            comment: "Create new commodity".to_string(),
284            subcommands: vec![],
285            arguments: vec![
286                ArgumentNode {
287                    name: "symbol".to_string(),
288                    comment: "The abbreviation (or symbol) of the commodity".to_string(),
289                    completions: None,
290                },
291                ArgumentNode {
292                    name: "name".to_string(),
293                    comment: "Human-readable name of commodity".to_string(),
294                    completions: None,
295                },
296            ],
297        }
298    }
299}
300
301#[derive(Debug)]
302pub struct CliCommodityList;
303
304impl CliRunnable for CliCommodityList {
305    fn run<'a>(
306        &'a self,
307        args: &'a HashMap<&str, &Argument>,
308    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
309        Box::pin(async move {
310            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
311                *user_id
312            } else {
313                return Err(CommandError::Execution(CmdError::Args(
314                    "user_id is required".to_string(),
315                )));
316            };
317
318            let result = ListCommodities::new().user_id(user_id).run().await?;
319            if let Some(CmdResult::TaggedEntities { entities, .. }) = result {
320                let mut result: Vec<String> = vec![];
321                for (_, tags) in entities {
322                    if let (FinanceEntity::Tag(s), FinanceEntity::Tag(n)) =
323                        (&tags["symbol"], &tags["name"])
324                    {
325                        result.push(format!("{} - {}", s.tag_value, n.tag_value));
326                    }
327                }
328                Ok(Some(CmdResult::Lines(result)))
329            } else {
330                Ok(None)
331            }
332        })
333    }
334}
335
336impl CliCommand for CliCommodityList {
337    fn node() -> CommandNode {
338        CommandNode {
339            name: "list".to_string(),
340            command: Some(Box::new(CliCommodityList)),
341            comment: "List all commodities".to_string(),
342            subcommands: vec![],
343            arguments: vec![],
344        }
345    }
346}
347
348#[derive(Debug)]
349pub struct CliCommodityCompletion;
350
351impl CliRunnable for CliCommodityCompletion {
352    fn run<'a>(
353        &'a self,
354        args: &'a HashMap<&str, &Argument>,
355    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
356        Box::pin(async move {
357            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
358                *user_id
359            } else {
360                return Err(CommandError::Execution(CmdError::Args(
361                    "user_id is required".to_string(),
362                )));
363            };
364
365            Ok(ListCommodities::new().user_id(user_id).run().await?)
366        })
367    }
368}
369
370#[derive(Debug)]
371pub struct CliAccountCreate;
372
373impl CliRunnable for CliAccountCreate {
374    fn run<'a>(
375        &'a self,
376        args: &'a HashMap<&str, &Argument>,
377    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
378        Box::pin(async move {
379            let name = if let Some(Argument::String(name)) = args.get("name") {
380                name.clone()
381            } else {
382                return Err(CommandError::Execution(CmdError::Args(
383                    "name is required".to_string(),
384                )));
385            };
386
387            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
388                *user_id
389            } else {
390                return Err(CommandError::Execution(CmdError::Args(
391                    "user_id is required".to_string(),
392                )));
393            };
394
395            let mut builder = CreateAccount::new().name(name).user_id(user_id);
396
397            // Handle optional parent parameter
398            if let Some(Argument::Uuid(parent_id)) = args.get("parent") {
399                builder = builder.parent(*parent_id);
400            }
401
402            Ok(builder.run().await?)
403        })
404    }
405}
406
407impl CliCommand for CliAccountCreate {
408    fn node() -> CommandNode {
409        CommandNode {
410            name: "create".to_string(),
411            command: Some(Box::new(CliAccountCreate)),
412            comment: "Create new account".to_string(),
413            subcommands: vec![],
414            arguments: vec![
415                ArgumentNode {
416                    name: "name".to_string(),
417                    comment: "Name of the account".to_string(),
418                    completions: None,
419                },
420                ArgumentNode {
421                    name: "parent".to_string(),
422                    comment: "Optional parent account".to_string(),
423                    completions: None,
424                },
425            ],
426        }
427    }
428}
429
430#[derive(Debug)]
431pub struct CliAccountList;
432
433impl CliRunnable for CliAccountList {
434    fn run<'a>(
435        &'a self,
436        args: &'a HashMap<&str, &Argument>,
437    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
438        Box::pin(async move {
439            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
440                *user_id
441            } else {
442                return Err(CommandError::Execution(CmdError::Args(
443                    "user_id is required".to_string(),
444                )));
445            };
446
447            let result = ListAccounts::new().user_id(user_id).run().await?;
448            if let Some(CmdResult::TaggedEntities { entities, .. }) = result {
449                let mut result: Vec<String> = vec![];
450                for (_, tags) in entities {
451                    if let FinanceEntity::Tag(n) = &tags["name"] {
452                        result.push(n.tag_value.clone());
453                    }
454                }
455                Ok(Some(CmdResult::Lines(result)))
456            } else {
457                Ok(None)
458            }
459        })
460    }
461}
462
463impl CliCommand for CliAccountList {
464    fn node() -> CommandNode {
465        CommandNode {
466            name: "list".to_string(),
467            command: Some(Box::new(CliAccountList)),
468            comment: "List all accounts".to_string(),
469            subcommands: vec![],
470            arguments: vec![],
471        }
472    }
473}
474
475#[derive(Debug)]
476pub struct CliAccountCompletion;
477
478impl CliRunnable for CliAccountCompletion {
479    fn run<'a>(
480        &'a self,
481        args: &'a HashMap<&str, &Argument>,
482    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
483        Box::pin(async move {
484            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
485                *user_id
486            } else {
487                return Err(CommandError::Execution(CmdError::Args(
488                    "user_id is required".to_string(),
489                )));
490            };
491
492            Ok(ListAccounts::new().user_id(user_id).run().await?)
493        })
494    }
495}
496
497#[derive(Debug)]
498pub struct CliTransactionCreate;
499
500/// Required fields a transaction create command needs extracted from
501/// the untyped `Argument` bag.
502struct TransactionInputs {
503    from_account: Uuid,
504    to_account: Uuid,
505    user_id: Uuid,
506    from_currency: Uuid,
507    to_currency: Uuid,
508    value: Rational64,
509    to_amount: Rational64,
510    note: Option<String>,
511}
512
513fn require_uuid(
514    args: &HashMap<&str, &Argument>,
515    key: &str,
516    what: &str,
517) -> Result<Uuid, CommandError> {
518    let Some(Argument::Uuid(v)) = args.get(key) else {
519        return Err(CommandError::Argument(format!("{what} is required")));
520    };
521    Ok(*v)
522}
523
524fn require_rational(
525    args: &HashMap<&str, &Argument>,
526    key: &str,
527    what: &str,
528) -> Result<Rational64, CommandError> {
529    let Some(Argument::Rational(v)) = args.get(key) else {
530        return Err(CommandError::Argument(format!("{what} is required")));
531    };
532    Ok(*v)
533}
534
535fn extract_transaction_inputs(
536    args: &HashMap<&str, &Argument>,
537) -> Result<TransactionInputs, CommandError> {
538    let from_account = require_uuid(args, "from", "from account not provided")?;
539    let to_account = require_uuid(args, "to", "to account not provided")?;
540    let value = require_rational(args, "value", "value not provided")?;
541    let user_id = require_uuid(args, "user_id", "User ID")?;
542    let from_currency = require_uuid(args, "from_currency", "from_currency")?;
543    let to_currency = require_uuid(args, "to_currency", "to_currency")?;
544    let to_amount = if from_currency == to_currency {
545        value
546    } else {
547        require_rational(
548            args,
549            "to_amount",
550            "to_amount (required when currencies differ)",
551        )?
552    };
553    let note = match args.get("note") {
554        Some(Argument::String(s)) => Some(s.clone()),
555        _ => None,
556    };
557    Ok(TransactionInputs {
558        from_account,
559        to_account,
560        user_id,
561        from_currency,
562        to_currency,
563        value,
564        to_amount,
565        note,
566    })
567}
568
569fn build_split(id: Uuid, tx_id: Uuid, account: Uuid, commodity: Uuid, value: Rational64) -> Split {
570    Split {
571        id,
572        tx_id,
573        account_id: account,
574        commodity_id: commodity,
575        value_num: *value.numer(),
576        value_denom: *value.denom(),
577        reconcile_state: None,
578        reconcile_date: None,
579        lot_id: None,
580    }
581}
582
583impl CliRunnable for CliTransactionCreate {
584    fn run<'a>(
585        &'a self,
586        args: &'a HashMap<&str, &Argument>,
587    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
588        Box::pin(async move {
589            let inputs = extract_transaction_inputs(args)?;
590            let tx_id = Uuid::new_v4();
591            let now = Utc::now();
592            let from_split_id = Uuid::new_v4();
593            let to_split_id = Uuid::new_v4();
594
595            let from_split = build_split(
596                from_split_id,
597                tx_id,
598                inputs.from_account,
599                inputs.from_currency,
600                -inputs.value,
601            );
602            let to_split = build_split(
603                to_split_id,
604                tx_id,
605                inputs.to_account,
606                inputs.to_currency,
607                inputs.to_amount,
608            );
609            let entities = vec![
610                FinanceEntity::Split(from_split),
611                FinanceEntity::Split(to_split),
612            ];
613
614            let mut cmd = CreateTransaction::new()
615                .user_id(inputs.user_id)
616                .splits(entities)
617                .id(tx_id)
618                .post_date(now)
619                .enter_date(now);
620
621            if inputs.from_currency != inputs.to_currency {
622                let price = Price {
623                    id: Uuid::new_v4(),
624                    date: now,
625                    commodity_id: inputs.to_currency,
626                    currency_id: inputs.from_currency,
627                    commodity_split: Some(to_split_id),
628                    currency_split: Some(from_split_id),
629                    value_num: *inputs.value.numer() * *inputs.to_amount.denom(),
630                    value_denom: *inputs.value.denom() * *inputs.to_amount.numer(),
631                };
632                cmd = cmd.prices(vec![FinanceEntity::Price(price)]);
633            }
634            if let Some(note) = inputs.note {
635                cmd = cmd.note(note);
636            }
637
638            Ok(cmd.run().await?)
639        })
640    }
641}
642
643impl CliCommand for CliTransactionCreate {
644    fn node() -> CommandNode {
645        CommandNode {
646            name: "create".to_string(),
647            command: Some(Box::new(CliTransactionCreate)),
648            comment: "Create new transaction".to_string(),
649            subcommands: vec![],
650            arguments: vec![
651                ArgumentNode {
652                    name: "from".to_string(),
653                    comment: "Source account".to_string(),
654                    completions: Some(Box::new(CliAccountCompletion)),
655                },
656                ArgumentNode {
657                    name: "to".to_string(),
658                    comment: "Destination account".to_string(),
659                    completions: Some(Box::new(CliAccountCompletion)),
660                },
661                ArgumentNode {
662                    name: "from_currency".to_string(),
663                    comment: "Currency for the source transaction".to_string(),
664                    completions: Some(Box::new(CliCommodityCompletion)),
665                },
666                ArgumentNode {
667                    name: "to_currency".to_string(),
668                    comment: "Currency for the destination transaction".to_string(),
669                    completions: Some(Box::new(CliCommodityCompletion)),
670                },
671                ArgumentNode {
672                    name: "value".to_string(),
673                    comment: "Transaction amount (from account)".to_string(),
674                    completions: None,
675                },
676                ArgumentNode {
677                    name: "to_amount".to_string(),
678                    comment: "Transaction amount (to account, required when currencies differ)"
679                        .to_string(),
680                    completions: None,
681                },
682                ArgumentNode {
683                    name: "note".to_string(),
684                    comment: "Text memo for transaction".to_string(),
685                    completions: None,
686                },
687            ],
688        }
689    }
690}
691
692#[derive(Debug)]
693pub struct CliTransactionList;
694
695impl CliRunnable for CliTransactionList {
696    fn run<'a>(
697        &'a self,
698        args: &'a HashMap<&str, &Argument>,
699    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
700        Box::pin(async move {
701            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
702                *user_id
703            } else {
704                return Err(CommandError::Execution(CmdError::Args(
705                    "user_id is required".to_string(),
706                )));
707            };
708
709            let mut cmd = ListTransactions::new().user_id(user_id);
710
711            // Add optional account filter if provided
712            if let Some(Argument::Uuid(account_id)) = args.get("account") {
713                cmd = cmd.account(*account_id);
714            }
715
716            let result = cmd.run().await?;
717            if let Some(CmdResult::TaggedEntities { entities, .. }) = result {
718                let mut result: Vec<String> = vec![];
719                for (entity, tags) in entities {
720                    if let FinanceEntity::Transaction(tx) = entity {
721                        result.push(format!(
722                            "{} - {}",
723                            if let Some(FinanceEntity::Tag(note)) = tags.get("note") {
724                                note.tag_value.clone()
725                            } else {
726                                tx.id.to_string()
727                            },
728                            tx.post_date
729                        ));
730                    }
731                }
732                Ok(Some(CmdResult::Lines(result)))
733            } else {
734                Ok(None)
735            }
736        })
737    }
738}
739
740impl CliCommand for CliTransactionList {
741    fn node() -> CommandNode {
742        CommandNode {
743            name: "list".to_string(),
744            command: Some(Box::new(CliTransactionList)),
745            comment: "List all transactions".to_string(),
746            subcommands: vec![],
747            arguments: vec![ArgumentNode {
748                name: "account".to_string(),
749                comment: "Optional account to filter by".to_string(),
750                completions: Some(Box::new(CliAccountCompletion)),
751            }],
752        }
753    }
754}
755
756async fn get_cli_balance_with_currency(
757    account_id: Uuid,
758    user_id: Uuid,
759) -> Result<(Rational64, String, String), CmdError> {
760    // First get the commodity information for this account
761    let commodities_result = GetAccountCommodities::new()
762        .user_id(user_id)
763        .account_id(account_id)
764        .run()
765        .await?;
766
767    let Some(CmdResult::CommodityInfoList(commodities)) = commodities_result else {
768        return Ok((
769            Rational64::new(0, 1),
770            "No transaction yet".to_string(),
771            "NONE".to_string(),
772        ));
773    };
774
775    // Get balance without specifying commodity to get multi-currency result
776    let balance_result = GetBalance::new()
777        .user_id(user_id)
778        .account_id(account_id)
779        .run()
780        .await?;
781
782    match balance_result {
783        Some(CmdResult::MultiCurrencyBalance(balances)) => {
784            if balances.is_empty() {
785                // No transactions yet
786                Ok((
787                    Rational64::new(0, 1),
788                    "No transaction yet".to_string(),
789                    "NONE".to_string(),
790                ))
791            } else if balances.len() == 1 {
792                // Single currency - need to get commodity info
793                let (commodity, balance) = &balances[0];
794                let commodity_info = commodities
795                    .iter()
796                    .find(|c| c.commodity_id == commodity.id)
797                    .map_or_else(
798                        || ("Unknown".to_string(), "?".to_string()),
799                        |c| (c.name.clone(), c.symbol.clone()),
800                    );
801                Ok((*balance, commodity_info.0, commodity_info.1))
802            } else {
803                // Multiple currencies - show all balances comma-separated
804                let balance_strings: Vec<String> = balances
805                    .iter()
806                    .map(|(commodity, balance)| {
807                        let commodity_info = commodities
808                            .iter()
809                            .find(|c| c.commodity_id == commodity.id)
810                            .map_or_else(
811                                || ("Unknown".to_string(), "?".to_string()),
812                                |c| (c.name.clone(), c.symbol.clone()),
813                            );
814                        format!("{} {}", balance, commodity_info.1)
815                    })
816                    .collect();
817
818                // Use first balance for the numeric part (for compatibility)
819                let (first_commodity, first_balance) = &balances[0];
820                let first_commodity_info = commodities
821                    .iter()
822                    .find(|c| c.commodity_id == first_commodity.id)
823                    .map_or_else(
824                        || ("Unknown".to_string(), "?".to_string()),
825                        |c| (c.name.clone(), c.symbol.clone()),
826                    );
827
828                Ok((
829                    *first_balance,
830                    balance_strings.join(", "),
831                    first_commodity_info.1,
832                ))
833            }
834        }
835        Some(CmdResult::Rational(balance)) => {
836            // Single currency result (when commodity_id was specified)
837            if balance == Rational64::new(0, 1) {
838                // Zero balance - show "No transaction yet" message
839                Ok((
840                    Rational64::new(0, 1),
841                    "No transaction yet".to_string(),
842                    "NONE".to_string(),
843                ))
844            } else if commodities.is_empty() {
845                Ok((balance, "Unknown".to_string(), "?".to_string()))
846            } else {
847                let commodity = &commodities[0];
848                Ok((balance, commodity.name.clone(), commodity.symbol.clone()))
849            }
850        }
851        None => {
852            // Zero balance
853            Ok((
854                Rational64::new(0, 1),
855                "No transaction yet".to_string(),
856                "NONE".to_string(),
857            ))
858        }
859        _ => Err(CmdError::Args(
860            "Unexpected result type from GetBalance".to_string(),
861        )),
862    }
863}
864
865#[derive(Debug)]
866pub struct CliAccountBalance;
867
868impl CliRunnable for CliAccountBalance {
869    fn run<'a>(
870        &'a self,
871        args: &'a HashMap<&str, &Argument>,
872    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
873        Box::pin(async move {
874            // Extract account ID from arguments
875            let account_id = if let Some(Argument::Uuid(account_id)) = args.get("account") {
876                *account_id
877            } else {
878                return Err(CommandError::Argument("Account ID is required".to_string()));
879            };
880
881            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
882                *user_id
883            } else {
884                return Err(CommandError::Argument("User ID is required".to_string()));
885            };
886
887            // Get balance with currency information
888            let (balance, currency_name, currency_symbol) =
889                get_cli_balance_with_currency(account_id, user_id)
890                    .await
891                    .map_err(|e| {
892                        CommandError::Argument(format!("Balance calculation failed: {e}"))
893                    })?;
894
895            // Format the result to include currency information
896            let formatted_result = if currency_name.contains(", ") {
897                // Multi-currency: show only the comma-separated list
898                currency_name
899            } else {
900                // Single currency: show traditional format
901                format!("{balance} {currency_symbol} ({currency_name})")
902            };
903
904            Ok(Some(CmdResult::String(formatted_result)))
905        })
906    }
907}
908
909impl CliCommand for CliAccountBalance {
910    fn node() -> CommandNode {
911        CommandNode {
912            name: "balance".to_string(),
913            command: Some(Box::new(CliAccountBalance)),
914            comment: "Get the current balance and currency of an account".to_string(),
915            subcommands: vec![],
916            arguments: vec![ArgumentNode {
917                name: "account".to_string(),
918                comment: "Account ID to get balance for".to_string(),
919                completions: Some(Box::new(CliAccountCompletion)),
920            }],
921        }
922    }
923}
924
925// ---------- Reports ----------
926//
927// CLI report commands run the server-side report command, flatten the
928// result through the shared view projection, hand it to the plotting
929// crate's adapters, and render the resulting `ChartSpec` as text. The
930// output lands in the CLI's log region via `CmdResult::Lines`, one
931// line per text-renderer row.
932
933fn parse_chart_kind(args: &HashMap<&str, &Argument>) -> ChartKind {
934    match args.get("chart") {
935        Some(Argument::String(s)) => match s.to_ascii_lowercase().as_str() {
936            "line" => ChartKind::Line,
937            "stacked" | "stackedbar" => ChartKind::StackedBar,
938            _ => ChartKind::Bar,
939        },
940        _ => ChartKind::Bar,
941    }
942}
943
944fn parse_date_arg(
945    args: &HashMap<&str, &Argument>,
946    key: &str,
947    end_of_day: bool,
948) -> Option<DateTime<Utc>> {
949    let raw = match args.get(key)? {
950        Argument::String(s) => s.clone(),
951        Argument::DateTime(d) => return Some(*d),
952        _ => return None,
953    };
954    let date = NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok()?;
955    let time = if end_of_day {
956        date.and_hms_opt(23, 59, 59)
957    } else {
958        date.and_hms_opt(0, 0, 0)
959    }?;
960    Some(time.and_utc())
961}
962
963fn text_to_lines(text: &str) -> CmdResult {
964    CmdResult::Lines(text.lines().map(str::to_string).collect())
965}
966
967fn report_error(msg: impl Into<String>) -> CommandError {
968    CommandError::Execution(CmdError::Args(msg.into()))
969}
970
971#[derive(Debug)]
972pub struct CliReportsBalance;
973
974impl CliRunnable for CliReportsBalance {
975    fn run<'a>(
976        &'a self,
977        args: &'a HashMap<&str, &Argument>,
978    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
979        Box::pin(async move {
980            let user_id = match args.get("user_id") {
981                Some(Argument::Uuid(id)) => *id,
982                _ => return Err(report_error("user_id is required")),
983            };
984
985            let mut cmd = BalanceReport::new().user_id(user_id);
986            if let Some(df) = parse_date_arg(args, "from", false) {
987                cmd = cmd.date_from(df);
988            }
989            if let Some(dt) = parse_date_arg(args, "to", true) {
990                cmd = cmd.as_of(dt);
991            }
992
993            let Some(CmdResult::Report(report_data)) = cmd.run().await? else {
994                return Ok(Some(text_to_lines("Balance: no data.")));
995            };
996
997            let rows = flatten_report_data(&report_data);
998            let spec = balance_chart(
999                &rows,
1000                BalanceChartOpts {
1001                    kind: parse_chart_kind(args),
1002                    top_n: 10,
1003                    sort_order: SortOrder::MagnitudeDesc,
1004                },
1005            );
1006            Ok(Some(text_to_lines(&render_text_default(&spec))))
1007        })
1008    }
1009}
1010
1011impl CliCommand for CliReportsBalance {
1012    fn node() -> CommandNode {
1013        CommandNode {
1014            name: "balance".to_string(),
1015            command: Some(Box::new(CliReportsBalance)),
1016            comment: "Balance chart (top-level accounts by magnitude)".to_string(),
1017            subcommands: vec![],
1018            arguments: vec![
1019                ArgumentNode {
1020                    name: "from".to_string(),
1021                    comment: "Period start (YYYY-MM-DD). Omit for snapshot mode.".to_string(),
1022                    completions: None,
1023                },
1024                ArgumentNode {
1025                    name: "to".to_string(),
1026                    comment: "Period end or snapshot cutoff (YYYY-MM-DD).".to_string(),
1027                    completions: None,
1028                },
1029                ArgumentNode {
1030                    name: "chart".to_string(),
1031                    comment: "Chart kind: bar (default) | line | stacked".to_string(),
1032                    completions: None,
1033                },
1034            ],
1035        }
1036    }
1037}
1038
1039#[derive(Debug)]
1040pub struct CliReportsActivity;
1041
1042impl CliRunnable for CliReportsActivity {
1043    fn run<'a>(
1044        &'a self,
1045        args: &'a HashMap<&str, &Argument>,
1046    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
1047        Box::pin(async move {
1048            let user_id = match args.get("user_id") {
1049                Some(Argument::Uuid(id)) => *id,
1050                _ => return Err(report_error("user_id is required")),
1051            };
1052            let date_from = parse_date_arg(args, "from", false)
1053                .ok_or_else(|| report_error("from (YYYY-MM-DD) is required"))?;
1054            let date_to = parse_date_arg(args, "to", true)
1055                .ok_or_else(|| report_error("to (YYYY-MM-DD) is required"))?;
1056
1057            let Some(CmdResult::Activity(activity_data)) = ActivityReport::new()
1058                .user_id(user_id)
1059                .date_from(date_from)
1060                .date_to(date_to)
1061                .run()
1062                .await?
1063            else {
1064                return Ok(Some(text_to_lines("Activity: no data.")));
1065            };
1066
1067            let periods = flatten_activity_data(&activity_data);
1068            let spec = activity_chart(
1069                &periods,
1070                ActivityChartOpts {
1071                    kind: parse_chart_kind(args),
1072                    include_net: true,
1073                },
1074            );
1075            Ok(Some(text_to_lines(&render_text_default(&spec))))
1076        })
1077    }
1078}
1079
1080impl CliCommand for CliReportsActivity {
1081    fn node() -> CommandNode {
1082        CommandNode {
1083            name: "activity".to_string(),
1084            command: Some(Box::new(CliReportsActivity)),
1085            comment: "Activity chart (Income vs Expense over a period)".to_string(),
1086            subcommands: vec![],
1087            arguments: vec![
1088                ArgumentNode {
1089                    name: "from".to_string(),
1090                    comment: "Period start (YYYY-MM-DD) — required.".to_string(),
1091                    completions: None,
1092                },
1093                ArgumentNode {
1094                    name: "to".to_string(),
1095                    comment: "Period end (YYYY-MM-DD) — required.".to_string(),
1096                    completions: None,
1097                },
1098                ArgumentNode {
1099                    name: "chart".to_string(),
1100                    comment: "Chart kind: bar (default) | line | stacked".to_string(),
1101                    completions: None,
1102                },
1103            ],
1104        }
1105    }
1106}
1107
1108#[derive(Debug)]
1109pub struct CliReportsBreakdown;
1110
1111impl CliRunnable for CliReportsBreakdown {
1112    fn run<'a>(
1113        &'a self,
1114        args: &'a HashMap<&str, &Argument>,
1115    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
1116        Box::pin(async move {
1117            let user_id = match args.get("user_id") {
1118                Some(Argument::Uuid(id)) => *id,
1119                _ => return Err(report_error("user_id is required")),
1120            };
1121            let date_from = parse_date_arg(args, "from", false)
1122                .ok_or_else(|| report_error("from (YYYY-MM-DD) is required"))?;
1123            let date_to = parse_date_arg(args, "to", true)
1124                .ok_or_else(|| report_error("to (YYYY-MM-DD) is required"))?;
1125
1126            let mut cmd = CategoryBreakdown::new()
1127                .user_id(user_id)
1128                .date_from(date_from)
1129                .date_to(date_to);
1130            if let Some(Argument::String(tag)) = args.get("tag") {
1131                cmd = cmd.tag_name(tag.clone());
1132            }
1133
1134            let Some(CmdResult::Breakdown(breakdown_data)) = cmd.run().await? else {
1135                return Ok(Some(text_to_lines("Breakdown: no data.")));
1136            };
1137
1138            let periods = flatten_breakdown_data(&breakdown_data);
1139            let spec = breakdown_chart(
1140                &periods,
1141                BreakdownChartOpts {
1142                    kind: parse_chart_kind(args),
1143                    top_n: 10,
1144                },
1145            );
1146            Ok(Some(text_to_lines(&render_text_default(&spec))))
1147        })
1148    }
1149}
1150
1151impl CliCommand for CliReportsBreakdown {
1152    fn node() -> CommandNode {
1153        CommandNode {
1154            name: "breakdown".to_string(),
1155            command: Some(Box::new(CliReportsBreakdown)),
1156            comment: "Category breakdown chart (top-N tag values)".to_string(),
1157            subcommands: vec![],
1158            arguments: vec![
1159                ArgumentNode {
1160                    name: "from".to_string(),
1161                    comment: "Period start (YYYY-MM-DD) — required.".to_string(),
1162                    completions: None,
1163                },
1164                ArgumentNode {
1165                    name: "to".to_string(),
1166                    comment: "Period end (YYYY-MM-DD) — required.".to_string(),
1167                    completions: None,
1168                },
1169                ArgumentNode {
1170                    name: "tag".to_string(),
1171                    comment: "Pivot tag name (default: category).".to_string(),
1172                    completions: None,
1173                },
1174                ArgumentNode {
1175                    name: "chart".to_string(),
1176                    comment: "Chart kind: bar (default) | line | stacked".to_string(),
1177                    completions: None,
1178                },
1179            ],
1180        }
1181    }
1182}
1183
1184#[derive(Debug)]
1185pub struct CliSshKeyAdd;
1186
1187impl CliRunnable for CliSshKeyAdd {
1188    fn run<'a>(
1189        &'a self,
1190        args: &'a HashMap<&str, &Argument>,
1191    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
1192        Box::pin(async move {
1193            let user_id = require_uuid(args, "user_id", "user_id")?;
1194            let key_type = require_string(args, "key_type", "key_type")?;
1195            let key_blob = require_data(args, "key_blob", "key_blob")?;
1196            let fingerprint = require_string(args, "fingerprint", "fingerprint")?;
1197            let mut cmd = server::command::ssh_key::AddSshKey::new()
1198                .user_id(user_id)
1199                .key_type(key_type)
1200                .key_blob(key_blob)
1201                .fingerprint(fingerprint);
1202            if let Some(Argument::String(a)) = args.get("annotation") {
1203                cmd = cmd.annotation(a.clone());
1204            }
1205            Ok(cmd.run().await?)
1206        })
1207    }
1208}
1209
1210impl CliCommand for CliSshKeyAdd {
1211    fn node() -> CommandNode {
1212        CommandNode {
1213            name: "add".to_string(),
1214            command: Some(Box::new(CliSshKeyAdd)),
1215            comment: "Register a user's SSH public key".to_string(),
1216            subcommands: vec![],
1217            arguments: vec![
1218                ArgumentNode {
1219                    name: "key_type".to_string(),
1220                    comment: "OpenSSH algorithm, e.g. `ssh-ed25519`".to_string(),
1221                    completions: None,
1222                },
1223                ArgumentNode {
1224                    name: "key_blob".to_string(),
1225                    comment: "Decoded public-key wire bytes".to_string(),
1226                    completions: None,
1227                },
1228                ArgumentNode {
1229                    name: "fingerprint".to_string(),
1230                    comment: "SHA-256 fingerprint as `SHA256:<base64>`".to_string(),
1231                    completions: None,
1232                },
1233                ArgumentNode {
1234                    name: "annotation".to_string(),
1235                    comment: "Optional user-supplied label".to_string(),
1236                    completions: None,
1237                },
1238            ],
1239        }
1240    }
1241}
1242
1243#[derive(Debug)]
1244pub struct CliSshKeyList;
1245
1246impl CliRunnable for CliSshKeyList {
1247    fn run<'a>(
1248        &'a self,
1249        args: &'a HashMap<&str, &Argument>,
1250    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
1251        Box::pin(async move {
1252            let user_id = require_uuid(args, "user_id", "user_id")?;
1253            Ok(server::command::ssh_key::ListSshKeys::new()
1254                .user_id(user_id)
1255                .run()
1256                .await?)
1257        })
1258    }
1259}
1260
1261impl CliCommand for CliSshKeyList {
1262    fn node() -> CommandNode {
1263        CommandNode {
1264            name: "list".to_string(),
1265            command: Some(Box::new(CliSshKeyList)),
1266            comment: "List the SSH keys registered for a user".to_string(),
1267            subcommands: vec![],
1268            arguments: vec![],
1269        }
1270    }
1271}
1272
1273#[derive(Debug)]
1274pub struct CliSshKeyRemove;
1275
1276impl CliRunnable for CliSshKeyRemove {
1277    fn run<'a>(
1278        &'a self,
1279        args: &'a HashMap<&str, &Argument>,
1280    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
1281        Box::pin(async move {
1282            let user_id = require_uuid(args, "user_id", "user_id")?;
1283            let fingerprint = require_string(args, "fingerprint", "fingerprint")?;
1284            Ok(server::command::ssh_key::RemoveSshKey::new()
1285                .user_id(user_id)
1286                .fingerprint(fingerprint)
1287                .run()
1288                .await?)
1289        })
1290    }
1291}
1292
1293impl CliCommand for CliSshKeyRemove {
1294    fn node() -> CommandNode {
1295        CommandNode {
1296            name: "remove".to_string(),
1297            command: Some(Box::new(CliSshKeyRemove)),
1298            comment: "Remove an SSH key by fingerprint".to_string(),
1299            subcommands: vec![],
1300            arguments: vec![ArgumentNode {
1301                name: "fingerprint".to_string(),
1302                comment: "SHA-256 fingerprint (SHA256:…)".to_string(),
1303                completions: None,
1304            }],
1305        }
1306    }
1307}
1308
1309fn require_string(
1310    args: &HashMap<&str, &Argument>,
1311    key: &str,
1312    what: &str,
1313) -> Result<String, CommandError> {
1314    let Some(Argument::String(v)) = args.get(key) else {
1315        return Err(CommandError::Argument(format!("{what} is required")));
1316    };
1317    Ok(v.clone())
1318}
1319
1320fn require_data(
1321    args: &HashMap<&str, &Argument>,
1322    key: &str,
1323    what: &str,
1324) -> Result<Vec<u8>, CommandError> {
1325    let Some(Argument::Data(v)) = args.get(key) else {
1326        return Err(CommandError::Argument(format!("{what} is required")));
1327    };
1328    Ok(v.clone())
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333    use super::*;
1334    use num_rational::Rational64;
1335    use sqlx::types::Uuid;
1336
1337    fn args_empty() -> HashMap<&'static str, &'static Argument> {
1338        HashMap::new()
1339    }
1340
1341    fn block_on<F: Future>(f: F) -> F::Output {
1342        tokio::runtime::Builder::new_current_thread()
1343            .enable_all()
1344            .build()
1345            .expect("runtime")
1346            .block_on(f)
1347    }
1348
1349    #[test]
1350    fn get_config_rejects_missing_name() {
1351        let err = block_on(CliGetConfig.run(&args_empty())).expect_err("missing name should error");
1352        assert!(matches!(err, CommandError::Argument(_)));
1353    }
1354
1355    #[test]
1356    fn set_config_rejects_missing_value() {
1357        let name = Argument::String("some_name".to_string());
1358        let mut args: HashMap<&str, &Argument> = HashMap::new();
1359        args.insert("name", &name);
1360        let err = block_on(CliSetConfig.run(&args)).expect_err("missing value should error");
1361        assert!(matches!(err, CommandError::Argument(_)));
1362    }
1363
1364    #[test]
1365    fn select_column_rejects_missing_table() {
1366        let field = Argument::String("foo".to_string());
1367        let mut args: HashMap<&str, &Argument> = HashMap::new();
1368        args.insert("field", &field);
1369        let err = block_on(CliSelectColumn.run(&args)).expect_err("missing table should error");
1370        assert!(matches!(err, CommandError::Argument(_)));
1371    }
1372
1373    #[test]
1374    fn account_create_rejects_missing_user_id() {
1375        let name = Argument::String("Cash".to_string());
1376        let mut args: HashMap<&str, &Argument> = HashMap::new();
1377        args.insert("name", &name);
1378        let err = block_on(CliAccountCreate.run(&args)).expect_err("missing user_id should error");
1379        assert!(matches!(err, CommandError::Execution(_)));
1380    }
1381
1382    #[test]
1383    fn account_list_rejects_missing_user_id() {
1384        let err =
1385            block_on(CliAccountList.run(&args_empty())).expect_err("missing user_id should error");
1386        assert!(matches!(err, CommandError::Execution(_)));
1387    }
1388
1389    #[test]
1390    fn account_balance_rejects_missing_account() {
1391        let user_id = Argument::Uuid(Uuid::new_v4());
1392        let mut args: HashMap<&str, &Argument> = HashMap::new();
1393        args.insert("user_id", &user_id);
1394        let err = block_on(CliAccountBalance.run(&args)).expect_err("missing account should error");
1395        assert!(matches!(err, CommandError::Argument(_)));
1396    }
1397
1398    #[test]
1399    fn account_balance_rejects_missing_user_id() {
1400        let account = Argument::Uuid(Uuid::new_v4());
1401        let mut args: HashMap<&str, &Argument> = HashMap::new();
1402        args.insert("account", &account);
1403        let err = block_on(CliAccountBalance.run(&args)).expect_err("missing user_id should error");
1404        assert!(matches!(err, CommandError::Argument(_)));
1405    }
1406
1407    #[test]
1408    fn commodity_create_rejects_missing_name() {
1409        let symbol = Argument::String("USD".to_string());
1410        let user_id = Argument::Uuid(Uuid::new_v4());
1411        let mut args: HashMap<&str, &Argument> = HashMap::new();
1412        args.insert("symbol", &symbol);
1413        args.insert("user_id", &user_id);
1414        let err = block_on(CliCommodityCreate.run(&args)).expect_err("missing name should error");
1415        assert!(matches!(err, CommandError::Argument(_)));
1416    }
1417
1418    #[test]
1419    fn commodity_list_rejects_missing_user_id() {
1420        let err = block_on(CliCommodityList.run(&args_empty()))
1421            .expect_err("missing user_id should error");
1422        assert!(matches!(err, CommandError::Execution(_)));
1423    }
1424
1425    #[test]
1426    fn transaction_list_rejects_missing_user_id() {
1427        let err = block_on(CliTransactionList.run(&args_empty()))
1428            .expect_err("missing user_id should error");
1429        assert!(matches!(err, CommandError::Execution(_)));
1430    }
1431
1432    #[test]
1433    fn transaction_create_rejects_missing_from_account() {
1434        let err = block_on(CliTransactionCreate.run(&args_empty()))
1435            .expect_err("missing from should error");
1436        assert!(matches!(err, CommandError::Argument(_)));
1437    }
1438
1439    #[test]
1440    fn transaction_create_rejects_missing_value() {
1441        let from = Argument::Uuid(Uuid::new_v4());
1442        let to = Argument::Uuid(Uuid::new_v4());
1443        let mut args: HashMap<&str, &Argument> = HashMap::new();
1444        args.insert("from", &from);
1445        args.insert("to", &to);
1446        let err =
1447            block_on(CliTransactionCreate.run(&args)).expect_err("missing value should error");
1448        assert!(matches!(err, CommandError::Argument(_)));
1449    }
1450
1451    #[test]
1452    fn transaction_create_rejects_missing_to_amount_when_currencies_differ() {
1453        let from = Argument::Uuid(Uuid::new_v4());
1454        let to = Argument::Uuid(Uuid::new_v4());
1455        let user_id = Argument::Uuid(Uuid::new_v4());
1456        let value = Argument::Rational(Rational64::new(100, 1));
1457        let from_currency = Argument::Uuid(Uuid::new_v4());
1458        let to_currency = Argument::Uuid(Uuid::new_v4());
1459        let mut args: HashMap<&str, &Argument> = HashMap::new();
1460        args.insert("from", &from);
1461        args.insert("to", &to);
1462        args.insert("user_id", &user_id);
1463        args.insert("value", &value);
1464        args.insert("from_currency", &from_currency);
1465        args.insert("to_currency", &to_currency);
1466        let err = block_on(CliTransactionCreate.run(&args))
1467            .expect_err("missing to_amount with differing currencies should error");
1468        assert!(matches!(err, CommandError::Argument(ref s) if s.contains("to_amount")));
1469    }
1470
1471    #[test]
1472    fn reports_balance_rejects_missing_user_id() {
1473        let err = block_on(CliReportsBalance.run(&args_empty()))
1474            .expect_err("missing user_id should error");
1475        assert!(matches!(err, CommandError::Execution(_)));
1476    }
1477
1478    #[test]
1479    fn reports_activity_rejects_missing_dates() {
1480        let user_id = Argument::Uuid(Uuid::new_v4());
1481        let mut args: HashMap<&str, &Argument> = HashMap::new();
1482        args.insert("user_id", &user_id);
1483        let err =
1484            block_on(CliReportsActivity.run(&args)).expect_err("missing from/to should error");
1485        assert!(matches!(err, CommandError::Execution(_)));
1486    }
1487
1488    #[test]
1489    fn reports_breakdown_rejects_missing_dates() {
1490        let user_id = Argument::Uuid(Uuid::new_v4());
1491        let mut args: HashMap<&str, &Argument> = HashMap::new();
1492        args.insert("user_id", &user_id);
1493        let err =
1494            block_on(CliReportsBreakdown.run(&args)).expect_err("missing from/to should error");
1495        assert!(matches!(err, CommandError::Execution(_)));
1496    }
1497
1498    #[test]
1499    fn parse_chart_kind_defaults_to_bar() {
1500        assert!(matches!(parse_chart_kind(&args_empty()), ChartKind::Bar));
1501    }
1502
1503    #[test]
1504    fn parse_chart_kind_accepts_line() {
1505        let chart = Argument::String("line".to_string());
1506        let mut args: HashMap<&str, &Argument> = HashMap::new();
1507        args.insert("chart", &chart);
1508        assert!(matches!(parse_chart_kind(&args), ChartKind::Line));
1509    }
1510
1511    #[test]
1512    fn parse_chart_kind_accepts_stacked_aliases() {
1513        for raw in ["stacked", "stackedbar", "STACKED"] {
1514            let chart = Argument::String(raw.to_string());
1515            let mut args: HashMap<&str, &Argument> = HashMap::new();
1516            args.insert("chart", &chart);
1517            assert!(matches!(parse_chart_kind(&args), ChartKind::StackedBar));
1518        }
1519    }
1520
1521    #[test]
1522    fn parse_date_arg_reads_string_at_start_of_day() {
1523        let raw = Argument::String("2026-04-30".to_string());
1524        let mut args: HashMap<&str, &Argument> = HashMap::new();
1525        args.insert("from", &raw);
1526        let dt = parse_date_arg(&args, "from", false).expect("parseable date");
1527        assert_eq!(dt.to_rfc3339(), "2026-04-30T00:00:00+00:00");
1528    }
1529
1530    #[test]
1531    fn parse_date_arg_reads_string_at_end_of_day() {
1532        let raw = Argument::String("2026-04-30".to_string());
1533        let mut args: HashMap<&str, &Argument> = HashMap::new();
1534        args.insert("to", &raw);
1535        let dt = parse_date_arg(&args, "to", true).expect("parseable date");
1536        assert_eq!(dt.to_rfc3339(), "2026-04-30T23:59:59+00:00");
1537    }
1538
1539    #[test]
1540    fn parse_date_arg_returns_none_on_garbage() {
1541        let raw = Argument::String("not-a-date".to_string());
1542        let mut args: HashMap<&str, &Argument> = HashMap::new();
1543        args.insert("from", &raw);
1544        assert!(parse_date_arg(&args, "from", false).is_none());
1545    }
1546
1547    #[test]
1548    fn text_to_lines_splits_multi_line_text() {
1549        let lines = text_to_lines("a\nb\nc");
1550        match lines {
1551            CmdResult::Lines(v) => assert_eq!(v, vec!["a", "b", "c"]),
1552            other => panic!("unexpected: {other:?}"),
1553        }
1554    }
1555}