Skip to main content

tidy/
main.rs

1// #t(file: rust_vec_string_field) clap-populated argument vectors must stay growable
2
3//! Command-line entry point for the multi-language workspace linter.
4
5#[global_allocator]
6static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
7
8use std::{
9    ffi::OsStr,
10    process::{Command, ExitCode},
11};
12
13use clap::{Parser, Subcommand};
14use tabled::Tabled;
15use wowlab_common::{cli, output};
16use wowlab_fs::{atomic, path::Path};
17use wowlab_tidy::{Config, RuleParam, llm, runner};
18
19#[derive(Parser)]
20#[command(name = "tidy", about = "Wow Lab source code tidy checker")]
21#[expect(
22    clippy::struct_excessive_bools,
23    reason = "independent command-line switches are represented directly as clap booleans"
24)]
25struct Args {
26    #[command(subcommand)]
27    command: Option<TidyCommand>,
28
29    #[arg(long, short)]
30    rule: Vec<String>,
31
32    /// Auto-fix violations that have fixes available.
33    #[arg(long)]
34    fix: bool,
35
36    /// Preview fixes without writing (requires --fix).
37    #[arg(long, global = true)]
38    dry_run: bool,
39
40    /// Run rules, formatting, generators, and warning-denied workspace Clippy.
41    #[arg(long)]
42    ci: bool,
43
44    /// Generate a tidy.toml config with all registered rules enabled.
45    #[arg(long)]
46    init: bool,
47
48    /// List all registered rules with descriptions.
49    #[arg(long)]
50    list: bool,
51
52    /// Parse a tidy config file, merge with registered rule metadata, and output as JSON.
53    #[arg(long, value_name = "PATH")]
54    parse_config: Option<String>,
55
56    /// Show detailed info for a specific rule (description, examples).
57    #[arg(long)]
58    detail: Option<String>,
59
60    /// Output all rules, details, and current violations as markdown for LLM consumption.
61    #[arg(long)]
62    llm: bool,
63
64    /// Only check these crates (e.g. `--filter common --filter engine`).
65    #[arg(long, short = 'F', global = true)]
66    filter: Vec<String>,
67
68    /// Report all suppression directives in the codebase.
69    #[arg(long)]
70    suppressions: bool,
71
72    /// Suppress all output except violation counts (exit code still reflects pass/fail).
73    #[arg(long, short, global = true)]
74    quiet: bool,
75
76    /// Only check files with uncommitted changes (staged + unstaged, via `git diff`).
77    #[arg(long, short = 'd', global = true)]
78    dirty: bool,
79
80    /// Treat config warnings (missing/extra rules) as errors.
81    #[arg(long)]
82    strict: bool,
83}
84
85#[derive(Subcommand)]
86enum TidyCommand {
87    /// Remove suppression targets that no longer hide a tidy violation.
88    Clean,
89}
90
91fn all_rules_meta() -> Vec<(&'static str, &'static [RuleParam])> {
92    wowlab_tidy::all_rules()
93        .into_iter()
94        .map(|r| (r.name, r.params))
95        .collect()
96}
97
98fn main() -> ExitCode {
99    let args = Args::parse();
100    let app = cli::boot(
101        "tidy",
102        env!("CARGO_PKG_VERSION"),
103        args.quiet || args.llm,
104        "TIDY_ROOT",
105    );
106    let crates_dir = app.crates_dir();
107    let config_path = crates_dir.join("tidy.toml");
108
109    if args.list {
110        print_rule_list();
111
112        return ExitCode::SUCCESS;
113    }
114
115    if let Some(ref config_path) = args.parse_config {
116        return print_parsed_config(config_path);
117    }
118
119    if let Some(ref rule_name) = args.detail {
120        return print_rule_detail(rule_name);
121    }
122
123    if args.init {
124        return handle_init(&config_path);
125    }
126
127    let config = match load_and_validate_config(&config_path, args.strict) {
128        Ok(c) => c,
129        Err(code) => return code,
130    };
131
132    if matches!(args.command, Some(TidyCommand::Clean)) {
133        let run_ctx = runner::RunCtx {
134            rule_filter: &[],
135            crate_filter: &args.filter,
136            config: &config,
137            root: &app.root,
138            crates_dir: &crates_dir,
139            quiet: args.quiet,
140            dirty: args.dirty,
141        };
142
143        return runner::clean_suppressions(&run_ctx, args.dry_run);
144    }
145
146    if args.llm {
147        return llm::print(&args.rule, &args.filter, &config, &app.root, &crates_dir);
148    }
149
150    if args.suppressions {
151        return runner::report_suppressions(
152            &args.filter,
153            &config,
154            &app.root,
155            &crates_dir,
156            args.quiet,
157        );
158    }
159
160    let fix_mode = match (args.fix, args.dry_run) {
161        (true, true) => runner::FixMode::DryRun,
162        (true, false) => runner::FixMode::Apply,
163        _ => runner::FixMode::Off,
164    };
165
166    let run_ctx = runner::RunCtx {
167        rule_filter: &args.rule,
168        crate_filter: &args.filter,
169        config: &config,
170        root: &app.root,
171        crates_dir: &crates_dir,
172        quiet: args.quiet,
173        dirty: args.dirty,
174    };
175
176    let mut failed = !runner::run(&run_ctx, fix_mode);
177
178    if args.ci {
179        if !run_cargo_check(
180            "fmt",
181            &["+nightly", "fmt", "--all", "--", "--check"],
182            &crates_dir,
183        ) {
184            failed = true;
185        }
186
187        if !run_cargo_check("codegen --check", &["codegen", "--check"], &app.root) {
188            failed = true;
189        }
190
191        if !run_cargo_check("docgen --check", &["docgen", "--check"], &app.root) {
192            failed = true;
193        }
194
195        if !run_cargo_check("tablegen --check", &["tablegen", "--check"], &app.root) {
196            failed = true;
197        }
198
199        let clippy_target_dir =
200            runner::cargo_target_dir(&app.root, &crates_dir).join("tidy-clippy");
201
202        if !run_cargo_check_with_environment(
203            "clippy",
204            &[
205                "clippy",
206                "--workspace",
207                "--all-targets",
208                "--",
209                "-D",
210                "warnings",
211            ],
212            &crates_dir,
213            &[
214                ("CARGO_TARGET_DIR", clippy_target_dir.as_os_str()),
215                ("SQLX_OFFLINE", OsStr::new("true")),
216            ],
217        ) {
218            failed = true;
219        }
220    }
221
222    if failed {
223        ExitCode::FAILURE
224    } else {
225        ExitCode::SUCCESS
226    }
227}
228
229fn load_and_validate_config(config_path: &Path, strict: bool) -> Result<Config, ExitCode> {
230    let mut config = Config::load(config_path).map_err(|e| {
231        output::error(&format!("tidy.toml: {e}"));
232        output::blank();
233        output::error("tidy.toml is required. Run `tidy --init` to generate one.");
234
235        ExitCode::FAILURE
236    })?;
237
238    let registered = all_rules_meta();
239    let (errors, warnings) = config.validate(&registered);
240
241    for w in &warnings {
242        output::warning(w);
243    }
244
245    if strict && !warnings.is_empty() {
246        output::blank();
247        output::error("--strict: config warnings treated as errors.");
248
249        return Err(ExitCode::FAILURE);
250    }
251
252    if !errors.is_empty() {
253        for e in &errors {
254            output::error(e);
255        }
256
257        output::blank();
258        output::error(
259            "tidy.toml has invalid config. \
260             Fix it manually or delete and run `tidy --init`.",
261        );
262
263        return Err(ExitCode::FAILURE);
264    }
265
266    if !warnings.is_empty() {
267        config.backfill_defaults(&registered);
268    }
269
270    Ok(config)
271}
272
273fn handle_init(config_path: &Path) -> ExitCode {
274    let meta = all_rules_meta();
275    let config = Config::generate_default(&meta);
276
277    if let Err(error) = atomic::create(config_path, config.to_toml_string()) {
278        if error.is_already_exists() {
279            output::error(&format!(
280                "tidy.toml already exists at {}",
281                config_path.display()
282            ));
283            output::error("Delete it first if you want to regenerate.");
284        } else {
285            output::error(&format!(
286                "failed to write {}: {error}",
287                config_path.display()
288            ));
289        }
290
291        return ExitCode::FAILURE;
292    }
293
294    output::success(&format!(
295        "Created {} with {} rules enabled.",
296        config_path.display(),
297        meta.len()
298    ));
299
300    ExitCode::SUCCESS
301}
302
303fn run_cargo_check(name: &str, cargo_args: &[&str], crates_dir: &Path) -> bool {
304    run_cargo_check_with_environment(name, cargo_args, crates_dir, &[])
305}
306
307fn run_cargo_check_with_environment(
308    name: &str,
309    cargo_args: &[&str],
310    crates_dir: &Path,
311    environment: &[(&str, &OsStr)],
312) -> bool {
313    output::header(&format!("cargo {name}"));
314
315    let mut command = Command::new("cargo");
316
317    for (name, _) in std::env::vars_os() {
318        if is_cargo_package_context(&name) {
319            command.env_remove(name);
320        }
321    }
322
323    let status = command
324        .args(cargo_args)
325        .current_dir(crates_dir)
326        .envs(environment.iter().copied())
327        .status();
328
329    match status {
330        Ok(s) if s.success() => {
331            output::success(&format!("cargo {name} passed"));
332            output::blank();
333
334            true
335        }
336        Ok(_) => {
337            output::error(&format!("cargo {name} failed"));
338            output::blank();
339
340            false
341        }
342        Err(e) => {
343            output::error(&format!("cargo {name}: {e}"));
344            output::blank();
345
346            false
347        }
348    }
349}
350
351fn is_cargo_package_context(name: &OsStr) -> bool {
352    let Some(name) = name.to_str() else {
353        return false;
354    };
355
356    name.starts_with("CARGO_PKG_")
357        || matches!(
358            name,
359            "CARGO_BIN_NAME"
360                | "CARGO_CRATE_NAME"
361                | "CARGO_MANIFEST_DIR"
362                | "CARGO_MANIFEST_PATH"
363                | "CARGO_PRIMARY_PACKAGE"
364        )
365}
366
367fn print_rule_list() {
368    let rules = wowlab_tidy::all_rules();
369    let rows: Vec<RuleListRow> = rules
370        .iter()
371        .map(|r| {
372            let params_str = r
373                .params
374                .iter()
375                .map(|p| p.name)
376                .collect::<Vec<_>>()
377                .join(", ");
378
379            RuleListRow {
380                name: r.name.to_string(),
381                severity: r.severity.as_str().to_string(),
382                kind: r.kind.as_str().to_string(),
383                fixable: if r.fixable { "yes" } else { "no" }.to_string(),
384                params: params_str,
385                description: r.description.to_string(),
386            }
387        })
388        .collect();
389
390    let fixable = rows.iter().filter(|r| r.fixable == "yes").count();
391
392    output::detail(&format!(
393        "{} rules registered ({fixable} fixable)",
394        rows.len()
395    ));
396    output::blank();
397    output::table(rows);
398}
399
400#[derive(Tabled)]
401#[tabled(crate = "tabled")]
402struct RuleListRow {
403    #[tabled(rename = "Rule")]
404    name: String,
405    #[tabled(rename = "Severity")]
406    severity: String,
407    #[tabled(rename = "Type")]
408    kind: String,
409    #[tabled(rename = "Fixable")]
410    fixable: String,
411    #[tabled(rename = "Params")]
412    params: String,
413    #[tabled(rename = "Description")]
414    description: String,
415}
416
417fn print_parsed_config(config_path: &str) -> ExitCode {
418    let config = match Config::load(Path::new(config_path)) {
419        Ok(c) => c,
420        Err(e) => {
421            output::error(&e.to_string());
422
423            return ExitCode::FAILURE;
424        }
425    };
426
427    let rules = wowlab_tidy::all_rules();
428    let entries = config.resolved_rules(&rules);
429
430    let json = match serde_json::to_string(&entries) {
431        Ok(json) => json,
432        Err(error) => {
433            output::error(&format!("failed to serialize config: {error}"));
434
435            return ExitCode::FAILURE;
436        }
437    };
438
439    println!("{json}");
440
441    ExitCode::SUCCESS
442}
443
444fn print_rule_detail(name: &str) -> ExitCode {
445    use wowlab_tidy::ParamDefault;
446
447    let rules = wowlab_tidy::all_rules();
448    let found = rules.into_iter().find(|r| r.name == name);
449
450    let Some(d) = found else {
451        output::error(&format!("unknown rule: {name}"));
452        output::blank();
453        let meta = all_rules_meta();
454
455        for (n, _) in &meta {
456            output::detail(n);
457        }
458
459        return ExitCode::FAILURE;
460    };
461
462    output::header(name);
463    output::kv("type", d.kind.as_str());
464    output::kv("severity", d.severity.as_str());
465    output::kv("fixable", if d.fixable { "yes" } else { "no" });
466    output::kv("description", d.description);
467
468    if !d.params.is_empty() {
469        output::blank();
470        output::subheader("parameters");
471
472        for p in d.params {
473            let type_str = match p.param_type {
474                wowlab_tidy::ParamType::Int => "i64",
475                wowlab_tidy::ParamType::StringArray => "[String]",
476            };
477            let default_str = match &p.default {
478                ParamDefault::Int(d) => d.to_string(),
479                ParamDefault::StringArray(d) => format!("{d:?}"),
480            };
481
482            output::kv(p.name, &format!("{type_str}, default = {default_str}"));
483        }
484    }
485
486    output::blank();
487    output::detail(d.justification);
488    output::blank();
489
490    let examples = d.examples;
491
492    if examples.is_empty() {
493        output::detail("no examples defined");
494    } else {
495        let bad: Vec<_> = examples.iter().filter(|e| !e.pass).collect();
496        let good: Vec<_> = examples.iter().filter(|e| e.pass).collect();
497
498        if !bad.is_empty() {
499            output::subheader(&format!(
500                "bad ({} example{})",
501                bad.len(),
502                if bad.len() == 1 { "" } else { "s" }
503            ));
504            output::blank();
505
506            for (i, ex) in bad.iter().enumerate() {
507                let label = if ex.label.is_empty() {
508                    "fail"
509                } else {
510                    ex.label
511                };
512
513                output::error(&format!("{}. {label}", i + 1));
514
515                for line in ex.code.lines() {
516                    output::detail(&format!("  {line}"));
517                }
518
519                output::blank();
520            }
521        }
522
523        if !good.is_empty() {
524            output::subheader(&format!(
525                "good ({} example{})",
526                good.len(),
527                if good.len() == 1 { "" } else { "s" }
528            ));
529            output::blank();
530
531            for (i, ex) in good.iter().enumerate() {
532                let label = if ex.label.is_empty() {
533                    "pass"
534                } else {
535                    ex.label
536                };
537
538                output::success(&format!("{}. {label}", i + 1));
539
540                for line in ex.code.lines() {
541                    output::detail(&format!("  {line}"));
542                }
543
544                output::blank();
545            }
546        }
547    }
548
549    ExitCode::SUCCESS
550}
551
552#[cfg(test)]
553mod tests {
554    use googletest::prelude::*;
555    use wowlab_fs::{directory, file, temporary::Directory};
556
557    use super::*;
558
559    #[gtest]
560    fn nested_cargo_drops_only_parent_package_context() -> Result<()> {
561        verify_true!(is_cargo_package_context(OsStr::new("CARGO_MANIFEST_DIR")))?;
562        verify_true!(is_cargo_package_context(OsStr::new("CARGO_PKG_VERSION")))?;
563        verify_true!(is_cargo_package_context(OsStr::new(
564            "CARGO_PRIMARY_PACKAGE"
565        )))?;
566        verify_false!(is_cargo_package_context(OsStr::new("CARGO_HOME")))?;
567
568        verify_false!(is_cargo_package_context(OsStr::new("CARGO_MAKEFLAGS")))
569    }
570
571    #[gtest]
572    fn init_creates_a_complete_config_without_overwriting_it() -> Result<()> {
573        let temporary = Directory::new().or_fail()?;
574        let path = temporary.path().join("tidy.toml");
575
576        verify_eq!(handle_init(&path), ExitCode::SUCCESS)?;
577        let initial = file::read_text(&path).or_fail()?;
578
579        Config::load(&path).or_fail()?;
580
581        verify_eq!(handle_init(&path), ExitCode::FAILURE)?;
582        verify_eq!(file::read_text(&path).or_fail()?, initial)?;
583
584        verify_that!(directory::entries(temporary.path()).or_fail()?, len(eq(1)))
585    }
586}