Skip to main content

spec_driven_docs/gates/
spec_rule_id_unique.rs

1//! Gate: a rule ID resolves to exactly one requirement across the project.
2//!
3//! A commit citing a duplicated ID names two rules at once, so the citation
4//! stops being an address. Corpus-wide by construction: the duplicate is
5//! only visible when every spec is read together.
6
7use std::collections::BTreeMap;
8
9use crate::domain::finding::Finding;
10use crate::domain::rule_id::RuleId;
11use crate::gates::paths::docs_root;
12use crate::gates::{GateCtx, GateResult, Violation, read_text};
13
14/// The rules this gate can cite.
15pub const CITES: &[RuleId] = &[RuleId::RuleIdIsUniqueAndSlugged];
16
17/// List the spec documents under the documentation root, or `None` when the
18/// layout has moved.
19pub(crate) fn spec_files(ctx: &GateCtx) -> Option<Vec<camino::Utf8PathBuf>> {
20    let specs = docs_root(ctx).join("specs");
21    let mut names: Vec<String> = ctx
22        .path(&specs)
23        .read_dir_utf8()
24        .ok()?
25        .filter_map(Result::ok)
26        .map(|entry| entry.file_name().to_string())
27        .filter(|name| {
28            name.strip_prefix("SPEC-")
29                .and_then(|rest| rest.strip_suffix(".md"))
30                .is_some_and(|slug| !slug.is_empty())
31        })
32        .collect();
33    if names.is_empty() {
34        return None;
35    }
36    names.sort();
37    Some(names.into_iter().map(|name| specs.join(name)).collect())
38}
39
40/// Judge the whole spec corpus.
41///
42/// # Errors
43///
44/// [`crate::gates::GateError::Io`] when a spec cannot be read.
45pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
46    let Some(files) = spec_files(ctx) else {
47        return Ok(vec![Violation::Layout(
48            "no specs matched; the layout moved".to_string(),
49        )]);
50    };
51    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
52    for file in files {
53        for id in crate::embedded::rule_ids_in(&read_text(ctx, &file)?) {
54            *counts.entry(id).or_default() += 1;
55        }
56    }
57    let duplicated: Vec<String> = counts
58        .into_iter()
59        .filter(|(_, count)| *count > 1)
60        .map(|(id, _)| format!("### `{id}`"))
61        .collect();
62    if duplicated.is_empty() {
63        return Ok(vec![]);
64    }
65    let mut violations = vec![Violation::Finding(Finding::global(
66        RuleId::RuleIdIsUniqueAndSlugged,
67        "",
68    ))];
69    violations.extend(duplicated.into_iter().map(Violation::Note));
70    Ok(violations)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    fn write(dir: &tempfile::TempDir, name: &str, text: &str) {
78        let specs = dir.path().join("_docs/specs");
79        std::fs::create_dir_all(&specs).unwrap();
80        std::fs::write(specs.join(name), text).unwrap();
81    }
82
83    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
84        let ctx = GateCtx::new(dir.path().to_str().unwrap());
85        run(&ctx, &[])
86            .unwrap()
87            .iter()
88            .map(ToString::to_string)
89            .collect()
90    }
91
92    #[test]
93    fn accepts_unique_ids_across_the_corpus() {
94        let dir = tempfile::tempdir().unwrap();
95        write(&dir, "SPEC-a.md", "### `a:one` — One\n");
96        write(&dir, "SPEC-b.md", "### `b:one` — One\n");
97        assert!(run_in(&dir).is_empty());
98    }
99
100    #[test]
101    fn rejects_a_duplicate_and_names_it() {
102        let dir = tempfile::tempdir().unwrap();
103        write(&dir, "SPEC-a.md", "### `sample:works` — Works\n");
104        write(&dir, "SPEC-b.md", "### `sample:works` — Works again\n");
105        let out = run_in(&dir);
106        assert_eq!(
107            out,
108            vec![
109                "FAIL docs-specs:rule-id-is-unique-and-slugged".to_string(),
110                "### `sample:works`".to_string(),
111            ]
112        );
113    }
114
115    #[test]
116    fn a_missing_corpus_is_a_layout_failure() {
117        let dir = tempfile::tempdir().unwrap();
118        assert_eq!(
119            run_in(&dir),
120            vec!["FAIL no specs matched; the layout moved".to_string()]
121        );
122    }
123}