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}
22
23impl InstallHost {
24 pub fn as_db_value(self) -> &'static str {
28 match self {
29 InstallHost::ClaudeCode => "claude-code",
30 InstallHost::CodexCli => "codex-cli",
31 }
32 }
33
34 pub fn parse(s: &str) -> Result<Self> {
38 match s {
39 "claude-code" => Ok(InstallHost::ClaudeCode),
40 "codex-cli" => Ok(InstallHost::CodexCli),
41 other => Err(anyhow!(
42 "invalid host '{other}'; schema writes require --host claude-code or --host codex-cli"
43 )),
44 }
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct WorkspaceKey {
53 pub root_path: PathBuf,
54}
55
56impl WorkspaceKey {
57 pub fn from_cwd_and_toplevel(cwd: &Path, git_toplevel: Option<&Path>) -> Self {
61 let root_path = git_toplevel.unwrap_or(cwd).to_path_buf();
62 Self { root_path }
63 }
64
65 pub fn from_cwd(cwd: &Path) -> Self {
69 let toplevel = resolve_toplevel(cwd);
70 Self::from_cwd_and_toplevel(cwd, toplevel.as_deref())
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
77pub struct ProjectKey {
78 pub workspace: WorkspaceKey,
79 pub project_path: PathBuf,
80 pub project_key: String,
81}
82
83impl ProjectKey {
84 pub fn from_workspace(workspace: WorkspaceKey, project_label: Option<&str>) -> Self {
85 let project_path = workspace.root_path.clone();
86 let project_key = project_label
87 .map(str::to_owned)
88 .unwrap_or_else(|| project_path.to_string_lossy().into_owned());
89 Self {
90 workspace,
91 project_path,
92 project_key,
93 }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Hash)]
100pub struct SessionId(pub String);
101
102#[derive(Debug, Clone, PartialEq, Eq, Hash)]
106pub struct TurnId(pub String);
107
108#[derive(Debug, Clone, PartialEq, Eq, Hash)]
112pub struct EventId(pub String);
113
114impl EventId {
115 pub fn synthesize(turn: Option<&TurnId>, event_name: &str, tool_use_id: Option<&str>) -> Self {
119 let turn_part = turn.map(|t| t.0.as_str()).unwrap_or("no-turn");
120 let id = match tool_use_id {
121 Some(t) => format!("{turn_part}:{event_name}:{t}"),
122 None => format!("{turn_part}:{event_name}"),
123 };
124 EventId(id)
125 }
126}
127
128#[derive(Debug, Clone)]
132pub struct CaptureIdentity {
133 pub host: InstallHost,
134 pub workspace: WorkspaceKey,
135 pub project: ProjectKey,
136 pub session_id: SessionId,
137 pub turn_id: Option<TurnId>,
138 pub event_id: EventId,
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn install_host_round_trip() {
147 assert_eq!(
148 InstallHost::parse("claude-code").unwrap(),
149 InstallHost::ClaudeCode
150 );
151 assert_eq!(
152 InstallHost::parse("codex-cli").unwrap(),
153 InstallHost::CodexCli
154 );
155 assert_eq!(InstallHost::ClaudeCode.as_db_value(), "claude-code");
156 assert_eq!(InstallHost::CodexCli.as_db_value(), "codex-cli");
157 }
158
159 #[test]
160 fn install_host_rejects_unknown() {
161 let err = InstallHost::parse("unknown").unwrap_err().to_string();
162 assert!(err.contains("invalid host"));
163 assert!(InstallHost::parse("").is_err());
164 }
165
166 #[test]
167 fn workspace_prefers_git_toplevel_over_cwd() {
168 let cwd = Path::new("/repo/sub/dir");
169 let toplevel = Path::new("/repo");
170 let ws = WorkspaceKey::from_cwd_and_toplevel(cwd, Some(toplevel));
171 assert_eq!(ws.root_path, PathBuf::from("/repo"));
172 }
173
174 #[test]
175 fn workspace_falls_back_to_cwd_when_not_in_git() {
176 let cwd = Path::new("/tmp/scratch");
177 let ws = WorkspaceKey::from_cwd_and_toplevel(cwd, None);
178 assert_eq!(ws.root_path, PathBuf::from("/tmp/scratch"));
179 }
180
181 #[test]
182 fn project_defaults_to_workspace_root_path_string() {
183 let ws = WorkspaceKey::from_cwd_and_toplevel(Path::new("/repo"), None);
184 let project = ProjectKey::from_workspace(ws.clone(), None);
185 assert_eq!(project.project_path, PathBuf::from("/repo"));
186 assert_eq!(project.project_key, "/repo");
187 }
188
189 #[test]
190 fn project_uses_explicit_label_when_provided() {
191 let ws = WorkspaceKey::from_cwd_and_toplevel(Path::new("/repo"), None);
192 let project = ProjectKey::from_workspace(ws, Some("my-project"));
193 assert_eq!(project.project_key, "my-project");
194 }
195
196 #[test]
197 fn event_id_includes_tool_use_id_when_present() {
198 let turn = TurnId("t1".into());
199 let id = EventId::synthesize(Some(&turn), "PostToolUse", Some("tu_42"));
200 assert_eq!(id.0, "t1:PostToolUse:tu_42");
201 }
202
203 #[test]
204 fn event_id_omits_tool_use_id_for_turn_level_events() {
205 let turn = TurnId("t1".into());
206 let id = EventId::synthesize(Some(&turn), "UserPromptSubmit", None);
207 assert_eq!(id.0, "t1:UserPromptSubmit");
208 }
209
210 #[test]
211 fn event_id_uses_no_turn_marker_when_host_lacks_turn() {
212 let id = EventId::synthesize(None, "SessionStart", None);
213 assert_eq!(id.0, "no-turn:SessionStart");
214 }
215}