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 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 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
490struct 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 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 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 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 Ok((
777 Rational64::new(0, 1),
778 "No transaction yet".to_string(),
779 "NONE".to_string(),
780 ))
781 } else if balances.len() == 1 {
782 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 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 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 if balance == Rational64::new(0, 1) {
828 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 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 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 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 let formatted_result = if currency_name.contains(", ") {
887 currency_name
889 } else {
890 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
915fn 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}