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.
19/// The specs this gate judges, filtered.
20///
21/// [`spec_files`] stays unfiltered because the same files are support for
22/// `adr-cites-a-live-rule` and `gate-message-cites-a-rule`, which resolve a
23/// rule id against them rather than judging their contents.
24pub(crate) fn spec_files_judged(ctx: &GateCtx) -> Option<Vec<camino::Utf8PathBuf>> {
25    // `None` means the layout moved, which is read from the unfiltered set.
26    // A project that reserves every spec has a layout and judges none of
27    // it, which is a different answer and not a failure.
28    Some(ctx.retained(spec_files(ctx)?))
29}
30
31pub(crate) fn spec_files(ctx: &GateCtx) -> Option<Vec<camino::Utf8PathBuf>> {
32    let specs = docs_root(ctx).join("specs");
33    let mut names: Vec<String> = ctx
34        .path(&specs)
35        .read_dir_utf8()
36        .ok()?
37        .filter_map(Result::ok)
38        .map(|entry| entry.file_name().to_string())
39        .filter(|name| {
40            name.strip_prefix("SPEC-")
41                .and_then(|rest| rest.strip_suffix(".md"))
42                .is_some_and(|slug| !slug.is_empty())
43        })
44        .collect();
45    if names.is_empty() {
46        return None;
47    }
48    names.sort();
49    Some(names.into_iter().map(|name| specs.join(name)).collect())
50}
51
52/// Judge the whole spec corpus.
53///
54/// # Errors
55///
56/// [`crate::gates::GateError::Io`] when a spec cannot be read.
57pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
58    let Some(files) = spec_files_judged(ctx) else {
59        return Ok(vec![Violation::Layout(
60            "no specs matched; the layout moved".to_string(),
61        )]);
62    };
63    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
64    for file in files {
65        for id in crate::embedded::rule_ids_in(&read_text(ctx, &file)?) {
66            *counts.entry(id).or_default() += 1;
67        }
68    }
69    let duplicated: Vec<String> = counts
70        .into_iter()
71        .filter(|(_, count)| *count > 1)
72        .map(|(id, _)| format!("### `{id}`"))
73        .collect();
74    if duplicated.is_empty() {
75        return Ok(vec![]);
76    }
77    let mut violations = vec![Violation::Finding(Finding::global(
78        RuleId::RuleIdIsUniqueAndSlugged,
79        "",
80    ))];
81    violations.extend(duplicated.into_iter().map(Violation::Note));
82    Ok(violations)
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    fn write(dir: &tempfile::TempDir, name: &str, text: &str) {
90        let specs = dir.path().join("_docs/specs");
91        std::fs::create_dir_all(&specs).unwrap();
92        std::fs::write(specs.join(name), text).unwrap();
93    }
94
95    fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
96        let ctx = GateCtx::new(dir.path().to_str().unwrap());
97        run(&ctx, &[])
98            .unwrap()
99            .iter()
100            .map(ToString::to_string)
101            .collect()
102    }
103
104    #[test]
105    fn accepts_unique_ids_across_the_corpus() {
106        let dir = tempfile::tempdir().unwrap();
107        write(&dir, "SPEC-a.md", "### `a:one` — One\n");
108        write(&dir, "SPEC-b.md", "### `b:one` — One\n");
109        assert!(run_in(&dir).is_empty());
110    }
111
112    #[test]
113    fn rejects_a_duplicate_and_names_it() {
114        let dir = tempfile::tempdir().unwrap();
115        write(&dir, "SPEC-a.md", "### `sample:works` — Works\n");
116        write(&dir, "SPEC-b.md", "### `sample:works` — Works again\n");
117        let out = run_in(&dir);
118        assert_eq!(
119            out,
120            vec![
121                "FAIL docs-specs:rule-id-is-unique-and-slugged".to_string(),
122                "### `sample:works`".to_string(),
123            ]
124        );
125    }
126
127    #[test]
128    fn a_missing_corpus_is_a_layout_failure() {
129        let dir = tempfile::tempdir().unwrap();
130        assert_eq!(
131            run_in(&dir),
132            vec!["FAIL no specs matched; the layout moved".to_string()]
133        );
134    }
135}