use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use serde_json::{Map, Value};
use crate::{ui, workspace};
pub(crate) const BUNDLE_DIRS: [&str; 2] = ["i18n", "email_i18n"];
#[derive(Debug, Parser)]
pub struct I18nArgs {
#[command(subcommand)]
pub command: I18nCommand,
}
#[derive(Debug, Subcommand)]
pub enum I18nCommand {
Check(CheckArgs),
}
#[derive(Debug, Parser)]
pub struct CheckArgs {
#[arg(long, conflicts_with = "all")]
pub module: Option<String>,
#[arg(long)]
pub all: bool,
}
pub fn run(args: I18nArgs) -> Result<()> {
let I18nCommand::Check(args) = args.command;
ui::warn(crate::tr!(
"portaki i18n check is now portaki check --only i18n — use that from now on",
"portaki i18n check devient portaki check --only i18n — utilisez désormais ce nom"
));
for member in workspace::resolve(args.module.as_deref(), Some(args.all))? {
gate(&member.root).with_context(|| format!("module {}", member.id))?;
}
ui::blank();
Ok(())
}
pub fn gate(module_root: &Path) -> Result<()> {
let checking = ui::step(crate::tr!(
"checking every text in every language",
"vérification de chaque texte dans chaque langue"
));
let problems = problems(module_root)?;
if problems.is_empty() {
checking.done(crate::tr!(
"every text, in every language",
"chaque texte, dans chaque langue"
));
return Ok(());
}
checking.abandon();
for problem in &problems {
ui::detail(problem);
}
anyhow::bail!(crate::tr!(
"{} text(s) missing — write them, then run portaki check --only i18n again",
"{} texte(s) manquant(s) — écrivez-les, puis relancez portaki check --only i18n",
problems.len()
))
}
pub fn problems(module_root: &Path) -> Result<Vec<String>> {
let mut problems = Vec::new();
for dir in BUNDLE_DIRS {
let bundles = read_bundles(&module_root.join(dir))?;
problems.extend(
incomplete(&bundles)
.into_iter()
.map(|problem| format!("{dir}/{problem}")),
);
}
Ok(problems)
}
pub(crate) fn read_bundles(dir: &Path) -> Result<BTreeMap<String, Map<String, Value>>> {
let mut bundles = BTreeMap::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(bundles);
};
for path in entries.flatten().map(|entry| entry.path()) {
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let raw =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let texts: Map<String, Value> = serde_json::from_str(&raw)
.with_context(|| format!("{} is not a JSON object", path.display()))?;
let locale = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
bundles.insert(locale, texts);
}
Ok(bundles)
}
fn incomplete(bundles: &BTreeMap<String, Map<String, Value>>) -> Vec<String> {
let every: BTreeSet<&String> = bundles.values().flat_map(Map::keys).collect();
let mut problems = Vec::new();
for (locale, texts) in bundles {
for key in &every {
match texts.get(*key) {
None => problems.push(format!("{locale}.json: `{key}` missing")),
Some(Value::String(text)) if text.trim().is_empty() => {
problems.push(format!("{locale}.json: `{key}` empty"))
}
_ => {}
}
}
}
problems
}
#[cfg(test)]
mod tests {
use super::*;
fn bundles(raw: &[(&str, Value)]) -> BTreeMap<String, Map<String, Value>> {
raw.iter()
.map(|(locale, texts)| (locale.to_string(), texts.as_object().unwrap().clone()))
.collect()
}
#[test]
fn a_key_one_language_lacks_is_named() {
let found = incomplete(&bundles(&[
(
"fr-FR",
serde_json::json!({ "title": "Accès", "cta": "Voir" }),
),
(
"en-US",
serde_json::json!({ "title": "Access", "extra": " " }),
),
]));
assert_eq!(
found,
vec![
"en-US.json: `cta` missing",
"en-US.json: `extra` empty",
"fr-FR.json: `extra` missing",
]
);
}
#[test]
fn matching_bundles_pass() {
let found = incomplete(&bundles(&[
("fr", serde_json::json!({ "a": "x" })),
("en", serde_json::json!({ "a": "y" })),
]));
assert!(found.is_empty());
}
}