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("invalid hook command: {reason}")]
76 InvalidCommand {
77 reason: &'static str,
79 },
80
81 #[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 name: String,
89 key: String,
91 },
92
93 #[error("backup already exists at {0}")]
95 BackupExists(PathBuf),
96
97 #[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 path: PathBuf,
104 },
105
106 #[error("invalid TOML in {path}: {source}")]
108 TomlInvalid {
109 path: PathBuf,
111 #[source]
113 source: toml_edit::TomlError,
114 },
115
116 #[error(
120 "{kind} {name:?} is not owned by caller {expected:?} \
121 (actual_owner = {actual:?}); refusing to remove"
122 )]
123 NotOwnedByCaller {
124 kind: &'static str,
126 name: String,
128 expected: String,
130 actual: Option<String>,
132 },
133
134 #[error("config at {path} has drifted since install (content hash mismatch)")]
139 ConfigDrifted {
140 path: PathBuf,
142 },
143
144 #[error("config at {path} is {size} bytes, exceeding the {limit}-byte cap")]
147 ConfigTooLarge {
148 path: PathBuf,
150 size: u64,
152 limit: u64,
154 },
155
156 #[error("{0}")]
158 Other(#[from] anyhow::Error),
159}
160
161impl AgentConfigError {
162 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 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 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}