Lines
77.17 %
Functions
60 %
Branches
100 %
use server::command::{Argument, CmdError, CmdResult, config::SelectColumn};
use sqlx::types::Uuid;
use std::collections::HashMap;
use std::fmt::Debug;
use std::future::Future;
use std::pin::Pin;
use thiserror::Error;
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 is_leaf: bool,
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 CliCommand for CliGetConfig {
fn node() -> CommandNode {
CommandNode {
name: "get".to_string(),
is_leaf: true,
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 CliCommand for CliSetConfig {
name: "set".to_string(),
comment: "Set the value in config".to_string(),
name: "value".to_string(),
comment: "Value to set".to_string(),
pub struct CliVersion;
impl CliCommand for CliVersion {
name: "version".to_string(),
comment: "Print the software version".to_string(),
arguments: vec![],
pub struct CliSelectColumn;
impl CliRunnable for CliSelectColumn {
) -> Pin<Box<dyn Future<Output = Result<Option<CmdResult>, CommandError>> + Send + 'a>> {
Box::pin(async move {
match (args.get("field"), args.get("table")) {
(Some(Argument::String(field)), Some(Argument::String(table))) => {
Ok(SelectColumn::new()
.field(field.clone())
.table(table.clone())
.run()
.await?)
_ => Err(CommandError::Argument(
"No column or table provided".to_string(),
)),
})
impl CliCommand for CliSelectColumn {
name: "selcol".to_string(),
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 CliCommand for CliCommodityCreate {
name: "create".to_string(),
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 CliCommand for CliCommodityList {
name: "list".to_string(),
comment: "List all commodities".to_string(),
pub struct CliCommodityCompletion;
impl CliRunnable for CliCommodityCompletion {
use server::command::commodity::ListCommodities;
let user_id = if let Some(Argument::Uuid(user_id)) = args.get("user_id") {
*user_id
} else {
return Err(CommandError::Execution(CmdError::Args(
"user_id is required".to_string(),
)));
};
Ok(ListCommodities::new().user_id(user_id).run().await?)
pub struct CliAccountCreate;
impl CliCommand for 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 CliCommand for CliAccountList {
comment: "List all accounts".to_string(),
pub struct CliAccountCompletion;
impl CliRunnable for CliAccountCompletion {
use server::command::account::ListAccounts;
Ok(ListAccounts::new().user_id(user_id).run().await?)
pub struct CliTransactionCreate;
impl CliCommand for 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 CliCommand for CliTransactionList {
comment: "List all transactions".to_string(),
arguments: vec![ArgumentNode {
name: "account".to_string(),
comment: "Optional account to filter by".to_string(),
}],
pub struct CliAccountBalance;
impl CliCommand for CliAccountBalance {
name: "balance".to_string(),
comment: "Get the current balance and currency of an account".to_string(),
comment: "Account ID to get balance for".to_string(),
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(
) -> Result<Vec<u8>, CommandError> {
let Some(Argument::Data(v)) = args.get(key) else {
fn require_uuid(
) -> Result<Uuid, CommandError> {
let Some(Argument::Uuid(v)) = args.get(key) else {
Ok(*v)
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()
.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 {
name: "add".to_string(),
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 CliSshKeyRemove;
impl CliCommand for CliSshKeyRemove {
name: "remove".to_string(),
comment: "Remove an SSH key by fingerprint".to_string(),
comment: "SHA-256 fingerprint (SHA256:…)".to_string(),
#[cfg(test)]
mod tests {
use super::*;
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 select_column_rejects_missing_table() {
let field = Argument::String("foo".to_string());
let mut args: HashMap<&str, &Argument> = HashMap::new();
args.insert("field", &field);
let err = block_on(CliSelectColumn.run(&args)).expect_err("missing table should error");
assert!(matches!(err, CommandError::Argument(_)));