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
81/// Parse a USD amount that must be **>= 0** — a cost basis, FMV, proceeds, fee, or price (UX-P4-4a). A
82/// DISTINCT helper (NOT `parse_usd_arg`), so the legitimately-signed flags — MAGI, `--other-net-capital-gain`,
83/// the ad-hoc `--income`/`--magi` — keep the unguarded parser (guard per-flag, never in the shared parser;
84/// SPEC §3.3(a) `[G-I5, T-M1]`). No legitimate negative exists: §1012 basis, §1016 adjustments floor at zero,
85/// §301(c)(2)-(3)/§733 excess-of-basis is *gain* — never negative basis. Zero stays allowed (the app's
86/// conservative self-transfer default). This ALSO closes the `--basis=-N` clap `=`-form bypass, since the
87/// guard is on the parsed value, not clap's `-`-prefix detection. `field` names the flag in the refusal.
88pub fn parse_nonneg_usd_arg(s: &str, field: &str) -> Result<Usd, CliError> {
89    let v = parse_usd_arg(s)?;
90    if v < Decimal::ZERO {
91        return Err(CliError::Usage(format!(
92            "{field} must be >= 0 (got {v}); no legitimate negative cost basis / FMV / fee / price exists"
93        )));
94    }
95    Ok(v)
96}
97
98/// Parse a `--sell` sats amount that must be **> 0** (UX-P4-4a `[G2-1]`): a `<= 0` sell survives the pool
99/// coverage check and renders a fictional LOSS estimate. Wraps the core `parse_sell_arg` (no sign guard).
100pub fn parse_pos_sell_arg(s: &str, field: &str) -> Result<btctax_core::Sat, CliError> {
101    let sat = btctax_core::whatif::parse_sell_arg(s)
102        .map_err(|e| CliError::Usage(format!("bad {field} {s:?}: {e}")))?;
103    if sat <= 0 {
104        return Err(CliError::Usage(format!(
105            "{field} must be > 0 sat (got {sat})"
106        )));
107    }
108    Ok(sat)
109}
110
111pub fn parse_date_arg(s: &str) -> Result<TaxDate, CliError> {
112    let fmt = format_description!("[year]-[month]-[day]");
113    Date::parse(s.trim(), &fmt).map_err(|e| CliError::Usage(format!("bad date {s:?}: {e}")))
114}
115
116/// `<event-id-canonical>#<split-sequence>` → `LotId`.
117///
118/// Uses `rsplit_once('#')` so that a source_ref containing '#' (e.g. Conflict fingerprint or a
119/// semantic key like `in|1234|credit|1#0`) is handled correctly: the split-sequence suffix is
120/// always the LAST `#`-separated segment, and `EventId::canonical()` uses `|` as its separator
121/// (never `#`), so peeling from the rightmost `#` is unambiguous.
122pub fn parse_lot_id(s: &str) -> Result<LotId, CliError> {
123    let (origin, split) = s
124        .rsplit_once('#')
125        .ok_or_else(|| CliError::Usage(format!("bad lot id {s:?}; expected <event-id>#<split>")))?;
126    let origin_event_id = parse_event_id(origin)?;
127    let split_sequence = split
128        .trim()
129        .parse::<u32>()
130        .map_err(|_| CliError::Usage(format!("bad split_sequence in lot id {s:?}")))?;
131    Ok(LotId {
132        origin_event_id,
133        split_sequence,
134    })
135}
136
137/// `<lot-id>:<sat>` → `LotPick`.
138///
139/// Uses `rsplit_once(':')` so that the `<lot-id>` portion (which uses `|` and `#` as separators,
140/// never `:`) stays intact. The trailing `:<sat>` is always unambiguous.
141pub fn parse_lot_pick(s: &str) -> Result<LotPick, CliError> {
142    let (lot_str, sat_str) = s
143        .rsplit_once(':')
144        .ok_or_else(|| CliError::Usage(format!("bad --from {s:?}; expected <lot-id>:<sat>")))?;
145    let lot = parse_lot_id(lot_str)?;
146    let sat = sat_str
147        .trim()
148        .parse::<i64>()
149        .map_err(|e| CliError::Usage(format!("bad sat in --from {s:?}: {e}")))?;
150    Ok(LotPick { lot, sat })
151}
152
153pub fn parse_income_kind(s: &str) -> Result<IncomeKind, CliError> {
154    match s.to_ascii_lowercase().as_str() {
155        "mining" => Ok(IncomeKind::Mining),
156        "staking" => Ok(IncomeKind::Staking),
157        "interest" => Ok(IncomeKind::Interest),
158        "airdrop" => Ok(IncomeKind::Airdrop),
159        "reward" => Ok(IncomeKind::Reward),
160        _ => Err(CliError::Usage(format!(
161            "bad income kind {s:?} — expected one of: mining, staking, interest, airdrop, reward"
162        ))),
163    }
164}
165
166/// Parse a bulk-reclassify-outflow `--kind` argument. Accepts ONLY `sell`/`spend`; REJECTS `gift` and
167/// `donate` (and any other value) — the structural scope-lock [Q2]: `GiftOut`/`Donate` need a per-row
168/// `donee` and (for Donate) a qualified-appraisal FMV, so a uniform-across-many-rows bulk substitution
169/// of a market close price is semantically wrong. Those stay single-item (`reclassify-outflow`).
170pub fn parse_dispose_kind(s: &str) -> Result<DisposeKind, CliError> {
171    match s.to_ascii_lowercase().as_str() {
172        "sell" => Ok(DisposeKind::Sell),
173        "spend" => Ok(DisposeKind::Spend),
174        "gift" | "donate" => Err(CliError::Usage(format!(
175            "bulk-reclassify-outflow --kind {s:?} is out of scope: gift/donate need a per-row donee \
176             (and donate a qualified-appraisal FMV); use single-item `reclassify-outflow` instead"
177        ))),
178        _ => Err(CliError::Usage(format!(
179            "bad dispose kind {s:?} (expected sell|spend)"
180        ))),
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use btctax_core::{EventId, Fingerprint, IncomeKind, LotId, Source, SourceRef, WalletId};
188    use rust_decimal_macros::dec;
189    use time::macros::date;
190
191    #[test]
192    fn import_eventref_round_trips_even_with_pipe_in_source_ref() {
193        // Adapters mint direction-scoped source_refs that CONTAIN '|' (e.g. "out|cb-send").
194        let id = EventId::import(Source::Coinbase, SourceRef::new("out|cb-send"));
195        let s = id.canonical(); // "import|coinbase|out|cb-send"
196        assert_eq!(parse_event_id(&s).unwrap(), id);
197    }
198
199    /// UX-P4-4a: the non-negative money guard — no legitimate negative basis/FMV/fee/price exists, and the
200    /// `--basis=-N` clap `=`-form bypass is closed because the guard is on the PARSED value. Zero (the app's
201    /// conservative self-transfer default) and positive are allowed. (★ fault-inject: drop the
202    /// `< Decimal::ZERO` check and this goes RED.)
203    #[test]
204    fn parse_nonneg_usd_arg_refuses_negative_allows_zero_and_positive() {
205        assert!(parse_nonneg_usd_arg("-5000.00", "--basis").is_err());
206        assert!(parse_nonneg_usd_arg("-0.01", "--fmv").is_err());
207        assert_eq!(parse_nonneg_usd_arg("0", "--basis").unwrap(), dec!(0));
208        assert_eq!(
209            parse_nonneg_usd_arg("42000.50", "--fmv").unwrap(),
210            dec!(42000.50)
211        );
212        let err = format!(
213            "{:?}",
214            parse_nonneg_usd_arg("-1", "--donor-basis").unwrap_err()
215        );
216        assert!(
217            err.contains("--donor-basis") && err.contains(">= 0"),
218            "the refusal must name the flag + the rule: {err}"
219        );
220    }
221
222    /// UX-P4-4a [G2-1]: `--sell` must be > 0 — a `<= 0` sell survives the pool-coverage check and renders a
223    /// fictional LOSS. (★ fault-inject: drop the `sat <= 0` check and this goes RED.)
224    #[test]
225    fn parse_pos_sell_arg_refuses_zero_and_negative() {
226        assert!(parse_pos_sell_arg("0", "--sell").is_err());
227        assert!(parse_pos_sell_arg("-100000000", "--sell").is_err());
228        assert!(parse_pos_sell_arg("100000000", "--sell").unwrap() > 0);
229        assert!(parse_pos_sell_arg("0.5", "--sell").unwrap() > 0); // BTC decimal form
230    }
231
232    #[test]
233    fn decision_and_conflict_eventrefs_round_trip() {
234        let d = EventId::decision(7);
235        assert_eq!(parse_event_id(&d.canonical()).unwrap(), d);
236
237        let fp = Fingerprint::of_bytes(b"x");
238        let c = EventId::conflict(Source::Gemini, SourceRef::new("in|99|credit|1#0"), &fp);
239        assert_eq!(parse_event_id(&c.canonical()).unwrap(), c);
240    }
241
242    #[test]
243    fn bad_eventref_is_a_typed_error() {
244        assert!(matches!(
245            parse_event_id("garbage"),
246            Err(crate::CliError::BadEventRef(_))
247        ));
248        assert!(matches!(
249            parse_event_id("import|nosuchsource|x"),
250            Err(crate::CliError::BadEventRef(_))
251        ));
252    }
253
254    #[test]
255    fn wallet_usd_date_kind_parsers() {
256        assert_eq!(
257            parse_wallet_id("exchange:coinbase:main").unwrap(),
258            WalletId::Exchange {
259                provider: "coinbase".into(),
260                account: "main".into()
261            }
262        );
263        assert_eq!(
264            parse_wallet_id("self:cold").unwrap(),
265            WalletId::SelfCustody {
266                label: "cold".into()
267            }
268        );
269        assert_eq!(parse_usd_arg("1234.56").unwrap(), dec!(1234.56));
270        assert_eq!(parse_date_arg("2025-01-01").unwrap(), date!(2025 - 01 - 01));
271        assert_eq!(parse_income_kind("interest").unwrap(), IncomeKind::Interest);
272        assert!(parse_wallet_id("bogus").is_err());
273    }
274
275    #[test]
276    fn parse_lot_id_round_trips_all_three_origin_variants() {
277        // Import origin (with a '|' in source_ref — adapters mint direction-scoped refs)
278        let l_imp = LotId {
279            origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("out|cb-send")),
280            split_sequence: 3,
281        };
282        let s = format!(
283            "{}#{}",
284            l_imp.origin_event_id.canonical(),
285            l_imp.split_sequence
286        );
287        assert_eq!(parse_lot_id(&s).unwrap(), l_imp);
288
289        // Decision origin (Path-B seed lots: origin = allocation Decision id, resolve.rs:570-574)
290        let l_dec = LotId {
291            origin_event_id: EventId::decision(7),
292            split_sequence: 1,
293        };
294        assert_eq!(
295            parse_lot_id(&format!("{}#1", EventId::decision(7).canonical())).unwrap(),
296            l_dec
297        );
298
299        // Conflict origin
300        let fp = Fingerprint::of_bytes(b"x");
301        let cid = EventId::conflict(Source::Gemini, SourceRef::new("in|99|credit"), &fp);
302        let l_con = LotId {
303            origin_event_id: cid.clone(),
304            split_sequence: 0,
305        };
306        assert_eq!(
307            parse_lot_id(&format!("{}#0", cid.canonical())).unwrap(),
308            l_con
309        );
310
311        // N1: a source_ref containing '#' must still round-trip — rsplit_once('#') splits on the
312        // LAST '#' (the split-sequence suffix is always last); locks the rsplit choice
313        // (cf. eventref.rs decision_and_conflict_eventrefs_round_trip: "in|99|credit|1#0").
314        let l_hash = LotId {
315            origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("in|99|credit|1#0")),
316            split_sequence: 2,
317        };
318        assert_eq!(
319            parse_lot_id(&format!(
320                "{}#{}",
321                l_hash.origin_event_id.canonical(),
322                l_hash.split_sequence
323            ))
324            .unwrap(),
325            l_hash
326        );
327    }
328
329    #[test]
330    fn parse_lot_pick_splits_trailing_sat() {
331        let pick = parse_lot_pick("import|coinbase|X#0:25000").unwrap();
332        assert_eq!(
333            pick.lot,
334            LotId {
335                origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("X")),
336                split_sequence: 0,
337            }
338        );
339        assert_eq!(pick.sat, 25_000);
340    }
341
342    #[test]
343    fn parse_lot_id_rejects_missing_hash() {
344        assert!(matches!(
345            parse_lot_id("import|coinbase|X"),
346            Err(crate::CliError::Usage(_))
347        ));
348    }
349
350    #[test]
351    fn parse_lot_pick_rejects_missing_colon() {
352        assert!(matches!(
353            parse_lot_pick("import|coinbase|X#0"),
354            Err(crate::CliError::Usage(_))
355        ));
356    }
357}