main-spex 0.20.0

AI-first development framework that turns specifications into deterministic, verifiable code
use std::collections::{HashMap, HashSet};
use std::fs;

use crate::rule::RULES_DIR;
use crate::spec::{AcId, SPECS_DIR, SpecId};
use crate::templates::SCHEMAS_DIRNAME;
use crate::tracing::annotations;

use super::check::PackageCtx;
use super::finding::{Finding, Location, Source};
use super::scoped_file::ScopedFile;

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

const RULE_ID: &str = "spex-internal-spec-id-tracing";
const FORMAT_CHECK: &str = "annotation-format";
const ORPHAN_CHECK: &str = "no-orphan-annotations";

pub(super) fn validate_annotations(
    ctx: &PackageCtx,
    known_ids: &HashMap<SpecId, HashSet<AcId>>,
) -> Vec<Finding> {
    let mut findings = Vec::new();
    for file in ctx.candidates {
        if holds_illustrative_annotations(ctx, file) {
            continue;
        }
        let Ok(content) = fs::read_to_string(&file.abs) else {
            continue;
        };
        findings.extend(check_format(file, &content));
        findings.extend(check_orphans(file, &content, known_ids));
    }
    findings
}

fn holds_illustrative_annotations(ctx: &PackageCtx, file: &ScopedFile) -> bool {
    if file.rel.extension().is_some_and(|ext| ext == "md") {
        return true;
    }
    [SCHEMAS_DIRNAME, SPECS_DIR, RULES_DIR]
        .iter()
        .any(|dir| file.abs.starts_with(ctx.spex_dir.join(dir)))
}

fn check_format(file: &ScopedFile, content: &str) -> Vec<Finding> {
    annotations::malformed_lines(content)
        .into_iter()
        .map(|line| {
            finding(
                FORMAT_CHECK,
                Location::at_line(&file.rel, line),
                "malformed spec-id annotation".to_string(),
            )
        })
        .collect()
}

fn check_orphans(
    file: &ScopedFile,
    content: &str,
    known: &HashMap<SpecId, HashSet<AcId>>,
) -> Vec<Finding> {
    let refs: HashSet<(SpecId, Option<AcId>)> = annotations::parse(content)
        .into_iter()
        .map(|annotation| (annotation.spec_id, annotation.ac_id))
        .collect();

    let mut orphans: Vec<(SpecId, Option<AcId>)> = refs
        .into_iter()
        .filter(|(spec_id, ac_id)| match known.get(spec_id) {
            None => true,
            Some(ac_ids) => ac_id.as_ref().is_some_and(|ac_id| !ac_ids.contains(ac_id)),
        })
        .collect();
    orphans.sort();
    orphans
        .into_iter()
        .map(|(spec_id, ac_id)| {
            let message = match ac_id {
                Some(ac_id) if known.contains_key(&spec_id) => format!(
                    "orphan annotation references unknown acceptance criterion `{ac_id}` of spec `{spec_id}`"
                ),
                _ => format!("orphan annotation references unknown spec `{spec_id}`"),
            };
            finding(ORPHAN_CHECK, Location::in_file(&file.rel), message)
        })
        .collect()
}

fn finding(check: &str, location: Location, message: String) -> Finding {
    Finding::error(Source::internal(RULE_ID, check), Some(location), message)
}