1pub mod parse;
6
7use std::time::Duration;
8
9use clap::{Parser, Subcommand};
10use futures::StreamExt;
11use talea_core::api::*;
12
13use crate::{RetryPolicy, TaleaClient};
14
15#[derive(Parser)]
16#[command(name = "talea", about = "talea ledger client", version)]
17pub struct Cli {
18 #[arg(
20 long,
21 env = "TALEA_URL",
22 default_value = "http://127.0.0.1:8080",
23 global = true
24 )]
25 pub url: String,
26 #[arg(long, env = "TALEA_TOKEN", global = true)]
28 pub token: Option<String>,
29 #[arg(long, default_value_t = 30, global = true)]
31 pub timeout_secs: u64,
32 #[command(subcommand)]
33 pub command: Command,
34}
35
36#[derive(Subcommand)]
37pub enum Command {
38 Asset {
40 #[command(subcommand)]
41 cmd: AssetCmd,
42 },
43 Account {
45 #[command(subcommand)]
46 cmd: AccountCmd,
47 },
48 Post {
50 #[arg(long)]
51 book: Option<String>,
52 #[arg(long)]
55 idem: Option<String>,
56 #[arg(long)]
59 debit: Vec<String>,
60 #[arg(long)]
62 credit: Vec<String>,
63 #[arg(long)]
65 occurred_at: Option<String>,
66 #[arg(long)]
68 metadata: Option<String>,
69 #[arg(long)]
72 draft: Option<String>,
73 },
74 Balance {
76 #[arg(long)]
77 book: String,
78 #[arg(long)]
79 path: String,
80 #[arg(long)]
81 as_of: Option<String>,
82 },
83 History {
85 #[arg(long)]
86 book: String,
87 #[arg(long)]
88 path: String,
89 #[arg(long)]
90 after_seq: Option<i64>,
91 #[arg(long, default_value_t = 100)]
92 limit: u32,
93 },
94 Tx { tx_id: String },
96 TrialBalance {
98 #[arg(long)]
99 book: String,
100 #[arg(long)]
101 as_of: Option<String>,
102 },
103 Tail {
105 #[arg(long)]
106 book: String,
107 #[arg(long, default_value_t = 1)]
109 from: i64,
110 },
111 Completions { shell: clap_complete::Shell },
113 Man {
115 #[arg(long, default_value = ".")]
117 out_dir: std::path::PathBuf,
118 },
119}
120
121fn man_pages(cmd: &clap::Command) -> std::io::Result<Vec<(String, Vec<u8>)>> {
127 fn walk(
128 cmd: &clap::Command,
129 name: String,
130 out: &mut Vec<(String, Vec<u8>)>,
131 ) -> std::io::Result<()> {
132 let mut buf = Vec::new();
133 clap_mangen::Man::new(cmd.clone().name(name.clone())).render(&mut buf)?;
134 out.push((format!("{name}.1"), buf));
135 for sub in cmd.get_subcommands() {
136 if sub.is_hide_set() || sub.get_name() == "help" {
137 continue;
138 }
139 walk(sub, format!("{name}-{}", sub.get_name()), out)?;
140 }
141 Ok(())
142 }
143 let mut out = Vec::new();
144 walk(cmd, cmd.get_name().to_string(), &mut out)?;
145 Ok(out)
146}
147
148#[derive(Subcommand)]
149pub enum AssetCmd {
150 Register {
152 #[arg(long)]
153 id: String,
154 #[arg(long)]
156 class: String,
157 #[arg(long)]
159 network: Option<String>,
160 #[arg(long)]
162 native_id: Option<String>,
163 #[arg(long)]
164 precision: u8,
165 #[arg(long)]
166 name: String,
167 },
168}
169
170#[derive(Subcommand)]
171pub enum AccountCmd {
172 Open {
174 #[arg(long)]
175 book: String,
176 #[arg(long)]
177 path: String,
178 #[arg(long)]
179 asset: String,
180 #[arg(long)]
182 kind: String,
183 #[arg(long)]
185 normal_side: Option<String>,
186 #[arg(long)]
187 min_balance: Option<i64>,
188 },
189}
190
191fn invalid(reason: String) -> ApiError {
192 ApiError::InvalidDraft {
193 field: "args".into(),
194 reason,
195 }
196}
197
198fn to_json<T: serde::Serialize>(value: &T) -> ApiResult<serde_json::Value> {
202 serde_json::to_value(value).map_err(|e| invalid(format!("serializing response: {e}")))
203}
204
205fn build_client(cli: &Cli) -> ApiResult<TaleaClient> {
206 let mut builder = TaleaClient::builder(&cli.url)
207 .timeout(Duration::from_secs(cli.timeout_secs))
208 .retry(RetryPolicy::default());
209 if let Some(t) = &cli.token {
210 builder = builder.bearer_token(t);
211 }
212 builder.build()
213}
214
215fn parse_side(s: &str) -> ApiResult<talea_core::types::Direction> {
216 match s {
217 "debit" => Ok(talea_core::types::Direction::Debit),
218 "credit" => Ok(talea_core::types::Direction::Credit),
219 other => Err(invalid(format!(
220 "normal side '{other}' (want debit|credit)"
221 ))),
222 }
223}
224
225pub async fn execute(cli: Cli) -> ApiResult<Option<serde_json::Value>> {
228 let client = build_client(&cli)?;
229 match cli.command {
230 Command::Asset {
231 cmd:
232 AssetCmd::Register {
233 id,
234 class,
235 network,
236 native_id,
237 precision,
238 name,
239 },
240 } => {
241 client
242 .register_asset(AssetDraft {
243 id,
244 class,
245 network,
246 native_id,
247 precision,
248 name,
249 })
250 .await?;
251 Ok(None)
252 }
253 Command::Account {
254 cmd:
255 AccountCmd::Open {
256 book,
257 path,
258 asset,
259 kind,
260 normal_side,
261 min_balance,
262 },
263 } => {
264 let normal_side = normal_side.as_deref().map(parse_side).transpose()?;
265 client
266 .open_account(AccountDraft {
267 book,
268 path,
269 asset,
270 kind,
271 normal_side,
272 min_balance,
273 })
274 .await?;
275 Ok(None)
276 }
277 Command::Post {
278 book,
279 idem,
280 debit,
281 credit,
282 occurred_at,
283 metadata,
284 draft,
285 } => {
286 let base = match draft {
287 None => None,
288 Some(src) => {
289 let raw = if src == "-" {
290 use std::io::Read;
291 let mut buf = String::new();
292 std::io::stdin()
293 .read_to_string(&mut buf)
294 .map_err(|e| invalid(format!("reading stdin: {e}")))?;
295 buf
296 } else {
297 std::fs::read_to_string(&src)
298 .map_err(|e| invalid(format!("reading {src}: {e}")))?
299 };
300 Some(
301 serde_json::from_str(&raw)
302 .map_err(|e| invalid(format!("draft json: {e}")))?,
303 )
304 }
305 };
306 let debits = debit
307 .iter()
308 .map(|s| parse::parse_posting(s, talea_core::types::Direction::Debit))
309 .collect::<Result<Vec<_>, _>>()
310 .map_err(invalid)?;
311 let credits = credit
312 .iter()
313 .map(|s| parse::parse_posting(s, talea_core::types::Direction::Credit))
314 .collect::<Result<Vec<_>, _>>()
315 .map_err(invalid)?;
316 let occurred_at = occurred_at
317 .as_deref()
318 .map(parse::parse_rfc3339)
319 .transpose()
320 .map_err(invalid)?;
321 let metadata = metadata
322 .as_deref()
323 .map(serde_json::from_str)
324 .transpose()
325 .map_err(|e| invalid(format!("metadata json: {e}")))?;
326 let draft =
327 parse::build_draft(base, book, idem, debits, credits, occurred_at, metadata)
328 .map_err(invalid)?;
329 let posted = client.post(draft).await?;
330 Ok(Some(to_json(&posted)?))
331 }
332 Command::Balance { book, path, as_of } => {
333 let as_of = as_of
334 .as_deref()
335 .map(parse::parse_rfc3339)
336 .transpose()
337 .map_err(invalid)?;
338 let view = client.balance(&book, &path, as_of).await?;
339 Ok(Some(to_json(&view)?))
340 }
341 Command::History {
342 book,
343 path,
344 after_seq,
345 limit,
346 } => {
347 let page = client
348 .account_history(&book, &path, Page { after_seq, limit })
349 .await?;
350 Ok(Some(to_json(&page)?))
351 }
352 Command::Tx { tx_id } => {
353 let view = client.transaction(&tx_id).await?;
354 Ok(Some(to_json(&view)?))
355 }
356 Command::TrialBalance { book, as_of } => {
357 let as_of = as_of
358 .as_deref()
359 .map(parse::parse_rfc3339)
360 .transpose()
361 .map_err(invalid)?;
362 let tb = client.trial_balance(&book, as_of).await?;
363 Ok(Some(to_json(&tb)?))
364 }
365 Command::Tail { .. } => Err(invalid("tail is a streaming command; call run()".into())),
369 Command::Completions { .. } | Command::Man { .. } => {
370 Err(invalid("local command; call run()".into()))
371 }
372 }
373}
374
375pub async fn run(cli: Cli) -> ApiResult<()> {
377 match &cli.command {
378 Command::Completions { shell } => {
379 let mut cmd = <Cli as clap::CommandFactory>::command();
380 clap_complete::generate(*shell, &mut cmd, "talea", &mut std::io::stdout());
381 return Ok(());
382 }
383 Command::Man { out_dir } => {
384 std::fs::create_dir_all(out_dir)
385 .map_err(|e| invalid(format!("creating {}: {e}", out_dir.display())))?;
386 let pages = man_pages(&<Cli as clap::CommandFactory>::command())
387 .map_err(|e| invalid(format!("rendering man pages: {e}")))?;
388 for (name, page) in pages {
389 let path = out_dir.join(name);
390 std::fs::write(&path, page)
391 .map_err(|e| invalid(format!("writing {}: {e}", path.display())))?;
392 println!("{}", path.display());
393 }
394 return Ok(());
395 }
396 _ => {}
397 }
398 if let Command::Tail { book, from } = &cli.command {
399 let book = book.clone();
400 let from = *from;
401 let client = build_client(&cli)?;
402 let mut stream = client.subscribe(&book, from).await?;
403 while let Some(item) = stream.next().await {
404 match item {
407 Ok(env) => match serde_json::to_string(&env) {
408 Ok(line) => println!("{line}"),
409 Err(e) => eprintln!("failed to serialize event envelope: {e}"),
410 },
411 Err(e) => match serde_json::to_string(&e) {
412 Ok(line) => eprintln!("{line}"),
413 Err(ser) => eprintln!("failed to serialize stream error: {ser}"),
414 },
415 }
416 }
417 return Ok(());
418 }
419 if let Some(value) = execute(cli).await? {
420 let pretty = serde_json::to_string_pretty(&value)
421 .map_err(|e| invalid(format!("serializing output: {e}")))?;
422 println!("{pretty}");
423 }
424 Ok(())
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use clap::CommandFactory;
431
432 #[test]
433 fn completions_render_for_zsh() {
434 let mut buf = Vec::new();
435 let mut cmd = Cli::command();
436 clap_complete::generate(clap_complete::Shell::Zsh, &mut cmd, "talea", &mut buf);
437 let script = String::from_utf8(buf).unwrap();
438 assert!(script.contains("talea"));
439 assert!(script.contains("trial-balance"));
440 }
441
442 #[test]
443 fn man_pages_cover_every_subcommand() {
444 let pages = man_pages(&Cli::command()).unwrap();
445 let names: Vec<&str> = pages.iter().map(|(n, _)| n.as_str()).collect();
446 assert!(names.contains(&"talea.1"), "got {names:?}");
447 assert!(names.contains(&"talea-post.1"), "got {names:?}");
448 assert!(names.contains(&"talea-asset-register.1"), "got {names:?}");
449 assert!(!names.iter().any(|n| n.contains("help")), "got {names:?}");
450 for (name, content) in &pages {
451 assert!(!content.is_empty(), "{name} rendered empty");
452 }
453 }
454}