zengin-cli 0.1.0

Command-line parser for Zengin fixed-width files
use std::{
    fs,
    process::Command,
    sync::atomic::{AtomicU64, Ordering},
    time::{SystemTime, UNIX_EPOCH},
};

use zengin_fmt::{
    CodeDivision, OutputFormat,
    account_transfer::{Detail, End, File, Header, Trailer},
    to_bytes,
};

static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);

fn sample_file() -> File {
    File {
        header: Header {
            kind_code: 91,
            code_division: CodeDivision::Jis,
            collector_code: "1234567890".to_string(),
            collection_date: "0430".to_string(),
            collector_name: "テストシュウキン".to_string(),
            bank_code: "0001".to_string(),
            bank_name: "テストギンコウ".to_string(),
            branch_code: "123".to_string(),
            branch_name: "ホンテン".to_string(),
            account_type: 1,
            account_number: "7654321".to_string(),
        },
        details: vec![Detail {
            bank_code: "0005".to_string(),
            bank_name: "テストギンコウ".to_string(),
            branch_code: "001".to_string(),
            branch_name: "シテン".to_string(),
            account_type: 1,
            account_number: "1234567".to_string(),
            payer_name: "ヤマダタロウ".to_string(),
            amount: 1200,
            new_code: "0".to_string(),
            customer_number: "00000000001234567890".to_string(),
        }],
        trailer: Trailer {
            record_count: 1,
            total_amount: 1200,
        },
        end: End,
    }
}

fn pad_text(value: &str, width: usize) -> String {
    format!("{value:<width$}")
}

fn pad_number<T>(value: T, width: usize) -> String
where
    T: std::fmt::Display,
{
    format!("{value:0width$}")
}

fn sample_result_input() -> Vec<u8> {
    let mut lines = Vec::new();
    lines.push(format!(
        "1{:02}{}{}{}{}{}{}{}{}{}{}{}",
        91,
        0,
        "1234567890",
        pad_text("ACME COLLECTOR", 40),
        "0422",
        "0288",
        pad_text("BANK ALPHA", 15),
        "220",
        pad_text("MAIN BRANCH", 15),
        1,
        "5000001",
        " ".repeat(17),
    ));
    lines.push(format!(
        "2{}{}{}{}{}{}{}{}{}{}{}{}{}",
        "0288",
        pad_text("BANK ALPHA", 15),
        "110",
        pad_text("WEST", 15),
        " ".repeat(4),
        1,
        "6000001",
        pad_text("ALPHA INC", 30),
        pad_number(1000, 10),
        0,
        "01234567890123450001",
        0,
        " ".repeat(8),
    ));
    lines.push(format!(
        "2{}{}{}{}{}{}{}{}{}{}{}{}{}",
        "0288",
        pad_text("BANK ALPHA", 15),
        "650",
        pad_text("EAST", 15),
        " ".repeat(4),
        2,
        "6000002",
        pad_text("BETA LLC", 30),
        pad_number(2000, 10),
        1,
        "01234567890123450002",
        1,
        " ".repeat(8),
    ));
    lines.push(format!(
        "8{}{}{}{}{}{}{}",
        pad_number(2, 6),
        pad_number(3000, 12),
        pad_number(1, 6),
        pad_number(1000, 12),
        pad_number(1, 6),
        pad_number(2000, 12),
        " ".repeat(65),
    ));
    lines.push(format!("9{}", " ".repeat(119)));

    for line in &lines {
        assert_eq!(line.len(), 120);
    }

    lines.join("\r\n").into_bytes()
}

fn temp_input_path() -> std::path::PathBuf {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
    std::env::temp_dir().join(format!(
        "zengin-cli-{}-{stamp}-{counter}.txt",
        std::process::id()
    ))
}

#[test]
fn parses_input_file_to_json() {
    let input_path = temp_input_path();
    let input = to_bytes(&sample_file(), OutputFormat::readable()).unwrap();
    fs::write(&input_path, input).unwrap();

    let output = Command::new(env!("CARGO_BIN_EXE_zengin"))
        .arg("--type")
        .arg("request")
        .arg(&input_path)
        .output()
        .unwrap();

    let _ = fs::remove_file(&input_path);

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(json["header"]["kind_code"], 91);
    assert_eq!(json["header"]["collector_name"], "テストシュウキン");
    assert_eq!(json["header"]["bank_name"], "テストギンコウ");
    assert_eq!(json["details"][0]["amount"], 1200);
    assert_eq!(
        json["details"][0]["customer_number"],
        "00000000001234567890"
    );
}

#[test]
fn parses_input_file_to_csv() {
    let input_path = temp_input_path();
    let input = to_bytes(&sample_file(), OutputFormat::readable()).unwrap();
    fs::write(&input_path, input).unwrap();

    let output = Command::new(env!("CARGO_BIN_EXE_zengin"))
        .arg("--format")
        .arg("csv")
        .arg("--type")
        .arg("request")
        .arg(&input_path)
        .output()
        .unwrap();

    let _ = fs::remove_file(&input_path);

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).unwrap();
    let rows = stdout
        .trim_end()
        .lines()
        .map(|line| line.split(',').collect::<Vec<_>>())
        .collect::<Vec<_>>();
    let header = &rows[0];
    let column = |name: &str| {
        header
            .iter()
            .position(|column| *column == name)
            .unwrap_or_else(|| panic!("missing CSV column {name}"))
    };

    assert_eq!(rows.len(), 5);

    let detail = rows
        .iter()
        .find(|row| row[column("record_type")] == "detail")
        .expect("detail row");
    assert_eq!(detail[column("file_type")], "account-transfer");
    assert_eq!(detail[column("detail_index")], "1");
    assert_eq!(detail[column("payer_name")], "ヤマダタロウ");
    assert_eq!(detail[column("amount")], "1200");
    assert_eq!(detail[column("customer_number")], "00000000001234567890");

    let trailer = rows
        .iter()
        .find(|row| row[column("record_type")] == "trailer")
        .expect("trailer row");
    assert_eq!(trailer[column("record_count")], "1");
    assert_eq!(trailer[column("total_amount")], "1200");

    assert!(rows.iter().any(|row| row[column("record_type")] == "end"));
}

#[test]
fn csv_output_escapes_commas_and_quotes() {
    let input_path = temp_input_path();
    let mut file = sample_file();
    file.details[0].payer_name = "ACME, \"INC\"".to_string();
    let input = to_bytes(&file, OutputFormat::readable()).unwrap();
    fs::write(&input_path, input).unwrap();

    let output = Command::new(env!("CARGO_BIN_EXE_zengin"))
        .arg("--format=csv")
        .arg("--type=request")
        .arg(&input_path)
        .output()
        .unwrap();

    let _ = fs::remove_file(&input_path);

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).unwrap();
    assert!(stdout.contains("\"ACME, \"\"INC\"\"\""));
}

#[test]
fn parses_result_file_to_json() {
    let input_path = temp_input_path();
    fs::write(&input_path, sample_result_input()).unwrap();

    let output = Command::new(env!("CARGO_BIN_EXE_zengin"))
        .arg("--type=result")
        .arg(&input_path)
        .output()
        .unwrap();

    let _ = fs::remove_file(&input_path);

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(json["header"]["collection_date"], "0422");
    assert_eq!(json["header"]["bank_name"], "BANK ALPHA");
    assert_eq!(json["details"][0]["account_holder_name"], "ALPHA INC");
    assert_eq!(json["details"][1]["result_code"], 1);
    assert_eq!(json["trailer"]["failure_count"], 1);
}

#[test]
fn metadata_only_outputs_header_and_trailer_without_details() {
    let input_path = temp_input_path();
    fs::write(&input_path, sample_result_input()).unwrap();

    let output = Command::new(env!("CARGO_BIN_EXE_zengin"))
        .arg("--metadata-only")
        .arg("--type=result")
        .arg(&input_path)
        .output()
        .unwrap();

    let _ = fs::remove_file(&input_path);

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(json["header"]["collection_date"], "0422");
    assert_eq!(json["trailer"]["total_count"], 2);
    assert!(json.get("details").is_none());
    assert!(json.get("end").is_none());
}

#[test]
fn reports_usage_without_an_input_file() {
    let output = Command::new(env!("CARGO_BIN_EXE_zengin")).output().unwrap();

    assert!(!output.status.success());
    assert!(
        String::from_utf8_lossy(&output.stderr)
            .to_lowercase()
            .contains("usage:")
    );
}

#[test]
fn auto_mode_rejects_ambiguous_request_files() {
    let input_path = temp_input_path();
    let input = to_bytes(&sample_file(), OutputFormat::readable()).unwrap();
    fs::write(&input_path, input).unwrap();

    let output = Command::new(env!("CARGO_BIN_EXE_zengin"))
        .arg(&input_path)
        .output()
        .unwrap();

    let _ = fs::remove_file(&input_path);

    assert!(!output.status.success());
    assert!(String::from_utf8_lossy(&output.stderr).contains("valid as both"));
}

#[test]
fn rejects_input_files_over_10_mib() {
    let input_path = temp_input_path();
    let file = fs::File::create(&input_path).unwrap();
    file.set_len(10 * 1024 * 1024 + 1).unwrap();

    let output = Command::new(env!("CARGO_BIN_EXE_zengin"))
        .arg("--type")
        .arg("request")
        .arg(&input_path)
        .output()
        .unwrap();

    let _ = fs::remove_file(&input_path);

    assert!(!output.status.success());
    assert!(String::from_utf8_lossy(&output.stderr).contains("10 MiB limit"));
}