agent-config 0.3.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
//! Where a hook should be installed: globally for the user, or scoped to a
//! single project directory.

use std::path::{Path, PathBuf};

/// Install location for a hook.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Scope {
    /// User-global: writes to the harness's per-user config dir
    /// (e.g., `~/.claude/`, `~/.cursor/`).
    Global,

    /// Project-local: writes to a specific project directory
    /// (e.g., `./CLAUDE.md`, `./.clinerules`).
    Local(PathBuf),
}

impl Scope {
    /// Returns the [`ScopeKind`] discriminant.
    pub fn kind(&self) -> ScopeKind {
        match self {
            Scope::Global => ScopeKind::Global,
            Scope::Local(_) => ScopeKind::Local,
        }
    }

    /// Returns the local project root for [`Scope::Local`].
    pub fn local_root(&self) -> Option<&Path> {
        match self {
            Scope::Local(p) => Some(p),
            Scope::Global => None,
        }
    }

    /// Verify that `path` is contained within the local project root.
    ///
    /// For [`Scope::Global`], rejects symlinks anywhere along the path so a
    /// symlinked `~/.claude`, `~/.cursor`, etc. cannot redirect writes outside
    /// the user's intended config tree. For [`Scope::Local`], rejects symlink
    /// components under the project root, canonicalizes the deepest existing
    /// path component, then checks it stays within the canonical project root.
    ///
    /// Missing tail components are allowed when every existing ancestor stays
    /// inside the root and is not a symlink.
    ///
    /// # Errors
    ///
    /// Returns [`crate::AgentConfigError::PathResolution`] when:
    /// - any path component is a symlink (Global scope) or a symlink under the
    ///   local root (Local scope) — `"refusing to write through symlink"`,
    /// - the canonicalized path escapes the local project root —
    ///   `"refusing to write outside scope root"`,
    /// - an unexpected I/O error occurs while inspecting an existing component.
    pub fn ensure_contained(&self, path: &Path) -> Result<(), crate::error::AgentConfigError> {
        match self {
            Scope::Global => crate::util::fs_atomic::reject_symlink_components(path),
            Scope::Local(root) => crate::util::fs_atomic::ensure_contained(path, root),
        }
    }
}

/// Discriminant of [`Scope`] without payload, used in the
/// [`Integration::supported_scopes`](crate::integration::Integration::supported_scopes)
/// trait method.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ScopeKind {
    /// User-global scope.
    Global,
    /// Project-local scope.
    Local,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn scope_accessors() {
        assert_eq!(Scope::Global.kind(), ScopeKind::Global);
        assert_eq!(Scope::Local(PathBuf::from("/tmp")).kind(), ScopeKind::Local);
        assert!(Scope::Global.local_root().is_none());
        assert_eq!(
            Scope::Local(PathBuf::from("/project")).local_root(),
            Some(Path::new("/project"))
        );
        assert_ne!(ScopeKind::Global, ScopeKind::Local);
    }
}