Skip to main content

talea_client/cli/
mod.rs

1//! The `talea` CLI: thin shim over TaleaClient. `execute` returns the
2//! response as JSON (None for 204 ops) so tests never need a subprocess;
3//! `run` adds printing and the streaming `tail` loop.
4
5pub 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    /// Server base URL
19    #[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    /// Bearer token
27    #[arg(long, env = "TALEA_TOKEN", global = true)]
28    pub token: Option<String>,
29    /// Per-request timeout in seconds (not applied to `tail`)
30    #[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 registry operations
39    Asset {
40        #[command(subcommand)]
41        cmd: AssetCmd,
42    },
43    /// Account operations
44    Account {
45        #[command(subcommand)]
46        cmd: AccountCmd,
47    },
48    /// Post a balanced transaction
49    Post {
50        #[arg(long)]
51        book: Option<String>,
52        /// Idempotency key — REQUIRED (never auto-generated: a generated key
53        /// would defeat retry safety)
54        #[arg(long)]
55        idem: Option<String>,
56        /// <account>:<asset>:<minor>, repeatable (parsed from the right;
57        /// account paths may contain ':')
58        #[arg(long)]
59        debit: Vec<String>,
60        /// <account>:<asset>:<minor>, repeatable
61        #[arg(long)]
62        credit: Vec<String>,
63        /// RFC3339 business/event time (defaults to now, server-side)
64        #[arg(long)]
65        occurred_at: Option<String>,
66        /// Arbitrary JSON metadata
67        #[arg(long)]
68        metadata: Option<String>,
69        /// Full TransactionDraft JSON from a file, or '-' for stdin;
70        /// other flags override the draft's fields
71        #[arg(long)]
72        draft: Option<String>,
73    },
74    /// Current or point-in-time balance
75    Balance {
76        #[arg(long)]
77        book: String,
78        #[arg(long)]
79        path: String,
80        #[arg(long)]
81        as_of: Option<String>,
82    },
83    /// Paginated posting history for an account
84    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    /// Fetch a committed transaction by id
95    Tx { tx_id: String },
96    /// Per-asset debit/credit sums for a book
97    TrialBalance {
98        #[arg(long)]
99        book: String,
100        #[arg(long)]
101        as_of: Option<String>,
102    },
103    /// Stream the book's event log as JSON lines (Ctrl-C to stop)
104    Tail {
105        #[arg(long)]
106        book: String,
107        /// First seq to deliver (default 1 = from the beginning)
108        #[arg(long, default_value_t = 1)]
109        from: i64,
110    },
111    /// Print shell completions to stdout (e.g. `talea completions zsh`)
112    Completions { shell: clap_complete::Shell },
113    /// Write man pages (talea.1 plus one page per subcommand) to a directory
114    Man {
115        /// Output directory (created if missing)
116        #[arg(long, default_value = ".")]
117        out_dir: std::path::PathBuf,
118    },
119}
120
121/// (file name, roff content) for the command and every visible subcommand,
122/// depth-first: talea.1, talea-asset.1, talea-asset-register.1, ...
123///
124/// Rendering to an in-memory `Vec` cannot fail in practice; an `Err` is
125/// surfaced rather than panicking.
126fn 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 an asset (idempotent on id)
151    Register {
152        #[arg(long)]
153        id: String,
154        /// 'fiat' or 'crypto'
155        #[arg(long)]
156        class: String,
157        /// Required for crypto assets
158        #[arg(long)]
159        network: Option<String>,
160        /// Contract address / chain asset id
161        #[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 an account (idempotent on book+path)
173    Open {
174        #[arg(long)]
175        book: String,
176        #[arg(long)]
177        path: String,
178        #[arg(long)]
179        asset: String,
180        /// asset|liability|income|expense|equity|clearing
181        #[arg(long)]
182        kind: String,
183        /// debit|credit (omit for clearing accounts)
184        #[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
198/// Serialize a response view for printing. These are plain-data types whose
199/// serialization cannot fail in practice; if it ever did, surface a typed
200/// error rather than panic.
201fn 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
225/// Run one non-streaming command, returning the response as JSON
226/// (None for 204 operations). Tests call this directly.
227pub 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        // run() handles Tail/Completions/Man before calling execute(); a
366        // typed error (not a panic) for library callers that reach these
367        // directly
368        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
375/// Full CLI entry: printing + the streaming tail loop.
376pub 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            // Serialization of these envelopes cannot fail in practice; if it
405            // ever did, report it on stderr and keep the stream alive.
406            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}