Lines
64.28 %
Functions
34.69 %
Branches
100 %
use finance::price::Price;
use finance::split::Split;
use num_rational::Rational64;
use plotting::{
ChartKind,
adapters::{
ActivityChartOpts, BalanceChartOpts, BreakdownChartOpts, SortOrder, activity_chart,
balance_chart, breakdown_chart,
},
text::render_text_default,
};
use server::command::{
Argument, CmdError, CmdResult, FinanceEntity,
account::CreateAccount,
account::GetAccountCommodities,
account::GetBalance,
account::ListAccounts,
commodity::CreateCommodity,
commodity::ListCommodities,
config::GetConfig,
config::GetVersion,
config::SelectColumn,
config::SetConfig,
report::{
ActivityReport, BalanceReport, CategoryBreakdown,
view::{flatten_activity_data, flatten_breakdown_data, flatten_report_data},
transaction::CreateTransaction,
transaction::ListTransactions,
use sqlx::types::Uuid;
use sqlx::types::chrono::{DateTime, NaiveDate, Utc};
use std::collections::HashMap;
use std::fmt::Debug;
use std::future::Future;
use std::pin::Pin;
use thiserror::Error;
// Single trait that returns a Future
pub trait CliRunnable: Debug + Send {
fn run<'a>(
&'a self,
args: &'a HashMap<&str, &Argument>,
) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>>;
}
#[derive(Debug)]
pub struct ArgumentNode {
pub name: String,
pub comment: String,
pub completions: Option<Box<dyn CliRunnable>>,
pub struct CommandNode {
pub command: Option<Box<dyn CliRunnable>>,
pub subcommands: Vec<CommandNode>,
pub arguments: Vec<ArgumentNode>,
#[derive(Debug, Error)]
pub enum CommandError {
#[error("No such command: {0}")]
Command(String),
#[error("Arguments error: {0}")]
Argument(String),
#[error("Execution: {0}")]
Execution(#[from] CmdError),
pub trait CliCommand: Debug + Send {
fn node() -> CommandNode;
pub struct CliGetConfig;
impl CliRunnable for CliGetConfig {
) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
log::trace!("Running get with {args:?}");
Box::pin(async move {
if let Some(Argument::String(name)) = args.get("name") {
Ok(GetConfig::new().name(name.clone()).run().await?)
} else {
Err(CommandError::Argument("No field name provided".to_string()))
})
impl CliCommand for CliGetConfig {
fn node() -> CommandNode {
CommandNode {
name: "get".to_string(),
command: Some(Box::new(CliGetConfig)),
comment: "Print the value from config".to_string(),
subcommands: vec![],
arguments: vec![
ArgumentNode {
name: "name".to_string(),
comment: "Variable name".to_string(),
completions: None,
name: "print".to_string(),
comment: "Print return value".to_string(),
],
pub struct CliSetConfig;
impl CliRunnable for CliSetConfig {
log::debug!("Running set with {args:?}");
match (args.get("name"), args.get("value")) {
(Some(Argument::String(name)), Some(Argument::String(value))) => {
Ok(SetConfig::new()
.name(name.clone())
.value(value.clone())
.run()
.await?)
_ => Err(CommandError::Argument(
"No field name or value provided".to_string(),
)),
impl CliCommand for CliSetConfig {
name: "set".to_string(),
command: Some(Box::new(CliSetConfig)),
comment: "Set the value in config".to_string(),
name: "value".to_string(),
comment: "Value to set".to_string(),
pub struct CliVersion;
impl CliRunnable for CliVersion {
_args: &'a HashMap<&str, &Argument>,
Box::pin(async move { Ok(GetVersion::new().run().await?) })
impl CliCommand for CliVersion {
name: "version".to_string(),
command: Some(Box::new(CliVersion)),
comment: "Print the software version".to_string(),
arguments: vec![],
pub struct CliSelectColumn;
impl CliRunnable for CliSelectColumn {
match (args.get("field"), args.get("table")) {
(Some(Argument::String(field)), Some(Argument::String(table))) => {
Ok(SelectColumn::new()
.field(field.clone())
.table(table.clone())
"No column or table provided".to_string(),
impl CliCommand for CliSelectColumn {
name: "selcol".to_string(),
command: Some(Box::new(CliSelectColumn)),
comment: "Raw select of SQL table".to_string(),
name: "field".to_string(),
comment: "Field name".to_string(),
name: "table".to_string(),
comment: "Table name".to_string(),
pub struct CliCommodityCreate;
impl CliRunnable for CliCommodityCreate {
match (args.get("symbol"), args.get("name"), args.get("user_id")) {
(
Some(Argument::String(symbol)),
Some(Argument::String(name)),
Some(Argument::Uuid(user_id)),
) => Ok(CreateCommodity::new()
.symbol(symbol.clone())
.user_id(*user_id)
.await?),
"Provide symbol, name, user_id".to_string(),
impl CliCommand for CliCommodityCreate {
name: "create".to_string(),
command: Some(Box::new(CliCommodityCreate)),
comment: "Create new commodity".to_string(),
name: "symbol".to_string(),
comment: "The abbreviation (or symbol) of the commodity".to_string(),
comment: "Human-readable name of commodity".to_string(),
pub struct CliCommodityList;
impl CliRunnable for CliCommodityList {
let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
*user_id
return Err(CommandError::Execution(CmdError::Args(
"user_id is required".to_string(),
)));
let result = ListCommodities::new().user_id(user_id).run().await?;
if let Some(CmdResult::TaggedEntities { entities, .. }) = result {
let mut result: Vec<String> = vec![];
for (_, tags) in entities {
if let (FinanceEntity::Tag(s), FinanceEntity::Tag(n)) =
(&tags["symbol"], &tags["name"])
{
result.push(format!("{} - {}", s.tag_value, n.tag_value));
Ok(Some(CmdResult::Lines(result)))
Ok(None)
impl CliCommand for CliCommodityList {
name: "list".to_string(),
command: Some(Box::new(CliCommodityList)),
comment: "List all commodities".to_string(),
pub struct CliCommodityCompletion;
impl CliRunnable for CliCommodityCompletion {
Ok(ListCommodities::new().user_id(user_id).run().await?)
pub struct CliAccountCreate;
impl CliRunnable for CliAccountCreate {
let name = if let Some(Argument::String(name)) = args.get("name") {
name.clone()
"name is required".to_string(),
let mut builder = CreateAccount::new().name(name).user_id(user_id);
// Handle optional parent parameter
if let Some(Argument::Uuid(parent_id)) = args.get("parent") {
builder = builder.parent(*parent_id);
Ok(builder.run().await?)
impl CliCommand for CliAccountCreate {
command: Some(Box::new(CliAccountCreate)),
comment: "Create new account".to_string(),
comment: "Name of the account".to_string(),
name: "parent".to_string(),
comment: "Optional parent account".to_string(),
pub struct CliAccountList;
impl CliRunnable for CliAccountList {
let result = ListAccounts::new().user_id(user_id).run().await?;
if let FinanceEntity::Tag(n) = &tags["name"] {
result.push(n.tag_value.clone());
impl CliCommand for CliAccountList {
command: Some(Box::new(CliAccountList)),
comment: "List all accounts".to_string(),
pub struct CliAccountCompletion;
impl CliRunnable for CliAccountCompletion {
Ok(ListAccounts::new().user_id(user_id).run().await?)
pub struct CliTransactionCreate;
/// Required fields a transaction create command needs extracted from
/// the untyped `Argument` bag.
struct TransactionInputs {
from_account: Uuid,
to_account: Uuid,
user_id: Uuid,
from_currency: Uuid,
to_currency: Uuid,
value: Rational64,
to_amount: Rational64,
note: Option<String>,
fn require_uuid(
args: &HashMap<&str, &Argument>,
key: &str,
what: &str,
) -> Result<Uuid, CommandError> {
let Some(Argument::Uuid(v)) = args.get(key) else {
return Err(CommandError::Argument(format!("{what} is required")));
Ok(*v)
fn require_rational(
) -> Result<Rational64, CommandError> {
let Some(Argument::Rational(v)) = args.get(key) else {
fn extract_transaction_inputs(
) -> Result<TransactionInputs, CommandError> {
let from_account = require_uuid(args, "from", "from account not provided")?;
let to_account = require_uuid(args, "to", "to account not provided")?;
let value = require_rational(args, "value", "value not provided")?;
let user_id = require_uuid(args, "user_id", "User ID")?;
let from_currency = require_uuid(args, "from_currency", "from_currency")?;
let to_currency = require_uuid(args, "to_currency", "to_currency")?;
let to_amount = if from_currency == to_currency {
value
require_rational(
args,
"to_amount",
"to_amount (required when currencies differ)",
)?
let note = match args.get("note") {
Some(Argument::String(s)) => Some(s.clone()),
_ => None,
Ok(TransactionInputs {
from_account,
to_account,
user_id,
from_currency,
to_currency,
value,
to_amount,
note,
fn build_split(id: Uuid, tx_id: Uuid, account: Uuid, commodity: Uuid, value: Rational64) -> Split {
Split {
id,
tx_id,
account_id: account,
commodity_id: commodity,
value_num: *value.numer(),
value_denom: *value.denom(),
reconcile_state: None,
reconcile_date: None,
lot_id: None,
impl CliRunnable for CliTransactionCreate {
let inputs = extract_transaction_inputs(args)?;
let tx_id = Uuid::new_v4();
let now = Utc::now();
let from_split_id = Uuid::new_v4();
let to_split_id = Uuid::new_v4();
let from_split = build_split(
from_split_id,
inputs.from_account,
inputs.from_currency,
-inputs.value,
);
let to_split = build_split(
to_split_id,
inputs.to_account,
inputs.to_currency,
inputs.to_amount,
let entities = vec![
FinanceEntity::Split(from_split),
FinanceEntity::Split(to_split),
];
let mut cmd = CreateTransaction::new()
.user_id(inputs.user_id)
.splits(entities)
.id(tx_id)
.post_date(now)
.enter_date(now);
if inputs.from_currency != inputs.to_currency {
let price = Price {
id: Uuid::new_v4(),
date: now,
commodity_id: inputs.to_currency,
currency_id: inputs.from_currency,
commodity_split: Some(to_split_id),
currency_split: Some(from_split_id),
value_num: *inputs.value.numer() * *inputs.to_amount.denom(),
value_denom: *inputs.value.denom() * *inputs.to_amount.numer(),
cmd = cmd.prices(vec![FinanceEntity::Price(price)]);
if let Some(note) = inputs.note {
cmd = cmd.note(note);
Ok(cmd.run().await?)
impl CliCommand for CliTransactionCreate {
command: Some(Box::new(CliTransactionCreate)),
comment: "Create new transaction".to_string(),
name: "from".to_string(),
comment: "Source account".to_string(),
completions: Some(Box::new(CliAccountCompletion)),
name: "to".to_string(),
comment: "Destination account".to_string(),
name: "from_currency".to_string(),
comment: "Currency for the source transaction".to_string(),
completions: Some(Box::new(CliCommodityCompletion)),
name: "to_currency".to_string(),
comment: "Currency for the destination transaction".to_string(),
comment: "Transaction amount (from account)".to_string(),
name: "to_amount".to_string(),
comment: "Transaction amount (to account, required when currencies differ)"
.to_string(),
name: "note".to_string(),
comment: "Text memo for transaction".to_string(),
pub struct CliTransactionList;
impl CliRunnable for CliTransactionList {
let mut cmd = ListTransactions::new().user_id(user_id);
// Add optional account filter if provided
if let Some(Argument::Uuid(account_id)) = args.get("account") {
cmd = cmd.account(*account_id);
let result = cmd.run().await?;
for (entity, tags) in entities {
if let FinanceEntity::Transaction(tx) = entity {
result.push(format!(
"{} - {}",
if let Some(FinanceEntity::Tag(note)) = tags.get("note") {
note.tag_value.clone()
tx.id.to_string()
tx.post_date
));
impl CliCommand for CliTransactionList {
command: Some(Box::new(CliTransactionList)),
comment: "List all transactions".to_string(),
arguments: vec![ArgumentNode {
name: "account".to_string(),
comment: "Optional account to filter by".to_string(),
}],
async fn get_cli_balance_with_currency(
account_id: Uuid,
) -> Result<(Rational64, String, String), CmdError> {
// First get the commodity information for this account
let commodities_result = GetAccountCommodities::new()
.user_id(user_id)
.account_id(account_id)
.await?;
let Some(CmdResult::CommodityInfoList(commodities)) = commodities_result else {
return Ok((
Rational64::new(0, 1),
"No transaction yet".to_string(),
"NONE".to_string(),
// Get balance without specifying commodity to get multi-currency result
let balance_result = GetBalance::new()
match balance_result {
Some(CmdResult::MultiCurrencyBalance(balances)) => {
if balances.is_empty() {
// No transactions yet
Ok((
))
} else if balances.len() == 1 {
// Single currency - need to get commodity info
let (commodity, balance) = &balances[0];
let commodity_info = commodities
.iter()
.find(|c| c.commodity_id == commodity.id)
.map_or_else(
|| ("Unknown".to_string(), "?".to_string()),
|c| (c.name.clone(), c.symbol.clone()),
Ok((*balance, commodity_info.0, commodity_info.1))
// Multiple currencies - show all balances comma-separated
let balance_strings: Vec<String> = balances
.map(|(commodity, balance)| {
format!("{} {}", balance, commodity_info.1)
.collect();
// Use first balance for the numeric part (for compatibility)
let (first_commodity, first_balance) = &balances[0];
let first_commodity_info = commodities
.find(|c| c.commodity_id == first_commodity.id)
*first_balance,
balance_strings.join(", "),
first_commodity_info.1,
Some(CmdResult::Rational(balance)) => {
// Single currency result (when commodity_id was specified)
if balance == Rational64::new(0, 1) {
// Zero balance - show "No transaction yet" message
} else if commodities.is_empty() {
Ok((balance, "Unknown".to_string(), "?".to_string()))
let commodity = &commodities[0];
Ok((balance, commodity.name.clone(), commodity.symbol.clone()))
None => {
// Zero balance
_ => Err(CmdError::Args(
"Unexpected result type from GetBalance".to_string(),
pub struct CliAccountBalance;
impl CliRunnable for CliAccountBalance {
// Extract account ID from arguments
let account_id = if let Some(Argument::Uuid(account_id)) = args.get("account") {
*account_id
return Err(CommandError::Argument("Account ID is required".to_string()));
return Err(CommandError::Argument("User ID is required".to_string()));
// Get balance with currency information
let (balance, currency_name, currency_symbol) =
get_cli_balance_with_currency(account_id, user_id)
.await
.map_err(|e| {
CommandError::Argument(format!("Balance calculation failed: {e}"))
})?;
// Format the result to include currency information
let formatted_result = if currency_name.contains(", ") {
// Multi-currency: show only the comma-separated list
currency_name
// Single currency: show traditional format
format!("{balance} {currency_symbol} ({currency_name})")
Ok(Some(CmdResult::String(formatted_result)))
impl CliCommand for CliAccountBalance {
name: "balance".to_string(),
command: Some(Box::new(CliAccountBalance)),
comment: "Get the current balance and currency of an account".to_string(),
comment: "Account ID to get balance for".to_string(),
// ---------- Reports ----------
//
// CLI report commands run the server-side report command, flatten the
// result through the shared view projection, hand it to the plotting
// crate's adapters, and render the resulting `ChartSpec` as text. The
// output lands in the CLI's log region via `CmdResult::Lines`, one
// line per text-renderer row.
fn parse_chart_kind(args: &HashMap<&str, &Argument>) -> ChartKind {
match args.get("chart") {
Some(Argument::String(s)) => match s.to_ascii_lowercase().as_str() {
"line" => ChartKind::Line,
"stacked" | "stackedbar" => ChartKind::StackedBar,
_ => ChartKind::Bar,
fn parse_date_arg(
end_of_day: bool,
) -> Option<DateTime<Utc>> {
let raw = match args.get(key)? {
Argument::String(s) => s.clone(),
Argument::DateTime(d) => return Some(*d),
_ => return None,
let date = NaiveDate::parse_from_str(&raw, "%Y-%m-%d").ok()?;
let time = if end_of_day {
date.and_hms_opt(23, 59, 59)
date.and_hms_opt(0, 0, 0)
}?;
Some(time.and_utc())
fn text_to_lines(text: &str) -> CmdResult {
CmdResult::Lines(text.lines().map(str::to_string).collect())
fn report_error(msg: impl Into<String>) -> CommandError {
CommandError::Execution(CmdError::Args(msg.into()))
pub struct CliReportsBalance;
impl CliRunnable for CliReportsBalance {
let user_id = match args.get("user_id") {
Some(Argument::Uuid(id)) => *id,
_ => return Err(report_error("user_id is required")),
let mut cmd = BalanceReport::new().user_id(user_id);
if let Some(df) = parse_date_arg(args, "from", false) {
cmd = cmd.date_from(df);
if let Some(dt) = parse_date_arg(args, "to", true) {
cmd = cmd.as_of(dt);
let Some(CmdResult::Report(report_data)) = cmd.run().await? else {
return Ok(Some(text_to_lines("Balance: no data.")));
let rows = flatten_report_data(&report_data);
let spec = balance_chart(
&rows,
BalanceChartOpts {
kind: parse_chart_kind(args),
top_n: 10,
sort_order: SortOrder::MagnitudeDesc,
Ok(Some(text_to_lines(&render_text_default(&spec))))
impl CliCommand for CliReportsBalance {
command: Some(Box::new(CliReportsBalance)),
comment: "Balance chart (top-level accounts by magnitude)".to_string(),
comment: "Period start (YYYY-MM-DD). Omit for snapshot mode.".to_string(),
comment: "Period end or snapshot cutoff (YYYY-MM-DD).".to_string(),
name: "chart".to_string(),
comment: "Chart kind: bar (default) | line | stacked".to_string(),
pub struct CliReportsActivity;
impl CliRunnable for CliReportsActivity {
let date_from = parse_date_arg(args, "from", false)
.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()
.date_from(date_from)
.date_to(date_to)
.await?
else {
return Ok(Some(text_to_lines("Activity: no data.")));
let periods = flatten_activity_data(&activity_data);
let spec = activity_chart(
&periods,
ActivityChartOpts {
include_net: true,
impl CliCommand for CliReportsActivity {
name: "activity".to_string(),
command: Some(Box::new(CliReportsActivity)),
comment: "Activity chart (Income vs Expense over a period)".to_string(),
comment: "Period start (YYYY-MM-DD) — required.".to_string(),
comment: "Period end (YYYY-MM-DD) — required.".to_string(),
pub struct CliReportsBreakdown;
impl CliRunnable for CliReportsBreakdown {
let mut cmd = CategoryBreakdown::new()
.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(
BreakdownChartOpts {
impl CliCommand for CliReportsBreakdown {
name: "breakdown".to_string(),
command: Some(Box::new(CliReportsBreakdown)),
comment: "Category breakdown chart (top-N tag values)".to_string(),
name: "tag".to_string(),
comment: "Pivot tag name (default: category).".to_string(),
pub struct CliSshKeyAdd;
impl CliRunnable for CliSshKeyAdd {
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()
.key_type(key_type)
.key_blob(key_blob)
.fingerprint(fingerprint);
if let Some(Argument::String(a)) = args.get("annotation") {
cmd = cmd.annotation(a.clone());
impl CliCommand for CliSshKeyAdd {
name: "add".to_string(),
command: Some(Box::new(CliSshKeyAdd)),
comment: "Register a user's SSH public key".to_string(),
name: "key_type".to_string(),
comment: "OpenSSH algorithm, e.g. `ssh-ed25519`".to_string(),
name: "key_blob".to_string(),
comment: "Decoded public-key wire bytes".to_string(),
name: "fingerprint".to_string(),
comment: "SHA-256 fingerprint as `SHA256:<base64>`".to_string(),
name: "annotation".to_string(),
comment: "Optional user-supplied label".to_string(),
pub struct CliSshKeyList;
impl CliRunnable for CliSshKeyList {
Ok(server::command::ssh_key::ListSshKeys::new()
impl CliCommand for CliSshKeyList {
command: Some(Box::new(CliSshKeyList)),
comment: "List the SSH keys registered for a user".to_string(),
pub struct CliSshKeyRemove;
impl CliRunnable for CliSshKeyRemove {
Ok(server::command::ssh_key::RemoveSshKey::new()
.fingerprint(fingerprint)
impl CliCommand for CliSshKeyRemove {
name: "remove".to_string(),
command: Some(Box::new(CliSshKeyRemove)),
comment: "Remove an SSH key by fingerprint".to_string(),
comment: "SHA-256 fingerprint (SHA256:…)".to_string(),
fn require_string(
) -> Result<String, CommandError> {
let Some(Argument::String(v)) = args.get(key) else {
Ok(v.clone())
fn require_data(
) -> Result<Vec<u8>, CommandError> {
let Some(Argument::Data(v)) = args.get(key) else {
#[cfg(test)]
mod tests {
use super::*;
fn args_empty() -> HashMap<&'static str, &'static Argument> {
HashMap::new()
fn block_on<F: Future>(f: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime")
.block_on(f)
#[test]
fn get_config_rejects_missing_name() {
let err = block_on(CliGetConfig.run(&args_empty())).expect_err("missing name should error");
assert!(matches!(err, CommandError::Argument(_)));
fn set_config_rejects_missing_value() {
let name = Argument::String("some_name".to_string());
let mut args: HashMap<&str, &Argument> = HashMap::new();
args.insert("name", &name);
let err = block_on(CliSetConfig.run(&args)).expect_err("missing value should error");
fn select_column_rejects_missing_table() {
let field = Argument::String("foo".to_string());
args.insert("field", &field);
let err = block_on(CliSelectColumn.run(&args)).expect_err("missing table should error");
fn account_create_rejects_missing_user_id() {
let name = Argument::String("Cash".to_string());
let err = block_on(CliAccountCreate.run(&args)).expect_err("missing user_id should error");
assert!(matches!(err, CommandError::Execution(_)));
fn account_list_rejects_missing_user_id() {
let err =
block_on(CliAccountList.run(&args_empty())).expect_err("missing user_id should error");
fn account_balance_rejects_missing_account() {
let user_id = Argument::Uuid(Uuid::new_v4());
args.insert("user_id", &user_id);
let err = block_on(CliAccountBalance.run(&args)).expect_err("missing account should error");
fn account_balance_rejects_missing_user_id() {
let account = Argument::Uuid(Uuid::new_v4());
args.insert("account", &account);
let err = block_on(CliAccountBalance.run(&args)).expect_err("missing user_id should error");
fn commodity_create_rejects_missing_name() {
let symbol = Argument::String("USD".to_string());
args.insert("symbol", &symbol);
let err = block_on(CliCommodityCreate.run(&args)).expect_err("missing name should error");
fn commodity_list_rejects_missing_user_id() {
let err = block_on(CliCommodityList.run(&args_empty()))
.expect_err("missing user_id should error");
fn transaction_list_rejects_missing_user_id() {
let err = block_on(CliTransactionList.run(&args_empty()))
fn transaction_create_rejects_missing_from_account() {
let err = block_on(CliTransactionCreate.run(&args_empty()))
.expect_err("missing from should error");
fn transaction_create_rejects_missing_value() {
let from = Argument::Uuid(Uuid::new_v4());
let to = Argument::Uuid(Uuid::new_v4());
args.insert("from", &from);
args.insert("to", &to);
block_on(CliTransactionCreate.run(&args)).expect_err("missing value should error");
fn transaction_create_rejects_missing_to_amount_when_currencies_differ() {
let value = Argument::Rational(Rational64::new(100, 1));
let from_currency = Argument::Uuid(Uuid::new_v4());
let to_currency = Argument::Uuid(Uuid::new_v4());
args.insert("value", &value);
args.insert("from_currency", &from_currency);
args.insert("to_currency", &to_currency);
let err = block_on(CliTransactionCreate.run(&args))
.expect_err("missing to_amount with differing currencies should error");
assert!(matches!(err, CommandError::Argument(ref s) if s.contains("to_amount")));
fn reports_balance_rejects_missing_user_id() {
let err = block_on(CliReportsBalance.run(&args_empty()))
fn reports_activity_rejects_missing_dates() {
block_on(CliReportsActivity.run(&args)).expect_err("missing from/to should error");
fn reports_breakdown_rejects_missing_dates() {
block_on(CliReportsBreakdown.run(&args)).expect_err("missing from/to should error");
fn parse_chart_kind_defaults_to_bar() {
assert!(matches!(parse_chart_kind(&args_empty()), ChartKind::Bar));
fn parse_chart_kind_accepts_line() {
let chart = Argument::String("line".to_string());
args.insert("chart", &chart);
assert!(matches!(parse_chart_kind(&args), ChartKind::Line));
fn parse_chart_kind_accepts_stacked_aliases() {
for raw in ["stacked", "stackedbar", "STACKED"] {
let chart = Argument::String(raw.to_string());
assert!(matches!(parse_chart_kind(&args), ChartKind::StackedBar));
fn parse_date_arg_reads_string_at_start_of_day() {
let raw = Argument::String("2026-04-30".to_string());
args.insert("from", &raw);
let dt = parse_date_arg(&args, "from", false).expect("parseable date");
assert_eq!(dt.to_rfc3339(), "2026-04-30T00:00:00+00:00");
fn parse_date_arg_reads_string_at_end_of_day() {
args.insert("to", &raw);
let dt = parse_date_arg(&args, "to", true).expect("parseable date");
assert_eq!(dt.to_rfc3339(), "2026-04-30T23:59:59+00:00");
fn parse_date_arg_returns_none_on_garbage() {
let raw = Argument::String("not-a-date".to_string());
assert!(parse_date_arg(&args, "from", false).is_none());
fn text_to_lines_splits_multi_line_text() {
let lines = text_to_lines("a\nb\nc");
match lines {
CmdResult::Lines(v) => assert_eq!(v, vec!["a", "b", "c"]),
other => panic!("unexpected: {other:?}"),