Skip to main content

wowlab_tidy/
llm.rs

1// #t(file: rust_alloc_in_loop) markdown document builder uses format! throughout loops by design
2// #t(file: rust_println) --llm CLI output writes the document to stdout by design
3// #t(file: rust_vec_string_field) rule-info structs are built then immediately rendered; boxing buys nothing
4
5use std::{collections::BTreeMap, process::ExitCode};
6
7use wowlab_common::markdown::{self, Doc, Table};
8use wowlab_fs::path::Path;
9
10use crate::{Config, Example, ParamDefault, RuleKind, RuleParam, Severity, Violation, runner};
11
12struct LlmRuleInfo {
13    name: &'static str,
14    kind: RuleKind,
15    description: &'static str,
16    justification: &'static str,
17    severity: Severity,
18    examples: &'static [Example],
19    params: &'static [RuleParam],
20    enabled: bool,
21    fixable: bool,
22    ignore_patterns: Vec<String>,
23}
24
25fn gather_rules(config: &Config, rule_filter: &[String]) -> Vec<LlmRuleInfo> {
26    crate::all_rules()
27        .into_iter()
28        .filter(|r| rule_filter.is_empty() || rule_filter.iter().any(|f| f == r.name))
29        .map(|r| LlmRuleInfo {
30            name: r.name,
31            kind: r.kind,
32            description: r.description,
33            justification: r.justification,
34            severity: r.severity,
35            examples: r.examples,
36            params: r.params,
37            enabled: config.is_enabled(r.name),
38            fixable: r.fixable,
39            ignore_patterns: config.ignore_patterns(r.name).to_vec(),
40        })
41        .collect()
42}
43
44fn render_summary_table(doc: Doc, rules: &[LlmRuleInfo]) -> Doc {
45    let mut table = Table::new().headers([
46        "Rule",
47        "Severity",
48        "Type",
49        "Enabled",
50        "Fixable",
51        "Description",
52    ]);
53
54    for r in rules {
55        let enabled = if r.enabled { "yes" } else { "no" };
56        let fixable = if r.fixable { "yes" } else { "no" };
57
58        table = table.row([
59            r.name,
60            r.severity.as_str(),
61            r.kind.as_str(),
62            enabled,
63            fixable,
64            r.description,
65        ]);
66    }
67
68    doc.raw(table.build_markdown()).blank()
69}
70
71fn render_rule_details(mut doc: Doc, rules: &[LlmRuleInfo]) -> Doc {
72    for r in rules {
73        doc = doc.h3(r.name).blank().line(r.description).blank();
74
75        if !r.justification.is_empty() {
76            doc = doc.quote(r.justification).blank();
77        }
78
79        let enabled = if r.enabled { "yes" } else { "no" };
80        let fixable = if r.fixable { "yes" } else { "no" };
81        let mut meta = Table::new()
82            .headers(["", ""])
83            .row(["Severity", r.severity.as_str()])
84            .row(["Type", r.kind.as_str()])
85            .row(["Enabled", enabled])
86            .row(["Fixable", fixable]);
87
88        for p in r.params {
89            let type_str = match p.param_type {
90                crate::ParamType::Int => "i64",
91                crate::ParamType::StringArray => "[String]",
92            };
93            let default_str = match &p.default {
94                ParamDefault::Int(d) => d.to_string(),
95                ParamDefault::StringArray(d) => format!("{d:?}"),
96            };
97            let label = format!("Param: {}", p.name);
98            let value = format!("{type_str}, default = {default_str}");
99
100            meta = meta.row([&label, &value]);
101        }
102
103        doc = doc.raw(meta.build_markdown());
104
105        if !r.ignore_patterns.is_empty() {
106            let patterns = r
107                .ignore_patterns
108                .iter()
109                .map(markdown::code)
110                .collect::<Vec<_>>()
111                .join(", ");
112
113            doc = doc.kv_bullet("Ignored paths", &patterns);
114        }
115
116        doc = doc.blank();
117
118        let bad: Vec<_> = r.examples.iter().filter(|e| !e.pass).collect();
119        let good: Vec<_> = r.examples.iter().filter(|e| e.pass).collect();
120
121        if !bad.is_empty() {
122            doc = doc
123                .line(&markdown::bold("Bad (triggers violation):"))
124                .blank();
125
126            for ex in &bad {
127                if !ex.label.is_empty() {
128                    doc = doc.line(&markdown::italic(ex.label));
129                }
130
131                doc = doc.code_block("rust", ex.code).blank();
132            }
133        }
134
135        if !good.is_empty() {
136            doc = doc.line(&markdown::bold("Good (passes):")).blank();
137
138            for ex in &good {
139                if !ex.label.is_empty() {
140                    doc = doc.line(&markdown::italic(ex.label));
141                }
142
143                doc = doc.code_block("rust", ex.code).blank();
144            }
145        }
146    }
147
148    doc
149}
150
151fn render_violations(mut doc: Doc, violations: &[Violation]) -> Doc {
152    if violations.is_empty() {
153        return doc.line("No violations found.").blank();
154    }
155
156    doc = doc
157        .line(&markdown::bold(format!(
158            "{} violation(s) found. Fix these:",
159            violations.len()
160        )))
161        .blank();
162
163    let mut by_rule: BTreeMap<&str, Vec<&Violation>> = BTreeMap::new();
164
165    for v in violations {
166        by_rule.entry(v.rule_name()).or_default().push(v);
167    }
168
169    for (rule, vs) in &by_rule {
170        doc = doc.h3(&format!("{rule} ({} issues)", vs.len())).blank();
171
172        for v in vs {
173            let loc = format!("{}:{}", v.rel, v.line);
174
175            doc = doc.def(&loc, &v.message);
176        }
177
178        doc = doc.blank();
179    }
180
181    doc
182}
183
184#[rustfmt::skip]
185const DIRECTIVES: &[(&str, &str)] = &[
186    // #t:aligned
187    ("// #t(rule) reason"        ,          "suppress the next line for this rule"),
188    ("// #t(rule1, rule2) reason",  "suppress the next line for multiple rules")   ,
189    ("// #t(file: rule) reason"  ,    "skip the entire file for this rule")        ,
190    ("// #t(block: rule) reason" ,   "suppress until blank line or closing brace") ,
191    ("// #t(fn: rule) reason"    ,      "suppress until end of next function")     ,
192    ("// #t(*) reason"           ,             "suppress all rules (next line)")   ,
193];
194
195/// Render the rule catalog and current violations as a markdown document for LLM consumption.
196// #t(rust_param_order_consistency) CLI entry mirrors clap argument order; reordering is out of scope here
197pub fn print(
198    rule_filter: &[String],
199    crate_filter: &[String],
200    config: &Config,
201    root: &Path,
202    crates_dir: &Path,
203) -> ExitCode {
204    let rules = gather_rules(config, rule_filter);
205
206    let mut doc = Doc::new()
207        .h1("tidy — Rust source code linter")
208        .blank()
209        .line("Custom lint rules for the WoW Lab Rust workspace. Runs beyond what clippy and rustfmt cover.")
210        .blank();
211
212    let severity_table = Table::new()
213        .headers(["Severity", "Meaning"])
214        .row([
215            "high",
216            "Likely bug, security issue, or correctness problem. Fix these first.",
217        ])
218        .row([
219            "medium",
220            "Potential bug, performance issue, or maintainability concern.",
221        ])
222        .row([
223            "low",
224            "Style nit or minor improvement. Safe to suppress with good reason.",
225        ]);
226
227    doc = doc
228        .h2("Severity levels")
229        .blank()
230        .raw(severity_table.build_markdown())
231        .blank();
232
233    doc = doc
234        .h2("Suppression directives")
235        .blank()
236        .line("When a rule fires on code that is intentionally written that way, suppress it with a directive comment:")
237        .blank();
238
239    for (syntax, desc) in DIRECTIVES {
240        doc = doc.def(syntax, desc);
241    }
242
243    doc = doc
244        .blank()
245        .line("Rule names and a reason after `)` are required. Missing either is a violation.")
246        .blank();
247
248    doc = doc.h2("Rules").blank();
249    doc = render_summary_table(doc, &rules);
250
251    doc = doc.h2("Rule details").blank();
252    doc = render_rule_details(doc, &rules);
253
254    doc = doc.h2("Current violations").blank();
255    let run_ctx = runner::RunCtx {
256        rule_filter,
257        crate_filter,
258        config,
259        root,
260        crates_dir,
261        quiet: true,
262        dirty: false,
263    };
264    let violations = match runner::collect_violations(&run_ctx, false, None) {
265        Ok((violations, _, _)) => violations,
266        Err(error) => {
267            eprintln!("tidy: analysis aborted: {error}");
268
269            return ExitCode::FAILURE;
270        }
271    };
272
273    doc = render_violations(doc, &violations);
274
275    print!("{}", doc.build());
276
277    ExitCode::SUCCESS
278}