Skip to main content

asupersync/cli/
error.rs

1//! Structured error messages for CLI tools.
2//!
3//! Follows RFC 9457 (Problem Details) style for machine-readable errors
4//! with human-friendly formatting.
5
6use super::exit::ExitCode;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Structured error following RFC 9457 (Problem Details) style.
11///
12/// Provides machine-readable error information with human-friendly presentation.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CliError {
15    /// Error type identifier (machine-readable).
16    #[serde(rename = "type")]
17    pub error_type: String,
18
19    /// Short human-readable title.
20    pub title: String,
21
22    /// Detailed explanation.
23    #[serde(default, skip_serializing_if = "String::is_empty")]
24    pub detail: String,
25
26    /// Suggested action for recovery.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub suggestion: Option<String>,
29
30    /// Related documentation URL.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub docs_url: Option<String>,
33
34    /// Additional context (varies by error type).
35    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
36    pub context: HashMap<String, serde_json::Value>,
37
38    /// Exit code for this error.
39    pub exit_code: i32,
40}
41
42impl CliError {
43    /// Create a new CLI error.
44    #[must_use]
45    pub fn new(error_type: impl Into<String>, title: impl Into<String>) -> Self {
46        Self {
47            error_type: error_type.into(),
48            title: title.into(),
49            detail: String::new(),
50            suggestion: None,
51            docs_url: None,
52            context: HashMap::new(),
53            exit_code: ExitCode::RUNTIME_ERROR,
54        }
55    }
56
57    /// Add detailed explanation.
58    #[must_use]
59    pub fn detail(mut self, detail: impl Into<String>) -> Self {
60        self.detail = detail.into();
61        self
62    }
63
64    /// Add a suggested recovery action.
65    #[must_use]
66    pub fn suggestion(mut self, suggestion: impl Into<String>) -> Self {
67        self.suggestion = Some(suggestion.into());
68        self
69    }
70
71    /// Add documentation URL.
72    #[must_use]
73    pub fn docs(mut self, url: impl Into<String>) -> Self {
74        self.docs_url = Some(url.into());
75        self
76    }
77
78    /// Add context field.
79    #[must_use]
80    pub fn context(mut self, key: impl Into<String>, value: impl Serialize) -> Self {
81        if let Ok(v) = serde_json::to_value(value) {
82            self.context.insert(key.into(), v);
83        }
84        self
85    }
86
87    /// Set exit code.
88    #[must_use]
89    pub const fn exit_code(mut self, code: i32) -> Self {
90        self.exit_code = code;
91        self
92    }
93
94    /// Format for human output.
95    ///
96    /// When `color` is true, includes ANSI escape codes for terminal coloring.
97    #[must_use]
98    pub fn human_format(&self, color: bool) -> String {
99        let mut out = String::new();
100
101        // Error title in red
102        if color {
103            out.push_str("\x1b[1;31m"); // Bold red
104        }
105        out.push_str("Error: ");
106        out.push_str(&self.title);
107        if color {
108            out.push_str("\x1b[0m"); // Reset
109        }
110        out.push('\n');
111
112        // Detail in normal text
113        if !self.detail.is_empty() {
114            out.push_str(&self.detail);
115            out.push('\n');
116        }
117
118        // Suggestion in yellow
119        if let Some(ref suggestion) = self.suggestion {
120            out.push('\n');
121            if color {
122                out.push_str("\x1b[33m"); // Yellow
123            }
124            out.push_str("Suggestion: ");
125            out.push_str(suggestion);
126            if color {
127                out.push_str("\x1b[0m");
128            }
129            out.push('\n');
130        }
131
132        // Docs link in blue/underline
133        if let Some(ref docs) = self.docs_url {
134            if color {
135                out.push_str("\x1b[4;34m"); // Underline blue
136            }
137            out.push_str("See: ");
138            out.push_str(docs);
139            if color {
140                out.push_str("\x1b[0m");
141            }
142            out.push('\n');
143        }
144
145        // Context in dim
146        if !self.context.is_empty() {
147            out.push('\n');
148            if color {
149                out.push_str("\x1b[2m"); // Dim
150            }
151            out.push_str("Context:\n");
152            for (k, v) in &self.context {
153                use std::fmt::Write;
154                let _ = writeln!(out, "  {k}: {v}");
155            }
156            if color {
157                out.push_str("\x1b[0m");
158            }
159        }
160
161        out
162    }
163
164    /// Format as JSON.
165    #[must_use]
166    pub fn json_format(&self) -> String {
167        serde_json::to_string(self).unwrap_or_else(|_| self.title.clone())
168    }
169
170    /// Format as pretty JSON.
171    #[must_use]
172    pub fn json_pretty_format(&self) -> String {
173        serde_json::to_string_pretty(self).unwrap_or_else(|_| self.title.clone())
174    }
175}
176
177impl std::fmt::Display for CliError {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        write!(f, "{}: {}", self.error_type, self.title)
180    }
181}
182
183impl std::error::Error for CliError {}
184
185/// Standard error constructors.
186pub mod errors {
187    use super::{CliError, ExitCode};
188
189    /// Invalid argument error.
190    #[must_use]
191    pub fn invalid_argument(arg: &str, reason: &str) -> CliError {
192        CliError::new("invalid_argument", format!("Invalid argument: {arg}"))
193            .detail(reason)
194            .exit_code(ExitCode::USER_ERROR)
195    }
196
197    /// File not found error.
198    #[must_use]
199    pub fn file_not_found(path: &str) -> CliError {
200        CliError::new("file_not_found", "File not found")
201            .detail(format!("The file '{path}' does not exist"))
202            .suggestion("Check the path and try again")
203            .context("path", path)
204            .exit_code(ExitCode::USER_ERROR)
205    }
206
207    /// Permission denied error.
208    #[must_use]
209    pub fn permission_denied(path: &str) -> CliError {
210        CliError::new("permission_denied", "Permission denied")
211            .detail(format!("Cannot access '{path}'"))
212            .suggestion("Check file permissions or run with appropriate privileges")
213            .context("path", path)
214            .exit_code(ExitCode::USER_ERROR)
215    }
216
217    /// Invariant violation error.
218    #[must_use]
219    pub fn invariant_violation(invariant: &str, details: &str) -> CliError {
220        CliError::new(
221            "invariant_violation",
222            format!("Invariant violated: {invariant}"),
223        )
224        .detail(details)
225        .docs("https://docs.asupersync.dev/invariants")
226        .exit_code(ExitCode::RUNTIME_ERROR)
227    }
228
229    /// Parse error.
230    #[must_use]
231    pub fn parse_error(what: &str, details: &str) -> CliError {
232        CliError::new("parse_error", format!("Failed to parse {what}"))
233            .detail(details)
234            .exit_code(ExitCode::USER_ERROR)
235    }
236
237    /// Operation cancelled error.
238    #[must_use]
239    pub fn cancelled() -> CliError {
240        CliError::new("cancelled", "Operation cancelled")
241            .detail("The operation was cancelled by user or signal")
242            .exit_code(ExitCode::CANCELLED)
243    }
244
245    /// Timeout error.
246    #[must_use]
247    pub fn timeout(operation: &str, duration_ms: u64) -> CliError {
248        CliError::new("timeout", format!("Operation timed out: {operation}"))
249            .detail(format!("Exceeded timeout after {duration_ms}ms"))
250            .context("duration_ms", duration_ms)
251            .exit_code(ExitCode::RUNTIME_ERROR)
252    }
253
254    /// Internal error (bug).
255    #[must_use]
256    pub fn internal(details: &str) -> CliError {
257        CliError::new("internal_error", "Internal error")
258            .detail(details)
259            .suggestion(
260                "Please report this bug at https://github.com/Dicklesworthstone/asupersync/issues",
261            )
262            .exit_code(ExitCode::INTERNAL_ERROR)
263    }
264
265    /// Test failure error.
266    #[must_use]
267    pub fn test_failure(test_name: &str, reason: &str) -> CliError {
268        CliError::new("test_failure", format!("Test failed: {test_name}"))
269            .detail(reason)
270            .context("test_name", test_name)
271            .exit_code(ExitCode::TEST_FAILURE)
272    }
273
274    /// Oracle violation error.
275    #[must_use]
276    pub fn oracle_violation(oracle: &str, details: &str) -> CliError {
277        CliError::new("oracle_violation", format!("Oracle violation: {oracle}"))
278            .detail(details)
279            .context("oracle", oracle)
280            .exit_code(ExitCode::ORACLE_VIOLATION)
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::{errors, CliError, ExitCode};
287
288    fn init_test(name: &str) {
289        crate::test_utils::init_test_logging();
290        crate::test_phase!(name);
291    }
292
293    #[test]
294    fn error_serializes_to_json() {
295        init_test("error_serializes_to_json");
296        let error = CliError::new("test_error", "Test Error")
297            .detail("Something went wrong")
298            .suggestion("Try again")
299            .context("file", "test.rs")
300            .exit_code(1);
301
302        let json = serde_json::to_string(&error).unwrap();
303        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
304
305        crate::assert_with_log!(
306            parsed["type"] == "test_error",
307            "type",
308            "test_error",
309            parsed["type"].clone()
310        );
311        crate::assert_with_log!(
312            parsed["title"] == "Test Error",
313            "title",
314            "Test Error",
315            parsed["title"].clone()
316        );
317        crate::assert_with_log!(
318            parsed["detail"] == "Something went wrong",
319            "detail",
320            "Something went wrong",
321            parsed["detail"].clone()
322        );
323        crate::assert_with_log!(
324            parsed["suggestion"] == "Try again",
325            "suggestion",
326            "Try again",
327            parsed["suggestion"].clone()
328        );
329        crate::assert_with_log!(
330            parsed["context"]["file"] == "test.rs",
331            "context file",
332            "test.rs",
333            parsed["context"]["file"].clone()
334        );
335        crate::assert_with_log!(
336            parsed["exit_code"] == 1,
337            "exit_code",
338            1,
339            parsed["exit_code"].clone()
340        );
341        crate::test_complete!("error_serializes_to_json");
342    }
343
344    #[test]
345    fn error_human_format_includes_all_parts() {
346        init_test("error_human_format_includes_all_parts");
347        let error = CliError::new("test_error", "Test Error")
348            .detail("Details here")
349            .suggestion("Try this");
350
351        let human = error.human_format(false);
352
353        let has_title = human.contains("Error: Test Error");
354        crate::assert_with_log!(has_title, "title", true, has_title);
355        let has_details = human.contains("Details here");
356        crate::assert_with_log!(has_details, "details", true, has_details);
357        let has_suggestion = human.contains("Suggestion: Try this");
358        crate::assert_with_log!(has_suggestion, "suggestion", true, has_suggestion);
359        crate::test_complete!("error_human_format_includes_all_parts");
360    }
361
362    #[test]
363    fn error_human_format_no_ansi_when_disabled() {
364        init_test("error_human_format_no_ansi_when_disabled");
365        let error = CliError::new("test", "Test");
366        let human = error.human_format(false);
367
368        let has_ansi = human.contains("\x1b[");
369        crate::assert_with_log!(!has_ansi, "no ansi", false, has_ansi);
370        crate::test_complete!("error_human_format_no_ansi_when_disabled");
371    }
372
373    #[test]
374    fn error_human_format_has_ansi_when_enabled() {
375        init_test("error_human_format_has_ansi_when_enabled");
376        let error = CliError::new("test", "Test");
377        let human = error.human_format(true);
378
379        let has_ansi = human.contains("\x1b[");
380        crate::assert_with_log!(has_ansi, "has ansi", true, has_ansi);
381        crate::test_complete!("error_human_format_has_ansi_when_enabled");
382    }
383
384    #[test]
385    fn error_implements_display() {
386        init_test("error_implements_display");
387        let error = CliError::new("test_type", "Test Title");
388        let display = format!("{error}");
389
390        let has_type = display.contains("test_type");
391        crate::assert_with_log!(has_type, "type", true, has_type);
392        let has_title = display.contains("Test Title");
393        crate::assert_with_log!(has_title, "title", true, has_title);
394        crate::test_complete!("error_implements_display");
395    }
396
397    #[test]
398    fn standard_errors_have_correct_exit_codes() {
399        init_test("standard_errors_have_correct_exit_codes");
400        let invalid = errors::invalid_argument("foo", "bad").exit_code;
401        crate::assert_with_log!(
402            invalid == ExitCode::USER_ERROR,
403            "invalid_argument",
404            ExitCode::USER_ERROR,
405            invalid
406        );
407        let not_found = errors::file_not_found("/path").exit_code;
408        crate::assert_with_log!(
409            not_found == ExitCode::USER_ERROR,
410            "file_not_found",
411            ExitCode::USER_ERROR,
412            not_found
413        );
414        let permission = errors::permission_denied("/path").exit_code;
415        crate::assert_with_log!(
416            permission == ExitCode::USER_ERROR,
417            "permission_denied",
418            ExitCode::USER_ERROR,
419            permission
420        );
421        let cancelled = errors::cancelled().exit_code;
422        crate::assert_with_log!(
423            cancelled == ExitCode::CANCELLED,
424            "cancelled",
425            ExitCode::CANCELLED,
426            cancelled
427        );
428        let internal = errors::internal("bug").exit_code;
429        crate::assert_with_log!(
430            internal == ExitCode::INTERNAL_ERROR,
431            "internal",
432            ExitCode::INTERNAL_ERROR,
433            internal
434        );
435        let test_failure = errors::test_failure("test", "reason").exit_code;
436        crate::assert_with_log!(
437            test_failure == ExitCode::TEST_FAILURE,
438            "test_failure",
439            ExitCode::TEST_FAILURE,
440            test_failure
441        );
442        let oracle = errors::oracle_violation("oracle", "details").exit_code;
443        crate::assert_with_log!(
444            oracle == ExitCode::ORACLE_VIOLATION,
445            "oracle_violation",
446            ExitCode::ORACLE_VIOLATION,
447            oracle
448        );
449        crate::test_complete!("standard_errors_have_correct_exit_codes");
450    }
451
452    #[test]
453    fn error_context_accepts_various_types() {
454        init_test("error_context_accepts_various_types");
455        let error = CliError::new("test", "Test")
456            .context("string", "value")
457            .context("number", 42)
458            .context("bool", true)
459            .context("array", vec![1, 2, 3]);
460
461        let len = error.context.len();
462        crate::assert_with_log!(len == 4, "context len", 4, len);
463        crate::assert_with_log!(
464            error.context["string"] == "value",
465            "string",
466            "value",
467            error.context["string"].clone()
468        );
469        crate::assert_with_log!(
470            error.context["number"] == 42,
471            "number",
472            42,
473            error.context["number"].clone()
474        );
475        crate::assert_with_log!(
476            error.context["bool"] == true,
477            "bool",
478            true,
479            error.context["bool"].clone()
480        );
481        crate::test_complete!("error_context_accepts_various_types");
482    }
483
484    #[test]
485    fn error_deserializes_from_json() {
486        init_test("error_deserializes_from_json");
487        let json = r#"{"type":"test","title":"Test","exit_code":1}"#;
488        let error: CliError = serde_json::from_str(json).unwrap();
489
490        crate::assert_with_log!(error.error_type == "test", "type", "test", error.error_type);
491        crate::assert_with_log!(error.title == "Test", "title", "Test", error.title);
492        crate::assert_with_log!(error.exit_code == 1, "exit_code", 1, error.exit_code);
493        crate::test_complete!("error_deserializes_from_json");
494    }
495}