use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub type SatisfiesIndex = BTreeMap<String, Vec<PathBuf>>;
pub fn build_satisfies_index(specs_dir: &Path) -> SatisfiesIndex {
let mut index: SatisfiesIndex = BTreeMap::new();
for path in spec_files(specs_dir) {
let Ok(doc) = crate::spec_parser::parse_spec(&path) else {
continue;
};
for id in &doc.meta.satisfies {
index.entry(id.clone()).or_default().push(path.clone());
}
}
index
}
fn spec_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
collect(dir, &mut out);
out.sort();
out
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
if !should_skip_dir(&p) {
collect(&p, out);
}
} else if is_spec_file(&p) {
out.push(p);
}
}
}
fn should_skip_dir(path: &Path) -> bool {
matches!(
path.file_name().and_then(|name| name.to_str()),
Some(".agent-spec" | "_archive" | "archive")
)
}
fn is_spec_file(p: &Path) -> bool {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
name.ends_with(".spec.md") || name.ends_with(".spec")
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_index_maps_decision_to_specs() {
let dir = std::env::temp_dir().join(format!("kll-idx-{}", std::process::id()));
let specs = dir.join("specs");
std::fs::create_dir_all(&specs).unwrap();
std::fs::write(
specs.join("task-a.spec.md"),
"spec: task\nname: \"A\"\nsatisfies: [ADR-001]\n---\n## Intent\nx\n",
)
.unwrap();
std::fs::write(
specs.join("task-b.spec.md"),
"spec: task\nname: \"B\"\n---\n## Intent\nx\n",
)
.unwrap();
let idx = build_satisfies_index(&specs);
assert_eq!(idx.get("ADR-001").map(|v| v.len()), Some(1));
assert!(!idx.contains_key("ADR-999"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_satisfies_index_ignores_archived_specs_by_default() {
let dir = std::env::temp_dir().join(format!("kll-idx-archive-{}", std::process::id()));
let specs = dir.join("specs");
std::fs::create_dir_all(specs.join("archive")).unwrap();
std::fs::write(
specs.join("active.spec.md"),
"spec: task\nname: \"Active\"\nsatisfies: [REQ-ACTIVE]\n---\n## Intent\nx\n",
)
.unwrap();
std::fs::write(
specs.join("archive/old.spec.md"),
"spec: task\nname: \"Old\"\nsatisfies: [REQ-OLD]\n---\n## Intent\nx\n",
)
.unwrap();
let idx = build_satisfies_index(&specs);
assert!(idx.contains_key("REQ-ACTIVE"));
assert!(!idx.contains_key("REQ-OLD"));
std::fs::remove_dir_all(&dir).ok();
}
}