stern4rust/reporting/rule_listing.rs
1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use crate::reporting::output_format::OutputFormat;
6use crate::reporting::rule_explanation::RuleExplanation;
7use serde_json::Value;
8use serde_json::json;
9use serde_json::to_string_pretty;
10
11// The rule set as a document, in whichever form was asked for.
12//
13// This is not a report and it does not live on either printer, though it began
14// there. A printer holds what one run found -- files scanned, offences kept,
15// which rules were applied -- and renders that. A listing has no run behind it:
16// nothing was scanned, nothing was counted, and every field a printer carries
17// would be empty. The give-away was that both rendering functions took no
18// `self` and read no field; they were free functions wearing a printer's name.
19//
20// So the listing is its own subject, and it takes the format rather than the
21// caller choosing a printer by hand. That is also what keeps the two forms
22// honest with each other -- one type renders both, from one list, which is
23// ADR-MachineReadableReport's requirement that the two must not give different
24// pictures.
25pub struct RuleListing<'a> {
26 explanations: &'a [RuleExplanation],
27}
28
29impl<'a> RuleListing<'a> {
30 pub fn new(explanations: &'a [RuleExplanation]) -> Self {
31 Self { explanations }
32 }
33
34 pub fn render(&self, format: OutputFormat) -> String {
35 match format {
36 OutputFormat::Json => self.json(),
37 OutputFormat::Text => self.text(),
38 }
39 }
40
41 // One section per rule, in registry order, so the listing reads in the same
42 // order as the roster a report prints.
43 fn text(&self) -> String {
44 let mut out = String::from(
45 "stern4rust rules
46",
47 );
48 for entry in self.explanations {
49 out.push_str(&format!(
50 "
51{}
52 {}
53",
54 entry.name, entry.summary
55 ));
56 out.push_str(&Self::block("breaks", entry.breaks));
57 out.push_str(&Self::block("instead", entry.instead));
58 }
59 out
60 }
61
62 fn json(&self) -> String {
63 let entries: Vec<Value> = self
64 .explanations
65 .iter()
66 .map(|entry| {
67 json!({
68 "name": entry.name,
69 "summary": entry.summary,
70 "breaks": entry.breaks,
71 "instead": entry.instead,
72 })
73 })
74 .collect();
75 to_string_pretty(&json!({ "rules": entries }))
76 .unwrap_or_else(|_| String::from("{\"rules\":[]}"))
77 }
78
79 // Indented so a multi-line example stays one block rather than running into
80 // the next label.
81 fn block(label: &str, body: &str) -> String {
82 let mut out = format!(
83 "
84 {label}:
85"
86 );
87 for line in body.lines() {
88 out.push_str(&format!(
89 " {line}
90"
91 ));
92 }
93 out
94 }
95}