use btctax_cli::cmd;
use btctax_store::Passphrase;
use std::path::{Path, PathBuf};
fn pp() -> Passphrase {
Passphrase::new("pw".into())
}
const BIN: &str = env!("CARGO_BIN_EXE_btctax");
fn init_vault(dir: &Path) -> PathBuf {
let vault = dir.join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.join("k.asc")).unwrap();
vault
}
fn lot_vault(dir: &Path) -> PathBuf {
let csv = dir.join("buys.csv");
std::fs::write(
&csv,
"\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\
b1,2024-01-15 12:00:00 UTC,Buy,BTC,1.00000000,USD,30000.00,30000.00,30000.00,0.00,,,\r\n\
b2,2024-03-01 12:00:00 UTC,Buy,BTC,1.00000000,USD,40000.00,40000.00,40000.00,0.00,,,\r\n",
)
.unwrap();
let vault = dir.join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.join("k.asc")).unwrap();
cmd::import::run(&vault, &pp(), &[csv]).unwrap();
vault
}
fn run(vault: &Path, args: &[&str]) -> (i32, String, String) {
let out = std::process::Command::new(BIN)
.arg("--vault")
.arg(vault.to_str().unwrap())
.args(args)
.env("BTCTAX_PASSPHRASE", "pw")
.output()
.expect("btctax binary must execute");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn assert_refused(vault: &Path, args: &[&str], flag: &str, rule: &str) {
let (code, _stdout, stderr) = run(vault, args);
assert_ne!(code, 0, "{args:?} must be refused; stderr: {stderr}");
assert!(
stderr.contains(flag) && stderr.contains(rule),
"the refusal for {args:?} must name {flag:?} + {rule:?}; stderr: {stderr}"
);
}
#[test]
fn record_time_value_guards_are_wired_across_the_dispatch_surface() {
let dir = tempfile::tempdir().unwrap();
let v = init_vault(dir.path());
assert_refused(
&v,
&[
"reconcile",
"classify-inbound-income",
"ref",
"--kind",
"reward",
"--fmv=-1",
],
"--fmv",
">= 0",
);
assert_refused(
&v,
&[
"reconcile",
"classify-inbound-gift",
"ref",
"--fmv-at-gift=-1",
],
"--fmv-at-gift",
">= 0",
);
assert_refused(
&v,
&[
"reconcile",
"classify-inbound-gift",
"ref",
"--fmv-at-gift",
"100",
"--donor-basis=-1",
],
"--donor-basis",
">= 0",
);
assert_refused(
&v,
&[
"reconcile",
"classify-inbound-self-transfer",
"ref",
"--basis=-1",
],
"--basis",
">= 0",
);
assert_refused(
&v,
&[
"reconcile",
"reclassify-outflow",
"ref",
"--as-kind",
"sell",
"--amount=-1",
],
"--amount",
">= 0",
);
assert_refused(
&v,
&[
"reconcile",
"reclassify-outflow",
"ref",
"--as-kind",
"sell",
"--amount",
"100",
"--fee=-1",
],
"--fee",
">= 0",
);
assert_refused(
&v,
&["reconcile", "set-fmv", "ref", "--fmv=-1"],
"--fmv",
">= 0",
);
assert_refused(
&v,
&["what-if", "sell", "--sell=-1", "--wallet", "self:cold"],
"--sell",
"> 0",
);
assert_refused(
&v,
&["optimize", "consult", "--sell=-1", "--wallet", "self:cold"],
"--sell",
"> 0",
);
assert_refused(
&v,
&[
"what-if",
"sell",
"--sell=1000",
"--wallet",
"self:cold",
"--price=-1",
],
"--price",
">= 0",
);
assert_refused(
&v,
&[
"optimize",
"consult",
"--sell=1000",
"--wallet",
"self:cold",
"--proceeds=-1",
],
"--proceeds",
">= 0",
);
assert_refused(
&v,
&[
"what-if",
"sell",
"--sell=1000",
"--wallet",
"self:cold",
"--filing-status",
"single",
"--income",
"50000",
"--carryforward-in=-1",
],
"--carryforward-in",
">= 0",
);
assert_refused(
&v,
&[
"what-if",
"harvest",
"--target",
"zero-ltcg",
"--wallet",
"self:cold",
"--price=-1",
],
"--price",
">= 0",
);
assert_refused(
&v,
&[
"what-if",
"harvest",
"--target",
"zero-ltcg",
"--wallet",
"self:cold",
"--filing-status",
"single",
"--income",
"50000",
"--carryforward-in=-1",
],
"--carryforward-in",
">= 0",
);
}
#[test]
fn adhoc_negative_income_and_magi_are_accepted() {
let dir = tempfile::tempdir().unwrap();
let v = lot_vault(dir.path());
let sell = |income: &str| -> (i32, String, String) {
let income_flag = format!("--income={income}");
run(
&v,
&[
"what-if",
"sell",
"--sell=10000000",
"--wallet",
"exchange:coinbase:default",
"--at",
"2025-06-01",
"--filing-status",
"single",
&income_flag,
],
)
};
let (code, out_neg, stderr) = sell("-5000");
assert_eq!(
code, 0,
"negative --income must be accepted + computed; stderr: {stderr}"
);
assert!(
!stderr.contains("--income must be"),
"negative --income must NOT be sign-refused: {stderr}"
);
let (code_hi, out_hi, _) = sell("400000");
assert_eq!(code_hi, 0);
assert_ne!(
out_neg, out_hi,
"the negative --income must change the plan vs a high income — it must flow into the \
marginal computation, not be parsed-then-dropped"
);
let (code, _out, stderr) = run(
&v,
&[
"what-if",
"sell",
"--sell=10000000",
"--wallet",
"exchange:coinbase:default",
"--at",
"2025-06-01",
"--filing-status",
"single",
"--income",
"50000",
"--magi=-5000",
],
);
assert_eq!(
code, 0,
"negative --magi must be accepted + computed; stderr: {stderr}"
);
assert!(
!stderr.contains("--magi must be"),
"negative --magi must NOT be sign-refused: {stderr}"
);
}