Skip to main content

tec/
main.rs

1//! Run workspace lint suite concurrently (check or fix modes).
2//!
3//! # Errors
4//! - Workspace root discovery or lint execution fails.
5
6use std::fmt::Write;
7use std::ops::Deref;
8use std::path::Path;
9use std::process::Command;
10use std::process::Output;
11use std::time::Duration;
12use std::time::Instant;
13
14use owo_colors::OwoColorize;
15use ytil_cmd::CmdError;
16use ytil_cmd::CmdExt as _;
17use ytil_sys::cli::Args;
18use ytil_sys::rm::RmFilesOutcome;
19
20/// File suffixes considered Rust-related for conditional lint gating.
21const RUST_EXTENSIONS: &[&str] = &[".rs", "Cargo.toml"];
22
23/// Workspace lint check set.
24///
25/// Contains non-mutating lints safe for fast verification in hooks / CI:
26///
27/// Execution model:
28/// - Each lint spawns in its own thread; parallelism maximizes throughput while retaining deterministic join &
29///   reporting order defined by slice declaration order.
30/// - All runners are started before any join to avoid head-of-line blocking caused by early long-running lints.
31///
32/// Output contract:
33/// - Prints logical name, duration (`time=<Duration>`), status code, and stripped stdout or error.
34/// - Aggregate process exit code is 1 if any lint fails (non-zero status or panic), else 0.
35const LINTS_CHECK: &[(&str, LintBuilder)] = &[
36    ("clippy", |changed_paths| {
37        build_conditional_lint(changed_paths, RUST_EXTENSIONS, |path| {
38            LintFnResult::from(
39                Command::new("cargo")
40                    .args(["clippy", "--all-targets", "--all-features", "--", "-D", "warnings"])
41                    .current_dir(path)
42                    .exec()
43                    .map(LintFnSuccess::CmdOutput)
44                    .map_err(LintFnError::from),
45            )
46        })
47    }),
48    ("cargo fmt", |changed_paths| {
49        build_conditional_lint(changed_paths, RUST_EXTENSIONS, |path| {
50            LintFnResult::from(
51                Command::new("cargo")
52                    .args(["fmt", "--check"])
53                    .current_dir(path)
54                    .exec()
55                    .map(LintFnSuccess::CmdOutput)
56                    .map_err(LintFnError::from),
57            )
58        })
59    }),
60    ("cargo-machete", |changed_paths| {
61        build_conditional_lint(changed_paths, RUST_EXTENSIONS, |path| {
62            LintFnResult::from(
63                // Using `cargo-machete` rather than `cargo machete` to avoid issues caused by passing the
64                // `path`.
65                Command::new("cargo-machete")
66                    .args(["--with-metadata", &path.display().to_string()])
67                    .exec()
68                    .map(LintFnSuccess::CmdOutput)
69                    .map_err(LintFnError::from),
70            )
71        })
72    }),
73    ("cargo-sort", |changed_paths| {
74        build_conditional_lint(changed_paths, RUST_EXTENSIONS, |path| {
75            LintFnResult::from(
76                Command::new("cargo-sort")
77                    .args(["--workspace", "--check", "--check-format"])
78                    .current_dir(path)
79                    .exec()
80                    .map(LintFnSuccess::CmdOutput)
81                    .map_err(LintFnError::from),
82            )
83        })
84    }),
85    ("cargo-sort-derives", |changed_paths| {
86        build_conditional_lint(changed_paths, RUST_EXTENSIONS, |path| {
87            LintFnResult::from(
88                Command::new("cargo-sort-derives")
89                    .args(["sort-derives", "--check"])
90                    .current_dir(path)
91                    .exec()
92                    .map(LintFnSuccess::CmdOutput)
93                    .map_err(LintFnError::from),
94            )
95        })
96    }),
97    ("rust-doc-build", |changed_paths| {
98        build_conditional_lint(changed_paths, RUST_EXTENSIONS, |path| {
99            LintFnResult::from(
100                nomicon::generate_rust_doc(path)
101                    .map(LintFnSuccess::CmdOutput)
102                    .map_err(LintFnError::from),
103            )
104        })
105    }),
106];
107
108/// Workspace lint fix set.
109///
110/// Contains mutating lints for rapid remediation of formatting / unused dependencies / manifest ordering.
111///
112/// Execution model:
113/// - Parallel thread spawn identical to [`LINTS_CHECK`]; ordering of slice elements defines deterministic join &
114///   reporting sequence.
115///
116/// Output contract:
117/// - Same reporting shape as [`LINTS_CHECK`]: name, duration snippet (`time=<Duration>`), status code, stripped stdout
118///   or error.
119/// - Aggregate process exit code is 1 if any lint fails (non-zero status or panic), else 0.
120///
121/// Rationale:
122/// - Focused mutation set avoids accidentally introducing changes via check-only tools.
123/// - Deterministic ordered output aids CI log diffing while retaining concurrency for speed.
124/// - Mirrors structure of [`LINTS_CHECK`] for predictable maintenance (additions require updating both tables).
125const LINTS_FIX: &[(&str, LintBuilder)] = &[
126    ("clippy", |changed_paths| {
127        build_conditional_lint(changed_paths, RUST_EXTENSIONS, |path| {
128            LintFnResult::from(
129                Command::new("cargo")
130                    .args(["clippy", "--all-targets", "--all-features", "--", "-D", "warnings"])
131                    .current_dir(path)
132                    .exec()
133                    .map(LintFnSuccess::CmdOutput)
134                    .map_err(LintFnError::from),
135            )
136        })
137    }),
138    ("cargo fmt", |changed_paths| {
139        build_conditional_lint(changed_paths, RUST_EXTENSIONS, |path| {
140            LintFnResult::from(
141                Command::new("cargo")
142                    .args(["fmt"])
143                    .current_dir(path)
144                    .exec()
145                    .map(LintFnSuccess::CmdOutput)
146                    .map_err(LintFnError::from),
147            )
148        })
149    }),
150    ("cargo-machete", |changed_paths| {
151        build_conditional_lint(changed_paths, RUST_EXTENSIONS, |path| {
152            LintFnResult::from(
153                Command::new("cargo-machete")
154                    .args(["--fix", "--with-metadata", &path.display().to_string()])
155                    .exec()
156                    .map(LintFnSuccess::CmdOutput)
157                    .map_err(LintFnError::from),
158            )
159        })
160    }),
161    ("cargo-sort", |changed_paths| {
162        build_conditional_lint(changed_paths, RUST_EXTENSIONS, |path| {
163            LintFnResult::from(
164                Command::new("cargo-sort")
165                    .args(["--workspace"])
166                    .current_dir(path)
167                    .exec()
168                    .map(LintFnSuccess::CmdOutput)
169                    .map_err(LintFnError::from),
170            )
171        })
172    }),
173    ("cargo-sort-derives", |changed_paths| {
174        build_conditional_lint(changed_paths, RUST_EXTENSIONS, |path| {
175            LintFnResult::from(
176                Command::new("cargo-sort-derives")
177                    .args(["sort-derives"])
178                    .current_dir(path)
179                    .exec()
180                    .map(LintFnSuccess::CmdOutput)
181                    .map_err(LintFnError::from),
182            )
183        })
184    }),
185    ("rm-ds-store", |changed_paths| {
186        build_conditional_lint(changed_paths, &[], |path| {
187            LintFnResult::from(ytil_sys::rm::rm_matching_files(
188                path,
189                ".DS_Store",
190                &[".git", "target"],
191                false,
192            ))
193        })
194    }),
195    ("rust-doc-build", |changed_paths| {
196        build_conditional_lint(changed_paths, RUST_EXTENSIONS, |path| {
197            LintFnResult::from(
198                nomicon::generate_rust_doc(path)
199                    .map(LintFnSuccess::CmdOutput)
200                    .map_err(LintFnError::from),
201            )
202        })
203    }),
204];
205
206/// No-operation lint that reports "skipped" status.
207const LINT_NO_OP: Lint = |_| LintFnResult(Ok(LintFnSuccess::PlainMsg(format!("{}\n", "skipped".bold()))));
208
209/// Function pointer type for a single lint invocation.
210type Lint = fn(&Path) -> LintFnResult;
211
212/// Function pointer type for building lints based on file changes.
213type LintBuilder = fn(&[String]) -> Lint;
214
215/// Newtype wrapper around [`Result<LintFnSuccess, LintFnError>`].
216struct LintFnResult(Result<LintFnSuccess, LintFnError>);
217
218impl Deref for LintFnResult {
219    type Target = Result<LintFnSuccess, LintFnError>;
220
221    fn deref(&self) -> &Self::Target {
222        &self.0
223    }
224}
225
226impl From<Result<LintFnSuccess, LintFnError>> for LintFnResult {
227    fn from(value: Result<LintFnSuccess, LintFnError>) -> Self {
228        Self(value)
229    }
230}
231
232/// Converts [`RmFilesOutcome`] into [`LintFnResult`].
233impl From<RmFilesOutcome> for LintFnResult {
234    fn from(value: RmFilesOutcome) -> Self {
235        let mut msg = String::new();
236        for path in value.removed {
237            let _ = writeln!(&mut msg, "{} {}", "Removed".green(), path.display());
238        }
239        for (path, err) in &value.errors {
240            let _ = writeln!(
241                &mut msg,
242                "{} path{} error={}",
243                "Error removing".red(),
244                path.as_ref().map(|p| format!(" {:?}", p.display())).unwrap_or_default(),
245                format!("{err}").red()
246            );
247        }
248        if value.errors.is_empty() {
249            Self(Ok(LintFnSuccess::PlainMsg(msg)))
250        } else {
251            Self(Err(LintFnError::PlainMsg(msg)))
252        }
253    }
254}
255
256/// Error type for [`Lint`] function execution failures.
257///
258/// # Errors
259/// - [`LintFnError::CmdError`] Process spawning or execution failure.
260/// - [`LintFnError::PlainMsg`] Generic error with a plain message string.
261#[derive(Debug, thiserror::Error)]
262enum LintFnError {
263    #[error(transparent)]
264    CmdError(Box<CmdError>),
265    #[error("{0}")]
266    PlainMsg(String),
267}
268
269impl From<CmdError> for LintFnError {
270    fn from(err: CmdError) -> Self {
271        Self::CmdError(Box::new(err))
272    }
273}
274
275impl From<Box<CmdError>> for LintFnError {
276    fn from(err: Box<CmdError>) -> Self {
277        Self::CmdError(err)
278    }
279}
280
281/// Success result from [`Lint`] function execution.
282///
283/// # Variants
284/// - [`LintFnSuccess::CmdOutput`] Standard command output with status and streams.
285/// - [`LintFnSuccess::PlainMsg`] Simple string message (currently unused).
286enum LintFnSuccess {
287    CmdOutput(Output),
288    PlainMsg(String),
289}
290
291/// Conditionally returns the supplied lint or [`LINT_NO_OP`] based on file changes.
292///
293/// An empty `extensions` slice means the lint is unconditional (always runs).
294fn build_conditional_lint(changed_paths: &[String], extensions: &[&str], lint: Lint) -> Lint {
295    if extensions.is_empty()
296        || changed_paths
297            .iter()
298            .any(|path| extensions.iter().any(|ext| path.ends_with(ext)))
299    {
300        lint
301    } else {
302        LINT_NO_OP
303    }
304}
305
306/// Run a single lint, measure its duration, and report immediately.
307fn run_and_report(lint_name: &str, path: &Path, run: Lint) -> LintFnResult {
308    let start = Instant::now();
309    let lint_res = run(path);
310    report(lint_name, &lint_res, start.elapsed());
311    lint_res
312}
313
314/// Format and print the result of a completed lint execution.
315fn report(lint_name: &str, lint_res: &Result<LintFnSuccess, LintFnError>, elapsed: Duration) {
316    match lint_res {
317        Ok(LintFnSuccess::CmdOutput(output)) => {
318            println!(
319                "{} {} status={:?} \n{}",
320                lint_name.green().bold(),
321                format_timing(elapsed),
322                output.status.code(),
323                str::from_utf8(&output.stdout).unwrap_or_default()
324            );
325        }
326        Ok(LintFnSuccess::PlainMsg(msg)) => {
327            println!("{} {} \n{msg}", lint_name.green().bold(), format_timing(elapsed));
328        }
329        Err(err) => {
330            eprintln!("{} {} \n{err}", lint_name.red().bold(), format_timing(elapsed));
331        }
332    }
333}
334
335/// Format lint duration into `time=<duration>` snippet.
336fn format_timing(duration: Duration) -> String {
337    format!("time={duration:?}")
338}
339
340/// Run workspace lint suite concurrently (check or fix modes).
341#[ytil_sys::main]
342fn main() -> rootcause::Result<()> {
343    let args = ytil_sys::cli::get();
344    if args.has_help() {
345        println!("{}", include_str!("../help.txt"));
346        return Ok(());
347    }
348    let fix_mode = args.first().is_some_and(|s| s == "fix");
349
350    let (start_msg, lints) = if fix_mode {
351        ("lints fix", LINTS_FIX)
352    } else {
353        ("lints check", LINTS_CHECK)
354    };
355
356    let workspace_root = ytil_sys::dir::get_workspace_root()?;
357
358    let repo = ytil_git::repo::discover(&workspace_root)?;
359    let changed_paths = repo
360        .statuses(None)?
361        .iter()
362        .filter_map(|entry| entry.path().map(str::to_string))
363        .collect::<Vec<_>>();
364
365    let has_rust_changes = changed_paths
366        .iter()
367        .any(|p| RUST_EXTENSIONS.iter().any(|ext| p.ends_with(ext)));
368
369    if !has_rust_changes {
370        println!(
371            "\n{}\n",
372            "No Rust-related changes detected, skipping lints.".cyan().bold()
373        );
374        return Ok(());
375    }
376
377    println!(
378        "\nRunning {} {} in {}\n",
379        start_msg.cyan().bold(),
380        format!("{:#?}", lints.iter().map(|(lint, _)| lint).collect::<Vec<_>>())
381            .white()
382            .bold(),
383        workspace_root.display().to_string().white().bold(),
384    );
385
386    // Spawn all lints in parallel.
387    let lints_handles: Vec<_> = lints
388        .iter()
389        .map(|(lint_name, lint_builder)| {
390            (
391                lint_name,
392                std::thread::spawn({
393                    let workspace_root = workspace_root.clone();
394                    let changed_paths = changed_paths.clone();
395                    move || run_and_report(lint_name, &workspace_root, lint_builder(&changed_paths))
396                }),
397            )
398        })
399        .collect();
400
401    let mut errors_count: i32 = 0;
402    for (_lint_name, handle) in lints_handles {
403        match handle.join().as_deref() {
404            Ok(Ok(_)) => (),
405            Ok(Err(_)) => errors_count = errors_count.saturating_add(1),
406            Err(join_err) => {
407                errors_count = errors_count.saturating_add(1);
408                eprintln!(
409                    "{} error={}",
410                    "Error joining thread".red().bold(),
411                    format!("{join_err:#?}").red()
412                );
413            }
414        }
415    }
416
417    println!(); // Cosmetic spacing.
418
419    if errors_count > 0 {
420        std::process::exit(1);
421    }
422
423    Ok(())
424}
425
426#[cfg(test)]
427mod tests {
428    use std::io::Error;
429    use std::io::ErrorKind;
430    use std::path::PathBuf;
431
432    use rstest::rstest;
433
434    use super::*;
435
436    #[test]
437    fn from_rm_files_outcome_when_no_removed_no_errors_returns_success() {
438        let outcome = RmFilesOutcome {
439            removed: vec![],
440            errors: vec![],
441        };
442
443        let result = LintFnResult::from(outcome);
444
445        assert2::assert!(let Ok(LintFnSuccess::PlainMsg(msg)) = result.0);
446        pretty_assertions::assert_eq!(msg, "");
447    }
448
449    #[test]
450    fn from_rm_files_outcome_when_some_removed_no_errors_returns_success() {
451        let outcome = RmFilesOutcome {
452            removed: vec![PathBuf::from("file1.txt"), PathBuf::from("file2.txt")],
453            errors: vec![],
454        };
455
456        let result = LintFnResult::from(outcome);
457
458        assert2::assert!(let Ok(LintFnSuccess::PlainMsg(msg)) = result.0);
459        assert!(msg.contains("Removed"));
460        assert!(msg.contains("file1.txt"));
461        assert!(msg.contains("file2.txt"));
462    }
463
464    #[test]
465    fn from_rm_files_outcome_when_no_removed_some_errors_returns_failure() {
466        let outcome = RmFilesOutcome {
467            removed: vec![],
468            errors: vec![(
469                Some(PathBuf::from("badfile.txt")),
470                Error::new(ErrorKind::PermissionDenied, "permission denied"),
471            )],
472        };
473
474        let result = LintFnResult::from(outcome);
475
476        assert2::assert!(let Err(LintFnError::PlainMsg(msg)) = result.0);
477        assert!(msg.contains("Error removing"));
478        assert!(msg.contains("\"badfile.txt\""));
479        assert!(msg.contains("permission denied"));
480    }
481
482    #[test]
483    fn from_rm_files_outcome_when_error_without_path_returns_failure() {
484        let outcome = RmFilesOutcome {
485            removed: vec![],
486            errors: vec![(None, Error::new(ErrorKind::NotFound, "file not found"))],
487        };
488
489        let result = LintFnResult::from(outcome);
490
491        assert2::assert!(let Err(LintFnError::PlainMsg(msg)) = result.0);
492        assert!(msg.contains("Error removing"));
493        assert!(msg.contains("file not found"));
494    }
495
496    #[test]
497    fn from_rm_files_outcome_when_mixed_removed_and_errors_returns_failure() {
498        let outcome = RmFilesOutcome {
499            removed: vec![PathBuf::from("goodfile.txt")],
500            errors: vec![(Some(PathBuf::from("badfile.txt")), Error::other("some error"))],
501        };
502
503        let result = LintFnResult::from(outcome);
504
505        assert2::assert!(let Err(LintFnError::PlainMsg(msg)) = result.0);
506        assert!(msg.contains("Removed"));
507        assert!(msg.contains("goodfile.txt"));
508        assert!(msg.contains("Error removing"));
509        assert!(msg.contains("\"badfile.txt\""));
510        assert!(msg.contains("some error"));
511    }
512
513    #[rstest]
514    #[case::multiple_files_no_extension_filter(
515        &["README.md".to_string(), "src/main.rs".to_string()],
516        &[] as &[&str],
517        "dummy success"
518    )]
519    #[case::multiple_files_with_rs_extension_filter(
520        &["README.md".to_string(), "src/main.rs".to_string()],
521        &[".rs"],
522        "dummy success"
523    )]
524    #[case::single_non_rs_file_with_rs_extension_filter(
525        &["README.md".to_string()],
526        &[".rs"],
527        "skipped"
528    )]
529    #[case::cargo_toml_change_triggers_rust_extensions(
530        &["yog/yog/tec/Cargo.toml".to_string()],
531        RUST_EXTENSIONS,
532        "dummy success"
533    )]
534    #[case::non_rust_file_with_rust_extensions(
535        &["README.md".to_string()],
536        RUST_EXTENSIONS,
537        "skipped"
538    )]
539    fn build_conditional_lint_returns_expected_result(
540        #[case] changed_paths: &[String],
541        #[case] extensions: &[&str],
542        #[case] expected: &str,
543    ) {
544        let result_lint = build_conditional_lint(changed_paths, extensions, dummy_lint);
545        let lint_result = result_lint(Path::new("/tmp"));
546
547        assert2::assert!(let Ok(LintFnSuccess::PlainMsg(msg)) = lint_result.0);
548        // Using contains instead of exact match because [`NO_OP`] [`Lint`] returns a colorized [`String`].
549        assert!(msg.contains(expected));
550    }
551
552    fn dummy_lint(_path: &Path) -> LintFnResult {
553        LintFnResult(Ok(LintFnSuccess::PlainMsg("dummy success".to_string())))
554    }
555}