Lines
0 %
Functions
Branches
100 %
use finance::price::Price;
use finance::split::Split;
use num_rational::Rational64;
use server::command::{
Argument, CmdError, CmdResult, FinanceEntity, account::CreateAccount,
account::GetAccountCommodities, account::GetBalance, account::ListAccounts,
commodity::CreateCommodity, commodity::GetCommodity, commodity::ListCommodities,
config::GetConfig, config::GetVersion, config::SelectColumn, config::SetConfig,
transaction::CreateTransaction, transaction::ListTransactions,
};
use sqlx::types::Uuid;
use sqlx::types::chrono::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("fraction"),
args.get("symbol"),
args.get("name"),
args.get("user_id"),
) {
(
Some(Argument::Rational(fraction)),
Some(Argument::String(symbol)),
Some(Argument::String(name)),
Some(Argument::Uuid(user_id)),
) => Ok(CreateCommodity::new()
.fraction(*fraction)
.symbol(symbol.clone())
.user_id(*user_id)
.await?),
"Provide fraction, 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: "fraction".to_string(),
comment: "The maximal divisor of the 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;
impl CliRunnable for CliTransactionCreate {
let from_account = if let Some(Argument::Uuid(from)) = args.get("from") {
from
return Err(CommandError::Argument(
"from account not provided".to_string(),
));
let to_account = if let Some(Argument::Uuid(to)) = args.get("to") {
to
"to account not provided".to_string(),
let value = if let Some(Argument::Rational(val)) = args.get("value") {
val
return Err(CommandError::Argument("value not provided".to_string()));
return Err(CommandError::Argument("User ID is required".to_string()));
let from_currency =
if let Some(Argument::Uuid(from_currency)) = args.get("from_currency") {
*from_currency
"from_currency is required".to_string(),
let to_currency = if let Some(Argument::Uuid(to_currency)) = args.get("to_currency") {
*to_currency
"to_currency is required".to_string(),
let tx_id = Uuid::new_v4();
let now = Utc::now();
let from_split_id = Uuid::new_v4();
let to_split_id = Uuid::new_v4();
// Check if currencies differ - if so, we need to_amount for conversion
let currencies_differ = from_currency != to_currency;
let to_amount = if currencies_differ {
if let Some(Argument::Rational(to_val)) = args.get("to_amount") {
*to_val
"to_amount is required when currencies differ".to_string(),
*value
// Get commodity fractions for precision validation
let from_commodity_result = GetCommodity::new()
.user_id(user_id)
.commodity_id(from_currency)
.await?;
let to_commodity_result = GetCommodity::new()
.commodity_id(to_currency)
let (from_fraction, to_fraction) = match (from_commodity_result, to_commodity_result) {
Some(CmdResult::TaggedEntities(from_entities)),
Some(CmdResult::TaggedEntities(to_entities)),
) => {
let from_fraction =
if let Some((FinanceEntity::Commodity(c), _)) = from_entities.first() {
c.fraction
"From commodity not found".to_string(),
let to_fraction =
if let Some((FinanceEntity::Commodity(c), _)) = to_entities.first() {
"To commodity not found".to_string(),
(from_fraction, to_fraction)
_ => {
"Could not retrieve commodity information".to_string(),
// Create splits
let split1 = Split {
id: from_split_id,
tx_id,
account_id: *from_account,
commodity_id: from_currency,
value_num: -*value.numer(),
value_denom: *value.denom(),
reconcile_state: None,
reconcile_date: None,
lot_id: None,
let split2 = Split {
id: to_split_id,
account_id: *to_account,
commodity_id: to_currency,
value_num: *to_amount.numer(),
value_denom: *to_amount.denom(),
let note = if let Some(Argument::String(note)) = args.get("note") {
Some(note)
None
let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
let mut prices = vec![];
// Create price conversion if currencies differ
if currencies_differ {
let price = Price {
id: Uuid::new_v4(),
date: now,
currency_id: from_currency,
commodity_split: Some(to_split_id),
currency_split: Some(from_split_id),
value_num: *value.numer(),
value_denom: *to_amount.numer(),
prices.push(FinanceEntity::Price(price));
let mut cmd = CreateTransaction::new()
.splits(splits)
.id(tx_id)
.post_date(now)
.enter_date(now);
if !prices.is_empty() {
cmd = cmd.prices(prices);
if let Some(note) = note {
cmd = cmd.note(note.to_string());
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,
user_id: Uuid,
) -> Result<(Rational64, String, String), CmdError> {
// First get the commodity information for this account
let commodities_result = GetAccountCommodities::new()
.account_id(account_id)
let commodities = if let Some(CmdResult::CommodityInfoList(commodities)) = commodities_result {
commodities
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(|c| (c.name.clone(), c.symbol.clone()))
.unwrap_or_else(|| ("Unknown".to_string(), "?".to_string()));
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()));
// 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(),