1use 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
44const 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 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
129const 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
227const LINT_NO_OP: Lint = |_| LintFnResult(Ok(LintFnSuccess::PlainMsg(format!("{}\n", "skipped".bold()))));
235
236type Lint = fn(&Path) -> LintFnResult;
248
249type LintBuilder = fn(&[String]) -> Lint;
257
258struct 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
280impl 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#[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
335enum LintFnSuccess {
341 CmdOutput(Output),
342 PlainMsg(String),
343}
344
345fn 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
361fn 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
374fn 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
399fn format_timing(duration: Duration) -> String {
408 format!("time={duration:?}")
409}
410
411fn 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 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!(); 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 assert!(msg.contains(expected));
600 }
601
602 fn dummy_lint(_path: &Path) -> LintFnResult {
603 LintFnResult(Ok(LintFnSuccess::PlainMsg("dummy success".to_string())))
604 }
605}