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_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
86pub 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
107pub 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
136pub 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 let id = EventId::import(Source::Coinbase, SourceRef::new("out|cb-send"));
165 let s = id.canonical(); 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 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 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 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 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}