talea_client/cli/
parse.rs1use chrono::{DateTime, Utc};
4use talea_core::api::{PostingDraft, TransactionDraft, WireAmount};
5use talea_core::types::Direction;
6
7pub 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
37pub 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"); 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()); assert!(parse_posting("cash:USD:ten", Direction::Debit).is_err()); assert!(parse_posting("", Direction::Debit).is_err());
104 assert!(parse_posting("cash::1000", Direction::Debit).is_err()); }
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"); assert_eq!(draft.idempotency_key, "file-key"); assert_eq!(draft.postings.len(), 1); }
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}