linguini-cli 0.1.0-alpha.2

Command-line interface for Linguini localization projects
use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use std::fs;
use tempfile::TempDir;

fn linguini() -> Command {
    Command::cargo_bin("linguini").expect("linguini binary")
}

#[test]
fn help_is_generated_by_cli_argument_parser() {
    linguini()
        .arg("--help")
        .assert()
        .success()
        .stdout(contains("Experimental localization toolkit CLI"))
        .stdout(contains("Usage: linguini <COMMAND>"))
        .stdout(contains("init"))
        .stdout(contains("check"))
        .stdout(contains("fix"))
        .stdout(contains("build"))
        .stdout(contains("generate"))
        .stderr("");
}

#[test]
fn no_public_cldr_subcommand_exists() {
    linguini()
        .args(["cldr", "status"])
        .assert()
        .failure()
        .stderr(contains("cldr"));
}

#[test]
fn init_command_creates_project_files_without_cache_config() {
    let project = TempDir::new().expect("temp project");

    linguini()
        .current_dir(project.path())
        .arg("init")
        .assert()
        .success()
        .stdout(contains("created linguini.toml"))
        .stdout(contains("created schema"))
        .stdout(contains("created locales"));

    let config = fs::read_to_string(project.path().join("linguini.toml")).expect("config");
    assert!(project.path().join("linguini.toml").exists());
    assert!(project.path().join("schema").is_dir());
    assert!(project.path().join("locales").is_dir());
    assert!(!config.contains("cache"));
    assert!(config.contains("[targets.ts]"));
    assert!(config.contains("out = \"src/generated/linguini\""));
}

#[test]
fn check_command_lists_schema_and_locale_files() {
    let project = TempDir::new().expect("temp project");
    linguini()
        .current_dir(project.path())
        .arg("init")
        .assert()
        .success();

    let schema_dir = project.path().join("schema/shop");
    let locale_dir = project.path().join("locales/shop/delivery");
    fs::create_dir_all(&schema_dir).expect("schema dir");
    fs::create_dir_all(&locale_dir).expect("locale dir");
    fs::write(schema_dir.join("delivery.lgs"), "delivery()\n").expect("schema file");
    fs::write(locale_dir.join("en.lgl"), "delivery = Delivered\n").expect("locale file");

    linguini()
        .current_dir(project.path())
        .arg("check")
        .assert()
        .success()
        .stdout(contains("schema files:"))
        .stdout(contains("schema/shop/delivery.lgs [shop.delivery]"))
        .stdout(contains("locale files:"))
        .stdout(contains("locales/shop/delivery/en.lgl [en:shop.delivery]"));
}

#[test]
fn check_command_reports_syntax_diagnostics_on_stderr() {
    let project = TempDir::new().expect("temp project");
    linguini()
        .current_dir(project.path())
        .arg("init")
        .assert()
        .success();

    let schema_dir = project.path().join("schema/shop");
    fs::create_dir_all(&schema_dir).expect("schema dir");
    fs::write(schema_dir.join("broken.lgs"), "delivery(fruit: Fruit\n").expect("schema file");

    linguini()
        .current_dir(project.path())
        .arg("check")
        .assert()
        .failure()
        .stderr(contains("Error:"))
        .stderr(contains("schema/shop/broken.lgs"))
        .stderr(contains("schema syntax error"));
}

#[test]
fn check_command_reports_missing_messages_for_empty_locale_file() {
    let project = TempDir::new().expect("temp project");
    linguini()
        .current_dir(project.path())
        .arg("init")
        .assert()
        .success();

    let schema_dir = project.path().join("schema/shop");
    let locale_dir = project.path().join("locales/shop/delivery");
    fs::create_dir_all(&schema_dir).expect("schema dir");
    fs::create_dir_all(&locale_dir).expect("locale dir");
    fs::write(schema_dir.join("delivery.lgs"), "delivery()\n").expect("schema file");
    fs::write(locale_dir.join("en.lgl"), "").expect("locale file");

    linguini()
        .current_dir(project.path())
        .arg("check")
        .assert()
        .failure()
        .stderr(contains(
            "locale `en` for schema namespace `shop.delivery` is missing 1 schema message: `delivery`",
        ))
        .stderr(contains("locales/shop/delivery/en.lgl"))
        .stderr(contains("Fix: add missing locale message stubs"))
        .stderr(contains("linguini fix missing-messages:shop.delivery:en"))
        .stderr(predicates::str::is_match("locale file contains no declarations").unwrap().not());
}

#[test]
fn check_reports_missing_schema_messages_in_matching_locale_namespace() {
    let project = TempDir::new().expect("temp project");
    linguini()
        .current_dir(project.path())
        .arg("init")
        .assert()
        .success();

    let schema_dir = project.path().join("schema");
    let locale_dir = project.path().join("locales/shop");
    fs::create_dir_all(&schema_dir).expect("schema dir");
    fs::create_dir_all(&locale_dir).expect("locale dir");
    fs::write(schema_dir.join("shop.lgs"), "delivery()\ncounted()\n").expect("schema file");
    fs::write(locale_dir.join("en.lgl"), "delivery = Delivered\n").expect("locale file");

    linguini()
        .current_dir(project.path())
        .arg("check")
        .assert()
        .failure()
        .stderr(contains(
            "locale `en` for schema namespace `shop` is missing 1 schema message: `counted`",
        ))
        .stderr(contains("locales/shop/en.lgl"));
}

#[test]
fn check_requires_locales_to_follow_schema_namespace_directories() {
    let project = TempDir::new().expect("temp project");
    linguini()
        .current_dir(project.path())
        .arg("init")
        .assert()
        .success();

    let schema_dir = project.path().join("schema");
    let locale_dir = project.path().join("locales");
    fs::create_dir_all(&schema_dir).expect("schema dir");
    fs::create_dir_all(&locale_dir).expect("locale dir");
    fs::write(schema_dir.join("shop.lgs"), "delivery()\n").expect("schema file");
    fs::write(locale_dir.join("en.lgl"), "delivery = Delivered\n").expect("locale file");

    linguini()
        .current_dir(project.path())
        .arg("check")
        .assert()
        .failure()
        .stderr(contains(
            "required locale file is missing for schema namespace `shop`: `en`",
        ))
        .stderr(contains("expected path: locales/shop/en.lgl"))
        .stderr(contains(
            "locale namespace `<root>` has no matching schema namespace",
        ));
}

#[test]
fn check_command_warns_for_secondary_locale_missing_messages() {
    let project = TempDir::new().expect("temp project");
    linguini()
        .current_dir(project.path())
        .arg("init")
        .assert()
        .success();

    fs::write(
        project.path().join("linguini.toml"),
        r#"[project]
name = "shop"
default_locale = "en"
locales = ["en", "ru"]

[paths]
schema = "schema"
locale = "locales"

[targets.ts]
out = "src/generated/linguini"
module = "esm"
declaration = true
"#,
    )
    .expect("config");

    let schema_dir = project.path().join("schema");
    let locale_dir = project.path().join("locales/shop");
    fs::create_dir_all(&schema_dir).expect("schema dir");
    fs::create_dir_all(&locale_dir).expect("locale dir");
    fs::write(schema_dir.join("shop.lgs"), "delivery()\ncounted()\n").expect("schema file");
    fs::write(
        locale_dir.join("en.lgl"),
        "delivery = Delivered\ncounted = Counted\n",
    )
    .expect("default locale file");
    fs::write(locale_dir.join("ru.lgl"), "delivery = Доставлено\n").expect("secondary locale file");

    linguini()
        .current_dir(project.path())
        .arg("check")
        .assert()
        .success()
        .stdout(contains("Warning:"))
        .stdout(contains(
            "locale `ru` for schema namespace `shop` is missing 1 schema message: `counted`",
        ))
        .stdout(contains("Fix: add missing locale message stubs"))
        .stdout(contains("linguini fix missing-messages:shop:ru"));
}

#[test]
fn fix_command_applies_missing_locale_and_message_stubs() {
    let project = TempDir::new().expect("temp project");
    linguini()
        .current_dir(project.path())
        .arg("init")
        .assert()
        .success();

    fs::write(
        project.path().join("linguini.toml"),
        r#"[project]
name = "shop"
default_locale = "en"
locales = ["en", "ru"]

[paths]
schema = "schema"
locale = "locales"

[targets.ts]
out = "src/generated/linguini"
module = "esm"
declaration = true
"#,
    )
    .expect("config");

    let schema_dir = project.path().join("schema");
    let locale_dir = project.path().join("locales/shop");
    fs::create_dir_all(&schema_dir).expect("schema dir");
    fs::create_dir_all(&locale_dir).expect("locale dir");
    fs::write(schema_dir.join("shop.lgs"), "delivery()\ncounted()\n").expect("schema file");
    fs::write(locale_dir.join("en.lgl"), "delivery = Delivered\n").expect("locale file");

    linguini()
        .current_dir(project.path())
        .args(["fix", "--all"])
        .assert()
        .success()
        .stdout(contains("applied missing-messages:shop:en"))
        .stdout(contains("applied missing-locale:shop:ru"));

    let en = fs::read_to_string(locale_dir.join("en.lgl")).expect("en locale");
    let ru = fs::read_to_string(locale_dir.join("ru.lgl")).expect("ru locale");
    assert!(en.contains("counted = TODO"));
    assert!(ru.contains("delivery = TODO"));
    assert!(ru.contains("counted = TODO"));
}

#[test]
fn build_command_generates_typescript_and_does_not_require_cldr_cache() {
    let project = TempDir::new().expect("temp project");
    linguini()
        .current_dir(project.path())
        .arg("init")
        .assert()
        .success();

    let schema_dir = project.path().join("schema/shop");
    let locale_dir = project.path().join("locales/shop/delivery");
    fs::create_dir_all(&schema_dir).expect("schema dir");
    fs::create_dir_all(&locale_dir).expect("locale dir");
    fs::write(schema_dir.join("delivery.lgs"), "delivery(count: Number)\n").expect("schema file");
    fs::write(locale_dir.join("en.lgl"), "delivery = {count} deliveries\n").expect("locale file");

    let stale_file = project.path().join("src/generated/linguini/stale.ts");
    fs::create_dir_all(stale_file.parent().expect("stale parent")).expect("generated dir");
    fs::write(&stale_file, "export const stale = true;\\n").expect("stale file");

    linguini()
        .current_dir(project.path())
        .arg("build")
        .assert()
        .success()
        .stdout(contains("generated files:"))
        .stdout(contains("src/generated/linguini/locales/en.ts"))
        .stdout(contains("build: ok"));

    assert!(project
        .path()
        .join("src/generated/linguini/locales/en.ts")
        .exists());
    assert!(project
        .path()
        .join("src/generated/linguini/index.ts")
        .exists());
    assert!(!stale_file.exists());
    assert!(!project.path().join(".linguini/cache").exists());
}

#[test]
fn generate_command_outputs_rendered_locale_matrix() {
    let project = TempDir::new().expect("temp project");
    linguini()
        .current_dir(project.path())
        .arg("init")
        .assert()
        .success();

    let schema_dir = project.path().join("schema");
    let locale_dir = project.path().join("locales/shop");
    fs::create_dir_all(&schema_dir).expect("schema dir");
    fs::create_dir_all(&locale_dir).expect("locale dir");
    fs::write(
        schema_dir.join("shop.lgs"),
        "enum Fruit {\n  apple\n  pear\n}\ncounted(count: Number, fruit: Fruit)\n",
    )
    .expect("schema file");
    fs::write(
        locale_dir.join("en.lgl"),
        "impl Fruit {\n  apple {\n    form gen(Plural) {\n      one => apple\n      _ => apples\n    }\n  }\n  pear {\n    form gen(Plural) {\n      one => pear\n      _ => pears\n    }\n  }\n}\ncounted = {count} {fruit.gen(count)}\n",
    )
    .expect("locale file");

    linguini()
        .current_dir(project.path())
        .arg("generate")
        .assert()
        .success()
        .stdout(contains("\u{1b}["))
        .stdout(contains("locale"))
        .stdout(contains("en"))
        .stdout(contains("fruit"))
        .stdout(contains("apple"))
        .stdout(contains("pear"))
        .stdout(contains("count"))
        .stdout(contains("5"))
        .stdout(contains("\u{1b}[32m=>\u{1b}[0m 1 apple"))
        .stdout(contains("\u{1b}[32m=>\u{1b}[0m 5 apples"))
        .stdout(predicates::str::contains("\"locales\"").not());
}