use crate::CliError;
use btctax_core::{
DisposeKind, EventId, Fingerprint, IncomeKind, LotId, LotPick, Source, SourceRef, TaxDate, Usd,
WalletId,
};
use rust_decimal::Decimal;
use std::str::FromStr;
use time::macros::format_description;
use time::Date;
fn source_of(tag: &str) -> Option<Source> {
match tag {
"swan" => Some(Source::Swan),
"coinbase" => Some(Source::Coinbase),
"gemini" => Some(Source::Gemini),
"river" => Some(Source::River),
_ => None,
}
}
pub fn parse_event_id(s: &str) -> Result<EventId, CliError> {
let bad = || CliError::BadEventRef(s.to_string());
let parts: Vec<&str> = s.split('|').collect();
match parts.first().copied() {
Some("import") => {
if parts.len() < 3 {
return Err(bad());
}
let source = source_of(parts[1]).ok_or_else(bad)?;
let source_ref = parts[2..].join("|"); Ok(EventId::import(source, SourceRef::new(source_ref)))
}
Some("conflict") => {
if parts.len() < 4 {
return Err(bad());
}
let source = source_of(parts[1]).ok_or_else(bad)?;
let fp = Fingerprint(parts[parts.len() - 1].to_string()); let source_ref = parts[2..parts.len() - 1].join("|");
Ok(EventId::conflict(source, SourceRef::new(source_ref), &fp))
}
Some("decision") => {
if parts.len() != 2 {
return Err(bad());
}
let seq = parts[1].parse::<u64>().map_err(|_| bad())?;
Ok(EventId::decision(seq))
}
_ => Err(bad()),
}
}
pub fn parse_wallet_id(s: &str) -> Result<WalletId, CliError> {
let parts: Vec<&str> = s.splitn(3, ':').collect();
match parts.as_slice() {
["exchange", provider, account] if !provider.is_empty() && !account.is_empty() => {
Ok(WalletId::Exchange {
provider: (*provider).to_string(),
account: (*account).to_string(),
})
}
["self", label] if !label.is_empty() => Ok(WalletId::SelfCustody {
label: (*label).to_string(),
}),
_ => Err(CliError::Usage(format!(
"bad wallet {s:?}; use exchange:PROVIDER:ACCOUNT or self:LABEL"
))),
}
}
pub fn parse_usd_arg(s: &str) -> Result<Usd, CliError> {
Decimal::from_str(s.trim()).map_err(|e| CliError::Usage(format!("bad USD {s:?}: {e}")))
}
pub fn parse_nonneg_usd_arg(s: &str, field: &str) -> Result<Usd, CliError> {
let v = parse_usd_arg(s)?;
if v < Decimal::ZERO {
return Err(CliError::Usage(format!(
"{field} must be >= 0 (got {v}); no legitimate negative cost basis / FMV / fee / price exists"
)));
}
Ok(v)
}
pub fn parse_pos_sell_arg(s: &str, field: &str) -> Result<btctax_core::Sat, CliError> {
let sat = btctax_core::whatif::parse_sell_arg(s)
.map_err(|e| CliError::Usage(format!("bad {field} {s:?}: {e}")))?;
if sat <= 0 {
return Err(CliError::Usage(format!(
"{field} must be > 0 sat (got {sat})"
)));
}
Ok(sat)
}
pub fn parse_date_arg(s: &str) -> Result<TaxDate, CliError> {
let fmt = format_description!("[year]-[month]-[day]");
Date::parse(s.trim(), &fmt).map_err(|e| CliError::Usage(format!("bad date {s:?}: {e}")))
}
pub fn parse_lot_id(s: &str) -> Result<LotId, CliError> {
let (origin, split) = s
.rsplit_once('#')
.ok_or_else(|| CliError::Usage(format!("bad lot id {s:?}; expected <event-id>#<split>")))?;
let origin_event_id = parse_event_id(origin)?;
let split_sequence = split
.trim()
.parse::<u32>()
.map_err(|_| CliError::Usage(format!("bad split_sequence in lot id {s:?}")))?;
Ok(LotId {
origin_event_id,
split_sequence,
})
}
pub fn parse_lot_pick(s: &str) -> Result<LotPick, CliError> {
let (lot_str, sat_str) = s
.rsplit_once(':')
.ok_or_else(|| CliError::Usage(format!("bad --from {s:?}; expected <lot-id>:<sat>")))?;
let lot = parse_lot_id(lot_str)?;
let sat = sat_str
.trim()
.parse::<i64>()
.map_err(|e| CliError::Usage(format!("bad sat in --from {s:?}: {e}")))?;
Ok(LotPick { lot, sat })
}
pub fn parse_income_kind(s: &str) -> Result<IncomeKind, CliError> {
match s.to_ascii_lowercase().as_str() {
"mining" => Ok(IncomeKind::Mining),
"staking" => Ok(IncomeKind::Staking),
"interest" => Ok(IncomeKind::Interest),
"airdrop" => Ok(IncomeKind::Airdrop),
"reward" => Ok(IncomeKind::Reward),
_ => Err(CliError::Usage(format!(
"bad income kind {s:?} — expected one of: mining, staking, interest, airdrop, reward"
))),
}
}
pub fn parse_dispose_kind(s: &str) -> Result<DisposeKind, CliError> {
match s.to_ascii_lowercase().as_str() {
"sell" => Ok(DisposeKind::Sell),
"spend" => Ok(DisposeKind::Spend),
"gift" | "donate" => Err(CliError::Usage(format!(
"bulk-reclassify-outflow --kind {s:?} is out of scope: gift/donate need a per-row donee \
(and donate a qualified-appraisal FMV); use single-item `reclassify-outflow` instead"
))),
_ => Err(CliError::Usage(format!(
"bad dispose kind {s:?} (expected sell|spend)"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use btctax_core::{EventId, Fingerprint, IncomeKind, LotId, Source, SourceRef, WalletId};
use rust_decimal_macros::dec;
use time::macros::date;
#[test]
fn import_eventref_round_trips_even_with_pipe_in_source_ref() {
let id = EventId::import(Source::Coinbase, SourceRef::new("out|cb-send"));
let s = id.canonical(); assert_eq!(parse_event_id(&s).unwrap(), id);
}
#[test]
fn parse_nonneg_usd_arg_refuses_negative_allows_zero_and_positive() {
assert!(parse_nonneg_usd_arg("-5000.00", "--basis").is_err());
assert!(parse_nonneg_usd_arg("-0.01", "--fmv").is_err());
assert_eq!(parse_nonneg_usd_arg("0", "--basis").unwrap(), dec!(0));
assert_eq!(
parse_nonneg_usd_arg("42000.50", "--fmv").unwrap(),
dec!(42000.50)
);
let err = format!(
"{:?}",
parse_nonneg_usd_arg("-1", "--donor-basis").unwrap_err()
);
assert!(
err.contains("--donor-basis") && err.contains(">= 0"),
"the refusal must name the flag + the rule: {err}"
);
}
#[test]
fn parse_pos_sell_arg_refuses_zero_and_negative() {
assert!(parse_pos_sell_arg("0", "--sell").is_err());
assert!(parse_pos_sell_arg("-100000000", "--sell").is_err());
assert!(parse_pos_sell_arg("100000000", "--sell").unwrap() > 0);
assert!(parse_pos_sell_arg("0.5", "--sell").unwrap() > 0); }
#[test]
fn decision_and_conflict_eventrefs_round_trip() {
let d = EventId::decision(7);
assert_eq!(parse_event_id(&d.canonical()).unwrap(), d);
let fp = Fingerprint::of_bytes(b"x");
let c = EventId::conflict(Source::Gemini, SourceRef::new("in|99|credit|1#0"), &fp);
assert_eq!(parse_event_id(&c.canonical()).unwrap(), c);
}
#[test]
fn bad_eventref_is_a_typed_error() {
assert!(matches!(
parse_event_id("garbage"),
Err(crate::CliError::BadEventRef(_))
));
assert!(matches!(
parse_event_id("import|nosuchsource|x"),
Err(crate::CliError::BadEventRef(_))
));
}
#[test]
fn wallet_usd_date_kind_parsers() {
assert_eq!(
parse_wallet_id("exchange:coinbase:main").unwrap(),
WalletId::Exchange {
provider: "coinbase".into(),
account: "main".into()
}
);
assert_eq!(
parse_wallet_id("self:cold").unwrap(),
WalletId::SelfCustody {
label: "cold".into()
}
);
assert_eq!(parse_usd_arg("1234.56").unwrap(), dec!(1234.56));
assert_eq!(parse_date_arg("2025-01-01").unwrap(), date!(2025 - 01 - 01));
assert_eq!(parse_income_kind("interest").unwrap(), IncomeKind::Interest);
assert!(parse_wallet_id("bogus").is_err());
}
#[test]
fn parse_lot_id_round_trips_all_three_origin_variants() {
let l_imp = LotId {
origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("out|cb-send")),
split_sequence: 3,
};
let s = format!(
"{}#{}",
l_imp.origin_event_id.canonical(),
l_imp.split_sequence
);
assert_eq!(parse_lot_id(&s).unwrap(), l_imp);
let l_dec = LotId {
origin_event_id: EventId::decision(7),
split_sequence: 1,
};
assert_eq!(
parse_lot_id(&format!("{}#1", EventId::decision(7).canonical())).unwrap(),
l_dec
);
let fp = Fingerprint::of_bytes(b"x");
let cid = EventId::conflict(Source::Gemini, SourceRef::new("in|99|credit"), &fp);
let l_con = LotId {
origin_event_id: cid.clone(),
split_sequence: 0,
};
assert_eq!(
parse_lot_id(&format!("{}#0", cid.canonical())).unwrap(),
l_con
);
let l_hash = LotId {
origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("in|99|credit|1#0")),
split_sequence: 2,
};
assert_eq!(
parse_lot_id(&format!(
"{}#{}",
l_hash.origin_event_id.canonical(),
l_hash.split_sequence
))
.unwrap(),
l_hash
);
}
#[test]
fn parse_lot_pick_splits_trailing_sat() {
let pick = parse_lot_pick("import|coinbase|X#0:25000").unwrap();
assert_eq!(
pick.lot,
LotId {
origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("X")),
split_sequence: 0,
}
);
assert_eq!(pick.sat, 25_000);
}
#[test]
fn parse_lot_id_rejects_missing_hash() {
assert!(matches!(
parse_lot_id("import|coinbase|X"),
Err(crate::CliError::Usage(_))
));
}
#[test]
fn parse_lot_pick_rejects_missing_colon() {
assert!(matches!(
parse_lot_pick("import|coinbase|X#0"),
Err(crate::CliError::Usage(_))
));
}
}