1use std::path::PathBuf;
4
5use thiserror::Error;
6
7#[derive(Debug, Error)]
9#[non_exhaustive]
10pub enum AgentConfigError {
11 #[error("io error at {path}: {source}")]
13 Io {
14 path: PathBuf,
16 #[source]
18 source: std::io::Error,
19 },
20
21 #[error("invalid JSON in {path}: {source}")]
23 JsonInvalid {
24 path: PathBuf,
26 #[source]
28 source: serde_json::Error,
29 },
30
31 #[error("could not resolve path: {0}")]
33 PathResolution(String),
34
35 #[error("integration {id} does not support scope {scope:?}")]
37 UnsupportedScope {
38 id: &'static str,
40 scope: crate::scope::ScopeKind,
42 },
43
44 #[error("integration {id} surface is not supported on this platform: {reason}")]
47 UnsupportedPlatform {
48 id: &'static str,
50 reason: &'static str,
52 },
53
54 #[error("integration {id} requires field `{field}` in HookSpec")]
58 MissingSpecField {
59 id: &'static str,
61 field: &'static str,
63 },
64
65 #[error("integration {id} does not support HookSpec field `{field}` with value {value:?}")]
67 UnsupportedSpecField {
68 id: &'static str,
70 field: &'static str,
72 value: String,
74 },
75
76 #[error("invalid tag {tag:?}: {reason}")]
78 InvalidTag {
79 tag: String,
81 reason: &'static str,
83 },
84
85 #[error("integration {id} does not support {transport} transport: {reason}")]
87 UnsupportedTransport {
88 id: &'static str,
90 transport: &'static str,
92 reason: &'static str,
94 },
95
96 #[error("invalid hook command: {reason}")]
98 InvalidCommand {
99 reason: &'static str,
101 },
102
103 #[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 name: String,
111 key: String,
113 },
114
115 #[error("backup already exists at {0}")]
117 BackupExists(PathBuf),
118
119 #[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 path: PathBuf,
126 },
127
128 #[error("invalid TOML in {path}: {source}")]
130 TomlInvalid {
131 path: PathBuf,
133 #[source]
135 source: toml_edit::TomlError,
136 },
137
138 #[error(
142 "{kind} {name:?} is not owned by caller {expected:?} \
143 (actual_owner = {actual:?}); refusing to remove"
144 )]
145 NotOwnedByCaller {
146 kind: &'static str,
148 name: String,
150 expected: String,
152 actual: Option<String>,
154 },
155
156 #[error("config at {path} has drifted since install (content hash mismatch)")]
161 ConfigDrifted {
162 path: PathBuf,
164 },
165
166 #[error("config at {path} is {size} bytes, exceeding the {limit}-byte cap")]
169 ConfigTooLarge {
170 path: PathBuf,
172 size: u64,
174 limit: u64,
176 },
177
178 #[error("{0}")]
180 Other(#[from] anyhow::Error),
181}
182
183impl AgentConfigError {
184 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 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 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}