organizational_intelligence_plugin/
error.rs

1//! Error Handling for OIP
2//!
3//! PROD-002: Centralized error types with context and recovery hints
4//! Uses thiserror for ergonomic error definitions
5
6use thiserror::Error;
7
8/// Main error type for OIP operations
9#[derive(Error, Debug)]
10pub enum OipError {
11    // ===== Data Errors =====
12    #[error("No data available: {context}")]
13    NoData { context: String },
14
15    #[error("Invalid data format: {message}")]
16    InvalidData { message: String },
17
18    #[error("Data validation failed: {field} - {reason}")]
19    ValidationError { field: String, reason: String },
20
21    // ===== GitHub/Git Errors =====
22    #[error("GitHub API error: {message}")]
23    GitHubError { message: String },
24
25    #[error("Repository not found: {repo}")]
26    RepoNotFound { repo: String },
27
28    #[error("Git operation failed: {operation} - {reason}")]
29    GitError { operation: String, reason: String },
30
31    #[error("Authentication required: {message}")]
32    AuthRequired { message: String },
33
34    // ===== ML/Compute Errors =====
35    #[error("Model not trained: call train() before predict()")]
36    ModelNotTrained,
37
38    #[error("Insufficient data for {operation}: need {required}, got {actual}")]
39    InsufficientData {
40        operation: String,
41        required: usize,
42        actual: usize,
43    },
44
45    #[error("Computation failed: {operation} - {reason}")]
46    ComputeError { operation: String, reason: String },
47
48    #[error("GPU not available: {reason}")]
49    GpuUnavailable { reason: String },
50
51    // ===== Storage Errors =====
52    #[error("Storage error: {operation} - {reason}")]
53    StorageError { operation: String, reason: String },
54
55    #[error("File not found: {path}")]
56    FileNotFound { path: String },
57
58    #[error("IO error: {context}")]
59    IoError {
60        context: String,
61        #[source]
62        source: std::io::Error,
63    },
64
65    // ===== Configuration Errors =====
66    #[error("Configuration error: {message}")]
67    ConfigError { message: String },
68
69    #[error("Invalid argument: {arg} - {reason}")]
70    InvalidArgument { arg: String, reason: String },
71
72    // ===== Generic Errors =====
73    #[error("Operation failed: {message}")]
74    OperationFailed { message: String },
75
76    #[error(transparent)]
77    Other(#[from] anyhow::Error),
78}
79
80impl OipError {
81    // ===== Constructors =====
82
83    pub fn no_data(context: impl Into<String>) -> Self {
84        Self::NoData {
85            context: context.into(),
86        }
87    }
88
89    pub fn invalid_data(message: impl Into<String>) -> Self {
90        Self::InvalidData {
91            message: message.into(),
92        }
93    }
94
95    pub fn validation(field: impl Into<String>, reason: impl Into<String>) -> Self {
96        Self::ValidationError {
97            field: field.into(),
98            reason: reason.into(),
99        }
100    }
101
102    pub fn github(message: impl Into<String>) -> Self {
103        Self::GitHubError {
104            message: message.into(),
105        }
106    }
107
108    pub fn repo_not_found(repo: impl Into<String>) -> Self {
109        Self::RepoNotFound { repo: repo.into() }
110    }
111
112    pub fn git(operation: impl Into<String>, reason: impl Into<String>) -> Self {
113        Self::GitError {
114            operation: operation.into(),
115            reason: reason.into(),
116        }
117    }
118
119    pub fn auth_required(message: impl Into<String>) -> Self {
120        Self::AuthRequired {
121            message: message.into(),
122        }
123    }
124
125    pub fn insufficient_data(operation: impl Into<String>, required: usize, actual: usize) -> Self {
126        Self::InsufficientData {
127            operation: operation.into(),
128            required,
129            actual,
130        }
131    }
132
133    pub fn compute(operation: impl Into<String>, reason: impl Into<String>) -> Self {
134        Self::ComputeError {
135            operation: operation.into(),
136            reason: reason.into(),
137        }
138    }
139
140    pub fn gpu_unavailable(reason: impl Into<String>) -> Self {
141        Self::GpuUnavailable {
142            reason: reason.into(),
143        }
144    }
145
146    pub fn storage(operation: impl Into<String>, reason: impl Into<String>) -> Self {
147        Self::StorageError {
148            operation: operation.into(),
149            reason: reason.into(),
150        }
151    }
152
153    pub fn file_not_found(path: impl Into<String>) -> Self {
154        Self::FileNotFound { path: path.into() }
155    }
156
157    pub fn io(context: impl Into<String>, source: std::io::Error) -> Self {
158        Self::IoError {
159            context: context.into(),
160            source,
161        }
162    }
163
164    pub fn config(message: impl Into<String>) -> Self {
165        Self::ConfigError {
166            message: message.into(),
167        }
168    }
169
170    pub fn invalid_arg(arg: impl Into<String>, reason: impl Into<String>) -> Self {
171        Self::InvalidArgument {
172            arg: arg.into(),
173            reason: reason.into(),
174        }
175    }
176
177    pub fn failed(message: impl Into<String>) -> Self {
178        Self::OperationFailed {
179            message: message.into(),
180        }
181    }
182
183    // ===== Recovery Hints =====
184
185    /// Get a user-friendly recovery hint for this error
186    pub fn recovery_hint(&self) -> Option<&'static str> {
187        match self {
188            Self::NoData { .. } => Some("Try analyzing a repository first with 'oip-gpu analyze'"),
189            Self::RepoNotFound { .. } => {
190                Some("Check the repository name format (owner/repo) and ensure it exists")
191            }
192            Self::AuthRequired { .. } => Some("Set GITHUB_TOKEN environment variable"),
193            Self::ModelNotTrained => Some("Train the model first with predictor.train(features)"),
194            Self::InsufficientData { .. } => Some("Provide more training data or reduce k value"),
195            Self::GpuUnavailable { .. } => {
196                Some("Use --backend simd for CPU fallback, or install GPU drivers")
197            }
198            Self::FileNotFound { .. } => Some("Check the file path and ensure it exists"),
199            Self::ConfigError { .. } => Some("Check configuration file syntax (YAML/TOML)"),
200            Self::InvalidArgument { .. } => Some("Run with --help to see valid arguments"),
201            _ => None,
202        }
203    }
204
205    /// Check if this error is recoverable
206    pub fn is_recoverable(&self) -> bool {
207        matches!(
208            self,
209            Self::NoData { .. }
210                | Self::RepoNotFound { .. }
211                | Self::AuthRequired { .. }
212                | Self::ModelNotTrained
213                | Self::InsufficientData { .. }
214                | Self::GpuUnavailable { .. }
215                | Self::FileNotFound { .. }
216                | Self::ConfigError { .. }
217                | Self::InvalidArgument { .. }
218        )
219    }
220
221    /// Get error category for logging/metrics
222    pub fn category(&self) -> &'static str {
223        match self {
224            Self::NoData { .. } | Self::InvalidData { .. } | Self::ValidationError { .. } => "data",
225            Self::GitHubError { .. }
226            | Self::RepoNotFound { .. }
227            | Self::GitError { .. }
228            | Self::AuthRequired { .. } => "git",
229            Self::ModelNotTrained
230            | Self::InsufficientData { .. }
231            | Self::ComputeError { .. }
232            | Self::GpuUnavailable { .. } => "compute",
233            Self::StorageError { .. } | Self::FileNotFound { .. } | Self::IoError { .. } => {
234                "storage"
235            }
236            Self::ConfigError { .. } | Self::InvalidArgument { .. } => "config",
237            Self::OperationFailed { .. } | Self::Other(_) => "other",
238        }
239    }
240}
241
242/// Result type alias for OIP operations
243pub type OipResult<T> = Result<T, OipError>;
244
245/// Extension trait for adding context to errors
246pub trait ResultExt<T> {
247    /// Add context to an error
248    fn context(self, context: impl Into<String>) -> OipResult<T>;
249
250    /// Add context with a closure (lazy evaluation)
251    fn with_context<F, S>(self, f: F) -> OipResult<T>
252    where
253        F: FnOnce() -> S,
254        S: Into<String>;
255}
256
257impl<T, E: Into<OipError>> ResultExt<T> for Result<T, E> {
258    fn context(self, context: impl Into<String>) -> OipResult<T> {
259        self.map_err(|e| {
260            let inner = e.into();
261            OipError::OperationFailed {
262                message: format!("{}: {}", context.into(), inner),
263            }
264        })
265    }
266
267    fn with_context<F, S>(self, f: F) -> OipResult<T>
268    where
269        F: FnOnce() -> S,
270        S: Into<String>,
271    {
272        self.map_err(|e| {
273            let inner = e.into();
274            OipError::OperationFailed {
275                message: format!("{}: {}", f().into(), inner),
276            }
277        })
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_error_display() {
287        let err = OipError::no_data("empty feature store");
288        assert!(err.to_string().contains("No data available"));
289        assert!(err.to_string().contains("empty feature store"));
290    }
291
292    #[test]
293    fn test_error_recovery_hint() {
294        let err = OipError::ModelNotTrained;
295        assert!(err.recovery_hint().is_some());
296        assert!(err.recovery_hint().unwrap().contains("train"));
297    }
298
299    #[test]
300    fn test_error_is_recoverable() {
301        assert!(OipError::ModelNotTrained.is_recoverable());
302        assert!(OipError::repo_not_found("test/repo").is_recoverable());
303        assert!(!OipError::failed("unknown").is_recoverable());
304    }
305
306    #[test]
307    fn test_error_category() {
308        assert_eq!(OipError::ModelNotTrained.category(), "compute");
309        assert_eq!(OipError::repo_not_found("test").category(), "git");
310        assert_eq!(OipError::no_data("test").category(), "data");
311    }
312
313    #[test]
314    fn test_insufficient_data_error() {
315        let err = OipError::insufficient_data("k-means clustering", 10, 5);
316        assert!(err.to_string().contains("10"));
317        assert!(err.to_string().contains("5"));
318        assert!(err.is_recoverable());
319    }
320
321    #[test]
322    fn test_validation_error() {
323        let err = OipError::validation("category", "must be 0-9");
324        assert!(err.to_string().contains("category"));
325        assert!(err.to_string().contains("must be 0-9"));
326    }
327
328    #[test]
329    fn test_result_context() {
330        let result: Result<(), OipError> = Err(OipError::no_data("test"));
331        let with_context = result.context("during analysis");
332        assert!(with_context.is_err());
333        assert!(with_context.unwrap_err().to_string().contains("analysis"));
334    }
335
336    #[test]
337    fn test_result_with_context() {
338        let result: Result<(), OipError> = Err(OipError::no_data("test"));
339        let with_context = result.with_context(|| "lazy context");
340        assert!(with_context.is_err());
341        assert!(with_context.unwrap_err().to_string().contains("lazy"));
342    }
343
344    #[test]
345    fn test_invalid_data_constructor() {
346        let err = OipError::invalid_data("malformed JSON");
347        assert!(err.to_string().contains("Invalid data format"));
348        assert_eq!(err.category(), "data");
349    }
350
351    #[test]
352    fn test_github_error_constructor() {
353        let err = OipError::github("rate limit exceeded");
354        assert!(err.to_string().contains("GitHub API error"));
355        assert_eq!(err.category(), "git");
356    }
357
358    #[test]
359    fn test_git_error_constructor() {
360        let err = OipError::git("clone", "network timeout");
361        assert!(err.to_string().contains("Git operation failed"));
362        assert!(err.to_string().contains("clone"));
363        assert_eq!(err.category(), "git");
364    }
365
366    #[test]
367    fn test_auth_required_constructor() {
368        let err = OipError::auth_required("GitHub API requires token");
369        assert!(err.to_string().contains("Authentication required"));
370        assert!(err.recovery_hint().is_some());
371        assert!(err.recovery_hint().unwrap().contains("GITHUB_TOKEN"));
372        assert!(err.is_recoverable());
373    }
374
375    #[test]
376    fn test_compute_error_constructor() {
377        let err = OipError::compute("correlation", "division by zero");
378        assert!(err.to_string().contains("Computation failed"));
379        assert_eq!(err.category(), "compute");
380    }
381
382    #[test]
383    fn test_gpu_unavailable_constructor() {
384        let err = OipError::gpu_unavailable("no Vulkan driver");
385        assert!(err.to_string().contains("GPU not available"));
386        assert!(err.recovery_hint().unwrap().contains("simd"));
387        assert!(err.is_recoverable());
388    }
389
390    #[test]
391    fn test_storage_error_constructor() {
392        let err = OipError::storage("save", "disk full");
393        assert!(err.to_string().contains("Storage error"));
394        assert_eq!(err.category(), "storage");
395    }
396
397    #[test]
398    fn test_file_not_found_constructor() {
399        let err = OipError::file_not_found("/tmp/missing.db");
400        assert!(err.to_string().contains("File not found"));
401        assert!(err.recovery_hint().is_some());
402        assert!(err.is_recoverable());
403    }
404
405    #[test]
406    fn test_io_error_constructor() {
407        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
408        let err = OipError::io("reading file", io_err);
409        assert!(err.to_string().contains("IO error"));
410        assert_eq!(err.category(), "storage");
411    }
412
413    #[test]
414    fn test_config_error_constructor() {
415        let err = OipError::config("invalid YAML syntax");
416        assert!(err.to_string().contains("Configuration error"));
417        assert!(err.recovery_hint().unwrap().contains("YAML"));
418        assert!(err.is_recoverable());
419    }
420
421    #[test]
422    fn test_invalid_arg_constructor() {
423        let err = OipError::invalid_arg("--backend", "must be simd or gpu");
424        assert!(err.to_string().contains("Invalid argument"));
425        assert!(err.recovery_hint().unwrap().contains("--help"));
426        assert!(err.is_recoverable());
427    }
428
429    #[test]
430    fn test_failed_constructor() {
431        let err = OipError::failed("network unreachable");
432        assert!(err.to_string().contains("Operation failed"));
433        assert!(!err.is_recoverable());
434        assert_eq!(err.category(), "other");
435    }
436
437    #[test]
438    fn test_model_not_trained_recovery() {
439        let err = OipError::ModelNotTrained;
440        assert!(err.recovery_hint().unwrap().contains("train"));
441        assert!(err.is_recoverable());
442        assert_eq!(err.category(), "compute");
443    }
444
445    #[test]
446    fn test_repo_not_found_recovery() {
447        let err = OipError::repo_not_found("invalid/repo");
448        assert!(err.recovery_hint().unwrap().contains("owner/repo"));
449        assert!(err.is_recoverable());
450    }
451
452    #[test]
453    fn test_no_data_recovery() {
454        let err = OipError::no_data("empty store");
455        assert!(err.recovery_hint().unwrap().contains("analyze"));
456        assert!(err.is_recoverable());
457    }
458
459    #[test]
460    fn test_non_recoverable_errors() {
461        assert!(!OipError::invalid_data("test").is_recoverable());
462        assert!(!OipError::github("test").is_recoverable());
463        assert!(!OipError::git("op", "reason").is_recoverable());
464        assert!(!OipError::compute("op", "reason").is_recoverable());
465        assert!(!OipError::storage("op", "reason").is_recoverable());
466    }
467
468    #[test]
469    fn test_category_assignments() {
470        // Data errors
471        assert_eq!(OipError::invalid_data("test").category(), "data");
472        assert_eq!(OipError::validation("f", "r").category(), "data");
473
474        // Git errors
475        assert_eq!(OipError::github("test").category(), "git");
476        assert_eq!(OipError::git("o", "r").category(), "git");
477        assert_eq!(OipError::auth_required("test").category(), "git");
478
479        // Compute errors
480        assert_eq!(OipError::compute("o", "r").category(), "compute");
481
482        // Storage errors
483        assert_eq!(OipError::storage("o", "r").category(), "storage");
484        let io = std::io::Error::other("test");
485        assert_eq!(OipError::io("ctx", io).category(), "storage");
486
487        // Config errors
488        assert_eq!(OipError::config("test").category(), "config");
489        assert_eq!(OipError::invalid_arg("a", "r").category(), "config");
490    }
491}