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!("bad income kind {s:?}"))),
131 }
132}
133
134pub 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 let id = EventId::import(Source::Coinbase, SourceRef::new("out|cb-send"));
163 let s = id.canonical(); 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 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 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 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 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}