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 as _;
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", |changed_paths| {
35        build_conditional_lint(changed_paths, 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", |changed_paths| {
47        build_conditional_lint(changed_paths, 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", |changed_paths| {
59        build_conditional_lint(changed_paths, 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", |changed_paths| {
72        build_conditional_lint(changed_paths, 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", |changed_paths| {
84        build_conditional_lint(changed_paths, 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", |changed_paths| {
96        build_conditional_lint(changed_paths, 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", |changed_paths| {
125        build_conditional_lint(changed_paths, 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", |changed_paths| {
137        build_conditional_lint(changed_paths, 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", |changed_paths| {
149        build_conditional_lint(changed_paths, 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", |changed_paths| {
160        build_conditional_lint(changed_paths, 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", |changed_paths| {
172        build_conditional_lint(changed_paths, 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", |changed_paths| {
184        build_conditional_lint(changed_paths, 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(&[String]) -> Lint;
202
203/// Newtype wrapper around [`Result<LintFnSuccess, LintFnError>`].
204struct LintFnResult(Result<LintFnSuccess, LintFnError>);
205
206impl Deref for LintFnResult {
207    type Target = Result<LintFnSuccess, LintFnError>;
208
209    fn deref(&self) -> &Self::Target {
210        &self.0
211    }
212}
213
214impl From<Result<LintFnSuccess, LintFnError>> for LintFnResult {
215    fn from(value: Result<LintFnSuccess, LintFnError>) -> Self {
216        Self(value)
217    }
218}
219
220/// Error type for [`Lint`] function execution failures.
221///
222/// # Errors
223/// - [`LintFnError::CmdError`] Process spawning or execution failure.
224#[derive(Debug, thiserror::Error)]
225enum LintFnError {
226    #[error(transparent)]
227    CmdError(Box<CmdError>),
228}
229
230impl From<CmdError> for LintFnError {
231    fn from(err: CmdError) -> Self {
232        Self::CmdError(Box::new(err))
233    }
234}
235
236impl From<Box<CmdError>> for LintFnError {
237    fn from(err: Box<CmdError>) -> Self {
238        Self::CmdError(err)
239    }
240}
241
242/// Success result from [`Lint`] function execution.
243///
244/// # Variants
245/// - [`LintFnSuccess::CmdOutput`] Standard command output with status and streams.
246/// - [`LintFnSuccess::PlainMsg`] Simple string message.
247enum LintFnSuccess {
248    CmdOutput(Output),
249    PlainMsg(String),
250}
251
252/// Conditionally returns the supplied lint or [`LINT_NO_OP`] based on file changes.
253///
254/// An empty `extensions` slice means the lint is unconditional (always runs).
255fn build_conditional_lint(changed_paths: &[String], extensions: &[&str], lint: Lint) -> Lint {
256    if extensions.is_empty()
257        || changed_paths
258            .iter()
259            .any(|path| extensions.iter().any(|ext| path.ends_with(ext)))
260    {
261        lint
262    } else {
263        LINT_NO_OP
264    }
265}
266
267/// Run a single lint, measure its duration, and report immediately.
268fn run_and_report(lint_name: &str, path: &Path, run: Lint) -> LintFnResult {
269    let start = Instant::now();
270    let lint_res = run(path);
271    report(lint_name, &lint_res, start.elapsed());
272    lint_res
273}
274
275/// Format and print the result of a completed lint execution.
276fn report(lint_name: &str, lint_res: &Result<LintFnSuccess, LintFnError>, elapsed: Duration) {
277    match lint_res {
278        Ok(LintFnSuccess::CmdOutput(output)) => {
279            println!(
280                "{} {} status={:?} \n{}",
281                lint_name.green().bold(),
282                format_timing(elapsed),
283                output.status.code(),
284                str::from_utf8(&output.stdout).unwrap_or_default()
285            );
286        }
287        Ok(LintFnSuccess::PlainMsg(msg)) => {
288            println!("{} {} \n{msg}", lint_name.green().bold(), format_timing(elapsed));
289        }
290        Err(err) => {
291            eprintln!("{} {} \n{err}", lint_name.red().bold(), format_timing(elapsed));
292        }
293    }
294}
295
296/// Format lint duration into `time=<duration>` snippet.
297fn format_timing(duration: Duration) -> String {
298    format!("time={duration:?}")
299}
300
301/// Run workspace lint suite concurrently (check or fix modes).
302#[ytil_sys::main]
303fn main() -> rootcause::Result<()> {
304    let args = ytil_sys::cli::get();
305    if args.has_help() {
306        println!("{}", include_str!("../help.txt"));
307        return Ok(());
308    }
309    let fix_mode = args.first().is_some_and(|s| s == "fix");
310
311    let workspace_root = ytil_sys::dir::get_workspace_root()?;
312
313    let repo = ytil_git::repo::discover(&workspace_root)?;
314    let changed_paths = repo
315        .statuses(None)?
316        .iter()
317        .filter_map(|entry| entry.path().map(str::to_string))
318        .collect::<Vec<_>>();
319
320    let (start_msg, lints) = if fix_mode {
321        ("lints fix", LINTS_FIX)
322    } else {
323        ("lints check", LINTS_CHECK)
324    };
325
326    println!(
327        "\nRunning {} {} in {}\n",
328        start_msg.cyan().bold(),
329        format!("{:#?}", lints.iter().map(|(lint, _)| lint).collect::<Vec<_>>())
330            .white()
331            .bold(),
332        workspace_root.display().to_string().white().bold(),
333    );
334
335    // Spawn all lints in parallel.
336    let lints_handles: Vec<_> = lints
337        .iter()
338        .map(|(lint_name, lint_builder)| {
339            (
340                lint_name,
341                std::thread::spawn({
342                    let workspace_root = workspace_root.clone();
343                    let changed_paths = changed_paths.clone();
344                    move || run_and_report(lint_name, &workspace_root, lint_builder(&changed_paths))
345                }),
346            )
347        })
348        .collect();
349
350    let mut errors_count: i32 = 0;
351    for (_lint_name, handle) in lints_handles {
352        match handle.join().as_deref() {
353            Ok(Ok(_)) => (),
354            Ok(Err(_)) => errors_count = errors_count.saturating_add(1),
355            Err(join_err) => {
356                errors_count = errors_count.saturating_add(1);
357                eprintln!(
358                    "{} error={}",
359                    "Error joining thread".red().bold(),
360                    format!("{join_err:#?}").red()
361                );
362            }
363        }
364    }
365
366    println!(); // Cosmetic spacing.
367
368    if errors_count > 0 {
369        std::process::exit(1);
370    }
371
372    Ok(())
373}
374
375#[cfg(test)]
376mod tests {
377    use rstest::rstest;
378
379    use super::*;
380
381    #[rstest]
382    #[case::multiple_files_no_extension_filter(
383        &["README.md".to_string(), "src/main.rs".to_string()],
384        &[] as &[&str],
385        "dummy success"
386    )]
387    #[case::multiple_files_with_rs_extension_filter(
388        &["README.md".to_string(), "src/main.rs".to_string()],
389        &[".rs"],
390        "dummy success"
391    )]
392    #[case::single_non_rs_file_with_rs_extension_filter(
393        &["README.md".to_string()],
394        &[".rs"],
395        "skipped"
396    )]
397    #[case::cargo_toml_change_triggers_rust_extensions(
398        &["yog/yog/tec/Cargo.toml".to_string()],
399        RUST_EXTENSIONS,
400        "dummy success"
401    )]
402    #[case::non_rust_file_with_rust_extensions(
403        &["README.md".to_string()],
404        RUST_EXTENSIONS,
405        "skipped"
406    )]
407    fn build_conditional_lint_returns_expected_result(
408        #[case] changed_paths: &[String],
409        #[case] extensions: &[&str],
410        #[case] expected: &str,
411    ) {
412        let result_lint = build_conditional_lint(changed_paths, extensions, dummy_lint);
413        let lint_result = result_lint(Path::new("/tmp"));
414
415        assert2::assert!(let Ok(LintFnSuccess::PlainMsg(msg)) = lint_result.0);
416        // Using contains instead of exact match because [`NO_OP`] [`Lint`] returns a colorized [`String`].
417        assert!(msg.contains(expected));
418    }
419
420    fn dummy_lint(_path: &Path) -> LintFnResult {
421        LintFnResult(Ok(LintFnSuccess::PlainMsg("dummy success".to_string())))
422    }
423}