linguini-cli 0.1.0-alpha.3

Command-line interface for Linguini localization projects
use crate::{CliError, CliResult, FixArgs};
use linguini_analyzer::{locale_public_messages, schema_public_messages};
use linguini_config::LinguiniConfig;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use super::io::{create_dir_all, path_for_output, read_file, read_project_config, write_file};
use super::sources::{
    expected_locale_path, load_locale_sources, load_schema_sources, locale_index,
};
use super::util::pluralize;

#[derive(Debug, Clone)]
struct ProjectFix {
    id: String,
    title: String,
    operation: FixOperation,
}

#[derive(Debug, Clone)]
enum FixOperation {
    CreateFile { path: PathBuf, contents: String },
    AppendFile { path: PathBuf, contents: String },
}

pub(crate) fn fix_project(root: &Path, args: &FixArgs) -> CliResult<String> {
    let config = read_project_config(root)?;
    let fixes = available_project_fixes(root, &config)?;

    if !args.all && args.ids.is_empty() {
        return Ok(render_fix_list(&fixes));
    }

    let selected = select_fixes(&fixes, args)?;
    let mut output = String::new();
    for fix in selected {
        apply_project_fix(root, &fix, &mut output)?;
    }

    if output.is_empty() {
        output.push_str("no automatic fixes applied\n");
    }
    Ok(output)
}

fn render_fix_list(fixes: &[ProjectFix]) -> String {
    let mut output = String::new();
    if fixes.is_empty() {
        output.push_str("no automatic fixes available\n");
        return output;
    }

    output.push_str("available fixes:\n");
    for fix in fixes {
        output.push_str(&format!("- {}: {}\n", fix.id, fix.title));
    }
    output.push_str("run `linguini fix --all` or `linguini fix <id>...`\n");
    output
}

fn available_project_fixes(root: &Path, config: &LinguiniConfig) -> CliResult<Vec<ProjectFix>> {
    let schema_files = load_schema_sources(root, config)?;
    let locale_files = load_locale_sources(root, config)?;
    let locale_index = locale_index(&locale_files);
    let mut fixes = Vec::new();

    for schema_file in &schema_files {
        let schema_messages = schema_public_messages(&schema_file.ast);
        let schema_message_names = schema_messages
            .iter()
            .map(|message| message.name.clone())
            .collect::<Vec<_>>();

        for locale in &config.project.locales {
            let key = (schema_file.file.namespace.clone(), locale.clone());
            match locale_index.get(&key) {
                Some(locale_file) => {
                    let locale_messages = locale_public_messages(&locale_file.ast);
                    let missing = missing_schema_message_names(&schema_messages, &locale_messages);
                    if missing.is_empty() {
                        continue;
                    }
                    fixes.push(ProjectFix {
                        id: missing_messages_fix_id(&schema_file.file.namespace, locale),
                        title: format!(
                            "add {} missing message {} to {}",
                            missing.len(),
                            pluralize(missing.len(), "stub", "stubs"),
                            path_for_output(root, &locale_file.file.path)
                        ),
                        operation: FixOperation::AppendFile {
                            path: locale_file.file.path.clone(),
                            contents: append_stub_text(&locale_file.source, &missing),
                        },
                    });
                }
                None => {
                    let path =
                        expected_locale_path(root, config, &schema_file.file.namespace, locale);
                    fixes.push(ProjectFix {
                        id: missing_locale_fix_id(&schema_file.file.namespace, locale),
                        title: format!("create locale file {}", path_for_output(root, &path)),
                        operation: FixOperation::CreateFile {
                            path,
                            contents: locale_stub_text(&schema_message_names),
                        },
                    });
                }
            }
        }
    }

    Ok(fixes)
}

fn select_fixes(fixes: &[ProjectFix], args: &FixArgs) -> CliResult<Vec<ProjectFix>> {
    if args.all {
        return Ok(fixes.to_vec());
    }

    let by_id = fixes
        .iter()
        .map(|fix| (fix.id.as_str(), fix))
        .collect::<BTreeMap<_, _>>();
    let mut selected = Vec::new();
    let mut missing = Vec::new();

    for id in &args.ids {
        match by_id.get(id.as_str()) {
            Some(fix) => selected.push((*fix).clone()),
            None => missing.push(id.clone()),
        }
    }

    if !missing.is_empty() {
        return Err(CliError::Diagnostics(format!(
            "unknown fix {}: {}\nrun `linguini fix` to list available fixes\n",
            pluralize(missing.len(), "id", "ids"),
            missing.join(", ")
        )));
    }

    Ok(selected)
}

fn apply_project_fix(root: &Path, fix: &ProjectFix, output: &mut String) -> CliResult<()> {
    match &fix.operation {
        FixOperation::CreateFile { path, contents } => {
            if let Some(parent) = path.parent() {
                create_dir_all(parent)?;
            }
            if !path.exists() {
                write_file(path, contents)?;
            }
            output.push_str(&format!(
                "applied {}: created {}\n",
                fix.id,
                path_for_output(root, path)
            ));
        }
        FixOperation::AppendFile { path, contents } => {
            let mut existing = read_file(path)?;
            existing.push_str(contents);
            write_file(path, &existing)?;
            output.push_str(&format!(
                "applied {}: updated {}\n",
                fix.id,
                path_for_output(root, path)
            ));
        }
    }
    Ok(())
}

fn missing_schema_message_names(
    schema_messages: &[linguini_analyzer::RequiredLocaleMessage],
    locale_messages: &[linguini_analyzer::ImplementedLocaleMessage],
) -> Vec<String> {
    let locale_names = locale_messages
        .iter()
        .map(|message| message.name.as_str())
        .collect::<BTreeSet<_>>();

    schema_messages
        .iter()
        .filter(|message| !locale_names.contains(message.name.as_str()))
        .map(|message| message.name.clone())
        .collect()
}

fn append_stub_text(existing: &str, names: &[String]) -> String {
    let mut output = String::new();
    if !existing.is_empty() && !existing.ends_with('\n') {
        output.push('\n');
    }
    if !existing.is_empty() {
        output.push('\n');
    }
    output.push_str(&locale_stub_text(names));
    output
}

fn locale_stub_text(names: &[String]) -> String {
    let mut output = String::new();
    let mut groups: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
    let mut top_level = Vec::new();

    for name in names {
        if let Some((group, message)) = name.split_once('.') {
            groups.entry(group).or_default().push(message);
        } else {
            top_level.push(name.as_str());
        }
    }

    for name in top_level {
        output.push_str(&format!("{name} = TODO\n"));
    }
    for (group, messages) in groups {
        output.push_str(&format!("{group} {{\n"));
        for message in messages {
            output.push_str(&format!("  {message} = TODO\n"));
        }
        output.push_str("}\n");
    }
    output
}

pub(crate) fn missing_locale_fix_id(namespace: &str, locale: &str) -> String {
    format!("missing-locale:{}:{}", fix_id_namespace(namespace), locale)
}

pub(crate) fn missing_messages_fix_id(namespace: &str, locale: &str) -> String {
    format!(
        "missing-messages:{}:{}",
        fix_id_namespace(namespace),
        locale
    )
}

fn fix_id_namespace(namespace: &str) -> String {
    if namespace.is_empty() {
        "root".to_owned()
    } else {
        namespace.to_owned()
    }
}