Skip to main content

btctax_cli/
eventref.rs

1//! Parse CLI references back into core types. The primary case is the canonical `EventId` string the
2//! engine prints (`EventId::canonical()`): `import|<src>|<source_ref…>`, `conflict|<src>|<source_ref…>|<fp>`,
3//! `decision|<seq>`. `source_ref` itself may contain `|` (adapters mint direction-scoped refs like
4//! `out|cb-send`), so import rejoins parts[2..] and conflict takes the LAST part as the fingerprint.
5use crate::CliError;
6use btctax_core::{
7    DisposeKind, EventId, Fingerprint, IncomeKind, LotId, LotPick, Source, SourceRef, TaxDate, Usd,
8    WalletId,
9};
10use rust_decimal::Decimal;
11use std::str::FromStr;
12use time::macros::format_description;
13use time::Date;
14
15fn source_of(tag: &str) -> Option<Source> {
16    match tag {
17        "swan" => Some(Source::Swan),
18        "coinbase" => Some(Source::Coinbase),
19        "gemini" => Some(Source::Gemini),
20        "river" => Some(Source::River),
21        _ => None,
22    }
23}
24
25pub fn parse_event_id(s: &str) -> Result<EventId, CliError> {
26    let bad = || CliError::BadEventRef(s.to_string());
27    let parts: Vec<&str> = s.split('|').collect();
28    match parts.first().copied() {
29        Some("import") => {
30            if parts.len() < 3 {
31                return Err(bad());
32            }
33            let source = source_of(parts[1]).ok_or_else(bad)?;
34            let source_ref = parts[2..].join("|"); // may contain '|'
35            Ok(EventId::import(source, SourceRef::new(source_ref)))
36        }
37        Some("conflict") => {
38            if parts.len() < 4 {
39                return Err(bad());
40            }
41            let source = source_of(parts[1]).ok_or_else(bad)?;
42            let fp = Fingerprint(parts[parts.len() - 1].to_string()); // fingerprint is the last segment
43            let source_ref = parts[2..parts.len() - 1].join("|");
44            Ok(EventId::conflict(source, SourceRef::new(source_ref), &fp))
45        }
46        Some("decision") => {
47            if parts.len() != 2 {
48                return Err(bad());
49            }
50            let seq = parts[1].parse::<u64>().map_err(|_| bad())?;
51            Ok(EventId::decision(seq))
52        }
53        _ => Err(bad()),
54    }
55}
56
57/// `exchange:PROVIDER:ACCOUNT` | `self:LABEL`.
58pub fn parse_wallet_id(s: &str) -> Result<WalletId, CliError> {
59    let parts: Vec<&str> = s.splitn(3, ':').collect();
60    match parts.as_slice() {
61        ["exchange", provider, account] if !provider.is_empty() && !account.is_empty() => {
62            Ok(WalletId::Exchange {
63                provider: (*provider).to_string(),
64                account: (*account).to_string(),
65            })
66        }
67        ["self", label] if !label.is_empty() => Ok(WalletId::SelfCustody {
68            label: (*label).to_string(),
69        }),
70        _ => Err(CliError::Usage(format!(
71            "bad wallet {s:?}; use exchange:PROVIDER:ACCOUNT or self:LABEL"
72        ))),
73    }
74}
75
76/// Exact USD (NFR5): string → Decimal, never float.
77pub fn parse_usd_arg(s: &str) -> Result<Usd, CliError> {
78    Decimal::from_str(s.trim()).map_err(|e| CliError::Usage(format!("bad USD {s:?}: {e}")))
79}
80
81pub fn parse_date_arg(s: &str) -> Result<TaxDate, CliError> {
82    let fmt = format_description!("[year]-[month]-[day]");
83    Date::parse(s.trim(), &fmt).map_err(|e| CliError::Usage(format!("bad date {s:?}: {e}")))
84}
85
86/// `<event-id-canonical>#<split-sequence>` → `LotId`.
87///
88/// Uses `rsplit_once('#')` so that a source_ref containing '#' (e.g. Conflict fingerprint or a
89/// semantic key like `in|1234|credit|1#0`) is handled correctly: the split-sequence suffix is
90/// always the LAST `#`-separated segment, and `EventId::canonical()` uses `|` as its separator
91/// (never `#`), so peeling from the rightmost `#` is unambiguous.
92pub fn parse_lot_id(s: &str) -> Result<LotId, CliError> {
93    let (origin, split) = s
94        .rsplit_once('#')
95        .ok_or_else(|| CliError::Usage(format!("bad lot id {s:?}; expected <event-id>#<split>")))?;
96    let origin_event_id = parse_event_id(origin)?;
97    let split_sequence = split
98        .trim()
99        .parse::<u32>()
100        .map_err(|_| CliError::Usage(format!("bad split_sequence in lot id {s:?}")))?;
101    Ok(LotId {
102        origin_event_id,
103        split_sequence,
104    })
105}
106
107/// `<lot-id>:<sat>` → `LotPick`.
108///
109/// Uses `rsplit_once(':')` so that the `<lot-id>` portion (which uses `|` and `#` as separators,
110/// never `:`) stays intact. The trailing `:<sat>` is always unambiguous.
111pub fn parse_lot_pick(s: &str) -> Result<LotPick, CliError> {
112    let (lot_str, sat_str) = s
113        .rsplit_once(':')
114        .ok_or_else(|| CliError::Usage(format!("bad --from {s:?}; expected <lot-id>:<sat>")))?;
115    let lot = parse_lot_id(lot_str)?;
116    let sat = sat_str
117        .trim()
118        .parse::<i64>()
119        .map_err(|e| CliError::Usage(format!("bad sat in --from {s:?}: {e}")))?;
120    Ok(LotPick { lot, sat })
121}
122
123pub fn parse_income_kind(s: &str) -> Result<IncomeKind, CliError> {
124    match s.to_ascii_lowercase().as_str() {
125        "mining" => Ok(IncomeKind::Mining),
126        "staking" => Ok(IncomeKind::Staking),
127        "interest" => Ok(IncomeKind::Interest),
128        "airdrop" => Ok(IncomeKind::Airdrop),
129        "reward" => Ok(IncomeKind::Reward),
130        _ => Err(CliError::Usage(format!("bad income kind {s:?}"))),
131    }
132}
133
134/// Parse a bulk-reclassify-outflow `--kind` argument. Accepts ONLY `sell`/`spend`; REJECTS `gift` and
135/// `donate` (and any other value) — the structural scope-lock [Q2]: `GiftOut`/`Donate` need a per-row
136/// `donee` and (for Donate) a qualified-appraisal FMV, so a uniform-across-many-rows bulk substitution
137/// of a market close price is semantically wrong. Those stay single-item (`reclassify-outflow`).
138pub fn parse_dispose_kind(s: &str) -> Result<DisposeKind, CliError> {
139    match s.to_ascii_lowercase().as_str() {
140        "sell" => Ok(DisposeKind::Sell),
141        "spend" => Ok(DisposeKind::Spend),
142        "gift" | "donate" => Err(CliError::Usage(format!(
143            "bulk-reclassify-outflow --kind {s:?} is out of scope: gift/donate need a per-row donee \
144             (and donate a qualified-appraisal FMV); use single-item `reclassify-outflow` instead"
145        ))),
146        _ => Err(CliError::Usage(format!(
147            "bad dispose kind {s:?} (expected sell|spend)"
148        ))),
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use btctax_core::{EventId, Fingerprint, IncomeKind, LotId, Source, SourceRef, WalletId};
156    use rust_decimal_macros::dec;
157    use time::macros::date;
158
159    #[test]
160    fn import_eventref_round_trips_even_with_pipe_in_source_ref() {
161        // Adapters mint direction-scoped source_refs that CONTAIN '|' (e.g. "out|cb-send").
162        let id = EventId::import(Source::Coinbase, SourceRef::new("out|cb-send"));
163        let s = id.canonical(); // "import|coinbase|out|cb-send"
164        assert_eq!(parse_event_id(&s).unwrap(), id);
165    }
166
167    #[test]
168    fn decision_and_conflict_eventrefs_round_trip() {
169        let d = EventId::decision(7);
170        assert_eq!(parse_event_id(&d.canonical()).unwrap(), d);
171
172        let fp = Fingerprint::of_bytes(b"x");
173        let c = EventId::conflict(Source::Gemini, SourceRef::new("in|99|credit|1#0"), &fp);
174        assert_eq!(parse_event_id(&c.canonical()).unwrap(), c);
175    }
176
177    #[test]
178    fn bad_eventref_is_a_typed_error() {
179        assert!(matches!(
180            parse_event_id("garbage"),
181            Err(crate::CliError::BadEventRef(_))
182        ));
183        assert!(matches!(
184            parse_event_id("import|nosuchsource|x"),
185            Err(crate::CliError::BadEventRef(_))
186        ));
187    }
188
189    #[test]
190    fn wallet_usd_date_kind_parsers() {
191        assert_eq!(
192            parse_wallet_id("exchange:coinbase:main").unwrap(),
193            WalletId::Exchange {
194                provider: "coinbase".into(),
195                account: "main".into()
196            }
197        );
198        assert_eq!(
199            parse_wallet_id("self:cold").unwrap(),
200            WalletId::SelfCustody {
201                label: "cold".into()
202            }
203        );
204        assert_eq!(parse_usd_arg("1234.56").unwrap(), dec!(1234.56));
205        assert_eq!(parse_date_arg("2025-01-01").unwrap(), date!(2025 - 01 - 01));
206        assert_eq!(parse_income_kind("interest").unwrap(), IncomeKind::Interest);
207        assert!(parse_wallet_id("bogus").is_err());
208    }
209
210    #[test]
211    fn parse_lot_id_round_trips_all_three_origin_variants() {
212        // Import origin (with a '|' in source_ref — adapters mint direction-scoped refs)
213        let l_imp = LotId {
214            origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("out|cb-send")),
215            split_sequence: 3,
216        };
217        let s = format!(
218            "{}#{}",
219            l_imp.origin_event_id.canonical(),
220            l_imp.split_sequence
221        );
222        assert_eq!(parse_lot_id(&s).unwrap(), l_imp);
223
224        // Decision origin (Path-B seed lots: origin = allocation Decision id, resolve.rs:570-574)
225        let l_dec = LotId {
226            origin_event_id: EventId::decision(7),
227            split_sequence: 1,
228        };
229        assert_eq!(
230            parse_lot_id(&format!("{}#1", EventId::decision(7).canonical())).unwrap(),
231            l_dec
232        );
233
234        // Conflict origin
235        let fp = Fingerprint::of_bytes(b"x");
236        let cid = EventId::conflict(Source::Gemini, SourceRef::new("in|99|credit"), &fp);
237        let l_con = LotId {
238            origin_event_id: cid.clone(),
239            split_sequence: 0,
240        };
241        assert_eq!(
242            parse_lot_id(&format!("{}#0", cid.canonical())).unwrap(),
243            l_con
244        );
245
246        // N1: a source_ref containing '#' must still round-trip — rsplit_once('#') splits on the
247        // LAST '#' (the split-sequence suffix is always last); locks the rsplit choice
248        // (cf. eventref.rs decision_and_conflict_eventrefs_round_trip: "in|99|credit|1#0").
249        let l_hash = LotId {
250            origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("in|99|credit|1#0")),
251            split_sequence: 2,
252        };
253        assert_eq!(
254            parse_lot_id(&format!(
255                "{}#{}",
256                l_hash.origin_event_id.canonical(),
257                l_hash.split_sequence
258            ))
259            .unwrap(),
260            l_hash
261        );
262    }
263
264    #[test]
265    fn parse_lot_pick_splits_trailing_sat() {
266        let pick = parse_lot_pick("import|coinbase|X#0:25000").unwrap();
267        assert_eq!(
268            pick.lot,
269            LotId {
270                origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("X")),
271                split_sequence: 0,
272            }
273        );
274        assert_eq!(pick.sat, 25_000);
275    }
276
277    #[test]
278    fn parse_lot_id_rejects_missing_hash() {
279        assert!(matches!(
280            parse_lot_id("import|coinbase|X"),
281            Err(crate::CliError::Usage(_))
282        ));
283    }
284
285    #[test]
286    fn parse_lot_pick_rejects_missing_colon() {
287        assert!(matches!(
288            parse_lot_pick("import|coinbase|X#0"),
289            Err(crate::CliError::Usage(_))
290        ));
291    }
292}