Files
agent-manager/src/i18n.rs
T

621 lines
36 KiB
Rust

//! Internationalization (issue #74): locale selection and message lookup.
//!
//! The catalog uses the gettext convention: the **French message is the key**.
//! `tr()` returns its argument unchanged in French mode (zero behavior change
//! for the default locale), and the English translation when one exists in
//! the catalog. A message without an entry falls back to the French text, so
//! the CLI never shows raw keys.
//!
//! Locale resolution order (first match wins):
//! 1. the `--lang` global flag (`am --lang en <cmd>`),
//! 2. the `AM_LANG` environment variable,
//! 3. the `LANG` / `LC_ALL` / `LC_MESSAGES` environment variables,
//! 4. the default: French.
//!
//! Locale values are parsed leniently (`en`, `EN`, `en-US`, `en_US` … all
//! select English); anything unrecognized falls back to the default. Adding
//! another language only requires a new `Lang` variant and one more column in
//! the catalog entries.
//!
//! The `--json` contracts are untouched: only display strings go through the
//! catalog, never serialized field names or event kinds.
use std::sync::atomic::{AtomicU8, Ordering};
/// The two supported output languages.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lang {
Fr,
En,
}
impl Lang {
pub fn code(self) -> &'static str {
match self {
Lang::Fr => "fr",
Lang::En => "en",
}
}
pub fn is_en(self) -> bool {
matches!(self, Lang::En)
}
/// Human-readable label, in the language itself.
pub fn label(self) -> &'static str {
match self {
Lang::Fr => "français",
Lang::En => "english",
}
}
}
// ValueEnum so clap accepts `--lang fr|en` and rejects unknown values.
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum LangArg {
Fr,
En,
}
impl From<LangArg> for Lang {
fn from(v: LangArg) -> Self {
match v {
LangArg::Fr => Lang::Fr,
LangArg::En => Lang::En,
}
}
}
/// Active locale, as an atomic (the REPL can switch it at runtime).
static ACTIVE: AtomicU8 = AtomicU8::new(0); // 0 = Fr, 1 = En
/// Current locale.
pub fn active() -> Lang {
match ACTIVE.load(Ordering::Relaxed) {
1 => Lang::En,
_ => Lang::Fr,
}
}
/// Set the active locale (startup from `--lang`/env, or the REPL `lang`
/// command).
pub fn set_lang(lang: Lang) {
ACTIVE.store(if lang.is_en() { 1 } else { 0 }, Ordering::Relaxed);
}
/// Resolve the locale: explicit flag, then `AM_LANG`, then `LANG`/`LC_ALL`/
/// `LC_MESSAGES`, then the French default.
pub fn resolve(flag: Option<LangArg>) -> Lang {
if let Some(l) = flag {
return l.into();
}
if let Ok(v) = std::env::var("AM_LANG") {
if let Some(l) = parse(&v) {
return l;
}
}
for var in ["LC_ALL", "LC_MESSAGES", "LANG"] {
if let Ok(v) = std::env::var(var) {
if let Some(l) = parse(&v) {
return l;
}
}
}
Lang::Fr
}
/// Lenient parsing: "en", "EN", "en-US", "en_US.UTF-8", "fr-FR" …
fn parse(value: &str) -> Option<Lang> {
let v = value.trim().to_ascii_lowercase();
if v.starts_with("en") {
return Some(Lang::En);
}
if v.starts_with("fr") {
return Some(Lang::Fr);
}
None
}
// ---------------------------------------------------------------------------
// Catalog
// ---------------------------------------------------------------------------
//
// One entry per message: `(French message, English translation)`. The French
// text is the stable key (gettext convention): it is fixed by the call site
// and doubles as the fallback. Placeholders (`{}`, `{name}`) must appear in
// the same order in both languages — a test enforces it.
pub const CATALOG: &[(&str, &str)] = &[
// ---- messages généraux -------------------------------------------------
("aucun agent installé ou en cours d'exécution", "no installed or running agents"),
("aucun agent installé", "no installed agents"),
("aucun résultat", "no results"),
("aucun", "none"),
("inconnu", "unknown"),
("disponible", "available"),
("activé", "enabled"),
("désactivé", "disabled"),
("oui", "yes"),
("non", "no"),
("terminé", "finished"),
("échec", "failed"),
("interrompu", "interrupted"),
("en cours", "running"),
("installé", "installed"),
("non installé", "not installed"),
("externe", "external"),
("non installable", "not installable"),
("erreur", "error"),
("avertissement", "warning"),
("information", "info"),
("confirmer", "confirm"),
("annuler", "cancel"),
("version", "version"),
("chemin", "path"),
("projet", "project"),
("durée", "duration"),
("statut", "status"),
("session", "session"),
("date", "date"),
("début", "start"),
("fin", "end"),
("am ask est désactivé — settings.ask.enabled: true pour l'activer", "am ask is disabled — set settings.ask.enabled: true to enable it"),
("je n'ai pas compris — exemples: «installe claude et lance-le», «liste les agents», «arrête codex»", "I did not understand — examples: «installe claude et lance-le», «liste les agents», «arrête codex»"),
("exécuter ces commandes ?", "run these commands?"),
("annulé", "cancelled"),
("Exécuter ?", "Execute?"),
("dry-run : commande non exécutée", "dry-run: command not executed"),
("politique de sécurité (dry-run par défaut) : commande non exécutée — utilisez --yes", "safety policy (dry-run by default): command not executed — use --yes"),
("aichat n'a retourné aucune commande (dry-run)", "aichat returned no command (dry-run)"),
("aichat n'a pas généré de commande (exit {}){}", "aichat generated no command (exit {}){}"),
("usage: ai <demande> — ex: ai --exec \"compresse les fichiers JSON\"", "usage: ai <request> — e.g. ai --exec \"compress the JSON files\""),
("am n'est pas configuré — lancez am setup (provider + token)", "am is not configured — run am setup (provider + token)"),
("Configuration d'agent-manager (am setup)", "agent-manager configuration (am setup)"),
("Le token est stocké dans le trousseau OS — jamais en clair.", "The token is stored in the OS keyring — never in clear."),
("Installer le moteur IA (aichat) ?", "Install the AI engine (aichat)?"),
("Installation d'aichat…", "Installing aichat…"),
("aichat est manquant. Installer ?", "aichat is missing. Install it?"),
("installez aichat: am install aichat — puis réessayez", "install aichat: am install aichat — then retry"),
("Rôles copilot am-* générés", "am-* copilot roles generated"),
("Config aichat créée (provider + modèle)", "aichat config created (provider + model)"),
("am est configuré — essayez: am ai \"...\" ou am ai --exec \"...\"", "am is configured — try: am ai \"...\" or am ai --exec \"...\""),
("rôle '{}' inconnu — générez les rôles avec: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)", "unknown role '{}' — generate the roles with: am setup --roles (am-copilot, am-operator, am-dev, am-do, am-analyst, am-orchestrator)"),
("rôle 'am-copilot' introuvable — lancez am setup --roles", "role 'am-copilot' not found — run am setup --roles"),
("usage: ask <demande> — ex: ask installe claude et lance-le", "usage: ask <request> — e.g. ask installe claude et lance-le"),
("aucune source enregistrée — settings.registry.sources ou: am registry install <url>", "no sources registered — settings.registry.sources or: am registry install <url>"),
("installation refusée — source non fiable", "installation refused — untrusted source"),
("usage: registry publish <catalogue.yaml> --source <url> | registry search <mot> | registry install <url> | registry list", "usage: registry publish <catalog.yaml> --source <url> | registry search <query> | registry install <url> | registry list"),
("usage: suggest <requête> — ex: suggest un agent pour du Python", "usage: suggest <query> — e.g. suggest un agent pour du Python"),
// ---- providers (#88) ---------------------------------------------------
("aucun provider enregistré — voir: am providers add <nom> --base-url <url>", "no providers registered — see: am providers add <name> --base-url <url>"),
("provider '{}' inconnu", "unknown provider '{}'"),
("nom de provider invalide '{}' — attendu un slug [a-z0-9_-]", "invalid provider name '{}' — expected a slug [a-z0-9_-]"),
("usage: providers add <nom> --base-url <url> [--model <m>] [--models m1,m2]", "usage: providers add <name> --base-url <url> [--model <m>] [--models m1,m2]"),
("provider '{}' mis à jour", "provider '{}' updated"),
("provider '{}' ajouté", "provider '{}' added"),
("positionnez le token dans le keyring: am providers set-token {} (issue #89, à venir)", "store the token in the keyring: am providers set-token {} (issue #89, coming)"),
("provider '{}' supprimé", "provider '{}' removed"),
("le provider par défaut '{}' ne peut pas être supprimé — changez d'abord le provider par défaut", "the default provider '{}' cannot be removed — change the default provider first"),
("provider par défaut : {}", "default provider: {}"),
("usage: providers list | show <nom> | add <nom> --base-url <url> | remove <nom> | default <nom>", "usage: providers list | show <name> | add <name> --base-url <url> | remove <name> | default <name>"),
("token stocké pour le provider '{}'", "token stored for provider '{}'"),
("token présent pour le provider '{}'", "token present for provider '{}'"),
("aucun token pour le provider '{}' — voir: am providers set-token {}", "no token for provider '{}' — see: am providers set-token {}"),
("secret '{}' stocké pour {}", "secret '{}' stored for {}"),
("secret '{}' supprimé pour {}", "secret '{}' removed for {}"),
("secret '{}' inexistant pour {}", "secret '{}' does not exist for {}"),
("--no-config : aucune configuration post-install", "--no-config: no post-install configuration"),
("{} ne prend aucune configuration (install.configurable: false)", "{} takes no configuration (install.configurable: false)"),
("provider '{}' inconnu — providers enregistrés: {}", "unknown provider '{}' — registered providers: {}"),
("provider '{}' (déclaré par {}) non enregistré — voir: am providers add {} --base-url <url>", "provider '{}' (declared by {}) is not registered — see: am providers add {} --base-url <url>"),
("aucun provider configuré — voir: am providers add <nom> --base-url <url>", "no provider configured — see: am providers add <name> --base-url <url>"),
("modèle '{}' absent de la liste du provider '{}' — modèles: {}", "model '{}' is not in provider '{}' model list — models: {}"),
("configuré avec le provider {} (modèle {})", "configured with provider {} (model {})"),
("provider {} (modèle {}) au lancement de {}", "provider {} (model {}) when launching {}"),
("agent sans adaptateur de config — voir sa doc", "agent without a config adapter — see its docs"),
("config par variables d'env — injectée au démarrage", "env-var configuration — injected at start"),
("Configuration manuelle requise — à exécuter :", "Manual setup required — run:"),
("aucun provider résolu — fichiers de config non écrits", "no provider resolved — config files not written"),
("provider '{}' introuvable — fichiers de config non écrits", "provider '{}' not found — config files not written"),
("format inconnu pour {} — adaptateurs: toml, yaml, json, key=value", "unknown format for {} — adapters: toml, yaml, json, key=value"),
("clé '{}' inconnue dans config.files de {}", "unknown key '{}' in config.files of {}"),
("config écrite: {}", "config written: {}"),
("usage: secret set <nom> --agent <agent> --value <valeur> | secret set <nom> --provider <provider> --value <valeur>", "usage: secret set <name> --agent <agent> --value <value> | secret set <name> --provider <provider> --value <value>"),
("{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage", "{} locked entrie(s) — deletion scheduled for the next reboot"),
("{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am", "{} locked entrie(s) — the deferred process will delete them after am exits"),
("vouliez-vous dire", "did you mean"),
("aucune détectée", "none detected"),
("aucune commande enregistrée", "no recorded commands"),
("lancez le shell am et tapez quelques commandes : vos habitudes apparaîtront ici", "start the am shell and type a few commands: your habits will appear here"),
("am help <commande> pour le détail · am tip --random pour une astuce isolée · dans le shell am, tapez simplement 'tip'", "am help <command> for details · am tip --random for a single tip · inside the am shell, just type 'tip'"),
("Le saviez-vous ?", "Did you know?"),
("Vos commandes les plus utilisées", "Your most used commands"),
("Lancer & piloter", "Run & control"),
("Installer & maintenir", "Install & maintain"),
("Observer", "Observe"),
("Personnaliser", "Customize"),
("Boîte à outils", "Toolbox"),
("démarre un agent (premier plan par défaut)", "starts an agent (foreground by default)"),
("arrête un agent en arrière-plan (SIGTERM puis SIGKILL)", "stops a background agent (SIGTERM then SIGKILL)"),
("redémarre avec les mêmes options (agent, groupe, profil…)", "restarts with the same options (agent, group, profile…)"),
("supervise l'agent et le relance s'il meurt", "supervises the agent and restarts it if it dies"),
("exécute la commande de l'agent directement (sans gestion de processus)", "runs the agent command directly (no process management)"),
("agents installés (managed + external détectés sur le PATH)", "installed agents (managed + external detected on PATH)"),
("cherche dans le catalogue (nom, description, catégorie, tags)", "searches the catalog (name, description, category, tags)"),
("recommande un agent pour une demande en langage naturel (tags + usage réel)", "recommends an agent for a natural-language request (tags + real usage)"),
("inventaire des modèles locaux (ollama, llama.cpp, LM Studio)", "inventory of local models (ollama, llama.cpp, LM Studio)"),
("rafraîchit le catalogue officiel (diff + confirmation)", "refreshes the official catalog (diff + confirmation)"),
("qui a modifié quoi, quand (checksums config + journal d'événements)", "who changed what, when (config checksums + event journal)"),
("agent en service système (systemd / launchd / tâche Windows) avec démarrage auto", "registers the agent as a system service (systemd / launchd / Windows task) with autostart"),
("planifie une commande am quotidienne (ex: update --all le matin)", "schedules a daily am command (e.g. update --all in the morning)"),
("vérifications de l'environnement en continu, alerte dès qu'un problème apparaît", "continuous environment checks, alerts as soon as a problem appears"),
("démarre un groupe complet (ordre + attente de santé par défaut, --parallel sans attente)", "starts a whole group (ordered + health wait by default, --parallel without waiting)"),
("annule la dernière mise à jour (backup automatique avant chaque update)", "rolls back the last update (automatic backup before every update)"),
("génère une config projet pré-remplie (web, python, rust, cli)", "generates a pre-filled project config (web, python, rust, cli)"),
("exporte une session : métadonnées + commandes + extrait de log (JSON)", "exports a session: metadata + commands + log excerpt (JSON)"),
("installe l'agent et ses dépendances (Node, Python, Rust…)", "installs the agent and its dependencies (Node, Python, Rust…)"),
("met à jour un agent vers la dernière version disponible", "updates an agent to the latest available version"),
("supprime les fichiers et l'entrée d'état (l'autocomplétion s'actualise)", "removes the files and the state entry (completion refreshes)"),
("état de tous les agents (running / installed / external)", "state of every agent (running / installed / external)"),
("lit la fin du journal de l'agent", "reads the end of the agent log"),
("TUI en direct : activité, stats, sessions, projets", "live TUI: activity, stats, sessions, projects"),
("historique des sessions · statistiques · agents les plus utilisés", "session history · statistics · most used agents"),
("TUI temps réel des processus gérés (CPU, mémoire, uptime) avec alertes de seuils", "real-time TUI of managed processes (CPU, memory, uptime) with threshold alerts"),
("pousse l'état (state.json, journal, historique) dans le dépôt git configuré (sync_repo)", "pushes the state (state.json, journal, history) into the configured git repo (sync_repo)"),
("export/import d'un bundle de transfert machine A → B (config + état + historique + backups)", "export/import of a machine-transfer bundle A → B (config + state + history + backups)"),
("exporte une plage d'historique en playbook YAML (état_dir/playbooks/)", "exports a history range as a YAML playbook (state_dir/playbooks/)"),
("benchmark : même tâche sur plusieurs agents (durée, exit, coût)", "benchmark: the same task on several agents (duration, exit, cost)"),
("scripts d'extension sur les événements (contrat JSON stdin/stdout, timeout)", "event extension scripts (JSON contract stdin/stdout, timeout)"),
("une vue chronologique de toute l'activité", "a chronological view of all activity"),
("crée un raccourci (ex. cc → claude-code)", "creates a shortcut (e.g. cc → claude-code)"),
("profils d'environnement réutilisables (env + args)", "reusable environment profiles (env + args)"),
("configuration effective (catalogue fusionné, alias, groupes…)", "effective configuration (merged catalog, aliases, groups…)"),
("change la palette du shell, des tableaux et de cette page", "changes the palette of the shell, the tables and this page"),
("stocke un secret dans le keyring de l'OS, jamais en clair", "stores a secret in the OS keyring, never in plaintext"),
("annotez vos agents : étoile, note libre, tags personnels", "annotate your agents: star, free note, personal tags"),
("diagnostique l'environnement et répare les problèmes courants", "diagnoses the environment and fixes common problems"),
("sauvegarde config + état, puis restauration", "backs up config + state, then restores"),
("complétion dynamique : agents installés, alias et groupes au Tab", "dynamic completion: installed agents, aliases and groups on Tab"),
("page de manuel pour une commande", "manual page for a command"),
("cherche une commande dans toute l'aide", "searches every help page for a command"),
("met à jour agent-manager lui-même", "updates agent-manager itself"),
("détaché en arrière-plan, sortie dans le log", "detached in the background, output goes to the log"),
("variable d'environnement (répétable)", "environment variable (repeatable)"),
("profil d'environnement (env + args)", "environment profile (env + args)"),
("kill immédiat, sans délai de grâce", "immediate kill, no grace period"),
("délai de grâce avant le kill (défaut : 5 s)", "grace period before the kill (default: 5 s)"),
("relance automatique avec backoff", "automatic restart with backoff"),
("notification bureau à chaque relance", "desktop notification on every restart"),
("catalogue complet, y compris non installés", "the whole catalog, including uninstalled agents"),
("uniquement les agents actifs", "only active agents"),
("name, version ou status", "name, version or status"),
("filtre par catégorie", "filter by category"),
("sortie machine-readable", "machine-readable output"),
("purge les modèles inutilisés", "prunes unused models"),
("simulation sans rien supprimer", "simulation, deletes nothing"),
("seuil d'inutilisation (défaut : 30)", "unused threshold (default: 30)"),
("ajoute un catalogue d'équipe par URL", "adds a team catalog by URL"),
("rapport machine-readable", "machine-readable report"),
("état du service", "service status"),
("retire le service", "removes the service"),
("liste les planifications", "lists the schedules"),
("supprime une planification", "removes a schedule"),
("répare automatiquement ce qui peut l'être", "automatically repairs what can be repaired"),
("exécute l'agent dans son profil conteneur (docker/podman)", "runs the agent inside its container profile (docker/podman)"),
("liste les points de restauration", "lists the restore points"),
("restaure un point précis", "restores a specific point"),
("liste les templates disponibles", "lists the available templates"),
("destination (défaut ./am-session-<id>.json)", "destination (default ./am-session-<id>.json)"),
("purge les sessions terminées au-delà de N jours", "purges sessions finished more than N days ago"),
("choisit la méthode (index ou type:npm, binary…)", "selects the method (index or type:npm, binary…)"),
("réinstalle même si déjà présent", "reinstalls even if already present"),
("tous les agents installés d'un coup", "every installed agent at once"),
("supprime aussi les logs et la définition de la config", "also removes the logs and the config definition"),
("suit le log en direct", "follows the log live"),
("nombre de lignes (défaut : 20)", "number of lines (default: 20)"),
("7d, 30d, 90d ou all", "7d, 30d, 90d or all"),
("tokens + coût estimé par agent (issue #49)", "tokens + estimated cost per agent (issue #49)"),
("rafraîchissement (défaut 2s)", "refresh interval (default 2s)"),
("flux JSON pour les scripts", "JSON stream for scripts"),
("message de commit personnalisé", "custom commit message"),
("pousse automatiquement à la fermeture du REPL", "pushes automatically when the REPL closes"),
("crée agent-manager-migrate.amx", "creates agent-manager-migrate.amx"),
("chemin du bundle", "bundle path"),
("fichier playbook", "playbook file"),
("rejoue pas à pas avec confirmation", "replays step by step with confirmation"),
("exécution concurrente", "concurrent execution"),
("rapport comparatif stable (scripts/CI)", "stable comparison report (scripts/CI)"),
("exécute un plugin sur un événement de test (CI)", "runs a plugin against a test event (CI)"),
("activité à partir d'une date", "activity from a given date"),
("fichier de destination (défaut : am-export.json)", "destination file (default: am-export.json)"),
("vérifie seulement si une version existe", "only checks whether a version exists"),
("exécution séquentielle", "sequential execution"),
("kill de l'arbre de processus au timeout", "kills the process tree on timeout"),
("Tab complète vos agents", "Tab completes your agents"),
("dans le shell am, tapez 'start ' puis Tab : agents installés, alias et groupes s'affichent — et la liste se met à jour après chaque install/uninstall.", "in the am shell, type 'start ' then Tab: installed agents, aliases and groups show up — and the list refreshes after every install/uninstall."),
("!! relance la dernière commande", "!! re-runs the last command"),
("l'historique du shell accepte !!, !N et !prefix pour rejouer une commande sans la retaper.", "the shell history accepts !!, !N and !prefix to re-run a command without retyping it."),
("Le shell am double le shell système", "The am shell doubles as the system shell"),
("tout ce qui n'est pas une commande am est exécuté par votre shell (pwsh, cmd, bash…) : pas besoin de sortir pour lancer un outil.", "anything that is not an am command is run by your shell (pwsh, cmd, bash…): no need to leave to launch a tool."),
("--dry-run simule sans rien changer", "--dry-run simulates without changing anything"),
("testez 'install', 'update' ou 'uninstall' en toute sécurité : am affiche ce qu'il ferait, sans toucher au disque.", "test 'install', 'update' or 'uninstall' safely: am shows what it would do, without touching the disk."),
("Les groupes lancent plusieurs agents", "Groups launch several agents"),
("'start group:dev' démarre tout le groupe d'un coup ; créez les vôtres dans la config.", "'start group:dev' launches the whole group at once; create your own in the config."),
("Cherchez dans toute l'aide", "Search all the help"),
("'am help --find <mot>' parcourt descriptions, usages et exemples de toutes les commandes.", "'am help --find <word>' scans the descriptions, usages and examples of every command."),
("--json pour les scripts", "--json for scripts"),
("quasiment toutes les commandes acceptent --json et sortent des données structurées, parfaites pour vos scripts.", "almost every command accepts --json and outputs structured data, perfect for your scripts."),
("Les alias raccourcissent tout", "Aliases shorten everything"),
("'alias add cc claude-code' puis 'start cc' : un nom court pour chaque agent.", "'alias add cc claude-code' then 'start cc': a short name for every agent."),
("export/import sauvegardent votre monde", "export/import back up your world"),
("config, état, annotations et profils partent dans un fichier JSON, restaurables avec 'import'.", "config, state, annotations and profiles go into a JSON file, restorable with 'import'."),
("Les secrets vivent dans le keyring", "Secrets live in the keyring"),
("'secret set API_KEY --agent pi' stocke la valeur dans le trousseau de l'OS, jamais en clair dans la config.", "'secret set API_KEY --agent pi' stores the value in the OS keychain, never in plaintext in the config."),
("Le thème suit votre humeur", "The theme follows your mood"),
("'theme ocean', 'theme dracula'… la palette du shell am, des tableaux et de cette page change instantanément.", "'theme ocean', 'theme dracula'… the palette of the am shell, the tables and this page changes instantly."),
("doctor répare", "doctor repairs"),
("'am doctor --fix' vérifie l'environnement et corrige les problèmes courants (dossiers manquants, état corrompu…).", "'am doctor --fix' checks the environment and fixes common problems (missing folders, corrupted state…)."),
// ---- web (issue #54) ---------------------------------------------------
("am web — {} (Ctrl-C pour arrêter)", "am web — {} (Ctrl-C to stop)"),
("ouverture du navigateur…", "opening the browser…"),
("Vue d'ensemble", "Overview"),
("Statistiques", "Statistics"),
("Événements", "Events"),
("Projets", "Projects"),
("Agents installés", "Installed agents"),
("En cours", "Running"),
("Sessions (7 j)", "Sessions (7d)"),
("Événements (24 h)", "Events (24h)"),
("Top agents — sessions (7 j)", "Top agents — sessions (7d)"),
("Activité — sessions par jour (14 j)", "Activity — sessions per day (14d)"),
("Activité récente", "Recent activity"),
("Sessions en cours", "Running sessions"),
("Statistiques par agent — période 30 j", "Per-agent statistics — last 30 days"),
("Session", "Session"),
("Agent", "Agent"),
("Projet", "Project"),
("Statut", "Status"),
("Durée", "Duration"),
("Heure", "Time"),
("Type", "Type"),
("Détail", "Details"),
("Dernière activité", "Last activity"),
("Journal d'événements — aujourd'hui", "Event journal — today"),
("Dernière mise à jour", "Last updated"),
("Filtrer par agent, projet ou id…", "Filter by agent, project or id…"),
("Aucune session ne correspond au filtre", "No sessions match the filter"),
("Aucune session en cours", "No running sessions"),
("Aucun événement", "No events"),
("Aucune activité enregistrée", "No activity recorded yet"),
("Aucun projet suivi", "No tracked projects"),
("Actualiser", "Refresh"),
("serveur local", "local server"),
("Temps total", "Total time"),
("Projets suivis", "Tracked projects"),
("Terminée", "Finished"),
("Échec", "Failed"),
("Interrompue", "Interrupted"),
("chargement…", "loading…"),
("erreur de chargement", "load error"),
("Lancements", "Launches"),
("Durée moyenne", "Avg duration"),
("Échecs", "Failures"),
("Démarrée", "Started"),
("Fin", "End"),
];
/// Look up the English translation of a French message. Returns None when the
/// message is not in the catalog (the caller falls back to the French text).
pub fn lookup_en(fr: &str) -> Option<&'static str> {
CATALOG
.iter()
.find(|(k, _)| *k == fr)
.map(|(_, en)| *en)
}
/// Translate a message for the active locale. In French mode the input is
/// returned unchanged; in English mode the catalog translation is used, with
/// the French text as fallback when the message is not translated yet.
pub fn tr<'a>(key: &'a str) -> &'a str {
tr_in(active(), key)
}
/// Translate a message for an explicit locale.
pub fn tr_in<'a>(lang: Lang, key: &'a str) -> &'a str {
match lang {
Lang::Fr => key,
Lang::En => lookup_en(key).unwrap_or(key),
}
}
/// Format a localized message: `tr_fmt!("agent '{}' installé", name)`.
/// The English translation keeps the same placeholders (enforced by test).
/// Edition 2021 forbids non-literal format strings, so the formatting is
/// done by a small runtime `{}`-replacer; catalog entries therefore use
/// positional `{}` placeholders only (no `{name}`, `{:.1}`, …).
#[macro_export]
macro_rules! tr_fmt {
($key:literal $(, $arg:expr)*) => {
$crate::i18n::tr_fmt($key, &[$(&$arg as &dyn ::std::fmt::Display),*])
};
}
/// Format a message for the active locale, replacing every `{}` with the
/// Display rendering of the matching argument.
pub fn tr_fmt<'a>(key: &'a str, args: &[&dyn std::fmt::Display]) -> String {
match active() {
Lang::Fr => fmt_args(key, args),
Lang::En => fmt_args(lookup_en(key).unwrap_or(key), args),
}
}
/// Format a message for a GIVEN locale (deterministic — follows the app's
/// resolved language instead of the process-global locale).
pub fn tr_fmt_in<'a>(lang: Lang, key: &'a str, args: &[&dyn std::fmt::Display]) -> String {
match lang {
Lang::Fr => fmt_args(key, args),
Lang::En => fmt_args(lookup_en(key).unwrap_or(key), args),
}
}
/// Replace `{}` placeholders (positional) with the Display of each argument.
fn fmt_args(template: &str, args: &[&dyn std::fmt::Display]) -> String {
let mut out = String::with_capacity(template.len() + 16);
let mut rest = template;
let mut i = 0;
while let Some(pos) = rest.find("{}") {
out.push_str(&rest[..pos]);
if let Some(arg) = args.get(i) {
out.push_str(&arg.to_string());
}
i += 1;
rest = &rest[pos + 2..];
}
out.push_str(rest);
out
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// Env-var tests mutate the process environment and would race when run
/// in parallel; serialize them.
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn placeholders(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = s;
while let Some(i) = rest.find('{') {
let after = &rest[i..];
let close = after.find('}').map(|j| j + 1).unwrap_or(after.len());
out.push(after[..close].to_string());
rest = &after[close..];
}
out
}
#[test]
fn parses_locale_values() {
assert_eq!(parse("en"), Some(Lang::En));
assert_eq!(parse("EN"), Some(Lang::En));
assert_eq!(parse("en-US"), Some(Lang::En));
assert_eq!(parse("en_US.UTF-8"), Some(Lang::En));
assert_eq!(parse("fr"), Some(Lang::Fr));
assert_eq!(parse("fr-FR"), Some(Lang::Fr));
assert_eq!(parse("de"), None);
assert_eq!(parse(""), None);
assert_eq!(parse(" en "), Some(Lang::En));
}
#[test]
fn flag_wins_over_env() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("AM_LANG", "en");
std::env::set_var("LANG", "fr_FR");
let flag = Some(LangArg::En);
assert_eq!(resolve(flag), Lang::En);
let flag = Some(LangArg::Fr);
assert_eq!(resolve(flag), Lang::Fr);
}
#[test]
fn am_lang_env_beats_lang_env() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("AM_LANG", "en");
std::env::set_var("LANG", "fr_FR");
assert_eq!(resolve(None), Lang::En);
}
#[test]
fn lang_env_falls_back_to_default() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var("AM_LANG");
std::env::set_var("LANG", "en_GB");
assert_eq!(resolve(None), Lang::En);
std::env::set_var("LANG", "fr_FR");
assert_eq!(resolve(None), Lang::Fr);
std::env::remove_var("LANG");
std::env::remove_var("LC_ALL");
std::env::remove_var("LC_MESSAGES");
assert_eq!(resolve(None), Lang::Fr);
}
#[test]
fn unknown_env_falls_back_to_default() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var("AM_LANG");
std::env::set_var("LANG", "de_DE");
assert_eq!(resolve(None), Lang::Fr);
}
#[test]
fn tr_returns_input_in_french() {
set_lang(Lang::Fr);
assert_eq!(tr("aucun agent installé"), "aucun agent installé");
assert_eq!(tr("message non catalogué"), "message non catalogué");
}
#[test]
fn tr_translates_in_english_and_falls_back() {
set_lang(Lang::En);
assert_eq!(tr("aucun agent installé"), "no installed agents");
// Not in the catalog: the French text is the fallback, never a key.
assert_eq!(tr("message non catalogué"), "message non catalogué");
}
#[test]
fn tr_fmt_formats_both_locales() {
set_lang(Lang::Fr);
assert_eq!(crate::tr_fmt!("agent '{}' installé", "cc"), "agent 'cc' installé");
}
#[test]
fn fmt_args_replaces_positional_placeholders() {
assert_eq!(fmt_args("a {} b", &[&"x", &"y"]), "a x b");
assert_eq!(fmt_args("{}", &[&42]), "42");
assert_eq!(fmt_args("no placeholders", &[]), "no placeholders");
assert_eq!(fmt_args("{} {}", &[&1]), "1 ");
assert_eq!(fmt_args("", &[&1]), "");
}
#[test]
fn catalog_is_consistent() {
let mut seen = std::collections::HashSet::new();
for (fr, en) in CATALOG {
assert!(!fr.is_empty(), "empty french key");
assert!(!en.is_empty(), "empty english translation for {fr:?}");
assert!(seen.insert(*fr), "duplicate catalog key: {fr:?}");
// Placeholders must line up between the two languages.
assert_eq!(
placeholders(fr),
placeholders(en),
"placeholder mismatch for {fr:?}"
);
// Catalog entries only use positional {} placeholders: the
// runtime formatter (tr_fmt) cannot honour {name} or {:.1}.
assert!(
!fr.contains('{') || placeholders(fr).iter().all(|p| p == "{}"),
"{fr:?}: only '{{}}' placeholders are allowed in the catalog"
);
}
}
#[test]
fn every_placeholder_in_fr_is_present_in_en() {
for (fr, en) in CATALOG {
for ph in placeholders(fr) {
assert!(
en.contains(&ph),
"{fr:?}: placeholder {ph} missing in {en:?}"
);
}
}
}
}