1use 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
20const RUST_EXTENSIONS: &[&str] = &[".rs", "Cargo.toml"];
22
23const 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 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
108const 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
206const LINT_NO_OP: Lint = |_| LintFnResult(Ok(LintFnSuccess::PlainMsg(format!("{}\n", "skipped".bold()))));
208
209type Lint = fn(&Path) -> LintFnResult;
211
212type LintBuilder = fn(&[String]) -> Lint;
214
215struct 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
232impl 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#[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
281enum LintFnSuccess {
287 CmdOutput(Output),
288 PlainMsg(String),
289}
290
291fn 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
306fn 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
314fn 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
335fn format_timing(duration: Duration) -> String {
337 format!("time={duration:?}")
338}
339
340#[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 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!(); 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 assert!(msg.contains(expected));
550 }
551
552 fn dummy_lint(_path: &Path) -> LintFnResult {
553 LintFnResult(Ok(LintFnSuccess::PlainMsg("dummy success".to_string())))
554 }
555}