Skip to main content

agent_config/
error.rs

1//! Public error type for the crate.
2
3use std::path::PathBuf;
4
5use thiserror::Error;
6
7/// All failures returned by `agent-config`'s public API.
8#[derive(Debug, Error)]
9#[non_exhaustive]
10pub enum AgentConfigError {
11    /// Filesystem I/O failed. Wraps [`std::io::Error`] with the path that caused it.
12    #[error("io error at {path}: {source}")]
13    Io {
14        /// The path that triggered the error.
15        path: PathBuf,
16        /// The underlying I/O error.
17        #[source]
18        source: std::io::Error,
19    },
20
21    /// A target file existed but contained invalid JSON.
22    #[error("invalid JSON in {path}: {source}")]
23    JsonInvalid {
24        /// The path of the malformed file.
25        path: PathBuf,
26        /// The underlying parse error.
27        #[source]
28        source: serde_json::Error,
29    },
30
31    /// Could not resolve a platform path (e.g., home directory missing).
32    #[error("could not resolve path: {0}")]
33    PathResolution(String),
34
35    /// The integration does not support the requested scope.
36    #[error("integration {id} does not support scope {scope:?}")]
37    UnsupportedScope {
38        /// Integration id.
39        id: &'static str,
40        /// The rejected scope kind.
41        scope: crate::scope::ScopeKind,
42    },
43
44    /// The integration's surface requires a runtime not present on the
45    /// current host (for example, a POSIX shell for a `bash`-script hook).
46    #[error("integration {id} surface is not supported on this platform: {reason}")]
47    UnsupportedPlatform {
48        /// Integration id.
49        id: &'static str,
50        /// Why the platform is unsupported.
51        reason: &'static str,
52    },
53
54    /// The caller-supplied [`HookSpec`](crate::HookSpec) is missing a field this
55    /// integration requires (e.g., gemini needs `script`, prompt-only agents
56    /// need `rules`).
57    #[error("integration {id} requires field `{field}` in HookSpec")]
58    MissingSpecField {
59        /// Integration id.
60        id: &'static str,
61        /// The missing field name.
62        field: &'static str,
63    },
64
65    /// The hook tag is invalid (empty or contains illegal characters).
66    #[error("invalid tag {tag:?}: {reason}")]
67    InvalidTag {
68        /// The offending tag.
69        tag: String,
70        /// Why it was rejected.
71        reason: &'static str,
72    },
73
74    /// The caller supplied an invalid hook command.
75    #[error("invalid hook command: {reason}")]
76    InvalidCommand {
77        /// Why the command was rejected.
78        reason: &'static str,
79    },
80
81    /// A project-local MCP install tried to write likely secret material into
82    /// a repository-owned config file.
83    #[error(
84        "mcp server {name:?} includes likely secret env var {key:?} in local scope; refusing to write inline secret to project config"
85    )]
86    InlineSecretInLocalScope {
87        /// MCP server name.
88        name: String,
89        /// Environment variable key that looked secret-bearing.
90        key: String,
91    },
92
93    /// A would-be backup file already exists at `<path>.bak`.
94    #[error("backup already exists at {0}")]
95    BackupExists(PathBuf),
96
97    /// Could not acquire a filesystem lock before the timeout elapsed.
98    #[error(
99        "timed out waiting for lock at {path}; if no agent-config process is running, this lock may be stale and can be deleted"
100    )]
101    LockTimeout {
102        /// The lock file path that remained held.
103        path: PathBuf,
104    },
105
106    /// A target file existed but contained invalid TOML (Codex `config.toml`).
107    #[error("invalid TOML in {path}: {source}")]
108    TomlInvalid {
109        /// The path of the malformed file.
110        path: PathBuf,
111        /// The underlying parse error.
112        #[source]
113        source: toml_edit::TomlError,
114    },
115
116    /// Caller tried to uninstall an MCP server or skill that is owned by a
117    /// different consumer (or by a hand-edit not recorded in the sidecar
118    /// ledger). Refused to avoid clobbering work this caller did not install.
119    #[error(
120        "{kind} {name:?} is not owned by caller {expected:?} \
121        (actual_owner = {actual:?}); refusing to remove"
122    )]
123    NotOwnedByCaller {
124        /// What kind of resource is in dispute (e.g. `"mcp server"`, `"skill"`).
125        kind: &'static str,
126        /// The disputed name.
127        name: String,
128        /// The expected owner (the caller's tag).
129        expected: String,
130        /// The actual recorded owner, if any.
131        actual: Option<String>,
132    },
133
134    /// A config file has been modified since this library installed an entry
135    /// into it. The backup will not be restored to avoid overwriting user
136    /// changes. The caller should inspect the file and resolve the drift
137    /// manually.
138    #[error("config at {path} has drifted since install (content hash mismatch)")]
139    ConfigDrifted {
140        /// The config file whose content no longer matches the recorded hash.
141        path: PathBuf,
142    },
143
144    /// A config file exceeds the library's read-size cap, set to prevent a
145    /// pathological harness config from consuming unbounded memory.
146    #[error("config at {path} is {size} bytes, exceeding the {limit}-byte cap")]
147    ConfigTooLarge {
148        /// The oversized config path.
149        path: PathBuf,
150        /// Actual file size.
151        size: u64,
152        /// The current cap.
153        limit: u64,
154    },
155
156    /// Anything else, with context.
157    #[error("{0}")]
158    Other(#[from] anyhow::Error),
159}
160
161impl AgentConfigError {
162    /// Helper to wrap an I/O error with its path.
163    pub(crate) fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
164        Self::Io {
165            path: path.into(),
166            source,
167        }
168    }
169
170    /// Helper to wrap a JSON parse error with its path.
171    pub(crate) fn json(path: impl Into<PathBuf>, source: serde_json::Error) -> Self {
172        Self::JsonInvalid {
173            path: path.into(),
174            source,
175        }
176    }
177
178    /// Helper to wrap a TOML parse error with its path.
179    pub(crate) fn toml(path: impl Into<PathBuf>, source: toml_edit::TomlError) -> Self {
180        Self::TomlInvalid {
181            path: path.into(),
182            source,
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use std::str::FromStr;
191
192    #[test]
193    fn io_helper_format() {
194        let err = AgentConfigError::io(
195            "/some/path",
196            std::io::Error::new(std::io::ErrorKind::NotFound, "gone"),
197        );
198        let msg = format!("{err}");
199        assert!(msg.contains("/some/path"), "message: {msg}");
200        assert!(msg.contains("gone"), "message: {msg}");
201    }
202
203    #[test]
204    fn json_helper_format() {
205        let parse_err = serde_json::from_str::<serde_json::Value>("{bad").unwrap_err();
206        let err = AgentConfigError::json("/bad.json", parse_err);
207        let msg = format!("{err}");
208        assert!(msg.contains("/bad.json"), "message: {msg}");
209        assert!(msg.contains("invalid JSON"), "message: {msg}");
210    }
211
212    #[test]
213    fn from_anyhow() {
214        let err = AgentConfigError::from(anyhow::anyhow!("something broke"));
215        assert!(matches!(err, AgentConfigError::Other(_)));
216        assert_eq!(format!("{err}"), "something broke");
217    }
218
219    #[test]
220    fn display_format_for_each_variant() {
221        let io = AgentConfigError::Io {
222            path: PathBuf::from("/a"),
223            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
224        };
225        assert!(format!("{io}").contains("/a"));
226
227        let json = AgentConfigError::JsonInvalid {
228            path: PathBuf::from("/b.json"),
229            source: serde_json::from_str::<serde_json::Value>("{").unwrap_err(),
230        };
231        assert!(format!("{json}").contains("/b.json"));
232
233        let path = AgentConfigError::PathResolution("no home".into());
234        assert!(format!("{path}").contains("no home"));
235
236        let unsupported = AgentConfigError::UnsupportedScope {
237            id: "test",
238            scope: crate::scope::ScopeKind::Global,
239        };
240        assert!(format!("{unsupported}").contains("test"));
241
242        let unsupported_platform = AgentConfigError::UnsupportedPlatform {
243            id: "cline",
244            reason: "POSIX shell required",
245        };
246        let msg = format!("{unsupported_platform}");
247        assert!(msg.contains("cline"));
248        assert!(msg.contains("POSIX shell required"));
249
250        let missing = AgentConfigError::MissingSpecField {
251            id: "agent",
252            field: "command",
253        };
254        assert!(format!("{missing}").contains("command"));
255
256        let tag = AgentConfigError::InvalidTag {
257            tag: "bad!".into(),
258            reason: "chars",
259        };
260        assert!(format!("{tag}").contains("bad!"));
261
262        let command = AgentConfigError::InvalidCommand {
263            reason: "empty command",
264        };
265        assert!(format!("{command}").contains("empty command"));
266
267        let secret = AgentConfigError::InlineSecretInLocalScope {
268            name: "github".into(),
269            key: "GITHUB_TOKEN".into(),
270        };
271        assert!(format!("{secret}").contains("GITHUB_TOKEN"));
272
273        let backup = AgentConfigError::BackupExists(PathBuf::from("/c.bak"));
274        assert!(format!("{backup}").contains("/c.bak"));
275
276        let lock = AgentConfigError::LockTimeout {
277            path: PathBuf::from("/c.lock"),
278        };
279        assert!(format!("{lock}").contains("/c.lock"));
280
281        let toml = AgentConfigError::TomlInvalid {
282            path: PathBuf::from("/d.toml"),
283            source: toml_edit::DocumentMut::from_str("=bad")
284                .expect_err("malformed TOML to parse-fail"),
285        };
286        let toml_msg = format!("{toml}");
287        assert!(toml_msg.contains("/d.toml"));
288        assert!(toml_msg.contains("invalid TOML"));
289
290        let owned = AgentConfigError::NotOwnedByCaller {
291            kind: "mcp server",
292            name: "github".into(),
293            expected: "myapp".into(),
294            actual: Some("otherapp".into()),
295        };
296        let owned_msg = format!("{owned}");
297        assert!(owned_msg.contains("github"));
298        assert!(owned_msg.contains("myapp"));
299        assert!(owned_msg.contains("otherapp"));
300
301        let other = AgentConfigError::Other(anyhow::anyhow!("misc"));
302        assert_eq!(format!("{other}"), "misc");
303
304        let drifted = AgentConfigError::ConfigDrifted {
305            path: PathBuf::from("/e/config.json"),
306        };
307        let drifted_msg = format!("{drifted}");
308        assert!(drifted_msg.contains("/e/config.json"));
309        assert!(drifted_msg.contains("drifted"));
310
311        let too_large = AgentConfigError::ConfigTooLarge {
312            path: PathBuf::from("/f/big.json"),
313            size: 9_000_000,
314            limit: 8 * 1024 * 1024,
315        };
316        let too_large_msg = format!("{too_large}");
317        assert!(too_large_msg.contains("/f/big.json"));
318        assert!(too_large_msg.contains("9000000"));
319        assert!(too_large_msg.contains("8388608"));
320    }
321}