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