tec/
main.rs

1//! Run workspace lint suite concurrently (check or fix modes).
2//!
3//! Executes lints against the Cargo workspace root auto–detected via [`ytil_sys::dir::get_workspace_root`].
4//!
5//! # Behavior
6//! - Auto-detects workspace root (no positional CLI argument required).
7//! - Supports two modes:
8//!   - Check (default) runs non-mutating lints.
9//!   - Fix (`--fix` CLI flag) runs the lints that support automatic fixes.
10//! - Spawns one thread per lint; all run concurrently.
11//! - Result reporting joins threads in declaration order; a long first lint can delay visible output, potentially
12//!   giving a false impression of serial execution.
13//! - Prints each lint result with: success/error, duration (`time=<Duration>`), status code, stripped stdout or error.
14//! - Exits with code 1 if any lint command returns a non-zero status, any lint command invocation errors, or any lint
15//!   thread panics; exits 0 otherwise.
16//!
17//! # Returns
18//! - Process exit code communicates aggregate success (0) or failure (1).
19//!
20//! # Errors
21//! - Initialization errors from [`color_eyre::install`].
22//! - Workspace root discovery errors from [`ytil_sys::dir::get_workspace_root`].
23//!
24//! # Rationale
25//! Provides a single fast command (usable in git hooks / CI) aggregating core maintenance lints (style, dependency
26//! pruning, manifest ordering) without bespoke shell scripting.
27//! Split check vs fix modes minimize hook latency while enabling quick remediation.
28//! Adds deterministic, ordered reporting for stable output while retaining parallel execution for speed.
29
30use std::fmt::Write;
31use std::ops::Deref;
32use std::path::Path;
33use std::process::Command;
34use std::process::Output;
35use std::time::Duration;
36use std::time::Instant;
37
38use color_eyre::owo_colors::OwoColorize;
39use ytil_cmd::CmdError;
40use ytil_cmd::CmdExt as _;
41use ytil_sys::cli::Args;
42use ytil_sys::rm::RmFilesOutcome;
43
44/// Workspace lint check set.
45///
46/// Contains non-mutating lints safe for fast verification in hooks / CI:
47///
48/// Execution model:
49/// - Each lint spawns in its own thread; parallelism maximizes throughput while retaining deterministic join &
50///   reporting order defined by slice declaration order.
51/// - All runners are started before any join to avoid head-of-line blocking caused by early long-running lints.
52///
53/// Output contract:
54/// - Prints logical name, duration (`time=<Duration>`), status code, and stripped stdout or error.
55/// - Aggregate process exit code is 1 if any lint fails (non-zero status or panic), else 0.
56const LINTS_CHECK: &[(&str, LintBuilder)] = &[
57    ("clippy", |_| {
58        |path| {
59            LintFnResult::from(
60                Command::new("cargo")
61                    .args(["clippy", "--all-targets", "--all-features", "--", "-D", "warnings"])
62                    .current_dir(path)
63                    .exec()
64                    .map(LintFnSuccess::CmdOutput)
65                    .map_err(LintFnError::from),
66            )
67        }
68    }),
69    ("cargo fmt", |_| {
70        |path| {
71            LintFnResult::from(
72                Command::new("cargo")
73                    .args(["fmt", "--check"])
74                    .current_dir(path)
75                    .exec()
76                    .map(LintFnSuccess::CmdOutput)
77                    .map_err(LintFnError::from),
78            )
79        }
80    }),
81    ("cargo-machete", |_| {
82        |path| {
83            LintFnResult::from(
84                // Using `cargo-machete` rather than `cargo machete` to avoid issues caused by passing the
85                // `path`.
86                Command::new("cargo-machete")
87                    .args(["--with-metadata", &path.display().to_string()])
88                    .exec()
89                    .map(LintFnSuccess::CmdOutput)
90                    .map_err(LintFnError::from),
91            )
92        }
93    }),
94    ("cargo-sort", |_| {
95        |path| {
96            LintFnResult::from(
97                Command::new("cargo-sort")
98                    .args(["--workspace", "--check", "--check-format"])
99                    .current_dir(path)
100                    .exec()
101                    .map(LintFnSuccess::CmdOutput)
102                    .map_err(LintFnError::from),
103            )
104        }
105    }),
106    ("cargo-sort-derives", |_| {
107        |path| {
108            LintFnResult::from(
109                Command::new("cargo-sort-derives")
110                    .args(["sort-derives", "--check"])
111                    .current_dir(path)
112                    .exec()
113                    .map(LintFnSuccess::CmdOutput)
114                    .map_err(LintFnError::from),
115            )
116        }
117    }),
118    ("rust-doc-build", |_| {
119        |path| {
120            LintFnResult::from(
121                nomicon::generate_rust_doc(path)
122                    .map(LintFnSuccess::CmdOutput)
123                    .map_err(LintFnError::from),
124            )
125        }
126    }),
127];
128
129/// Workspace lint fix set.
130///
131/// Contains mutating lints for rapid remediation of formatting / unused dependencies / manifest ordering.
132///
133/// Execution model:
134/// - Parallel thread spawn identical to [`LINTS_CHECK`]; ordering of slice elements defines deterministic join &
135///   reporting sequence.
136///
137/// Output contract:
138/// - Same reporting shape as [`LINTS_CHECK`]: name, duration snippet (`time=<Duration>`), status code, stripped stdout
139///   or error.
140/// - Aggregate process exit code is 1 if any lint fails (non-zero status or panic), else 0.
141///
142/// Rationale:
143/// - Focused mutation set avoids accidentally introducing changes via check-only tools.
144/// - Deterministic ordered output aids CI log diffing while retaining concurrency for speed.
145/// - Mirrors structure of [`LINTS_CHECK`] for predictable maintenance (additions require updating both tables).
146const LINTS_FIX: &[(&str, LintBuilder)] = &[
147    ("clippy", |changed_paths| {
148        build_conditional_lint(changed_paths, Some(".rs"), |path| {
149            LintFnResult::from(
150                Command::new("cargo")
151                    .args(["clippy", "--all-targets", "--all-features", "--", "-D", "warnings"])
152                    .current_dir(path)
153                    .exec()
154                    .map(LintFnSuccess::CmdOutput)
155                    .map_err(LintFnError::from),
156            )
157        })
158    }),
159    ("cargo fmt", |changed_paths| {
160        build_conditional_lint(changed_paths, Some(".rs"), |path| {
161            LintFnResult::from(
162                Command::new("cargo")
163                    .args(["fmt"])
164                    .current_dir(path)
165                    .exec()
166                    .map(LintFnSuccess::CmdOutput)
167                    .map_err(LintFnError::from),
168            )
169        })
170    }),
171    ("cargo-machete", |changed_paths| {
172        build_conditional_lint(changed_paths, Some(".rs"), |path| {
173            LintFnResult::from(
174                Command::new("cargo-machete")
175                    .args(["--fix", "--with-metadata", &path.display().to_string()])
176                    .exec()
177                    .map(LintFnSuccess::CmdOutput)
178                    .map_err(LintFnError::from),
179            )
180        })
181    }),
182    ("cargo-sort", |changed_paths| {
183        build_conditional_lint(changed_paths, Some(".rs"), |path| {
184            LintFnResult::from(
185                Command::new("cargo-sort")
186                    .args(["--workspace"])
187                    .current_dir(path)
188                    .exec()
189                    .map(LintFnSuccess::CmdOutput)
190                    .map_err(LintFnError::from),
191            )
192        })
193    }),
194    ("cargo-sort-derives", |changed_paths| {
195        build_conditional_lint(changed_paths, Some(".rs"), |path| {
196            LintFnResult::from(
197                Command::new("cargo-sort-derives")
198                    .args(["sort-derives"])
199                    .current_dir(path)
200                    .exec()
201                    .map(LintFnSuccess::CmdOutput)
202                    .map_err(LintFnError::from),
203            )
204        })
205    }),
206    ("rm-ds-store", |changed_paths| {
207        build_conditional_lint(changed_paths, None, |path| {
208            LintFnResult::from(ytil_sys::rm::rm_matching_files(
209                path,
210                ".DS_Store",
211                &[".git", "target"],
212                false,
213            ))
214        })
215    }),
216    ("rust-doc-build", |changed_paths| {
217        build_conditional_lint(changed_paths, Some(".rs"), |path| {
218            LintFnResult::from(
219                nomicon::generate_rust_doc(path)
220                    .map(LintFnSuccess::CmdOutput)
221                    .map_err(LintFnError::from),
222            )
223        })
224    }),
225];
226
227/// No-operation lint that reports "skipped" status.
228///
229/// Used by [`build_conditional_lint`] when a lint should be skipped due to no relevant file changes.
230///
231/// # Rationale
232/// Provides a reusable constant for skipped lints, avoiding duplication of the skip logic and ensuring consistent
233/// output.
234const LINT_NO_OP: Lint = |_| LintFnResult(Ok(LintFnSuccess::PlainMsg(format!("{}\n", "skipped".bold()))));
235
236/// Function pointer type for a single lint invocation.
237///
238/// Encapsulates a non-mutating check or (optionally) mutating fix routine executed against the workspace root.
239///
240/// # Rationale
241/// Using a simple function pointer keeps dynamic dispatch trivial and avoids boxing trait objects; closures remain
242/// zero-cost and we can compose slices of `(name, LintFn)` without lifetime complications.
243///
244/// # Future Work
245/// - Consider an enum encapsulating richer metadata (e.g. auto-fix capability flag) to filter sets without duplicating
246///   entries across lists.
247type Lint = fn(&Path) -> LintFnResult;
248
249/// Function pointer type for building lints based on file changes.
250///
251/// Encapsulates logic to build or select a lint based on file changes, returning a [`Lint`] function to execute.
252///
253/// # Rationale
254/// Enables efficient conditional execution of lints, avoiding unnecessary work when no relevant files have changed
255/// while maintaining consistent output format.
256type LintBuilder = fn(&[String]) -> Lint;
257
258/// Newtype wrapper around [`Result<LintFnSuccess, LintFnError>`].
259///
260/// Provides ergonomic conversions from [`RmFilesOutcome`] and [`Deref`] access to the inner result.
261///
262/// # Rationale
263/// Wraps the result to enable custom conversions without orphan rule violations.
264struct LintFnResult(Result<LintFnSuccess, LintFnError>);
265
266impl Deref for LintFnResult {
267    type Target = Result<LintFnSuccess, LintFnError>;
268
269    fn deref(&self) -> &Self::Target {
270        &self.0
271    }
272}
273
274impl From<Result<LintFnSuccess, LintFnError>> for LintFnResult {
275    fn from(value: Result<LintFnSuccess, LintFnError>) -> Self {
276        Self(value)
277    }
278}
279
280/// Converts [`RmFilesOutcome`] into [`LintFnResult`] for uniform lint result handling.
281///
282/// Builds a formatted message listing removed files and errors, then wraps in success if no errors or failure
283/// otherwise.
284///
285/// # Rationale
286/// Enables treating file removal operations as lint results without duplicating conversion logic.
287impl From<RmFilesOutcome> for LintFnResult {
288    fn from(value: RmFilesOutcome) -> Self {
289        let mut msg = String::new();
290        for path in value.removed {
291            let _ = writeln!(&mut msg, "{} {}", "Removed".green(), path.display());
292        }
293        for (path, err) in &value.errors {
294            let _ = writeln!(
295                &mut msg,
296                "{} path{} error={}",
297                "Error removing".red(),
298                path.as_ref().map(|p| format!(" {:?}", p.display())).unwrap_or_default(),
299                format!("{err}").red()
300            );
301        }
302        if value.errors.is_empty() {
303            Self(Ok(LintFnSuccess::PlainMsg(msg)))
304        } else {
305            Self(Err(LintFnError::PlainMsg(msg)))
306        }
307    }
308}
309
310/// Error type for [`Lint`] function execution failures.
311///
312/// # Errors
313/// - [`LintFnError::CmdError`] Process spawning or execution failure.
314/// - [`LintFnError::PlainMsg`] Generic error with a plain message string.
315#[derive(Debug, thiserror::Error)]
316enum LintFnError {
317    #[error(transparent)]
318    CmdError(Box<CmdError>),
319    #[error("{0}")]
320    PlainMsg(String),
321}
322
323impl From<CmdError> for LintFnError {
324    fn from(err: CmdError) -> Self {
325        Self::CmdError(Box::new(err))
326    }
327}
328
329impl From<Box<CmdError>> for LintFnError {
330    fn from(err: Box<CmdError>) -> Self {
331        Self::CmdError(err)
332    }
333}
334
335/// Success result from [`Lint`] function execution.
336///
337/// # Variants
338/// - [`LintFnSuccess::CmdOutput`] Standard command output with status and streams.
339/// - [`LintFnSuccess::PlainMsg`] Simple string message (currently unused).
340enum LintFnSuccess {
341    CmdOutput(Output),
342    PlainMsg(String),
343}
344
345/// Conditionally returns the supplied lint or [`LINT_NO_OP`] based on file changes.
346///
347/// Returns the provided [`Lint`] function if no extension filter is set or if any changed file matches the specified
348/// extension. Otherwise, returns [`LINT_NO_OP`].
349///
350/// # Rationale
351/// Enables efficient skipping of lints when no relevant files have changed, reducing unnecessary work while
352/// maintaining deterministic output.
353fn build_conditional_lint(changed_paths: &[String], extension: Option<&str>, lint: Lint) -> Lint {
354    match extension {
355        Some(ext) if changed_paths.iter().any(|path| path.ends_with(ext)) => lint,
356        None => lint,
357        _ => LINT_NO_OP,
358    }
359}
360
361/// Run a single lint, measure its duration, and report immediately.
362///
363/// # Rationale
364/// Collapses the previous two‑step pattern (timing + later reporting) into one
365/// function so thread closures stay minimal and result propagation is explicit.
366/// This also prevents losing the error flag (a regression after refactor).
367fn run_and_report(lint_name: &str, path: &Path, run: Lint) -> LintFnResult {
368    let start = Instant::now();
369    let lint_res = run(path);
370    report(lint_name, &lint_res, start.elapsed());
371    lint_res
372}
373
374/// Format and print the result of a completed lint execution.
375///
376/// # Rationale
377/// Keeps output formatting separate from orchestration logic in [`main`]; enables
378/// alternate reporters (JSON, terse) later without threading timing logic everywhere.
379fn report(lint_name: &str, lint_res: &Result<LintFnSuccess, LintFnError>, elapsed: Duration) {
380    match lint_res {
381        Ok(LintFnSuccess::CmdOutput(output)) => {
382            println!(
383                "{} {} status={:?} \n{}",
384                lint_name.green().bold(),
385                format_timing(elapsed),
386                output.status.code(),
387                str::from_utf8(&output.stdout).unwrap_or_default()
388            );
389        }
390        Ok(LintFnSuccess::PlainMsg(msg)) => {
391            println!("{} {} \n{msg}", lint_name.green().bold(), format_timing(elapsed));
392        }
393        Err(err) => {
394            eprintln!("{} {} \n{err}", lint_name.red().bold(), format_timing(elapsed));
395        }
396    }
397}
398
399/// Format lint duration into `time=<duration>` snippet (auto-scaled, no color).
400///
401/// Note: Colorization (if any) is applied by the caller (e.g. in [`report`]) not here, keeping this helper suitable for
402/// future machine-readable output modes.
403///
404/// # Rationale
405/// - Improves readability vs raw integer milliseconds; preserves sub-ms precision.
406/// - Uses stable standard library formatting (no custom scaling logic).
407fn format_timing(duration: Duration) -> String {
408    format!("time={duration:?}")
409}
410
411/// Run workspace lint suite concurrently (check or fix modes).
412fn main() -> color_eyre::Result<()> {
413    color_eyre::install()?;
414
415    let args = ytil_sys::cli::get();
416    if args.has_help() {
417        println!("{}", include_str!("../help.txt"));
418        return Ok(());
419    }
420    let fix_mode = args.first().is_some_and(|s| s == "--fix");
421
422    let (start_msg, lints) = if fix_mode {
423        ("lints fix", LINTS_FIX)
424    } else {
425        ("lints check", LINTS_CHECK)
426    };
427
428    let workspace_root = ytil_sys::dir::get_workspace_root()?;
429
430    let repo = ytil_git::repo::discover(&workspace_root)?;
431    let changed_paths = repo
432        .statuses(None)?
433        .iter()
434        .filter_map(|entry| entry.path().map(str::to_string))
435        .collect::<Vec<_>>();
436
437    println!(
438        "\nRunning {} {} in {}\n",
439        start_msg.cyan().bold(),
440        format!("{:#?}", lints.iter().map(|(lint, _)| lint).collect::<Vec<_>>())
441            .white()
442            .bold(),
443        workspace_root.display().to_string().white().bold(),
444    );
445
446    // Spawn all lints in parallel.
447    let lints_handles: Vec<_> = lints
448        .iter()
449        .map(|(lint_name, lint_builder)| {
450            (
451                lint_name,
452                std::thread::spawn({
453                    let workspace_root = workspace_root.clone();
454                    let changed_paths = changed_paths.clone();
455                    move || run_and_report(lint_name, &workspace_root, lint_builder(&changed_paths))
456                }),
457            )
458        })
459        .collect();
460
461    let mut errors_count: i32 = 0;
462    for (_lint_name, handle) in lints_handles {
463        match handle.join().as_deref() {
464            Ok(Ok(_)) => (),
465            Ok(Err(_)) => errors_count = errors_count.saturating_add(1),
466            Err(join_err) => {
467                errors_count = errors_count.saturating_add(1);
468                eprintln!(
469                    "{} error={}",
470                    "Error joining thread".red().bold(),
471                    format!("{join_err:#?}").red()
472                );
473            }
474        }
475    }
476
477    println!(); // Cosmetic spacing.
478
479    if errors_count > 0 {
480        std::process::exit(1);
481    }
482
483    Ok(())
484}
485
486#[cfg(test)]
487mod tests {
488    use std::io::Error;
489    use std::io::ErrorKind;
490    use std::path::PathBuf;
491
492    use rstest::rstest;
493
494    use super::*;
495
496    #[test]
497    fn from_rm_files_outcome_when_no_removed_no_errors_returns_success() {
498        let outcome = RmFilesOutcome {
499            removed: vec![],
500            errors: vec![],
501        };
502
503        let result = LintFnResult::from(outcome);
504
505        assert2::let_assert!(Ok(LintFnSuccess::PlainMsg(msg)) = result.0);
506        pretty_assertions::assert_eq!(msg, "");
507    }
508
509    #[test]
510    fn from_rm_files_outcome_when_some_removed_no_errors_returns_success() {
511        let outcome = RmFilesOutcome {
512            removed: vec![PathBuf::from("file1.txt"), PathBuf::from("file2.txt")],
513            errors: vec![],
514        };
515
516        let result = LintFnResult::from(outcome);
517
518        assert2::let_assert!(Ok(LintFnSuccess::PlainMsg(msg)) = result.0);
519        assert!(msg.contains("Removed"));
520        assert!(msg.contains("file1.txt"));
521        assert!(msg.contains("file2.txt"));
522    }
523
524    #[test]
525    fn from_rm_files_outcome_when_no_removed_some_errors_returns_failure() {
526        let outcome = RmFilesOutcome {
527            removed: vec![],
528            errors: vec![(
529                Some(PathBuf::from("badfile.txt")),
530                Error::new(ErrorKind::PermissionDenied, "permission denied"),
531            )],
532        };
533
534        let result = LintFnResult::from(outcome);
535
536        assert2::let_assert!(Err(LintFnError::PlainMsg(msg)) = result.0);
537        assert!(msg.contains("Error removing"));
538        assert!(msg.contains("\"badfile.txt\""));
539        assert!(msg.contains("permission denied"));
540    }
541
542    #[test]
543    fn from_rm_files_outcome_when_error_without_path_returns_failure() {
544        let outcome = RmFilesOutcome {
545            removed: vec![],
546            errors: vec![(None, Error::new(ErrorKind::NotFound, "file not found"))],
547        };
548
549        let result = LintFnResult::from(outcome);
550
551        assert2::let_assert!(Err(LintFnError::PlainMsg(msg)) = result.0);
552        assert!(msg.contains("Error removing"));
553        assert!(msg.contains("file not found"));
554    }
555
556    #[test]
557    fn from_rm_files_outcome_when_mixed_removed_and_errors_returns_failure() {
558        let outcome = RmFilesOutcome {
559            removed: vec![PathBuf::from("goodfile.txt")],
560            errors: vec![(Some(PathBuf::from("badfile.txt")), Error::other("some error"))],
561        };
562
563        let result = LintFnResult::from(outcome);
564
565        assert2::let_assert!(Err(LintFnError::PlainMsg(msg)) = result.0);
566        assert!(msg.contains("Removed"));
567        assert!(msg.contains("goodfile.txt"));
568        assert!(msg.contains("Error removing"));
569        assert!(msg.contains("\"badfile.txt\""));
570        assert!(msg.contains("some error"));
571    }
572
573    #[rstest]
574    #[case::multiple_files_no_extension_filter(
575        &["README.md".to_string(), "src/main.rs".to_string()],
576        None,
577        "dummy success"
578    )]
579    #[case::multiple_files_with_rs_extension_filter(
580        &["README.md".to_string(), "src/main.rs".to_string()],
581        Some(".rs"),
582        "dummy success"
583    )]
584    #[case::single_non_rs_file_with_rs_extension_filter(
585        &["README.md".to_string()],
586        Some(".rs"),
587        "skipped"
588    )]
589    fn build_conditional_lint_returns_expected_result(
590        #[case] changed_paths: &[String],
591        #[case] extension: Option<&str>,
592        #[case] expected: &str,
593    ) {
594        let result_lint = build_conditional_lint(changed_paths, extension, dummy_lint);
595        let lint_result = result_lint(Path::new("/tmp"));
596
597        assert2::let_assert!(Ok(LintFnSuccess::PlainMsg(msg)) = lint_result.0);
598        // Using contains instead of exact match because [`NO_OP`] [`Lint`] returns a colorized [`String`].
599        assert!(msg.contains(expected));
600    }
601
602    fn dummy_lint(_path: &Path) -> LintFnResult {
603        LintFnResult(Ok(LintFnSuccess::PlainMsg("dummy success".to_string())))
604    }
605}