1#[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 #[arg(long)]
34 fix: bool,
35
36 #[arg(long, global = true)]
38 dry_run: bool,
39
40 #[arg(long)]
42 ci: bool,
43
44 #[arg(long)]
46 init: bool,
47
48 #[arg(long)]
50 list: bool,
51
52 #[arg(long, value_name = "PATH")]
54 parse_config: Option<String>,
55
56 #[arg(long)]
58 detail: Option<String>,
59
60 #[arg(long)]
62 llm: bool,
63
64 #[arg(long, short = 'F', global = true)]
66 filter: Vec<String>,
67
68 #[arg(long)]
70 suppressions: bool,
71
72 #[arg(long, short, global = true)]
74 quiet: bool,
75
76 #[arg(long, short = 'd', global = true)]
78 dirty: bool,
79
80 #[arg(long)]
82 strict: bool,
83}
84
85#[derive(Subcommand)]
86enum TidyCommand {
87 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(®istered);
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(®istered);
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}