linguini-cli 0.1.0-alpha.1

Command-line interface for Linguini localization projects
use super::{build_project, check_project, init_project, project::generate_project_data, Cli};
use clap::CommandFactory;
use linguini_test_support::temp_project_dir;
use std::fs;

#[test]
fn cli_argument_parser_is_clap_backed() {
    let command = Cli::command();
    let subcommands: Vec<_> = command
        .get_subcommands()
        .map(|command| command.get_name().to_owned())
        .collect();

    assert!(subcommands.contains(&"init".to_owned()));
    assert!(subcommands.contains(&"check".to_owned()));
    assert!(subcommands.contains(&"fix".to_owned()));
    assert!(subcommands.contains(&"build".to_owned()));
    assert!(subcommands.contains(&"generate".to_owned()));
    assert!(subcommands.contains(&"format".to_owned()));
    assert!(subcommands.contains(&"lsp".to_owned()));
    assert!(!subcommands.contains(&"cldr".to_owned()));
}

#[test]
fn init_creates_valid_project() {
    let project = temp_project_dir("init_creates_valid_project");

    init_project(project.path()).expect("init project");

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

#[test]
fn check_lists_discovered_files() {
    let project = temp_project_dir("check_lists_discovered_files");
    init_project(project.path()).expect("init project");

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

    let output = check_project(project.path()).expect("check project");

    assert!(output.contains("schema/shop/delivery.lgs [shop.delivery]"));
    assert!(output.contains("locales/shop/delivery/en.lgl [en:shop.delivery]"));
}

#[test]
fn format_command_formats_discovered_project_files() {
    let project = temp_project_dir("format_command_formats_discovered_project_files");
    init_project(project.path()).expect("init project");

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

    let output = super::run(vec!["format".to_owned()], Ok(project.path().to_path_buf()))
        .expect("format command");

    assert!(output.contains("schema/shop.lgs"));
    assert_eq!(
        fs::read_to_string(project.path().join("schema/shop.lgs")).expect("schema"),
        "delivery(count: Number)\n"
    );
    assert_eq!(
        fs::read_to_string(locale_dir.join("en.lgl")).expect("locale"),
        "delivery = {count} deliveries\n"
    );
}

#[test]
fn check_reports_schema_syntax_diagnostics() {
    let project = temp_project_dir("check_reports_schema_syntax_diagnostics");
    init_project(project.path()).expect("init project");

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

    let error = check_project(project.path()).expect_err("check fails");
    let rendered = error.to_string();

    assert!(rendered.contains("Error:"));
    assert!(rendered.contains("schema/shop/broken.lgs"));
    assert!(rendered.contains("schema syntax error"));
}

#[test]
fn check_reports_missing_schema_message_for_empty_locale_file() {
    let project = temp_project_dir("check_reports_missing_schema_message");
    init_project(project.path()).expect("init project");

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

    let error = check_project(project.path()).expect_err("check fails on missing message");
    let rendered = error.to_string();

    assert!(rendered.contains(
        "locale `en` for schema namespace `shop.delivery` is missing 1 schema message: `delivery`"
    ));
    assert!(rendered.contains("locales/shop/delivery/en.lgl"));
    assert!(rendered.contains("Fix: add missing locale message stubs"));
    assert!(!rendered.contains(",- ["));
    assert!(!rendered.contains("locale file contains no declarations"));
}

#[test]
fn check_rejects_root_locale_file_for_schema_namespace() {
    let project = temp_project_dir("check_rejects_root_locale_file");
    init_project(project.path()).expect("init project");

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

    let error = check_project(project.path()).expect_err("check fails on misplaced locale");
    let rendered = error.to_string();

    assert!(rendered.contains("required locale file is missing for schema namespace `shop`: `en`"));
    assert!(rendered.contains("expected path: locales/shop/en.lgl"));
    assert!(rendered.contains("locale namespace `<root>` has no matching schema namespace"));
    assert!(!rendered.contains(",- ["));
}

#[test]
fn check_warns_for_secondary_locale_missing_messages() {
    let project = temp_project_dir("check_warns_for_secondary_locale_missing_messages");
    init_project(project.path()).expect("init project");

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

    let output = check_project(project.path()).expect("secondary locale gaps are warnings");

    assert!(output.contains("Warning:"));
    assert!(output.contains(
        "locale `ru` for schema namespace `shop` is missing 1 schema message: `counted`"
    ));
    assert!(output.contains("Fix: add missing locale message stubs"));
    assert!(output.contains("linguini fix missing-messages:shop:ru"));
}

#[test]
fn build_generates_typescript_project_files_without_cldr_cache() {
    let project = temp_project_dir("build_generates_typescript");
    init_project(project.path()).expect("init project");

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

    let output = build_project(project.path()).expect("build project");

    assert!(output.contains("schema files:"));
    assert!(output.contains("locale files:"));
    assert!(output.contains("generated files:"));
    assert!(output.contains("src/generated/linguini/locales/en.ts"));
    assert!(output.contains("build: ok"));
    assert!(project
        .path()
        .join("src/generated/linguini/locales/en.ts")
        .exists());
    assert!(project
        .path()
        .join("src/generated/linguini/index.ts")
        .exists());
    let generated_locale =
        fs::read_to_string(project.path().join("src/generated/linguini/locales/en.ts"))
            .expect("read generated locale");
    assert!(generated_locale.contains("export const shop = {"));
    assert!(generated_locale.contains("  delivery: {"));
    assert!(generated_locale.contains("    delivery: (count: number) =>"));
    assert!(!stale_file.exists());
    assert!(!project.path().join(".linguini/cache").exists());
}

#[test]
fn build_replaces_existing_generated_tree() {
    let project = temp_project_dir("build_replaces_existing_generated_tree");
    init_project(project.path()).expect("init project");

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

    build_project(project.path()).expect("initial build");
    let out_dir = project.path().join("src/generated/linguini");
    let index_path = out_dir.join("index.ts");
    let locale_path = out_dir.join("locales/en.ts");
    let original_index = fs::read_to_string(&index_path).expect("read generated index");
    let original_locale = fs::read_to_string(&locale_path).expect("read generated locale");

    fs::write(&index_path, "// user edit that must be replaced\n").expect("corrupt index");
    fs::write(&locale_path, "// user edit that must be replaced\n").expect("corrupt locale");
    fs::write(out_dir.join("stale.ts"), "export const stale = true;\n").expect("stale file");
    fs::create_dir_all(out_dir.join("obsolete/nested")).expect("obsolete dir");
    fs::write(out_dir.join("obsolete/nested/file.ts"), "obsolete\n").expect("obsolete file");

    let output = build_project(project.path()).expect("second build");

    assert!(output.contains("replaced generated tree: src/generated/linguini"));
    assert_eq!(
        fs::read_to_string(&index_path).expect("read regenerated index"),
        original_index
    );
    assert_eq!(
        fs::read_to_string(&locale_path).expect("read regenerated locale"),
        original_locale
    );
    assert!(!out_dir.join("stale.ts").exists());
    assert!(!out_dir.join("obsolete").exists());
}

#[test]
fn generate_renders_locale_enum_and_plural_matrix() {
    let project = temp_project_dir("generate_renders_locale_enum_and_plural_matrix");
    init_project(project.path()).expect("init project");

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

    let output = generate_project_data(project.path()).expect("generated data");
    let plain = strip_ansi(&output);

    assert!(output.contains("\x1b["));
    assert!(!output.contains("\"locales\""));
    assert!(plain.contains("locale en"));
    assert!(plain.contains("message counted"));
    assert!(plain.contains("fruit=apple"));
    assert!(plain.contains("fruit=pear"));
    assert!(plain.contains("count=5"));
    assert!(plain.contains("=> 1 apple"));
    assert!(plain.contains("=> 5 apples"));
}

fn strip_ansi(value: &str) -> String {
    let mut output = String::new();
    let mut chars = value.chars().peekable();
    while let Some(character) = chars.next() {
        if character == '\x1b' && chars.peek() == Some(&'[') {
            chars.next();
            for code in chars.by_ref() {
                if code == 'm' {
                    break;
                }
            }
            continue;
        }
        output.push(character);
    }
    output
}