btctax-cli 0.13.0

btctax — an offline, single-user US Bitcoin tax ledger (CLI: import, reconcile, and compute).
Documentation
//! UX-P4-8 — I/O failures at a user-named path (`--vault`, `--out`) must NAME the path and offer a
//! one-clause remedy hint, instead of the bare `io: No such file or directory (os error 2)` /
//! `io: File exists (os error 17)` the raw `io::Error` produces. Mirrors the adapters' path-bearing
//! `AdapterError::Io { path, source }`.
mod fixtures;
use btctax_cli::{cmd, Session};
use btctax_store::Passphrase;

fn pp() -> Passphrase {
    Passphrase::new("pw".into())
}

/// A missing (or wrong) `--vault` names the path the user gave AND hints how to recover — check the
/// `--vault` flag, or run `btctax init` to create one — not a bare pathless `io` error.
#[test]
fn open_missing_vault_names_path_and_hint() {
    let dir = tempfile::tempdir().unwrap();
    let vault = dir.path().join("does_not_exist.pgp");
    let err = Session::open(&vault, &pp()).expect_err("a missing vault must error");
    let msg = err.to_string();
    assert!(
        msg.contains(&vault.display().to_string()),
        "names the vault path: {msg}"
    );
    assert!(msg.contains("--vault"), "hints the --vault flag: {msg}");
    assert!(msg.contains("init"), "hints `btctax init`: {msg}");
    assert!(
        !msg.contains("No such file or directory (os error 2)")
            || msg.contains(&vault.display().to_string()),
        "the raw errno may remain as the source, but the path must be present: {msg}"
    );
}

/// An `--out` that collides with an existing FILE (so the export directory cannot be created) names
/// the offending out path, not a bare `io: File exists`.
#[test]
fn export_out_collision_names_path() {
    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();

    // The chosen --out already exists as a plain file → `mkdir_owner_only` cannot create the dir.
    let out = dir.path().join("collide");
    std::fs::write(&out, b"i am a file, not a directory").unwrap();

    let err = cmd::admin::export_snapshot(&vault, &pp(), &out, None, None)
        .expect_err("an --out that collides with a file must error");
    let msg = err.to_string();
    assert!(
        msg.contains(&out.display().to_string()),
        "names the --out path: {msg}"
    );
    assert!(
        msg.contains("does not already exist as a file"),
        "carries the export-out hint (fold M2): {msg}"
    );
}

/// UX-P4-8 (fold r2-I1): a SUBPATH collision under a valid `--out` — here `out/lots.csv` already
/// exists as a directory, so `write_csv_exports`' `open_owner_only` fails AFTER the store's snapshot
/// write — still names the out path. This failure arrives as `CliError::Store(StoreError::Io)` (not
/// `CliError::Io`), the exact class the r1 wrap silently passed through.
#[test]
fn export_out_subpath_collision_names_path() {
    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();

    // A valid out dir (the store snapshot succeeds), but `out/lots.csv` is a DIRECTORY → the CSV
    // writer's `open_owner_only(out/lots.csv)` fails with `IsADirectory`.
    let out = dir.path().join("export");
    std::fs::create_dir(&out).unwrap();
    std::fs::create_dir(out.join("lots.csv")).unwrap();

    let err = cmd::admin::export_snapshot(&vault, &pp(), &out, None, None)
        .expect_err("a lots.csv-as-directory subpath collision must error");
    let msg = err.to_string();
    assert!(
        msg.contains(&out.display().to_string()),
        "names the --out path even for a Store(Io) subpath failure: {msg}"
    );
    assert!(
        msg.contains("does not already exist"),
        "carries the export-out hint: {msg}"
    );
}

/// UX-P4-8 (fold I2): a `backup-key --out` that cannot be written (here: `--out` is an existing
/// directory) names the out path, not a bare `io: Is a directory`.
#[test]
fn backup_key_out_collision_names_path() {
    let dir = tempfile::tempdir().unwrap();
    let vault = dir.path().join("vault.pgp");
    cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
    let out = dir.path().join("outdir");
    std::fs::create_dir(&out).unwrap(); // writing the key file onto a directory must fail

    let err = cmd::admin::backup_key(&vault, &pp(), &out)
        .expect_err("writing a key onto an existing directory must error");
    let msg = err.to_string();
    assert!(
        msg.contains(&out.display().to_string()),
        "names the --out path: {msg}"
    );
}