docpact 0.1.6

Deterministic documentation governance CLI for AI-assisted software teams.
Documentation
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::process::Command;

use miette::{IntoDiagnostic, Result, bail, miette};

use crate::cli::LintArgs;
use crate::config::normalize_path;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffSource {
    Files,
    Range,
    Staged,
    Worktree,
    MergeBase,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileComparison {
    pub previous: Option<String>,
    pub current: Option<String>,
}

pub fn get_changed_paths(root_dir: &Path, args: &LintArgs) -> Result<Vec<String>> {
    if let Some(files) = &args.files {
        let values = files
            .split(',')
            .map(|value| normalize_path(value.trim()))
            .filter(|value| !value.is_empty())
            .collect::<Vec<_>>();
        return Ok(dedup(values));
    }

    if args.staged {
        return git_name_only(root_dir, &["diff", "--name-only", "--cached"]);
    }

    if args.worktree {
        return git_name_only(root_dir, &["diff", "--name-only"]);
    }

    if let Some(reference) = &args.merge_base {
        let merge_base = git_stdout(root_dir, &["merge-base", "HEAD", reference])?;
        return git_name_only(
            root_dir,
            &["diff", "--name-only", &format!("{merge_base}...HEAD")],
        );
    }

    if let (Some(base), Some(head)) = (&args.base, &args.head) {
        return git_name_only(
            root_dir,
            &["diff", "--name-only", &format!("{base}...{head}")],
        );
    }

    bail!(
        "Pass either --files, --staged, --worktree, --merge-base <ref>, or both --base and --head."
    )
}

pub fn get_head_commit(root_dir: &Path) -> Result<String> {
    git_stdout(root_dir, &["rev-parse", "HEAD"])
}

pub fn get_tracked_paths(root_dir: &Path) -> Result<Vec<String>> {
    git_name_only(root_dir, &["ls-files"])
}

pub fn is_commit_reachable_from_head(root_dir: &Path, commit: &str) -> Result<bool> {
    let output = Command::new("git")
        .args(["merge-base", "--is-ancestor", commit, "HEAD"])
        .current_dir(root_dir)
        .output()
        .into_diagnostic()?;

    match output.status.code() {
        Some(0) => Ok(true),
        Some(1) => Ok(false),
        _ => {
            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
            if stderr.contains("Not a valid object name")
                || stderr.contains("fatal: Not a valid commit name")
                || stderr.contains("unknown revision")
                || stderr.contains("bad revision")
                || stderr.contains("fatal: ambiguous argument")
            {
                Ok(false)
            } else {
                Err(miette!(
                    "git merge-base --is-ancestor {} HEAD failed: {}",
                    commit,
                    stderr
                ))
            }
        }
    }
}

pub fn get_unique_commits_since(
    root_dir: &Path,
    base_commit: &str,
    paths: &[String],
) -> Result<Vec<String>> {
    if paths.is_empty() {
        return Ok(Vec::new());
    }

    let mut args = vec![
        "rev-list".to_string(),
        format!("{base_commit}..HEAD"),
        "--".to_string(),
    ];
    args.extend(paths.iter().cloned());

    let output = Command::new("git")
        .args(args.iter().map(String::as_str))
        .current_dir(root_dir)
        .output()
        .into_diagnostic()?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        return Err(miette!(
            "git rev-list {}..HEAD failed: {}",
            base_commit,
            stderr
        ));
    }

    let stdout = String::from_utf8(output.stdout)
        .map_err(|error| miette!("git output was not valid UTF-8: {error}"))?;
    Ok(dedup(
        stdout
            .lines()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(str::to_string)
            .collect(),
    ))
}

pub fn get_file_comparison(
    root_dir: &Path,
    args: &LintArgs,
    rel_path: &str,
) -> Result<FileComparison> {
    let rel_path = normalize_path(rel_path);

    if args.staged {
        return Ok(FileComparison {
            previous: git_show_revision_path(root_dir, "HEAD", &rel_path)?,
            current: git_show_index_path(root_dir, &rel_path)?,
        });
    }

    if args.worktree || args.files.is_some() {
        return Ok(FileComparison {
            previous: git_show_revision_path(root_dir, "HEAD", &rel_path)?,
            current: read_worktree_path(root_dir, &rel_path)?,
        });
    }

    if let Some(reference) = &args.merge_base {
        let merge_base = git_stdout(root_dir, &["merge-base", "HEAD", reference])?;
        return Ok(FileComparison {
            previous: git_show_revision_path(root_dir, &merge_base, &rel_path)?,
            current: git_show_revision_path(root_dir, "HEAD", &rel_path)?,
        });
    }

    if let (Some(base), Some(head)) = (&args.base, &args.head) {
        let merge_base = git_stdout(root_dir, &["merge-base", base, head])?;
        return Ok(FileComparison {
            previous: git_show_revision_path(root_dir, &merge_base, &rel_path)?,
            current: git_show_revision_path(root_dir, head, &rel_path)?,
        });
    }

    bail!(
        "Pass either --files, --staged, --worktree, --merge-base <ref>, or both --base and --head."
    )
}

fn git_name_only(root_dir: &Path, args: &[&str]) -> Result<Vec<String>> {
    let output = git_stdout(root_dir, args)?;
    Ok(dedup(
        output
            .lines()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(normalize_path)
            .collect::<Vec<_>>(),
    ))
}

fn git_stdout(root_dir: &Path, args: &[&str]) -> Result<String> {
    let output = Command::new("git")
        .args(args)
        .current_dir(root_dir)
        .output()
        .into_diagnostic()?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        return Err(miette!("git {} failed: {}", args.join(" "), stderr));
    }

    String::from_utf8(output.stdout)
        .map(|value| value.trim().to_string())
        .map_err(|error| miette!("git output was not valid UTF-8: {error}"))
}

fn git_show_revision_path(
    root_dir: &Path,
    revision: &str,
    rel_path: &str,
) -> Result<Option<String>> {
    git_show_spec(root_dir, &format!("{revision}:{rel_path}"))
}

fn git_show_index_path(root_dir: &Path, rel_path: &str) -> Result<Option<String>> {
    git_show_spec(root_dir, &format!(":{rel_path}"))
}

fn git_show_spec(root_dir: &Path, spec: &str) -> Result<Option<String>> {
    let output = Command::new("git")
        .args(["show", spec])
        .current_dir(root_dir)
        .output()
        .into_diagnostic()?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        if is_missing_path_error(&stderr) {
            return Ok(None);
        }
        return Err(miette!("git show {} failed: {}", spec, stderr));
    }

    String::from_utf8(output.stdout)
        .map(Some)
        .map_err(|error| miette!("git output was not valid UTF-8: {error}"))
}

fn read_worktree_path(root_dir: &Path, rel_path: &str) -> Result<Option<String>> {
    let abs_path = root_dir.join(rel_path);
    if !abs_path.exists() {
        return Ok(None);
    }

    fs::read_to_string(abs_path).map(Some).into_diagnostic()
}

fn is_missing_path_error(stderr: &str) -> bool {
    stderr.contains("does not exist in")
        || stderr.contains("exists on disk, but not in")
        || stderr.contains("path '") && stderr.contains("not in the index")
        || stderr.contains("unknown revision or path not in the working tree")
        || stderr.contains("bad revision")
}

fn dedup(values: Vec<String>) -> Vec<String> {
    let mut seen = HashSet::new();
    let mut result = Vec::new();

    for value in values {
        if seen.insert(value.clone()) {
            result.push(value);
        }
    }

    result
}