Skip to main content

sparrow/
errors.rs

1//! Humanized error messages in French.
2//!
3//! Maps technical errors (HTTP status codes, connection failures, model lookup
4//! failures, config errors) to friendly, actionable messages in French. Every
5//! public function returns a `String` suitable for direct display to the user.
6
7use crate::provider::BrainError;
8
9/// Translate a [`BrainError`] into a French human-readable message.
10///
11/// The `provider_label` is the display name of the provider (e.g. "Anthropic",
12/// "NVIDIA NIM"). The `model` is the model name that was requested when the
13/// error occurred (empty string when unknown).
14///
15/// # Example
16/// ```ignore
17/// let err = BrainError::ServerError { status: 401, body: String::new() };
18/// let msg = errors::humanize_brain_error(&err, "Anthropic", "claude-sonnet-4-6");
19/// println!("{}", msg);
20/// ```
21pub fn humanize_brain_error(err: &BrainError, provider_label: &str, model: &str) -> String {
22    match err {
23        BrainError::RateLimit { retry_after } => {
24            humanize_rate_limit(provider_label, *retry_after)
25        }
26        BrainError::ServerError { status, body } => {
27            humanize_http_error(*status, body, provider_label, model)
28        }
29        BrainError::Timeout => {
30            format!(
31                "⏱️  {provider} a mis trop de temps à répondre. Réessaie ou change de provider (sparrow route set <provider>).",
32                provider = provider_label,
33            )
34        }
35        BrainError::Refusal(msg) => {
36            format!(
37                "🚫 {provider} a refusé la requête : {msg}\n→ Reformule ta demande ou change de modèle avec sparrow model --set <provider>:<model>.",
38                provider = provider_label,
39                msg = msg,
40            )
41        }
42        BrainError::Unknown(msg) => {
43            format!(
44                "❓ Erreur inconnue chez {provider} : {msg}\n→ Lance sparrow doctor pour un diagnostic complet.",
45                provider = provider_label,
46                msg = msg,
47            )
48        }
49    }
50}
51
52/// Translate a raw HTTP status code + body into a French message.
53fn humanize_http_error(status: u16, body: &str, provider_label: &str, model: &str) -> String {
54    match status {
55        401 | 403 => humanize_unauthorized(provider_label),
56        404 => {
57            if body.contains("model") || body.contains("Model") {
58                humanize_model_not_found(provider_label, model)
59            } else {
60                format!(
61                    "🔍 Ressource introuvable chez {provider}. Vérifie l'URL de base (sparrow config --edit).",
62                    provider = provider_label,
63                )
64            }
65        }
66        429 => humanize_rate_limit(provider_label, None),
67        500..=599 => {
68            if body.to_lowercase().contains("overloaded")
69                || body.to_lowercase().contains("capacity")
70            {
71                format!(
72                    "🏋️ {provider} est surchargé. Réessaie dans quelques minutes ou change de provider (sparrow route set <provider>).",
73                    provider = provider_label,
74                )
75            } else {
76                format!(
77                    "💥 Erreur serveur {provider} (HTTP {status}). C'est probablement temporaire — réessaie dans 30 secondes.\n→ Détails : {body}",
78                    provider = provider_label,
79                    status = status,
80                    body = body,
81                )
82            }
83        }
84        _ => format!(
85            "⚠️  Erreur HTTP {status} chez {provider} : {body}\n→ Lance sparrow doctor pour un diagnostic.",
86            status = status,
87            provider = provider_label,
88            body = body,
89        ),
90    }
91}
92
93/// "Ta clé API est invalide ou expirée…"
94fn humanize_unauthorized(provider_label: &str) -> String {
95    let (url, signup_hint) = match provider_label.to_lowercase().as_str() {
96        "anthropic" => (
97            "https://console.anthropic.com/settings/keys",
98            " (payant, ~20$/mois de crédits offerts)",
99        ),
100        "nvidia nim" | "nvidia" => (
101            "https://build.nvidia.com/explore/discover",
102            " (gratuit — crée un compte NVIDIA Developer)",
103        ),
104        "openai" | "openai codex" => (
105            "https://platform.openai.com/api-keys",
106            " (payant, crédits gratuits à l'inscription)",
107        ),
108        "google gemini" | "gemini" => (
109            "https://aistudio.google.com/app/apikey",
110            " (gratuit jusqu'à 1500 requêtes/jour)",
111        ),
112        "groq" => (
113            "https://console.groq.com/keys",
114            " (gratuit — généreux tier gratuit)",
115        ),
116        "deepseek" => (
117            "https://platform.deepseek.com/api_keys",
118            " (très bon marché, ~0.27$/M tokens)",
119        ),
120        "openrouter" => (
121            "https://openrouter.ai/keys",
122            " (crédits gratuits à l'inscription)",
123        ),
124        "xai" | "xai (grok)" => (
125            "https://console.x.ai/",
126            " (payant)",
127        ),
128        _ => ("", ""),
129    };
130
131    if url.is_empty() {
132        format!(
133            "🔑 Ta clé API pour {provider} est invalide ou expirée.\n\
134             → Vérifie ta clé avec : sparrow auth list\n\
135             → Ajoutes-en une nouvelle : sparrow auth add {provider_lower}",
136            provider = provider_label,
137            provider_lower = provider_label.to_lowercase(),
138        )
139    } else {
140        format!(
141            "🔑 Ta clé API {provider} est invalide ou expirée.\n\
142             → Va sur {url} pour en créer une{signup}\n\
143             → Puis ajoute-la avec : sparrow auth add {provider_lower}\n\
144             → Ou exporte-la : export {env_var}=\"ta-clé\"",
145            provider = provider_label,
146            url = url,
147            signup = signup_hint,
148            provider_lower = provider_label.to_lowercase().replace(' ', "-"),
149            env_var = provider_env_var(provider_label),
150        )
151    }
152}
153
154/// "T'as envoyé trop de requêtes…"
155fn humanize_rate_limit(provider_label: &str, retry_after: Option<u64>) -> String {
156    let wait = match retry_after {
157        Some(s) if s > 0 => format!("{s} secondes"),
158        _ => "quelques minutes".to_string(),
159    };
160    format!(
161        "⏳ T'as envoyé trop de requêtes à {provider}. Réessaie dans {wait}.\n\
162         → Astuce : utilise un modèle moins cher pour les tâches simples (sparrow route set nvidia).",
163        provider = provider_label,
164        wait = wait,
165    )
166}
167
168/// "Le modèle X n'existe pas chez Y…"
169fn humanize_model_not_found(provider_label: &str, model: &str) -> String {
170    format!(
171        "🤷 Le modèle \"{model}\" n'existe pas chez {provider}.\n\
172         → Liste les modèles dispos : sparrow model --list\n\
173         → Change de modèle : sparrow model --set {provider_lower}:<model>",
174        model = model,
175        provider = provider_label,
176        provider_lower = provider_label.to_lowercase().replace(' ', "-"),
177    )
178}
179
180/// Guess the standard env-var name for a provider.
181fn provider_env_var(provider_label: &str) -> String {
182    match provider_label.to_lowercase().as_str() {
183        "anthropic" => "ANTHROPIC_API_KEY".into(),
184        "nvidia nim" | "nvidia" => "NVIDIA_API_KEY".into(),
185        "openai" | "openai codex" => "OPENAI_API_KEY".into(),
186        "google gemini" | "gemini" => "GEMINI_API_KEY".into(),
187        "groq" => "GROQ_API_KEY".into(),
188        "deepseek" => "DEEPSEEK_API_KEY".into(),
189        "openrouter" => "OPENROUTER_API_KEY".into(),
190        "xai" | "xai (grok)" => "XAI_API_KEY".into(),
191        "huggingface" | "hugging face" => "HF_TOKEN".into(),
192        "nous" | "nous portal" => "NOUS_API_KEY".into(),
193        "novita" | "novitaai" => "NOVITA_API_KEY".into(),
194        "alibaba" | "alibaba cloud" => "DASHSCOPE_API_KEY".into(),
195        other => format!("{}_API_KEY", other.to_uppercase().replace(' ', "_").replace('-', "_")),
196    }
197}
198
199/// Translate a connection-refused / DNS / timeout error into a French message.
200///
201/// Call this when `reqwest` returns a connection error (not an HTTP error).
202pub fn humanize_connection_error(provider_label: &str, error_msg: &str) -> String {
203    let lower = error_msg.to_lowercase();
204
205    if lower.contains("connection refused") || lower.contains("connect refused") {
206        format!(
207            "🔌 Impossible de contacter {provider}. Le serveur a refusé la connexion.\n\
208             → Vérifie l'URL de base : sparrow config --edit\n\
209             → Si t'utilises Ollama : ollama serve doit tourner.",
210            provider = provider_label,
211        )
212    } else if lower.contains("dns") || lower.contains("no address") || lower.contains("name") {
213        format!(
214            "🌐 Impossible de résoudre le nom d'hôte de {provider}. Check ta connexion internet ou DNS.",
215            provider = provider_label,
216        )
217    } else if lower.contains("timeout") || lower.contains("timed out") {
218        format!(
219            "⏱️  Timeout en contactant {provider}. Check ta connexion ou VPN.\n\
220             → Si le problème persiste, change de provider : sparrow route set nvidia",
221            provider = provider_label,
222        )
223    } else if lower.contains("ssl") || lower.contains("tls") || lower.contains("certificate") {
224        format!(
225            "🔒 Erreur SSL/TLS avec {provider}. Vérifie tes certificats ou désactive le VPN.\n\
226             → Détail : {error_msg}",
227            provider = provider_label,
228            error_msg = error_msg,
229        )
230    } else {
231        format!(
232            "📡 Problème de connexion vers {provider} : {error_msg}\n\
233             → Check ta connexion ou VPN.\n\
234             → Lance sparrow doctor pour un diagnostic complet.",
235            provider = provider_label,
236            error_msg = error_msg,
237        )
238    }
239}
240
241/// Config-related errors in French.
242pub fn humanize_config_error(error_type: &str, context: &str) -> String {
243    match error_type {
244        "no_provider" => format!(
245            "⚙️  Aucun provider configuré.\n\
246             → Lance le setup : sparrow setup\n\
247             → Ou définis une variable d'environnement : export {ctx}_API_KEY=\"ta-clé\"\n\
248             → Providers gratuits dispos : NVIDIA (sparrow auth add nvidia), Groq, Gemini",
249            ctx = context.to_uppercase(),
250        ),
251        "no_model" => format!(
252            "⚙️  Aucun modèle défini pour le provider \"{ctx}\".\n\
253             → Liste les modèles : sparrow model --list\n\
254             → Définis un modèle : sparrow model --set {ctx}:<nom-du-modèle>",
255            ctx = context,
256        ),
257        "no_config_file" => format!(
258            "📄 Pas de fichier de config Sparrow trouvé.\n\
259             → Premier lancement ? Lance sparrow setup\n\
260             → Sinon, crée ~/.config/sparrow/config.toml manuellement",
261        ),
262        "config_parse_error" => format!(
263            "📄 Erreur de parsing dans le fichier de config.\n\
264             → Édite le fichier : sparrow config --edit\n\
265             → Détail de l'erreur : {ctx}\n\
266             → Format attendu : TOML valide (voir https://toml.io)",
267            ctx = context,
268        ),
269        "invalid_autonomy" => format!(
270            "⚙️  Niveau d'autonomie invalide : \"{ctx}\".\n\
271             → Valeurs acceptées : supervised, trusted, autonomous\n\
272             → Modifie : sparrow config --edit",
273            ctx = context,
274        ),
275        "invalid_sandbox" => format!(
276            "⚙️  Type de sandbox invalide : \"{ctx}\".\n\
277             → Valeurs acceptées : local, local-hardened, docker\n\
278             → Modifie : sparrow config --edit",
279            ctx = context,
280        ),
281        "budget_exceeded" => format!(
282            "💰 Budget dépassé !\n\
283             → Budget actuel : ${ctx}\n\
284             → Augmente le budget : sparrow config --edit (section [budget])\n\
285             → Ou utilise un provider gratuit : sparrow route set nvidia",
286            ctx = context,
287        ),
288        _ => format!(
289            "⚠️  Erreur de configuration : {ctx}\n\
290             → Lance sparrow doctor pour un diagnostic complet.\n\
291             → Édite la config : sparrow config --edit",
292            ctx = context,
293        ),
294    }
295}
296
297/// Best-effort translation of any `anyhow::Error` into French.
298///
299/// Walks the error chain looking for known patterns (HTTP status codes,
300/// connection errors, etc.). Falls back to the raw error message when no
301/// pattern matches.
302pub fn humanize_anyhow(err: &anyhow::Error, provider_label: &str, model: &str) -> String {
303    let msg = format!("{err:#}");
304
305    // Try to extract an HTTP status code from the error chain
306    if let Some(status) = extract_http_status(&msg) {
307        return humanize_http_error(status, &msg, provider_label, model);
308    }
309
310    // Connection errors
311    if msg.contains("Connection refused")
312        || msg.contains("connect")
313        || msg.contains("DNS")
314        || msg.contains("timeout")
315        || msg.contains("SSL")
316        || msg.contains("TLS")
317    {
318        return humanize_connection_error(provider_label, &msg);
319    }
320
321    // Config errors
322    if msg.contains("config") || msg.contains("toml") || msg.contains("parse") {
323        return humanize_config_error("config_parse_error", &msg);
324    }
325
326    // Generic fallback with French flavour
327    format!(
328        "💥 Oups ! Une erreur est survenue : {msg}\n\
329         → Lance sparrow doctor pour un diagnostic.\n\
330         → Si le problème persiste, ouvre une issue : https://github.com/ucav/Sparrow/issues",
331        msg = msg,
332    )
333}
334
335/// Try to extract an HTTP status code from an error message string.
336fn extract_http_status(msg: &str) -> Option<u16> {
337    // Look for patterns like "HTTP 401", "status: 429", "500 Internal Server Error"
338    let re = regex::Regex::new(r"(?i)(?:HTTP\s+|status:\s*|^\s*)(\d{3})\b").ok()?;
339    re.captures(msg)
340        .and_then(|caps| caps.get(1))
341        .and_then(|m| m.as_str().parse::<u16>().ok())
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_unauthorized_anthropic() {
350        let msg = humanize_http_error(401, "", "Anthropic", "claude-sonnet-4-6");
351        assert!(msg.contains("invalide"));
352        assert!(msg.contains("console.anthropic.com"));
353        assert!(msg.contains("ANTHROPIC_API_KEY"));
354    }
355
356    #[test]
357    fn test_rate_limit() {
358        let msg = humanize_rate_limit("Groq", Some(30));
359        assert!(msg.contains("trop de requêtes"));
360        assert!(msg.contains("30 secondes"));
361    }
362
363    #[test]
364    fn test_model_not_found() {
365        let msg = humanize_model_not_found("NVIDIA NIM", "gpt-5-ultra");
366        assert!(msg.contains("n'existe pas"));
367        assert!(msg.contains("gpt-5-ultra"));
368    }
369
370    #[test]
371    fn test_connection_refused() {
372        let msg = humanize_connection_error("Ollama", "Connection refused (os error 111)");
373        assert!(msg.contains("Impossible de contacter"));
374        assert!(msg.contains("ollama serve"));
375    }
376
377    #[test]
378    fn test_config_no_provider() {
379        let msg = humanize_config_error("no_provider", "ANTHROPIC");
380        assert!(msg.contains("Aucun provider"));
381    }
382
383    #[test]
384    fn test_extract_http_status() {
385        assert_eq!(extract_http_status("HTTP 401 Unauthorized"), Some(401));
386        assert_eq!(extract_http_status("status: 429 Too Many Requests"), Some(429));
387        assert_eq!(extract_http_status("500 Internal Server Error"), Some(500));
388        assert_eq!(extract_http_status("no status here"), None);
389    }
390}