agent-config 0.1.0

Install hooks/integrations into AI coding harnesses (Claude Code, Cursor, Gemini CLI, OpenCode, Codex CLI, Cline, Windsurf, ...) without learning each one's filesystem layout.
Documentation
//! Public error type for the crate.

use std::path::PathBuf;

use thiserror::Error;

/// All failures returned by `agent-config`'s public API.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum AgentConfigError {
    /// Filesystem I/O failed. Wraps [`std::io::Error`] with the path that caused it.
    #[error("io error at {path}: {source}")]
    Io {
        /// The path that triggered the error.
        path: PathBuf,
        /// The underlying I/O error.
        #[source]
        source: std::io::Error,
    },

    /// A target file existed but contained invalid JSON.
    #[error("invalid JSON in {path}: {source}")]
    JsonInvalid {
        /// The path of the malformed file.
        path: PathBuf,
        /// The underlying parse error.
        #[source]
        source: serde_json::Error,
    },

    /// Could not resolve a platform path (e.g., home directory missing).
    #[error("could not resolve path: {0}")]
    PathResolution(String),

    /// The integration does not support the requested scope.
    #[error("integration {id} does not support scope {scope:?}")]
    UnsupportedScope {
        /// Integration id.
        id: &'static str,
        /// The rejected scope kind.
        scope: crate::scope::ScopeKind,
    },

    /// The integration's surface requires a runtime not present on the
    /// current host (for example, a POSIX shell for a `bash`-script hook).
    #[error("integration {id} surface is not supported on this platform: {reason}")]
    UnsupportedPlatform {
        /// Integration id.
        id: &'static str,
        /// Why the platform is unsupported.
        reason: &'static str,
    },

    /// The caller-supplied [`HookSpec`](crate::HookSpec) is missing a field this
    /// integration requires (e.g., gemini needs `script`, prompt-only agents
    /// need `rules`).
    #[error("integration {id} requires field `{field}` in HookSpec")]
    MissingSpecField {
        /// Integration id.
        id: &'static str,
        /// The missing field name.
        field: &'static str,
    },

    /// The hook tag is invalid (empty or contains illegal characters).
    #[error("invalid tag {tag:?}: {reason}")]
    InvalidTag {
        /// The offending tag.
        tag: String,
        /// Why it was rejected.
        reason: &'static str,
    },

    /// The caller supplied an invalid hook command.
    #[error("invalid hook command: {reason}")]
    InvalidCommand {
        /// Why the command was rejected.
        reason: &'static str,
    },

    /// A project-local MCP install tried to write likely secret material into
    /// a repository-owned config file.
    #[error(
        "mcp server {name:?} includes likely secret env var {key:?} in local scope; refusing to write inline secret to project config"
    )]
    InlineSecretInLocalScope {
        /// MCP server name.
        name: String,
        /// Environment variable key that looked secret-bearing.
        key: String,
    },

    /// A would-be backup file already exists at `<path>.bak`.
    #[error("backup already exists at {0}")]
    BackupExists(PathBuf),

    /// Could not acquire a filesystem lock before the timeout elapsed.
    #[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 {
        /// The lock file path that remained held.
        path: PathBuf,
    },

    /// A target file existed but contained invalid TOML (Codex `config.toml`).
    #[error("invalid TOML in {path}: {source}")]
    TomlInvalid {
        /// The path of the malformed file.
        path: PathBuf,
        /// The underlying parse error.
        #[source]
        source: toml_edit::TomlError,
    },

    /// Caller tried to uninstall an MCP server or skill that is owned by a
    /// different consumer (or by a hand-edit not recorded in the sidecar
    /// ledger). Refused to avoid clobbering work this caller did not install.
    #[error(
        "{kind} {name:?} is not owned by caller {expected:?} \
        (actual_owner = {actual:?}); refusing to remove"
    )]
    NotOwnedByCaller {
        /// What kind of resource is in dispute (e.g. `"mcp server"`, `"skill"`).
        kind: &'static str,
        /// The disputed name.
        name: String,
        /// The expected owner (the caller's tag).
        expected: String,
        /// The actual recorded owner, if any.
        actual: Option<String>,
    },

    /// A config file has been modified since this library installed an entry
    /// into it. The backup will not be restored to avoid overwriting user
    /// changes. The caller should inspect the file and resolve the drift
    /// manually.
    #[error("config at {path} has drifted since install (content hash mismatch)")]
    ConfigDrifted {
        /// The config file whose content no longer matches the recorded hash.
        path: PathBuf,
    },

    /// A config file exceeds the library's read-size cap, set to prevent a
    /// pathological harness config from consuming unbounded memory.
    #[error("config at {path} is {size} bytes, exceeding the {limit}-byte cap")]
    ConfigTooLarge {
        /// The oversized config path.
        path: PathBuf,
        /// Actual file size.
        size: u64,
        /// The current cap.
        limit: u64,
    },

    /// Anything else, with context.
    #[error("{0}")]
    Other(#[from] anyhow::Error),
}

impl AgentConfigError {
    /// Helper to wrap an I/O error with its path.
    pub(crate) fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
        Self::Io {
            path: path.into(),
            source,
        }
    }

    /// Helper to wrap a JSON parse error with its path.
    pub(crate) fn json(path: impl Into<PathBuf>, source: serde_json::Error) -> Self {
        Self::JsonInvalid {
            path: path.into(),
            source,
        }
    }

    /// Helper to wrap a TOML parse error with its path.
    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"));
    }
}