spec_driven_docs/gates/
spec_rule_id_unique.rs1use 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
14pub const CITES: &[RuleId] = &[RuleId::RuleIdIsUniqueAndSlugged];
16
17pub(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
40pub 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}