1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[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#[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 #[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 #[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 #[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 #[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 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}