cranpose-localization 0.9.11

Scoped Fluent catalogs and localization tooling for Cranpose
Documentation
use std::{collections::BTreeMap, env, fs, path::Path};

use anyhow::{Context, Result, bail};
use cranpose_localization::tooling::{
    audit_ui_sources, extract_sources, generate_android_locale_config, generate_native_fixtures,
    load_catalogs, load_locale_manifest, merge_source_catalog, update_ios_localizations,
    validate_catalogs,
};

fn main() -> Result<()> {
    let mut args = env::args().skip(1);
    let Some(command) = args.next() else {
        usage();
        return Ok(());
    };
    if command == "--help" {
        usage();
        return Ok(());
    }
    let options = options(args)?;
    match command.as_str() {
        "extract" => extract(&options),
        "check" => check(&options),
        "audit" => audit(&options),
        "native-fixtures" => native_fixtures(&options),
        _ => bail!("unknown command `{command}`; use --help"),
    }
}

fn usage() {
    println!(
        "cranpose-l10n extract --source src --output locales/en/my-app.ftl [--source-locale en] [--check]\ncranpose-l10n check --catalogs locales --fallback en [--allow-missing] [--manifest locales/localization.toml]\ncranpose-l10n audit --source src --config localization.toml [--check]\ncranpose-l10n native-fixtures --manifest locales/localization.toml --catalogs locales --config localization.toml --output android.json [--library-catalogs framework-locales] [--ios-output ios.json] [--android-locales locales_config.xml] [--ios-plist Info.plist] [--check]"
    );
}

fn options(mut args: impl Iterator<Item = String>) -> Result<BTreeMap<String, String>> {
    let mut options = BTreeMap::new();
    while let Some(name) = args.next() {
        let value = match name.as_str() {
            "--check" | "--allow-missing" => String::new(),
            "--source" | "--output" | "--ios-output" | "--android-locales" | "--ios-plist"
            | "--source-locale" | "--catalogs" | "--library-catalogs" | "--fallback"
            | "--config" | "--manifest" => args
                .next()
                .with_context(|| format!("missing value for {name}"))?,
            _ => bail!("unknown option `{name}`"),
        };
        if options.insert(name.clone(), value).is_some() {
            bail!("duplicate option `{name}`");
        }
    }
    Ok(options)
}

fn required<'a>(options: &'a BTreeMap<String, String>, name: &str) -> Result<&'a str> {
    options
        .get(name)
        .map(String::as_str)
        .with_context(|| format!("missing {name}"))
}

fn extract(options: &BTreeMap<String, String>) -> Result<()> {
    let source = Path::new(required(options, "--source")?);
    let output = Path::new(required(options, "--output")?);
    let locale = options.get("--source-locale").map_or("en", String::as_str);
    let extracted = extract_sources(source, locale).map_err(anyhow::Error::msg)?;
    let previous = match fs::read_to_string(output) {
        Ok(source) => source,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(error) => return Err(error.into()),
    };
    let text = merge_source_catalog(&previous, &extracted).map_err(anyhow::Error::msg)?;
    if options.contains_key("--check") {
        if previous != text {
            bail!(
                "{} is out of date; rerun extract without --check and review source changes",
                output.display()
            );
        }
    } else {
        if let Some(parent) = output.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(output, text).with_context(|| output.display().to_string())?;
        println!(
            "Updated {}; review changed IDs before updating translations.",
            output.display()
        );
    }
    Ok(())
}

fn check(options: &BTreeMap<String, String>) -> Result<()> {
    let files =
        load_catalogs(Path::new(required(options, "--catalogs")?)).map_err(anyhow::Error::msg)?;
    let missing =
        validate_catalogs(&files, required(options, "--fallback")?).map_err(anyhow::Error::msg)?;
    for diagnostic in &missing {
        eprintln!("{diagnostic}");
    }
    if !missing.is_empty() && !options.contains_key("--allow-missing") {
        bail!("{} missing translations", missing.len());
    }
    println!("Validated {} catalogs.", files.len());
    if let Some(path) = options.get("--manifest") {
        let languages = load_locale_manifest(Path::new(path)).map_err(anyhow::Error::msg)?;
        let catalog_tags: std::collections::BTreeSet<_> =
            files.iter().map(|file| file.locale.as_str()).collect();
        let manifest_tags: std::collections::BTreeSet<_> = languages
            .iter()
            .map(|language| language.tag.as_str())
            .collect();
        if catalog_tags != manifest_tags {
            bail!("manifest locale tags must match catalog directories");
        }
        println!(
            "Validated {} ordered language declarations.",
            languages.len()
        );
    }
    Ok(())
}

fn audit(options: &BTreeMap<String, String>) -> Result<()> {
    let findings = audit_ui_sources(
        Path::new(required(options, "--source")?),
        Path::new(required(options, "--config")?),
    )
    .map_err(anyhow::Error::msg)?;
    println!("[");
    for (index, finding) in findings.iter().enumerate() {
        if index > 0 {
            println!(",");
        }
        print!(
            "  {{\"file\":{},\"line\":{},\"column\":{},\"text\":{}}}",
            serde_json::to_string(&finding.file)?,
            finding.line,
            finding.column,
            serde_json::to_string(&finding.text)?
        );
    }
    println!("\n]");
    if !findings.is_empty() && options.contains_key("--check") {
        bail!("{} untranslated UI text literals found", findings.len());
    }
    Ok(())
}

fn native_fixtures(options: &BTreeMap<String, String>) -> Result<()> {
    let content = generate_native_fixtures(
        Path::new(required(options, "--manifest")?),
        Path::new(required(options, "--catalogs")?),
        options.get("--library-catalogs").map(Path::new),
        Path::new(required(options, "--config")?),
    )
    .map_err(anyhow::Error::msg)?;
    write_native_fixture_outputs(options, &content)?;
    write_platform_locale_outputs(options)?;
    println!("Generated native fixtures for the declared languages.");
    Ok(())
}

fn write_native_fixture_outputs(options: &BTreeMap<String, String>, content: &str) -> Result<()> {
    for name in ["--output", "--ios-output"] {
        let Some(path) = options.get(name) else {
            continue;
        };
        write_generated(Path::new(path), content, options.contains_key("--check"))?;
    }
    Ok(())
}

fn write_platform_locale_outputs(options: &BTreeMap<String, String>) -> Result<()> {
    if let Some(path) = options.get("--android-locales") {
        let content = generate_android_locale_config(Path::new(required(options, "--manifest")?))
            .map_err(anyhow::Error::msg)?;
        write_generated(Path::new(path), &content, options.contains_key("--check"))?;
    }
    if let Some(path) = options.get("--ios-plist") {
        let path = Path::new(path);
        let original = fs::read_to_string(path).with_context(|| path.display().to_string())?;
        let content =
            update_ios_localizations(&original, Path::new(required(options, "--manifest")?))
                .map_err(anyhow::Error::msg)?;
        write_generated(path, &content, options.contains_key("--check"))?;
    }
    Ok(())
}

fn write_generated(path: &Path, content: &str, check: bool) -> Result<()> {
    if check {
        let existing = fs::read_to_string(path).with_context(|| path.display().to_string())?;
        if existing != content {
            bail!("{} is stale; rerun native-fixtures", path.display());
        }
    } else {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(path, content).with_context(|| path.display().to_string())?;
    }
    Ok(())
}