1use std::path::PathBuf;
6use thiserror::Error;
7
8#[derive(Error, Debug)]
10pub enum CoreError {
11 #[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 #[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 #[error("File watcher error: {message}")]
70 WatchError {
71 message: String,
72 #[source]
73 source: Option<notify::Error>,
74 },
75
76 #[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 #[error("Invalid configuration: {message}")]
92 InvalidConfig { message: String },
93
94 #[error("Claude home directory not found")]
95 ClaudeHomeNotFound,
96
97 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum ErrorSeverity {
113 Warning,
115 Error,
117 Fatal,
119}
120
121#[derive(Debug, Clone)]
123pub struct LoadError {
124 pub source: String,
125 pub message: String,
126 pub severity: ErrorSeverity,
127 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 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
161 self.suggestion = Some(suggestion.into());
162 self
163 }
164
165 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#[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 pub fn has_fatal_errors(&self) -> bool {
250 self.errors
251 .iter()
252 .any(|e| e.severity == ErrorSeverity::Fatal)
253 }
254
255 pub fn has_errors(&self) -> bool {
257 !self.errors.is_empty()
258 }
259
260 pub fn warnings(&self) -> impl Iterator<Item = &LoadError> {
262 self.errors
263 .iter()
264 .filter(|e| e.severity == ErrorSeverity::Warning)
265 }
266
267 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 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#[derive(Debug, Clone, PartialEq, Eq)]
299pub enum DegradedState {
300 Healthy,
302 PartialData {
304 missing: Vec<String>,
305 reason: String,
306 },
307 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}