turnlog 0.1.0

Lightweight local provenance recorder for agent sessions and turns
use crate::model::VcsInfo;
use std::path::{Path, PathBuf};
use std::process::Command;

fn run(cwd: &Path, cmd: &str, args: &[&str]) -> Option<String> {
    let out = Command::new(cmd)
        .args(args)
        .current_dir(cwd)
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}

pub fn repo_root(cwd: &Path) -> PathBuf {
    if let Some(root) = run(cwd, "jj", &["root"]).filter(|s| !s.is_empty()) {
        return PathBuf::from(root);
    }
    if let Some(root) = run(cwd, "git", &["rev-parse", "--show-toplevel"]).filter(|s| !s.is_empty())
    {
        return PathBuf::from(root);
    }
    cwd.to_path_buf()
}

pub fn detect(cwd: &Path) -> VcsInfo {
    if run(cwd, "jj", &["root"]).is_some() {
        return detect_jj(cwd);
    }
    if run(cwd, "git", &["rev-parse", "--show-toplevel"]).is_some() {
        return detect_git(cwd);
    }
    VcsInfo::None
}

pub fn diff(cwd: &Path) -> Option<String> {
    if run(cwd, "jj", &["root"]).is_some() {
        return run(cwd, "jj", &["diff", "--git"]);
    }
    if run(cwd, "git", &["rev-parse", "--show-toplevel"]).is_some() {
        return run(cwd, "git", &["diff"]);
    }
    None
}

fn detect_jj(cwd: &Path) -> VcsInfo {
    let jj_change = run(
        cwd,
        "jj",
        &["log", "-r", "@", "--no-graph", "-T", "change_id.short()"],
    );
    let jj_commit = run(
        cwd,
        "jj",
        &["log", "-r", "@", "--no-graph", "-T", "commit_id.short()"],
    );
    let jj_operation = run(
        cwd,
        "jj",
        &[
            "op",
            "log",
            "--limit",
            "1",
            "--no-graph",
            "-T",
            "id.short()",
        ],
    );
    let git_head = run(cwd, "git", &["rev-parse", "HEAD"]);
    let git_branch = run(cwd, "git", &["branch", "--show-current"]).filter(|s| !s.is_empty());
    let changed_files = jj_changed_files(cwd);
    let dirty = !changed_files.is_empty();
    VcsInfo::Jj {
        jj_change,
        jj_commit,
        jj_operation,
        git_head,
        git_branch,
        dirty,
        changed_files,
    }
}

fn detect_git(cwd: &Path) -> VcsInfo {
    let git_head = run(cwd, "git", &["rev-parse", "HEAD"]);
    let git_branch = run(cwd, "git", &["branch", "--show-current"]).filter(|s| !s.is_empty());
    let changed_files = git_changed_files(cwd);
    let dirty = !changed_files.is_empty();
    VcsInfo::Git {
        git_head,
        git_branch,
        dirty,
        changed_files,
    }
}

fn jj_changed_files(cwd: &Path) -> Vec<String> {
    let Some(raw) = run(cwd, "jj", &["status", "--no-pager"]) else {
        return vec![];
    };
    raw.lines()
        .filter(|line| {
            matches!(
                line.as_bytes().first(),
                Some(b'A' | b'M' | b'D' | b'R' | b'C')
            )
        })
        .filter_map(|line| line.get(2..).map(str::trim))
        .filter(|s| !s.is_empty())
        .map(ToOwned::to_owned)
        .collect()
}

fn git_changed_files(cwd: &Path) -> Vec<String> {
    let Some(raw) = run(cwd, "git", &["status", "--porcelain"]) else {
        return vec![];
    };
    raw.lines()
        .filter_map(|line| line.get(2..).map(str::trim))
        .filter(|s| !s.is_empty())
        .map(|s| s.split(" -> ").last().unwrap_or(s).to_string())
        .collect()
}