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