1use 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("|"); 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()); 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
57pub 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
76pub 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_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
98pub 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
116pub 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
137pub 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
166pub 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 let id = EventId::import(Source::Coinbase, SourceRef::new("out|cb-send"));
195 let s = id.canonical(); assert_eq!(parse_event_id(&s).unwrap(), id);
197 }
198
199 #[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 #[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); }
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 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 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 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 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}