Skip to main content

spec_driven_docs/gates/
ki_filename_shape.rs

1//! Gate: a case id is `KI-<slug>.md`, and the slug is not a counter.
2//!
3//! The id a suppression cites has to survive a merge, so it names the bug
4//! rather than its position in a queue. A digit inside the slug is ordinary
5//! — an upstream issue number belongs to the story it tells — but a slug
6//! that opens with one is the counter this rejects.
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::CaseIdIsASlug];
14
15const RULE: RuleId = RuleId::CaseIdIsASlug;
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        let Some(after_prefix) = basename.strip_prefix("KI-") else {
27            violations.push(Violation::Finding(Finding::on_file(
28                RULE,
29                file,
30                "no KI- prefix",
31            )));
32            continue;
33        };
34        let Some(slug) = after_prefix.strip_suffix(".md") else {
35            violations.push(Violation::Finding(Finding::on_file(
36                RULE,
37                file,
38                "not a markdown file",
39            )));
40            continue;
41        };
42        if slug.as_bytes().first().is_some_and(u8::is_ascii_digit) {
43            violations.push(Violation::Finding(Finding::on_file(
44                RULE,
45                file,
46                "a slug, not a counter",
47            )));
48        } else if slug.is_empty()
49            || !slug
50                .bytes()
51                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
52        {
53            violations.push(Violation::Finding(Finding::on_file(
54                RULE,
55                file,
56                "the slug is lowercase, digits and hyphens",
57            )));
58        }
59    }
60    Ok(violations)
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    fn run_on(files: &[&str]) -> Vec<String> {
68        let ctx = GateCtx::new(".");
69        let files: Vec<String> = files.iter().map(ToString::to_string).collect();
70        run(&ctx, &files)
71            .unwrap()
72            .iter()
73            .map(ToString::to_string)
74            .collect()
75    }
76
77    #[test]
78    fn accepts_a_slug_with_an_inner_digit() {
79        assert!(run_on(&["_docs/reference/known-issues/KI-vendor-500.md"]).is_empty());
80    }
81
82    #[test]
83    fn rejects_a_counter() {
84        let out = run_on(&["_docs/reference/known-issues/KI-001-vendor.md"]);
85        assert_eq!(out.len(), 1);
86        assert!(out[0].contains("known-issues:case-id-is-a-slug"));
87        assert!(out[0].ends_with(": a slug, not a counter"));
88    }
89
90    #[test]
91    fn rejects_missing_prefix_wrong_extension_and_bad_charset() {
92        assert!(run_on(&["x/ISSUE-a.md"])[0].ends_with(": no KI- prefix"));
93        assert!(run_on(&["x/KI-a.txt"])[0].ends_with(": not a markdown file"));
94        assert!(
95            run_on(&["x/KI-Weird.md"])[0].ends_with(": the slug is lowercase, digits and hyphens")
96        );
97    }
98}