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