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 spec contains a field or value that is unsupported by this integration.
66    #[error("integration {id} does not support HookSpec field `{field}` with value {value:?}")]
67    UnsupportedSpecField {
68        /// Integration id.
69        id: &'static str,
70        /// The unsupported field name.
71        field: &'static str,
72        /// The value that was rejected.
73        value: String,
74    },
75
76    /// The hook tag is invalid (empty or contains illegal characters).
77    #[error("invalid tag {tag:?}: {reason}")]
78    InvalidTag {
79        /// The offending tag.
80        tag: String,
81        /// Why it was rejected.
82        reason: &'static str,
83    },
84
85    /// The integration does not support this transport kind on this surface.
86    #[error("integration {id} does not support {transport} transport: {reason}")]
87    UnsupportedTransport {
88        /// Integration id.
89        id: &'static str,
90        /// Transport kind, for example `"sse"`.
91        transport: &'static str,
92        /// Why it was rejected.
93        reason: &'static str,
94    },
95
96    /// The caller supplied an invalid hook command.
97    #[error("invalid hook command: {reason}")]
98    InvalidCommand {
99        /// Why the command was rejected.
100        reason: &'static str,
101    },
102
103    /// A project-local MCP install tried to write likely secret material into
104    /// a repository-owned config file.
105    #[error(
106        "mcp server {name:?} includes likely secret env var {key:?} in local scope; refusing to write inline secret to project config"
107    )]
108    InlineSecretInLocalScope {
109        /// MCP server name.
110        name: String,
111        /// Environment variable key that looked secret-bearing.
112        key: String,
113    },
114
115    /// A would-be backup file already exists at `<path>.bak`.
116    #[error("backup already exists at {0}")]
117    BackupExists(PathBuf),
118
119    /// Could not acquire a filesystem lock before the timeout elapsed.
120    #[error(
121        "timed out waiting for lock at {path}; if no agent-config process is running, this lock may be stale and can be deleted"
122    )]
123    LockTimeout {
124        /// The lock file path that remained held.
125        path: PathBuf,
126    },
127
128    /// A target file existed but contained invalid TOML (Codex `config.toml`).
129    #[error("invalid TOML in {path}: {source}")]
130    TomlInvalid {
131        /// The path of the malformed file.
132        path: PathBuf,
133        /// The underlying parse error.
134        #[source]
135        source: toml_edit::TomlError,
136    },
137
138    /// Caller tried to uninstall an MCP server or skill that is owned by a
139    /// different consumer (or by a hand-edit not recorded in the sidecar
140    /// ledger). Refused to avoid clobbering work this caller did not install.
141    #[error(
142        "{kind} {name:?} is not owned by caller {expected:?} \
143        (actual_owner = {actual:?}); refusing to remove"
144    )]
145    NotOwnedByCaller {
146        /// What kind of resource is in dispute (e.g. `"mcp server"`, `"skill"`).
147        kind: &'static str,
148        /// The disputed name.
149        name: String,
150        /// The expected owner (the caller's tag).
151        expected: String,
152        /// The actual recorded owner, if any.
153        actual: Option<String>,
154    },
155
156    /// A config file has been modified since this library installed an entry
157    /// into it. The backup will not be restored to avoid overwriting user
158    /// changes. The caller should inspect the file and resolve the drift
159    /// manually.
160    #[error("config at {path} has drifted since install (content hash mismatch)")]
161    ConfigDrifted {
162        /// The config file whose content no longer matches the recorded hash.
163        path: PathBuf,
164    },
165
166    /// A config file exceeds the library's read-size cap, set to prevent a
167    /// pathological harness config from consuming unbounded memory.
168    #[error("config at {path} is {size} bytes, exceeding the {limit}-byte cap")]
169    ConfigTooLarge {
170        /// The oversized config path.
171        path: PathBuf,
172        /// Actual file size.
173        size: u64,
174        /// The current cap.
175        limit: u64,
176    },
177
178    /// Anything else, with context.
179    #[error("{0}")]
180    Other(#[from] anyhow::Error),
181}
182
183impl AgentConfigError {
184    /// Helper to wrap an I/O error with its path.
185    pub(crate) fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
186        Self::Io {
187            path: path.into(),
188            source,
189        }
190    }
191
192    /// Helper to wrap a JSON parse error with its path.
193    pub(crate) fn json(path: impl Into<PathBuf>, source: serde_json::Error) -> Self {
194        Self::JsonInvalid {
195            path: path.into(),
196            source,
197        }
198    }
199
200    /// Helper to wrap a TOML parse error with its path.
201    pub(crate) fn toml(path: impl Into<PathBuf>, source: toml_edit::TomlError) -> Self {
202        Self::TomlInvalid {
203            path: path.into(),
204            source,
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use std::str::FromStr;
213
214    #[test]
215    fn io_helper_format() {
216        let err = AgentConfigError::io(
217            "/some/path",
218            std::io::Error::new(std::io::ErrorKind::NotFound, "gone"),
219        );
220        let msg = format!("{err}");
221        assert!(msg.contains("/some/path"), "message: {msg}");
222        assert!(msg.contains("gone"), "message: {msg}");
223    }
224
225    #[test]
226    fn json_helper_format() {
227        let parse_err = serde_json::from_str::<serde_json::Value>("{bad").unwrap_err();
228        let err = AgentConfigError::json("/bad.json", parse_err);
229        let msg = format!("{err}");
230        assert!(msg.contains("/bad.json"), "message: {msg}");
231        assert!(msg.contains("invalid JSON"), "message: {msg}");
232    }
233
234    #[test]
235    fn from_anyhow() {
236        let err = AgentConfigError::from(anyhow::anyhow!("something broke"));
237        assert!(matches!(err, AgentConfigError::Other(_)));
238        assert_eq!(format!("{err}"), "something broke");
239    }
240
241    #[test]
242    fn display_format_for_each_variant() {
243        let io = AgentConfigError::Io {
244            path: PathBuf::from("/a"),
245            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
246        };
247        assert!(format!("{io}").contains("/a"));
248
249        let json = AgentConfigError::JsonInvalid {
250            path: PathBuf::from("/b.json"),
251            source: serde_json::from_str::<serde_json::Value>("{").unwrap_err(),
252        };
253        assert!(format!("{json}").contains("/b.json"));
254
255        let path = AgentConfigError::PathResolution("no home".into());
256        assert!(format!("{path}").contains("no home"));
257
258        let unsupported = AgentConfigError::UnsupportedScope {
259            id: "test",
260            scope: crate::scope::ScopeKind::Global,
261        };
262        assert!(format!("{unsupported}").contains("test"));
263
264        let unsupported_platform = AgentConfigError::UnsupportedPlatform {
265            id: "cline",
266            reason: "POSIX shell required",
267        };
268        let msg = format!("{unsupported_platform}");
269        assert!(msg.contains("cline"));
270        assert!(msg.contains("POSIX shell required"));
271
272        let unsupported_transport = AgentConfigError::UnsupportedTransport {
273            id: "codex",
274            transport: "sse",
275            reason: "not supported",
276        };
277        let msg = format!("{unsupported_transport}");
278        assert!(msg.contains("codex"));
279        assert!(msg.contains("sse"));
280        assert!(msg.contains("not supported"));
281
282        let missing = AgentConfigError::MissingSpecField {
283            id: "agent",
284            field: "command",
285        };
286        assert!(format!("{missing}").contains("command"));
287
288        let unsupported_field = AgentConfigError::UnsupportedSpecField {
289            id: "agent",
290            field: "event",
291            value: "PostToolUse".to_string(),
292        };
293        assert!(format!("{unsupported_field}").contains("PostToolUse"));
294
295        let tag = AgentConfigError::InvalidTag {
296            tag: "bad!".into(),
297            reason: "chars",
298        };
299        assert!(format!("{tag}").contains("bad!"));
300
301        let command = AgentConfigError::InvalidCommand {
302            reason: "empty command",
303        };
304        assert!(format!("{command}").contains("empty command"));
305
306        let secret = AgentConfigError::InlineSecretInLocalScope {
307            name: "github".into(),
308            key: "GITHUB_TOKEN".into(),
309        };
310        assert!(format!("{secret}").contains("GITHUB_TOKEN"));
311
312        let backup = AgentConfigError::BackupExists(PathBuf::from("/c.bak"));
313        assert!(format!("{backup}").contains("/c.bak"));
314
315        let lock = AgentConfigError::LockTimeout {
316            path: PathBuf::from("/c.lock"),
317        };
318        assert!(format!("{lock}").contains("/c.lock"));
319
320        let toml = AgentConfigError::TomlInvalid {
321            path: PathBuf::from("/d.toml"),
322            source: toml_edit::DocumentMut::from_str("=bad")
323                .expect_err("malformed TOML to parse-fail"),
324        };
325        let toml_msg = format!("{toml}");
326        assert!(toml_msg.contains("/d.toml"));
327        assert!(toml_msg.contains("invalid TOML"));
328
329        let owned = AgentConfigError::NotOwnedByCaller {
330            kind: "mcp server",
331            name: "github".into(),
332            expected: "myapp".into(),
333            actual: Some("otherapp".into()),
334        };
335        let owned_msg = format!("{owned}");
336        assert!(owned_msg.contains("github"));
337        assert!(owned_msg.contains("myapp"));
338        assert!(owned_msg.contains("otherapp"));
339
340        let other = AgentConfigError::Other(anyhow::anyhow!("misc"));
341        assert_eq!(format!("{other}"), "misc");
342
343        let drifted = AgentConfigError::ConfigDrifted {
344            path: PathBuf::from("/e/config.json"),
345        };
346        let drifted_msg = format!("{drifted}");
347        assert!(drifted_msg.contains("/e/config.json"));
348        assert!(drifted_msg.contains("drifted"));
349
350        let too_large = AgentConfigError::ConfigTooLarge {
351            path: PathBuf::from("/f/big.json"),
352            size: 9_000_000,
353            limit: 8 * 1024 * 1024,
354        };
355        let too_large_msg = format!("{too_large}");
356        assert!(too_large_msg.contains("/f/big.json"));
357        assert!(too_large_msg.contains("9000000"));
358        assert!(too_large_msg.contains("8388608"));
359    }
360}