use anyhow::{anyhow, Result};
use std::path::{Path, PathBuf};
use crate::git_util::resolve_toplevel;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InstallHost {
ClaudeCode,
CodexCli,
Cursor,
}
impl InstallHost {
pub fn as_db_value(self) -> &'static str {
match self {
InstallHost::ClaudeCode => "claude-code",
InstallHost::CodexCli => "codex-cli",
InstallHost::Cursor => "cursor",
}
}
pub fn parse(s: &str) -> Result<Self> {
match s {
"claude-code" => Ok(InstallHost::ClaudeCode),
"codex-cli" => Ok(InstallHost::CodexCli),
"cursor" => Ok(InstallHost::Cursor),
other => Err(anyhow!(
"invalid host '{other}'; hook host must be one of claude-code, codex-cli, cursor"
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct WorkspaceKey {
pub root_path: PathBuf,
}
impl WorkspaceKey {
pub fn from_cwd_and_toplevel(cwd: &Path, git_toplevel: Option<&Path>) -> Self {
let root_path = git_toplevel.unwrap_or(cwd).to_path_buf();
Self { root_path }
}
pub fn from_cwd(cwd: &Path) -> Self {
let toplevel = resolve_toplevel(cwd);
Self::from_cwd_and_toplevel(cwd, toplevel.as_deref())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProjectKey {
pub workspace: WorkspaceKey,
pub project_path: PathBuf,
pub project_key: String,
}
impl ProjectKey {
pub fn from_workspace(workspace: WorkspaceKey, project_label: Option<&str>) -> Self {
let project_path = workspace.root_path.clone();
let project_key = project_label
.map(str::to_owned)
.unwrap_or_else(|| project_path.to_string_lossy().into_owned());
Self {
workspace,
project_path,
project_key,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SessionId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TurnId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EventId(pub String);
impl EventId {
pub fn synthesize(turn: Option<&TurnId>, event_name: &str, tool_use_id: Option<&str>) -> Self {
let turn_part = turn.map(|t| t.0.as_str()).unwrap_or("no-turn");
let id = match tool_use_id {
Some(t) => format!("{turn_part}:{event_name}:{t}"),
None => format!("{turn_part}:{event_name}"),
};
EventId(id)
}
}
#[derive(Debug, Clone)]
pub struct CaptureIdentity {
pub host: InstallHost,
pub workspace: WorkspaceKey,
pub project: ProjectKey,
pub session_id: SessionId,
pub turn_id: Option<TurnId>,
pub event_id: EventId,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_host_round_trip() {
assert_eq!(
InstallHost::parse("claude-code").unwrap(),
InstallHost::ClaudeCode
);
assert_eq!(
InstallHost::parse("codex-cli").unwrap(),
InstallHost::CodexCli
);
assert_eq!(InstallHost::parse("cursor").unwrap(), InstallHost::Cursor);
assert_eq!(InstallHost::ClaudeCode.as_db_value(), "claude-code");
assert_eq!(InstallHost::CodexCli.as_db_value(), "codex-cli");
assert_eq!(InstallHost::Cursor.as_db_value(), "cursor");
}
#[test]
fn install_host_rejects_unknown() {
let err = InstallHost::parse("unknown").unwrap_err().to_string();
assert!(err.contains("invalid host"));
assert!(err.contains("claude-code, codex-cli, cursor"));
assert!(InstallHost::parse("").is_err());
}
#[test]
fn install_host_rejects_aliases_and_arbitrary_values() {
for value in [
"claude",
"codex",
"Cursor",
"cursor-ide",
"CURSOR",
" cursor",
"cursor ",
"claudecode",
"codexcli",
] {
assert!(
InstallHost::parse(value).is_err(),
"alias '{value}' must fail the closed-set hook-host parser"
);
}
}
#[test]
fn workspace_prefers_git_toplevel_over_cwd() {
let cwd = Path::new("/repo/sub/dir");
let toplevel = Path::new("/repo");
let ws = WorkspaceKey::from_cwd_and_toplevel(cwd, Some(toplevel));
assert_eq!(ws.root_path, PathBuf::from("/repo"));
}
#[test]
fn workspace_falls_back_to_cwd_when_not_in_git() {
let cwd = Path::new("/tmp/scratch");
let ws = WorkspaceKey::from_cwd_and_toplevel(cwd, None);
assert_eq!(ws.root_path, PathBuf::from("/tmp/scratch"));
}
#[test]
fn project_defaults_to_workspace_root_path_string() {
let ws = WorkspaceKey::from_cwd_and_toplevel(Path::new("/repo"), None);
let project = ProjectKey::from_workspace(ws.clone(), None);
assert_eq!(project.project_path, PathBuf::from("/repo"));
assert_eq!(project.project_key, "/repo");
}
#[test]
fn project_uses_explicit_label_when_provided() {
let ws = WorkspaceKey::from_cwd_and_toplevel(Path::new("/repo"), None);
let project = ProjectKey::from_workspace(ws, Some("my-project"));
assert_eq!(project.project_key, "my-project");
}
#[test]
fn event_id_includes_tool_use_id_when_present() {
let turn = TurnId("t1".into());
let id = EventId::synthesize(Some(&turn), "PostToolUse", Some("tu_42"));
assert_eq!(id.0, "t1:PostToolUse:tu_42");
}
#[test]
fn event_id_omits_tool_use_id_for_turn_level_events() {
let turn = TurnId("t1".into());
let id = EventId::synthesize(Some(&turn), "UserPromptSubmit", None);
assert_eq!(id.0, "t1:UserPromptSubmit");
}
#[test]
fn event_id_uses_no_turn_marker_when_host_lacks_turn() {
let id = EventId::synthesize(None, "SessionStart", None);
assert_eq!(id.0, "no-turn:SessionStart");
}
}