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("invalid tag {tag:?}: {reason}")]
67 InvalidTag {
68 tag: String,
70 reason: &'static str,
72 },
73
74 #[error("integration {id} does not support {transport} transport: {reason}")]
76 UnsupportedTransport {
77 id: &'static str,
79 transport: &'static str,
81 reason: &'static str,
83 },
84
85 #[error("invalid hook command: {reason}")]
87 InvalidCommand {
88 reason: &'static str,
90 },
91
92 #[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 name: String,
100 key: String,
102 },
103
104 #[error("backup already exists at {0}")]
106 BackupExists(PathBuf),
107
108 #[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 path: PathBuf,
115 },
116
117 #[error("invalid TOML in {path}: {source}")]
119 TomlInvalid {
120 path: PathBuf,
122 #[source]
124 source: toml_edit::TomlError,
125 },
126
127 #[error(
131 "{kind} {name:?} is not owned by caller {expected:?} \
132 (actual_owner = {actual:?}); refusing to remove"
133 )]
134 NotOwnedByCaller {
135 kind: &'static str,
137 name: String,
139 expected: String,
141 actual: Option<String>,
143 },
144
145 #[error("config at {path} has drifted since install (content hash mismatch)")]
150 ConfigDrifted {
151 path: PathBuf,
153 },
154
155 #[error("config at {path} is {size} bytes, exceeding the {limit}-byte cap")]
158 ConfigTooLarge {
159 path: PathBuf,
161 size: u64,
163 limit: u64,
165 },
166
167 #[error("{0}")]
169 Other(#[from] anyhow::Error),
170}
171
172impl AgentConfigError {
173 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 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 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}