1use std::fmt;
13
14#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
18pub enum Rule {
19 AsciiOnly,
21 LineLength,
23 NoAnsi,
25 ErrorPrefix,
27 WarningPrefix,
29 ConfirmOnSet,
31 ListCountSummary,
33 LabelColonValue,
35 BooleanWords,
37 NoNakedPrint,
39 NoCursorMoves,
41 UnitsSpelledOut,
43 NoAdHocTimestamps,
45 StdoutStderrSeparation,
47}
48
49impl Rule {
50 #[must_use]
53 pub const fn id(self) -> &'static str {
54 match self {
55 Self::AsciiOnly => "R1",
56 Self::LineLength => "R2",
57 Self::NoAnsi => "R3",
58 Self::ErrorPrefix => "R4",
59 Self::WarningPrefix => "R5",
60 Self::ConfirmOnSet => "R6",
61 Self::ListCountSummary => "R7",
62 Self::LabelColonValue => "R8",
63 Self::BooleanWords => "R9",
64 Self::NoNakedPrint => "R10",
65 Self::NoCursorMoves => "R11",
66 Self::UnitsSpelledOut => "R12",
67 Self::NoAdHocTimestamps => "R13",
68 Self::StdoutStderrSeparation => "R14",
69 }
70 }
71
72 #[must_use]
75 pub const fn description(self) -> &'static str {
76 match self {
77 Self::AsciiOnly => "ASCII only",
78 Self::LineLength => "line under 80 chars",
79 Self::NoAnsi => "no ANSI escapes",
80 Self::ErrorPrefix => "error prefix",
81 Self::WarningPrefix => "warning prefix",
82 Self::ConfirmOnSet => "confirm after set",
83 Self::ListCountSummary => "list count summary",
84 Self::LabelColonValue => "label colon value",
85 Self::BooleanWords => "booleans as words",
86 Self::NoNakedPrint => "no naked print",
87 Self::NoCursorMoves => "no cursor or spinners",
88 Self::UnitsSpelledOut => "units spelled out",
89 Self::NoAdHocTimestamps => "timestamps only via aprintln",
90 Self::StdoutStderrSeparation => "stdout and stderr separated",
91 }
92 }
93}
94
95#[derive(Clone, Debug, PartialEq, Eq)]
101pub struct Violation {
102 pub rule: Rule,
104 pub line: usize,
106 pub message: String,
108}
109
110impl fmt::Display for Violation {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 write!(
113 f,
114 "{} {}: line {}: {}",
115 self.rule.id(),
116 self.rule.description(),
117 self.line,
118 self.message,
119 )
120 }
121}
122
123const BAD_UNITS: &[&str] = &["MHz", "kHz", "GHz", "Hz", "mW", "kW", "dB"];
127
128fn check_r3_no_ansi(line: &str, violations: &mut Vec<Violation>) -> bool {
131 line.as_bytes()
132 .iter()
133 .position(|&b| b == 0x1B)
134 .is_some_and(|offset| {
135 violations.push(Violation {
136 rule: Rule::NoAnsi,
137 line: 0,
138 message: format!("ESC byte at offset {offset}"),
139 });
140 true
141 })
142}
143
144fn check_r1_ascii(line: &str, violations: &mut Vec<Violation>) {
146 for (offset, byte) in line.as_bytes().iter().enumerate() {
147 if !(0x20..=0x7E).contains(byte) {
148 violations.push(Violation {
149 rule: Rule::AsciiOnly,
150 line: 0,
151 message: format!("byte 0x{byte:02X} at offset {offset}"),
152 });
153 break;
157 }
158 }
159}
160
161fn check_r2_line_length(line: &str, violations: &mut Vec<Violation>) {
163 let char_count = line.chars().count();
164 if char_count > 80 {
165 violations.push(Violation {
166 rule: Rule::LineLength,
167 line: 0,
168 message: format!("{char_count} characters (max 80)"),
169 });
170 }
171}
172
173fn check_r4_error_prefix(line: &str, violations: &mut Vec<Violation>) {
177 if let Some(first_word) = line.split_whitespace().next() {
178 let lower = first_word.to_lowercase();
179 let looks_like_error = lower == "error"
180 || lower == "error:"
181 || (lower.starts_with("error") && lower.len() <= 7);
182 if looks_like_error && !line.starts_with("Error: ") {
183 violations.push(Violation {
184 rule: Rule::ErrorPrefix,
185 line: 0,
186 message: format!(
187 "line looks like an error report but does not start with \"Error: \" (first word: {first_word:?})"
188 ),
189 });
190 }
191 }
192}
193
194fn check_r5_warning_prefix(line: &str, violations: &mut Vec<Violation>) {
197 if let Some(first_word) = line.split_whitespace().next() {
198 let lower = first_word.to_lowercase();
199 let looks_like_warning = lower == "warning"
200 || lower == "warning:"
201 || (lower.starts_with("warning") && lower.len() <= 9);
202 if looks_like_warning && !line.starts_with("Warning: ") {
203 violations.push(Violation {
204 rule: Rule::WarningPrefix,
205 line: 0,
206 message: format!(
207 "line looks like a warning but does not start with \"Warning: \" (first word: {first_word:?})"
208 ),
209 });
210 }
211 }
212}
213
214fn check_r9_boolean_words(line: &str, violations: &mut Vec<Violation>) {
217 for bad in [": true", ": false"] {
218 if let Some(offset) = line.find(bad) {
219 let next = line.as_bytes().get(offset + bad.len());
220 let is_standalone = next.is_none_or(|&b| !b.is_ascii_alphanumeric());
221 if is_standalone {
222 violations.push(Violation {
223 rule: Rule::BooleanWords,
224 line: 0,
225 message: format!(
226 "boolean literal {bad:?} at offset {offset} - use on/off instead"
227 ),
228 });
229 }
230 }
231 }
232}
233
234fn check_r12_units(line: &str, violations: &mut Vec<Violation>) {
239 let bytes = line.as_bytes();
240 for unit in BAD_UNITS {
241 let mut search_start = 0;
242 while let Some(rel_offset) = line[search_start..].find(unit) {
243 let offset = search_start + rel_offset;
244 search_start = offset + unit.len();
245
246 let prev = if offset == 0 { b' ' } else { bytes[offset - 1] };
247 let next = bytes.get(offset + unit.len()).copied().unwrap_or(b' ');
248
249 let prev_ok = !prev.is_ascii_alphanumeric();
250 let next_ok = !next.is_ascii_alphanumeric();
251
252 if prev_ok && next_ok {
253 violations.push(Violation {
254 rule: Rule::UnitsSpelledOut,
255 line: 0,
256 message: format!("shorthand unit {unit:?} at offset {offset} - spell it out"),
257 });
258 break;
259 }
260 }
261 }
262}
263
264fn check_r8_label_colon(line: &str, violations: &mut Vec<Violation>) {
272 const DIRECT_VERBS: &[&str] = &["tuned", "stepped", "recalled"];
276 const VERB_PHRASES: &[&str] = &[" set to "];
281 for prefix in ["Band A ", "Band B "] {
282 if !line.starts_with(prefix) || line.contains(": ") {
283 continue;
284 }
285 let rest = &line[prefix.len()..];
286 let first = rest.split_whitespace().next().unwrap_or("");
288 if DIRECT_VERBS.contains(&first) {
289 continue;
290 }
291 if VERB_PHRASES.iter().any(|p| rest.contains(p)) {
293 continue;
294 }
295 violations.push(Violation {
296 rule: Rule::LabelColonValue,
297 line: 0,
298 message: format!("line starts with {prefix:?} but has no colon separator"),
299 });
300 break;
301 }
302}
303
304pub fn check_line(line: &str) -> Result<(), Vec<Violation>> {
315 let mut violations = Vec::new();
316
317 if check_r3_no_ansi(line, &mut violations) {
320 return Err(violations);
321 }
322
323 check_r1_ascii(line, &mut violations);
324 check_r2_line_length(line, &mut violations);
325 check_r4_error_prefix(line, &mut violations);
326 check_r5_warning_prefix(line, &mut violations);
327 check_r9_boolean_words(line, &mut violations);
328 check_r12_units(line, &mut violations);
329 check_r8_label_colon(line, &mut violations);
330
331 if violations.is_empty() {
332 Ok(())
333 } else {
334 Err(violations)
335 }
336}
337
338pub fn check_output(output: &str) -> Result<(), Vec<Violation>> {
349 let mut violations = Vec::new();
350 let trimmed = output.strip_suffix('\n').unwrap_or(output);
355 for (line_no, line) in trimmed.split('\n').enumerate() {
356 if let Err(mut v) = check_line(line) {
357 for violation in &mut v {
358 violation.line = line_no;
359 }
360 violations.append(&mut v);
361 }
362 }
363 if violations.is_empty() {
364 Ok(())
365 } else {
366 Err(violations)
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn stub_check_line_accepts_empty() {
376 assert!(check_line("").is_ok());
377 }
378
379 #[test]
380 fn stub_check_output_accepts_empty() {
381 assert!(check_output("").is_ok());
382 }
383
384 #[test]
385 fn check_output_processes_intentional_blank_lines() {
386 let result = check_output("first line\n\nthird line\n");
396 assert!(result.is_ok(), "check_output should process blank lines");
397 }
398
399 #[test]
400 fn check_output_strips_trailing_newline_only_once() {
401 let result = check_output("a\n\n");
406 assert!(result.is_ok());
407 }
408
409 #[test]
410 fn rule_ids_are_r1_through_r14() {
411 assert_eq!(Rule::AsciiOnly.id(), "R1");
412 assert_eq!(Rule::LineLength.id(), "R2");
413 assert_eq!(Rule::NoAnsi.id(), "R3");
414 assert_eq!(Rule::ErrorPrefix.id(), "R4");
415 assert_eq!(Rule::WarningPrefix.id(), "R5");
416 assert_eq!(Rule::ConfirmOnSet.id(), "R6");
417 assert_eq!(Rule::ListCountSummary.id(), "R7");
418 assert_eq!(Rule::LabelColonValue.id(), "R8");
419 assert_eq!(Rule::BooleanWords.id(), "R9");
420 assert_eq!(Rule::NoNakedPrint.id(), "R10");
421 assert_eq!(Rule::NoCursorMoves.id(), "R11");
422 assert_eq!(Rule::UnitsSpelledOut.id(), "R12");
423 assert_eq!(Rule::NoAdHocTimestamps.id(), "R13");
424 assert_eq!(Rule::StdoutStderrSeparation.id(), "R14");
425 }
426
427 #[test]
428 fn violation_display_includes_rule_id_and_line() {
429 let v = Violation {
430 rule: Rule::AsciiOnly,
431 line: 3,
432 message: "byte 0xE2 at offset 14".to_string(),
433 };
434 let rendered = v.to_string();
435 assert!(rendered.contains("R1"));
436 assert!(rendered.contains("ASCII only"));
437 assert!(rendered.contains("line 3"));
438 assert!(rendered.contains("byte 0xE2 at offset 14"));
439 }
440
441 #[test]
442 fn r1_accepts_plain_ascii() {
443 assert!(check_line("Band A frequency: 146.52 megahertz").is_ok());
444 assert!(check_line("Error: invalid frequency").is_ok());
445 assert!(check_line("").is_ok());
446 }
447
448 #[test]
449 fn r1_rejects_unicode_right_arrow() {
450 assert!(check_line("Forwarding RF \u{2192} internet").is_err());
451 }
452
453 #[test]
454 fn r1_rejects_em_dash() {
455 assert!(check_line("stopped \u{2014} good").is_err());
456 }
457
458 #[test]
459 fn r1_rejects_left_right_arrow() {
460 assert!(check_line("RF \u{2194} internet").is_err());
461 }
462
463 #[test]
464 fn r1_rejects_tab() {
465 assert!(check_line("Band\tA").is_err());
466 }
467
468 #[test]
469 fn r1_rejects_carriage_return() {
470 assert!(check_line("partial\rline").is_err());
471 }
472
473 #[test]
474 fn r1_rejects_null_byte() {
475 assert!(check_line("foo\0bar").is_err());
476 }
477
478 #[test]
479 fn r1_violation_includes_byte_offset() {
480 let err = check_line("hi \u{2192} there").unwrap_err();
481 let v = err.iter().find(|v| v.rule == Rule::AsciiOnly).unwrap();
482 assert!(v.message.contains("offset 3"));
483 }
484
485 #[test]
486 fn r2_accepts_80_char_line() {
487 let s = "a".repeat(80);
488 assert!(check_line(&s).is_ok());
489 }
490
491 #[test]
492 fn r2_rejects_81_char_line() {
493 let s = "a".repeat(81);
494 let err = check_line(&s).unwrap_err();
495 assert!(err.iter().any(|v| v.rule == Rule::LineLength));
496 }
497
498 #[test]
499 fn r2_violation_reports_actual_length() {
500 let s = "a".repeat(83);
501 let err = check_line(&s).unwrap_err();
502 let v = err.iter().find(|v| v.rule == Rule::LineLength).unwrap();
503 assert!(v.message.contains("83"));
504 }
505
506 #[test]
507 fn r3_rejects_ansi_color() {
508 let err = check_line("\x1b[31mError\x1b[0m").unwrap_err();
509 assert!(err.iter().any(|v| v.rule == Rule::NoAnsi));
510 }
511
512 #[test]
513 fn r3_rejects_cursor_move() {
514 let err = check_line("\x1b[2J").unwrap_err();
515 assert!(err.iter().any(|v| v.rule == Rule::NoAnsi));
516 }
517
518 #[test]
519 fn r3_accepts_plain_text() {
520 assert!(check_line("Band A frequency: 146.52 megahertz").is_ok());
521 }
522
523 #[test]
524 fn r4_accepts_proper_error_prefix() {
525 assert!(check_line("Error: invalid frequency").is_ok());
526 assert!(check_line("Error: something went wrong").is_ok());
527 }
528
529 #[test]
530 fn r4_accepts_line_without_error_word() {
531 assert!(check_line("Band A frequency: 146.52 megahertz").is_ok());
532 }
533
534 #[test]
535 fn r4_rejects_missing_prefix_on_error_line() {
536 let err = check_line("error: bad thing happened").unwrap_err();
537 assert!(err.iter().any(|v| v.rule == Rule::ErrorPrefix));
538 }
539
540 #[test]
541 fn r4_rejects_uppercase_no_colon() {
542 let err = check_line("Error invalid frequency").unwrap_err();
543 assert!(err.iter().any(|v| v.rule == Rule::ErrorPrefix));
544 }
545
546 #[test]
547 fn r4_accepts_word_errors_in_declarative_line() {
548 assert!(check_line("Total errors found: 0").is_ok());
549 }
550
551 #[test]
552 fn r5_accepts_proper_warning_prefix() {
553 assert!(check_line("Warning: authentication failed").is_ok());
554 }
555
556 #[test]
557 fn r5_rejects_lowercase_warning() {
558 let err = check_line("warning: auth failed").unwrap_err();
559 assert!(err.iter().any(|v| v.rule == Rule::WarningPrefix));
560 }
561
562 #[test]
563 fn r5_rejects_missing_colon() {
564 let err = check_line("Warning auth failed").unwrap_err();
565 assert!(err.iter().any(|v| v.rule == Rule::WarningPrefix));
566 }
567
568 #[test]
569 fn r9_accepts_on_off() {
570 assert!(check_line("Key lock: on").is_ok());
571 assert!(check_line("Bluetooth: off").is_ok());
572 }
573
574 #[test]
575 fn r9_rejects_true() {
576 let err = check_line("Key lock: true").unwrap_err();
577 assert!(err.iter().any(|v| v.rule == Rule::BooleanWords));
578 }
579
580 #[test]
581 fn r9_rejects_false() {
582 let err = check_line("Bluetooth: false").unwrap_err();
583 assert!(err.iter().any(|v| v.rule == Rule::BooleanWords));
584 }
585
586 #[test]
587 fn r9_accepts_truth_in_word() {
588 assert!(check_line("The truth is simple").is_ok());
589 }
590
591 #[test]
592 fn r12_accepts_spelled_units() {
593 assert!(check_line("Band A frequency: 146.52 megahertz").is_ok());
594 assert!(check_line("Band A power: high, 5 watts").is_ok());
595 }
596
597 #[test]
598 fn r12_rejects_mhz_shorthand() {
599 let err = check_line("146.52 MHz").unwrap_err();
600 assert!(err.iter().any(|v| v.rule == Rule::UnitsSpelledOut));
601 }
602
603 #[test]
604 fn r12_rejects_khz_shorthand() {
605 let err = check_line("step 25 kHz").unwrap_err();
606 assert!(err.iter().any(|v| v.rule == Rule::UnitsSpelledOut));
607 }
608
609 #[test]
610 fn r12_rejects_db_shorthand() {
611 let err = check_line("signal -110 dB").unwrap_err();
612 assert!(err.iter().any(|v| v.rule == Rule::UnitsSpelledOut));
613 }
614
615 #[test]
616 fn r12_accepts_hz_inside_word() {
617 assert!(check_line("Frequency in hertz").is_ok());
618 }
619
620 #[test]
621 fn r8_accepts_label_colon_value() {
622 assert!(check_line("Band A frequency: 146.52 megahertz").is_ok());
623 assert!(check_line("Radio model: TH-D75").is_ok());
624 }
625
626 #[test]
627 fn r8_accepts_imperative_sentence() {
628 assert!(check_line("Reading radio status, please wait.").is_ok());
629 }
630
631 #[test]
632 fn r8_rejects_labeled_line_without_colon() {
633 let err = check_line("Band A frequency 146.52 megahertz").unwrap_err();
634 assert!(err.iter().any(|v| v.rule == Rule::LabelColonValue));
635 }
636}