Skip to main content

ccboard_core/
error.rs

1//! Error types for ccboard-core
2//!
3//! Provides a comprehensive error hierarchy with thiserror for graceful degradation.
4
5use std::path::PathBuf;
6use thiserror::Error;
7
8/// Core error type for ccboard operations
9#[derive(Error, Debug)]
10pub enum CoreError {
11    // ===================
12    // IO Errors
13    // ===================
14    #[error("Failed to read file: {path}")]
15    FileRead {
16        path: PathBuf,
17        #[source]
18        source: std::io::Error,
19    },
20
21    #[error("Failed to write file: {path}")]
22    FileWrite {
23        path: PathBuf,
24        #[source]
25        source: std::io::Error,
26    },
27
28    #[error("File not found: {path}")]
29    FileNotFound { path: PathBuf },
30
31    #[error("Directory not found: {path}")]
32    DirectoryNotFound { path: PathBuf },
33
34    #[error("Invalid path: {path} - {reason}")]
35    InvalidPath { path: PathBuf, reason: String },
36
37    // ===================
38    // Parse Errors
39    // ===================
40    #[error("Failed to parse JSON in {path}: {message}")]
41    JsonParse {
42        path: PathBuf,
43        message: String,
44        #[source]
45        source: serde_json::Error,
46    },
47
48    #[error("Failed to parse YAML in {path}: {message}")]
49    YamlParse {
50        path: PathBuf,
51        message: String,
52        #[source]
53        source: serde_yaml::Error,
54    },
55
56    #[error("Malformed JSONL line {line_number} in {path}: {message}")]
57    JsonlParse {
58        path: PathBuf,
59        line_number: usize,
60        message: String,
61    },
62
63    #[error("Invalid frontmatter in {path}: {message}")]
64    FrontmatterParse { path: PathBuf, message: String },
65
66    // ===================
67    // Watch Errors
68    // ===================
69    #[error("File watcher error: {message}")]
70    WatchError {
71        message: String,
72        #[source]
73        source: Option<notify::Error>,
74    },
75
76    // ===================
77    // Store Errors
78    // ===================
79    #[error("Data store not initialized")]
80    StoreNotInitialized,
81
82    #[error("Session not found: {session_id}")]
83    SessionNotFound { session_id: String },
84
85    #[error("Lock acquisition timeout")]
86    LockTimeout,
87
88    // ===================
89    // Config Errors
90    // ===================
91    #[error("Invalid configuration: {message}")]
92    InvalidConfig { message: String },
93
94    #[error("Claude home directory not found")]
95    ClaudeHomeNotFound,
96
97    // ===================
98    // Circuit Breaker
99    // ===================
100    #[error("Operation timed out after {timeout_secs}s: {operation}")]
101    Timeout {
102        operation: String,
103        timeout_secs: u64,
104    },
105
106    #[error("Circuit breaker open for {operation}: {failures} consecutive failures")]
107    CircuitBreakerOpen { operation: String, failures: u32 },
108}
109
110/// Severity level for errors during load
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum ErrorSeverity {
113    /// Non-critical, can continue with degraded functionality
114    Warning,
115    /// Significant but not fatal
116    Error,
117    /// Cannot continue
118    Fatal,
119}
120
121/// Individual error entry in load report
122#[derive(Debug, Clone)]
123pub struct LoadError {
124    pub source: String,
125    pub message: String,
126    pub severity: ErrorSeverity,
127    /// Actionable suggestion for user (optional)
128    pub suggestion: Option<String>,
129}
130
131impl LoadError {
132    pub fn warning(source: impl Into<String>, message: impl Into<String>) -> Self {
133        Self {
134            source: source.into(),
135            message: message.into(),
136            severity: ErrorSeverity::Warning,
137            suggestion: None,
138        }
139    }
140
141    pub fn error(source: impl Into<String>, message: impl Into<String>) -> Self {
142        Self {
143            source: source.into(),
144            message: message.into(),
145            severity: ErrorSeverity::Error,
146            suggestion: None,
147        }
148    }
149
150    pub fn fatal(source: impl Into<String>, message: impl Into<String>) -> Self {
151        Self {
152            source: source.into(),
153            message: message.into(),
154            severity: ErrorSeverity::Fatal,
155            suggestion: None,
156        }
157    }
158
159    /// Add an actionable suggestion to this error
160    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
161        self.suggestion = Some(suggestion.into());
162        self
163    }
164
165    /// Create user-friendly error from CoreError with context-aware suggestions
166    pub fn from_core_error(source: impl Into<String>, error: &CoreError) -> Self {
167        let source = source.into();
168        let (message, suggestion) = match error {
169            CoreError::FileNotFound { path } => (
170                format!("File not found: {}", path.display()),
171                Some(format!("Check if file exists: ls {}", path.display())),
172            ),
173            CoreError::FileRead { path, .. } => (
174                format!("Cannot read file: {}", path.display()),
175                Some(format!("Check permissions: chmod +r {}", path.display())),
176            ),
177            CoreError::DirectoryNotFound { path } => (
178                format!("Directory not found: {}", path.display()),
179                Some(format!("Create directory: mkdir -p {}", path.display())),
180            ),
181            CoreError::JsonParse { path, message, .. } => (
182                format!("Invalid JSON in {}: {}", path.display(), message),
183                Some("Validate JSON syntax with: jq . <file>".to_string()),
184            ),
185            CoreError::JsonlParse {
186                path,
187                line_number,
188                message,
189            } => (
190                format!(
191                    "Malformed JSONL line {} in {}: {}",
192                    line_number,
193                    path.display(),
194                    message
195                ),
196                Some(format!(
197                    "Inspect line: sed -n '{}p' {}",
198                    line_number,
199                    path.display()
200                )),
201            ),
202            CoreError::ClaudeHomeNotFound => (
203                "Claude home directory not found".to_string(),
204                Some("Run 'claude' CLI at least once to initialize ~/.claude".to_string()),
205            ),
206            _ => (error.to_string(), None),
207        };
208
209        Self {
210            source,
211            message,
212            severity: ErrorSeverity::Error,
213            suggestion,
214        }
215    }
216}
217
218/// Report of errors encountered during data loading
219///
220/// Enables graceful degradation by tracking partial failures
221/// instead of failing completely on any error.
222#[derive(Debug, Default)]
223pub struct LoadReport {
224    pub errors: Vec<LoadError>,
225    pub stats_loaded: bool,
226    pub settings_loaded: bool,
227    pub sessions_scanned: usize,
228    pub sessions_failed: usize,
229}
230
231impl LoadReport {
232    pub fn new() -> Self {
233        Self::default()
234    }
235
236    pub fn add_error(&mut self, error: LoadError) {
237        self.errors.push(error);
238    }
239
240    pub fn add_warning(&mut self, source: impl Into<String>, message: impl Into<String>) {
241        self.errors.push(LoadError::warning(source, message));
242    }
243
244    pub fn add_fatal(&mut self, source: impl Into<String>, message: impl Into<String>) {
245        self.errors.push(LoadError::fatal(source, message));
246    }
247
248    /// Returns true if there are any fatal errors
249    pub fn has_fatal_errors(&self) -> bool {
250        self.errors
251            .iter()
252            .any(|e| e.severity == ErrorSeverity::Fatal)
253    }
254
255    /// Returns true if there are any errors (including warnings)
256    pub fn has_errors(&self) -> bool {
257        !self.errors.is_empty()
258    }
259
260    /// Returns only warnings
261    pub fn warnings(&self) -> impl Iterator<Item = &LoadError> {
262        self.errors
263            .iter()
264            .filter(|e| e.severity == ErrorSeverity::Warning)
265    }
266
267    /// Returns count by severity
268    pub fn error_count(&self) -> (usize, usize, usize) {
269        let warnings = self
270            .errors
271            .iter()
272            .filter(|e| e.severity == ErrorSeverity::Warning)
273            .count();
274        let errors = self
275            .errors
276            .iter()
277            .filter(|e| e.severity == ErrorSeverity::Error)
278            .count();
279        let fatal = self
280            .errors
281            .iter()
282            .filter(|e| e.severity == ErrorSeverity::Fatal)
283            .count();
284        (warnings, errors, fatal)
285    }
286
287    /// Merge another report into this one
288    pub fn merge(&mut self, other: LoadReport) {
289        self.errors.extend(other.errors);
290        self.stats_loaded = self.stats_loaded || other.stats_loaded;
291        self.settings_loaded = self.settings_loaded || other.settings_loaded;
292        self.sessions_scanned += other.sessions_scanned;
293        self.sessions_failed += other.sessions_failed;
294    }
295}
296
297/// Degraded state indicator for the data store
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub enum DegradedState {
300    /// Everything loaded successfully
301    Healthy,
302    /// Some data missing but functional
303    PartialData {
304        missing: Vec<String>,
305        reason: String,
306    },
307    /// Read-only mode due to errors
308    ReadOnly { reason: String },
309}
310
311impl DegradedState {
312    pub fn is_healthy(&self) -> bool {
313        matches!(self, DegradedState::Healthy)
314    }
315
316    pub fn is_degraded(&self) -> bool {
317        !self.is_healthy()
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn test_load_report_severity_counting() {
327        let mut report = LoadReport::new();
328        report.add_warning("stats", "File not found");
329        report.add_error(LoadError::error("settings", "Parse error"));
330        report.add_fatal("sessions", "Directory missing");
331
332        let (warnings, errors, fatal) = report.error_count();
333        assert_eq!(warnings, 1);
334        assert_eq!(errors, 1);
335        assert_eq!(fatal, 1);
336        assert!(report.has_fatal_errors());
337    }
338
339    #[test]
340    fn test_load_report_merge() {
341        let mut report1 = LoadReport::new();
342        report1.stats_loaded = true;
343        report1.sessions_scanned = 10;
344
345        let mut report2 = LoadReport::new();
346        report2.settings_loaded = true;
347        report2.sessions_scanned = 20;
348        report2.add_warning("test", "warning");
349
350        report1.merge(report2);
351
352        assert!(report1.stats_loaded);
353        assert!(report1.settings_loaded);
354        assert_eq!(report1.sessions_scanned, 30);
355        assert_eq!(report1.errors.len(), 1);
356    }
357}