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
39pub 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 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
500struct 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 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 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 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 Ok((
787 Rational64::new(0, 1),
788 "No transaction yet".to_string(),
789 "NONE".to_string(),
790 ))
791 } else if balances.len() == 1 {
792 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 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 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 if balance == Rational64::new(0, 1) {
838 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 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 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 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 let formatted_result = if currency_name.contains(", ") {
897 currency_name
899 } else {
900 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
925fn 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}