openlatch-client 0.6.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Cursor path resolution and detection.
//!
//! The sibling of [`crate::hooks::codex_cli`], and deliberately the same shape:
//! this module owns the Cursor root for **every** caller — the binding, the
//! config-monitor manifest and the isolation fixtures. Nothing else in the crate
//! computes `~/.cursor`.
//!
//! Cursor has no upstream variable that relocates its config directory, so the
//! seam is ours: [`CONFIG_DIR_ENV`]. It exists before detection is armed, or
//! the first test that installs "every detected agent" writes the real
//! `~/.cursor` of the machine running it.

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

/// Relocates Cursor's configuration root (`~/.cursor`).
///
/// OpenLatch's own seam, not Cursor's: `olbox` exports it so a sandboxed
/// instance never reads or writes the developer's real `~/.cursor`, and a
/// Cursor launched in that sandbox is pointed at the same root through its
/// sandboxed `HOME`.
///
/// **Its lock is `cline::SEAM_ENV_LOCK`, not one of its own.** The shared absent-agent
/// fixture ([`crate::hooks::cline::absent_seams`]) writes this variable beside the three
/// Cline seams under that lock, so a second lock guarding the same variable would let a
/// resolver test and an absent-seam fixture write it at once.
pub const CONFIG_DIR_ENV: &str = "OPENLATCH_CURSOR_DIR";

/// The root's name under the home directory, when no seam relocates it.
const DEFAULT_DIR_NAME: &str = ".cursor";

/// `$OPENLATCH_CURSOR_DIR` when set to something non-empty.
///
/// An empty value reads as unset, as `CODEX_HOME` does: an exported-but-blank
/// variable would otherwise resolve every path below it against the process
/// cwd.
fn relocated_dir() -> Option<PathBuf> {
    std::env::var_os(CONFIG_DIR_ENV)
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
}

/// The machine's own Cursor root, `~/.cursor`, whatever the seam says.
fn default_root() -> Option<PathBuf> {
    dirs::home_dir().map(|home| home.join(DEFAULT_DIR_NAME))
}

/// THE Cursor root, whether or not it exists: `$OPENLATCH_CURSOR_DIR` when set
/// and non-empty, else `~/.cursor` (`%USERPROFILE%\.cursor` on Windows).
///
/// Split from [`detect`] for the reason its Codex counterpart is: `detect`
/// answers "is Cursor installed", while the config-monitor manifest needs the
/// path regardless.
pub fn root() -> Option<PathBuf> {
    relocated_dir().or_else(default_root)
}

/// The file Cursor reads user-level hook registrations from.
pub fn hooks_json_path(root: &Path) -> PathBuf {
    root.join("hooks.json")
}

/// Is the Cursor root this process would write the **machine-global** one?
///
/// The same contract as [`crate::hooks::codex_cli::config_is_machine_global`]:
/// paths are canonicalized, so a seam pointed at the real `~/.cursor` through a
/// symlink or a trailing slash still reads as machine-global, and anything we
/// cannot resolve answers `true` — declining to write is the safe direction.
pub fn config_is_machine_global() -> bool {
    let (Some(resolved), Some(default)) = (root(), default_root()) else {
        return true;
    };
    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
    canonical(&resolved) == canonical(&default)
}

/// A Cursor seam pointed at a path under `root` that is **never created** —
/// Cursor absent, for a fixture that must not see the developer's own.
///
/// Folded into [`crate::hooks::cline::absent_seams`], which is the one
/// definition every fixture applies, so a fixture isolating Cline isolates
/// Cursor by the same line. Plain `pub` for the reason that helper is: an
/// integration target under `tests/` cannot see a `#[cfg(test)]` item, and
/// `pub mod hooks` is behind `full-cli`, so the lean hook never links it.
pub fn absent_seam(root: &Path) -> (&'static str, PathBuf) {
    (CONFIG_DIR_ENV, root.join("absent-cursor"))
}

/// Detect whether Cursor is installed.
///
/// Returns the root when the IDE **or** the CLI is installed. A leftover
/// `~/.cursor` alone is not a detection: it outlives an uninstalled Cursor, and
/// wiring hooks for an agent that is gone is a file nobody asked for.
///
/// Under the isolation seam the root existing counts instead: a sandbox has no
/// application of its own, and the seam is the operator saying where Cursor
/// lives.
pub fn detect() -> Option<PathBuf> {
    detect_in(
        relocated_dir(),
        dirs::home_dir().as_deref(),
        std::env::var_os("PATH").as_deref(),
    )
}

/// [`detect`] with its inputs as parameters, so a test can hand it a temporary
/// home and `PATH` without redirecting either for the whole test binary.
fn detect_in(
    relocated: Option<PathBuf>,
    home: Option<&Path>,
    path_var: Option<&std::ffi::OsStr>,
) -> Option<PathBuf> {
    if let Some(root) = relocated {
        return root.is_dir().then_some(root);
    }
    let home = home?;
    (ide_installed(home, path_var) || cli_installed(home, path_var))
        .then(|| home.join(DEFAULT_DIR_NAME))
}

/// Is the Cursor IDE installed?
///
/// Every probe is a `Path::exists` or a PATH walk. **Never run a Cursor binary
/// here**: on macOS the application binary launches the full GUI, with the
/// caller's `HOME`, even for `--version`.
fn ide_installed(home: &Path, path_var: Option<&std::ffi::OsStr>) -> bool {
    let mut candidates: Vec<PathBuf> = Vec::new();
    if cfg!(target_os = "macos") {
        candidates.push(PathBuf::from("/Applications").join("Cursor.app")); // portability-ok: macOS bundle root, reached only when cfg!(target_os = "macos")
        candidates.push(home.join("Applications").join("Cursor.app"));
    }
    if cfg!(windows) {
        if let Some(local) = dirs::data_local_dir() {
            candidates.push(local.join("Programs").join("cursor").join("Cursor.exe"));
        }
        if let Some(program_files) = std::env::var_os("ProgramFiles") {
            candidates.push(
                PathBuf::from(program_files)
                    .join("cursor")
                    .join("Cursor.exe"),
            );
        }
    }
    if cfg!(target_os = "linux") {
        candidates.push(PathBuf::from("/usr/share/cursor")); // portability-ok: Linux package root, reached only when cfg!(target_os = "linux")
        candidates.push(PathBuf::from("/opt/Cursor")); // portability-ok: Linux AppImage root, reached only when cfg!(target_os = "linux")
        candidates.push(PathBuf::from("/usr/share/applications").join("cursor.desktop")); // portability-ok: Linux desktop entry, reached only when cfg!(target_os = "linux")
        candidates.push(
            home.join(".local")
                .join("share")
                .join("applications")
                .join("cursor.desktop"),
        );
    }
    candidates.iter().any(|path| path.exists())
        || (cfg!(target_os = "linux") && on_path(path_var, "cursor"))
}

/// Is the Cursor CLI (`cursor-agent`) installed?
///
/// Found by its own name or its install directory — **never by the bare name
/// `agent`**, which the CLI also installs and which is generic enough to
/// belong to any other tool on the host.
fn cli_installed(home: &Path, path_var: Option<&std::ffi::OsStr>) -> bool {
    if on_path(path_var, "cursor-agent") {
        return true;
    }
    let mut candidates = vec![home.join(".local").join("share").join("cursor-agent")];
    if cfg!(windows) {
        if let Some(local) = dirs::data_local_dir() {
            candidates.push(local.join("cursor-agent"));
        }
    }
    candidates.iter().any(|path| path.exists())
}

/// An executable named `name` on an **absolute** entry of `path_var`.
///
/// A relative entry is skipped for the reason `identity::git_email` skips it:
/// it would resolve against whatever cwd the process has. On Windows the
/// launcher shims carry an extension, so each of the usual ones is tried.
fn on_path(path_var: Option<&std::ffi::OsStr>, name: &str) -> bool {
    let Some(path_var) = path_var else {
        return false;
    };
    let names: Vec<String> = if cfg!(windows) {
        ["exe", "cmd", "ps1"]
            .iter()
            .map(|ext| format!("{name}.{ext}"))
            .collect()
    } else {
        vec![name.to_string()]
    };
    std::env::split_paths(path_var)
        .filter(|dir| dir.is_absolute())
        .any(|dir| names.iter().any(|n| dir.join(n).is_file()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::hooks::cline::EnvOverride;

    /// Every test here writes [`CONFIG_DIR_ENV`], and that variable's lock is
    /// the Cline seam lock (see its doc).
    fn seam_lock() -> std::sync::MutexGuard<'static, ()> {
        crate::hooks::cline::SEAM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner())
    }

    #[test]
    fn cursor_root_honours_the_seam() {
        let _lock = seam_lock();
        let dir = tempfile::tempdir().expect("temp dir");
        let _env = EnvOverride::apply([(CONFIG_DIR_ENV, Some(dir.path().as_os_str().to_owned()))]);

        assert_eq!(root().as_deref(), Some(dir.path()));
        assert_eq!(
            hooks_json_path(&root().expect("root")),
            dir.path().join("hooks.json")
        );
        assert!(
            !config_is_machine_global(),
            "a relocated root is not the machine's own"
        );
        assert_eq!(
            detect().as_deref(),
            Some(dir.path()),
            "under the seam, the root existing counts"
        );
    }

    #[test]
    fn cursor_root_empty_seam_is_unset() {
        let _lock = seam_lock();
        let _env = EnvOverride::apply([(CONFIG_DIR_ENV, Some(std::ffi::OsString::new()))]);

        assert_eq!(relocated_dir(), None, "an empty seam reads as unset");
        assert_eq!(root(), default_root());
        assert!(
            config_is_machine_global(),
            "an empty seam leaves the machine's own root"
        );
    }

    #[test]
    fn an_absent_seam_is_not_a_detection() {
        let _lock = seam_lock();
        let dir = tempfile::tempdir().expect("temp dir");
        let (key, path) = absent_seam(dir.path());
        let _env = EnvOverride::apply([(key, Some(path.clone().into_os_string()))]);

        assert!(
            !path.exists(),
            "the premise: the absent seam names a path it never creates"
        );
        assert_eq!(detect(), None);
    }

    /// D-02, on a temporary home with no application and no CLI in reach: a
    /// `~/.cursor` left behind by an uninstalled Cursor does not arm detection.
    ///
    /// The IDE probe also reads `/Applications` on macOS, which a test cannot
    /// redirect; on a host that really has Cursor installed the premise does not
    /// hold, and the case asserts what detection must then answer instead of
    /// skipping silently.
    #[test]
    fn leftover_dot_cursor_alone_is_not_a_detection() {
        let home = tempfile::tempdir().expect("temp home");
        std::fs::create_dir_all(home.path().join(".cursor")).expect("leftover root");
        let empty = tempfile::tempdir().expect("empty PATH dir");
        let path_var = empty.path().as_os_str();
        let expected = home.path().join(".cursor");

        if ide_installed(home.path(), Some(path_var)) {
            assert_eq!(
                detect_in(None, Some(home.path()), Some(path_var)),
                Some(expected)
            );
            return;
        }
        assert!(!cli_installed(home.path(), Some(path_var)));
        assert_eq!(
            detect_in(None, Some(home.path()), Some(path_var)),
            None,
            "a leftover ~/.cursor alone is not an install"
        );

        // The CLI's install directory alone IS one.
        std::fs::create_dir_all(
            home.path()
                .join(".local")
                .join("share")
                .join("cursor-agent"),
        )
        .expect("cli dir");
        assert_eq!(
            detect_in(None, Some(home.path()), Some(path_var)),
            Some(expected)
        );
    }

    #[test]
    fn the_generic_agent_name_is_not_the_cli() {
        let home = tempfile::tempdir().expect("temp home");
        let bin = tempfile::tempdir().expect("PATH dir");
        let path_var = bin.path().as_os_str();
        std::fs::write(bin.path().join("agent"), "").expect("a generic `agent`");
        assert!(
            !cli_installed(home.path(), Some(path_var)),
            "`agent` alone must never read as Cursor's CLI"
        );

        let name = if cfg!(windows) {
            "cursor-agent.exe"
        } else {
            "cursor-agent"
        };
        std::fs::write(bin.path().join(name), "").expect("cursor-agent");
        assert!(cli_installed(home.path(), Some(path_var)));
    }
}