ito-domain 0.1.32

Domain models and repositories for Ito
Documentation
//! Event context resolution: session ID, harness session ID, git context,
//! and user identity.
//!
//! These helpers are intentionally placed in the domain layer because the
//! `EventContext` type is defined here. The actual filesystem and process
//! operations use `std` directly (no external dependencies beyond `uuid`
//! which is available transitively).

use std::path::Path;

use super::event::EventContext;

/// Git-related context fields.
#[derive(Debug, Clone, Default)]
pub struct GitContext {
    /// Current branch name (None if detached HEAD or not in a git repo).
    pub branch: Option<String>,
    /// Worktree name if not the main worktree.
    pub worktree: Option<String>,
    /// Short HEAD commit hash.
    pub commit: Option<String>,
}

/// Resolve the full `EventContext` for the current CLI invocation.
///
/// Combines session ID, harness session ID, git context, and user identity.
/// All resolution is best-effort: failures result in `None` values, never errors.
pub fn resolve_context(ito_path: &Path) -> EventContext {
    let session_id = resolve_session_id(ito_path);
    let harness_session_id = resolve_harness_session_id();
    let git = resolve_git_context();

    EventContext {
        session_id,
        harness_session_id,
        branch: git.branch,
        worktree: git.worktree,
        commit: git.commit,
    }
}

/// Resolve (or create) the session ID.
///
/// Reads from `{ito_path}/.state/audit/.session`. If the file doesn't exist,
/// generates a new UUID v4 and writes it. The `.session` file is gitignored.
pub fn resolve_session_id(ito_path: &Path) -> String {
    let session_dir = ito_path.join(".state").join("audit");
    let session_file = session_dir.join(".session");

    // Try to read existing session ID
    if let Ok(contents) = std::fs::read_to_string(&session_file) {
        let contents = contents.trim().to_string();
        if !contents.is_empty() {
            return contents;
        }
    }

    // Generate new session ID
    let id = uuid::Uuid::new_v4().to_string();

    // Best-effort write
    let _ = std::fs::create_dir_all(&session_dir);
    let _ = std::fs::write(&session_file, &id);

    id
}

/// Check environment variables for a harness session ID.
///
/// Uses the shared Ito harness environment precedence so audit events and
/// instruction rendering resolve the same session source.
pub fn resolve_harness_session_id() -> Option<String> {
    ito_common::harness::resolve_harness_session_id()
}

/// Resolve git context (branch, worktree, commit) from the current directory.
///
/// All fields are best-effort: if git is not available or the directory is
/// not a git repo, fields are `None`.
pub fn resolve_git_context() -> GitContext {
    let branch = run_git_command(&["symbolic-ref", "--short", "HEAD"]);
    let commit = run_git_command(&["rev-parse", "--short=8", "HEAD"]);

    // Detect worktree: compare git-dir with common-dir
    let worktree = detect_worktree_name();

    GitContext {
        branch,
        worktree,
        commit,
    }
}

/// Resolve the user identity for the `by` field.
///
/// Uses `git config user.name`, falling back to `$USER`, formatted as
/// `@lowercase-hyphenated`.
pub fn resolve_user_identity() -> String {
    let name = run_git_command(&["config", "user.name"])
        .or_else(|| std::env::var("USER").ok())
        .unwrap_or_else(|| "unknown".to_string());

    format!("@{}", name.to_lowercase().replace(' ', "-"))
}

/// Run a git command and return its stdout as a trimmed string, or None on failure.
fn run_git_command(args: &[&str]) -> Option<String> {
    let output = std::process::Command::new("git").args(args).output().ok()?;

    if !output.status.success() {
        return None;
    }

    let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if s.is_empty() { None } else { Some(s) }
}

/// Detect if we're in a worktree (not the main worktree) and return its name.
fn detect_worktree_name() -> Option<String> {
    let git_dir = run_git_command(&["rev-parse", "--git-dir"])?;
    let common_dir = run_git_command(&["rev-parse", "--git-common-dir"])?;

    // If git-dir != common-dir, we're in a worktree
    let git_dir_path = std::path::Path::new(&git_dir);
    let common_dir_path = std::path::Path::new(&common_dir);

    if git_dir_path.canonicalize().ok()? != common_dir_path.canonicalize().ok()? {
        // Extract worktree name from the path
        let toplevel = run_git_command(&["rev-parse", "--show-toplevel"])?;
        let path = std::path::Path::new(&toplevel);
        Some(path.file_name()?.to_string_lossy().to_string())
    } else {
        None
    }
}

#[cfg(test)]
#[path = "context_tests.rs"]
mod context_tests;