Skip to main content

spec_driven_docs/gates/
adr_filename_shape.rs

1//! Gate: a record filename is `ADR-<slug>.md`, and the slug carries no digit.
2//!
3//! The slug is the identifier: a counter lets two branches each allocate the
4//! next number, and the merge leaves one identity claimed twice. Only the
5//! filenames pre-commit hands over are judged; record content is other
6//! gates' business.
7
8use crate::domain::finding::Finding;
9use crate::domain::rule_id::RuleId;
10use crate::gates::{GateCtx, GateResult, Violation};
11
12/// The rules this gate can cite.
13pub const CITES: &[RuleId] = &[RuleId::FilenameCarriesNoDigit];
14
15const RULE: RuleId = RuleId::FilenameCarriesNoDigit;
16
17/// Judge every file pre-commit passed.
18///
19/// # Errors
20///
21/// None; the gate reads only the argument paths themselves.
22pub fn run(_ctx: &GateCtx, files: &[String]) -> GateResult {
23    let mut violations = Vec::new();
24    for file in files {
25        let basename = file.rsplit('/').next().unwrap_or(file);
26        if basename == "TEMPLATE-adr.md" {
27            continue;
28        }
29        let Some(after_prefix) = basename.strip_prefix("ADR-") else {
30            violations.push(Violation::Finding(Finding::on_file(
31                RULE,
32                file,
33                "no ADR- prefix",
34            )));
35            continue;
36        };
37        let Some(slug) = after_prefix.strip_suffix(".md") else {
38            violations.push(Violation::Finding(Finding::on_file(
39                RULE,
40                file,
41                "not a markdown file",
42            )));
43            continue;
44        };
45        if slug.is_empty() || !slug.bytes().all(|b| b.is_ascii_lowercase() || b == b'-') {
46            violations.push(Violation::Finding(Finding::on_file(
47                RULE,
48                file,
49                "the slug is lowercase and hyphens, with no digit",
50            )));
51        }
52    }
53    Ok(violations)
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    fn run_on(files: &[&str]) -> Vec<String> {
61        let ctx = GateCtx::new(".");
62        let files: Vec<String> = files.iter().map(ToString::to_string).collect();
63        run(&ctx, &files)
64            .unwrap()
65            .iter()
66            .map(ToString::to_string)
67            .collect()
68    }
69
70    #[test]
71    fn accepts_a_slugged_record_and_the_template() {
72        assert!(run_on(&["_docs/decisions/ADR-use-slugs.md"]).is_empty());
73        assert!(run_on(&["_docs/decisions/TEMPLATE-adr.md"]).is_empty());
74    }
75
76    #[test]
77    fn rejects_a_digit_in_the_slug() {
78        let out = run_on(&["_docs/decisions/ADR-use-v2.md"]);
79        assert_eq!(out.len(), 1);
80        assert!(out[0].contains("decision-records:filename-carries-no-digit"));
81        assert!(out[0].contains("the slug is lowercase and hyphens, with no digit"));
82    }
83
84    #[test]
85    fn rejects_missing_prefix_and_wrong_extension() {
86        assert!(run_on(&["_docs/decisions/RECORD-x.md"])[0].contains("no ADR- prefix"));
87        assert!(run_on(&["_docs/decisions/ADR-x.txt"])[0].contains("not a markdown file"));
88    }
89}