1use crate::error::Error;
15use std::fmt::Write as _;
16
17#[derive(Debug, Clone)]
19pub struct ErrorHint {
20 pub summary: &'static str,
22 pub hints: &'static [&'static str],
24 pub context_fields: &'static [&'static str],
26}
27
28#[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
478pub fn format_error_with_hints(error: &Error) -> String {
482 let hint = hints_for_error(error);
483 let mut output = String::new();
484
485 let _ = writeln!(&mut output, "Error: {error}");
487
488 if !error.to_string().contains(hint.summary) {
490 output.push('\n');
491 output.push_str(hint.summary);
492 output.push('\n');
493 }
494
495 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 let error = Error::session("database locked");
672 let hint = hints_for_error(&error);
673 assert!(!hint.hints.is_empty());
675 }
676
677 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
942 fn test_json_data_error_hints() {
943 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 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 assert!(hint.summary.contains("JSON"));
958 }
959
960 #[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 #[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 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 mod proptest_error_hints {
1030 use super::*;
1031 use proptest::prelude::*;
1032
1033 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}