Skip to main content

ito_domain/audit/
context.rs

1//! Event context resolution: session ID, harness session ID, git context,
2//! and user identity.
3//!
4//! These helpers are intentionally placed in the domain layer because the
5//! `EventContext` type is defined here. The actual filesystem and process
6//! operations use `std` directly (no external dependencies beyond `uuid`
7//! which is available transitively).
8
9use std::path::Path;
10
11use super::event::EventContext;
12
13/// Git-related context fields.
14#[derive(Debug, Clone, Default)]
15pub struct GitContext {
16    /// Current branch name (None if detached HEAD or not in a git repo).
17    pub branch: Option<String>,
18    /// Worktree name if not the main worktree.
19    pub worktree: Option<String>,
20    /// Short HEAD commit hash.
21    pub commit: Option<String>,
22}
23
24/// Resolve the full `EventContext` for the current CLI invocation.
25///
26/// Combines session ID, harness session ID, git context, and user identity.
27/// All resolution is best-effort: failures result in `None` values, never errors.
28pub fn resolve_context(ito_path: &Path) -> EventContext {
29    let session_id = resolve_session_id(ito_path);
30    let harness_session_id = resolve_harness_session_id();
31    let git = resolve_git_context();
32
33    EventContext {
34        session_id,
35        harness_session_id,
36        branch: git.branch,
37        worktree: git.worktree,
38        commit: git.commit,
39    }
40}
41
42/// Resolve (or create) the session ID.
43///
44/// Reads from `{ito_path}/.state/audit/.session`. If the file doesn't exist,
45/// generates a new UUID v4 and writes it. The `.session` file is gitignored.
46pub fn resolve_session_id(ito_path: &Path) -> String {
47    let session_dir = ito_path.join(".state").join("audit");
48    let session_file = session_dir.join(".session");
49
50    // Try to read existing session ID
51    if let Ok(contents) = std::fs::read_to_string(&session_file) {
52        let contents = contents.trim().to_string();
53        if !contents.is_empty() {
54            return contents;
55        }
56    }
57
58    // Generate new session ID
59    let id = uuid::Uuid::new_v4().to_string();
60
61    // Best-effort write
62    let _ = std::fs::create_dir_all(&session_dir);
63    let _ = std::fs::write(&session_file, &id);
64
65    id
66}
67
68/// Check environment variables for a harness session ID.
69///
70/// Checks (in order): `ITO_HARNESS_SESSION_ID`, `CLAUDE_SESSION_ID`,
71/// `OPENCODE_SESSION_ID`, `CODEX_SESSION_ID`. Returns the first found.
72pub fn resolve_harness_session_id() -> Option<String> {
73    let env_vars = [
74        "ITO_HARNESS_SESSION_ID",
75        "CLAUDE_SESSION_ID",
76        "OPENCODE_SESSION_ID",
77        "CODEX_SESSION_ID",
78    ];
79
80    for var in env_vars {
81        if let Ok(val) = std::env::var(var)
82            && !val.is_empty()
83        {
84            return Some(val);
85        }
86    }
87
88    None
89}
90
91/// Resolve git context (branch, worktree, commit) from the current directory.
92///
93/// All fields are best-effort: if git is not available or the directory is
94/// not a git repo, fields are `None`.
95pub fn resolve_git_context() -> GitContext {
96    let branch = run_git_command(&["symbolic-ref", "--short", "HEAD"]);
97    let commit = run_git_command(&["rev-parse", "--short=8", "HEAD"]);
98
99    // Detect worktree: compare git-dir with common-dir
100    let worktree = detect_worktree_name();
101
102    GitContext {
103        branch,
104        worktree,
105        commit,
106    }
107}
108
109/// Resolve the user identity for the `by` field.
110///
111/// Uses `git config user.name`, falling back to `$USER`, formatted as
112/// `@lowercase-hyphenated`.
113pub fn resolve_user_identity() -> String {
114    let name = run_git_command(&["config", "user.name"])
115        .or_else(|| std::env::var("USER").ok())
116        .unwrap_or_else(|| "unknown".to_string());
117
118    format!("@{}", name.to_lowercase().replace(' ', "-"))
119}
120
121/// Run a git command and return its stdout as a trimmed string, or None on failure.
122fn run_git_command(args: &[&str]) -> Option<String> {
123    let output = std::process::Command::new("git").args(args).output().ok()?;
124
125    if !output.status.success() {
126        return None;
127    }
128
129    let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
130    if s.is_empty() { None } else { Some(s) }
131}
132
133/// Detect if we're in a worktree (not the main worktree) and return its name.
134fn detect_worktree_name() -> Option<String> {
135    let git_dir = run_git_command(&["rev-parse", "--git-dir"])?;
136    let common_dir = run_git_command(&["rev-parse", "--git-common-dir"])?;
137
138    // If git-dir != common-dir, we're in a worktree
139    let git_dir_path = std::path::Path::new(&git_dir);
140    let common_dir_path = std::path::Path::new(&common_dir);
141
142    if git_dir_path.canonicalize().ok()? != common_dir_path.canonicalize().ok()? {
143        // Extract worktree name from the path
144        let toplevel = run_git_command(&["rev-parse", "--show-toplevel"])?;
145        let path = std::path::Path::new(&toplevel);
146        Some(path.file_name()?.to_string_lossy().to_string())
147    } else {
148        None
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn resolve_user_identity_returns_at_prefixed_string() {
158        let identity = resolve_user_identity();
159        assert!(identity.starts_with('@'));
160        assert!(!identity.contains(' '));
161    }
162
163    #[test]
164    fn resolve_harness_session_id_returns_none_without_env() {
165        // In test environment, these env vars are typically not set
166        // We can't guarantee they're unset, so just test the function doesn't panic
167        let _result = resolve_harness_session_id();
168    }
169
170    #[test]
171    fn resolve_git_context_does_not_panic() {
172        // This test verifies the function is safe to call in any environment
173        let ctx = resolve_git_context();
174        // In a git repo, branch should be Some; but we don't enforce it
175        // since CI might have detached HEAD
176        let _ = ctx;
177    }
178
179    #[test]
180    fn resolve_session_id_generates_uuid() {
181        let tmp = tempfile::tempdir().expect("tempdir");
182        let ito_path = tmp.path().join(".ito");
183        std::fs::create_dir_all(&ito_path).expect("create ito dir");
184
185        let id = resolve_session_id(&ito_path);
186        assert!(!id.is_empty());
187        // Should be a valid UUID v4 format (36 chars with hyphens)
188        assert_eq!(id.len(), 36);
189        assert!(id.contains('-'));
190    }
191
192    #[test]
193    fn resolve_session_id_is_stable_across_calls() {
194        let tmp = tempfile::tempdir().expect("tempdir");
195        let ito_path = tmp.path().join(".ito");
196        std::fs::create_dir_all(&ito_path).expect("create ito dir");
197
198        let id1 = resolve_session_id(&ito_path);
199        let id2 = resolve_session_id(&ito_path);
200        assert_eq!(id1, id2);
201    }
202
203    #[test]
204    fn resolve_context_populates_session_id() {
205        let tmp = tempfile::tempdir().expect("tempdir");
206        let ito_path = tmp.path().join(".ito");
207        std::fs::create_dir_all(&ito_path).expect("create ito dir");
208
209        let ctx = resolve_context(&ito_path);
210        assert!(!ctx.session_id.is_empty());
211    }
212}