Skip to main content

pi/
error_hints.rs

1//! Error hints: mapping from error variants to user-facing remediation suggestions.
2//!
3//! Each error variant maps to:
4//! - A 1-line summary (human readable)
5//! - 0-2 actionable hints (commands, env vars, paths)
6//! - Contextual fields that should be printed with the error
7//!
8//! # Design Principles
9//! - Hints must be stable for testability
10//! - Avoid OS-specific hints unless OS is reliably detectable
11//! - Never suggest destructive actions
12//! - Prefer specific, actionable guidance over generic messages
13
14use crate::error::Error;
15use std::fmt::Write as _;
16
17/// A remediation hint for an error.
18#[derive(Debug, Clone)]
19pub struct ErrorHint {
20    /// Brief 1-line summary of the error category.
21    pub summary: &'static str,
22    /// Actionable hints for the user (0-2 items).
23    pub hints: &'static [&'static str],
24    /// Context fields that should be displayed with the error.
25    pub context_fields: &'static [&'static str],
26}
27
28/// Get remediation hints for an error variant.
29///
30/// Returns structured hints that can be rendered in any output mode
31/// (interactive, print, RPC).
32#[allow(clippy::too_many_lines)]
33pub fn hints_for_error(error: &Error) -> ErrorHint {
34    match error {
35        Error::Config(msg) => config_hints(msg),
36        Error::SessionNotFound { .. } | Error::Session(_) => session_hints(error),
37        Error::Auth(msg) => auth_hints(msg),
38        Error::Provider { message, .. } => provider_hints(message),
39        Error::Tool { tool, message } => tool_hints(tool, message),
40        Error::Validation(msg) => validation_hints(msg),
41        Error::Extension(msg) => extension_hints(msg),
42        Error::Io(err) => io_hints(err),
43        Error::Json(err) => json_hints(err),
44        Error::Sqlite(err) => sqlite_hints(err),
45        Error::Aborted => aborted_hints(),
46        Error::Api(msg) => api_hints(msg),
47    }
48}
49
50fn config_hints(msg: &str) -> ErrorHint {
51    if msg.contains("cassette") {
52        return ErrorHint {
53            summary: "VCR cassette missing or invalid",
54            hints: &[
55                "If running tests, set VCR_MODE=record to create cassettes",
56                "Or ensure VCR_CASSETTE_DIR contains the expected cassette file",
57            ],
58            context_fields: &["file_path"],
59        };
60    }
61    if msg.contains("settings.json") {
62        return ErrorHint {
63            summary: "Invalid or missing configuration file",
64            hints: &[
65                "Check that ~/.pi/agent/settings.json exists and is valid JSON",
66                "Run 'pi config' to see configuration paths and precedence",
67            ],
68            context_fields: &["file_path"],
69        };
70    }
71    if msg.contains("models.json") {
72        return ErrorHint {
73            summary: "Invalid models configuration",
74            hints: &[
75                "Verify ~/.pi/agent/models.json has valid JSON syntax",
76                "Check that 'providers' key exists in models.json",
77            ],
78            context_fields: &["file_path", "parse_error"],
79        };
80    }
81    ErrorHint {
82        summary: "Configuration error",
83        hints: &["Check configuration file syntax and required fields"],
84        context_fields: &[],
85    }
86}
87
88fn session_hints(error: &Error) -> ErrorHint {
89    match error {
90        Error::SessionNotFound { .. } => ErrorHint {
91            summary: "Session file not found",
92            hints: &[
93                "Use 'pi' without --session to start a new session",
94                "Use 'pi --resume' to pick from existing sessions",
95            ],
96            context_fields: &["path"],
97        },
98        Error::Session(msg) if msg.contains("corrupted") || msg.contains("invalid") => ErrorHint {
99            summary: "Session file is corrupted or invalid",
100            hints: &[
101                "Start a new session with 'pi'",
102                "Session files are JSONL format - check for malformed lines",
103            ],
104            context_fields: &["path", "line_number"],
105        },
106        Error::Session(msg) if msg.contains("locked") => ErrorHint {
107            summary: "Session file is locked by another process",
108            hints: &["Close other Pi instances using this session"],
109            context_fields: &["path"],
110        },
111        _ => ErrorHint {
112            summary: "Session error",
113            hints: &["Try starting a new session with 'pi'"],
114            context_fields: &[],
115        },
116    }
117}
118
119fn auth_hints(msg: &str) -> ErrorHint {
120    if msg.contains("API key") || msg.contains("api_key") {
121        return ErrorHint {
122            summary: "API key not configured",
123            hints: &[
124                "Set ANTHROPIC_API_KEY environment variable",
125                "Or add key to ~/.pi/agent/auth.json",
126            ],
127            context_fields: &["provider"],
128        };
129    }
130    if msg.contains("401") || msg.contains("unauthorized") {
131        return ErrorHint {
132            summary: "API key is invalid or expired",
133            hints: &[
134                "Verify your API key is correct and active",
135                "Check API key permissions at your provider's console",
136            ],
137            context_fields: &["provider", "status_code"],
138        };
139    }
140    if msg.contains("OAuth") || msg.contains("refresh") {
141        return ErrorHint {
142            summary: "OAuth token expired or invalid",
143            hints: &[
144                "Run 'pi login <provider>' to re-authenticate",
145                "Or set API key directly via environment variable",
146            ],
147            context_fields: &["provider"],
148        };
149    }
150    if msg.contains("lock") {
151        return ErrorHint {
152            summary: "Auth file locked by another process",
153            hints: &["Close other Pi instances that may be using auth.json"],
154            context_fields: &["path"],
155        };
156    }
157    ErrorHint {
158        summary: "Authentication error",
159        hints: &["Check your API credentials"],
160        context_fields: &[],
161    }
162}
163
164fn provider_hints(message: &str) -> ErrorHint {
165    if message.contains("429") || message.contains("rate limit") {
166        return ErrorHint {
167            summary: "Rate limit exceeded",
168            hints: &[
169                "Wait a moment and try again",
170                "Consider using a different model or reducing request frequency",
171            ],
172            context_fields: &["provider", "retry_after"],
173        };
174    }
175    if message.contains("500") || message.contains("server error") {
176        return ErrorHint {
177            summary: "Provider server error",
178            hints: &[
179                "This is a temporary issue - try again shortly",
180                "Check provider status page for outages",
181            ],
182            context_fields: &["provider", "status_code"],
183        };
184    }
185    if message.contains("connection") || message.contains("network") {
186        return ErrorHint {
187            summary: "Network connection error",
188            hints: &[
189                "Check your internet connection",
190                "If using a proxy, verify proxy settings",
191            ],
192            context_fields: &["provider", "url"],
193        };
194    }
195    if message.contains("timeout") {
196        return ErrorHint {
197            summary: "Request timed out",
198            hints: &[
199                "Try again - the provider may be slow",
200                "Consider using a smaller context or simpler request",
201            ],
202            context_fields: &["provider", "timeout_seconds"],
203        };
204    }
205    if message.contains("model") && message.contains("not found") {
206        return ErrorHint {
207            summary: "Model not found or unavailable",
208            hints: &[
209                "Check that the model ID is correct",
210                "Use 'pi --list-models' to see available models",
211            ],
212            context_fields: &["provider", "model_id"],
213        };
214    }
215    ErrorHint {
216        summary: "Provider API error",
217        hints: &["Check provider documentation for this error"],
218        context_fields: &["provider", "status_code"],
219    }
220}
221
222fn tool_hints(tool: &str, message: &str) -> ErrorHint {
223    if tool == "read" && message.contains("not found") {
224        return ErrorHint {
225            summary: "File not found",
226            hints: &[
227                "Verify the file path is correct",
228                "Use 'ls' or 'find' to locate the file",
229            ],
230            context_fields: &["path"],
231        };
232    }
233    if tool == "read" && message.contains("permission") {
234        return ErrorHint {
235            summary: "Permission denied reading file",
236            hints: &["Check file permissions"],
237            context_fields: &["path"],
238        };
239    }
240    if tool == "write" && message.contains("permission") {
241        return ErrorHint {
242            summary: "Permission denied writing file",
243            hints: &["Check directory permissions"],
244            context_fields: &["path"],
245        };
246    }
247    if tool == "edit" && message.contains("not found") {
248        return ErrorHint {
249            summary: "Text to replace not found in file",
250            hints: &[
251                "Verify the old_text exactly matches content in the file",
252                "Use 'read' to see the current file content",
253            ],
254            context_fields: &["path", "old_text_preview"],
255        };
256    }
257    if tool == "edit" && message.contains("ambiguous") {
258        return ErrorHint {
259            summary: "Multiple matches found for replacement",
260            hints: &["Provide more context in old_text to make it unique"],
261            context_fields: &["path", "match_count"],
262        };
263    }
264    if tool == "bash" && message.contains("timeout") {
265        return ErrorHint {
266            summary: "Command timed out",
267            hints: &[
268                "Increase timeout with 'timeout' parameter",
269                "Consider breaking into smaller commands",
270            ],
271            context_fields: &["command", "timeout_seconds"],
272        };
273    }
274    if tool == "bash" && message.contains("exit code") {
275        return ErrorHint {
276            summary: "Command failed with non-zero exit code",
277            hints: &["Review command output for error details"],
278            context_fields: &["command", "exit_code", "stderr"],
279        };
280    }
281    if tool == "grep" && message.contains("pattern") {
282        return ErrorHint {
283            summary: "Invalid regex pattern",
284            hints: &["Check regex syntax - special characters may need escaping"],
285            context_fields: &["pattern"],
286        };
287    }
288    if tool == "find" && message.contains("fd") {
289        return ErrorHint {
290            summary: "fd command not found",
291            hints: &[
292                "Install fd: 'apt install fd-find' or 'brew install fd'",
293                "The binary may be named 'fdfind' on some systems",
294            ],
295            context_fields: &[],
296        };
297    }
298    ErrorHint {
299        summary: "Tool execution error",
300        hints: &["Review the tool parameters and try again"],
301        context_fields: &["tool", "command"],
302    }
303}
304
305fn validation_hints(msg: &str) -> ErrorHint {
306    if msg.contains("required") {
307        return ErrorHint {
308            summary: "Required field missing",
309            hints: &["Provide all required parameters"],
310            context_fields: &["field_name"],
311        };
312    }
313    if msg.contains("type") {
314        return ErrorHint {
315            summary: "Invalid parameter type",
316            hints: &["Check parameter types match expected schema"],
317            context_fields: &["field_name", "expected_type"],
318        };
319    }
320    ErrorHint {
321        summary: "Validation error",
322        hints: &["Check input parameters"],
323        context_fields: &[],
324    }
325}
326
327fn extension_hints(msg: &str) -> ErrorHint {
328    if msg.contains("not found") {
329        return ErrorHint {
330            summary: "Extension not found",
331            hints: &[
332                "Check extension name is correct",
333                "Use 'pi list' to see installed extensions",
334            ],
335            context_fields: &["extension_name"],
336        };
337    }
338    if msg.contains("manifest") {
339        return ErrorHint {
340            summary: "Invalid extension manifest",
341            hints: &[
342                "Check extension manifest.json syntax",
343                "Verify required fields are present",
344            ],
345            context_fields: &["extension_name", "manifest_path"],
346        };
347    }
348    if msg.contains("capability") || msg.contains("permission") {
349        return ErrorHint {
350            summary: "Extension capability denied",
351            hints: &[
352                "Extension requires capabilities not granted by policy",
353                "Review extension security settings",
354            ],
355            context_fields: &["extension_name", "capability"],
356        };
357    }
358    ErrorHint {
359        summary: "Extension error",
360        hints: &["Check extension configuration"],
361        context_fields: &["extension_name"],
362    }
363}
364
365fn io_hints(err: &std::io::Error) -> ErrorHint {
366    match err.kind() {
367        std::io::ErrorKind::NotFound => ErrorHint {
368            summary: "File or directory not found",
369            hints: &["Verify the path exists"],
370            context_fields: &["path"],
371        },
372        std::io::ErrorKind::PermissionDenied => ErrorHint {
373            summary: "Permission denied",
374            hints: &["Check file/directory permissions"],
375            context_fields: &["path"],
376        },
377        std::io::ErrorKind::AlreadyExists => ErrorHint {
378            summary: "File already exists",
379            hints: &["Use a different path or remove existing file first"],
380            context_fields: &["path"],
381        },
382        _ => ErrorHint {
383            summary: "I/O error",
384            hints: &["Check file system and permissions"],
385            context_fields: &["path"],
386        },
387    }
388}
389
390fn json_hints(err: &serde_json::Error) -> ErrorHint {
391    if err.is_syntax() {
392        return ErrorHint {
393            summary: "Invalid JSON syntax",
394            hints: &[
395                "Check for missing commas, brackets, or quotes",
396                "Validate JSON at jsonlint.com or similar",
397            ],
398            context_fields: &["line", "column"],
399        };
400    }
401    if err.is_data() {
402        return ErrorHint {
403            summary: "JSON data does not match expected structure",
404            hints: &["Check that JSON fields match expected schema"],
405            context_fields: &["field_path"],
406        };
407    }
408    ErrorHint {
409        summary: "JSON error",
410        hints: &["Verify JSON syntax and structure"],
411        context_fields: &[],
412    }
413}
414
415fn sqlite_hints(err: &sqlmodel_core::Error) -> ErrorHint {
416    let message = err.to_string();
417    if message.contains("locked") {
418        return ErrorHint {
419            summary: "Database locked",
420            hints: &["Close other Pi instances using this database"],
421            context_fields: &["db_path"],
422        };
423    }
424    if message.contains("corrupt") {
425        return ErrorHint {
426            summary: "Database corrupted",
427            hints: &[
428                "The session index may need to be rebuilt",
429                "Delete ~/.pi/agent/sessions/index.db to rebuild",
430            ],
431            context_fields: &["db_path"],
432        };
433    }
434    ErrorHint {
435        summary: "Database error",
436        hints: &["Check database file permissions and integrity"],
437        context_fields: &["db_path"],
438    }
439}
440
441const fn aborted_hints() -> ErrorHint {
442    ErrorHint {
443        summary: "Operation cancelled by user",
444        hints: &[],
445        context_fields: &[],
446    }
447}
448
449fn api_hints(msg: &str) -> ErrorHint {
450    if msg.contains("401") {
451        return ErrorHint {
452            summary: "Unauthorized API request",
453            hints: &["Check your API credentials"],
454            context_fields: &["url", "status_code"],
455        };
456    }
457    if msg.contains("403") {
458        return ErrorHint {
459            summary: "Forbidden API request",
460            hints: &["Check API key permissions for this resource"],
461            context_fields: &["url", "status_code"],
462        };
463    }
464    if msg.contains("404") {
465        return ErrorHint {
466            summary: "API resource not found",
467            hints: &["Check the API endpoint URL"],
468            context_fields: &["url"],
469        };
470    }
471    ErrorHint {
472        summary: "API error",
473        hints: &["Check API documentation"],
474        context_fields: &["url", "status_code"],
475    }
476}
477
478/// Format an error with its hints for display.
479///
480/// Returns a formatted string suitable for terminal output.
481pub fn format_error_with_hints(error: &Error) -> String {
482    let hint = hints_for_error(error);
483    let mut output = String::new();
484
485    // Error message
486    let _ = writeln!(&mut output, "Error: {error}");
487
488    // Summary if different from error message
489    if !error.to_string().contains(hint.summary) {
490        output.push('\n');
491        output.push_str(hint.summary);
492        output.push('\n');
493    }
494
495    // Hints
496    if !hint.hints.is_empty() {
497        output.push_str("\nSuggestions:\n");
498        for &h in hint.hints {
499            let _ = writeln!(&mut output, "  • {h}");
500        }
501    }
502
503    output
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn test_config_error_hints() {
512        let error = Error::config("settings.json not found");
513        let hint = hints_for_error(&error);
514        assert!(hint.summary.contains("configuration"));
515        assert!(!hint.hints.is_empty());
516    }
517
518    #[test]
519    fn test_auth_error_api_key_hints() {
520        let error = Error::auth("API key not set");
521        let hint = hints_for_error(&error);
522        assert!(hint.summary.contains("API key"));
523        assert!(hint.hints.iter().any(|h| h.contains("ANTHROPIC_API_KEY")));
524    }
525
526    #[test]
527    fn test_auth_error_401_hints() {
528        let error = Error::auth("401 unauthorized");
529        let hint = hints_for_error(&error);
530        assert!(hint.summary.contains("invalid") || hint.summary.contains("expired"));
531    }
532
533    #[test]
534    fn test_provider_rate_limit_hints() {
535        let error = Error::provider("anthropic", "429 rate limit exceeded");
536        let hint = hints_for_error(&error);
537        assert!(hint.summary.contains("Rate limit"));
538        assert!(hint.hints.iter().any(|h| h.contains("Wait")));
539    }
540
541    #[test]
542    fn test_tool_read_not_found_hints() {
543        let error = Error::tool("read", "file not found: /path/to/file");
544        let hint = hints_for_error(&error);
545        assert!(hint.summary.contains("not found"));
546        assert!(hint.context_fields.contains(&"path"));
547    }
548
549    #[test]
550    fn test_tool_edit_ambiguous_hints() {
551        let error = Error::tool("edit", "ambiguous match: found 3 occurrences");
552        let hint = hints_for_error(&error);
553        assert!(hint.summary.contains("Multiple"));
554        assert!(hint.hints.iter().any(|h| h.contains("context")));
555    }
556
557    #[test]
558    fn test_tool_fd_not_found_hints() {
559        let error = Error::tool("find", "fd command not found");
560        let hint = hints_for_error(&error);
561        assert!(hint.hints.iter().any(|h| h.contains("apt install")));
562    }
563
564    #[test]
565    fn test_session_not_found_hints() {
566        let error = Error::SessionNotFound {
567            path: "/path/to/session.jsonl".to_string(),
568        };
569        let hint = hints_for_error(&error);
570        assert!(hint.summary.contains("not found"));
571        assert!(hint.hints.iter().any(|h| h.contains("--resume")));
572    }
573
574    #[test]
575    fn test_json_syntax_error_hints() {
576        let json_err = serde_json::from_str::<serde_json::Value>("{ invalid }").unwrap_err();
577        let error = Error::Json(Box::new(json_err));
578        let hint = hints_for_error(&error);
579        assert!(hint.summary.contains("JSON") || hint.summary.contains("syntax"));
580    }
581
582    #[test]
583    fn test_aborted_has_no_hints() {
584        let error = Error::Aborted;
585        let hint = hints_for_error(&error);
586        assert!(hint.hints.is_empty());
587    }
588
589    #[test]
590    fn test_format_error_with_hints() {
591        let error = Error::auth("API key not set");
592        let formatted = format_error_with_hints(&error);
593        assert!(formatted.contains("Error:"));
594        assert!(formatted.contains("Suggestions:"));
595    }
596
597    #[test]
598    fn test_format_error_with_hints_includes_api_key_suggestion() {
599        let error = Error::auth("API key not set");
600        let formatted = format_error_with_hints(&error);
601        assert!(formatted.contains("ANTHROPIC_API_KEY"));
602        assert!(formatted.contains("auth.json"));
603    }
604
605    #[test]
606    fn test_format_error_with_hints_includes_json_syntax_suggestions() {
607        let json_err = serde_json::from_str::<serde_json::Value>("{ invalid }").unwrap_err();
608        let error = Error::Json(Box::new(json_err));
609        let formatted = format_error_with_hints(&error);
610        assert!(formatted.contains("Invalid JSON syntax"));
611        assert!(formatted.contains("Validate JSON"));
612    }
613
614    #[test]
615    fn test_format_error_with_hints_includes_fd_install_hint() {
616        let error = Error::tool("find", "fd command not found");
617        let formatted = format_error_with_hints(&error);
618        assert!(formatted.contains("fd"));
619        assert!(formatted.contains("apt install"));
620    }
621
622    #[test]
623    fn test_format_error_with_hints_includes_read_permission_hint() {
624        let error = Error::tool("read", "permission denied: /etc/shadow");
625        let formatted = format_error_with_hints(&error);
626        assert!(formatted.contains("Permission denied"));
627        assert!(formatted.contains("Check file permissions"));
628    }
629
630    #[test]
631    fn test_format_error_with_hints_includes_vcr_cassette_hint() {
632        let error = Error::config("Failed to read cassette /tmp/cassette.json: missing file");
633        let formatted = format_error_with_hints(&error);
634        assert!(formatted.contains("VCR cassette"));
635        assert!(formatted.contains("VCR_MODE=record"));
636        assert!(formatted.contains("VCR_CASSETTE_DIR"));
637    }
638
639    #[test]
640    fn test_extension_capability_denied_hints() {
641        let error = Error::extension("capability network not allowed by policy");
642        let hint = hints_for_error(&error);
643        assert!(hint.summary.contains("capability") || hint.summary.contains("denied"));
644    }
645
646    #[test]
647    fn test_provider_timeout_hints() {
648        let error = Error::provider("openai", "request timeout after 120s");
649        let hint = hints_for_error(&error);
650        assert!(hint.summary.contains("timed out") || hint.summary.contains("timeout"));
651    }
652
653    #[test]
654    fn test_provider_connection_hints() {
655        let error = Error::provider("anthropic", "connection refused");
656        let hint = hints_for_error(&error);
657        assert!(hint.summary.contains("Network") || hint.summary.contains("connection"));
658    }
659
660    #[test]
661    fn test_io_permission_denied_hints() {
662        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
663        let error = Error::Io(Box::new(io_err));
664        let hint = hints_for_error(&error);
665        assert!(hint.summary.contains("Permission"));
666    }
667
668    #[test]
669    fn test_sqlite_locked_hints() {
670        // Create a mock sqlite error string
671        let error = Error::session("database locked");
672        let hint = hints_for_error(&error);
673        // Falls back to generic session error since it's not actually a Sqlite variant
674        assert!(!hint.hints.is_empty());
675    }
676
677    // -----------------------------------------------------------------------
678    // config_hints additional branches
679    // -----------------------------------------------------------------------
680
681    #[test]
682    fn test_config_models_json_hints() {
683        let error = Error::config("models.json parse error at line 5");
684        let hint = hints_for_error(&error);
685        assert_eq!(hint.summary, "Invalid models configuration");
686        assert!(hint.context_fields.contains(&"parse_error"));
687    }
688
689    #[test]
690    fn test_config_generic_fallback() {
691        let error = Error::config("some unknown config issue");
692        let hint = hints_for_error(&error);
693        assert_eq!(hint.summary, "Configuration error");
694    }
695
696    // -----------------------------------------------------------------------
697    // session_hints additional branches
698    // -----------------------------------------------------------------------
699
700    #[test]
701    fn test_session_corrupted_hints() {
702        let error = Error::session("file corrupted at line 42");
703        let hint = hints_for_error(&error);
704        assert!(hint.summary.contains("corrupted"));
705        assert!(hint.context_fields.contains(&"line_number"));
706    }
707
708    #[test]
709    fn test_session_invalid_hints() {
710        let error = Error::session("invalid session format");
711        let hint = hints_for_error(&error);
712        assert!(hint.summary.contains("corrupted") || hint.summary.contains("invalid"));
713    }
714
715    #[test]
716    fn test_session_locked_hints() {
717        let error = Error::session("session file locked by pid 1234");
718        let hint = hints_for_error(&error);
719        assert!(hint.summary.contains("locked"));
720        assert!(hint.hints.iter().any(|h| h.contains("Close")));
721    }
722
723    #[test]
724    fn test_session_generic_fallback() {
725        let error = Error::session("something went wrong");
726        let hint = hints_for_error(&error);
727        assert_eq!(hint.summary, "Session error");
728    }
729
730    // -----------------------------------------------------------------------
731    // auth_hints additional branches
732    // -----------------------------------------------------------------------
733
734    #[test]
735    fn test_auth_oauth_hints() {
736        let error = Error::auth("OAuth token expired for provider X");
737        let hint = hints_for_error(&error);
738        assert!(hint.summary.contains("OAuth"));
739        assert!(hint.hints.iter().any(|h| h.contains("pi login")));
740    }
741
742    #[test]
743    fn test_auth_refresh_hints() {
744        let error = Error::auth("failed to refresh token");
745        let hint = hints_for_error(&error);
746        assert!(hint.summary.contains("OAuth"));
747    }
748
749    #[test]
750    fn test_auth_lock_hints() {
751        let error = Error::auth("auth file lock contention");
752        let hint = hints_for_error(&error);
753        assert!(hint.summary.contains("locked"));
754    }
755
756    #[test]
757    fn test_auth_generic_fallback() {
758        let error = Error::auth("unknown auth issue");
759        let hint = hints_for_error(&error);
760        assert_eq!(hint.summary, "Authentication error");
761    }
762
763    // -----------------------------------------------------------------------
764    // provider_hints additional branches
765    // -----------------------------------------------------------------------
766
767    #[test]
768    fn test_provider_server_error_500_hints() {
769        let error = Error::provider("openai", "500 internal server error");
770        let hint = hints_for_error(&error);
771        assert!(hint.summary.contains("server error"));
772        assert!(hint.hints.iter().any(|h| h.contains("status page")));
773    }
774
775    #[test]
776    fn test_provider_server_error_text_hints() {
777        let error = Error::provider("anthropic", "server error: bad gateway");
778        let hint = hints_for_error(&error);
779        assert!(hint.summary.contains("server error"));
780    }
781
782    #[test]
783    fn test_provider_model_not_found_hints() {
784        let error = Error::provider("openai", "model gpt-99 not found");
785        let hint = hints_for_error(&error);
786        assert!(hint.summary.contains("Model not found"));
787        assert!(hint.hints.iter().any(|h| h.contains("--list-models")));
788    }
789
790    #[test]
791    fn test_provider_generic_fallback() {
792        let error = Error::provider("unknown", "something broke");
793        let hint = hints_for_error(&error);
794        assert_eq!(hint.summary, "Provider API error");
795    }
796
797    // -----------------------------------------------------------------------
798    // tool_hints additional branches
799    // -----------------------------------------------------------------------
800
801    #[test]
802    fn test_tool_write_permission_hints() {
803        let error = Error::tool("write", "permission denied: /etc/config");
804        let hint = hints_for_error(&error);
805        assert!(hint.summary.contains("Permission denied"));
806        assert!(hint.hints.iter().any(|h| h.contains("directory")));
807    }
808
809    #[test]
810    fn test_tool_edit_not_found_hints() {
811        let error = Error::tool("edit", "text not found in file");
812        let hint = hints_for_error(&error);
813        assert!(hint.summary.contains("not found"));
814        assert!(hint.hints.iter().any(|h| h.contains("old_text")));
815    }
816
817    #[test]
818    fn test_tool_bash_timeout_hints() {
819        let error = Error::tool("bash", "command timeout after 120s");
820        let hint = hints_for_error(&error);
821        assert!(hint.summary.contains("timed out"));
822        assert!(hint.context_fields.contains(&"timeout_seconds"));
823    }
824
825    #[test]
826    fn test_tool_bash_exit_code_hints() {
827        let error = Error::tool("bash", "exit code 1");
828        let hint = hints_for_error(&error);
829        assert!(hint.summary.contains("exit code"));
830        assert!(hint.context_fields.contains(&"stderr"));
831    }
832
833    #[test]
834    fn test_tool_grep_pattern_hints() {
835        let error = Error::tool("grep", "invalid regex pattern: [unterminated");
836        let hint = hints_for_error(&error);
837        assert!(hint.summary.contains("regex"));
838        assert!(hint.hints.iter().any(|h| h.contains("escaping")));
839    }
840
841    #[test]
842    fn test_tool_generic_fallback() {
843        let error = Error::tool("unknown_tool", "something went wrong");
844        let hint = hints_for_error(&error);
845        assert_eq!(hint.summary, "Tool execution error");
846    }
847
848    // -----------------------------------------------------------------------
849    // validation_hints branches
850    // -----------------------------------------------------------------------
851
852    #[test]
853    fn test_validation_required_hints() {
854        let error = Error::validation("field 'name' is required");
855        let hint = hints_for_error(&error);
856        assert!(hint.summary.contains("Required"));
857        assert!(hint.context_fields.contains(&"field_name"));
858    }
859
860    #[test]
861    fn test_validation_type_hints() {
862        let error = Error::validation("expected type string, got number");
863        let hint = hints_for_error(&error);
864        assert!(hint.summary.contains("type"));
865        assert!(hint.context_fields.contains(&"expected_type"));
866    }
867
868    #[test]
869    fn test_validation_generic_fallback() {
870        let error = Error::validation("value out of range");
871        let hint = hints_for_error(&error);
872        assert_eq!(hint.summary, "Validation error");
873    }
874
875    // -----------------------------------------------------------------------
876    // extension_hints additional branches
877    // -----------------------------------------------------------------------
878
879    #[test]
880    fn test_extension_not_found_hints() {
881        let error = Error::extension("extension my-ext not found");
882        let hint = hints_for_error(&error);
883        assert!(hint.summary.contains("not found"));
884        assert!(hint.hints.iter().any(|h| h.contains("pi list")));
885    }
886
887    #[test]
888    fn test_extension_manifest_hints() {
889        let error = Error::extension("invalid manifest for extension foo");
890        let hint = hints_for_error(&error);
891        assert!(hint.summary.contains("manifest"));
892        assert!(hint.context_fields.contains(&"manifest_path"));
893    }
894
895    #[test]
896    fn test_extension_permission_hints() {
897        let error = Error::extension("permission denied for exec capability");
898        let hint = hints_for_error(&error);
899        assert!(hint.summary.contains("denied"));
900    }
901
902    #[test]
903    fn test_extension_generic_fallback() {
904        let error = Error::extension("runtime crashed");
905        let hint = hints_for_error(&error);
906        assert_eq!(hint.summary, "Extension error");
907    }
908
909    // -----------------------------------------------------------------------
910    // io_hints additional branches
911    // -----------------------------------------------------------------------
912
913    #[test]
914    fn test_io_not_found_hints() {
915        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
916        let error = Error::Io(Box::new(io_err));
917        let hint = hints_for_error(&error);
918        assert!(hint.summary.contains("not found"));
919    }
920
921    #[test]
922    fn test_io_already_exists_hints() {
923        let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "file exists");
924        let error = Error::Io(Box::new(io_err));
925        let hint = hints_for_error(&error);
926        assert!(hint.summary.contains("already exists"));
927    }
928
929    #[test]
930    fn test_io_generic_fallback() {
931        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
932        let error = Error::Io(Box::new(io_err));
933        let hint = hints_for_error(&error);
934        assert_eq!(hint.summary, "I/O error");
935    }
936
937    // -----------------------------------------------------------------------
938    // json_hints additional branches
939    // -----------------------------------------------------------------------
940
941    #[test]
942    fn test_json_data_error_hints() {
943        // Trigger a data error (wrong type for field)
944        let json_err = serde_json::from_str::<Vec<i32>>(r#"{"not": "an array"}"#).unwrap_err();
945        let error = Error::Json(Box::new(json_err));
946        let hint = hints_for_error(&error);
947        assert!(hint.summary.contains("data") || hint.summary.contains("structure"));
948    }
949
950    #[test]
951    fn test_json_eof_fallback() {
952        // EOF error is neither syntax nor data
953        let json_err = serde_json::from_str::<serde_json::Value>("").unwrap_err();
954        let error = Error::Json(Box::new(json_err));
955        let hint = hints_for_error(&error);
956        // EOF may be classified as syntax or generic depending on serde_json version
957        assert!(hint.summary.contains("JSON"));
958    }
959
960    // -----------------------------------------------------------------------
961    // api_hints branches
962    // -----------------------------------------------------------------------
963
964    #[test]
965    fn test_api_401_hints() {
966        let error = Error::api("401 Unauthorized");
967        let hint = hints_for_error(&error);
968        assert!(hint.summary.contains("Unauthorized"));
969        assert!(hint.context_fields.contains(&"status_code"));
970    }
971
972    #[test]
973    fn test_api_403_hints() {
974        let error = Error::api("403 Forbidden");
975        let hint = hints_for_error(&error);
976        assert!(hint.summary.contains("Forbidden"));
977        assert!(hint.hints.iter().any(|h| h.contains("permissions")));
978    }
979
980    #[test]
981    fn test_api_404_hints() {
982        let error = Error::api("404 Not Found");
983        let hint = hints_for_error(&error);
984        assert!(hint.summary.contains("not found"));
985        assert!(hint.context_fields.contains(&"url"));
986    }
987
988    #[test]
989    fn test_api_generic_fallback() {
990        let error = Error::api("502 Bad Gateway");
991        let hint = hints_for_error(&error);
992        assert_eq!(hint.summary, "API error");
993    }
994
995    // -----------------------------------------------------------------------
996    // format_error_with_hints additional tests
997    // -----------------------------------------------------------------------
998
999    #[test]
1000    fn test_format_error_aborted_no_suggestions() {
1001        let error = Error::Aborted;
1002        let formatted = format_error_with_hints(&error);
1003        assert!(formatted.contains("Error:"));
1004        assert!(!formatted.contains("Suggestions:"));
1005    }
1006
1007    #[test]
1008    fn test_format_error_includes_summary_when_different() {
1009        let error = Error::provider("openai", "429 rate limit exceeded");
1010        let formatted = format_error_with_hints(&error);
1011        // Summary "Rate limit exceeded" should appear since error message differs
1012        assert!(formatted.contains("Rate limit"));
1013        assert!(formatted.contains("Suggestions:"));
1014    }
1015
1016    #[test]
1017    fn test_format_error_io_not_found() {
1018        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
1019        let error = Error::Io(Box::new(io_err));
1020        let formatted = format_error_with_hints(&error);
1021        assert!(formatted.contains("not found"));
1022        assert!(formatted.contains("Verify the path"));
1023    }
1024
1025    // -----------------------------------------------------------------------
1026    // Property-based tests
1027    // -----------------------------------------------------------------------
1028
1029    mod proptest_error_hints {
1030        use super::*;
1031        use proptest::prelude::*;
1032
1033        /// Build an Error from an index + message (avoids Clone requirement).
1034        fn make_error(variant: usize, msg: &str) -> Error {
1035            match variant % 9 {
1036                0 => Error::config(msg),
1037                1 => Error::session(msg),
1038                2 => Error::auth(msg),
1039                3 => Error::validation(msg),
1040                4 => Error::extension(msg),
1041                5 => Error::api(msg),
1042                6 => Error::provider("test", msg),
1043                7 => Error::tool("test", msg),
1044                _ => Error::Aborted,
1045            }
1046        }
1047
1048        proptest! {
1049            /// `hints_for_error` never panics on any error variant.
1050            #[test]
1051            fn hints_for_error_never_panics(variant in 0..9usize, msg in "[\\w\\s./]{0,80}") {
1052                let error = make_error(variant, &msg);
1053                let hint = hints_for_error(&error);
1054                assert!(!hint.summary.is_empty());
1055                assert!(hint.hints.len() <= 2);
1056                assert!(hint.context_fields.len() <= 3);
1057            }
1058
1059            /// `format_error_with_hints` never panics and always starts with "Error:".
1060            #[test]
1061            fn format_error_never_panics(variant in 0..9usize, msg in "[\\w\\s./]{0,80}") {
1062                let error = make_error(variant, &msg);
1063                let formatted = format_error_with_hints(&error);
1064                assert!(formatted.starts_with("Error:"));
1065            }
1066
1067            /// Summary is always non-empty and contains no control characters.
1068            #[test]
1069            fn summary_is_clean(variant in 0..9usize, msg in "[\\w\\s./]{0,80}") {
1070                let error = make_error(variant, &msg);
1071                let hint = hints_for_error(&error);
1072                assert!(!hint.summary.is_empty());
1073                assert!(!hint.summary.contains('\n'));
1074                assert!(!hint.summary.contains('\r'));
1075            }
1076
1077            /// Each hint line is non-empty.
1078            #[test]
1079            fn hints_are_nonempty(variant in 0..9usize, msg in "[\\w\\s./]{0,80}") {
1080                let error = make_error(variant, &msg);
1081                let hint = hints_for_error(&error);
1082                for &h in hint.hints {
1083                    assert!(!h.is_empty());
1084                }
1085            }
1086
1087            /// Each context field is a valid identifier-like string.
1088            #[test]
1089            fn context_fields_are_identifiers(variant in 0..9usize, msg in "[\\w\\s./]{0,80}") {
1090                let error = make_error(variant, &msg);
1091                let hint = hints_for_error(&error);
1092                for &field in hint.context_fields {
1093                    assert!(!field.is_empty());
1094                    assert!(field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
1095                }
1096            }
1097
1098            /// Config error with "cassette" always maps to VCR hint.
1099            #[test]
1100            fn config_cassette_keyword_triggers_vcr(prefix in "[a-zA-Z ]{0,30}") {
1101                let msg = format!("{prefix} cassette missing");
1102                let error = Error::config(msg);
1103                let hint = hints_for_error(&error);
1104                assert_eq!(hint.summary, "VCR cassette missing or invalid");
1105            }
1106
1107            /// Config error with "settings.json" always maps to settings hint.
1108            #[test]
1109            fn config_settings_keyword_triggers_settings(prefix in "[a-zA-Z ]{0,30}") {
1110                let msg = format!("{prefix} settings.json not found");
1111                let error = Error::config(msg);
1112                let hint = hints_for_error(&error);
1113                assert!(hint.summary.contains("configuration"));
1114            }
1115
1116            /// Config error with "models.json" always maps to models hint.
1117            #[test]
1118            fn config_models_keyword_triggers_models(prefix in "[a-zA-Z ]{0,30}") {
1119                let msg = format!("{prefix} models.json parse error");
1120                let error = Error::config(msg);
1121                let hint = hints_for_error(&error);
1122                assert_eq!(hint.summary, "Invalid models configuration");
1123            }
1124
1125            /// Auth error with "API key" always mentions API key setup.
1126            #[test]
1127            fn auth_api_key_keyword(suffix in "[a-zA-Z ]{0,30}") {
1128                let msg = format!("API key {suffix}");
1129                let error = Error::auth(msg);
1130                let hint = hints_for_error(&error);
1131                assert!(hint.summary.contains("API key"));
1132                assert!(hint.hints.iter().any(|h| h.contains("ANTHROPIC_API_KEY")));
1133            }
1134
1135            /// Provider error with "429" always triggers rate limit hint.
1136            #[test]
1137            fn provider_429_triggers_rate_limit(provider in "[a-z]{1,10}", suffix in "[a-zA-Z ]{0,30}") {
1138                let msg = format!("429 {suffix}");
1139                let error = Error::provider(provider, msg);
1140                let hint = hints_for_error(&error);
1141                assert_eq!(hint.summary, "Rate limit exceeded");
1142            }
1143
1144            /// Provider error with "timeout" always triggers timeout hint.
1145            #[test]
1146            fn provider_timeout_triggers_timeout_hint(provider in "[a-z]{1,10}", prefix in "[a-zA-Z ]{0,30}") {
1147                let msg = format!("{prefix} timeout");
1148                let error = Error::provider(provider, msg);
1149                let hint = hints_for_error(&error);
1150                assert!(!hint.summary.is_empty());
1151            }
1152
1153            /// Aborted error always has empty hints.
1154            #[test]
1155            fn aborted_always_empty_hints(_dummy in 0..10u32) {
1156                let hint = hints_for_error(&Error::Aborted);
1157                assert!(hint.hints.is_empty());
1158                assert!(hint.context_fields.is_empty());
1159                assert_eq!(hint.summary, "Operation cancelled by user");
1160            }
1161
1162            /// `format_error_with_hints` includes "Suggestions:" iff hints are non-empty.
1163            #[test]
1164            fn format_includes_suggestions_iff_hints(variant in 0..9usize, msg in "[\\w\\s./]{0,80}") {
1165                let error = make_error(variant, &msg);
1166                let hint = hints_for_error(&error);
1167                let formatted = format_error_with_hints(&error);
1168                if hint.hints.is_empty() {
1169                    assert!(!formatted.contains("Suggestions:"));
1170                } else {
1171                    assert!(formatted.contains("Suggestions:"));
1172                }
1173            }
1174
1175            /// Tool error category detection: "read" + "not found" → File not found.
1176            #[test]
1177            fn tool_read_not_found_hint(suffix in "[a-zA-Z /]{0,40}") {
1178                let msg = format!("not found {suffix}");
1179                let error = Error::tool("read", msg);
1180                let hint = hints_for_error(&error);
1181                assert_eq!(hint.summary, "File not found");
1182            }
1183
1184            /// Tool error: unknown tool always gets generic hint.
1185            #[test]
1186            fn tool_unknown_gets_generic(tool in "[a-z]{5,10}", msg in "[a-zA-Z ]{0,40}") {
1187                let error = Error::tool(tool, msg);
1188                let hint = hints_for_error(&error);
1189                assert_eq!(hint.summary, "Tool execution error");
1190            }
1191        }
1192    }
1193}