switchyard/api/
errors.rs

1use thiserror::Error;
2pub mod map;
3
4#[derive(Debug, Error)]
5pub enum ApiError {
6    #[error("policy violation: {0}")]
7    PolicyViolation(String),
8    #[error("locking timeout: {0}")]
9    LockingTimeout(String),
10    #[error("filesystem error: {0}")]
11    FilesystemError(String),
12    #[error("cross-filesystem degraded path not allowed: {0}")]
13    ExdevDegraded(String),
14    #[error("smoke tests failed")]
15    SmokeFailed,
16    #[error("ownership check failed: {0}")]
17    OwnershipError(String),
18    #[error("attestation failed: {0}")]
19    AttestationFailed(String),
20}
21
22/// Best-effort mapping from apply-stage error strings to a chain of stable summary error IDs.
23/// Always includes a top-level classification; may include co-emitted categories like `E_OWNERSHIP`.
24#[must_use]
25pub fn infer_summary_error_ids(errors: &[String]) -> Vec<&'static str> {
26    let mut out: Vec<&'static str> = Vec::new();
27    let joined = errors.join("; ").to_lowercase();
28    if joined.contains("smoke") {
29        out.push(id_str(ErrorId::E_SMOKE));
30    }
31    if joined.contains("lock") {
32        out.push(id_str(ErrorId::E_LOCKING));
33    }
34    if joined.contains("ownership") {
35        out.push(id_str(ErrorId::E_OWNERSHIP));
36    }
37    if joined.contains("exdev") {
38        out.push(id_str(ErrorId::E_EXDEV));
39    }
40    // Heuristics for cross-filesystem rename errors when OS strings differ
41    if joined.contains("xdev")
42        || joined.contains("cross-device")
43        || joined.contains("cross device")
44        || joined.contains("os error 18")
45        || joined.contains("errno 18")
46    {
47        out.push(id_str(ErrorId::E_EXDEV));
48    }
49    if joined.contains("atomic") || joined.contains("symlink") {
50        out.push(id_str(ErrorId::E_ATOMIC_SWAP));
51    }
52    if joined.contains("backup") && joined.contains("missing") {
53        out.push(id_str(ErrorId::E_BACKUP_MISSING));
54    }
55    if joined.contains("restore") && joined.contains("failed") {
56        out.push(id_str(ErrorId::E_RESTORE_FAILED));
57    }
58    if out.is_empty() {
59        out.push(id_str(ErrorId::E_POLICY));
60    } else {
61        // Ensure E_POLICY is present last for routing when other specifics exist
62        out.push(id_str(ErrorId::E_POLICY));
63    }
64    // Deduplicate while preserving order
65    let mut seen = std::collections::HashSet::new();
66    out.into_iter().filter(|id| seen.insert(*id)).collect()
67}
68
69impl From<crate::types::errors::Error> for ApiError {
70    fn from(e: crate::types::errors::Error) -> Self {
71        use crate::types::errors::ErrorKind::{InvalidPath, Io, Policy};
72        match e.kind {
73            InvalidPath | Io => ApiError::FilesystemError(e.msg),
74            Policy => ApiError::PolicyViolation(e.msg),
75        }
76    }
77}
78
79// Stable identifiers aligned with SPEC/error_codes.toml
80// We intentionally keep SCREAMING_SNAKE_CASE to match emitted IDs.
81#[allow(
82    non_camel_case_types,
83    reason = "Error IDs must match SPEC/error_codes.toml format"
84)]
85#[derive(Clone, Copy, Debug)]
86pub enum ErrorId {
87    E_POLICY,
88    E_OWNERSHIP,
89    E_LOCKING,
90    E_ATOMIC_SWAP,
91    E_EXDEV,
92    E_BACKUP_MISSING,
93    E_RESTORE_FAILED,
94    E_SMOKE,
95    E_GENERIC,
96}
97
98#[must_use]
99pub const fn id_str(id: ErrorId) -> &'static str {
100    match id {
101        ErrorId::E_POLICY => "E_POLICY",
102        ErrorId::E_OWNERSHIP => "E_OWNERSHIP",
103        ErrorId::E_LOCKING => "E_LOCKING",
104        ErrorId::E_ATOMIC_SWAP => "E_ATOMIC_SWAP",
105        ErrorId::E_EXDEV => "E_EXDEV",
106        ErrorId::E_BACKUP_MISSING => "E_BACKUP_MISSING",
107        ErrorId::E_RESTORE_FAILED => "E_RESTORE_FAILED",
108        ErrorId::E_SMOKE => "E_SMOKE",
109        ErrorId::E_GENERIC => "E_GENERIC",
110    }
111}
112
113#[must_use]
114pub const fn exit_code_for(id: ErrorId) -> i32 {
115    match id {
116        ErrorId::E_POLICY => 10,
117        ErrorId::E_OWNERSHIP => 20,
118        ErrorId::E_LOCKING => 30,
119        ErrorId::E_ATOMIC_SWAP => 40,
120        ErrorId::E_EXDEV => 50,
121        ErrorId::E_BACKUP_MISSING => 60,
122        ErrorId::E_RESTORE_FAILED => 70,
123        ErrorId::E_SMOKE => 80,
124        ErrorId::E_GENERIC => 1,
125    }
126}
127
128#[must_use]
129pub fn exit_code_for_id_str(s: &str) -> Option<i32> {
130    match s {
131        "E_POLICY" => Some(10),
132        "E_OWNERSHIP" => Some(20),
133        "E_LOCKING" => Some(30),
134        "E_ATOMIC_SWAP" => Some(40),
135        "E_EXDEV" => Some(50),
136        "E_BACKUP_MISSING" => Some(60),
137        "E_RESTORE_FAILED" => Some(70),
138        "E_SMOKE" => Some(80),
139        _ => None,
140    }
141}