Skip to main content

ripsed_core/
error.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// All error codes in ripsed's error taxonomy.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum ErrorCode {
8    NoMatches,
9    InvalidRegex,
10    InvalidRequest,
11    FileNotFound,
12    PermissionDenied,
13    BinaryFileSkipped,
14    AtomicRollback,
15    WriteFailed,
16    InternalError,
17}
18
19/// A structured error with code, message, hint, and context.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct RipsedError {
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub operation_index: Option<usize>,
24    pub code: ErrorCode,
25    pub message: String,
26    pub hint: String,
27    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
28    pub context: HashMap<String, serde_json::Value>,
29}
30
31impl std::fmt::Display for RipsedError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        write!(f, "{}", self.message)
34    }
35}
36
37impl std::error::Error for RipsedError {}
38
39impl RipsedError {
40    pub fn no_matches(
41        operation_index: usize,
42        pattern: &str,
43        files_searched: usize,
44        suggestions: Vec<String>,
45    ) -> Self {
46        let mut context = HashMap::new();
47        context.insert(
48            "pattern".to_string(),
49            serde_json::Value::String(pattern.to_string()),
50        );
51        context.insert(
52            "files_searched".to_string(),
53            serde_json::Value::Number(files_searched.into()),
54        );
55        if !suggestions.is_empty() {
56            context.insert(
57                "suggestions".to_string(),
58                serde_json::Value::Array(
59                    suggestions
60                        .iter()
61                        .map(|s| serde_json::Value::String(s.clone()))
62                        .collect(),
63                ),
64            );
65        }
66
67        let hint = if suggestions.is_empty() {
68            "Check for typos in the pattern. If using regex, ensure --regex is set.".to_string()
69        } else {
70            format!(
71                "Check for typos in the pattern. Did you mean '{}'? If using regex, ensure --regex is set.",
72                suggestions[0]
73            )
74        };
75
76        Self {
77            operation_index: Some(operation_index),
78            code: ErrorCode::NoMatches,
79            message: format!(
80                "Pattern '{}' matched 0 lines across {} files.",
81                pattern, files_searched
82            ),
83            hint,
84            context,
85        }
86    }
87
88    pub fn invalid_regex(operation_index: usize, pattern: &str, error: &str) -> Self {
89        let mut context = HashMap::new();
90        context.insert(
91            "pattern".to_string(),
92            serde_json::Value::String(pattern.to_string()),
93        );
94
95        Self {
96            operation_index: Some(operation_index),
97            code: ErrorCode::InvalidRegex,
98            message: format!("Regex failed to compile: {error}."),
99            hint: format!("Check the regex syntax in pattern '{pattern}'. {error}"),
100            context,
101        }
102    }
103
104    pub fn invalid_request(message: impl Into<String>, hint: impl Into<String>) -> Self {
105        Self {
106            operation_index: None,
107            code: ErrorCode::InvalidRequest,
108            message: message.into(),
109            hint: hint.into(),
110            context: HashMap::new(),
111        }
112    }
113
114    pub fn file_not_found(path: &str, suggestions: Vec<String>) -> Self {
115        let mut context = HashMap::new();
116        context.insert(
117            "path".to_string(),
118            serde_json::Value::String(path.to_string()),
119        );
120
121        let hint = if suggestions.is_empty() {
122            format!("The path '{path}' does not exist. Check for typos.")
123        } else {
124            format!(
125                "The path '{path}' does not exist. Did you mean '{}'?",
126                suggestions[0]
127            )
128        };
129
130        Self {
131            operation_index: None,
132            code: ErrorCode::FileNotFound,
133            message: format!("Target path '{path}' does not exist."),
134            hint,
135            context,
136        }
137    }
138
139    pub fn permission_denied(path: &str) -> Self {
140        let mut context = HashMap::new();
141        context.insert(
142            "path".to_string(),
143            serde_json::Value::String(path.to_string()),
144        );
145
146        Self {
147            operation_index: None,
148            code: ErrorCode::PermissionDenied,
149            message: format!("Cannot read or write '{path}'."),
150            hint: format!(
151                "Check file permissions for '{path}'. Try chmod or run with appropriate permissions."
152            ),
153            context,
154        }
155    }
156
157    pub fn binary_file_skipped(path: &str) -> Self {
158        let mut context = HashMap::new();
159        context.insert(
160            "path".to_string(),
161            serde_json::Value::String(path.to_string()),
162        );
163
164        Self {
165            operation_index: None,
166            code: ErrorCode::BinaryFileSkipped,
167            message: format!("Binary file '{path}' was skipped."),
168            hint: "Binary files are skipped by default. Use --binary to include them.".to_string(),
169            context,
170        }
171    }
172
173    pub fn atomic_rollback(operation_index: usize, file: &str, reason: &str) -> Self {
174        let mut context = HashMap::new();
175        context.insert(
176            "file".to_string(),
177            serde_json::Value::String(file.to_string()),
178        );
179        context.insert(
180            "reason".to_string(),
181            serde_json::Value::String(reason.to_string()),
182        );
183
184        Self {
185            operation_index: Some(operation_index),
186            code: ErrorCode::AtomicRollback,
187            message: format!(
188                "Batch operation failed at '{file}': {reason}. All changes reverted."
189            ),
190            hint: "Nothing was written. Consider splitting into smaller batches or fixing the failing operation.".to_string(),
191            context,
192        }
193    }
194
195    pub fn write_failed(path: &str, os_error: &str) -> Self {
196        let mut context = HashMap::new();
197        context.insert(
198            "path".to_string(),
199            serde_json::Value::String(path.to_string()),
200        );
201        context.insert(
202            "os_error".to_string(),
203            serde_json::Value::String(os_error.to_string()),
204        );
205
206        Self {
207            operation_index: None,
208            code: ErrorCode::WriteFailed,
209            message: format!("Could not write to '{path}': {os_error}."),
210            hint: "Check disk space and path validity.".to_string(),
211            context,
212        }
213    }
214
215    pub fn internal_error(message: impl Into<String>) -> Self {
216        Self {
217            operation_index: None,
218            code: ErrorCode::InternalError,
219            message: message.into(),
220            hint: "This is a bug in ripsed. Please report it.".to_string(),
221            context: HashMap::new(),
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    // ── ErrorCode serde ──
231
232    #[test]
233    fn error_code_serializes_to_snake_case() {
234        let json = serde_json::to_string(&ErrorCode::NoMatches).unwrap();
235        assert_eq!(json, r#""no_matches""#);
236
237        let json = serde_json::to_string(&ErrorCode::InvalidRegex).unwrap();
238        assert_eq!(json, r#""invalid_regex""#);
239
240        let json = serde_json::to_string(&ErrorCode::BinaryFileSkipped).unwrap();
241        assert_eq!(json, r#""binary_file_skipped""#);
242    }
243
244    #[test]
245    fn error_code_roundtrips_all_variants() {
246        let codes = [
247            ErrorCode::NoMatches,
248            ErrorCode::InvalidRegex,
249            ErrorCode::InvalidRequest,
250            ErrorCode::FileNotFound,
251            ErrorCode::PermissionDenied,
252            ErrorCode::BinaryFileSkipped,
253            ErrorCode::AtomicRollback,
254            ErrorCode::WriteFailed,
255            ErrorCode::InternalError,
256        ];
257        for code in &codes {
258            let json = serde_json::to_string(code).unwrap();
259            let deserialized: ErrorCode = serde_json::from_str(&json).unwrap();
260            assert_eq!(*code, deserialized);
261        }
262    }
263
264    // ── Factory methods ──
265
266    #[test]
267    fn no_matches_without_suggestions() {
268        let err = RipsedError::no_matches(0, "foobar", 10, vec![]);
269        assert_eq!(err.code, ErrorCode::NoMatches);
270        assert_eq!(err.operation_index, Some(0));
271        assert!(err.message.contains("foobar"));
272        assert!(err.message.contains("10 files"));
273        assert!(err.hint.contains("typos"));
274        assert!(!err.context.contains_key("suggestions"));
275    }
276
277    #[test]
278    fn no_matches_with_suggestions() {
279        let err = RipsedError::no_matches(2, "fobar", 5, vec!["foobar".into()]);
280        assert!(err.hint.contains("foobar"));
281        let suggestions = err.context.get("suggestions").unwrap().as_array().unwrap();
282        assert_eq!(suggestions.len(), 1);
283        assert_eq!(suggestions[0], "foobar");
284    }
285
286    #[test]
287    fn invalid_regex_has_pattern_context() {
288        let err = RipsedError::invalid_regex(1, "[bad", "unclosed bracket");
289        assert_eq!(err.code, ErrorCode::InvalidRegex);
290        assert_eq!(err.operation_index, Some(1));
291        assert!(err.message.contains("unclosed bracket"));
292        assert_eq!(err.context["pattern"], "[bad");
293    }
294
295    #[test]
296    fn invalid_request_has_no_operation_index() {
297        let err = RipsedError::invalid_request("bad request", "fix it");
298        assert_eq!(err.code, ErrorCode::InvalidRequest);
299        assert!(err.operation_index.is_none());
300        assert_eq!(err.message, "bad request");
301        assert_eq!(err.hint, "fix it");
302    }
303
304    #[test]
305    fn file_not_found_without_suggestions() {
306        let err = RipsedError::file_not_found("/missing/path", vec![]);
307        assert_eq!(err.code, ErrorCode::FileNotFound);
308        assert!(err.hint.contains("typos"));
309        assert_eq!(err.context["path"], "/missing/path");
310    }
311
312    #[test]
313    fn file_not_found_with_suggestions() {
314        let err = RipsedError::file_not_found("/src/mian.rs", vec!["/src/main.rs".into()]);
315        assert!(err.hint.contains("/src/main.rs"));
316    }
317
318    #[test]
319    fn permission_denied_includes_path() {
320        let err = RipsedError::permission_denied("/etc/shadow");
321        assert_eq!(err.code, ErrorCode::PermissionDenied);
322        assert!(err.message.contains("/etc/shadow"));
323        assert_eq!(err.context["path"], "/etc/shadow");
324    }
325
326    #[test]
327    fn binary_file_skipped_includes_path() {
328        let err = RipsedError::binary_file_skipped("image.png");
329        assert_eq!(err.code, ErrorCode::BinaryFileSkipped);
330        assert!(err.message.contains("image.png"));
331    }
332
333    #[test]
334    fn atomic_rollback_includes_file_and_reason() {
335        let err = RipsedError::atomic_rollback(3, "src/lib.rs", "disk full");
336        assert_eq!(err.code, ErrorCode::AtomicRollback);
337        assert_eq!(err.operation_index, Some(3));
338        assert!(err.message.contains("src/lib.rs"));
339        assert!(err.message.contains("disk full"));
340        assert_eq!(err.context["file"], "src/lib.rs");
341        assert_eq!(err.context["reason"], "disk full");
342    }
343
344    #[test]
345    fn write_failed_includes_os_error() {
346        let err = RipsedError::write_failed("/tmp/out", "permission denied");
347        assert_eq!(err.code, ErrorCode::WriteFailed);
348        assert!(err.message.contains("permission denied"));
349        assert_eq!(err.context["os_error"], "permission denied");
350    }
351
352    #[test]
353    fn internal_error_has_bug_hint() {
354        let err = RipsedError::internal_error("unexpected state");
355        assert_eq!(err.code, ErrorCode::InternalError);
356        assert!(err.hint.contains("bug"));
357    }
358
359    // ── Display impl ──
360
361    #[test]
362    fn display_shows_message() {
363        let err = RipsedError::invalid_request("test message", "hint");
364        assert_eq!(format!("{err}"), "test message");
365    }
366
367    // ── JSON serialization ──
368
369    #[test]
370    fn error_serializes_to_json() {
371        let err = RipsedError::no_matches(0, "pat", 1, vec![]);
372        let json = serde_json::to_value(&err).unwrap();
373        assert_eq!(json["code"], "no_matches");
374        assert_eq!(json["operation_index"], 0);
375        assert!(json["message"].is_string());
376        assert!(json["hint"].is_string());
377    }
378
379    #[test]
380    fn error_without_context_omits_context_field() {
381        let err = RipsedError::internal_error("oops");
382        let json = serde_json::to_value(&err).unwrap();
383        // context should be omitted when empty (skip_serializing_if)
384        assert!(json.get("context").is_none());
385    }
386
387    #[test]
388    fn error_without_operation_index_omits_field() {
389        let err = RipsedError::invalid_request("bad", "fix");
390        let json = serde_json::to_value(&err).unwrap();
391        assert!(json.get("operation_index").is_none());
392    }
393}