Skip to main content

talea_client/cli/
parse.rs

1//! Pure arg -> draft parsers. No I/O, no clap types: trivially testable.
2
3use chrono::{DateTime, Utc};
4use talea_core::api::{PostingDraft, TransactionDraft, WireAmount};
5use talea_core::types::Direction;
6
7/// `--debit treasury:btc:USD:1000` — parsed from the RIGHT so account paths
8/// may contain ':'. Format: <account>:<asset>:<minor>.
9pub fn parse_posting(s: &str, direction: Direction) -> Result<PostingDraft, String> {
10    let mut it = s.rsplitn(3, ':');
11    let minor = it.next().filter(|p| !p.is_empty()).ok_or("empty posting")?;
12    let asset = it
13        .next()
14        .filter(|p| !p.is_empty())
15        .ok_or("missing asset (want <account>:<asset>:<minor>)")?;
16    let account = it
17        .next()
18        .filter(|p| !p.is_empty())
19        .ok_or("missing account (want <account>:<asset>:<minor>)")?;
20    let minor: i64 = minor.parse().map_err(|e| format!("minor units: {e}"))?;
21    Ok(PostingDraft {
22        account: account.to_string(),
23        amount: WireAmount {
24            minor,
25            asset: asset.to_string(),
26        },
27        direction,
28    })
29}
30
31pub fn parse_rfc3339(s: &str) -> Result<DateTime<Utc>, String> {
32    DateTime::parse_from_rfc3339(s)
33        .map(|t| t.with_timezone(&Utc))
34        .map_err(|e| format!("timestamp: {e}"))
35}
36
37/// Builds the final draft. `base` comes from --draft (file or stdin); flag
38/// values override its fields. Without --draft, --book and --idem are
39/// required and postings come from --debit/--credit.
40pub fn build_draft(
41    base: Option<TransactionDraft>,
42    book: Option<String>,
43    idem: Option<String>,
44    debits: Vec<PostingDraft>,
45    credits: Vec<PostingDraft>,
46    occurred_at: Option<DateTime<Utc>>,
47    metadata: Option<serde_json::Value>,
48) -> Result<TransactionDraft, String> {
49    let mut draft = base.unwrap_or(TransactionDraft {
50        book: String::new(),
51        idempotency_key: String::new(),
52        postings: vec![],
53        external_refs: vec![],
54        metadata: serde_json::Value::Null,
55        occurred_at: None,
56    });
57    if let Some(b) = book {
58        draft.book = b;
59    }
60    if let Some(k) = idem {
61        draft.idempotency_key = k;
62    }
63    if !debits.is_empty() || !credits.is_empty() {
64        draft.postings = debits.into_iter().chain(credits).collect();
65    }
66    if let Some(t) = occurred_at {
67        draft.occurred_at = Some(t);
68    }
69    if let Some(m) = metadata {
70        draft.metadata = m;
71    }
72    if draft.book.is_empty() {
73        return Err("--book is required (or provide it in --draft)".into());
74    }
75    if draft.idempotency_key.is_empty() {
76        return Err("--idem is required (or provide it in --draft)".into());
77    }
78    if draft.postings.is_empty() {
79        return Err("at least one --debit/--credit is required (or postings in --draft)".into());
80    }
81    Ok(draft)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn posting_parses_from_the_right() {
90        let p = parse_posting("treasury:btc:USD:1000", Direction::Debit).unwrap();
91        assert_eq!(p.account, "treasury:btc"); // colon-bearing path survives
92        assert_eq!(p.amount.asset, "USD");
93        assert_eq!(p.amount.minor, 1000);
94
95        let p = parse_posting("cash:USD:5", Direction::Credit).unwrap();
96        assert_eq!(p.account, "cash");
97    }
98
99    #[test]
100    fn posting_rejects_malformed_input() {
101        assert!(parse_posting("USD:1000", Direction::Debit).is_err()); // no account
102        assert!(parse_posting("cash:USD:ten", Direction::Debit).is_err()); // bad minor
103        assert!(parse_posting("", Direction::Debit).is_err());
104        assert!(parse_posting("cash::1000", Direction::Debit).is_err()); // empty asset
105    }
106
107    #[test]
108    fn rfc3339_round_trips() {
109        let t = parse_rfc3339("2026-06-04T12:00:00Z").unwrap();
110        assert_eq!(t.to_rfc3339(), "2026-06-04T12:00:00+00:00");
111        assert!(parse_rfc3339("yesterday").is_err());
112    }
113
114    #[test]
115    fn draft_flags_override_base() {
116        let base: TransactionDraft = serde_json::from_value(serde_json::json!({
117            "book": "from-file",
118            "idempotency_key": "file-key",
119            "postings": [
120                {"account":"a","amount":{"minor":1,"asset":"USD"},"direction":"debit"}
121            ]
122        }))
123        .unwrap();
124        let draft = build_draft(
125            Some(base),
126            Some("cli-book".into()),
127            None,
128            vec![],
129            vec![],
130            None,
131            None,
132        )
133        .unwrap();
134        assert_eq!(draft.book, "cli-book"); // flag wins
135        assert_eq!(draft.idempotency_key, "file-key"); // file value kept
136        assert_eq!(draft.postings.len(), 1); // file postings kept
137    }
138
139    #[test]
140    fn missing_required_fields_error() {
141        assert!(build_draft(None, None, Some("k".into()), vec![], vec![], None, None).is_err());
142        assert!(build_draft(None, Some("b".into()), None, vec![], vec![], None, None).is_err());
143        assert!(
144            build_draft(
145                None,
146                Some("b".into()),
147                Some("k".into()),
148                vec![],
149                vec![],
150                None,
151                None
152            )
153            .is_err()
154        );
155    }
156}