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