forex-split 1.0.3

Split a receipt in one currency into categories for bookkeeping in another currency.
Documentation
use std::env;
use std::io::Write;
use std::process::{Command, Stdio};

fn binary_path() -> String {
    env::var("CARGO_BIN_EXE_forex-split")
        .or_else(|_| env::var("CARGO_BIN_EXE_forex_split"))
        .expect("binary path not set by cargo test")
}

fn run_binary(args: &[&str], input: &str) -> std::process::Output {
    let mut child = Command::new(binary_path())
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn binary");

    if let Some(mut stdin) = child.stdin.take() {
        stdin
            .write_all(input.as_bytes())
            .expect("failed to write test input");
    }

    child.wait_with_output().expect("failed to read output")
}

fn stdout_text(output: &std::process::Output) -> String {
    String::from_utf8_lossy(&output.stdout).into_owned()
}

fn stderr_text(output: &std::process::Output) -> String {
    String::from_utf8_lossy(&output.stderr).into_owned()
}

#[test]
fn cli_handles_all_arguments_and_named_categories() {
    let output = run_binary(
        &["299.28", "26.84"],
        "Alcohol 6.9\nFood 17.5\n\n",
    );

    assert!(output.status.success());

    let stdout = stdout_text(&output);
    assert!(stdout.contains("Category"));
    assert!(stdout.contains("Foreign subtotal"));
    assert!(stdout.contains("Domestic subtotal"));
    assert!(stdout.contains("Alcohol"));
    assert!(stdout.contains("Food"));
    assert!(stdout.contains("76.94"));
    assert!(stdout.contains("195.13"));
    assert!(stdout.contains("Other"));
    assert!(stdout.contains("27.21"));
}

#[test]
fn cli_prompts_for_missing_foreign_total() {
    let output = run_binary(&["299.28"], "26.84\n\n");

    assert!(output.status.success());

    let stdout = stdout_text(&output);
    assert!(stdout.contains("Please enter foreign total:"));
    assert!(stdout.contains("Other"));
    assert!(stdout.contains("26.84"));
    assert!(stdout.contains("299.28"));
}

#[test]
fn cli_prompts_for_both_missing_totals() {
    let output = run_binary(&[], "299.28\n26.84\n\n");

    assert!(output.status.success());

    let stdout = stdout_text(&output);
    assert!(stdout.contains("Please enter domestic total:"));
    assert!(stdout.contains("Please enter foreign total:"));
    assert!(stdout.contains("Other"));
}

#[test]
fn cli_retries_after_invalid_domestic_total() {
    let output = run_binary(&[], "abc\n299.28\n26.84\n\n");

    assert!(output.status.success());

    let stdout = stdout_text(&output);
    assert!(stdout.contains("Please try again. Enter a valid number:"));
    assert!(stdout.contains("Please enter foreign total:"));
    assert!(stdout.contains("Other"));
}

#[test]
fn cli_retries_after_invalid_foreign_total() {
    let output = run_binary(&[], "299.28\nabc\n26.84\n\n");

    assert!(output.status.success());

    let stdout = stdout_text(&output);
    assert!(stdout.contains("Please try again. Enter a valid number:"));
    assert!(stdout.contains("Other"));
}

#[test]
fn cli_handles_unnamed_categories_and_other() {
    let output = run_binary(&["299.28", "26.84"], "6.9\n17.5\n\n");

    assert!(output.status.success());

    let stdout = stdout_text(&output);
    assert!(stdout.contains("Unnamed category 1"));
    assert!(stdout.contains("Unnamed category 2"));
    assert!(stdout.contains("Other"));
}

#[test]
fn cli_finishes_when_remaining_total_reaches_zero() {
    let output = run_binary(&["10", "10"], "Food 10\n");

    assert!(output.status.success());

    let stdout = stdout_text(&output);
    assert!(stdout.contains("Food"));
    assert!(stdout.contains("10.00"));
    assert!(!stdout.contains("Other"));
}

#[test]
fn cli_rejects_oversized_subtotal_and_recovers() {
    let output = run_binary(&["299.28", "26.84"], "Food 30\n\n");

    assert!(output.status.success());

    let stderr = stderr_text(&output);
    let stdout = stdout_text(&output);
    assert!(stderr.contains("The entered subtotal exceeds the remaining total"));
    assert!(stdout.contains("Other"));
}

#[test]
fn cli_accumulates_repeated_named_categories() {
    let output = run_binary(&["299.28", "26.84"], "Food 9.9\nFood 7.59\n\n");

    assert!(output.status.success());

    let stdout = stdout_text(&output);
    assert!(stdout.contains("Food"));
    assert!(stdout.contains("17.49"));
    assert!(stdout.contains("Other"));
    assert!(stdout.contains("9.35"));
}

#[test]
fn cli_reports_invalid_subtotal_and_recovers() {
    let output = run_binary(&["299.28", "26.84"], "Alcohol foo\nAlcohol 6.9\n\n");

    assert!(output.status.success());

    let stderr = stderr_text(&output);
    let stdout = stdout_text(&output);
    assert!(stderr.contains("Please enter the subtotal as the last element on the line"));
    assert!(stdout.contains("Alcohol"));
    assert!(stdout.contains("Other"));
}