Skip to main content

remem/
identity.rs

1//! Typed identity for capture, extraction, and memory rows.
2//!
3//! The identity tuple
4//! `(host, workspace, project, session_id, turn_id, event_id)` is a mix of
5//! host-supplied and remem-synthesized fields. This module owns the synthesis
6//! rules and the typed wrappers; nothing else should construct these values
7//! from raw strings.
8
9use anyhow::{anyhow, Result};
10use std::path::{Path, PathBuf};
11
12use crate::git_util::resolve_toplevel;
13
14/// Install-time host. Distinct from `context::host::HostKind` (which
15/// allows `Unknown` for detection): schema writes forbid `unknown`, and the
16/// value is always sourced from the install-baked `--host` argument.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum InstallHost {
19    ClaudeCode,
20    CodexCli,
21    Cursor,
22}
23
24impl InstallHost {
25    /// String written into `hosts.name` and matched by capture / extraction
26    /// queries. Kept separate from any context/env representations so that a
27    /// rename in one layer does not silently widen identity.
28    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    /// Parse from the `--host` CLI argument. This is the shared exact
37    /// hook-host parser (GH-823 B-001): the recognized closed set is exactly
38    /// `claude-code`, `codex-cli`, and `cursor`. Aliases, misspellings, empty
39    /// strings, `unknown`, and arbitrary values are refused at the boundary,
40    /// in contrast to `context::host::HostKind` detection.
41    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/// Workspace identity synthesized from cwd + `git rev-parse
54/// --show-toplevel`, falling back to cwd when the directory is not a git
55/// worktree.
56#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57pub struct WorkspaceKey {
58    pub root_path: PathBuf,
59}
60
61impl WorkspaceKey {
62    /// Resolve the workspace root for a capture event. Pure: callers pass in
63    /// the cwd and the resolved git toplevel (if any). The caller is
64    /// responsible for invoking git; this keeps the function unit-testable.
65    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    /// Convenience wrapper that resolves the git toplevel for `cwd` via the
71    /// `git` binary, falling back to `cwd` when the directory is outside any
72    /// git worktree or git is unavailable. Spawns one subprocess.
73    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/// Project identity within a workspace. Defaults to the workspace root, but
80/// may be narrowed via an explicit `--project` label or sub-directory.
81#[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/// Session id, supplied by the host payload (Claude Code & Codex both expose
103/// it). Wrapped to avoid mixing it with `event_id` and `turn_id` strings.
104#[derive(Debug, Clone, PartialEq, Eq, Hash)]
105pub struct SessionId(pub String);
106
107/// Turn id. Codex provides it on every turn-scoped hook; Claude Code does not,
108/// so remem synthesizes a per-(host, session) monotonic counter (handled in a
109/// later Milestone A step against the `sessions` table).
110#[derive(Debug, Clone, PartialEq, Eq, Hash)]
111pub struct TurnId(pub String);
112
113/// Event id. Always remem-synthesized: neither Claude Code nor Codex exposes
114/// a stable per-event id. The composition rule keeps it deterministic so that
115/// duplicate hook invocations coalesce on `UNIQUE(host_id, session_id, event_id)`.
116#[derive(Debug, Clone, PartialEq, Eq, Hash)]
117pub struct EventId(pub String);
118
119impl EventId {
120    /// `event_id = "<turn>:<event_name>" + optional ":<tool_use_id>"`.
121    /// `turn` falls back to the literal `no-turn` when the host does not
122    /// expose a turn id (Claude Code outside a single user turn).
123    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/// Full six-tuple captured at hook entry. The capture path passes this
134/// straight into `captured_events` after resolving foreign keys for host /
135/// workspace / project / session.
136#[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}