1use super::exit::ExitCode;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CliError {
15 #[serde(rename = "type")]
17 pub error_type: String,
18
19 pub title: String,
21
22 #[serde(default, skip_serializing_if = "String::is_empty")]
24 pub detail: String,
25
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub suggestion: Option<String>,
29
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub docs_url: Option<String>,
33
34 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
36 pub context: HashMap<String, serde_json::Value>,
37
38 pub exit_code: i32,
40}
41
42impl CliError {
43 #[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 #[must_use]
59 pub fn detail(mut self, detail: impl Into<String>) -> Self {
60 self.detail = detail.into();
61 self
62 }
63
64 #[must_use]
66 pub fn suggestion(mut self, suggestion: impl Into<String>) -> Self {
67 self.suggestion = Some(suggestion.into());
68 self
69 }
70
71 #[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 #[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 #[must_use]
89 pub const fn exit_code(mut self, code: i32) -> Self {
90 self.exit_code = code;
91 self
92 }
93
94 #[must_use]
98 pub fn human_format(&self, color: bool) -> String {
99 let mut out = String::new();
100
101 if color {
103 out.push_str("\x1b[1;31m"); }
105 out.push_str("Error: ");
106 out.push_str(&self.title);
107 if color {
108 out.push_str("\x1b[0m"); }
110 out.push('\n');
111
112 if !self.detail.is_empty() {
114 out.push_str(&self.detail);
115 out.push('\n');
116 }
117
118 if let Some(ref suggestion) = self.suggestion {
120 out.push('\n');
121 if color {
122 out.push_str("\x1b[33m"); }
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 if let Some(ref docs) = self.docs_url {
134 if color {
135 out.push_str("\x1b[4;34m"); }
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 if !self.context.is_empty() {
147 out.push('\n');
148 if color {
149 out.push_str("\x1b[2m"); }
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 #[must_use]
166 pub fn json_format(&self) -> String {
167 serde_json::to_string(self).unwrap_or_else(|_| self.title.clone())
168 }
169
170 #[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
185pub mod errors {
187 use super::{CliError, ExitCode};
188
189 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}