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    // ── Factory methods ──
245
246    #[test]
247    fn no_matches_without_suggestions() {
248        let err = RipsedError::no_matches(0, "foobar", 10, vec![]);
249        assert_eq!(err.code, ErrorCode::NoMatches);
250        assert_eq!(err.operation_index, Some(0));
251        assert!(err.message.contains("foobar"));
252        assert!(err.message.contains("10 files"));
253        assert!(err.hint.contains("typos"));
254        assert!(!err.context.contains_key("suggestions"));
255    }
256
257    #[test]
258    fn no_matches_with_suggestions() {
259        let err = RipsedError::no_matches(2, "fobar", 5, vec!["foobar".into()]);
260        assert!(err.hint.contains("foobar"));
261        let suggestions = err.context.get("suggestions").unwrap().as_array().unwrap();
262        assert_eq!(suggestions.len(), 1);
263        assert_eq!(suggestions[0], "foobar");
264    }
265
266    #[test]
267    fn invalid_regex_has_pattern_context() {
268        let err = RipsedError::invalid_regex(1, "[bad", "unclosed bracket");
269        assert_eq!(err.code, ErrorCode::InvalidRegex);
270        assert_eq!(err.operation_index, Some(1));
271        assert!(err.message.contains("unclosed bracket"));
272        assert_eq!(err.context["pattern"], "[bad");
273    }
274
275    #[test]
276    fn invalid_request_has_no_operation_index() {
277        let err = RipsedError::invalid_request("bad request", "fix it");
278        assert_eq!(err.code, ErrorCode::InvalidRequest);
279        assert!(err.operation_index.is_none());
280        assert_eq!(err.message, "bad request");
281        assert_eq!(err.hint, "fix it");
282    }
283
284    #[test]
285    fn file_not_found_without_suggestions() {
286        let err = RipsedError::file_not_found("/missing/path", vec![]);
287        assert_eq!(err.code, ErrorCode::FileNotFound);
288        assert!(err.hint.contains("typos"));
289        assert_eq!(err.context["path"], "/missing/path");
290    }
291
292    #[test]
293    fn file_not_found_with_suggestions() {
294        let err = RipsedError::file_not_found("/src/mian.rs", vec!["/src/main.rs".into()]);
295        assert!(err.hint.contains("/src/main.rs"));
296    }
297
298    #[test]
299    fn permission_denied_includes_path() {
300        let err = RipsedError::permission_denied("/etc/shadow");
301        assert_eq!(err.code, ErrorCode::PermissionDenied);
302        assert!(err.message.contains("/etc/shadow"));
303        assert_eq!(err.context["path"], "/etc/shadow");
304    }
305
306    #[test]
307    fn binary_file_skipped_includes_path() {
308        let err = RipsedError::binary_file_skipped("image.png");
309        assert_eq!(err.code, ErrorCode::BinaryFileSkipped);
310        assert!(err.message.contains("image.png"));
311    }
312
313    #[test]
314    fn atomic_rollback_includes_file_and_reason() {
315        let err = RipsedError::atomic_rollback(3, "src/lib.rs", "disk full");
316        assert_eq!(err.code, ErrorCode::AtomicRollback);
317        assert_eq!(err.operation_index, Some(3));
318        assert!(err.message.contains("src/lib.rs"));
319        assert!(err.message.contains("disk full"));
320        assert_eq!(err.context["file"], "src/lib.rs");
321        assert_eq!(err.context["reason"], "disk full");
322    }
323
324    #[test]
325    fn write_failed_includes_os_error() {
326        let err = RipsedError::write_failed("/tmp/out", "permission denied");
327        assert_eq!(err.code, ErrorCode::WriteFailed);
328        assert!(err.message.contains("permission denied"));
329        assert_eq!(err.context["os_error"], "permission denied");
330    }
331
332    #[test]
333    fn internal_error_has_bug_hint() {
334        let err = RipsedError::internal_error("unexpected state");
335        assert_eq!(err.code, ErrorCode::InternalError);
336        assert!(err.hint.contains("bug"));
337    }
338
339    // ── Display impl ──
340
341    #[test]
342    fn display_shows_message() {
343        let err = RipsedError::invalid_request("test message", "hint");
344        assert_eq!(format!("{err}"), "test message");
345    }
346
347    // ── JSON serialization ──
348
349    #[test]
350    fn error_serializes_to_json() {
351        let err = RipsedError::no_matches(0, "pat", 1, vec![]);
352        let json = serde_json::to_value(&err).unwrap();
353        assert_eq!(json["code"], "no_matches");
354        assert_eq!(json["operation_index"], 0);
355        assert!(json["message"].is_string());
356        assert!(json["hint"].is_string());
357    }
358
359    #[test]
360    fn error_without_context_omits_context_field() {
361        let err = RipsedError::internal_error("oops");
362        let json = serde_json::to_value(&err).unwrap();
363        // context should be omitted when empty (skip_serializing_if)
364        assert!(json.get("context").is_none());
365    }
366
367    #[test]
368    fn error_without_operation_index_omits_field() {
369        let err = RipsedError::invalid_request("bad", "fix");
370        let json = serde_json::to_value(&err).unwrap();
371        assert!(json.get("operation_index").is_none());
372    }
373}