Skip to main content

vfa_tui/
error.rs

1use thiserror::Error;
2
3/// Domain-specific errors with structured context.
4///
5/// All recoverable and non-recoverable errors the TUI can surface.
6/// Each variant includes structured context fields for precise error reporting.
7#[derive(Debug, Error)]
8pub enum TuiError {
9    // === Catalog Errors ===
10    #[error("catalog file not found: {path}")]
11    CatalogNotFound { path: String },
12
13    #[error("catalog parse error in {path} at byte {offset}: {detail}")]
14    CatalogParse {
15        path: String,
16        offset: usize,
17        detail: String,
18    },
19
20    #[error("catalog entry skipped in {path}: control byte at offset {offset} in field '{field}'")]
21    TaintedEntry {
22        path: String,
23        offset: usize,
24        field: String,
25    },
26
27    // === Workspace Errors ===
28    #[error("workspace not found: traversed to filesystem root from {start}")]
29    WorkspaceNotFound { start: String },
30
31    #[error("invalid workspace: {path} missing {missing}")]
32    InvalidWorkspace { path: String, missing: String },
33
34    // === Registry Errors ===
35    /// Workspace registry TOML failed to parse. `path` is the registry file;
36    /// `line` is the 1-based line number when available.
37    #[error("registry parse error in {path}: {detail}")]
38    RegistryParse { path: String, detail: String },
39
40    /// Two registry entries resolve to the same canonical workspace path.
41    /// `entries` is a human-readable description of the conflicting entries.
42    #[error("duplicate workspace path in {path}: {entries}")]
43    RegistryDuplicate { path: String, entries: String },
44
45    /// A required field is absent from a workspace registry entry.
46    #[error("registry entry '{entry}' missing required field '{field}'")]
47    RegistryFieldMissing { entry: String, field: String },
48
49    // === Policy Errors ===
50    /// Policy TOML failed to parse. `line` is the 1-based source line when available.
51    #[error("policy parse error in {path}{}: {detail}", .line.map(|l| format!(" at line {l}")).unwrap_or_default())]
52    PolicyParse {
53        path: String,
54        line: Option<usize>,
55        detail: String,
56    },
57
58    /// A policy rule references a nonexistent asset, role, or uses an
59    /// unsupported rule type.
60    #[error("invalid policy rule '{rule}': {reason}")]
61    PolicyInvalidRule { rule: String, reason: String },
62
63    // === Gate Errors ===
64    /// Cycle detected while building the gate execution DAG.
65    /// `gates` is the comma-separated list of gate names forming the cycle.
66    #[error("cycle detected in gate DAG involving: {gates}")]
67    GateCycle { gates: String },
68
69    /// The gates.toml (or equivalent) could not be parsed.
70    #[error("gate config parse error in {path}: {detail}")]
71    GateConfigParse { path: String, detail: String },
72
73    // === Persistence Errors ===
74    /// SQLite database file could not be opened or created.
75    #[error("persistence open failed for {path}: {detail}")]
76    PersistenceOpen { path: String, detail: String },
77
78    /// Schema migration between two versions failed.
79    #[error("persistence migration from v{from} to v{to} failed: {detail}")]
80    PersistenceMigration { from: u32, to: u32, detail: String },
81
82    /// A SQLite query or statement failed at runtime.
83    /// Mapped from `rusqlite::Error` via `From`.
84    #[error("persistence query error: {detail}")]
85    PersistenceQuery { detail: String },
86
87    /// Audit log hash chain integrity check failed at the given entry ID.
88    #[error("audit log hash chain broken at entry {entry_id}")]
89    AuditChainBroken { entry_id: i64 },
90
91    // === Configuration Errors ===
92    /// Two or more CLI flags or config values are mutually exclusive.
93    #[error("configuration conflict: {detail}")]
94    ConfigConflict { detail: String },
95
96    /// A specific CLI flag or config value is invalid.
97    #[error("invalid configuration for '{flag}': {detail}")]
98    ConfigInvalid { flag: String, detail: String },
99
100    // === Subprocess Errors ===
101    #[error("subprocess failed: {command} exited with code {code}")]
102    SubprocessFailed { command: String, code: i32 },
103
104    #[error("subprocess timed out after {timeout_secs}s: {command}")]
105    SubprocessTimeout { command: String, timeout_secs: u64 },
106
107    // === Security Errors ===
108    #[error("validation rejected: {value} violates {rule}")]
109    ValidationRejected { value: String, rule: String },
110
111    #[error("path traversal rejected: {path}")]
112    PathTraversal { path: String },
113
114    // === Terminal / Logging Errors ===
115    #[error("terminal capability missing: {capability}")]
116    TerminalCapability { capability: String },
117
118    #[error("log destination unavailable: {path}: {reason}")]
119    LogDestination { path: String, reason: String },
120}
121
122// ---------------------------------------------------------------------------
123// From conversions
124// ---------------------------------------------------------------------------
125
126impl From<std::io::Error> for TuiError {
127    fn from(err: std::io::Error) -> Self {
128        // Map I/O errors to the most appropriate variant based on error kind.
129        match err.kind() {
130            std::io::ErrorKind::NotFound => TuiError::CatalogNotFound {
131                path: err
132                    .get_ref()
133                    .map(|e| e.to_string())
134                    .unwrap_or_else(|| "unknown".to_string()),
135            },
136            _ => TuiError::LogDestination {
137                path: "unknown".to_string(),
138                reason: err.to_string(),
139            },
140        }
141    }
142}
143
144impl From<serde_json::Error> for TuiError {
145    fn from(err: serde_json::Error) -> Self {
146        TuiError::CatalogParse {
147            path: "unknown".to_string(),
148            offset: err.column(),
149            detail: err.to_string(),
150        }
151    }
152}
153
154/// Maps a rusqlite runtime error to `PersistenceQuery`.
155impl From<rusqlite::Error> for TuiError {
156    fn from(err: rusqlite::Error) -> Self {
157        TuiError::PersistenceQuery {
158            detail: err.to_string(),
159        }
160    }
161}
162
163/// Maps a TOML deserialization error to `ConfigInvalid`.
164///
165/// We use `ConfigInvalid` rather than introducing a one-off `TomlParse`
166/// variant because all TOML errors in this codebase surface as configuration
167/// problems (registry, policy, gates), and the existing `ConfigInvalid` shape
168/// (`flag` + `detail`) gives callers enough context to add the specific flag /
169/// file name at the call site.  This keeps the enum focused while remaining
170/// ergonomic.
171impl From<toml::de::Error> for TuiError {
172    fn from(err: toml::de::Error) -> Self {
173        TuiError::ConfigInvalid {
174            flag: "toml".to_string(),
175            detail: err.to_string(),
176        }
177    }
178}
179
180// ---------------------------------------------------------------------------
181// Tests
182// ---------------------------------------------------------------------------
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    // -----------------------------------------------------------------------
189    // Display format tests — existing variants (unchanged)
190    // -----------------------------------------------------------------------
191
192    #[test]
193    fn test_catalog_not_found_display() {
194        let err = TuiError::CatalogNotFound {
195            path: "/some/path/agents.json".to_string(),
196        };
197        assert_eq!(
198            err.to_string(),
199            "catalog file not found: /some/path/agents.json"
200        );
201    }
202
203    #[test]
204    fn test_catalog_parse_display() {
205        let err = TuiError::CatalogParse {
206            path: "catalog/agents.json".to_string(),
207            offset: 42,
208            detail: "unexpected token".to_string(),
209        };
210        assert_eq!(
211            err.to_string(),
212            "catalog parse error in catalog/agents.json at byte 42: unexpected token"
213        );
214    }
215
216    #[test]
217    fn test_tainted_entry_display() {
218        let err = TuiError::TaintedEntry {
219            path: "catalog/agents.json".to_string(),
220            offset: 10,
221            field: "summary".to_string(),
222        };
223        assert_eq!(
224            err.to_string(),
225            "catalog entry skipped in catalog/agents.json: control byte at offset 10 in field 'summary'"
226        );
227    }
228
229    #[test]
230    fn test_workspace_not_found_display() {
231        let err = TuiError::WorkspaceNotFound {
232            start: "/home/user/projects".to_string(),
233        };
234        assert_eq!(
235            err.to_string(),
236            "workspace not found: traversed to filesystem root from /home/user/projects"
237        );
238    }
239
240    #[test]
241    fn test_invalid_workspace_display() {
242        let err = TuiError::InvalidWorkspace {
243            path: "/home/user/repo".to_string(),
244            missing: "catalog/agents.json".to_string(),
245        };
246        assert_eq!(
247            err.to_string(),
248            "invalid workspace: /home/user/repo missing catalog/agents.json"
249        );
250    }
251
252    #[test]
253    fn test_subprocess_failed_display() {
254        let err = TuiError::SubprocessFailed {
255            command: "npm run validate".to_string(),
256            code: 1,
257        };
258        assert_eq!(
259            err.to_string(),
260            "subprocess failed: npm run validate exited with code 1"
261        );
262    }
263
264    #[test]
265    fn test_subprocess_timeout_display() {
266        let err = TuiError::SubprocessTimeout {
267            command: "npm run validate".to_string(),
268            timeout_secs: 300,
269        };
270        assert_eq!(
271            err.to_string(),
272            "subprocess timed out after 300s: npm run validate"
273        );
274    }
275
276    #[test]
277    fn test_validation_rejected_display() {
278        let err = TuiError::ValidationRejected {
279            value: "../../etc/passwd".to_string(),
280            rule: "path_traversal".to_string(),
281        };
282        assert_eq!(
283            err.to_string(),
284            "validation rejected: ../../etc/passwd violates path_traversal"
285        );
286    }
287
288    #[test]
289    fn test_path_traversal_display() {
290        let err = TuiError::PathTraversal {
291            path: "../../etc/passwd".to_string(),
292        };
293        assert_eq!(err.to_string(), "path traversal rejected: ../../etc/passwd");
294    }
295
296    #[test]
297    fn test_terminal_capability_display() {
298        let err = TuiError::TerminalCapability {
299            capability: "256-color".to_string(),
300        };
301        assert_eq!(err.to_string(), "terminal capability missing: 256-color");
302    }
303
304    #[test]
305    fn test_log_destination_display() {
306        let err = TuiError::LogDestination {
307            path: "/var/log/tui.log".to_string(),
308            reason: "permission denied".to_string(),
309        };
310        assert_eq!(
311            err.to_string(),
312            "log destination unavailable: /var/log/tui.log: permission denied"
313        );
314    }
315
316    // -----------------------------------------------------------------------
317    // Display format tests — NEW registry variants
318    // -----------------------------------------------------------------------
319
320    #[test]
321    fn test_registry_parse_display() {
322        let err = TuiError::RegistryParse {
323            path: "workspaces.toml".to_string(),
324            detail: "invalid key".to_string(),
325        };
326        let s = err.to_string();
327        assert!(s.contains("workspaces.toml"), "missing path: {s}");
328        assert!(s.contains("invalid key"), "missing detail: {s}");
329    }
330
331    #[test]
332    fn test_registry_duplicate_display() {
333        let err = TuiError::RegistryDuplicate {
334            path: "workspaces.toml".to_string(),
335            entries: "entry 1 and entry 3".to_string(),
336        };
337        let s = err.to_string();
338        assert!(s.contains("workspaces.toml"), "missing path: {s}");
339        assert!(s.contains("entry 1 and entry 3"), "missing entries: {s}");
340    }
341
342    #[test]
343    fn test_registry_field_missing_display() {
344        let err = TuiError::RegistryFieldMissing {
345            entry: "my-workspace".to_string(),
346            field: "path".to_string(),
347        };
348        let s = err.to_string();
349        assert!(s.contains("my-workspace"), "missing entry: {s}");
350        assert!(s.contains("path"), "missing field: {s}");
351    }
352
353    // -----------------------------------------------------------------------
354    // Display format tests — NEW policy variants
355    // -----------------------------------------------------------------------
356
357    #[test]
358    fn test_policy_parse_with_line_display() {
359        let err = TuiError::PolicyParse {
360            path: "policies.toml".to_string(),
361            line: Some(42),
362            detail: "unexpected value".to_string(),
363        };
364        let s = err.to_string();
365        assert!(s.contains("policies.toml"), "missing path: {s}");
366        assert!(s.contains("42"), "missing line number: {s}");
367        assert!(s.contains("unexpected value"), "missing detail: {s}");
368    }
369
370    #[test]
371    fn test_policy_parse_without_line_display() {
372        let err = TuiError::PolicyParse {
373            path: "policies.toml".to_string(),
374            line: None,
375            detail: "missing table".to_string(),
376        };
377        let s = err.to_string();
378        assert!(s.contains("policies.toml"), "missing path: {s}");
379        assert!(s.contains("missing table"), "missing detail: {s}");
380        // Should not contain a spurious line number
381        assert!(!s.contains("line None"), "spurious 'None' in message: {s}");
382    }
383
384    #[test]
385    fn test_policy_invalid_rule_display() {
386        let err = TuiError::PolicyInvalidRule {
387            rule: "require-security-agent".to_string(),
388            reason: "references nonexistent asset 'security-agent-v1'".to_string(),
389        };
390        let s = err.to_string();
391        assert!(s.contains("require-security-agent"), "missing rule: {s}");
392        assert!(
393            s.contains("references nonexistent asset"),
394            "missing reason: {s}"
395        );
396    }
397
398    // -----------------------------------------------------------------------
399    // Display format tests — NEW gate variants
400    // -----------------------------------------------------------------------
401
402    #[test]
403    fn test_gate_cycle_display() {
404        let err = TuiError::GateCycle {
405            gates: "lint -> test -> lint".to_string(),
406        };
407        let s = err.to_string();
408        assert!(s.contains("lint -> test -> lint"), "missing gates: {s}");
409        assert!(s.contains("cycle"), "missing cycle keyword: {s}");
410    }
411
412    #[test]
413    fn test_gate_config_parse_display() {
414        let err = TuiError::GateConfigParse {
415            path: "gates.toml".to_string(),
416            detail: "unknown key 'prereqs'".to_string(),
417        };
418        let s = err.to_string();
419        assert!(s.contains("gates.toml"), "missing path: {s}");
420        assert!(s.contains("unknown key"), "missing detail: {s}");
421    }
422
423    // -----------------------------------------------------------------------
424    // Display format tests — NEW persistence variants
425    // -----------------------------------------------------------------------
426
427    #[test]
428    fn test_persistence_open_display() {
429        let err = TuiError::PersistenceOpen {
430            path: "/tmp/vfa.db".to_string(),
431            detail: "no such file or directory".to_string(),
432        };
433        let s = err.to_string();
434        assert!(s.contains("/tmp/vfa.db"), "missing path: {s}");
435        assert!(s.contains("no such file"), "missing detail: {s}");
436    }
437
438    #[test]
439    fn test_persistence_migration_display() {
440        let err = TuiError::PersistenceMigration {
441            from: 1,
442            to: 2,
443            detail: "column 'foo' already exists".to_string(),
444        };
445        let s = err.to_string();
446        assert!(s.contains('1'), "missing from version: {s}");
447        assert!(s.contains('2'), "missing to version: {s}");
448        assert!(s.contains("column 'foo'"), "missing detail: {s}");
449    }
450
451    #[test]
452    fn test_persistence_query_display() {
453        let err = TuiError::PersistenceQuery {
454            detail: "no rows returned".to_string(),
455        };
456        let s = err.to_string();
457        assert!(s.contains("no rows returned"), "missing detail: {s}");
458    }
459
460    #[test]
461    fn test_audit_chain_broken_display() {
462        let err = TuiError::AuditChainBroken { entry_id: 99 };
463        let s = err.to_string();
464        assert!(s.contains("99"), "missing entry_id: {s}");
465        assert!(s.contains("audit"), "missing 'audit': {s}");
466    }
467
468    // -----------------------------------------------------------------------
469    // Display format tests — NEW configuration variants
470    // -----------------------------------------------------------------------
471
472    #[test]
473    fn test_config_conflict_display() {
474        let err = TuiError::ConfigConflict {
475            detail: "--report and --validate-config are mutually exclusive".to_string(),
476        };
477        let s = err.to_string();
478        assert!(s.contains("mutually exclusive"), "missing detail: {s}");
479    }
480
481    #[test]
482    fn test_config_invalid_display() {
483        let err = TuiError::ConfigInvalid {
484            flag: "--log-level".to_string(),
485            detail: "unknown level 'verbose'".to_string(),
486        };
487        let s = err.to_string();
488        assert!(s.contains("--log-level"), "missing flag: {s}");
489        assert!(s.contains("unknown level"), "missing detail: {s}");
490    }
491
492    // -----------------------------------------------------------------------
493    // From<rusqlite::Error> — maps to PersistenceQuery
494    // -----------------------------------------------------------------------
495
496    #[test]
497    fn test_from_rusqlite_query_returned_no_rows() {
498        let rusqlite_err = rusqlite::Error::QueryReturnedNoRows;
499        let tui_err: TuiError = rusqlite_err.into();
500        match tui_err {
501            TuiError::PersistenceQuery { detail } => {
502                assert!(!detail.is_empty(), "detail should not be empty");
503            }
504            other => panic!("expected PersistenceQuery, got {other:?}"),
505        }
506    }
507
508    #[test]
509    fn test_from_rusqlite_bad_query_via_connection() {
510        let conn =
511            rusqlite::Connection::open_in_memory().expect("in-memory SQLite should always open");
512        // Execute a query against a table that does not exist — this produces
513        // a real rusqlite error we can convert.
514        let rusqlite_err = conn
515            .execute("SELECT * FROM nonexistent_table", [])
516            .unwrap_err();
517        let tui_err: TuiError = rusqlite_err.into();
518        match tui_err {
519            TuiError::PersistenceQuery { detail } => {
520                assert!(
521                    detail.contains("nonexistent_table") || !detail.is_empty(),
522                    "detail should describe the error: {detail}"
523                );
524            }
525            other => panic!("expected PersistenceQuery, got {other:?}"),
526        }
527    }
528
529    // -----------------------------------------------------------------------
530    // From<toml::de::Error> — maps to ConfigInvalid
531    // -----------------------------------------------------------------------
532
533    #[test]
534    fn test_from_toml_de_error() {
535        // Parse intentionally invalid TOML to obtain a real toml::de::Error.
536        let toml_err = toml::from_str::<toml::Value>("key = [invalid").unwrap_err();
537        let tui_err: TuiError = toml_err.into();
538        match tui_err {
539            TuiError::ConfigInvalid { flag, detail } => {
540                assert_eq!(flag, "toml", "flag should be 'toml'");
541                assert!(!detail.is_empty(), "detail should describe the parse error");
542            }
543            other => panic!("expected ConfigInvalid, got {other:?}"),
544        }
545    }
546
547    // -----------------------------------------------------------------------
548    // From<std::io::Error> — existing conversions unchanged
549    // -----------------------------------------------------------------------
550
551    #[test]
552    fn test_from_io_error_not_found() {
553        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
554        let tui_err: TuiError = io_err.into();
555        match tui_err {
556            TuiError::CatalogNotFound { path } => {
557                assert_eq!(path, "file missing");
558            }
559            _ => panic!("expected CatalogNotFound variant"),
560        }
561    }
562
563    #[test]
564    fn test_from_io_error_other() {
565        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
566        let tui_err: TuiError = io_err.into();
567        match tui_err {
568            TuiError::LogDestination { reason, .. } => {
569                assert!(reason.contains("access denied"));
570            }
571            _ => panic!("expected LogDestination variant"),
572        }
573    }
574
575    // -----------------------------------------------------------------------
576    // From<serde_json::Error> — existing conversion unchanged
577    // -----------------------------------------------------------------------
578
579    #[test]
580    fn test_from_serde_json_error() {
581        let json_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
582        let tui_err: TuiError = json_err.into();
583        match tui_err {
584            TuiError::CatalogParse { detail, .. } => {
585                assert!(!detail.is_empty());
586            }
587            _ => panic!("expected CatalogParse variant"),
588        }
589    }
590
591    // -----------------------------------------------------------------------
592    // Trait bounds
593    // -----------------------------------------------------------------------
594
595    #[test]
596    fn test_error_is_send_sync() {
597        fn assert_send_sync<T: Send + Sync>() {}
598        assert_send_sync::<TuiError>();
599    }
600}