provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use anyhow::Context;
use camino::Utf8Path;
use provenance_core::{Manifest, Requirement, Rule, Scope, ScopeId};
use provenance_macros::rule;
use provenance_store::{
    state_store::StateStore,
    statement_analysis::{analyze_changed_statements, StatementDiagnostic},
};
use serde::de::DeserializeOwned;

pub(super) struct CommittedStatementAnalysis {
    pub candidate_commit: String,
    pub base_commit: Option<String>,
    pub diagnostics: Vec<StatementDiagnostic>,
}

#[derive(Default)]
struct StatementFamily {
    requirements: Vec<Requirement>,
    rules: Vec<Rule>,
}

#[rule("rule_ste_manual_changed_statement_report")]
pub(super) fn changed_statements_from_head(
    store: &StateStore,
    repo: &Utf8Path,
    manifest: &Manifest,
) -> anyhow::Result<Vec<StatementDiagnostic>> {
    if !has_head(repo)? {
        return Ok(Vec::new());
    }

    let base = read_commit_family(repo, "HEAD", &manifest.scopes)?;
    let candidate = read_working_family(store, &manifest.scopes)?;
    Ok(analyze_family_change(repo, &base, &candidate))
}

#[rule("rule_ste_strict_committed_statement_selection")]
pub(super) fn changed_statements_from_commits(
    repo: &Utf8Path,
    manifest: &Manifest,
    explicit_base: Option<&str>,
) -> anyhow::Result<CommittedStatementAnalysis> {
    let candidate_commit = resolve_commit(repo, "HEAD")?;
    let base_commit = match explicit_base {
        Some(base) => Some(resolve_commit(repo, base)?),
        None => first_parent(repo, &candidate_commit)?,
    };
    let base = base_commit.as_deref().map_or_else(
        || Ok(StatementFamily::default()),
        |base| read_commit_family(repo, base, &manifest.scopes),
    )?;
    let candidate = read_commit_family(repo, &candidate_commit, &manifest.scopes)?;
    let diagnostics = analyze_family_change(repo, &base, &candidate);
    Ok(CommittedStatementAnalysis {
        candidate_commit,
        base_commit,
        diagnostics,
    })
}

fn has_head(repo: &Utf8Path) -> anyhow::Result<bool> {
    Ok(std::process::Command::new("git")
        .current_dir(repo)
        .args(["rev-parse", "--verify", "HEAD"])
        .output()?
        .status
        .success())
}

fn resolve_commit(repo: &Utf8Path, revision: &str) -> anyhow::Result<String> {
    let commit = format!("{revision}^{{commit}}");
    let output = std::process::Command::new("git")
        .current_dir(repo)
        .args(["rev-parse", "--verify", "--end-of-options", &commit])
        .output()?;
    anyhow::ensure!(
        output.status.success(),
        "Git revision {revision:?} does not identify a commit: {}",
        String::from_utf8_lossy(&output.stderr).trim()
    );
    Ok(String::from_utf8(output.stdout)?.trim().to_owned())
}

#[rule("rule_ste_strict_initial_commit_base")]
fn first_parent(repo: &Utf8Path, candidate: &str) -> anyhow::Result<Option<String>> {
    let output = std::process::Command::new("git")
        .current_dir(repo)
        .args(["cat-file", "-p", candidate])
        .output()?;
    anyhow::ensure!(
        output.status.success(),
        "cannot read Git commit {candidate}: {}",
        String::from_utf8_lossy(&output.stderr).trim()
    );
    let object = String::from_utf8(output.stdout)?;
    let Some(parent) = object.lines().find_map(|line| line.strip_prefix("parent ")) else {
        return Ok(None);
    };
    resolve_commit(repo, parent)
        .map(Some)
        .context("Git HEAD first parent is not available. Fetch more Git history")
}

fn read_commit_family(
    repo: &Utf8Path,
    commit: &str,
    scopes: &[Scope],
) -> anyhow::Result<StatementFamily> {
    let mut family = StatementFamily::default();
    for scope in scopes {
        family
            .requirements
            .extend(read_commit_records::<Requirement>(
                repo,
                commit,
                &scope.id,
                "requirements/req.jsonl",
            )?);
        family.rules.extend(read_commit_records::<Rule>(
            repo,
            commit,
            &scope.id,
            "rules/rule.jsonl",
        )?);
    }
    Ok(family)
}

fn read_working_family(store: &StateStore, scopes: &[Scope]) -> anyhow::Result<StatementFamily> {
    let mut family = StatementFamily::default();
    for scope in scopes {
        family
            .requirements
            .extend(store.list_requirements(&scope.id)?);
        family.rules.extend(store.list_rules(&scope.id)?);
    }
    Ok(family)
}

fn analyze_family_change(
    repo: &Utf8Path,
    base: &StatementFamily,
    candidate: &StatementFamily,
) -> Vec<StatementDiagnostic> {
    let layout = provenance_store::layout::ProvenanceLayout::new(repo.to_owned());
    let dictionary = provenance_store::dictionary_reference::load_project_dictionary(&layout);
    analyze_changed_statements(
        &base.requirements,
        &base.rules,
        &candidate.requirements,
        &candidate.rules,
        dictionary.as_ref(),
    )
}

fn read_commit_records<T: DeserializeOwned>(
    repo: &Utf8Path,
    commit: &str,
    scope: &ScopeId,
    family_path: &str,
) -> anyhow::Result<Vec<T>> {
    let path = format!(
        ".provenance/state/scopes/{}/{}",
        scope.as_str(),
        family_path
    );
    let listing = std::process::Command::new("git")
        .current_dir(repo)
        .args(["ls-tree", "--name-only", commit, "--", &path])
        .output()?;
    anyhow::ensure!(
        listing.status.success(),
        "cannot inspect Git commit {commit} for {path}: {}",
        String::from_utf8_lossy(&listing.stderr).trim()
    );
    if listing.stdout.is_empty() {
        return Ok(Vec::new());
    }
    let object = format!("{commit}:{path}");
    let output = std::process::Command::new("git")
        .current_dir(repo)
        .args(["show", &object])
        .output()?;
    anyhow::ensure!(
        output.status.success(),
        "cannot read {path} from Git commit {commit}: {}",
        String::from_utf8_lossy(&output.stderr).trim()
    );
    String::from_utf8(output.stdout)?
        .lines()
        .filter(|line| !line.trim().is_empty())
        .map(serde_json::from_str)
        .collect::<Result<Vec<_>, _>>()
        .map_err(Into::into)
}