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!(
131            "bad income kind {s:?} — expected one of: mining, staking, interest, airdrop, reward"
132        ))),
133    }
134}
135
136/// Parse a bulk-reclassify-outflow `--kind` argument. Accepts ONLY `sell`/`spend`; REJECTS `gift` and
137/// `donate` (and any other value) — the structural scope-lock [Q2]: `GiftOut`/`Donate` need a per-row
138/// `donee` and (for Donate) a qualified-appraisal FMV, so a uniform-across-many-rows bulk substitution
139/// of a market close price is semantically wrong. Those stay single-item (`reclassify-outflow`).
140pub fn parse_dispose_kind(s: &str) -> Result<DisposeKind, CliError> {
141    match s.to_ascii_lowercase().as_str() {
142        "sell" => Ok(DisposeKind::Sell),
143        "spend" => Ok(DisposeKind::Spend),
144        "gift" | "donate" => Err(CliError::Usage(format!(
145            "bulk-reclassify-outflow --kind {s:?} is out of scope: gift/donate need a per-row donee \
146             (and donate a qualified-appraisal FMV); use single-item `reclassify-outflow` instead"
147        ))),
148        _ => Err(CliError::Usage(format!(
149            "bad dispose kind {s:?} (expected sell|spend)"
150        ))),
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use btctax_core::{EventId, Fingerprint, IncomeKind, LotId, Source, SourceRef, WalletId};
158    use rust_decimal_macros::dec;
159    use time::macros::date;
160
161    #[test]
162    fn import_eventref_round_trips_even_with_pipe_in_source_ref() {
163        // Adapters mint direction-scoped source_refs that CONTAIN '|' (e.g. "out|cb-send").
164        let id = EventId::import(Source::Coinbase, SourceRef::new("out|cb-send"));
165        let s = id.canonical(); // "import|coinbase|out|cb-send"
166        assert_eq!(parse_event_id(&s).unwrap(), id);
167    }
168
169    #[test]
170    fn decision_and_conflict_eventrefs_round_trip() {
171        let d = EventId::decision(7);
172        assert_eq!(parse_event_id(&d.canonical()).unwrap(), d);
173
174        let fp = Fingerprint::of_bytes(b"x");
175        let c = EventId::conflict(Source::Gemini, SourceRef::new("in|99|credit|1#0"), &fp);
176        assert_eq!(parse_event_id(&c.canonical()).unwrap(), c);
177    }
178
179    #[test]
180    fn bad_eventref_is_a_typed_error() {
181        assert!(matches!(
182            parse_event_id("garbage"),
183            Err(crate::CliError::BadEventRef(_))
184        ));
185        assert!(matches!(
186            parse_event_id("import|nosuchsource|x"),
187            Err(crate::CliError::BadEventRef(_))
188        ));
189    }
190
191    #[test]
192    fn wallet_usd_date_kind_parsers() {
193        assert_eq!(
194            parse_wallet_id("exchange:coinbase:main").unwrap(),
195            WalletId::Exchange {
196                provider: "coinbase".into(),
197                account: "main".into()
198            }
199        );
200        assert_eq!(
201            parse_wallet_id("self:cold").unwrap(),
202            WalletId::SelfCustody {
203                label: "cold".into()
204            }
205        );
206        assert_eq!(parse_usd_arg("1234.56").unwrap(), dec!(1234.56));
207        assert_eq!(parse_date_arg("2025-01-01").unwrap(), date!(2025 - 01 - 01));
208        assert_eq!(parse_income_kind("interest").unwrap(), IncomeKind::Interest);
209        assert!(parse_wallet_id("bogus").is_err());
210    }
211
212    #[test]
213    fn parse_lot_id_round_trips_all_three_origin_variants() {
214        // Import origin (with a '|' in source_ref — adapters mint direction-scoped refs)
215        let l_imp = LotId {
216            origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("out|cb-send")),
217            split_sequence: 3,
218        };
219        let s = format!(
220            "{}#{}",
221            l_imp.origin_event_id.canonical(),
222            l_imp.split_sequence
223        );
224        assert_eq!(parse_lot_id(&s).unwrap(), l_imp);
225
226        // Decision origin (Path-B seed lots: origin = allocation Decision id, resolve.rs:570-574)
227        let l_dec = LotId {
228            origin_event_id: EventId::decision(7),
229            split_sequence: 1,
230        };
231        assert_eq!(
232            parse_lot_id(&format!("{}#1", EventId::decision(7).canonical())).unwrap(),
233            l_dec
234        );
235
236        // Conflict origin
237        let fp = Fingerprint::of_bytes(b"x");
238        let cid = EventId::conflict(Source::Gemini, SourceRef::new("in|99|credit"), &fp);
239        let l_con = LotId {
240            origin_event_id: cid.clone(),
241            split_sequence: 0,
242        };
243        assert_eq!(
244            parse_lot_id(&format!("{}#0", cid.canonical())).unwrap(),
245            l_con
246        );
247
248        // N1: a source_ref containing '#' must still round-trip — rsplit_once('#') splits on the
249        // LAST '#' (the split-sequence suffix is always last); locks the rsplit choice
250        // (cf. eventref.rs decision_and_conflict_eventrefs_round_trip: "in|99|credit|1#0").
251        let l_hash = LotId {
252            origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("in|99|credit|1#0")),
253            split_sequence: 2,
254        };
255        assert_eq!(
256            parse_lot_id(&format!(
257                "{}#{}",
258                l_hash.origin_event_id.canonical(),
259                l_hash.split_sequence
260            ))
261            .unwrap(),
262            l_hash
263        );
264    }
265
266    #[test]
267    fn parse_lot_pick_splits_trailing_sat() {
268        let pick = parse_lot_pick("import|coinbase|X#0:25000").unwrap();
269        assert_eq!(
270            pick.lot,
271            LotId {
272                origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("X")),
273                split_sequence: 0,
274            }
275        );
276        assert_eq!(pick.sat, 25_000);
277    }
278
279    #[test]
280    fn parse_lot_id_rejects_missing_hash() {
281        assert!(matches!(
282            parse_lot_id("import|coinbase|X"),
283            Err(crate::CliError::Usage(_))
284        ));
285    }
286
287    #[test]
288    fn parse_lot_pick_rejects_missing_colon() {
289        assert!(matches!(
290            parse_lot_pick("import|coinbase|X#0"),
291            Err(crate::CliError::Usage(_))
292        ));
293    }
294}