openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
use std::path::PathBuf;

use super::super::binding::AgentBinding;

const EVENT_TYPES: [&str; 9] = [
    "PreToolUse",
    "PostToolUse",
    "UserPromptSubmit",
    "Notification",
    "Stop",
    "SubagentStop",
    "PreCompact",
    "SessionStart",
    "SessionEnd",
];

pub struct ClaudeCodeBinding {
    pub claude_dir: PathBuf,
    pub settings_path: PathBuf,
}

impl ClaudeCodeBinding {
    /// Delegates to `hooks::claude_code`, which owns directory resolution for
    /// every caller. This used to re-implement it against `dirs::home_dir()`,
    /// and that duplication is why `$CLAUDE_CONFIG_DIR` was honoured on one
    /// path and ignored on another. Resolve here and the two drift again.
    pub fn detect() -> Option<Self> {
        let claude_dir = crate::hooks::claude_code::detect()?;
        let settings_path = crate::hooks::claude_code::settings_json_path(&claude_dir);
        Some(Self {
            claude_dir,
            settings_path,
        })
    }
}

impl AgentBinding for ClaudeCodeBinding {
    fn agent_type(&self) -> &str {
        "claude-code"
    }

    fn settings_path(&self) -> PathBuf {
        self.settings_path.clone()
    }

    fn hook_event_types(&self) -> &[&str] {
        &EVENT_TYPES
    }

    fn token_env_var(&self) -> &str {
        "OPENLATCH_TOKEN"
    }
}

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

    #[test]
    fn claude_code_agent_type() {
        let b = ClaudeCodeBinding {
            claude_dir: PathBuf::from("/home/test/.claude"),
            settings_path: PathBuf::from("/home/test/.claude/settings.json"),
        };
        assert_eq!(b.agent_type(), "claude-code");
    }

    #[test]
    fn claude_code_event_types_has_9_entries() {
        let b = ClaudeCodeBinding {
            claude_dir: PathBuf::from("/home/test/.claude"),
            settings_path: PathBuf::from("/home/test/.claude/settings.json"),
        };
        assert_eq!(b.hook_event_types().len(), 9);
        assert!(b.hook_event_types().contains(&"PreToolUse"));
        assert!(b.hook_event_types().contains(&"SessionEnd"));
    }

    /// `$CLAUDE_CONFIG_DIR` relocates the settings file this binding writes —
    /// the gate that keeps a test sandbox, and a user who has relocated their
    /// Claude config, from resolving to the real `~/.claude`. See
    /// `hooks::claude_code::detect` for why the seam has to be an env var.
    ///
    /// Runs under `daemon::identity`'s `ENV_LOCK` and `EnvGuard` rather than a
    /// private lock of its own: `CLAUDE_CONFIG_DIR` is already in that guard's
    /// `MANAGED` list, and a second lock over the same variable would not
    /// exclude the first — the two would interleave in this same test binary.
    /// `blocking_lock` is correct here because this test has no async runtime.
    #[test]
    fn claude_config_dir_relocates_the_settings_path() {
        let _lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
        let env = crate::daemon::identity::test_support::EnvGuard::clear();
        let dir = tempfile::tempdir().expect("temp dir");

        env.set("CLAUDE_CONFIG_DIR", dir.path());
        let b = ClaudeCodeBinding::detect().expect("relocated dir exists, so detect must succeed");
        assert_eq!(b.claude_dir, dir.path());
        assert_eq!(b.settings_path(), dir.path().join("settings.json"));

        // Empty is "unset" — otherwise an exported-but-blank variable would
        // resolve the config directory to "".
        env.set("CLAUDE_CONFIG_DIR", "");
        if let Some(b) = ClaudeCodeBinding::detect() {
            assert_ne!(b.claude_dir, std::path::Path::new(""));
        }
    }

    #[test]
    fn claude_code_binding_is_arc_dyn_compatible() {
        let b = ClaudeCodeBinding {
            claude_dir: PathBuf::from("/tmp/.claude"),
            settings_path: PathBuf::from("/tmp/.claude/settings.json"),
        };
        let _arc: Arc<dyn AgentBinding> = Arc::new(b);
    }
}