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_judged(ctx: &GateCtx) -> Option<Vec<camino::Utf8PathBuf>> {
25 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
52pub 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}