spec_driven_docs/gates/
ki_bugzilla_report_width.rs1use crate::domain::finding::Finding;
14use crate::domain::rule_id::RuleId;
15use crate::gates::paths::ki_records_judged;
16use crate::gates::{GateCtx, GateResult, Violation, read_text};
17
18pub const CITES: &[RuleId] = &[RuleId::BugzillaReportBodyFitsReportWidth];
20
21const RULE: RuleId = RuleId::BugzillaReportBodyFitsReportWidth;
22const WIDTH: usize = 79;
23
24fn is_bugzilla_reference(line: &str) -> bool {
25 let lower = line.to_lowercase();
26 let Some(_) = lower.trim_start().strip_prefix("upstream:") else {
27 return false;
28 };
29 ["bugzilla", "show_bug.cgi", "bsc#", "boo#", "bnc#"]
30 .iter()
31 .any(|needle| lower.contains(needle))
32}
33
34fn columns(line: &str) -> usize {
35 unicode_width::UnicodeWidthStr::width(line)
36}
37
38fn judge(record: &str, text: &str, violations: &mut Vec<Violation>) {
39 let mut report = false;
40 let mut fence = false;
41 let mut bugzilla = false;
42 for (number, line) in text.lines().enumerate() {
43 if is_bugzilla_reference(line) {
44 bugzilla = true;
45 }
46 if line == "## Report" {
47 report = true;
48 continue;
49 }
50 if !fence && line.starts_with("## ") {
51 report = false;
52 }
53 if report && line.starts_with("```") {
54 fence = !fence;
55 continue;
56 }
57 if !bugzilla {
58 continue;
59 }
60 if report && fence && columns(line) > WIDTH {
61 violations.push(Violation::Finding(Finding::on_line(
62 RULE,
63 record,
64 number + 1,
65 format!("{} columns", columns(line)),
66 )));
67 }
68 if report && !fence && !line.is_empty() {
69 violations.push(Violation::Finding(Finding::on_line(
70 RULE,
71 record,
72 number + 1,
73 "body outside a fence",
74 )));
75 }
76 }
77}
78
79pub fn run(ctx: &GateCtx, args: &[String]) -> GateResult {
85 let mut violations = Vec::new();
86 for record in ki_records_judged(ctx, args)? {
87 let text = read_text(ctx, &record)?;
88 judge(record.as_str(), &text, &mut violations);
89 }
90 Ok(violations)
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use crate::gates::tests_support::ki_fixture_upstream;
97
98 const BUGZILLA: &str = "https://bugzilla.example/show_bug.cgi?id=123";
99
100 fn run_on(upstream: &str, body: &str) -> Vec<String> {
101 let dir = ki_fixture_upstream(upstream, body);
102 let ctx = GateCtx::new(dir.path().to_str().unwrap());
103 run(&ctx, &[])
104 .unwrap()
105 .iter()
106 .map(ToString::to_string)
107 .collect()
108 }
109
110 fn fenced(line: &str) -> String {
111 format!("# V\n## How it works\nRun.\n## Report\n```text\n{line}\n```\n")
112 }
113
114 #[test]
115 fn accepts_a_fitting_fenced_body() {
116 assert!(run_on(BUGZILLA, &fenced("body")).is_empty());
117 }
118
119 #[test]
120 fn rejects_an_over_wide_line_with_its_column_count() {
121 let out = run_on(BUGZILLA, &fenced(&"x".repeat(100)));
122 assert_eq!(out.len(), 1);
123 assert!(out[0].contains("known-issues:a-bugzilla-report-body-fits-in-79-columns"));
124 assert!(out[0].ends_with(": 100 columns"));
125 }
126
127 #[test]
128 fn recognises_a_rebranded_tracker_and_an_indented_capitalised_key() {
129 let over = fenced(&"x".repeat(100));
130 assert_eq!(
131 run_on("https://bugs.kde.org/show_bug.cgi?id=123", &over).len(),
132 1
133 );
134
135 let dir = ki_fixture_upstream("placeholder", &over);
136 let record = dir.path().join("_docs/reference/known-issues/KI-vendor.md");
137 let text = std::fs::read_to_string(&record)
138 .unwrap()
139 .replace("upstream: placeholder", &format!(" Upstream: {BUGZILLA}"));
140 std::fs::write(&record, text).unwrap();
141 let ctx = GateCtx::new(dir.path().to_str().unwrap());
142 assert_eq!(run(&ctx, &[]).unwrap().len(), 1);
143 }
144
145 #[test]
146 fn rejects_body_text_outside_a_fence() {
147 let out = run_on(BUGZILLA, "# V\n## Report\nloose text\n");
148 assert_eq!(out.len(), 1);
149 assert!(out[0].ends_with(": body outside a fence"));
150 }
151
152 #[test]
153 fn a_non_bugzilla_tracker_is_out_of_scope() {
154 assert!(run_on("https://github.com/x/y/issues/1", &fenced(&"x".repeat(100))).is_empty());
155 }
156}