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"))
}

fn write_xcstrings_fixture(path: &std::path::Path) {
    let xcstrings = r#"{
  "sourceLanguage": "en",
  "version": "1.0",
  "strings": {
    "greeting": {
      "comment": "Shown on the home screen.",
      "localizations": {
        "en": {
          "stringUnit": {
            "state": "translated",
            "value": "Hello"
          }
        },
        "fr": {
          "stringUnit": {
            "state": "translated",
            "value": "Bonjour"
          }
        }
      }
    },
    "pending": {
      "localizations": {
        "en": {
          "stringUnit": {
            "state": "translated",
            "value": "Pending"
          }
        }
      }
    }
  }
}
"#;

    fs::write(path, xcstrings).unwrap();
}

fn write_strings_fixture(path: &std::path::Path) {
    let strings = r#"/* Greeting */
"greeting" = "Hello";
"pending" = "Pending";
"#;
    fs::write(path, strings).unwrap();
}

fn write_xliff_fixture(path: &std::path::Path) {
    let xliff = r#"<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file original="Base.lproj/Localizable.strings" source-language="en" target-language="fr" datatype="plaintext">
    <body>
      <trans-unit id="greeting" xml:space="preserve" resname="GREETING">
        <source>Hello</source>
        <target>Bonjour</target>
        <note>Shown on the home screen.</note>
      </trans-unit>
      <trans-unit id="pending" xml:space="preserve">
        <source>Pending</source>
      </trans-unit>
    </body>
  </file>
</xliff>
"#;
    fs::write(path, xliff).unwrap();
}

#[test]
fn test_convert_xcstrings_to_xliff() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("Localizable.xcstrings");
    let output = temp_dir.path().join("Localizable.xliff");
    write_xcstrings_fixture(&input);

    let out = langcodec_cmd()
        .args([
            "convert",
            "--input",
            input.to_str().unwrap(),
            "--output",
            output.to_str().unwrap(),
            "--output-lang",
            "fr",
        ])
        .output()
        .unwrap();

    assert!(
        out.status.success(),
        "stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );

    let xml = fs::read_to_string(&output).unwrap();
    assert!(xml.contains(r#"target-language="fr""#));
    assert!(xml.contains("<target>Bonjour</target>"));
    assert!(xml.contains("<target/>"));
}

#[test]
fn test_convert_xliff_to_xcstrings() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("Localizable.xliff");
    let output = temp_dir.path().join("Localizable.xcstrings");
    write_xliff_fixture(&input);

    let out = langcodec_cmd()
        .args([
            "convert",
            "--input",
            input.to_str().unwrap(),
            "--output",
            output.to_str().unwrap(),
        ])
        .output()
        .unwrap();

    assert!(
        out.status.success(),
        "stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );

    let content = fs::read_to_string(&output).unwrap();
    assert!(content.contains(r#""sourceLanguage": "en""#));
    assert!(content.contains(r#""fr""#));
    assert!(content.contains(r#""pending""#));
}

#[test]
fn test_convert_strings_to_xliff_with_empty_target_language() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("en.strings");
    let output = temp_dir.path().join("Localizable.xliff");
    write_strings_fixture(&input);

    let out = langcodec_cmd()
        .args([
            "convert",
            "--input",
            input.to_str().unwrap(),
            "--output",
            output.to_str().unwrap(),
            "--source-language",
            "en",
            "--output-lang",
            "fr",
        ])
        .output()
        .unwrap();

    assert!(
        out.status.success(),
        "stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );

    let xml = fs::read_to_string(&output).unwrap();
    assert!(xml.contains(r#"source-language="en""#));
    assert!(xml.contains(r#"target-language="fr""#));
    assert!(xml.contains("<target/>"));
}

#[test]
fn test_convert_xliff_to_android_strings() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("Localizable.xliff");
    let output = temp_dir.path().join("values-fr").join("strings.xml");
    write_xliff_fixture(&input);

    let out = langcodec_cmd()
        .args([
            "convert",
            "--input",
            input.to_str().unwrap(),
            "--output",
            output.to_str().unwrap(),
            "--output-lang",
            "fr",
        ])
        .output()
        .unwrap();

    assert!(
        out.status.success(),
        "stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );

    let xml = fs::read_to_string(&output).unwrap();
    assert!(xml.contains(r#"name="greeting""#));
    assert!(xml.contains("Bonjour"));
    assert!(!xml.contains(r#"<string name="pending">"#));
}

#[test]
fn test_convert_xliff_requires_output_lang() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("Localizable.xcstrings");
    let output = temp_dir.path().join("Localizable.xliff");
    write_xcstrings_fixture(&input);

    let out = langcodec_cmd()
        .args([
            "convert",
            "--input",
            input.to_str().unwrap(),
            "--output",
            output.to_str().unwrap(),
        ])
        .output()
        .unwrap();

    assert!(!out.status.success());
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(combined.contains("--output-lang"));
}

#[test]
fn test_convert_xliff_surfaces_ambiguous_source_language() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("translations.csv");
    let output = temp_dir.path().join("Localizable.xliff");
    fs::write(
        &input,
        "key,en,fr,de\nwelcome,Welcome,Bienvenue,Willkommen\n",
    )
    .unwrap();

    let out = langcodec_cmd()
        .args([
            "convert",
            "--input",
            input.to_str().unwrap(),
            "--output",
            output.to_str().unwrap(),
            "--output-lang",
            "fr",
        ])
        .output()
        .unwrap();

    assert!(!out.status.success());
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(combined.contains("--source-language") || combined.contains("Could not infer"));
}

#[test]
fn test_view_reads_xliff_input() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("Localizable.xliff");
    write_xliff_fixture(&input);

    let out = langcodec_cmd()
        .args([
            "view",
            "--input",
            input.to_str().unwrap(),
            "--lang",
            "fr",
            "--keys-only",
        ])
        .output()
        .unwrap();

    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("greeting"));
    assert!(stdout.contains("pending"));
}

#[test]
fn test_debug_reads_xliff_input() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("Localizable.xliff");
    let output = temp_dir.path().join("debug.json");
    write_xliff_fixture(&input);

    let out = langcodec_cmd()
        .args([
            "debug",
            "--input",
            input.to_str().unwrap(),
            "--output",
            output.to_str().unwrap(),
        ])
        .output()
        .unwrap();

    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let json = fs::read_to_string(&output).unwrap();
    assert!(json.contains(r#""language": "en""#));
    assert!(json.contains(r#""language": "fr""#));
    assert!(json.contains(r#""greeting""#));
}

#[test]
fn test_edit_rejects_xliff() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("Localizable.xliff");
    write_xliff_fixture(&input);

    let out = langcodec_cmd()
        .args([
            "edit",
            "set",
            "--inputs",
            input.to_str().unwrap(),
            "--key",
            "greeting",
            "--value",
            "Bonjour!",
        ])
        .output()
        .unwrap();

    assert!(!out.status.success());
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(combined.contains("not supported by `edit`"));
}

#[test]
fn test_sync_rejects_xliff() {
    let temp_dir = TempDir::new().unwrap();
    let source = temp_dir.path().join("source.xliff");
    let target = temp_dir.path().join("target.csv");
    write_xliff_fixture(&source);
    fs::write(&target, "key,en,fr\ngreeting,Hello,Bonjour\n").unwrap();

    let out = langcodec_cmd()
        .args([
            "sync",
            "--source",
            source.to_str().unwrap(),
            "--target",
            target.to_str().unwrap(),
            "--dry-run",
        ])
        .output()
        .unwrap();

    assert!(!out.status.success());
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(combined.contains("not supported by `sync`"));
}

#[test]
fn test_normalize_rejects_xliff() {
    let temp_dir = TempDir::new().unwrap();
    let input = temp_dir.path().join("Localizable.xliff");
    write_xliff_fixture(&input);

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

    assert!(!out.status.success());
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(combined.contains("not supported by `normalize`"));
}