use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum AgentConfigError {
#[error("io error at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid JSON in {path}: {source}")]
JsonInvalid {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("could not resolve path: {0}")]
PathResolution(String),
#[error("integration {id} does not support scope {scope:?}")]
UnsupportedScope {
id: &'static str,
scope: crate::scope::ScopeKind,
},
#[error("integration {id} surface is not supported on this platform: {reason}")]
UnsupportedPlatform {
id: &'static str,
reason: &'static str,
},
#[error("integration {id} requires field `{field}` in HookSpec")]
MissingSpecField {
id: &'static str,
field: &'static str,
},
#[error("invalid tag {tag:?}: {reason}")]
InvalidTag {
tag: String,
reason: &'static str,
},
#[error("invalid hook command: {reason}")]
InvalidCommand {
reason: &'static str,
},
#[error(
"mcp server {name:?} includes likely secret env var {key:?} in local scope; refusing to write inline secret to project config"
)]
InlineSecretInLocalScope {
name: String,
key: String,
},
#[error("backup already exists at {0}")]
BackupExists(PathBuf),
#[error(
"timed out waiting for lock at {path}; if no agent-config process is running, this lock may be stale and can be deleted"
)]
LockTimeout {
path: PathBuf,
},
#[error("invalid TOML in {path}: {source}")]
TomlInvalid {
path: PathBuf,
#[source]
source: toml_edit::TomlError,
},
#[error(
"{kind} {name:?} is not owned by caller {expected:?} \
(actual_owner = {actual:?}); refusing to remove"
)]
NotOwnedByCaller {
kind: &'static str,
name: String,
expected: String,
actual: Option<String>,
},
#[error("config at {path} has drifted since install (content hash mismatch)")]
ConfigDrifted {
path: PathBuf,
},
#[error("config at {path} is {size} bytes, exceeding the {limit}-byte cap")]
ConfigTooLarge {
path: PathBuf,
size: u64,
limit: u64,
},
#[error("{0}")]
Other(#[from] anyhow::Error),
}
impl AgentConfigError {
pub(crate) fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
Self::Io {
path: path.into(),
source,
}
}
pub(crate) fn json(path: impl Into<PathBuf>, source: serde_json::Error) -> Self {
Self::JsonInvalid {
path: path.into(),
source,
}
}
pub(crate) fn toml(path: impl Into<PathBuf>, source: toml_edit::TomlError) -> Self {
Self::TomlInvalid {
path: path.into(),
source,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn io_helper_format() {
let err = AgentConfigError::io(
"/some/path",
std::io::Error::new(std::io::ErrorKind::NotFound, "gone"),
);
let msg = format!("{err}");
assert!(msg.contains("/some/path"), "message: {msg}");
assert!(msg.contains("gone"), "message: {msg}");
}
#[test]
fn json_helper_format() {
let parse_err = serde_json::from_str::<serde_json::Value>("{bad").unwrap_err();
let err = AgentConfigError::json("/bad.json", parse_err);
let msg = format!("{err}");
assert!(msg.contains("/bad.json"), "message: {msg}");
assert!(msg.contains("invalid JSON"), "message: {msg}");
}
#[test]
fn from_anyhow() {
let err = AgentConfigError::from(anyhow::anyhow!("something broke"));
assert!(matches!(err, AgentConfigError::Other(_)));
assert_eq!(format!("{err}"), "something broke");
}
#[test]
fn display_format_for_each_variant() {
let io = AgentConfigError::Io {
path: PathBuf::from("/a"),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
};
assert!(format!("{io}").contains("/a"));
let json = AgentConfigError::JsonInvalid {
path: PathBuf::from("/b.json"),
source: serde_json::from_str::<serde_json::Value>("{").unwrap_err(),
};
assert!(format!("{json}").contains("/b.json"));
let path = AgentConfigError::PathResolution("no home".into());
assert!(format!("{path}").contains("no home"));
let unsupported = AgentConfigError::UnsupportedScope {
id: "test",
scope: crate::scope::ScopeKind::Global,
};
assert!(format!("{unsupported}").contains("test"));
let unsupported_platform = AgentConfigError::UnsupportedPlatform {
id: "cline",
reason: "POSIX shell required",
};
let msg = format!("{unsupported_platform}");
assert!(msg.contains("cline"));
assert!(msg.contains("POSIX shell required"));
let missing = AgentConfigError::MissingSpecField {
id: "agent",
field: "command",
};
assert!(format!("{missing}").contains("command"));
let tag = AgentConfigError::InvalidTag {
tag: "bad!".into(),
reason: "chars",
};
assert!(format!("{tag}").contains("bad!"));
let command = AgentConfigError::InvalidCommand {
reason: "empty command",
};
assert!(format!("{command}").contains("empty command"));
let secret = AgentConfigError::InlineSecretInLocalScope {
name: "github".into(),
key: "GITHUB_TOKEN".into(),
};
assert!(format!("{secret}").contains("GITHUB_TOKEN"));
let backup = AgentConfigError::BackupExists(PathBuf::from("/c.bak"));
assert!(format!("{backup}").contains("/c.bak"));
let lock = AgentConfigError::LockTimeout {
path: PathBuf::from("/c.lock"),
};
assert!(format!("{lock}").contains("/c.lock"));
let toml = AgentConfigError::TomlInvalid {
path: PathBuf::from("/d.toml"),
source: toml_edit::DocumentMut::from_str("=bad")
.expect_err("malformed TOML to parse-fail"),
};
let toml_msg = format!("{toml}");
assert!(toml_msg.contains("/d.toml"));
assert!(toml_msg.contains("invalid TOML"));
let owned = AgentConfigError::NotOwnedByCaller {
kind: "mcp server",
name: "github".into(),
expected: "myapp".into(),
actual: Some("otherapp".into()),
};
let owned_msg = format!("{owned}");
assert!(owned_msg.contains("github"));
assert!(owned_msg.contains("myapp"));
assert!(owned_msg.contains("otherapp"));
let other = AgentConfigError::Other(anyhow::anyhow!("misc"));
assert_eq!(format!("{other}"), "misc");
let drifted = AgentConfigError::ConfigDrifted {
path: PathBuf::from("/e/config.json"),
};
let drifted_msg = format!("{drifted}");
assert!(drifted_msg.contains("/e/config.json"));
assert!(drifted_msg.contains("drifted"));
let too_large = AgentConfigError::ConfigTooLarge {
path: PathBuf::from("/f/big.json"),
size: 9_000_000,
limit: 8 * 1024 * 1024,
};
let too_large_msg = format!("{too_large}");
assert!(too_large_msg.contains("/f/big.json"));
assert!(too_large_msg.contains("9000000"));
assert!(too_large_msg.contains("8388608"));
}
}