pub mod parse;
use std::time::Duration;
use clap::{Parser, Subcommand};
use futures::StreamExt;
use talea_core::api::*;
use crate::{RetryPolicy, TaleaClient};
#[derive(Parser)]
#[command(name = "talea", about = "talea ledger client", version)]
pub struct Cli {
#[arg(
long,
env = "TALEA_URL",
default_value = "http://127.0.0.1:8080",
global = true
)]
pub url: String,
#[arg(long, env = "TALEA_TOKEN", global = true)]
pub token: Option<String>,
#[arg(long, default_value_t = 30, global = true)]
pub timeout_secs: u64,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
Asset {
#[command(subcommand)]
cmd: AssetCmd,
},
Account {
#[command(subcommand)]
cmd: AccountCmd,
},
Post {
#[arg(long)]
book: Option<String>,
#[arg(long)]
idem: Option<String>,
#[arg(long)]
debit: Vec<String>,
#[arg(long)]
credit: Vec<String>,
#[arg(long)]
occurred_at: Option<String>,
#[arg(long)]
metadata: Option<String>,
#[arg(long)]
draft: Option<String>,
},
Balance {
#[arg(long)]
book: String,
#[arg(long)]
path: String,
#[arg(long)]
as_of: Option<String>,
},
History {
#[arg(long)]
book: String,
#[arg(long)]
path: String,
#[arg(long)]
after_seq: Option<i64>,
#[arg(long, default_value_t = 100)]
limit: u32,
},
Tx { tx_id: String },
TrialBalance {
#[arg(long)]
book: String,
#[arg(long)]
as_of: Option<String>,
},
Tail {
#[arg(long)]
book: String,
#[arg(long, default_value_t = 1)]
from: i64,
},
Completions { shell: clap_complete::Shell },
Man {
#[arg(long, default_value = ".")]
out_dir: std::path::PathBuf,
},
}
fn man_pages(cmd: &clap::Command) -> std::io::Result<Vec<(String, Vec<u8>)>> {
fn walk(
cmd: &clap::Command,
name: String,
out: &mut Vec<(String, Vec<u8>)>,
) -> std::io::Result<()> {
let mut buf = Vec::new();
clap_mangen::Man::new(cmd.clone().name(name.clone())).render(&mut buf)?;
out.push((format!("{name}.1"), buf));
for sub in cmd.get_subcommands() {
if sub.is_hide_set() || sub.get_name() == "help" {
continue;
}
walk(sub, format!("{name}-{}", sub.get_name()), out)?;
}
Ok(())
}
let mut out = Vec::new();
walk(cmd, cmd.get_name().to_string(), &mut out)?;
Ok(out)
}
#[derive(Subcommand)]
pub enum AssetCmd {
Register {
#[arg(long)]
id: String,
#[arg(long)]
class: String,
#[arg(long)]
network: Option<String>,
#[arg(long)]
native_id: Option<String>,
#[arg(long)]
precision: u8,
#[arg(long)]
name: String,
},
}
#[derive(Subcommand)]
pub enum AccountCmd {
Open {
#[arg(long)]
book: String,
#[arg(long)]
path: String,
#[arg(long)]
asset: String,
#[arg(long)]
kind: String,
#[arg(long)]
normal_side: Option<String>,
#[arg(long)]
min_balance: Option<i64>,
},
}
fn invalid(reason: String) -> ApiError {
ApiError::InvalidDraft {
field: "args".into(),
reason,
}
}
fn to_json<T: serde::Serialize>(value: &T) -> ApiResult<serde_json::Value> {
serde_json::to_value(value).map_err(|e| invalid(format!("serializing response: {e}")))
}
fn build_client(cli: &Cli) -> ApiResult<TaleaClient> {
let mut builder = TaleaClient::builder(&cli.url)
.timeout(Duration::from_secs(cli.timeout_secs))
.retry(RetryPolicy::default());
if let Some(t) = &cli.token {
builder = builder.bearer_token(t);
}
builder.build()
}
fn parse_side(s: &str) -> ApiResult<talea_core::types::Direction> {
match s {
"debit" => Ok(talea_core::types::Direction::Debit),
"credit" => Ok(talea_core::types::Direction::Credit),
other => Err(invalid(format!(
"normal side '{other}' (want debit|credit)"
))),
}
}
pub async fn execute(cli: Cli) -> ApiResult<Option<serde_json::Value>> {
let client = build_client(&cli)?;
match cli.command {
Command::Asset {
cmd:
AssetCmd::Register {
id,
class,
network,
native_id,
precision,
name,
},
} => {
client
.register_asset(AssetDraft {
id,
class,
network,
native_id,
precision,
name,
})
.await?;
Ok(None)
}
Command::Account {
cmd:
AccountCmd::Open {
book,
path,
asset,
kind,
normal_side,
min_balance,
},
} => {
let normal_side = normal_side.as_deref().map(parse_side).transpose()?;
client
.open_account(AccountDraft {
book,
path,
asset,
kind,
normal_side,
min_balance,
})
.await?;
Ok(None)
}
Command::Post {
book,
idem,
debit,
credit,
occurred_at,
metadata,
draft,
} => {
let base = match draft {
None => None,
Some(src) => {
let raw = if src == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| invalid(format!("reading stdin: {e}")))?;
buf
} else {
std::fs::read_to_string(&src)
.map_err(|e| invalid(format!("reading {src}: {e}")))?
};
Some(
serde_json::from_str(&raw)
.map_err(|e| invalid(format!("draft json: {e}")))?,
)
}
};
let debits = debit
.iter()
.map(|s| parse::parse_posting(s, talea_core::types::Direction::Debit))
.collect::<Result<Vec<_>, _>>()
.map_err(invalid)?;
let credits = credit
.iter()
.map(|s| parse::parse_posting(s, talea_core::types::Direction::Credit))
.collect::<Result<Vec<_>, _>>()
.map_err(invalid)?;
let occurred_at = occurred_at
.as_deref()
.map(parse::parse_rfc3339)
.transpose()
.map_err(invalid)?;
let metadata = metadata
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|e| invalid(format!("metadata json: {e}")))?;
let draft =
parse::build_draft(base, book, idem, debits, credits, occurred_at, metadata)
.map_err(invalid)?;
let posted = client.post(draft).await?;
Ok(Some(to_json(&posted)?))
}
Command::Balance { book, path, as_of } => {
let as_of = as_of
.as_deref()
.map(parse::parse_rfc3339)
.transpose()
.map_err(invalid)?;
let view = client.balance(&book, &path, as_of).await?;
Ok(Some(to_json(&view)?))
}
Command::History {
book,
path,
after_seq,
limit,
} => {
let page = client
.account_history(&book, &path, Page { after_seq, limit })
.await?;
Ok(Some(to_json(&page)?))
}
Command::Tx { tx_id } => {
let view = client.transaction(&tx_id).await?;
Ok(Some(to_json(&view)?))
}
Command::TrialBalance { book, as_of } => {
let as_of = as_of
.as_deref()
.map(parse::parse_rfc3339)
.transpose()
.map_err(invalid)?;
let tb = client.trial_balance(&book, as_of).await?;
Ok(Some(to_json(&tb)?))
}
Command::Tail { .. } => Err(invalid("tail is a streaming command; call run()".into())),
Command::Completions { .. } | Command::Man { .. } => {
Err(invalid("local command; call run()".into()))
}
}
}
pub async fn run(cli: Cli) -> ApiResult<()> {
match &cli.command {
Command::Completions { shell } => {
let mut cmd = <Cli as clap::CommandFactory>::command();
clap_complete::generate(*shell, &mut cmd, "talea", &mut std::io::stdout());
return Ok(());
}
Command::Man { out_dir } => {
std::fs::create_dir_all(out_dir)
.map_err(|e| invalid(format!("creating {}: {e}", out_dir.display())))?;
let pages = man_pages(&<Cli as clap::CommandFactory>::command())
.map_err(|e| invalid(format!("rendering man pages: {e}")))?;
for (name, page) in pages {
let path = out_dir.join(name);
std::fs::write(&path, page)
.map_err(|e| invalid(format!("writing {}: {e}", path.display())))?;
println!("{}", path.display());
}
return Ok(());
}
_ => {}
}
if let Command::Tail { book, from } = &cli.command {
let book = book.clone();
let from = *from;
let client = build_client(&cli)?;
let mut stream = client.subscribe(&book, from).await?;
while let Some(item) = stream.next().await {
match item {
Ok(env) => match serde_json::to_string(&env) {
Ok(line) => println!("{line}"),
Err(e) => eprintln!("failed to serialize event envelope: {e}"),
},
Err(e) => match serde_json::to_string(&e) {
Ok(line) => eprintln!("{line}"),
Err(ser) => eprintln!("failed to serialize stream error: {ser}"),
},
}
}
return Ok(());
}
if let Some(value) = execute(cli).await? {
let pretty = serde_json::to_string_pretty(&value)
.map_err(|e| invalid(format!("serializing output: {e}")))?;
println!("{pretty}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn completions_render_for_zsh() {
let mut buf = Vec::new();
let mut cmd = Cli::command();
clap_complete::generate(clap_complete::Shell::Zsh, &mut cmd, "talea", &mut buf);
let script = String::from_utf8(buf).unwrap();
assert!(script.contains("talea"));
assert!(script.contains("trial-balance"));
}
#[test]
fn man_pages_cover_every_subcommand() {
let pages = man_pages(&Cli::command()).unwrap();
let names: Vec<&str> = pages.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"talea.1"), "got {names:?}");
assert!(names.contains(&"talea-post.1"), "got {names:?}");
assert!(names.contains(&"talea-asset-register.1"), "got {names:?}");
assert!(!names.iter().any(|n| n.contains("help")), "got {names:?}");
for (name, content) in &pages {
assert!(!content.is_empty(), "{name} rendered empty");
}
}
}