Skip to main content

agent_config/
scope.rs

1//! Where a hook should be installed: globally for the user, or scoped to a
2//! single project directory.
3
4use std::path::{Path, PathBuf};
5
6/// Install location for a hook.
7#[derive(Debug, Clone)]
8#[non_exhaustive]
9pub enum Scope {
10    /// User-global: writes to the harness's per-user config dir
11    /// (e.g., `~/.claude/`, `~/.cursor/`).
12    Global,
13
14    /// Project-local: writes to a specific project directory
15    /// (e.g., `./CLAUDE.md`, `./.clinerules`).
16    Local(PathBuf),
17}
18
19impl Scope {
20    /// Returns the [`ScopeKind`] discriminant.
21    pub fn kind(&self) -> ScopeKind {
22        match self {
23            Scope::Global => ScopeKind::Global,
24            Scope::Local(_) => ScopeKind::Local,
25        }
26    }
27
28    /// Returns the local project root for [`Scope::Local`].
29    pub fn local_root(&self) -> Option<&Path> {
30        match self {
31            Scope::Local(p) => Some(p),
32            Scope::Global => None,
33        }
34    }
35
36    /// Verify that `path` is contained within the local project root.
37    ///
38    /// For [`Scope::Global`], rejects symlinks anywhere along the path so a
39    /// symlinked `~/.claude`, `~/.cursor`, etc. cannot redirect writes outside
40    /// the user's intended config tree. For [`Scope::Local`], rejects symlink
41    /// components under the project root, canonicalizes the deepest existing
42    /// path component, then checks it stays within the canonical project root.
43    ///
44    /// Missing tail components are allowed when every existing ancestor stays
45    /// inside the root and is not a symlink.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`crate::AgentConfigError::PathResolution`] when:
50    /// - any path component is a symlink (Global scope) or a symlink under the
51    ///   local root (Local scope) — `"refusing to write through symlink"`,
52    /// - the canonicalized path escapes the local project root —
53    ///   `"refusing to write outside scope root"`,
54    /// - an unexpected I/O error occurs while inspecting an existing component.
55    pub fn ensure_contained(&self, path: &Path) -> Result<(), crate::error::AgentConfigError> {
56        match self {
57            Scope::Global => crate::util::fs_atomic::reject_symlink_components(path),
58            Scope::Local(root) => crate::util::fs_atomic::ensure_contained(path, root),
59        }
60    }
61}
62
63/// Discriminant of [`Scope`] without payload, used in the
64/// [`Integration::supported_scopes`](crate::integration::Integration::supported_scopes)
65/// trait method.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67#[non_exhaustive]
68pub enum ScopeKind {
69    /// User-global scope.
70    Global,
71    /// Project-local scope.
72    Local,
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn scope_accessors() {
81        assert_eq!(Scope::Global.kind(), ScopeKind::Global);
82        assert_eq!(Scope::Local(PathBuf::from("/tmp")).kind(), ScopeKind::Local);
83        assert!(Scope::Global.local_root().is_none());
84        assert_eq!(
85            Scope::Local(PathBuf::from("/project")).local_root(),
86            Some(Path::new("/project"))
87        );
88        assert_ne!(ScopeKind::Global, ScopeKind::Local);
89    }
90}