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]
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 #[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 #[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 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}