Skip to main content

rusty_commit/output/
error.rs

1//! Error formatting utilities for Rusty Commit CLI.
2//!
3//! Provides structured, beautiful error messages with hints and context.
4
5use anyhow::Result;
6use colored::Colorize;
7
8use super::styling::{Styling, Theme};
9
10/// A structured error with context and hints.
11#[derive(Debug, Clone)]
12pub struct StructuredError {
13    /// The main error message.
14    message: String,
15    /// The provider that caused the error (if applicable).
16    provider: Option<String>,
17    /// The model that was being used (if applicable).
18    model: Option<String>,
19    /// The underlying error (if available).
20    underlying: Option<String>,
21    /// Contextual information.
22    context: Vec<(String, String)>,
23    /// Helpful hints for resolution.
24    hints: Vec<String>,
25    /// Exit code for the error.
26    exit_code: i32,
27}
28
29impl StructuredError {
30    /// Create a new structured error.
31    pub fn new(message: &str) -> Self {
32        Self {
33            message: message.to_string(),
34            provider: None,
35            model: None,
36            underlying: None,
37            context: Vec::new(),
38            hints: Vec::new(),
39            exit_code: 1,
40        }
41    }
42
43    /// Set the provider.
44    pub fn with_provider(mut self, provider: &str) -> Self {
45        self.provider = Some(provider.to_string());
46        self
47    }
48
49    /// Set the model.
50    pub fn with_model(mut self, model: &str) -> Self {
51        self.model = Some(model.to_string());
52        self
53    }
54
55    /// Set the underlying error.
56    pub fn with_underlying(mut self, error: &str) -> Self {
57        self.underlying = Some(error.to_string());
58        self
59    }
60
61    /// Add contextual information.
62    #[allow(dead_code)]
63    pub fn with_context(mut self, key: &str, value: &str) -> Self {
64        self.context.push((key.to_string(), value.to_string()));
65        self
66    }
67
68    /// Add a hint.
69    #[allow(dead_code)]
70    pub fn with_hint(mut self, hint: &str) -> Self {
71        self.hints.push(hint.to_string());
72        self
73    }
74
75    /// Add multiple hints.
76    pub fn with_hints<T: IntoIterator<Item = String>>(mut self, hints: T) -> Self {
77        self.hints.extend(hints);
78        self
79    }
80
81    /// Set the exit code.
82    pub fn with_exit_code(mut self, code: i32) -> Self {
83        self.exit_code = code;
84        self
85    }
86
87    /// Get the exit code.
88    pub fn exit_code(&self) -> i32 {
89        self.exit_code
90    }
91
92    /// Format the error for display.
93    pub fn display(&self, _theme: &Theme) -> String {
94        let mut output = String::new();
95
96        // Error header
97        output.push_str(&format!(
98            "{} {}\n",
99            Styling::error("X"),
100            Styling::header(&self.message)
101        ));
102
103        // Divider
104        output.push_str(&Styling::divider(50));
105        output.push('\n');
106
107        // Context information
108        if let Some(ref provider) = self.provider {
109            output.push_str(&format!("{}: {}\n", "Provider".dimmed(), provider));
110        }
111        if let Some(ref model) = self.model {
112            output.push_str(&format!("{}: {}\n", "Model".dimmed(), model));
113        }
114        if let Some(ref underlying) = self.underlying {
115            output.push_str(&format!("{}: {}\n", "Error".dimmed(), underlying));
116        }
117
118        // Additional context
119        for (key, value) in &self.context {
120            output.push_str(&format!("{}: {}\n", key.dimmed(), value));
121        }
122
123        // Hints
124        if !self.hints.is_empty() {
125            output.push('\n');
126            output.push_str("Suggestions:\n");
127            for hint in &self.hints {
128                output.push_str(&format!("  - {}\n", hint));
129            }
130        }
131
132        output
133    }
134
135    /// Format as JSON.
136    #[allow(dead_code)]
137    pub fn to_json(&self) -> String {
138        use serde_json::json;
139
140        let hints_array: Vec<String> = self.hints.clone();
141        let context_obj: serde_json::Map<String, serde_json::Value> = self
142            .context
143            .iter()
144            .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
145            .collect();
146
147        let obj = json!({
148            "error": self.message,
149            "provider": self.provider,
150            "model": self.model,
151            "underlying": self.underlying,
152            "context": context_obj,
153            "hints": hints_array,
154            "exit_code": self.exit_code,
155        });
156
157        serde_json::to_string_pretty(&obj).unwrap_or_else(|_| "{}".to_string())
158    }
159
160    /// Format as markdown.
161    #[allow(dead_code)]
162    pub fn to_markdown(&self) -> String {
163        let mut output = String::new();
164
165        output.push_str("## Error\n\n");
166        output.push_str(&format!("**{}**\n\n", self.message));
167
168        if let Some(ref provider) = self.provider {
169            output.push_str(&format!("- **Provider:** {}\n", provider));
170        }
171        if let Some(ref model) = self.model {
172            output.push_str(&format!("- **Model:** {}\n", model));
173        }
174        if let Some(ref underlying) = self.underlying {
175            output.push_str(&format!("- **Error:** {}\n", underlying));
176        }
177
178        if !self.hints.is_empty() {
179            output.push_str("\n## Suggestions\n\n");
180            for hint in &self.hints {
181                output.push_str(&format!("- {}\n", hint));
182            }
183        }
184
185        output
186    }
187}
188
189#[allow(dead_code)]
190/// Helper to convert anyhow errors to structured errors.
191pub trait ToStructured {
192    fn to_structured(&self) -> StructuredError;
193}
194
195impl ToStructured for anyhow::Error {
196    fn to_structured(&self) -> StructuredError {
197        StructuredError::new(&self.to_string())
198    }
199}
200
201/// Common error patterns with built-in hints.
202#[allow(dead_code)]
203pub mod patterns {
204    use super::*;
205
206    /// Rate limit exceeded error.
207    pub fn rate_limit(provider: &str, model: &str) -> StructuredError {
208        StructuredError::new("API rate limit exceeded")
209            .with_provider(provider)
210            .with_model(model)
211            .with_hints(vec![
212                "Wait a few seconds and try again".to_string(),
213                "Use a lighter/faster model".to_string(),
214                "Check the provider's rate limits".to_string(),
215            ])
216    }
217
218    /// Authentication error.
219    pub fn auth(provider: &str) -> StructuredError {
220        StructuredError::new("Authentication failed")
221            .with_provider(provider)
222            .with_exit_code(401)
223            .with_hints(vec![
224                "Run 'rco auth login' to authenticate".to_string(),
225                "Check your API key is valid".to_string(),
226                "Ensure your account has access to the model".to_string(),
227            ])
228    }
229
230    /// Invalid API key error.
231    pub fn invalid_api_key(provider: &str) -> StructuredError {
232        StructuredError::new("Invalid API key")
233            .with_provider(provider)
234            .with_exit_code(401)
235            .with_hints(vec![
236                "Check your API key is correct".to_string(),
237                "Run 'rco auth login' to re-authenticate".to_string(),
238                "Verify your API key has the right permissions".to_string(),
239            ])
240    }
241
242    /// No changes to commit error.
243    pub fn no_changes() -> StructuredError {
244        StructuredError::new("No changes to commit")
245            .with_exit_code(0)
246            .with_hints(vec![
247                "Stage some changes with 'git add'".to_string(),
248                "Use 'git add -A' to stage all changes".to_string(),
249                "Check for untracked files".to_string(),
250            ])
251    }
252
253    /// Not a git repository error.
254    pub fn not_git_repo() -> StructuredError {
255        StructuredError::new("Not a git repository")
256            .with_exit_code(128)
257            .with_hints(vec![
258                "Initialize a git repository with 'git init'".to_string(),
259                "Navigate to a git repository".to_string(),
260                "Clone a repository first".to_string(),
261            ])
262    }
263
264    /// Provider not found error.
265    pub fn provider_not_found(provider: &str) -> StructuredError {
266        StructuredError::new(&format!("Provider not found: {}", provider))
267            .with_exit_code(1)
268            .with_hints(vec![
269                "Check the provider name is correct".to_string(),
270                "Run 'rco config describe' to see available providers".to_string(),
271                "Supported providers: openai, anthropic, ollama, gemini, and more".to_string(),
272            ])
273    }
274
275    /// Model not found error.
276    pub fn model_not_found(model: &str, provider: &str) -> StructuredError {
277        StructuredError::new(&format!("Model not found: {}", model))
278            .with_provider(provider)
279            .with_exit_code(1)
280            .with_hints(vec![
281                "Check the model name is correct".to_string(),
282                "Run 'rco model --list' to see available models".to_string(),
283                "Try using the default model for this provider".to_string(),
284            ])
285    }
286
287    /// Network error.
288    pub fn network(error: &str) -> StructuredError {
289        StructuredError::new("Network error")
290            .with_underlying(error)
291            .with_hints(vec![
292                "Check your internet connection".to_string(),
293                "Verify the API endpoint is accessible".to_string(),
294                "Check for firewall or proxy issues".to_string(),
295                "Try again later".to_string(),
296            ])
297    }
298
299    /// Timeout error.
300    pub fn timeout(provider: &str) -> StructuredError {
301        StructuredError::new("Request timed out")
302            .with_provider(provider)
303            .with_hints(vec![
304                "Try again - it may be a temporary issue".to_string(),
305                "Use a smaller/faster model".to_string(),
306                "Check the provider's status page".to_string(),
307            ])
308    }
309}
310
311#[allow(dead_code)]
312/// Print a structured error with the appropriate format.
313pub fn print_error(error: &StructuredError, theme: &Theme) {
314    match theme.use_colors {
315        true => {
316            eprintln!("{}", error.display(theme));
317        }
318        false => {
319            eprintln!("Error: {}", error.message);
320            if let Some(ref provider) = error.provider {
321                eprintln!("Provider: {}", provider);
322            }
323            if let Some(ref model) = error.model {
324                eprintln!("Model: {}", model);
325            }
326            if let Some(ref underlying) = error.underlying {
327                eprintln!("Error: {}", underlying);
328            }
329            if !error.hints.is_empty() {
330                eprintln!("Suggestions:");
331                for hint in &error.hints {
332                    eprintln!("  - {}", hint);
333                }
334            }
335        }
336    }
337}
338
339#[allow(dead_code)]
340/// Exit with a structured error.
341pub fn exit_with_error(error: &StructuredError) -> ! {
342    let theme = Theme::new();
343    print_error(error, &theme);
344    std::process::exit(error.exit_code());
345}
346
347#[allow(dead_code)]
348/// Convert an anyhow Result to a StructuredError with optional context.
349pub fn context<T, E: std::error::Error + Send + Sync>(
350    result: Result<T, E>,
351    message: &str,
352) -> Result<T, Box<StructuredError>> {
353    result.map_err(|e| Box::new(StructuredError::new(message).with_underlying(&e.to_string())))
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_structured_error_new() {
362        let error = StructuredError::new("Test error");
363        assert_eq!(error.message, "Test error");
364        assert!(error.provider.is_none());
365        assert!(error.hints.is_empty());
366        assert_eq!(error.exit_code(), 1);
367    }
368
369    #[test]
370    fn test_structured_error_with_chain() {
371        let error = StructuredError::new("Main error")
372            .with_provider("TestProvider")
373            .with_model("TestModel")
374            .with_underlying("Underlying error")
375            .with_hint("Hint 1")
376            .with_hint("Hint 2")
377            .with_exit_code(42);
378
379        assert_eq!(error.message, "Main error");
380        assert_eq!(error.provider, Some("TestProvider".to_string()));
381        assert_eq!(error.model, Some("TestModel".to_string()));
382        assert_eq!(error.underlying, Some("Underlying error".to_string()));
383        assert_eq!(error.hints.len(), 2);
384        assert_eq!(error.exit_code(), 42);
385    }
386
387    #[test]
388    fn test_error_patterns_rate_limit() {
389        let error = patterns::rate_limit("Anthropic", "claude-3-5-haiku");
390        assert!(error.message.contains("rate limit"));
391        assert_eq!(error.provider, Some("Anthropic".to_string()));
392        assert_eq!(error.model, Some("claude-3-5-haiku".to_string()));
393        assert!(!error.hints.is_empty());
394    }
395
396    #[test]
397    fn test_error_patterns_auth() {
398        let error = patterns::auth("OpenAI");
399        assert!(error.message.contains("Authentication"));
400        assert_eq!(error.exit_code(), 401);
401    }
402
403    #[test]
404    fn test_error_patterns_no_changes() {
405        let error = patterns::no_changes();
406        assert!(error.message.contains("No changes"));
407        assert_eq!(error.exit_code(), 0);
408    }
409
410    #[test]
411    fn test_error_to_json() {
412        let error = StructuredError::new("Test").with_hint("Hint 1");
413        let json = error.to_json();
414        assert!(json.contains("Test"));
415        assert!(json.contains("Hint 1"));
416    }
417
418    #[test]
419    fn test_error_to_markdown() {
420        let error = StructuredError::new("Test Error")
421            .with_provider("TestProvider")
422            .with_hint("Try again");
423        let md = error.to_markdown();
424        assert!(md.contains("## Error"));
425        assert!(md.contains("Test Error"));
426        assert!(md.contains("Provider"));
427        assert!(md.contains("## Suggestions"));
428    }
429
430    #[test]
431    fn test_structured_error_display() {
432        let theme = Theme::new();
433        let error = StructuredError::new("Test error").with_hint("Test hint");
434        let display = error.display(&theme);
435        assert!(display.contains("Test error"));
436        assert!(display.contains("Suggestions"));
437        assert!(display.contains("Test hint"));
438    }
439}