provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use crate::output::{self, OutputFormat};
use camino::{Utf8Path, Utf8PathBuf};
use provenance_core::{ensure_supported_schema_version, Manifest};
use provenance_macros::rule;
use provenance_store::{layout::ProvenanceLayout, state_store::StateStore};
use std::collections::BTreeSet;

mod index;
mod references;
mod scope;
mod statement_report;

use index::CheckIndex;

#[rule("rule_ste_strict_committed_statement_gate")]
pub(super) fn check(
    repo: &Utf8Path,
    strict: bool,
    base: Option<&str>,
    format: OutputFormat,
) -> anyhow::Result<()> {
    let store = StateStore::new(ProvenanceLayout::new(repo.to_path_buf()));
    let report = store.with_repository_publication(|| {
        let manifest = store.manifest()?;
        collect_report_locked(&store, repo, &manifest, strict, base)
    })?;
    let has_findings = !report.diagnostics.is_empty();
    output::print(format, &report)?;
    anyhow::ensure!(
        !strict || !has_findings,
        "strict statement check found ASD-STE100 findings"
    );
    Ok(())
}

#[derive(serde::Serialize)]
struct CheckReport {
    status: &'static str,
    #[serde(flatten, skip_serializing_if = "Option::is_none")]
    commits: Option<CommitRange>,
    diagnostics: Vec<provenance_store::statement_analysis::StatementDiagnostic>,
}

#[derive(serde::Serialize)]
struct CommitRange {
    candidate_commit: String,
    base_commit: Option<String>,
}

pub(super) fn validate_repository(repo: Utf8PathBuf) -> anyhow::Result<()> {
    let store = StateStore::new(ProvenanceLayout::new(repo));
    store.with_repository_publication(|| {
        let manifest = store.manifest()?;
        validate_locked(&store, &manifest)
    })
}

pub(super) fn validate_repository_with_manifest(
    repo: &Utf8Path,
    manifest: &Manifest,
) -> anyhow::Result<()> {
    let layout = ProvenanceLayout::new(repo.to_path_buf());
    let store = StateStore::new(layout.clone());
    match std::fs::symlink_metadata(layout.provenance_dir()) {
        Ok(_) => store.with_repository_publication(|| validate_locked(&store, manifest)),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            provenance_store::publication::with_read_only_validation(&layout, || {
                validate_locked(&store, manifest)
            })
        }
        Err(error) => Err(error.into()),
    }
}

pub(super) fn recover_repository_before_init(repo: &Utf8Path) -> anyhow::Result<()> {
    let layout = ProvenanceLayout::new(repo.to_path_buf());
    match std::fs::symlink_metadata(layout.provenance_dir()) {
        Ok(_) => StateStore::new(layout).with_repository_publication(|| Ok(())),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(error.into()),
    }
}

fn collect_report_locked(
    store: &StateStore,
    repo: &Utf8Path,
    manifest: &Manifest,
    strict: bool,
    base: Option<&str>,
) -> anyhow::Result<CheckReport> {
    validate_locked(store, manifest)?;
    if strict {
        let analysis = statement_report::changed_statements_from_commits(repo, manifest, base)?;
        let status = if analysis.diagnostics.is_empty() {
            "ok"
        } else {
            "findings"
        };
        return Ok(CheckReport {
            status,
            commits: Some(CommitRange {
                candidate_commit: analysis.candidate_commit,
                base_commit: analysis.base_commit,
            }),
            diagnostics: analysis.diagnostics,
        });
    }
    Ok(CheckReport {
        status: "ok",
        commits: None,
        diagnostics: statement_report::changed_statements_from_head(store, repo, manifest)?,
    })
}

fn validate_locked(store: &StateStore, manifest: &Manifest) -> anyhow::Result<()> {
    ensure_supported_schema_version("manifest", manifest.schema_version)?;
    anyhow::ensure!(
        !manifest.scopes.is_empty(),
        "manifest must contain at least one scope"
    );
    let manifest_scopes: BTreeSet<_> = manifest
        .scopes
        .iter()
        .map(|scope| scope.id.as_str().to_string())
        .collect();

    let scope_directory_findings = store
        .list_scope_directories()?
        .into_iter()
        .filter(|directory| !manifest_scopes.contains(directory))
        .map(|directory| format!("scope directory {directory} is absent from manifest"))
        .collect::<Vec<_>>();

    let mut index = CheckIndex::default();
    let mut dangling = Vec::new();
    scope::validate(
        store,
        &manifest.scopes,
        &manifest.disposition_actor_ids,
        &manifest_scopes,
        &mut index,
        &mut dangling,
    )?;

    anyhow::ensure!(
        scope_directory_findings.is_empty(),
        "scope directory finding(s):\n- {}",
        scope_directory_findings.join("\n- ")
    );
    anyhow::ensure!(
        dangling.is_empty(),
        "dangling reference(s):\n- {}",
        dangling.join("\n- ")
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use provenance_core::{Manifest, RepoPathPrefix, ScopeId};

    #[test]
    #[provenance_macros::verifies("rule_init_validates_planned_repository", examples)]
    fn planned_manifest_validation_runs_publication_recovery_before_reading_state() {
        let directory = tempfile::tempdir().unwrap();
        let repo = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).unwrap();
        let layout = ProvenanceLayout::new(repo.clone());
        std::fs::create_dir_all(layout.scopes_dir()).unwrap();
        std::fs::write(layout.manifest_path(), "not the planned manifest").unwrap();
        std::fs::create_dir_all(layout.cache_dir()).unwrap();
        std::fs::write(layout.publication_marker_path(), "not a publication marker").unwrap();
        let manifest = Manifest::default_with_scope(
            ScopeId::new("default").unwrap(),
            RepoPathPrefix::new("."),
        );

        let error = validate_repository_with_manifest(&repo, &manifest).unwrap_err();

        assert!(format!("{error:#}").contains("expected ident"));
        assert!(layout.publication_lock_path().exists());
        assert!(layout.import_transactions_dir().exists());
    }

    #[cfg(unix)]
    #[test]
    #[provenance_macros::verifies("rule_init_validates_planned_repository", examples)]
    fn planned_manifest_validation_refuses_a_symlinked_publication_cache() {
        use std::os::unix::fs::symlink;

        let directory = tempfile::tempdir().unwrap();
        let repo = Utf8PathBuf::from_path_buf(directory.path().join("repo")).unwrap();
        let outside = directory.path().join("outside");
        let layout = ProvenanceLayout::new(repo.clone());
        std::fs::create_dir_all(layout.provenance_dir()).unwrap();
        std::fs::create_dir_all(&outside).unwrap();
        symlink(&outside, layout.cache_dir()).unwrap();
        let manifest = Manifest::default_with_scope(
            ScopeId::new("default").unwrap(),
            RepoPathPrefix::new("."),
        );

        let error = validate_repository_with_manifest(&repo, &manifest).unwrap_err();

        assert!(format!("{error:#}").contains("symlink component"));
    }

    #[test]
    fn planned_manifest_validation_locks_an_existing_state_tree() {
        let directory = tempfile::tempdir().unwrap();
        let repo = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).unwrap();
        let layout = ProvenanceLayout::new(repo.clone());
        std::fs::create_dir_all(layout.scopes_dir()).unwrap();
        let manifest = Manifest::default_with_scope(
            ScopeId::new("default").unwrap(),
            RepoPathPrefix::new("."),
        );

        validate_repository_with_manifest(&repo, &manifest).unwrap();

        assert!(layout.publication_lock_path().exists());
    }

    #[test]
    fn planned_manifest_validation_keeps_a_new_repository_read_only() {
        let directory = tempfile::tempdir().unwrap();
        let repo = Utf8PathBuf::from_path_buf(directory.path().join("repo")).unwrap();
        let layout = ProvenanceLayout::new(repo.clone());
        let manifest = Manifest::default_with_scope(
            ScopeId::new("default").unwrap(),
            RepoPathPrefix::new("."),
        );

        validate_repository_with_manifest(&repo, &manifest).unwrap();

        assert!(!layout.provenance_dir().exists());
    }
}