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}
22
23impl InstallHost {
24    /// String written into `hosts.name` and matched by capture / extraction
25    /// queries. Kept separate from any context/env representations so that a
26    /// rename in one layer does not silently widen identity.
27    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    /// Parse from the `--host` CLI argument. Any other value is an
35    /// install error and must be refused at the boundary. `unknown` is
36    /// explicitly rejected here, in contrast to `context::host::HostKind`.
37    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/// Workspace identity synthesized from cwd + `git rev-parse
49/// --show-toplevel`, falling back to cwd when the directory is not a git
50/// worktree.
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct WorkspaceKey {
53    pub root_path: PathBuf,
54}
55
56impl WorkspaceKey {
57    /// Resolve the workspace root for a capture event. Pure: callers pass in
58    /// the cwd and the resolved git toplevel (if any). The caller is
59    /// responsible for invoking git; this keeps the function unit-testable.
60    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    /// Convenience wrapper that resolves the git toplevel for `cwd` via the
66    /// `git` binary, falling back to `cwd` when the directory is outside any
67    /// git worktree or git is unavailable. Spawns one subprocess.
68    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/// Project identity within a workspace. Defaults to the workspace root, but
75/// may be narrowed via an explicit `--project` label or sub-directory.
76#[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/// Session id, supplied by the host payload (Claude Code & Codex both expose
98/// it). Wrapped to avoid mixing it with `event_id` and `turn_id` strings.
99#[derive(Debug, Clone, PartialEq, Eq, Hash)]
100pub struct SessionId(pub String);
101
102/// Turn id. Codex provides it on every turn-scoped hook; Claude Code does not,
103/// so remem synthesizes a per-(host, session) monotonic counter (handled in a
104/// later Milestone A step against the `sessions` table).
105#[derive(Debug, Clone, PartialEq, Eq, Hash)]
106pub struct TurnId(pub String);
107
108/// Event id. Always remem-synthesized: neither Claude Code nor Codex exposes
109/// a stable per-event id. The composition rule keeps it deterministic so that
110/// duplicate hook invocations coalesce on `UNIQUE(host_id, session_id, event_id)`.
111#[derive(Debug, Clone, PartialEq, Eq, Hash)]
112pub struct EventId(pub String);
113
114impl EventId {
115    /// `event_id = "<turn>:<event_name>" + optional ":<tool_use_id>"`.
116    /// `turn` falls back to the literal `no-turn` when the host does not
117    /// expose a turn id (Claude Code outside a single user turn).
118    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/// Full six-tuple captured at hook entry. The capture path passes this
129/// straight into `captured_events` after resolving foreign keys for host /
130/// workspace / project / session.
131#[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}