Skip to main content

rustant_core/
error.rs

1//! Error types for the Rustant agent core.
2//!
3//! Uses `thiserror` for public API error types with structured error variants
4//! covering LLM, tool execution, memory, configuration, and safety domains.
5
6use std::path::PathBuf;
7use uuid::Uuid;
8
9/// Top-level error type for the Rustant core library.
10#[derive(Debug, thiserror::Error)]
11pub enum RustantError {
12    #[error("LLM error: {0}")]
13    Llm(#[from] LlmError),
14
15    #[error("Tool error: {0}")]
16    Tool(#[from] ToolError),
17
18    #[error("Memory error: {0}")]
19    Memory(#[from] MemoryError),
20
21    #[error("Configuration error: {0}")]
22    Config(#[from] ConfigError),
23
24    #[error("Safety error: {0}")]
25    Safety(#[from] SafetyError),
26
27    #[error("Agent error: {0}")]
28    Agent(#[from] AgentError),
29
30    #[error("Channel error: {0}")]
31    Channel(#[from] ChannelError),
32
33    #[error("Node error: {0}")]
34    Node(#[from] NodeError),
35
36    #[error("Workflow error: {0}")]
37    Workflow(#[from] WorkflowError),
38
39    #[error("Browser error: {0}")]
40    Browser(#[from] BrowserError),
41
42    #[error("Scheduler error: {0}")]
43    Scheduler(#[from] SchedulerError),
44
45    #[error("Voice error: {0}")]
46    Voice(#[from] VoiceError),
47
48    #[error("IO error: {0}")]
49    Io(#[from] std::io::Error),
50
51    #[error("Serialization error: {0}")]
52    Serialization(#[from] serde_json::Error),
53}
54
55/// Errors from LLM provider interactions.
56#[derive(Debug, thiserror::Error)]
57pub enum LlmError {
58    #[error("API request failed: {message}")]
59    ApiRequest { message: String },
60
61    #[error("API response parse error: {message}")]
62    ResponseParse { message: String },
63
64    #[error("Streaming error: {message}")]
65    Streaming { message: String },
66
67    #[error("Context window exceeded: used {used} of {limit} tokens")]
68    ContextOverflow { used: usize, limit: usize },
69
70    #[error("Model not supported: {model}")]
71    UnsupportedModel { model: String },
72
73    #[error("Authentication failed for provider {provider}")]
74    AuthFailed { provider: String },
75
76    #[error("Rate limited by provider, retry after {retry_after_secs}s")]
77    RateLimited { retry_after_secs: u64 },
78
79    #[error("Request timed out after {timeout_secs}s")]
80    Timeout { timeout_secs: u64 },
81
82    #[error("Provider connection failed: {message}")]
83    Connection { message: String },
84
85    #[error("OAuth flow failed: {message}")]
86    OAuthFailed { message: String },
87}
88
89/// Errors from tool registration and execution.
90#[derive(Debug, thiserror::Error)]
91pub enum ToolError {
92    #[error("Tool not found: {name}")]
93    NotFound { name: String },
94
95    #[error("Tool already registered: {name}")]
96    AlreadyRegistered { name: String },
97
98    #[error("Invalid arguments for tool '{name}': {reason}")]
99    InvalidArguments { name: String, reason: String },
100
101    #[error("Tool '{name}' execution failed: {message}")]
102    ExecutionFailed { name: String, message: String },
103
104    #[error("Tool '{name}' timed out after {timeout_secs}s")]
105    Timeout { name: String, timeout_secs: u64 },
106
107    #[error("Tool '{name}' was cancelled")]
108    Cancelled { name: String },
109
110    #[error("Permission denied for tool '{name}': {reason}")]
111    PermissionDenied { name: String, reason: String },
112}
113
114/// Errors from the memory system.
115#[derive(Debug, thiserror::Error)]
116pub enum MemoryError {
117    #[error("Context compression failed: {message}")]
118    CompressionFailed { message: String },
119
120    #[error("Memory persistence error: {message}")]
121    PersistenceError { message: String },
122
123    #[error("Memory capacity exceeded")]
124    CapacityExceeded,
125
126    #[error("Failed to load session: {message}")]
127    SessionLoadFailed { message: String },
128}
129
130/// Errors from the configuration system.
131#[derive(Debug, thiserror::Error)]
132pub enum ConfigError {
133    #[error("Configuration file not found: {path}")]
134    FileNotFound { path: PathBuf },
135
136    #[error("Invalid configuration: {message}")]
137    Invalid { message: String },
138
139    #[error("Missing required field: {field}")]
140    MissingField { field: String },
141
142    #[error("Environment variable not set: {var}")]
143    EnvVarMissing { var: String },
144
145    #[error("Configuration parse error: {message}")]
146    ParseError { message: String },
147}
148
149/// Errors from the safety guardian.
150#[derive(Debug, thiserror::Error)]
151pub enum SafetyError {
152    #[error("Action denied by safety policy: {reason}")]
153    PolicyDenied { reason: String },
154
155    #[error("Path access denied: {path}")]
156    PathDenied { path: PathBuf },
157
158    #[error("Command not allowed: {command}")]
159    CommandDenied { command: String },
160
161    #[error("Network access denied for host: {host}")]
162    NetworkDenied { host: String },
163
164    #[error("Sandbox creation failed: {message}")]
165    SandboxFailed { message: String },
166
167    #[error("Approval was rejected by user")]
168    ApprovalRejected,
169}
170
171/// Errors from the agent orchestrator.
172#[derive(Debug, thiserror::Error)]
173pub enum AgentError {
174    #[error("Maximum iterations ({max}) reached without completing task")]
175    MaxIterationsReached { max: usize },
176
177    #[error("Agent is already processing a task")]
178    AlreadyBusy,
179
180    #[error("Agent has been shut down")]
181    ShutDown,
182
183    #[error("Task was cancelled")]
184    Cancelled,
185
186    #[error("Invalid state transition: {from} -> {to}")]
187    InvalidStateTransition { from: String, to: String },
188
189    #[error("Budget exceeded: {message}")]
190    BudgetExceeded { message: String },
191}
192
193/// Errors from the channel system.
194#[derive(Debug, thiserror::Error)]
195pub enum ChannelError {
196    #[error("Channel '{name}' connection failed: {message}")]
197    ConnectionFailed { name: String, message: String },
198
199    #[error("Channel '{name}' send failed: {message}")]
200    SendFailed { name: String, message: String },
201
202    #[error("Channel '{name}' is not connected")]
203    NotConnected { name: String },
204
205    #[error("Channel '{name}' authentication failed")]
206    AuthFailed { name: String },
207
208    #[error("Channel '{name}' rate limited")]
209    RateLimited { name: String },
210}
211
212/// Errors from the node system.
213#[derive(Debug, thiserror::Error)]
214pub enum NodeError {
215    #[error("No capable node for capability: {capability}")]
216    NoCapableNode { capability: String },
217
218    #[error("Node '{node_id}' execution failed: {message}")]
219    ExecutionFailed { node_id: String, message: String },
220
221    #[error("Node '{node_id}' is unreachable")]
222    Unreachable { node_id: String },
223
224    #[error("Consent denied for capability: {capability}")]
225    ConsentDenied { capability: String },
226
227    #[error("Node discovery failed: {message}")]
228    DiscoveryFailed { message: String },
229}
230
231/// Errors from the workflow engine.
232#[derive(Debug, thiserror::Error)]
233pub enum WorkflowError {
234    #[error("Workflow parse error: {message}")]
235    ParseError { message: String },
236
237    #[error("Workflow validation failed: {message}")]
238    ValidationFailed { message: String },
239
240    #[error("Workflow step '{step}' failed: {message}")]
241    StepFailed { step: String, message: String },
242
243    #[error("Workflow '{name}' not found")]
244    NotFound { name: String },
245
246    #[error("Workflow run '{run_id}' not found")]
247    RunNotFound { run_id: Uuid },
248
249    #[error("Workflow approval timed out for step '{step}'")]
250    ApprovalTimeout { step: String },
251
252    #[error("Workflow cancelled")]
253    Cancelled,
254
255    #[error("Template render error: {message}")]
256    TemplateError { message: String },
257}
258
259/// Errors from the browser automation system.
260#[derive(Debug, thiserror::Error)]
261pub enum BrowserError {
262    #[error("Navigation failed: {message}")]
263    NavigationFailed { message: String },
264
265    #[error("Element not found: {selector}")]
266    ElementNotFound { selector: String },
267
268    #[error("JavaScript evaluation failed: {message}")]
269    JsEvalFailed { message: String },
270
271    #[error("Screenshot failed: {message}")]
272    ScreenshotFailed { message: String },
273
274    #[error("Browser timeout after {timeout_secs}s")]
275    Timeout { timeout_secs: u64 },
276
277    #[error("URL blocked by security policy: {url}")]
278    UrlBlocked { url: String },
279
280    #[error("Browser session error: {message}")]
281    SessionError { message: String },
282
283    #[error("CDP protocol error: {message}")]
284    CdpError { message: String },
285
286    #[error("Page limit exceeded: maximum {max} pages")]
287    PageLimitExceeded { max: usize },
288
289    #[error("Tab not found: {tab_id}")]
290    TabNotFound { tab_id: String },
291
292    #[error("Browser not connected")]
293    NotConnected,
294}
295
296/// Errors from the scheduler system.
297#[derive(Debug, thiserror::Error)]
298pub enum SchedulerError {
299    #[error("Invalid cron expression '{expression}': {message}")]
300    InvalidCronExpression { expression: String, message: String },
301
302    #[error("Job '{name}' not found")]
303    JobNotFound { name: String },
304
305    #[error("Job '{name}' already exists")]
306    JobAlreadyExists { name: String },
307
308    #[error("Job '{name}' is disabled")]
309    JobDisabled { name: String },
310
311    #[error("Maximum background jobs ({max}) exceeded")]
312    MaxJobsExceeded { max: usize },
313
314    #[error("Background job '{id}' not found")]
315    BackgroundJobNotFound { id: Uuid },
316
317    #[error("Webhook verification failed: {message}")]
318    WebhookVerificationFailed { message: String },
319
320    #[error("Scheduler state persistence error: {message}")]
321    PersistenceError { message: String },
322}
323
324/// Errors from the voice and audio system.
325#[derive(Debug, thiserror::Error)]
326pub enum VoiceError {
327    #[error("Audio device error: {message}")]
328    AudioDevice { message: String },
329
330    #[error("STT transcription failed: {message}")]
331    TranscriptionFailed { message: String },
332
333    #[error("TTS synthesis failed: {message}")]
334    SynthesisFailed { message: String },
335
336    #[error("Wake word detection error: {message}")]
337    WakeWordError { message: String },
338
339    #[error("Voice pipeline error: {message}")]
340    PipelineError { message: String },
341
342    #[error("Unsupported audio format: {format}")]
343    UnsupportedFormat { format: String },
344
345    #[error("Voice model not found: {model}")]
346    ModelNotFound { model: String },
347
348    #[error("Voice feature not enabled (compile with --features voice)")]
349    FeatureNotEnabled,
350
351    #[error("Voice session timeout after {timeout_secs}s")]
352    Timeout { timeout_secs: u64 },
353
354    #[error("Voice provider authentication failed: {provider}")]
355    AuthFailed { provider: String },
356
357    #[error("Audio I/O error: {message}")]
358    AudioError { message: String },
359}
360
361/// Trait providing actionable recovery guidance for errors.
362///
363/// Each error variant maps to a human-friendly suggestion and next steps,
364/// helping users recover without external documentation.
365pub trait UserGuidance {
366    /// A concise suggestion for the most likely recovery action.
367    fn suggestion(&self) -> Option<String>;
368
369    /// Ordered list of next steps the user can try.
370    fn next_steps(&self) -> Vec<String>;
371}
372
373impl UserGuidance for RustantError {
374    fn suggestion(&self) -> Option<String> {
375        match self {
376            RustantError::Llm(e) => e.suggestion(),
377            RustantError::Tool(e) => e.suggestion(),
378            RustantError::Memory(e) => e.suggestion(),
379            RustantError::Config(e) => e.suggestion(),
380            RustantError::Safety(e) => e.suggestion(),
381            RustantError::Agent(e) => e.suggestion(),
382            RustantError::Channel(e) => e.suggestion(),
383            RustantError::Node(e) => e.suggestion(),
384            RustantError::Workflow(e) => e.suggestion(),
385            RustantError::Browser(e) => e.suggestion(),
386            RustantError::Scheduler(e) => e.suggestion(),
387            RustantError::Voice(e) => e.suggestion(),
388            RustantError::Io(_) => Some("Check file permissions and disk space.".into()),
389            RustantError::Serialization(_) => {
390                Some("Data may be corrupted. Try /doctor to check.".into())
391            }
392        }
393    }
394
395    fn next_steps(&self) -> Vec<String> {
396        match self {
397            RustantError::Llm(e) => e.next_steps(),
398            RustantError::Tool(e) => e.next_steps(),
399            RustantError::Agent(e) => e.next_steps(),
400            RustantError::Node(e) => e.next_steps(),
401            RustantError::Workflow(e) => e.next_steps(),
402            RustantError::Browser(e) => e.next_steps(),
403            RustantError::Scheduler(e) => e.next_steps(),
404            RustantError::Voice(e) => e.next_steps(),
405            RustantError::Memory(e) => e.next_steps(),
406            RustantError::Config(e) => e.next_steps(),
407            RustantError::Safety(e) => e.next_steps(),
408            RustantError::Channel(e) => e.next_steps(),
409            _ => vec![],
410        }
411    }
412}
413
414impl UserGuidance for LlmError {
415    fn suggestion(&self) -> Option<String> {
416        match self {
417            LlmError::AuthFailed { provider } => Some(format!(
418                "Authentication failed for {}. Check your API key.",
419                provider
420            )),
421            LlmError::RateLimited { retry_after_secs } => Some(format!(
422                "Rate limited. Rustant will retry in {}s.",
423                retry_after_secs
424            )),
425            LlmError::Connection { .. } => {
426                Some("Cannot reach the LLM provider. Check your network.".into())
427            }
428            LlmError::Timeout { timeout_secs } => {
429                Some(format!("Request timed out after {}s.", timeout_secs))
430            }
431            LlmError::ContextOverflow { used, limit } => Some(format!(
432                "Context full ({}/{} tokens). Use /compact to free space.",
433                used, limit
434            )),
435            LlmError::UnsupportedModel { model } => Some(format!(
436                "Model '{}' is not supported by this provider.",
437                model
438            )),
439            _ => None,
440        }
441    }
442
443    fn next_steps(&self) -> Vec<String> {
444        match self {
445            LlmError::AuthFailed { .. } => vec![
446                "Run /doctor to verify API key status.".into(),
447                "Run /setup to reconfigure your provider.".into(),
448            ],
449            LlmError::RateLimited { .. } => {
450                vec!["Wait for the retry or switch models with /config model <name>.".into()]
451            }
452            LlmError::Connection { .. } => vec![
453                "Check your internet connection.".into(),
454                "Run /doctor to test LLM connectivity.".into(),
455            ],
456            LlmError::ContextOverflow { .. } => vec![
457                "Use /compact to compress conversation history.".into(),
458                "Use /pin to protect important messages before compression.".into(),
459            ],
460            _ => vec![],
461        }
462    }
463}
464
465impl UserGuidance for ToolError {
466    fn suggestion(&self) -> Option<String> {
467        match self {
468            ToolError::NotFound { name } => Some(format!(
469                "Tool '{}' is not registered. Use /tools to list available tools.",
470                name
471            )),
472            ToolError::InvalidArguments { name, reason } => {
473                Some(format!("Invalid arguments for '{}': {}", name, reason))
474            }
475            ToolError::ExecutionFailed { name, message } => {
476                // Try to categorize the failure
477                if message.contains("No such file") || message.contains("not found") {
478                    Some("File not found. Use file_list to browse available files.".to_string())
479                } else if message.contains("Permission denied") {
480                    Some(format!(
481                        "Permission denied for '{name}'. Check file permissions."
482                    ))
483                } else {
484                    Some(format!(
485                        "Tool '{name}' failed. The agent will try to recover."
486                    ))
487                }
488            }
489            ToolError::Timeout { name, timeout_secs } => Some(format!(
490                "Tool '{}' timed out after {}s. Consider breaking the task into smaller steps.",
491                name, timeout_secs
492            )),
493            ToolError::PermissionDenied { name, .. } => Some(format!(
494                "Permission denied for '{}'. Adjust with /permissions.",
495                name
496            )),
497            _ => None,
498        }
499    }
500
501    fn next_steps(&self) -> Vec<String> {
502        match self {
503            ToolError::Timeout { .. } => {
504                vec!["Try a more specific query or smaller file range.".into()]
505            }
506            ToolError::NotFound { .. } => vec!["Run /tools to see registered tools.".into()],
507            _ => vec![],
508        }
509    }
510}
511
512impl UserGuidance for MemoryError {
513    fn suggestion(&self) -> Option<String> {
514        match self {
515            MemoryError::CompressionFailed { .. } => {
516                Some("Context compression failed. Use /compact to retry manually.".into())
517            }
518            MemoryError::CapacityExceeded => {
519                Some("Memory capacity exceeded. Use /compact or start a new session.".into())
520            }
521            MemoryError::SessionLoadFailed { message } => Some(format!(
522                "Session load failed: {}. Use /sessions to list available sessions.",
523                message
524            )),
525            _ => None,
526        }
527    }
528
529    fn next_steps(&self) -> Vec<String> {
530        vec![]
531    }
532}
533
534impl UserGuidance for ConfigError {
535    fn suggestion(&self) -> Option<String> {
536        match self {
537            ConfigError::MissingField { field } => Some(format!(
538                "Missing config field '{}'. Run /setup to configure.",
539                field
540            )),
541            ConfigError::EnvVarMissing { var } => {
542                Some(format!("Set environment variable {} or run /setup.", var))
543            }
544            _ => None,
545        }
546    }
547
548    fn next_steps(&self) -> Vec<String> {
549        vec![]
550    }
551}
552
553impl UserGuidance for SafetyError {
554    fn suggestion(&self) -> Option<String> {
555        match self {
556            SafetyError::ApprovalRejected => {
557                Some("Action was denied. The agent will try an alternative approach.".into())
558            }
559            SafetyError::PathDenied { path } => Some(format!(
560                "Path '{}' is blocked by safety policy.",
561                path.display()
562            )),
563            SafetyError::CommandDenied { command } => Some(format!(
564                "Command '{}' is not in the allowed list. Adjust in config.",
565                command
566            )),
567            _ => None,
568        }
569    }
570
571    fn next_steps(&self) -> Vec<String> {
572        vec![]
573    }
574}
575
576impl UserGuidance for AgentError {
577    fn suggestion(&self) -> Option<String> {
578        match self {
579            AgentError::MaxIterationsReached { max } => Some(format!(
580                "Task exceeded {} iterations. Break it into smaller steps.",
581                max
582            )),
583            AgentError::BudgetExceeded { .. } => {
584                Some("Token budget exceeded. Start a new session or increase the budget.".into())
585            }
586            AgentError::Cancelled => Some("Task was cancelled.".into()),
587            _ => None,
588        }
589    }
590
591    fn next_steps(&self) -> Vec<String> {
592        match self {
593            AgentError::MaxIterationsReached { .. } => vec![
594                "Increase limit with /config max_iterations <n>.".into(),
595                "Break your task into smaller, focused steps.".into(),
596            ],
597            _ => vec![],
598        }
599    }
600}
601
602impl UserGuidance for ChannelError {
603    fn suggestion(&self) -> Option<String> {
604        match self {
605            ChannelError::ConnectionFailed { name, .. } => Some(format!(
606                "Channel '{}' connection failed. Check credentials.",
607                name
608            )),
609            ChannelError::AuthFailed { name } => Some(format!(
610                "Channel '{}' auth failed. Re-run channel setup.",
611                name
612            )),
613            _ => None,
614        }
615    }
616
617    fn next_steps(&self) -> Vec<String> {
618        vec![]
619    }
620}
621
622impl UserGuidance for NodeError {
623    fn suggestion(&self) -> Option<String> {
624        match self {
625            NodeError::NoCapableNode { capability } => Some(format!(
626                "No node has the '{}' capability. Check node configuration.",
627                capability
628            )),
629            NodeError::ExecutionFailed { node_id, .. } => Some(format!(
630                "Node '{}' failed. It may be overloaded or misconfigured.",
631                node_id
632            )),
633            NodeError::Unreachable { node_id } => Some(format!(
634                "Node '{}' is unreachable. Check network connectivity.",
635                node_id
636            )),
637            NodeError::ConsentDenied { capability } => Some(format!(
638                "Consent denied for '{}'. Grant permission in node settings.",
639                capability
640            )),
641            NodeError::DiscoveryFailed { .. } => {
642                Some("Node discovery failed. Check gateway configuration.".into())
643            }
644        }
645    }
646
647    fn next_steps(&self) -> Vec<String> {
648        match self {
649            NodeError::Unreachable { .. } => vec![
650                "Verify the node is running and accessible.".into(),
651                "Check firewall and network settings.".into(),
652            ],
653            _ => vec![],
654        }
655    }
656}
657
658impl UserGuidance for WorkflowError {
659    fn suggestion(&self) -> Option<String> {
660        match self {
661            WorkflowError::NotFound { name } => Some(format!(
662                "Workflow '{}' not found. Use /workflows to list available workflows.",
663                name
664            )),
665            WorkflowError::StepFailed { step, .. } => Some(format!(
666                "Workflow step '{}' failed. Check inputs and retry.",
667                step
668            )),
669            WorkflowError::ValidationFailed { message } => Some(format!(
670                "Workflow validation failed: {}. Fix the definition and retry.",
671                message
672            )),
673            WorkflowError::ApprovalTimeout { step } => Some(format!(
674                "Approval timed out for step '{}'. Re-run the workflow.",
675                step
676            )),
677            WorkflowError::Cancelled => Some("Workflow was cancelled.".into()),
678            _ => None,
679        }
680    }
681
682    fn next_steps(&self) -> Vec<String> {
683        match self {
684            WorkflowError::NotFound { .. } => {
685                vec!["Run /workflows to see available workflow templates.".into()]
686            }
687            _ => vec![],
688        }
689    }
690}
691
692impl UserGuidance for BrowserError {
693    fn suggestion(&self) -> Option<String> {
694        match self {
695            BrowserError::NotConnected => {
696                Some("Browser is not connected. Start a browser session first.".into())
697            }
698            BrowserError::Timeout { timeout_secs } => Some(format!(
699                "Browser timed out after {}s. The page may be slow to load.",
700                timeout_secs
701            )),
702            BrowserError::ElementNotFound { selector } => Some(format!(
703                "Element '{}' not found. The page structure may have changed.",
704                selector
705            )),
706            BrowserError::UrlBlocked { url } => {
707                Some(format!("URL '{}' is blocked by security policy.", url))
708            }
709            BrowserError::NavigationFailed { .. } => {
710                Some("Navigation failed. Check the URL and try again.".into())
711            }
712            _ => None,
713        }
714    }
715
716    fn next_steps(&self) -> Vec<String> {
717        match self {
718            BrowserError::NotConnected => {
719                vec!["Run 'rustant browser test' to verify browser connectivity.".into()]
720            }
721            _ => vec![],
722        }
723    }
724}
725
726impl UserGuidance for SchedulerError {
727    fn suggestion(&self) -> Option<String> {
728        match self {
729            SchedulerError::InvalidCronExpression { expression, .. } => Some(format!(
730                "Invalid cron expression '{}'. Use standard cron syntax (e.g., '0 9 * * *').",
731                expression
732            )),
733            SchedulerError::JobNotFound { name } => Some(format!(
734                "Job '{}' not found. Use 'rustant cron list' to see existing jobs.",
735                name
736            )),
737            SchedulerError::JobAlreadyExists { name } => Some(format!(
738                "Job '{}' already exists. Use a different name or remove the existing one.",
739                name
740            )),
741            SchedulerError::MaxJobsExceeded { max } => Some(format!(
742                "Maximum of {} jobs reached. Remove some before adding new ones.",
743                max
744            )),
745            _ => None,
746        }
747    }
748
749    fn next_steps(&self) -> Vec<String> {
750        match self {
751            SchedulerError::JobNotFound { .. } => {
752                vec!["Run 'rustant cron list' to see existing jobs.".into()]
753            }
754            _ => vec![],
755        }
756    }
757}
758
759impl UserGuidance for VoiceError {
760    fn suggestion(&self) -> Option<String> {
761        match self {
762            VoiceError::FeatureNotEnabled => Some(
763                "Voice features require the 'voice' feature flag. Recompile with --features voice."
764                    .into(),
765            ),
766            VoiceError::AudioDevice { .. } => {
767                Some("Audio device error. Check that a microphone/speaker is connected.".into())
768            }
769            VoiceError::AuthFailed { provider } => Some(format!(
770                "Voice provider '{}' auth failed. Check API key.",
771                provider
772            )),
773            VoiceError::ModelNotFound { model } => Some(format!(
774                "Voice model '{}' not found. Check available models.",
775                model
776            )),
777            VoiceError::Timeout { timeout_secs } => Some(format!(
778                "Voice operation timed out after {}s.",
779                timeout_secs
780            )),
781            _ => None,
782        }
783    }
784
785    fn next_steps(&self) -> Vec<String> {
786        match self {
787            VoiceError::FeatureNotEnabled => {
788                vec!["Recompile: cargo build --features voice".into()]
789            }
790            VoiceError::AuthFailed { .. } => {
791                vec!["Run /doctor to verify API key status.".into()]
792            }
793            _ => vec![],
794        }
795    }
796}
797
798/// A type alias for results using the top-level `RustantError`.
799pub type Result<T> = std::result::Result<T, RustantError>;
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    #[test]
806    fn test_error_display_llm() {
807        let err = RustantError::Llm(LlmError::ApiRequest {
808            message: "connection refused".into(),
809        });
810        assert_eq!(
811            err.to_string(),
812            "LLM error: API request failed: connection refused"
813        );
814    }
815
816    #[test]
817    fn test_error_display_tool() {
818        let err = RustantError::Tool(ToolError::NotFound {
819            name: "nonexistent".into(),
820        });
821        assert_eq!(err.to_string(), "Tool error: Tool not found: nonexistent");
822    }
823
824    #[test]
825    fn test_error_display_safety() {
826        let err = RustantError::Safety(SafetyError::PathDenied {
827            path: PathBuf::from("/etc/passwd"),
828        });
829        assert_eq!(
830            err.to_string(),
831            "Safety error: Path access denied: /etc/passwd"
832        );
833    }
834
835    #[test]
836    fn test_error_display_config() {
837        let err = RustantError::Config(ConfigError::MissingField {
838            field: "llm.api_key".into(),
839        });
840        assert_eq!(
841            err.to_string(),
842            "Configuration error: Missing required field: llm.api_key"
843        );
844    }
845
846    #[test]
847    fn test_error_display_agent() {
848        let err = RustantError::Agent(AgentError::MaxIterationsReached { max: 25 });
849        assert_eq!(
850            err.to_string(),
851            "Agent error: Maximum iterations (25) reached without completing task"
852        );
853    }
854
855    #[test]
856    fn test_error_from_io() {
857        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
858        let err: RustantError = io_err.into();
859        assert!(matches!(err, RustantError::Io(_)));
860    }
861
862    #[test]
863    fn test_error_from_serde() {
864        let serde_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
865        let err: RustantError = serde_err.into();
866        assert!(matches!(err, RustantError::Serialization(_)));
867    }
868
869    #[test]
870    fn test_tool_error_variants() {
871        let err = ToolError::InvalidArguments {
872            name: "file_read".into(),
873            reason: "path is required".into(),
874        };
875        assert_eq!(
876            err.to_string(),
877            "Invalid arguments for tool 'file_read': path is required"
878        );
879
880        let err = ToolError::Timeout {
881            name: "shell_exec".into(),
882            timeout_secs: 30,
883        };
884        assert_eq!(err.to_string(), "Tool 'shell_exec' timed out after 30s");
885    }
886
887    #[test]
888    fn test_error_display_channel() {
889        let err = RustantError::Channel(ChannelError::ConnectionFailed {
890            name: "telegram".into(),
891            message: "timeout".into(),
892        });
893        assert_eq!(
894            err.to_string(),
895            "Channel error: Channel 'telegram' connection failed: timeout"
896        );
897
898        let err = ChannelError::NotConnected {
899            name: "slack".into(),
900        };
901        assert_eq!(err.to_string(), "Channel 'slack' is not connected");
902    }
903
904    #[test]
905    fn test_error_display_node() {
906        let err = RustantError::Node(NodeError::NoCapableNode {
907            capability: "shell".into(),
908        });
909        assert_eq!(
910            err.to_string(),
911            "Node error: No capable node for capability: shell"
912        );
913
914        let err = NodeError::ConsentDenied {
915            capability: "filesystem".into(),
916        };
917        assert_eq!(err.to_string(), "Consent denied for capability: filesystem");
918    }
919
920    #[test]
921    fn test_error_display_voice() {
922        let err = RustantError::Voice(VoiceError::TranscriptionFailed {
923            message: "model not loaded".into(),
924        });
925        assert_eq!(
926            err.to_string(),
927            "Voice error: STT transcription failed: model not loaded"
928        );
929
930        let err = VoiceError::FeatureNotEnabled;
931        assert_eq!(
932            err.to_string(),
933            "Voice feature not enabled (compile with --features voice)"
934        );
935
936        let err = VoiceError::AudioDevice {
937            message: "no microphone found".into(),
938        };
939        assert_eq!(err.to_string(), "Audio device error: no microphone found");
940    }
941
942    #[test]
943    fn test_llm_error_variants() {
944        let err = LlmError::ContextOverflow {
945            used: 150_000,
946            limit: 128_000,
947        };
948        assert_eq!(
949            err.to_string(),
950            "Context window exceeded: used 150000 of 128000 tokens"
951        );
952
953        let err = LlmError::RateLimited {
954            retry_after_secs: 60,
955        };
956        assert_eq!(err.to_string(), "Rate limited by provider, retry after 60s");
957    }
958
959    #[test]
960    fn test_node_error_guidance() {
961        let err = NodeError::Unreachable {
962            node_id: "node-1".into(),
963        };
964        assert!(err.suggestion().is_some());
965        assert!(err.suggestion().unwrap().contains("node-1"));
966        assert!(!err.next_steps().is_empty());
967    }
968
969    #[test]
970    fn test_workflow_error_guidance() {
971        let err = WorkflowError::NotFound {
972            name: "deploy".into(),
973        };
974        assert!(err.suggestion().unwrap().contains("deploy"));
975        assert!(!err.next_steps().is_empty());
976    }
977
978    #[test]
979    fn test_browser_error_guidance() {
980        let err = BrowserError::NotConnected;
981        assert!(err.suggestion().is_some());
982        assert!(!err.next_steps().is_empty());
983    }
984
985    #[test]
986    fn test_scheduler_error_guidance() {
987        let err = SchedulerError::JobNotFound {
988            name: "backup".into(),
989        };
990        assert!(err.suggestion().unwrap().contains("backup"));
991        assert!(!err.next_steps().is_empty());
992    }
993
994    #[test]
995    fn test_voice_error_guidance() {
996        let err = VoiceError::FeatureNotEnabled;
997        assert!(err.suggestion().unwrap().contains("voice"));
998        assert!(!err.next_steps().is_empty());
999    }
1000
1001    #[test]
1002    fn test_rustant_error_dispatches_all_guidance() {
1003        // Verify node errors dispatch through RustantError
1004        let err = RustantError::Node(NodeError::Unreachable {
1005            node_id: "n".into(),
1006        });
1007        assert!(err.suggestion().is_some());
1008        assert!(!err.next_steps().is_empty());
1009
1010        // Verify voice errors dispatch through RustantError
1011        let err = RustantError::Voice(VoiceError::FeatureNotEnabled);
1012        assert!(err.suggestion().is_some());
1013        assert!(!err.next_steps().is_empty());
1014
1015        // Verify workflow errors dispatch through RustantError
1016        let err = RustantError::Workflow(WorkflowError::NotFound { name: "w".into() });
1017        assert!(err.suggestion().is_some());
1018    }
1019
1020    #[test]
1021    fn test_memory_error_guidance() {
1022        let err = MemoryError::CompressionFailed {
1023            message: "out of memory".into(),
1024        };
1025        assert!(err.suggestion().is_some());
1026
1027        let err = MemoryError::CapacityExceeded;
1028        assert!(err.suggestion().is_some());
1029    }
1030
1031    #[test]
1032    fn test_config_error_guidance() {
1033        let err = ConfigError::MissingField {
1034            field: "api_key".into(),
1035        };
1036        assert!(err.suggestion().is_some());
1037
1038        let err = ConfigError::EnvVarMissing {
1039            var: "OPENAI_API_KEY".into(),
1040        };
1041        assert!(err.suggestion().is_some());
1042    }
1043
1044    #[test]
1045    fn test_safety_error_guidance() {
1046        let err = SafetyError::PathDenied {
1047            path: "/etc/passwd".into(),
1048        };
1049        assert!(err.suggestion().is_some());
1050
1051        let err = SafetyError::ApprovalRejected;
1052        assert!(err.suggestion().is_some());
1053    }
1054
1055    #[test]
1056    fn test_next_steps_delegation_memory_config_safety_channel() {
1057        // These error types should delegate next_steps through RustantError
1058        let err = RustantError::Memory(MemoryError::CompressionFailed {
1059            message: "test".into(),
1060        });
1061        // Should not panic; returns vec (may be empty but delegation works)
1062        let _ = err.next_steps();
1063
1064        let err = RustantError::Config(ConfigError::MissingField {
1065            field: "test".into(),
1066        });
1067        let _ = err.next_steps();
1068
1069        let err = RustantError::Safety(SafetyError::ApprovalRejected);
1070        let _ = err.next_steps();
1071
1072        let err = RustantError::Channel(ChannelError::ConnectionFailed {
1073            name: "test".into(),
1074            message: "fail".into(),
1075        });
1076        let _ = err.next_steps();
1077    }
1078}