1use thiserror::Error;
2
3#[derive(Debug, Error)]
8pub enum TuiError {
9 #[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 #[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 #[error("registry parse error in {path}: {detail}")]
38 RegistryParse { path: String, detail: String },
39
40 #[error("duplicate workspace path in {path}: {entries}")]
43 RegistryDuplicate { path: String, entries: String },
44
45 #[error("registry entry '{entry}' missing required field '{field}'")]
47 RegistryFieldMissing { entry: String, field: String },
48
49 #[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 #[error("invalid policy rule '{rule}': {reason}")]
61 PolicyInvalidRule { rule: String, reason: String },
62
63 #[error("cycle detected in gate DAG involving: {gates}")]
67 GateCycle { gates: String },
68
69 #[error("gate config parse error in {path}: {detail}")]
71 GateConfigParse { path: String, detail: String },
72
73 #[error("persistence open failed for {path}: {detail}")]
76 PersistenceOpen { path: String, detail: String },
77
78 #[error("persistence migration from v{from} to v{to} failed: {detail}")]
80 PersistenceMigration { from: u32, to: u32, detail: String },
81
82 #[error("persistence query error: {detail}")]
85 PersistenceQuery { detail: String },
86
87 #[error("audit log hash chain broken at entry {entry_id}")]
89 AuditChainBroken { entry_id: i64 },
90
91 #[error("configuration conflict: {detail}")]
94 ConfigConflict { detail: String },
95
96 #[error("invalid configuration for '{flag}': {detail}")]
98 ConfigInvalid { flag: String, detail: String },
99
100 #[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 #[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 #[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
122impl From<std::io::Error> for TuiError {
127 fn from(err: std::io::Error) -> Self {
128 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
154impl From<rusqlite::Error> for TuiError {
156 fn from(err: rusqlite::Error) -> Self {
157 TuiError::PersistenceQuery {
158 detail: err.to_string(),
159 }
160 }
161}
162
163impl 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#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 #[test]
534 fn test_from_toml_de_error() {
535 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 #[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 #[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 #[test]
596 fn test_error_is_send_sync() {
597 fn assert_send_sync<T: Send + Sync>() {}
598 assert_send_sync::<TuiError>();
599 }
600}