1use anyhow::{anyhow, Result};
10use std::path::{Path, PathBuf};
11
12use crate::git_util::resolve_toplevel;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum InstallHost {
19 ClaudeCode,
20 CodexCli,
21 Cursor,
22}
23
24impl InstallHost {
25 pub fn as_db_value(self) -> &'static str {
29 match self {
30 InstallHost::ClaudeCode => "claude-code",
31 InstallHost::CodexCli => "codex-cli",
32 InstallHost::Cursor => "cursor",
33 }
34 }
35
36 pub fn parse(s: &str) -> Result<Self> {
42 match s {
43 "claude-code" => Ok(InstallHost::ClaudeCode),
44 "codex-cli" => Ok(InstallHost::CodexCli),
45 "cursor" => Ok(InstallHost::Cursor),
46 other => Err(anyhow!(
47 "invalid host '{other}'; hook host must be one of claude-code, codex-cli, cursor"
48 )),
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57pub struct WorkspaceKey {
58 pub root_path: PathBuf,
59}
60
61impl WorkspaceKey {
62 pub fn from_cwd_and_toplevel(cwd: &Path, git_toplevel: Option<&Path>) -> Self {
66 let root_path = git_toplevel.unwrap_or(cwd).to_path_buf();
67 Self { root_path }
68 }
69
70 pub fn from_cwd(cwd: &Path) -> Self {
74 let toplevel = resolve_toplevel(cwd);
75 Self::from_cwd_and_toplevel(cwd, toplevel.as_deref())
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Hash)]
82pub struct ProjectKey {
83 pub workspace: WorkspaceKey,
84 pub project_path: PathBuf,
85 pub project_key: String,
86}
87
88impl ProjectKey {
89 pub fn from_workspace(workspace: WorkspaceKey, project_label: Option<&str>) -> Self {
90 let project_path = workspace.root_path.clone();
91 let project_key = project_label
92 .map(str::to_owned)
93 .unwrap_or_else(|| project_path.to_string_lossy().into_owned());
94 Self {
95 workspace,
96 project_path,
97 project_key,
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Hash)]
105pub struct SessionId(pub String);
106
107#[derive(Debug, Clone, PartialEq, Eq, Hash)]
111pub struct TurnId(pub String);
112
113#[derive(Debug, Clone, PartialEq, Eq, Hash)]
117pub struct EventId(pub String);
118
119impl EventId {
120 pub fn synthesize(turn: Option<&TurnId>, event_name: &str, tool_use_id: Option<&str>) -> Self {
124 let turn_part = turn.map(|t| t.0.as_str()).unwrap_or("no-turn");
125 let id = match tool_use_id {
126 Some(t) => format!("{turn_part}:{event_name}:{t}"),
127 None => format!("{turn_part}:{event_name}"),
128 };
129 EventId(id)
130 }
131}
132
133#[derive(Debug, Clone)]
137pub struct CaptureIdentity {
138 pub host: InstallHost,
139 pub workspace: WorkspaceKey,
140 pub project: ProjectKey,
141 pub session_id: SessionId,
142 pub turn_id: Option<TurnId>,
143 pub event_id: EventId,
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn install_host_round_trip() {
152 assert_eq!(
153 InstallHost::parse("claude-code").unwrap(),
154 InstallHost::ClaudeCode
155 );
156 assert_eq!(
157 InstallHost::parse("codex-cli").unwrap(),
158 InstallHost::CodexCli
159 );
160 assert_eq!(InstallHost::parse("cursor").unwrap(), InstallHost::Cursor);
161 assert_eq!(InstallHost::ClaudeCode.as_db_value(), "claude-code");
162 assert_eq!(InstallHost::CodexCli.as_db_value(), "codex-cli");
163 assert_eq!(InstallHost::Cursor.as_db_value(), "cursor");
164 }
165
166 #[test]
167 fn install_host_rejects_unknown() {
168 let err = InstallHost::parse("unknown").unwrap_err().to_string();
169 assert!(err.contains("invalid host"));
170 assert!(err.contains("claude-code, codex-cli, cursor"));
171 assert!(InstallHost::parse("").is_err());
172 }
173
174 #[test]
175 fn install_host_rejects_aliases_and_arbitrary_values() {
176 for value in [
177 "claude",
178 "codex",
179 "Cursor",
180 "cursor-ide",
181 "CURSOR",
182 " cursor",
183 "cursor ",
184 "claudecode",
185 "codexcli",
186 ] {
187 assert!(
188 InstallHost::parse(value).is_err(),
189 "alias '{value}' must fail the closed-set hook-host parser"
190 );
191 }
192 }
193
194 #[test]
195 fn workspace_prefers_git_toplevel_over_cwd() {
196 let cwd = Path::new("/repo/sub/dir");
197 let toplevel = Path::new("/repo");
198 let ws = WorkspaceKey::from_cwd_and_toplevel(cwd, Some(toplevel));
199 assert_eq!(ws.root_path, PathBuf::from("/repo"));
200 }
201
202 #[test]
203 fn workspace_falls_back_to_cwd_when_not_in_git() {
204 let cwd = Path::new("/tmp/scratch");
205 let ws = WorkspaceKey::from_cwd_and_toplevel(cwd, None);
206 assert_eq!(ws.root_path, PathBuf::from("/tmp/scratch"));
207 }
208
209 #[test]
210 fn project_defaults_to_workspace_root_path_string() {
211 let ws = WorkspaceKey::from_cwd_and_toplevel(Path::new("/repo"), None);
212 let project = ProjectKey::from_workspace(ws.clone(), None);
213 assert_eq!(project.project_path, PathBuf::from("/repo"));
214 assert_eq!(project.project_key, "/repo");
215 }
216
217 #[test]
218 fn project_uses_explicit_label_when_provided() {
219 let ws = WorkspaceKey::from_cwd_and_toplevel(Path::new("/repo"), None);
220 let project = ProjectKey::from_workspace(ws, Some("my-project"));
221 assert_eq!(project.project_key, "my-project");
222 }
223
224 #[test]
225 fn event_id_includes_tool_use_id_when_present() {
226 let turn = TurnId("t1".into());
227 let id = EventId::synthesize(Some(&turn), "PostToolUse", Some("tu_42"));
228 assert_eq!(id.0, "t1:PostToolUse:tu_42");
229 }
230
231 #[test]
232 fn event_id_omits_tool_use_id_for_turn_level_events() {
233 let turn = TurnId("t1".into());
234 let id = EventId::synthesize(Some(&turn), "UserPromptSubmit", None);
235 assert_eq!(id.0, "t1:UserPromptSubmit");
236 }
237
238 #[test]
239 fn event_id_uses_no_turn_marker_when_host_lacks_turn() {
240 let id = EventId::synthesize(None, "SessionStart", None);
241 assert_eq!(id.0, "no-turn:SessionStart");
242 }
243}