1
use finance::price::Price;
2
use finance::split::Split;
3
use num_rational::Rational64;
4
use plotting::{
5
    ChartKind,
6
    adapters::{
7
        ActivityChartOpts, BalanceChartOpts, BreakdownChartOpts, SortOrder, activity_chart,
8
        balance_chart, breakdown_chart,
9
    },
10
    text::render_text_default,
11
};
12
use 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
};
31
use sqlx::types::Uuid;
32
use sqlx::types::chrono::{DateTime, NaiveDate, Utc};
33
use std::collections::HashMap;
34
use std::fmt::Debug;
35
use std::future::Future;
36
use std::pin::Pin;
37
use thiserror::Error;
38

            
39
// Single trait that returns a Future
40
pub 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)]
48
pub struct ArgumentNode {
49
    pub name: String,
50
    pub comment: String,
51
    pub completions: Option<Box<dyn CliRunnable>>,
52
}
53

            
54
#[derive(Debug)]
55
pub 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)]
64
pub 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

            
73
pub trait CliCommand: Debug + Send {
74
    fn node() -> CommandNode;
75
}
76

            
77
#[derive(Debug)]
78
pub struct CliGetConfig;
79

            
80
impl CliRunnable for CliGetConfig {
81
1
    fn run<'a>(
82
1
        &'a self,
83
1
        args: &'a HashMap<&str, &Argument>,
84
1
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
85
1
        log::trace!("Running get with {args:?}");
86
1
        Box::pin(async move {
87
1
            if let Some(Argument::String(name)) = args.get("name") {
88
                Ok(GetConfig::new().name(name.clone()).run().await?)
89
            } else {
90
1
                Err(CommandError::Argument("No field name provided".to_string()))
91
            }
92
1
        })
93
1
    }
94
}
95

            
96
impl CliCommand for CliGetConfig {
97
85
    fn node() -> CommandNode {
98
85
        CommandNode {
99
85
            name: "get".to_string(),
100
85
            command: Some(Box::new(CliGetConfig)),
101
85
            comment: "Print the value from config".to_string(),
102
85
            subcommands: vec![],
103
85
            arguments: vec![
104
85
                ArgumentNode {
105
85
                    name: "name".to_string(),
106
85
                    comment: "Variable name".to_string(),
107
85
                    completions: None,
108
85
                },
109
85
                ArgumentNode {
110
85
                    name: "print".to_string(),
111
85
                    comment: "Print return value".to_string(),
112
85
                    completions: None,
113
85
                },
114
85
            ],
115
85
        }
116
85
    }
117
}
118

            
119
#[derive(Debug)]
120
pub struct CliSetConfig;
121

            
122
impl CliRunnable for CliSetConfig {
123
1
    fn run<'a>(
124
1
        &'a self,
125
1
        args: &'a HashMap<&str, &Argument>,
126
1
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
127
1
        log::debug!("Running set with {args:?}");
128
1
        Box::pin(async move {
129
1
            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
1
                _ => Err(CommandError::Argument(
138
1
                    "No field name or value provided".to_string(),
139
1
                )),
140
            }
141
1
        })
142
1
    }
143
}
144

            
145
impl CliCommand for CliSetConfig {
146
85
    fn node() -> CommandNode {
147
85
        CommandNode {
148
85
            name: "set".to_string(),
149
85
            command: Some(Box::new(CliSetConfig)),
150
85
            comment: "Set the value in config".to_string(),
151
85
            subcommands: vec![],
152
85
            arguments: vec![
153
85
                ArgumentNode {
154
85
                    name: "name".to_string(),
155
85
                    comment: "Variable name".to_string(),
156
85
                    completions: None,
157
85
                },
158
85
                ArgumentNode {
159
85
                    name: "value".to_string(),
160
85
                    comment: "Value to set".to_string(),
161
85
                    completions: None,
162
85
                },
163
85
            ],
164
85
        }
165
85
    }
166
}
167

            
168
#[derive(Debug)]
169
pub struct CliVersion;
170

            
171
impl 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

            
180
impl CliCommand for CliVersion {
181
85
    fn node() -> CommandNode {
182
85
        CommandNode {
183
85
            name: "version".to_string(),
184
85
            command: Some(Box::new(CliVersion)),
185
85
            comment: "Print the software version".to_string(),
186
85
            subcommands: vec![],
187
85
            arguments: vec![],
188
85
        }
189
85
    }
190
}
191

            
192
#[derive(Debug)]
193
pub struct CliSelectColumn;
194

            
195
impl CliRunnable for CliSelectColumn {
196
1
    fn run<'a>(
197
1
        &'a self,
198
1
        args: &'a HashMap<&str, &Argument>,
199
1
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
200
1
        Box::pin(async move {
201
1
            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
1
                _ => Err(CommandError::Argument(
210
1
                    "No column or table provided".to_string(),
211
1
                )),
212
            }
213
1
        })
214
1
    }
215
}
216

            
217
impl CliCommand for CliSelectColumn {
218
85
    fn node() -> CommandNode {
219
85
        CommandNode {
220
85
            name: "selcol".to_string(),
221
85
            command: Some(Box::new(CliSelectColumn)),
222
85
            comment: "Raw select of SQL table".to_string(),
223
85
            subcommands: vec![],
224
85
            arguments: vec![
225
85
                ArgumentNode {
226
85
                    name: "field".to_string(),
227
85
                    comment: "Field name".to_string(),
228
85
                    completions: None,
229
85
                },
230
85
                ArgumentNode {
231
85
                    name: "table".to_string(),
232
85
                    comment: "Table name".to_string(),
233
85
                    completions: None,
234
85
                },
235
85
            ],
236
85
        }
237
85
    }
238
}
239

            
240
#[derive(Debug)]
241
pub struct CliCommodityCreate;
242

            
243
impl CliRunnable for CliCommodityCreate {
244
1
    fn run<'a>(
245
1
        &'a self,
246
1
        args: &'a HashMap<&str, &Argument>,
247
1
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
248
1
        Box::pin(async move {
249
1
            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
1
                _ => Err(CommandError::Argument(
261
1
                    "Provide symbol, name, user_id".to_string(),
262
1
                )),
263
            }
264
1
        })
265
1
    }
266
}
267

            
268
impl CliCommand for CliCommodityCreate {
269
85
    fn node() -> CommandNode {
270
85
        CommandNode {
271
85
            name: "create".to_string(),
272
85
            command: Some(Box::new(CliCommodityCreate)),
273
85
            comment: "Create new commodity".to_string(),
274
85
            subcommands: vec![],
275
85
            arguments: vec![
276
85
                ArgumentNode {
277
85
                    name: "symbol".to_string(),
278
85
                    comment: "The abbreviation (or symbol) of the commodity".to_string(),
279
85
                    completions: None,
280
85
                },
281
85
                ArgumentNode {
282
85
                    name: "name".to_string(),
283
85
                    comment: "Human-readable name of commodity".to_string(),
284
85
                    completions: None,
285
85
                },
286
85
            ],
287
85
        }
288
85
    }
289
}
290

            
291
#[derive(Debug)]
292
pub struct CliCommodityList;
293

            
294
impl CliRunnable for CliCommodityList {
295
1
    fn run<'a>(
296
1
        &'a self,
297
1
        args: &'a HashMap<&str, &Argument>,
298
1
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
299
1
        Box::pin(async move {
300
1
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
301
                *user_id
302
            } else {
303
1
                return Err(CommandError::Execution(CmdError::Args(
304
1
                    "user_id is required".to_string(),
305
1
                )));
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
1
        })
323
1
    }
324
}
325

            
326
impl CliCommand for CliCommodityList {
327
85
    fn node() -> CommandNode {
328
85
        CommandNode {
329
85
            name: "list".to_string(),
330
85
            command: Some(Box::new(CliCommodityList)),
331
85
            comment: "List all commodities".to_string(),
332
85
            subcommands: vec![],
333
85
            arguments: vec![],
334
85
        }
335
85
    }
336
}
337

            
338
#[derive(Debug)]
339
pub struct CliCommodityCompletion;
340

            
341
impl 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)]
361
pub struct CliAccountCreate;
362

            
363
impl CliRunnable for CliAccountCreate {
364
1
    fn run<'a>(
365
1
        &'a self,
366
1
        args: &'a HashMap<&str, &Argument>,
367
1
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
368
1
        Box::pin(async move {
369
1
            let name = if let Some(Argument::String(name)) = args.get("name") {
370
1
                name.clone()
371
            } else {
372
                return Err(CommandError::Execution(CmdError::Args(
373
                    "name is required".to_string(),
374
                )));
375
            };
376

            
377
1
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
378
                *user_id
379
            } else {
380
1
                return Err(CommandError::Execution(CmdError::Args(
381
1
                    "user_id is required".to_string(),
382
1
                )));
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
1
        })
394
1
    }
395
}
396

            
397
impl CliCommand for CliAccountCreate {
398
85
    fn node() -> CommandNode {
399
85
        CommandNode {
400
85
            name: "create".to_string(),
401
85
            command: Some(Box::new(CliAccountCreate)),
402
85
            comment: "Create new account".to_string(),
403
85
            subcommands: vec![],
404
85
            arguments: vec![
405
85
                ArgumentNode {
406
85
                    name: "name".to_string(),
407
85
                    comment: "Name of the account".to_string(),
408
85
                    completions: None,
409
85
                },
410
85
                ArgumentNode {
411
85
                    name: "parent".to_string(),
412
85
                    comment: "Optional parent account".to_string(),
413
85
                    completions: None,
414
85
                },
415
85
            ],
416
85
        }
417
85
    }
418
}
419

            
420
#[derive(Debug)]
421
pub struct CliAccountList;
422

            
423
impl CliRunnable for CliAccountList {
424
1
    fn run<'a>(
425
1
        &'a self,
426
1
        args: &'a HashMap<&str, &Argument>,
427
1
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
428
1
        Box::pin(async move {
429
1
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
430
                *user_id
431
            } else {
432
1
                return Err(CommandError::Execution(CmdError::Args(
433
1
                    "user_id is required".to_string(),
434
1
                )));
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
1
        })
450
1
    }
451
}
452

            
453
impl CliCommand for CliAccountList {
454
85
    fn node() -> CommandNode {
455
85
        CommandNode {
456
85
            name: "list".to_string(),
457
85
            command: Some(Box::new(CliAccountList)),
458
85
            comment: "List all accounts".to_string(),
459
85
            subcommands: vec![],
460
85
            arguments: vec![],
461
85
        }
462
85
    }
463
}
464

            
465
#[derive(Debug)]
466
pub struct CliAccountCompletion;
467

            
468
impl 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)]
488
pub struct CliTransactionCreate;
489

            
490
/// Required fields a transaction create command needs extracted from
491
/// the untyped `Argument` bag.
492
struct 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

            
503
8
fn require_uuid(
504
8
    args: &HashMap<&str, &Argument>,
505
8
    key: &str,
506
8
    what: &str,
507
8
) -> Result<Uuid, CommandError> {
508
8
    let Some(Argument::Uuid(v)) = args.get(key) else {
509
1
        return Err(CommandError::Argument(format!("{what} is required")));
510
    };
511
7
    Ok(*v)
512
8
}
513

            
514
3
fn require_rational(
515
3
    args: &HashMap<&str, &Argument>,
516
3
    key: &str,
517
3
    what: &str,
518
3
) -> Result<Rational64, CommandError> {
519
3
    let Some(Argument::Rational(v)) = args.get(key) else {
520
2
        return Err(CommandError::Argument(format!("{what} is required")));
521
    };
522
1
    Ok(*v)
523
3
}
524

            
525
3
fn extract_transaction_inputs(
526
3
    args: &HashMap<&str, &Argument>,
527
3
) -> Result<TransactionInputs, CommandError> {
528
3
    let from_account = require_uuid(args, "from", "from account not provided")?;
529
2
    let to_account = require_uuid(args, "to", "to account not provided")?;
530
2
    let value = require_rational(args, "value", "value not provided")?;
531
1
    let user_id = require_uuid(args, "user_id", "User ID")?;
532
1
    let from_currency = require_uuid(args, "from_currency", "from_currency")?;
533
1
    let to_currency = require_uuid(args, "to_currency", "to_currency")?;
534
1
    let to_amount = if from_currency == to_currency {
535
        value
536
    } else {
537
1
        require_rational(
538
1
            args,
539
1
            "to_amount",
540
1
            "to_amount (required when currencies differ)",
541
1
        )?
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
3
}
558

            
559
fn 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

            
573
impl CliRunnable for CliTransactionCreate {
574
3
    fn run<'a>(
575
3
        &'a self,
576
3
        args: &'a HashMap<&str, &Argument>,
577
3
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
578
3
        Box::pin(async move {
579
3
            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
3
        })
630
3
    }
631
}
632

            
633
impl CliCommand for CliTransactionCreate {
634
85
    fn node() -> CommandNode {
635
85
        CommandNode {
636
85
            name: "create".to_string(),
637
85
            command: Some(Box::new(CliTransactionCreate)),
638
85
            comment: "Create new transaction".to_string(),
639
85
            subcommands: vec![],
640
85
            arguments: vec![
641
85
                ArgumentNode {
642
85
                    name: "from".to_string(),
643
85
                    comment: "Source account".to_string(),
644
85
                    completions: Some(Box::new(CliAccountCompletion)),
645
85
                },
646
85
                ArgumentNode {
647
85
                    name: "to".to_string(),
648
85
                    comment: "Destination account".to_string(),
649
85
                    completions: Some(Box::new(CliAccountCompletion)),
650
85
                },
651
85
                ArgumentNode {
652
85
                    name: "from_currency".to_string(),
653
85
                    comment: "Currency for the source transaction".to_string(),
654
85
                    completions: Some(Box::new(CliCommodityCompletion)),
655
85
                },
656
85
                ArgumentNode {
657
85
                    name: "to_currency".to_string(),
658
85
                    comment: "Currency for the destination transaction".to_string(),
659
85
                    completions: Some(Box::new(CliCommodityCompletion)),
660
85
                },
661
85
                ArgumentNode {
662
85
                    name: "value".to_string(),
663
85
                    comment: "Transaction amount (from account)".to_string(),
664
85
                    completions: None,
665
85
                },
666
85
                ArgumentNode {
667
85
                    name: "to_amount".to_string(),
668
85
                    comment: "Transaction amount (to account, required when currencies differ)"
669
85
                        .to_string(),
670
85
                    completions: None,
671
85
                },
672
85
                ArgumentNode {
673
85
                    name: "note".to_string(),
674
85
                    comment: "Text memo for transaction".to_string(),
675
85
                    completions: None,
676
85
                },
677
85
            ],
678
85
        }
679
85
    }
680
}
681

            
682
#[derive(Debug)]
683
pub struct CliTransactionList;
684

            
685
impl CliRunnable for CliTransactionList {
686
1
    fn run<'a>(
687
1
        &'a self,
688
1
        args: &'a HashMap<&str, &Argument>,
689
1
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
690
1
        Box::pin(async move {
691
1
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
692
                *user_id
693
            } else {
694
1
                return Err(CommandError::Execution(CmdError::Args(
695
1
                    "user_id is required".to_string(),
696
1
                )));
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
1
        })
727
1
    }
728
}
729

            
730
impl CliCommand for CliTransactionList {
731
85
    fn node() -> CommandNode {
732
85
        CommandNode {
733
85
            name: "list".to_string(),
734
85
            command: Some(Box::new(CliTransactionList)),
735
85
            comment: "List all transactions".to_string(),
736
85
            subcommands: vec![],
737
85
            arguments: vec![ArgumentNode {
738
85
                name: "account".to_string(),
739
85
                comment: "Optional account to filter by".to_string(),
740
85
                completions: Some(Box::new(CliAccountCompletion)),
741
85
            }],
742
85
        }
743
85
    }
744
}
745

            
746
async 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)]
856
pub struct CliAccountBalance;
857

            
858
impl CliRunnable for CliAccountBalance {
859
2
    fn run<'a>(
860
2
        &'a self,
861
2
        args: &'a HashMap<&str, &Argument>,
862
2
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
863
2
        Box::pin(async move {
864
            // Extract account ID from arguments
865
2
            let account_id = if let Some(Argument::Uuid(account_id)) = args.get("account") {
866
1
                *account_id
867
            } else {
868
1
                return Err(CommandError::Argument("Account ID is required".to_string()));
869
            };
870

            
871
1
            let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
872
                *user_id
873
            } else {
874
1
                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
2
        })
896
2
    }
897
}
898

            
899
impl CliCommand for CliAccountBalance {
900
85
    fn node() -> CommandNode {
901
85
        CommandNode {
902
85
            name: "balance".to_string(),
903
85
            command: Some(Box::new(CliAccountBalance)),
904
85
            comment: "Get the current balance and currency of an account".to_string(),
905
85
            subcommands: vec![],
906
85
            arguments: vec![ArgumentNode {
907
85
                name: "account".to_string(),
908
85
                comment: "Account ID to get balance for".to_string(),
909
85
                completions: Some(Box::new(CliAccountCompletion)),
910
85
            }],
911
85
        }
912
85
    }
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

            
923
5
fn parse_chart_kind(args: &HashMap<&str, &Argument>) -> ChartKind {
924
5
    match args.get("chart") {
925
4
        Some(Argument::String(s)) => match s.to_ascii_lowercase().as_str() {
926
4
            "line" => ChartKind::Line,
927
3
            "stacked" | "stackedbar" => ChartKind::StackedBar,
928
            _ => ChartKind::Bar,
929
        },
930
1
        _ => ChartKind::Bar,
931
    }
932
5
}
933

            
934
5
fn parse_date_arg(
935
5
    args: &HashMap<&str, &Argument>,
936
5
    key: &str,
937
5
    end_of_day: bool,
938
5
) -> Option<DateTime<Utc>> {
939
5
    let raw = match args.get(key)? {
940
3
        Argument::String(s) => s.clone(),
941
        Argument::DateTime(d) => return Some(*d),
942
        _ => return None,
943
    };
944
3
    let date = NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok()?;
945
2
    let time = if end_of_day {
946
1
        date.and_hms_opt(23, 59, 59)
947
    } else {
948
1
        date.and_hms_opt(0, 0, 0)
949
    }?;
950
2
    Some(time.and_utc())
951
5
}
952

            
953
1
fn text_to_lines(text: &str) -> CmdResult {
954
1
    CmdResult::Lines(text.lines().map(str::to_string).collect())
955
1
}
956

            
957
3
fn report_error(msg: impl Into<String>) -> CommandError {
958
3
    CommandError::Execution(CmdError::Args(msg.into()))
959
3
}
960

            
961
#[derive(Debug)]
962
pub struct CliReportsBalance;
963

            
964
impl CliRunnable for CliReportsBalance {
965
1
    fn run<'a>(
966
1
        &'a self,
967
1
        args: &'a HashMap<&str, &Argument>,
968
1
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
969
1
        Box::pin(async move {
970
1
            let user_id = match args.get("user_id") {
971
                Some(Argument::Uuid(id)) => *id,
972
1
                _ => 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
1
        })
998
1
    }
999
}
impl CliCommand for CliReportsBalance {
85
    fn node() -> CommandNode {
85
        CommandNode {
85
            name: "balance".to_string(),
85
            command: Some(Box::new(CliReportsBalance)),
85
            comment: "Balance chart (top-level accounts by magnitude)".to_string(),
85
            subcommands: vec![],
85
            arguments: vec![
85
                ArgumentNode {
85
                    name: "from".to_string(),
85
                    comment: "Period start (YYYY-MM-DD). Omit for snapshot mode.".to_string(),
85
                    completions: None,
85
                },
85
                ArgumentNode {
85
                    name: "to".to_string(),
85
                    comment: "Period end or snapshot cutoff (YYYY-MM-DD).".to_string(),
85
                    completions: None,
85
                },
85
                ArgumentNode {
85
                    name: "chart".to_string(),
85
                    comment: "Chart kind: bar (default) | line | stacked".to_string(),
85
                    completions: None,
85
                },
85
            ],
85
        }
85
    }
}
#[derive(Debug)]
pub struct CliReportsActivity;
impl CliRunnable for CliReportsActivity {
1
    fn run<'a>(
1
        &'a self,
1
        args: &'a HashMap<&str, &Argument>,
1
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
1
        Box::pin(async move {
1
            let user_id = match args.get("user_id") {
1
                Some(Argument::Uuid(id)) => *id,
                _ => return Err(report_error("user_id is required")),
            };
1
            let date_from = parse_date_arg(args, "from", false)
1
                .ok_or_else(|| report_error("from (YYYY-MM-DD) is required"))?;
            let date_to = parse_date_arg(args, "to", true)
                .ok_or_else(|| report_error("to (YYYY-MM-DD) is required"))?;
            let Some(CmdResult::Activity(activity_data)) = ActivityReport::new()
                .user_id(user_id)
                .date_from(date_from)
                .date_to(date_to)
                .run()
                .await?
            else {
                return Ok(Some(text_to_lines("Activity: no data.")));
            };
            let periods = flatten_activity_data(&activity_data);
            let spec = activity_chart(
                &periods,
                ActivityChartOpts {
                    kind: parse_chart_kind(args),
                    include_net: true,
                },
            );
            Ok(Some(text_to_lines(&render_text_default(&spec))))
1
        })
1
    }
}
impl CliCommand for CliReportsActivity {
85
    fn node() -> CommandNode {
85
        CommandNode {
85
            name: "activity".to_string(),
85
            command: Some(Box::new(CliReportsActivity)),
85
            comment: "Activity chart (Income vs Expense over a period)".to_string(),
85
            subcommands: vec![],
85
            arguments: vec![
85
                ArgumentNode {
85
                    name: "from".to_string(),
85
                    comment: "Period start (YYYY-MM-DD) — required.".to_string(),
85
                    completions: None,
85
                },
85
                ArgumentNode {
85
                    name: "to".to_string(),
85
                    comment: "Period end (YYYY-MM-DD) — required.".to_string(),
85
                    completions: None,
85
                },
85
                ArgumentNode {
85
                    name: "chart".to_string(),
85
                    comment: "Chart kind: bar (default) | line | stacked".to_string(),
85
                    completions: None,
85
                },
85
            ],
85
        }
85
    }
}
#[derive(Debug)]
pub struct CliReportsBreakdown;
impl CliRunnable for CliReportsBreakdown {
1
    fn run<'a>(
1
        &'a self,
1
        args: &'a HashMap<&str, &Argument>,
1
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
1
        Box::pin(async move {
1
            let user_id = match args.get("user_id") {
1
                Some(Argument::Uuid(id)) => *id,
                _ => return Err(report_error("user_id is required")),
            };
1
            let date_from = parse_date_arg(args, "from", false)
1
                .ok_or_else(|| report_error("from (YYYY-MM-DD) is required"))?;
            let date_to = parse_date_arg(args, "to", true)
                .ok_or_else(|| report_error("to (YYYY-MM-DD) is required"))?;
            let mut cmd = CategoryBreakdown::new()
                .user_id(user_id)
                .date_from(date_from)
                .date_to(date_to);
            if let Some(Argument::String(tag)) = args.get("tag") {
                cmd = cmd.tag_name(tag.clone());
            }
            let Some(CmdResult::Breakdown(breakdown_data)) = cmd.run().await? else {
                return Ok(Some(text_to_lines("Breakdown: no data.")));
            };
            let periods = flatten_breakdown_data(&breakdown_data);
            let spec = breakdown_chart(
                &periods,
                BreakdownChartOpts {
                    kind: parse_chart_kind(args),
                    top_n: 10,
                },
            );
            Ok(Some(text_to_lines(&render_text_default(&spec))))
1
        })
1
    }
}
impl CliCommand for CliReportsBreakdown {
85
    fn node() -> CommandNode {
85
        CommandNode {
85
            name: "breakdown".to_string(),
85
            command: Some(Box::new(CliReportsBreakdown)),
85
            comment: "Category breakdown chart (top-N tag values)".to_string(),
85
            subcommands: vec![],
85
            arguments: vec![
85
                ArgumentNode {
85
                    name: "from".to_string(),
85
                    comment: "Period start (YYYY-MM-DD) — required.".to_string(),
85
                    completions: None,
85
                },
85
                ArgumentNode {
85
                    name: "to".to_string(),
85
                    comment: "Period end (YYYY-MM-DD) — required.".to_string(),
85
                    completions: None,
85
                },
85
                ArgumentNode {
85
                    name: "tag".to_string(),
85
                    comment: "Pivot tag name (default: category).".to_string(),
85
                    completions: None,
85
                },
85
                ArgumentNode {
85
                    name: "chart".to_string(),
85
                    comment: "Chart kind: bar (default) | line | stacked".to_string(),
85
                    completions: None,
85
                },
85
            ],
85
        }
85
    }
}
#[derive(Debug)]
pub struct CliSshKeyAdd;
impl CliRunnable for CliSshKeyAdd {
    fn run<'a>(
        &'a self,
        args: &'a HashMap<&str, &Argument>,
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
        Box::pin(async move {
            let user_id = require_uuid(args, "user_id", "user_id")?;
            let key_type = require_string(args, "key_type", "key_type")?;
            let key_blob = require_data(args, "key_blob", "key_blob")?;
            let fingerprint = require_string(args, "fingerprint", "fingerprint")?;
            let mut cmd = server::command::ssh_key::AddSshKey::new()
                .user_id(user_id)
                .key_type(key_type)
                .key_blob(key_blob)
                .fingerprint(fingerprint);
            if let Some(Argument::String(a)) = args.get("annotation") {
                cmd = cmd.annotation(a.clone());
            }
            Ok(cmd.run().await?)
        })
    }
}
impl CliCommand for CliSshKeyAdd {
85
    fn node() -> CommandNode {
85
        CommandNode {
85
            name: "add".to_string(),
85
            command: Some(Box::new(CliSshKeyAdd)),
85
            comment: "Register a user's SSH public key".to_string(),
85
            subcommands: vec![],
85
            arguments: vec![
85
                ArgumentNode {
85
                    name: "key_type".to_string(),
85
                    comment: "OpenSSH algorithm, e.g. `ssh-ed25519`".to_string(),
85
                    completions: None,
85
                },
85
                ArgumentNode {
85
                    name: "key_blob".to_string(),
85
                    comment: "Decoded public-key wire bytes".to_string(),
85
                    completions: None,
85
                },
85
                ArgumentNode {
85
                    name: "fingerprint".to_string(),
85
                    comment: "SHA-256 fingerprint as `SHA256:<base64>`".to_string(),
85
                    completions: None,
85
                },
85
                ArgumentNode {
85
                    name: "annotation".to_string(),
85
                    comment: "Optional user-supplied label".to_string(),
85
                    completions: None,
85
                },
85
            ],
85
        }
85
    }
}
#[derive(Debug)]
pub struct CliSshKeyList;
impl CliRunnable for CliSshKeyList {
    fn run<'a>(
        &'a self,
        args: &'a HashMap<&str, &Argument>,
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
        Box::pin(async move {
            let user_id = require_uuid(args, "user_id", "user_id")?;
            Ok(server::command::ssh_key::ListSshKeys::new()
                .user_id(user_id)
                .run()
                .await?)
        })
    }
}
impl CliCommand for CliSshKeyList {
85
    fn node() -> CommandNode {
85
        CommandNode {
85
            name: "list".to_string(),
85
            command: Some(Box::new(CliSshKeyList)),
85
            comment: "List the SSH keys registered for a user".to_string(),
85
            subcommands: vec![],
85
            arguments: vec![],
85
        }
85
    }
}
#[derive(Debug)]
pub struct CliSshKeyRemove;
impl CliRunnable for CliSshKeyRemove {
    fn run<'a>(
        &'a self,
        args: &'a HashMap<&str, &Argument>,
    ) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
        Box::pin(async move {
            let user_id = require_uuid(args, "user_id", "user_id")?;
            let fingerprint = require_string(args, "fingerprint", "fingerprint")?;
            Ok(server::command::ssh_key::RemoveSshKey::new()
                .user_id(user_id)
                .fingerprint(fingerprint)
                .run()
                .await?)
        })
    }
}
impl CliCommand for CliSshKeyRemove {
85
    fn node() -> CommandNode {
85
        CommandNode {
85
            name: "remove".to_string(),
85
            command: Some(Box::new(CliSshKeyRemove)),
85
            comment: "Remove an SSH key by fingerprint".to_string(),
85
            subcommands: vec![],
85
            arguments: vec![ArgumentNode {
85
                name: "fingerprint".to_string(),
85
                comment: "SHA-256 fingerprint (SHA256:…)".to_string(),
85
                completions: None,
85
            }],
85
        }
85
    }
}
fn require_string(
    args: &HashMap<&str, &Argument>,
    key: &str,
    what: &str,
) -> Result<String, CommandError> {
    let Some(Argument::String(v)) = args.get(key) else {
        return Err(CommandError::Argument(format!("{what} is required")));
    };
    Ok(v.clone())
}
fn require_data(
    args: &HashMap<&str, &Argument>,
    key: &str,
    what: &str,
) -> Result<Vec<u8>, CommandError> {
    let Some(Argument::Data(v)) = args.get(key) else {
        return Err(CommandError::Argument(format!("{what} is required")));
    };
    Ok(v.clone())
}
#[cfg(test)]
mod tests {
    use super::*;
    use num_rational::Rational64;
    use sqlx::types::Uuid;
7
    fn args_empty() -> HashMap<&'static str, &'static Argument> {
7
        HashMap::new()
7
    }
16
    fn block_on<F: Future>(f: F) -> F::Output {
16
        tokio::runtime::Builder::new_current_thread()
16
            .enable_all()
16
            .build()
16
            .expect("runtime")
16
            .block_on(f)
16
    }
    #[test]
1
    fn get_config_rejects_missing_name() {
1
        let err = block_on(CliGetConfig.run(&args_empty())).expect_err("missing name should error");
1
        assert!(matches!(err, CommandError::Argument(_)));
1
    }
    #[test]
1
    fn set_config_rejects_missing_value() {
1
        let name = Argument::String("some_name".to_string());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("name", &name);
1
        let err = block_on(CliSetConfig.run(&args)).expect_err("missing value should error");
1
        assert!(matches!(err, CommandError::Argument(_)));
1
    }
    #[test]
1
    fn select_column_rejects_missing_table() {
1
        let field = Argument::String("foo".to_string());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("field", &field);
1
        let err = block_on(CliSelectColumn.run(&args)).expect_err("missing table should error");
1
        assert!(matches!(err, CommandError::Argument(_)));
1
    }
    #[test]
1
    fn account_create_rejects_missing_user_id() {
1
        let name = Argument::String("Cash".to_string());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("name", &name);
1
        let err = block_on(CliAccountCreate.run(&args)).expect_err("missing user_id should error");
1
        assert!(matches!(err, CommandError::Execution(_)));
1
    }
    #[test]
1
    fn account_list_rejects_missing_user_id() {
1
        let err =
1
            block_on(CliAccountList.run(&args_empty())).expect_err("missing user_id should error");
1
        assert!(matches!(err, CommandError::Execution(_)));
1
    }
    #[test]
1
    fn account_balance_rejects_missing_account() {
1
        let user_id = Argument::Uuid(Uuid::new_v4());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("user_id", &user_id);
1
        let err = block_on(CliAccountBalance.run(&args)).expect_err("missing account should error");
1
        assert!(matches!(err, CommandError::Argument(_)));
1
    }
    #[test]
1
    fn account_balance_rejects_missing_user_id() {
1
        let account = Argument::Uuid(Uuid::new_v4());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("account", &account);
1
        let err = block_on(CliAccountBalance.run(&args)).expect_err("missing user_id should error");
1
        assert!(matches!(err, CommandError::Argument(_)));
1
    }
    #[test]
1
    fn commodity_create_rejects_missing_name() {
1
        let symbol = Argument::String("USD".to_string());
1
        let user_id = Argument::Uuid(Uuid::new_v4());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("symbol", &symbol);
1
        args.insert("user_id", &user_id);
1
        let err = block_on(CliCommodityCreate.run(&args)).expect_err("missing name should error");
1
        assert!(matches!(err, CommandError::Argument(_)));
1
    }
    #[test]
1
    fn commodity_list_rejects_missing_user_id() {
1
        let err = block_on(CliCommodityList.run(&args_empty()))
1
            .expect_err("missing user_id should error");
1
        assert!(matches!(err, CommandError::Execution(_)));
1
    }
    #[test]
1
    fn transaction_list_rejects_missing_user_id() {
1
        let err = block_on(CliTransactionList.run(&args_empty()))
1
            .expect_err("missing user_id should error");
1
        assert!(matches!(err, CommandError::Execution(_)));
1
    }
    #[test]
1
    fn transaction_create_rejects_missing_from_account() {
1
        let err = block_on(CliTransactionCreate.run(&args_empty()))
1
            .expect_err("missing from should error");
1
        assert!(matches!(err, CommandError::Argument(_)));
1
    }
    #[test]
1
    fn transaction_create_rejects_missing_value() {
1
        let from = Argument::Uuid(Uuid::new_v4());
1
        let to = Argument::Uuid(Uuid::new_v4());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("from", &from);
1
        args.insert("to", &to);
1
        let err =
1
            block_on(CliTransactionCreate.run(&args)).expect_err("missing value should error");
1
        assert!(matches!(err, CommandError::Argument(_)));
1
    }
    #[test]
1
    fn transaction_create_rejects_missing_to_amount_when_currencies_differ() {
1
        let from = Argument::Uuid(Uuid::new_v4());
1
        let to = Argument::Uuid(Uuid::new_v4());
1
        let user_id = Argument::Uuid(Uuid::new_v4());
1
        let value = Argument::Rational(Rational64::new(100, 1));
1
        let from_currency = Argument::Uuid(Uuid::new_v4());
1
        let to_currency = Argument::Uuid(Uuid::new_v4());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("from", &from);
1
        args.insert("to", &to);
1
        args.insert("user_id", &user_id);
1
        args.insert("value", &value);
1
        args.insert("from_currency", &from_currency);
1
        args.insert("to_currency", &to_currency);
1
        let err = block_on(CliTransactionCreate.run(&args))
1
            .expect_err("missing to_amount with differing currencies should error");
1
        assert!(matches!(err, CommandError::Argument(ref s) if s.contains("to_amount")));
1
    }
    #[test]
1
    fn reports_balance_rejects_missing_user_id() {
1
        let err = block_on(CliReportsBalance.run(&args_empty()))
1
            .expect_err("missing user_id should error");
1
        assert!(matches!(err, CommandError::Execution(_)));
1
    }
    #[test]
1
    fn reports_activity_rejects_missing_dates() {
1
        let user_id = Argument::Uuid(Uuid::new_v4());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("user_id", &user_id);
1
        let err =
1
            block_on(CliReportsActivity.run(&args)).expect_err("missing from/to should error");
1
        assert!(matches!(err, CommandError::Execution(_)));
1
    }
    #[test]
1
    fn reports_breakdown_rejects_missing_dates() {
1
        let user_id = Argument::Uuid(Uuid::new_v4());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("user_id", &user_id);
1
        let err =
1
            block_on(CliReportsBreakdown.run(&args)).expect_err("missing from/to should error");
1
        assert!(matches!(err, CommandError::Execution(_)));
1
    }
    #[test]
1
    fn parse_chart_kind_defaults_to_bar() {
1
        assert!(matches!(parse_chart_kind(&args_empty()), ChartKind::Bar));
1
    }
    #[test]
1
    fn parse_chart_kind_accepts_line() {
1
        let chart = Argument::String("line".to_string());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("chart", &chart);
1
        assert!(matches!(parse_chart_kind(&args), ChartKind::Line));
1
    }
    #[test]
1
    fn parse_chart_kind_accepts_stacked_aliases() {
3
        for raw in ["stacked", "stackedbar", "STACKED"] {
3
            let chart = Argument::String(raw.to_string());
3
            let mut args: HashMap<&str, &Argument> = HashMap::new();
3
            args.insert("chart", &chart);
3
            assert!(matches!(parse_chart_kind(&args), ChartKind::StackedBar));
        }
1
    }
    #[test]
1
    fn parse_date_arg_reads_string_at_start_of_day() {
1
        let raw = Argument::String("2026-04-30".to_string());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("from", &raw);
1
        let dt = parse_date_arg(&args, "from", false).expect("parseable date");
1
        assert_eq!(dt.to_rfc3339(), "2026-04-30T00:00:00+00:00");
1
    }
    #[test]
1
    fn parse_date_arg_reads_string_at_end_of_day() {
1
        let raw = Argument::String("2026-04-30".to_string());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("to", &raw);
1
        let dt = parse_date_arg(&args, "to", true).expect("parseable date");
1
        assert_eq!(dt.to_rfc3339(), "2026-04-30T23:59:59+00:00");
1
    }
    #[test]
1
    fn parse_date_arg_returns_none_on_garbage() {
1
        let raw = Argument::String("not-a-date".to_string());
1
        let mut args: HashMap<&str, &Argument> = HashMap::new();
1
        args.insert("from", &raw);
1
        assert!(parse_date_arg(&args, "from", false).is_none());
1
    }
    #[test]
1
    fn text_to_lines_splits_multi_line_text() {
1
        let lines = text_to_lines("a\nb\nc");
1
        match lines {
1
            CmdResult::Lines(v) => assert_eq!(v, vec!["a", "b", "c"]),
            other => panic!("unexpected: {other:?}"),
        }
1
    }
}