use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use crate::rule::RULES_DIR;
use crate::spec::{AcId, SPECS_DIR, SpecId};
use crate::templates::{
AGENTS_DIRNAME, AGENTS_FILENAME, DOCS_DIRNAME, SCHEMAS_DIRNAME, SKILLS_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,
agent_root: &Path,
known_ids: &HashMap<SpecId, HashSet<AcId>>,
) -> Vec<Finding> {
let mut findings = Vec::new();
for file in ctx.candidates {
if holds_illustrative_annotations(ctx.spex_dir, agent_root, 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(spex_dir: &Path, agent_root: &Path, file: &ScopedFile) -> bool {
let skipped_spex_dirs = [SCHEMAS_DIRNAME, SPECS_DIR, RULES_DIR, DOCS_DIRNAME];
file.abs == agent_root.join(AGENTS_FILENAME)
|| file
.abs
.starts_with(agent_root.join(AGENTS_DIRNAME).join(SKILLS_DIRNAME))
|| skipped_spex_dirs
.iter()
.any(|dir| file.abs.starts_with(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)
}