Lines
48.31 %
Functions
20.43 %
Branches
100 %
use clap::{Parser, Subcommand};
use cli_core::ssh_keys::{parse_authorized_keys_line, parse_public_key_file};
use cli_core::{
CliAccountBalance, CliAccountCreate, CliAccountList, CliCommodityCreate, CliCommodityList,
CliGetConfig, CliReportsActivity, CliReportsBalance, CliReportsBreakdown, CliSelectColumn,
CliSetConfig, CliSshKeyAdd, CliSshKeyList, CliSshKeyRemove, CliTransactionCreate,
CliTransactionList, CliVersion, CommandError, start_server,
};
use exitfailure::ExitFailure;
use log::LevelFilter;
use num_rational::Rational64;
use server::command::Argument;
use sqlx::types::Uuid;
use std::collections::HashMap;
use std::str::FromStr;
mod dispatch;
use dispatch::run_and_print;
#[derive(Debug, Clone)]
struct FieldContentPair {
field: String,
content: String,
}
impl FromStr for FieldContentPair {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.splitn(2, '=').collect();
if parts.len() == 2 {
Ok(FieldContentPair {
field: parts[0].to_string(),
content: parts[1].to_string(),
})
} else {
Err("Expected format `field=content`".to_string())
fn parse_rational(s: &str) -> Result<Rational64, String> {
if let Some((num, denom)) = s.split_once('/') {
let n: i64 = num
.parse()
.map_err(|e: std::num::ParseIntError| e.to_string())?;
let d: i64 = denom
if d == 0 {
return Err("denominator cannot be zero".to_string());
Ok(Rational64::new(n, d))
let n: i64 = s
Ok(Rational64::new(n, 1))
#[derive(Parser, Debug)]
#[command(name = "nomisync", about = "Nomisync automation CLI")]
struct Cli {
#[arg(short = 'u', long)]
userid: Uuid,
#[arg(short = 'd', long)]
database: Option<String>,
#[arg(long)]
setopt: Option<FieldContentPair>,
#[arg(long, default_value = "warn")]
loglevel: LevelFilter,
#[command(subcommand)]
cmd: Command,
#[derive(Subcommand, Debug)]
enum Command {
/// Print the software version
Version,
/// Access to accounts
Account(AccountCmd),
/// Access to transactions
Transaction(TransactionCmd),
/// Access to commodities
Commodity(CommodityCmd),
/// Access to configuration
Config(ConfigCmd),
/// Access to SQL database
Sql(SqlCmd),
/// Text-rendered report charts
Reports(ReportsCmd),
/// Manage SSH public keys for remote TUI access
#[command(subcommand, name = "ssh-key")]
SshKey(SshKeyCmd),
enum SshKeyCmd {
/// Register a public key for the current user
Add {
/// Path to a `.pub` OpenSSH public-key file
#[arg(
long,
conflicts_with = "public_key",
required_unless_present = "public_key"
)]
key_file: Option<String>,
/// OpenSSH `authorized_keys` line passed inline
conflicts_with = "key_file",
required_unless_present = "key_file"
public_key: Option<String>,
/// Optional human-readable label
annotation: Option<String>,
},
/// List all keys for the current user
List,
/// Remove a key by its SHA-256 fingerprint
Remove {
/// Fingerprint, e.g. `SHA256:abc…`
fingerprint: String,
enum AccountCmd {
/// List all accounts
/// Get the current balance and currency of an account
Balance {
account: Uuid,
/// Create new account
Create {
name: String,
parent: Option<Uuid>,
enum TransactionCmd {
/// List all transactions
List {
account: Option<Uuid>,
/// Create new transaction
from: Uuid,
to: Uuid,
from_currency: Uuid,
to_currency: Uuid,
#[arg(long, value_parser = parse_rational)]
value: Rational64,
to_amount: Option<Rational64>,
note: Option<String>,
enum CommodityCmd {
/// List all commodities
/// Create new commodity
symbol: String,
enum ConfigCmd {
/// Print the value from config
Get {
/// Set the value in config
Set {
value: String,
enum SqlCmd {
/// Raw select of SQL table
Selcol {
table: String,
enum ReportsCmd {
/// Balance chart (top-level accounts by magnitude)
from: Option<String>,
to: Option<String>,
#[arg(long, default_value = "bar")]
chart: String,
/// Activity chart (Income vs Expense over a period)
Activity {
from: String,
to: String,
/// Category breakdown chart (top-N tag values)
Breakdown {
tag: Option<String>,
#[tokio::main]
async fn main() -> Result<(), ExitFailure> {
let cli = Cli::parse();
env_logger::Builder::new()
.filter_level(cli.loglevel)
.target(env_logger::Target::Stderr)
.init();
let setopt = cli.setopt.map(|p| (p.field, p.content));
start_server(cli.database, setopt).await?;
let outcome = dispatch_command(cli.userid, cli.cmd).await;
match outcome {
Ok(()) => Ok(()),
Err(err) => {
eprintln!("Error: {err}");
std::process::exit(1);
async fn dispatch_command(userid: Uuid, cmd: Command) -> Result<(), CommandError> {
match cmd {
Command::Version => run_and_print(&CliVersion, HashMap::new()).await,
Command::Account(c) => run_account(userid, c).await,
Command::Transaction(c) => run_transaction(userid, c).await,
Command::Commodity(c) => run_commodity(userid, c).await,
Command::Config(c) => run_config(c).await,
Command::Sql(c) => run_sql(c).await,
Command::Reports(c) => run_reports(userid, c).await,
Command::SshKey(c) => run_ssh_key(userid, c).await,
fn user_args(userid: Uuid) -> HashMap<&'static str, Argument> {
let mut args = HashMap::new();
args.insert("user_id", Argument::Uuid(userid));
args
async fn run_account(userid: Uuid, cmd: AccountCmd) -> Result<(), CommandError> {
AccountCmd::List => run_and_print(&CliAccountList, user_args(userid)).await,
AccountCmd::Balance { account } => {
let mut args = user_args(userid);
args.insert("account", Argument::Uuid(account));
run_and_print(&CliAccountBalance, args).await
AccountCmd::Create { name, parent } => {
args.insert("name", Argument::String(name));
if let Some(p) = parent {
args.insert("parent", Argument::Uuid(p));
run_and_print(&CliAccountCreate, args).await
async fn run_transaction(userid: Uuid, cmd: TransactionCmd) -> Result<(), CommandError> {
TransactionCmd::List { account } => {
if let Some(a) = account {
args.insert("account", Argument::Uuid(a));
run_and_print(&CliTransactionList, args).await
TransactionCmd::Create {
from,
to,
from_currency,
to_currency,
value,
to_amount,
note,
} => {
args.insert("from", Argument::Uuid(from));
args.insert("to", Argument::Uuid(to));
args.insert("from_currency", Argument::Uuid(from_currency));
args.insert("to_currency", Argument::Uuid(to_currency));
args.insert("value", Argument::Rational(value));
if let Some(t) = to_amount {
args.insert("to_amount", Argument::Rational(t));
if let Some(n) = note {
args.insert("note", Argument::String(n));
run_and_print(&CliTransactionCreate, args).await
async fn run_commodity(userid: Uuid, cmd: CommodityCmd) -> Result<(), CommandError> {
CommodityCmd::List => run_and_print(&CliCommodityList, user_args(userid)).await,
CommodityCmd::Create { symbol, name } => {
args.insert("symbol", Argument::String(symbol));
run_and_print(&CliCommodityCreate, args).await
async fn run_config(cmd: ConfigCmd) -> Result<(), CommandError> {
ConfigCmd::Get { name } => {
let mut args: HashMap<&str, Argument> = HashMap::new();
run_and_print(&CliGetConfig, args).await
ConfigCmd::Set { name, value } => {
args.insert("value", Argument::String(value));
run_and_print(&CliSetConfig, args).await
async fn run_sql(cmd: SqlCmd) -> Result<(), CommandError> {
SqlCmd::Selcol { field, table } => {
args.insert("field", Argument::String(field));
args.insert("table", Argument::String(table));
run_and_print(&CliSelectColumn, args).await
async fn run_reports(userid: Uuid, cmd: ReportsCmd) -> Result<(), CommandError> {
ReportsCmd::Balance { from, to, chart } => {
if let Some(s) = from {
args.insert("from", Argument::String(s));
if let Some(s) = to {
args.insert("to", Argument::String(s));
args.insert("chart", Argument::String(chart));
run_and_print(&CliReportsBalance, args).await
ReportsCmd::Activity { from, to, chart } => {
args.insert("from", Argument::String(from));
args.insert("to", Argument::String(to));
run_and_print(&CliReportsActivity, args).await
ReportsCmd::Breakdown {
tag,
chart,
if let Some(t) = tag {
args.insert("tag", Argument::String(t));
run_and_print(&CliReportsBreakdown, args).await
async fn run_ssh_key(userid: Uuid, cmd: SshKeyCmd) -> Result<(), CommandError> {
SshKeyCmd::Add {
key_file,
public_key,
annotation,
let parsed = if let Some(path) = key_file {
parse_public_key_file(&path)
.map_err(|e| CommandError::Argument(format!("ssh-key parse: {e}")))?
} else if let Some(line) = public_key {
parse_authorized_keys_line(&line)
return Err(CommandError::Argument(
"either --key-file or --public-key is required".to_string(),
));
args.insert("key_type", Argument::String(parsed.key_type));
args.insert("key_blob", Argument::Data(parsed.key_blob));
args.insert("fingerprint", Argument::String(parsed.fingerprint));
let label = annotation.unwrap_or(parsed.comment);
if !label.is_empty() {
args.insert("annotation", Argument::String(label));
run_and_print(&CliSshKeyAdd, args).await
SshKeyCmd::List => run_and_print(&CliSshKeyList, user_args(userid)).await,
SshKeyCmd::Remove { fingerprint } => {
args.insert("fingerprint", Argument::String(fingerprint));
run_and_print(&CliSshKeyRemove, args).await
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn field_content_pair_parses_key_value() {
let p: FieldContentPair = "locale=en".parse().unwrap();
assert_eq!(p.field, "locale");
assert_eq!(p.content, "en");
fn field_content_pair_rejects_missing_equals() {
assert!("locale".parse::<FieldContentPair>().is_err());
fn field_content_pair_handles_value_with_equals() {
let p: FieldContentPair = "sql=SELECT 1=1".parse().unwrap();
assert_eq!(p.field, "sql");
assert_eq!(p.content, "SELECT 1=1");
fn parse_rational_handles_integer() {
let r = parse_rational("42").unwrap();
assert_eq!(r, Rational64::new(42, 1));
fn parse_rational_handles_fraction() {
let r = parse_rational("3/4").unwrap();
assert_eq!(r, Rational64::new(3, 4));
fn parse_rational_rejects_zero_denominator() {
assert!(parse_rational("1/0").is_err());
fn parse_rational_rejects_non_numeric() {
assert!(parse_rational("abc").is_err());
fn cli_parses_version_subcommand() {
let uuid = Uuid::new_v4();
let parsed =
Cli::try_parse_from(["nomisync", "--userid", &uuid.to_string(), "version"]).unwrap();
assert!(matches!(parsed.cmd, Command::Version));
fn cli_parses_reports_balance_with_flags() {
let parsed = Cli::try_parse_from([
"nomisync",
"--userid",
&uuid.to_string(),
"reports",
"balance",
"--from",
"2026-01-01",
"--to",
"2026-04-30",
"--chart",
"line",
])
.unwrap();
let Command::Reports(ReportsCmd::Balance { from, to, chart }) = parsed.cmd else {
panic!("expected reports balance");
assert_eq!(from.unwrap(), "2026-01-01");
assert_eq!(to.unwrap(), "2026-04-30");
assert_eq!(chart, "line");
fn cli_parses_account_create_with_optional_parent() {
"account",
"create",
"--name",
"Cash",
let Command::Account(AccountCmd::Create { name, parent }) = parsed.cmd else {
panic!("expected account create");
assert_eq!(name, "Cash");
assert!(parent.is_none());
fn cli_parses_transaction_create_rational() {
let from = Uuid::new_v4();
let to = Uuid::new_v4();
let fc = Uuid::new_v4();
let tc = Uuid::new_v4();
"transaction",
&from.to_string(),
&to.to_string(),
"--from-currency",
&fc.to_string(),
"--to-currency",
&tc.to_string(),
"--value",
"100/1",
let Command::Transaction(TransactionCmd::Create { value, .. }) = parsed.cmd else {
panic!("expected transaction create");
assert_eq!(value, Rational64::new(100, 1));
fn cli_rejects_missing_userid() {
let res = Cli::try_parse_from(["nomisync", "version"]);
assert!(res.is_err());