Skip to main content

tokmd_core/
error.rs

1//! Structured error types for binding-friendly API.
2//!
3//! These error types are designed to be easily converted to JSON
4//! for FFI boundaries while providing rich error information.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::fmt;
9
10/// Error codes for tokmd operations.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ErrorCode {
14    /// Path does not exist or is not accessible.
15    PathNotFound,
16    /// Invalid path format.
17    InvalidPath,
18    /// Scan operation failed.
19    ScanError,
20    /// Analysis operation failed.
21    AnalysisError,
22    /// Invalid JSON input.
23    InvalidJson,
24    /// Unknown operation mode.
25    UnknownMode,
26    /// Invalid settings/arguments.
27    InvalidSettings,
28    /// I/O error during operation.
29    IoError,
30    /// Internal error (unexpected state).
31    InternalError,
32    /// Feature not yet implemented.
33    NotImplemented,
34    /// Git is not available on PATH.
35    GitNotAvailable,
36    /// Not inside a git repository.
37    NotGitRepository,
38    /// Git operation failed.
39    GitOperationFailed,
40    /// Configuration file not found.
41    ConfigNotFound,
42    /// Configuration file invalid.
43    ConfigInvalid,
44}
45
46impl fmt::Display for ErrorCode {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            ErrorCode::PathNotFound => write!(f, "path_not_found"),
50            ErrorCode::InvalidPath => write!(f, "invalid_path"),
51            ErrorCode::ScanError => write!(f, "scan_error"),
52            ErrorCode::AnalysisError => write!(f, "analysis_error"),
53            ErrorCode::InvalidJson => write!(f, "invalid_json"),
54            ErrorCode::UnknownMode => write!(f, "unknown_mode"),
55            ErrorCode::InvalidSettings => write!(f, "invalid_settings"),
56            ErrorCode::IoError => write!(f, "io_error"),
57            ErrorCode::InternalError => write!(f, "internal_error"),
58            ErrorCode::NotImplemented => write!(f, "not_implemented"),
59            ErrorCode::GitNotAvailable => write!(f, "git_not_available"),
60            ErrorCode::NotGitRepository => write!(f, "not_git_repository"),
61            ErrorCode::GitOperationFailed => write!(f, "git_operation_failed"),
62            ErrorCode::ConfigNotFound => write!(f, "config_not_found"),
63            ErrorCode::ConfigInvalid => write!(f, "config_invalid"),
64        }
65    }
66}
67
68/// Structured error for FFI-friendly error reporting.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct TokmdError {
71    /// Error code for programmatic handling.
72    pub code: ErrorCode,
73    /// Human-readable error message.
74    pub message: String,
75    /// Optional additional details.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub details: Option<String>,
78    /// Optional helpful suggestions for resolving the error.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub suggestions: Option<Vec<String>>,
81}
82
83impl TokmdError {
84    /// Create a new error with given code and message.
85    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
86        Self {
87            code,
88            message: message.into(),
89            details: None,
90            suggestions: None,
91        }
92    }
93
94    /// Create an error with additional details.
95    pub fn with_details(
96        code: ErrorCode,
97        message: impl Into<String>,
98        details: impl Into<String>,
99    ) -> Self {
100        Self {
101            code,
102            message: message.into(),
103            details: Some(details.into()),
104            suggestions: None,
105        }
106    }
107
108    /// Create an error with suggestions.
109    pub fn with_suggestions(
110        code: ErrorCode,
111        message: impl Into<String>,
112        suggestions: Vec<String>,
113    ) -> Self {
114        Self {
115            code,
116            message: message.into(),
117            details: None,
118            suggestions: Some(suggestions),
119        }
120    }
121
122    /// Create an error with both details and suggestions.
123    pub fn with_details_and_suggestions(
124        code: ErrorCode,
125        message: impl Into<String>,
126        details: impl Into<String>,
127        suggestions: Vec<String>,
128    ) -> Self {
129        Self {
130            code,
131            message: message.into(),
132            details: Some(details.into()),
133            suggestions: Some(suggestions),
134        }
135    }
136
137    /// Create a git not available error.
138    pub fn git_not_available() -> Self {
139        Self::with_suggestions(
140            ErrorCode::GitNotAvailable,
141            "git is not available on PATH".to_string(),
142            vec![
143                "Install git from https://git-scm.com/downloads".to_string(),
144                "Ensure git is in your system PATH".to_string(),
145                "Verify installation by running: git --version".to_string(),
146            ],
147        )
148    }
149
150    /// Create a not git repository error.
151    pub fn not_git_repository(path: &str) -> Self {
152        Self::with_details_and_suggestions(
153            ErrorCode::NotGitRepository,
154            format!("Not inside a git repository: {}", path),
155            "The current directory is not a git repository".to_string(),
156            vec![
157                "Initialize a git repository: git init".to_string(),
158                "Navigate to a git repository directory".to_string(),
159                "Use --no-git flag to disable git features".to_string(),
160            ],
161        )
162    }
163
164    /// Create a git operation failed error.
165    pub fn git_operation_failed(operation: &str, reason: &str) -> Self {
166        Self::with_details(
167            ErrorCode::GitOperationFailed,
168            format!("Git operation failed: {}", operation),
169            format!("Reason: {}", reason),
170        )
171    }
172
173    /// Create a config not found error.
174    pub fn config_not_found(path: &str) -> Self {
175        Self::with_suggestions(
176            ErrorCode::ConfigNotFound,
177            format!("Configuration file not found: {}", path),
178            vec![
179                "Create a tokmd.toml configuration file".to_string(),
180                "Run 'tokmd init' to generate a template".to_string(),
181                "Use default settings by omitting --config flag".to_string(),
182            ],
183        )
184    }
185
186    /// Create a config invalid error.
187    pub fn config_invalid(path: &str, reason: &str) -> Self {
188        Self::with_details_and_suggestions(
189            ErrorCode::ConfigInvalid,
190            format!("Invalid configuration file: {}", path),
191            format!("Reason: {}", reason),
192            vec![
193                "Check the configuration file syntax".to_string(),
194                "Refer to documentation for valid options".to_string(),
195                "Run 'tokmd init' to generate a valid template".to_string(),
196            ],
197        )
198    }
199
200    /// Create a path not found error with suggestions.
201    pub fn path_not_found_with_suggestions(path: &str) -> Self {
202        Self::with_details_and_suggestions(
203            ErrorCode::PathNotFound,
204            format!("Path not found: {}", path),
205            "The specified path does not exist or is not accessible".to_string(),
206            vec![
207                "Check the path spelling".to_string(),
208                "Verify the path exists: ls -la".to_string(),
209                "Ensure you have read permissions".to_string(),
210            ],
211        )
212    }
213
214    /// Create a path not found error.
215    pub fn path_not_found(path: &str) -> Self {
216        Self::new(ErrorCode::PathNotFound, format!("Path not found: {}", path))
217    }
218
219    /// Create an invalid JSON error.
220    pub fn invalid_json(err: impl fmt::Display) -> Self {
221        Self::new(ErrorCode::InvalidJson, format!("Invalid JSON: {}", err))
222    }
223
224    /// Create an unknown mode error.
225    pub fn unknown_mode(mode: &str) -> Self {
226        Self::new(ErrorCode::UnknownMode, format!("Unknown mode: {}", mode))
227    }
228
229    /// Create a scan error from an anyhow error.
230    pub fn scan_error(err: impl fmt::Display) -> Self {
231        Self::new(ErrorCode::ScanError, format!("Scan failed: {}", err))
232    }
233
234    /// Create an analysis error from an anyhow error.
235    pub fn analysis_error(err: impl fmt::Display) -> Self {
236        Self::new(
237            ErrorCode::AnalysisError,
238            format!("Analysis failed: {}", err),
239        )
240    }
241
242    /// Create an I/O error.
243    pub fn io_error(err: impl fmt::Display) -> Self {
244        Self::new(ErrorCode::IoError, format!("I/O error: {}", err))
245    }
246
247    /// Create an internal error.
248    pub fn internal(err: impl fmt::Display) -> Self {
249        Self::new(ErrorCode::InternalError, format!("Internal error: {}", err))
250    }
251
252    /// Create a not implemented error.
253    pub fn not_implemented(feature: impl Into<String>) -> Self {
254        Self::new(ErrorCode::NotImplemented, feature)
255    }
256
257    /// Create an invalid settings error for a specific field.
258    pub fn invalid_field(field: &str, expected: &str) -> Self {
259        Self::new(
260            ErrorCode::InvalidSettings,
261            format!("Invalid value for '{}': expected {}", field, expected),
262        )
263    }
264
265    /// Convert to JSON string.
266    pub fn to_json(&self) -> String {
267        serde_json::to_string(self).unwrap_or_else(|_| {
268            format!(r#"{{"code":"{}","message":"{}"}}"#, self.code, self.message)
269        })
270    }
271}
272
273impl fmt::Display for TokmdError {
274    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275        if let Some(details) = &self.details {
276            write!(f, "[{}] {}: {}", self.code, self.message, details)
277        } else {
278            write!(f, "[{}] {}", self.code, self.message)
279        }
280    }
281}
282
283impl std::error::Error for TokmdError {}
284
285impl From<anyhow::Error> for TokmdError {
286    fn from(err: anyhow::Error) -> Self {
287        Self::internal(err)
288    }
289}
290
291impl From<serde_json::Error> for TokmdError {
292    fn from(err: serde_json::Error) -> Self {
293        Self::invalid_json(err)
294    }
295}
296
297impl From<std::io::Error> for TokmdError {
298    fn from(err: std::io::Error) -> Self {
299        Self::io_error(err)
300    }
301}
302
303/// Error details for response envelope.
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct ErrorDetails {
306    /// The error code.
307    pub code: String,
308    /// Human-readable message.
309    pub message: String,
310    /// Optional additional details.
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub details: Option<String>,
313}
314
315impl From<&TokmdError> for ErrorDetails {
316    fn from(err: &TokmdError) -> Self {
317        Self {
318            code: err.code.to_string(),
319            message: err.message.clone(),
320            details: err.details.clone(),
321        }
322    }
323}
324
325/// Stable JSON response envelope for FFI.
326///
327/// Success: `{"ok": true, "data": {...}}`
328/// Error: `{"ok": false, "error": {"code": "...", "message": "...", "details": ...}}`
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct ResponseEnvelope {
331    /// Whether the operation succeeded.
332    pub ok: bool,
333    /// The result data (present when ok=true).
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub data: Option<Value>,
336    /// The error details (present when ok=false).
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub error: Option<ErrorDetails>,
339}
340
341impl ResponseEnvelope {
342    /// Create a success response with given data.
343    pub fn success(data: Value) -> Self {
344        Self {
345            ok: true,
346            data: Some(data),
347            error: None,
348        }
349    }
350
351    /// Create an error response from a TokmdError.
352    pub fn error(err: &TokmdError) -> Self {
353        Self {
354            ok: false,
355            data: None,
356            error: Some(ErrorDetails::from(err)),
357        }
358    }
359
360    /// Convert to JSON string.
361    pub fn to_json(&self) -> String {
362        serde_json::to_string(self).unwrap_or_else(|_| {
363            if self.ok {
364                r#"{"ok":true,"data":null}"#.to_string()
365            } else {
366                let (code, message) = self
367                    .error
368                    .as_ref()
369                    .map(|e| (e.code.as_str(), e.message.as_str()))
370                    .unwrap_or(("internal_error", "serialization failed"));
371                format!(
372                    r#"{{"ok":false,"error":{{"code":"{}","message":"{}"}}}}"#,
373                    code, message
374                )
375            }
376        })
377    }
378}
379
380/// JSON error response wrapper for FFI.
381///
382/// DEPRECATED: Use ResponseEnvelope instead for new code.
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct ErrorResponse {
385    /// Always `true` for error responses.
386    pub error: bool,
387    /// The error code.
388    pub code: String,
389    /// Human-readable message.
390    pub message: String,
391    /// Optional details.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub details: Option<String>,
394}
395
396impl From<TokmdError> for ErrorResponse {
397    fn from(err: TokmdError) -> Self {
398        Self {
399            error: true,
400            code: err.code.to_string(),
401            message: err.message,
402            details: err.details,
403        }
404    }
405}
406
407impl ErrorResponse {
408    /// Convert to JSON string.
409    pub fn to_json(&self) -> String {
410        serde_json::to_string(self).unwrap_or_else(|_| {
411            format!(
412                r#"{{"error":true,"code":"{}","message":"{}"}}"#,
413                self.code, self.message
414            )
415        })
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn error_codes_serialize_to_snake_case() {
425        let err = TokmdError::path_not_found("/some/path");
426        let json = err.to_json();
427        assert!(json.contains("\"code\":\"path_not_found\""));
428    }
429
430    #[test]
431    fn error_response_has_error_true() {
432        let err = TokmdError::unknown_mode("foo");
433        let resp: ErrorResponse = err.into();
434        assert!(resp.error);
435        assert_eq!(resp.code, "unknown_mode");
436    }
437
438    #[test]
439    fn error_display_includes_code() {
440        let err = TokmdError::new(ErrorCode::ScanError, "test message");
441        let display = err.to_string();
442        assert!(display.contains("[scan_error]"));
443        assert!(display.contains("test message"));
444    }
445
446    #[test]
447    fn invalid_field_error() {
448        let err = TokmdError::invalid_field("children", "'collapse' or 'separate'");
449        assert_eq!(err.code, ErrorCode::InvalidSettings);
450        assert!(err.message.contains("children"));
451        assert!(err.message.contains("'collapse' or 'separate'"));
452    }
453
454    #[test]
455    fn response_envelope_success() {
456        let data = serde_json::json!({"rows": []});
457        let envelope = ResponseEnvelope::success(data.clone());
458        assert!(envelope.ok);
459        assert!(envelope.error.is_none());
460        assert_eq!(envelope.data, Some(data));
461    }
462
463    #[test]
464    fn error_with_suggestions() {
465        let err = TokmdError::git_not_available();
466        assert_eq!(err.code, ErrorCode::GitNotAvailable);
467        assert!(err.suggestions.is_some());
468        let suggestions = err.suggestions.expect("should have suggestions");
469        assert!(!suggestions.is_empty());
470    }
471
472    #[test]
473    fn error_with_details_and_suggestions() {
474        let err = TokmdError::not_git_repository("/some/path");
475        assert_eq!(err.code, ErrorCode::NotGitRepository);
476        assert!(err.details.is_some());
477        assert!(err.suggestions.is_some());
478    }
479}