langcodec-cli 0.12.0

A universal CLI tool for converting and inspecting localization files (Apple, Android, CSV, etc.)
Documentation
use std::fs;
use std::process::Command;
use tempfile::TempDir;

fn langcodec_cmd() -> Command {
    Command::new(assert_cmd::cargo::cargo_bin!("langcodec"))
}

#[test]
fn test_main_help_lists_normalize() {
    let output = langcodec_cmd().args(["--help"]).output().unwrap();
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("normalize"));
}

#[test]
fn test_normalize_requires_inputs_argument() {
    let output = langcodec_cmd().args(["normalize"]).output().unwrap();
    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(stderr.contains("--inputs"));
}

#[test]
fn test_normalize_command_executes_successfully() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("en.strings");
    fs::write(&input, "\"a\" = \"A\";\n\"b\" = \"B\";\n").unwrap();

    let output = langcodec_cmd()
        .args(["normalize", "-i", input.to_str().unwrap()])
        .output()
        .unwrap();
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "normalize command failed with stderr: {stderr}"
    );
    assert!(
        !stderr.contains("panicked at"),
        "normalize command panicked"
    );
}

#[test]
fn test_normalize_check_fails_on_drift() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("en.strings");
    fs::write(&input, "\"z\" = \"%@\";\n\"a\" = \"A\";\n").unwrap();

    let output = langcodec_cmd()
        .args(["normalize", "-i", input.to_str().unwrap(), "--check"])
        .output()
        .unwrap();

    assert!(!output.status.success());
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        combined.contains("would change"),
        "expected output to mention drift; got: {combined}"
    );
}

#[test]
fn test_normalize_dry_run_does_not_write() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("en.strings");
    let before = "\"z\" = \"%@\";\n\"a\" = \"A\";\n";
    fs::write(&input, before).unwrap();

    let output = langcodec_cmd()
        .args(["normalize", "-i", input.to_str().unwrap(), "--dry-run"])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let after = fs::read_to_string(&input).unwrap();
    assert_eq!(after, before);
}

#[test]
fn test_normalize_check_detects_drift_across_standard_formats() {
    let temp_dir = TempDir::new().unwrap();
    let cases = [
        (
            "apple_strings",
            "en.strings",
            "\"z\" = \"%@\";\n\"a\" = \"A\";\n",
        ),
        (
            "android_xml",
            "strings.xml",
            r#"<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="z">Hello %@</string>
    <string name="a">A</string>
</resources>
"#,
        ),
        ("csv", "translations.csv", "z,%@\na,A\n"),
        ("tsv", "translations.tsv", "z\t%@\na\tA\n"),
        (
            "xcstrings",
            "Localizable.xcstrings",
            r#"{
  "sourceLanguage": "en",
  "version": "1.0",
  "strings": {
    "z": {
      "localizations": {
        "en": {
          "stringUnit": {
            "state": "translated",
            "value": "Hello %@"
          }
        }
      }
    },
    "a": {
      "localizations": {
        "en": {
          "stringUnit": {
            "state": "translated",
            "value": "A"
          }
        }
      }
    }
  }
}
"#,
        ),
    ];

    for (label, filename, contents) in cases {
        let input = temp_dir.path().join(filename);
        fs::write(&input, contents).unwrap();

        let output = langcodec_cmd()
            .args(["normalize", "-i", input.to_str().unwrap(), "--check"])
            .output()
            .unwrap();

        assert!(
            !output.status.success(),
            "{label} should report drift in --check mode"
        );
        let combined = format!(
            "{}{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
        assert!(
            combined.contains("would change"),
            "{label} expected drift message, got: {combined}"
        );
    }
}

#[test]
fn test_normalize_output_written_even_when_unchanged() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("en.strings");
    let output_path = temp_dir.path().join("out").join("normalized.strings");
    fs::write(&input, "\"a\" = \"A\";\n\"b\" = \"B\";\n").unwrap();

    let output = langcodec_cmd()
        .args([
            "normalize",
            "-i",
            input.to_str().unwrap(),
            "-o",
            output_path.to_str().unwrap(),
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(output_path.exists(), "expected output file to be created");

    let written = fs::read_to_string(&output_path).unwrap();
    assert!(written.contains("\"a\" = \"A\";"));
    assert!(written.contains("\"b\" = \"B\";"));
}

#[test]
fn test_normalize_check_and_dry_run_with_output_do_not_create_directories() {
    for mode in ["--check", "--dry-run"] {
        let temp_dir = TempDir::new().unwrap();
        let input = temp_dir.path().join("en.strings");
        let missing_parent = temp_dir.path().join("missing").join("nested");
        let output_path = missing_parent.join("out.strings");
        fs::write(&input, "\"z\" = \"%@\";\n\"a\" = \"A\";\n").unwrap();

        let output = langcodec_cmd()
            .args([
                "normalize",
                "-i",
                input.to_str().unwrap(),
                "-o",
                output_path.to_str().unwrap(),
                mode,
            ])
            .output()
            .unwrap();

        if mode == "--check" {
            assert!(
                !output.status.success(),
                "check mode should fail when drift is detected"
            );
        } else {
            assert!(
                output.status.success(),
                "dry-run failed with stderr: {}",
                String::from_utf8_lossy(&output.stderr)
            );
        }

        assert!(
            !missing_parent.exists(),
            "mode {mode} unexpectedly created output directory"
        );
        assert!(
            !output_path.exists(),
            "mode {mode} unexpectedly created output file"
        );
    }
}

#[test]
fn test_normalize_rejects_output_with_multiple_inputs() {
    let temp_dir = TempDir::new().unwrap();
    let input_a = temp_dir.path().join("a.strings");
    let input_b = temp_dir.path().join("b.strings");
    let output_path = temp_dir.path().join("out.strings");
    fs::write(&input_a, "\"a\" = \"A\";\n").unwrap();
    fs::write(&input_b, "\"b\" = \"B\";\n").unwrap();

    let output = langcodec_cmd()
        .args([
            "normalize",
            "-i",
            input_a.to_str().unwrap(),
            input_b.to_str().unwrap(),
            "-o",
            output_path.to_str().unwrap(),
        ])
        .output()
        .unwrap();

    assert!(!output.status.success());
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        combined.contains("--output cannot be used with multiple input files"),
        "expected --output multi-input rejection; got: {combined}"
    );
}

#[test]
fn test_normalize_continue_on_error_aggregates_and_returns_non_zero() {
    let temp_dir = TempDir::new().unwrap();
    let good = temp_dir.path().join("good.strings");
    let bad = temp_dir.path().join("bad.txt");
    fs::write(&good, "\"z\" = \"%@\";\n\"a\" = \"A\";\n").unwrap();
    fs::write(&bad, "not a supported localization format").unwrap();

    let output = langcodec_cmd()
        .args([
            "normalize",
            "-i",
            bad.to_str().unwrap(),
            good.to_str().unwrap(),
            "--continue-on-error",
        ])
        .output()
        .unwrap();

    assert!(
        !output.status.success(),
        "expected non-zero when at least one file fails"
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    let combined = format!("{stdout}{stderr}");

    assert!(
        combined.contains("Summary: processed 2; success: 1; failed: 1"),
        "expected summary counts, got: {combined}"
    );
    assert!(
        combined.contains("✅ Normalized:"),
        "expected successful file processing to continue, got: {combined}"
    );
}

#[test]
fn test_normalize_continue_on_error_counts_literal_missing_input_in_summary() {
    let temp_dir = TempDir::new().unwrap();
    let good = temp_dir.path().join("good.strings");
    let missing = temp_dir.path().join("missing.strings");
    fs::write(&good, "\"z\" = \"%@\";\n\"a\" = \"A\";\n").unwrap();

    let output = langcodec_cmd()
        .args([
            "normalize",
            "-i",
            missing.to_str().unwrap(),
            good.to_str().unwrap(),
            "--continue-on-error",
        ])
        .output()
        .unwrap();

    assert!(
        !output.status.success(),
        "expected non-zero when at least one file fails"
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    let combined = format!("{stdout}{stderr}");

    assert!(
        combined.contains("Input file does not exist"),
        "expected missing-input error, got: {combined}"
    );
    assert!(
        combined.contains("Summary: processed 2; success: 1; failed: 1"),
        "expected coherent summary counts, got: {combined}"
    );
}

#[test]
fn test_normalize_strict_requires_language_for_single_language_formats() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("Localizable.strings");
    fs::write(&input, "\"hello\" = \"Hello\";\n").unwrap();

    let output = langcodec_cmd()
        .args(["--strict", "normalize", "-i", input.to_str().unwrap()])
        .output()
        .unwrap();

    assert!(
        !output.status.success(),
        "expected strict normalize to fail without language inference"
    );
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        combined.contains("missing language"),
        "expected strict missing-language error, got: {combined}"
    );
}

#[test]
fn test_normalize_reports_no_matches_for_glob_patterns() {
    let temp_dir = TempDir::new().unwrap();
    let missing_glob = temp_dir.path().join("missing").join("*.strings");

    let output = langcodec_cmd()
        .args(["normalize", "-i", missing_glob.to_str().unwrap()])
        .output()
        .unwrap();

    assert!(
        !output.status.success(),
        "expected normalize to fail when glob pattern matches no files"
    );
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        combined.contains("No input files matched the provided patterns"),
        "expected no-match glob error, got: {combined}"
    );
}