mod fixtures;
use btctax_cli::{cmd, Session};
use btctax_core::{BlockerKind, EventPayload, InboundClass, OutflowClass, Severity};
use btctax_store::Passphrase;
use csv::Reader;
use std::fs::File;
use std::path::Path;
use time::macros::datetime;
fn pp() -> Passphrase {
Passphrase::new("pw".into())
}
#[test]
fn export_snapshot_writes_sqlite_and_csvs_and_backup_key() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(
&vault,
&pp(),
&[fixtures::coinbase_buy_sell_send(dir.path())],
)
.unwrap();
let out = dir.path().join("export");
let sqlite = cmd::admin::export_snapshot(&vault, &pp(), &out, None, None)
.unwrap()
.path;
assert!(sqlite.exists(), "snapshot.sqlite (store)");
let lots_path = out.join("lots.csv");
let disposals_path = out.join("disposals.csv");
let removals_path = out.join("removals.csv");
let income_path = out.join("income.csv");
assert!(lots_path.exists());
assert!(disposals_path.exists());
assert!(removals_path.exists());
assert!(income_path.exists());
let mut reader = Reader::from_reader(File::open(&lots_path).unwrap());
let records: Vec<_> = reader
.records()
.collect::<Result<Vec<_>, _>>()
.expect("Failed to read lots.csv records");
assert!(
!records.is_empty(),
"lots.csv must contain at least one data record"
);
let expected_remaining_sat = "5000000";
let found_expected = records
.iter()
.any(|rec| rec.get(4) == Some(expected_remaining_sat));
assert!(
found_expected,
"Expected to find a lot with remaining_sat={}, but none was found in lots.csv",
expected_remaining_sat
);
let mut dreader = Reader::from_reader(File::open(&disposals_path).unwrap());
let disposal_records: Vec<_> = dreader
.records()
.collect::<Result<Vec<_>, _>>()
.expect("Failed to read disposals.csv records");
assert!(
!disposal_records.is_empty(),
"disposals.csv must contain at least one data record (the Sell event)"
);
let term_col = disposal_records[0].get(8).expect("term column missing");
assert!(
term_col == "short" || term_col == "long",
"term column must use stable tag ('short'/'long'), got: {:?}",
term_col
);
assert_eq!(
term_col, "short",
"fixture sell (Mar→Jun 2025) must be short-term, got: {:?}",
term_col
);
let mut lreader2 = Reader::from_reader(File::open(&lots_path).unwrap());
let lot_records: Vec<_> = lreader2
.records()
.collect::<Result<Vec<_>, _>>()
.expect("Failed to re-read lots.csv records");
let bs_col = lot_records[0].get(6).expect("basis_source column missing");
assert_eq!(
bs_col, "exchange",
"basis_source column must use stable tag 'exchange' (not Debug 'ExchangeProvided'), got: {:?}",
bs_col
);
let key = dir.path().join("backup.asc");
cmd::admin::backup_key(&vault, &pp(), &key).unwrap();
assert!(key.exists());
}
#[test]
fn export_writes_year_scoped_form8949_and_schedule_d() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(
&vault,
&pp(),
&[fixtures::coinbase_buy_sell_send(dir.path())],
)
.unwrap();
let out_none = dir.path().join("export_none");
cmd::admin::export_snapshot(&vault, &pp(), &out_none, None, None).unwrap();
assert!(
!out_none.join("form8949.csv").exists(),
"form8949.csv must be omitted without --tax-year"
);
assert!(!out_none.join("schedule_d.csv").exists());
let out = dir.path().join("export_2025");
cmd::admin::export_snapshot(&vault, &pp(), &out, Some(2025), None).unwrap();
let f8949 = out.join("form8949.csv");
let schedd = out.join("schedule_d.csv");
assert!(
f8949.exists(),
"form8949.csv must be written for --tax-year"
);
assert!(
schedd.exists(),
"schedule_d.csv must be written for --tax-year"
);
let mut fr = Reader::from_reader(File::open(&f8949).unwrap());
let headers: Vec<String> = fr
.headers()
.unwrap()
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(
headers,
vec![
"part",
"box",
"box_needs_review",
"description",
"date_acquired",
"date_sold",
"proceeds",
"cost_basis",
"adjustment_code",
"adjustment_amount",
"gain",
"wallet",
"disposition_kind",
],
"form8949.csv columns must be the stable snake_case contract"
);
let recs: Vec<_> = fr.records().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(recs.len(), 1, "one ST sell leg → one Form 8949 row");
let row = &recs[0];
assert_eq!(row.get(0), Some("ST"), "part");
assert_eq!(row.get(1), Some("C"), "box (conservative C default)");
assert_eq!(
row.get(2),
Some("true"),
"exchange disposition → box_needs_review"
);
assert_eq!(row.get(3), Some("0.02000000 BTC"), "exact BTC description");
assert_eq!(row.get(8), Some(""), "adjustment_code blank");
assert_eq!(row.get(9), Some("0"), "adjustment_amount zero");
let mut sr = Reader::from_reader(File::open(&schedd).unwrap());
let sheaders: Vec<String> = sr
.headers()
.unwrap()
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(sheaders, vec!["part", "proceeds", "cost_basis", "gain"]);
let srecs: Vec<_> = sr.records().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(srecs.len(), 2, "Schedule D has two part rows (ST, LT)");
assert_eq!(srecs[0].get(0), Some("ST"));
assert_eq!(srecs[1].get(0), Some("LT"));
}
#[test]
fn removals_csv_multi_leg_donation_no_double_count() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
let now = datetime!(2026-04-01 12:00:00 UTC);
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(
&vault,
&pp(),
&[fixtures::coinbase_two_lot_donation(dir.path())],
)
.unwrap();
let out_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(
state.pending_reconciliation.len(),
1,
"Send must be pending before reclassification"
);
state.pending_reconciliation[0].event.canonical()
};
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::Donate {
appraisal_required: false,
},
btctax_cli::eventref::parse_usd_arg("100000.00").unwrap(),
None,
None,
now,
)
.unwrap();
let out = dir.path().join("export");
cmd::admin::export_snapshot(&vault, &pp(), &out, None, None).unwrap();
let removals_path = out.join("removals.csv");
assert!(removals_path.exists(), "removals.csv must exist");
const DED_COL: usize = 9;
let mut reader = Reader::from_reader(File::open(&removals_path).unwrap());
let records: Vec<_> = reader
.records()
.collect::<Result<Vec<_>, _>>()
.expect("Failed to read removals.csv records");
assert_eq!(
records.len(),
2,
"expected 2 removal leg rows (one per lot consumed by the donation); got {}",
records.len()
);
let mut hreader = Reader::from_reader(File::open(&removals_path).unwrap());
let header: Vec<String> = hreader
.headers()
.unwrap()
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(
header.get(8).map(String::as_str),
Some("acquired_at"),
"removals.csv must show the acquired_at column at index 8; header = {header:?}"
);
for rec in &records {
let acq = rec.get(8).expect("acquired_at cell missing");
assert!(
acq.contains('-') && !acq.is_empty(),
"acquired_at must be a populated date; got {acq:?}"
);
}
let row0_ded = records[0]
.get(DED_COL)
.expect("row 0 claimed_deduction missing");
let row1_ded = records[1]
.get(DED_COL)
.expect("row 1 claimed_deduction missing");
assert_eq!(
row0_ded, "52000.00",
"first leg row must carry the full claimed_deduction ($52,000); got {row0_ded:?}"
);
assert!(
row1_ded.is_empty(),
"second leg row must have an empty claimed_deduction (not {row1_ded:?}); \
a non-empty value means the deduction is double-counted in a spreadsheet SUM()"
);
let sum: f64 = records
.iter()
.filter_map(|r| r.get(DED_COL))
.filter(|s| !s.is_empty())
.map(|s| {
s.parse::<f64>()
.expect("non-empty claimed_deduction must be numeric")
})
.sum();
assert!(
(sum - 52_000.0_f64).abs() < 0.01,
"SUM of claimed_deduction column must equal $52,000 (no double-count); got {sum}"
);
}
#[test]
fn export_writes_form8283_with_section_b_and_aggregation_caveat() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
let now = datetime!(2026-04-01 12:00:00 UTC);
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(
&vault,
&pp(),
&[fixtures::coinbase_two_lot_donation(dir.path())],
)
.unwrap();
let out_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
state.pending_reconciliation[0].event.canonical()
};
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::Donate {
appraisal_required: false,
},
btctax_cli::eventref::parse_usd_arg("100000.00").unwrap(),
None,
None,
now,
)
.unwrap();
let out = dir.path().join("export_2026");
cmd::admin::export_snapshot(&vault, &pp(), &out, Some(2026), None).unwrap();
let f8283 = out.join("form8283.csv");
assert!(
f8283.exists(),
"form8283.csv must be written for --tax-year"
);
let raw = std::fs::read_to_string(&f8283).unwrap();
assert!(
raw.contains("\u{00a7}170(f)(11)(F) year-aggregate"),
"form8283.csv must carry the §170(f)(11)(F) year-aggregate aggregation note:\n{raw}"
);
assert!(
raw.starts_with("# "),
"note must be a leading comment line:\n{raw}"
);
assert!(
!raw.contains("[R0-M1]"),
"the $500 note must NOT appear when the total deduction exceeds $500:\n{raw}"
);
let mut rdr = csv::ReaderBuilder::new()
.comment(Some(b'#'))
.from_reader(File::open(&f8283).unwrap());
let headers: Vec<String> = rdr
.headers()
.unwrap()
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(
headers,
vec![
"section",
"description",
"how_acquired",
"date_acquired",
"date_contributed",
"cost_basis",
"fmv",
"claimed_deduction",
"fmv_method",
"donee",
"appraiser",
"needs_review",
"donee_ein",
"donee_address",
"appraiser_tin",
"appraiser_ptin",
"appraiser_qualifications",
"appraisal_date",
],
"form8283.csv columns must be the stable snake_case contract"
);
let recs: Vec<_> = rdr.records().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(recs.len(), 2, "two donation legs → two Form 8283 rows");
let section_b_rows = recs.iter().filter(|r| r.get(0) == Some("B")).count();
assert_eq!(
section_b_rows, 1,
"exactly one (first) leg row carries Section B; got {section_b_rows}"
);
for r in &recs {
assert_eq!(r.get(11), Some("true"), "needs_review must be true");
assert_eq!(r.get(9), Some(""), "donee blank");
assert_eq!(r.get(10), Some(""), "appraiser blank");
}
let carrier = recs
.iter()
.find(|r| r.get(0) == Some("B"))
.expect("carrier row");
assert_eq!(
carrier.get(8),
Some("qualified appraisal"),
"Section B carrier row must have fmv_method = 'qualified appraisal'"
);
let non_carrier = recs
.iter()
.find(|r| r.get(0) == Some(""))
.expect("non-carrier row");
assert_eq!(
non_carrier.get(8),
Some(""),
"non-carrier row must have fmv_method = '' (carrier convention)"
);
let deds: Vec<&str> = recs
.iter()
.filter_map(|r| r.get(7))
.filter(|s| !s.is_empty())
.collect();
assert_eq!(deds, vec!["52000.00"], "deduction on the first leg only");
}
#[test]
fn export_form8283_small_donation_shows_500_floor_note() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
let now = datetime!(2025-12-31 12:00:00 UTC);
let csv_path = dir.path().join("coinbase_small_donation.csv");
std::fs::write(
&csv_path,
"\r\nTransactions\r\nUser,00000000-0000-0000-0000-000000000000\r\n\
ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,\
Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes,Sender Address,Recipient Address\r\n\
sd-buy,2025-01-01 12:00:00 UTC,Buy,BTC,0.00100000,USD,100000.00,100.00,100.00,0.00,,,\r\n\
sd-send,2025-06-01 12:00:00 UTC,Send,BTC,0.00100000,USD,300.00,,,,,,bc1qsyntheticcharity\r\n",
)
.unwrap();
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(&vault, &pp(), &[csv_path]).unwrap();
let out_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
state.pending_reconciliation[0].event.canonical()
};
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::Donate {
appraisal_required: false,
},
btctax_cli::eventref::parse_usd_arg("300.00").unwrap(),
None,
None,
now,
)
.unwrap();
let out = dir.path().join("export_2025_small");
cmd::admin::export_snapshot(&vault, &pp(), &out, Some(2025), None).unwrap();
let raw = std::fs::read_to_string(out.join("form8283.csv")).unwrap();
assert!(
raw.contains("[R0-M1]") && raw.contains("<= $500"),
"small (≤ $500) donation total must carry the filing-floor note:\n{raw}"
);
let mut rdr = csv::ReaderBuilder::new()
.comment(Some(b'#'))
.from_reader(File::open(out.join("form8283.csv")).unwrap());
let recs: Vec<_> = rdr.records().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(
recs.len(),
1,
"the single donation leg row is still emitted"
);
assert_eq!(recs[0].get(0), Some("A"), "≤ $5k → Section A");
}
#[cfg(unix)]
#[test]
fn csv_exports_are_owner_only_on_pre_existing_dir() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(
&vault,
&pp(),
&[fixtures::coinbase_buy_sell_send(dir.path())],
)
.unwrap();
let out = dir.path().join("exports_pre");
std::fs::create_dir_all(&out).unwrap();
cmd::admin::export_snapshot(&vault, &pp(), &out, None, None).unwrap();
for name in ["lots.csv", "disposals.csv", "removals.csv", "income.csv"] {
let path = out.join(name);
assert!(path.exists(), "{name} must exist");
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"{name} must be owner-only (0o600), got {:#o}",
mode & 0o777
);
}
}
#[test]
fn removals_csv_has_donee_column_populated_and_empty() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
let now = datetime!(2026-04-01 12:00:00 UTC);
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(
&vault,
&pp(),
&[fixtures::coinbase_buy_sell_send(dir.path())],
)
.unwrap();
let out_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
state.pending_reconciliation[0].event.canonical()
};
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::GiftOut,
btctax_cli::eventref::parse_usd_arg("2100.00").unwrap(),
None,
Some("Alice".to_string()), now,
)
.unwrap();
let out = dir.path().join("export_donee");
cmd::admin::export_snapshot(&vault, &pp(), &out, None, None).unwrap();
let removals_path = out.join("removals.csv");
assert!(removals_path.exists(), "removals.csv must exist");
let mut rdr = Reader::from_reader(File::open(&removals_path).unwrap());
let header: Vec<String> = rdr
.headers()
.unwrap()
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(
header.get(10).map(String::as_str),
Some("donee"),
"removals.csv must have 'donee' header at column 10; got: {header:?}"
);
let records: Vec<_> = rdr
.records()
.collect::<Result<Vec<_>, _>>()
.expect("removals.csv must parse");
assert!(
!records.is_empty(),
"expected at least one removal row in removals.csv"
);
let donee_cell = records[0].get(10).expect("donee column missing on row 0");
assert_eq!(
donee_cell, "Alice",
"GiftOut with donee 'Alice' must appear in removals.csv donee column; got {donee_cell:?}"
);
}
#[test]
fn engine_b_tax_math_unchanged_by_donee() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
let now = datetime!(2026-04-01 12:00:00 UTC);
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(
&vault,
&pp(),
&[fixtures::coinbase_buy_sell_send(dir.path())],
)
.unwrap();
let baseline_disposals = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
state.disposals.clone()
};
let out_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
state.pending_reconciliation[0].event.canonical()
};
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::GiftOut,
btctax_cli::eventref::parse_usd_arg("2100.00").unwrap(),
None,
Some("Alice".to_string()),
now,
)
.unwrap();
let after_disposals = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
state.disposals.clone()
};
assert_eq!(
baseline_disposals.len(),
after_disposals.len(),
"donee on a gift must NOT change disposal count"
);
for (before, after) in baseline_disposals.iter().zip(after_disposals.iter()) {
assert_eq!(
before.legs.len(),
after.legs.len(),
"disposal leg count must be unchanged by donee"
);
for (bl, al) in before.legs.iter().zip(after.legs.iter()) {
assert_eq!(bl.gain, al.gain, "disposal gain must be unchanged by donee");
assert_eq!(
bl.basis, al.basis,
"disposal basis must be unchanged by donee"
);
assert_eq!(
bl.proceeds, al.proceeds,
"disposal proceeds must be unchanged by donee"
);
}
}
}
fn run_export_bin(vault: &Path, out: &Path, tax_year: Option<i32>) -> (i32, String) {
let bin = env!("CARGO_BIN_EXE_btctax");
let mut command = std::process::Command::new(bin);
command
.arg("--vault")
.arg(vault.to_str().expect("vault path is valid UTF-8"))
.arg("export-snapshot")
.arg("--out")
.arg(out.to_str().expect("out path is valid UTF-8"));
if let Some(y) = tax_year {
command.arg("--tax-year").arg(y.to_string());
}
let output = command
.env("BTCTAX_PASSPHRASE", "pw")
.output()
.expect("btctax binary must execute");
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let code = output
.status
.code()
.expect("btctax process must exit normally (not via signal)");
(code, stderr)
}
#[test]
fn export_report_counts_only_hard() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
let now = datetime!(2026-04-01 12:00:00 UTC);
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(&vault, &pp(), &[fixtures::coinbase_buy_receive(dir.path())]).unwrap();
let in_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
events
.iter()
.find(|e| matches!(e.payload, EventPayload::TransferIn(_)))
.expect("the Receive must fold to a TransferIn")
.id
.canonical()
};
cmd::reconcile::classify_inbound(
&vault,
&pp(),
&in_ref,
InboundClass::SelfTransferMine {
basis: None,
acquired_at: None,
},
now,
)
.unwrap();
{
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state
.blockers
.iter()
.any(|b| b.kind == BlockerKind::SelfTransferInboundZeroBasis),
"the zero-basis Advisory must be present"
);
assert!(
state
.blockers
.iter()
.all(|b| b.kind.severity() != Severity::Hard),
"an Advisory-only ledger has no Hard blocker"
);
}
let out = dir.path().join("export_advisory");
let report = cmd::admin::export_snapshot(&vault, &pp(), &out, None, None).unwrap();
assert_eq!(
report.unresolved_hard, 0,
"a real Advisory (SelfTransferInboundZeroBasis) must NOT count as a Hard blocker"
);
let dir2 = tempfile::tempdir().unwrap();
let vault2 = dir2.path().join("vault.pgp");
cmd::init::run(&vault2, &pp(), &dir2.path().join("k.asc")).unwrap();
cmd::import::run(
&vault2,
&pp(),
&[fixtures::coinbase_buy_receive(dir2.path())],
)
.unwrap();
let out2 = dir2.path().join("export_hard");
let report2 = cmd::admin::export_snapshot(&vault2, &pp(), &out2, None, None).unwrap();
assert!(
report2.unresolved_hard >= 1,
"an unclassified inbound (UnknownBasisInbound) is a Hard blocker and must be counted; got {}",
report2.unresolved_hard
);
}
#[test]
fn export_report_path_points_at_snapshot() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(
&vault,
&pp(),
&[fixtures::coinbase_buy_sell_send(dir.path())],
)
.unwrap();
let out = dir.path().join("export");
let report = cmd::admin::export_snapshot(&vault, &pp(), &out, None, None).unwrap();
assert!(report.path.exists(), "the returned path must exist");
assert_eq!(
report.path.file_name().and_then(|n| n.to_str()),
Some("snapshot.sqlite"),
"report.path must point at the store snapshot.sqlite; got {:?}",
report.path
);
assert!(
report.path.starts_with(&out),
"the snapshot must live inside the requested out-dir"
);
}
#[test]
fn export_still_writes_files_with_blockers() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(&vault, &pp(), &[fixtures::coinbase_buy_receive(dir.path())]).unwrap();
let out = dir.path().join("export_blocked");
let report = cmd::admin::export_snapshot(&vault, &pp(), &out, Some(2025), None).unwrap();
assert!(
report.unresolved_hard >= 1,
"the unclassified inbound is a Hard blocker"
);
for name in [
"snapshot.sqlite",
"lots.csv",
"disposals.csv",
"removals.csv",
"income.csv",
"form8949.csv",
"schedule_d.csv",
] {
assert!(
out.join(name).exists(),
"{name} must still be written despite the Hard blocker"
);
}
}
#[test]
fn export_with_hard_blockers_warns_on_stderr() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(&vault, &pp(), &[fixtures::coinbase_buy_receive(dir.path())]).unwrap();
let n = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
state
.blockers
.iter()
.filter(|b| b.kind.severity() == Severity::Hard)
.count()
};
assert!(n >= 1, "the fixture must carry at least one Hard blocker");
let out = dir.path().join("export_warn");
let (code, stderr) = run_export_bin(&vault, &out, Some(2025));
assert_eq!(
code, 0,
"export is a disclosure, not a refusal — exit 0; stderr: {stderr}"
);
assert!(
stderr.contains("NOT COMPUTABLE"),
"stderr must flag the year NOT COMPUTABLE; got: {stderr}"
);
assert!(
stderr.contains(&format!("{n} unresolved Hard blocker")),
"stderr must carry the exact unresolved-Hard count ({n}); got: {stderr}"
);
assert!(
stderr.contains("verify"),
"stderr must direct the user to `btctax verify`; got: {stderr}"
);
assert!(
stderr.contains("INFORMATIONAL, not final"),
"stderr must state the forms are INFORMATIONAL, not final; got: {stderr}"
);
assert!(
out.join("snapshot.sqlite").exists(),
"snapshot must be written"
);
assert!(
out.join("form8949.csv").exists(),
"form8949.csv must be written"
);
}
#[test]
fn export_full_no_year_warns_informational() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(&vault, &pp(), &[fixtures::coinbase_buy_receive(dir.path())]).unwrap();
let out = dir.path().join("export_full");
let (code, stderr) = run_export_bin(&vault, &out, None);
assert_eq!(code, 0, "exit 0; stderr: {stderr}");
assert!(
stderr.contains("INFORMATIONAL, not final"),
"the full-export warning must say INFORMATIONAL, not final; got: {stderr}"
);
assert!(
stderr.contains("NOT COMPUTABLE"),
"the full-export warning must say every affected year is NOT COMPUTABLE; got: {stderr}"
);
assert!(
stderr.contains("figures"),
"the no-year message must say 'figures' (projection CSVs), not 'forms'; got: {stderr}"
);
}
#[test]
fn export_clean_ledger_no_warning() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(
&vault,
&pp(),
&[fixtures::coinbase_buy_sell_send(dir.path())],
)
.unwrap();
{
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state
.blockers
.iter()
.all(|b| b.kind.severity() != Severity::Hard),
"the clean fixture must carry zero Hard blockers"
);
}
let out = dir.path().join("export_clean");
let (code, stderr) = run_export_bin(&vault, &out, Some(2025));
assert_eq!(code, 0, "clean export exits 0; stderr: {stderr}");
assert!(
!stderr.contains('\u{26a0}'),
"a clean ledger must print NO ⚠ warning; got: {stderr}"
);
assert!(
!stderr.contains("NOT COMPUTABLE"),
"a clean ledger must not flag NOT COMPUTABLE; got: {stderr}"
);
}