1use crate::{
2 chars,
3 command::{self, ActualInvoke, TidyOutcome},
4 config,
5 config_init::{self, InitComponent},
6 paths::{self, finder::Finder},
7 vcs,
8};
9use anyhow::{Error, Result};
10use clap::{ArgAction, ArgGroup, Parser};
11use comfy_table::{presets::UTF8_FULL, Cell, ContentArrangement, Table};
12use fern::{
13 colors::{Color, ColoredLevelConfig},
14 Dispatch,
15};
16use itertools::Itertools;
17use log::{debug, error, info};
18use rayon::{prelude::*, ThreadPool, ThreadPoolBuilder};
19use std::{
20 env,
21 fmt::Write,
22 io::stdout,
23 path::{Path, PathBuf},
24 time::{Duration, Instant},
25};
26use thiserror::Error;
27
28#[derive(Debug, Error)]
29enum PreciousError {
30 #[error("No mode or paths were provided in the command line args")]
31 NoModeOrPathsInCliArgs,
32
33 #[error("The path given in --config, {}, has no parent directory", file.display())]
34 ConfigFileHasNoParent { file: PathBuf },
35
36 #[error("Could not find a VCS checkout root starting from {cwd:}")]
37 CannotFindRoot { cwd: String },
38
39 #[error("No {what:} commands defined in your config")]
40 NoCommands { what: String },
41
42 #[error("No {what:} commands match the given command name, {name:}")]
43 NoCommandsMatchCommandName { what: String, name: String },
44
45 #[error("No {what:} commands match the given label, {label:}")]
46 NoCommandsMatchLabel { what: String, label: String },
47}
48
49#[derive(Debug)]
50struct Exit {
51 status: i8,
52 message: Option<String>,
53 error: Option<String>,
54}
55
56impl From<Error> for Exit {
57 fn from(err: Error) -> Exit {
58 Exit {
59 status: 1,
60 message: None,
61 error: Some(err.to_string()),
62 }
63 }
64}
65
66#[derive(Debug)]
67struct ActionFailure {
68 error: String,
69 config_key: String,
70 paths: Vec<PathBuf>,
71}
72
73#[derive(Debug, Parser)]
74#[clap(name = "precious")]
75#[clap(author, version)]
76#[clap(propagate_version = true)]
77#[clap(subcommand_required = true, arg_required_else_help = true)]
78#[clap(max_term_width = 100)]
79#[allow(clippy::struct_excessive_bools)]
80pub struct App {
82 #[clap(long, short)]
84 config: Option<PathBuf>,
85 #[clap(long, short)]
87 jobs: Option<usize>,
88 #[clap(long, global = true)]
90 ascii: bool,
91 #[clap(long, short, global = true)]
93 quiet: bool,
94 #[clap(long = "no-color", action = ArgAction::SetFalse, global = true)]
96 color: bool,
97
98 #[clap(long, short, global = true)]
100 verbose: bool,
101 #[clap(long, global = true)]
103 debug: bool,
104 #[clap(long, global = true)]
106 trace: bool,
107
108 #[clap(subcommand)]
109 subcommand: Subcommand,
110}
111
112#[derive(Debug, Parser)]
113pub enum Subcommand {
114 Lint(CommonArgs),
115 #[clap(alias = "fix")]
116 Tidy(CommonArgs),
117 Config(ConfigArgs),
118}
119
120#[derive(Debug, Parser)]
121#[clap(group(
122 ArgGroup::new("path-spec")
123 .required(true)
124 .args(&["all", "git", "staged", "git_diff_from", "staged_with_stash", "paths"]),
125))]
126#[allow(clippy::struct_excessive_bools)]
127pub struct CommonArgs {
128 #[clap(long)]
131 command: Option<String>,
132 #[clap(long, short)]
134 all: bool,
135 #[clap(long, short)]
137 git: bool,
138 #[clap(long, short)]
140 staged: bool,
141 #[clap(long, short = 'd', value_name = "REF")]
147 git_diff_from: Option<String>,
148 #[clap(long)]
152 staged_with_stash: bool,
153 #[clap(long)]
157 label: Option<String>,
158 #[clap(value_parser)]
160 paths: Vec<PathBuf>,
161}
162
163#[derive(Debug, Parser)]
164pub struct ConfigArgs {
165 #[clap(subcommand)]
166 subcommand: ConfigSubcommand,
167}
168
169#[derive(Debug, Parser)]
170enum ConfigSubcommand {
171 List,
172 Init(ConfigInitArgs),
173}
174
175#[derive(Debug, Parser)]
176#[clap(group(
177 ArgGroup::new("components")
178 .required(true)
179 .args(&["component", "auto"]),
180))]
181pub struct ConfigInitArgs {
182 #[clap(long, short, value_enum)]
183 component: Vec<InitComponent>,
184 #[clap(long, short)]
185 auto: bool,
186 #[clap(long, short, default_value = "precious.toml")]
187 path: PathBuf,
188}
189
190#[must_use]
191pub fn app() -> App {
192 App::parse()
193}
194
195impl App {
196 #[allow(clippy::missing_errors_doc)]
197 pub fn init_logger(&self) -> Result<(), log::SetLoggerError> {
198 let line_colors = ColoredLevelConfig::new()
199 .error(Color::Red)
200 .warn(Color::Yellow)
201 .info(Color::BrightBlack)
202 .debug(Color::BrightBlack)
203 .trace(Color::BrightBlack);
204
205 let level = if self.trace {
206 log::LevelFilter::Trace
207 } else if self.debug {
208 log::LevelFilter::Debug
209 } else if self.verbose {
210 log::LevelFilter::Info
211 } else {
212 log::LevelFilter::Warn
213 };
214
215 let use_color = self.color;
216 let level_colors = line_colors.info(Color::Green).debug(Color::Black);
217
218 Dispatch::new()
219 .format(move |out, message, record| {
220 if !use_color {
221 out.finish(format_args!(
222 "[{target}][{level}] {message}",
223 target = record.target(),
224 level = record.level(),
225 message = message,
226 ));
227 return;
228 }
229
230 out.finish(format_args!(
231 "{color_line}[{target}][{level}{color_line}] {message}\x1B[0m",
232 color_line = format_args!(
233 "\x1B[{}m",
234 line_colors.get_color(&record.level()).to_fg_str()
235 ),
236 target = record.target(),
237 level = level_colors.color(record.level()),
238 message = message,
239 ));
240 })
241 .level(level)
242 .level_for("globset", log::LevelFilter::Info)
243 .chain(std::io::stderr())
244 .apply()
245 }
246
247 #[allow(clippy::missing_errors_doc)]
248 pub fn run(self) -> Result<i8> {
249 self.run_with_output(stdout())
250 }
251
252 fn run_with_output(self, output: impl std::io::Write) -> Result<i8> {
253 if let Subcommand::Config(config_args) = &self.subcommand {
254 if let ConfigSubcommand::Init(init_args) = &config_args.subcommand {
255 config_init::write_config_files(
256 init_args.auto,
257 &init_args.component,
258 &init_args.path,
259 )?;
260 return Ok(0);
261 }
262 }
263
264 let (cwd, project_root, config_file, config) = self.load_config()?;
265
266 match self.subcommand {
267 Subcommand::Lint(_) | Subcommand::Tidy(_) => {
268 Ok(LintOrTidyRunner::new(self, cwd, project_root, config)?.run())
269 }
270 Subcommand::Config(args) => {
271 match args.subcommand {
272 ConfigSubcommand::List => {
273 print_config(output, &config_file, config)?;
274 }
275 ConfigSubcommand::Init(_) => {
276 unreachable!("This is handled earlier")
277 }
278 }
279
280 Ok(0)
281 }
282 }
283 }
284
285 #[cfg(test)]
287 fn new_lint_or_tidy_runner(self) -> Result<LintOrTidyRunner> {
288 let (cwd, project_root, _, config) = self.load_config()?;
289 LintOrTidyRunner::new(self, cwd, project_root, config)
290 }
291
292 fn load_config(&self) -> Result<(PathBuf, PathBuf, PathBuf, config::Config)> {
293 let cwd = env::current_dir()?;
294 let project_root = project_root(self.config.as_deref(), &cwd)?;
295 let config_file = self.config_file(&project_root);
296 let config = config::Config::new(&config_file)?;
297
298 Ok((cwd, project_root, config_file, config))
299 }
300
301 fn config_file(&self, dir: &Path) -> PathBuf {
302 if let Some(cf) = self.config.as_ref() {
303 debug!("Loading config from {} (set via flag)", cf.display());
304 return cf.clone();
305 }
306
307 let default = default_config_file(dir);
308 debug!(
309 "Loading config from {} (default location)",
310 default.display()
311 );
312 default
313 }
314}
315
316fn project_root(config_file: Option<&Path>, cwd: &Path) -> Result<PathBuf> {
317 if let Some(file) = config_file {
318 if let Some(p) = file.parent() {
319 return Ok(p.to_path_buf());
320 }
321 return Err(PreciousError::ConfigFileHasNoParent {
322 file: file.to_path_buf(),
323 }
324 .into());
325 }
326
327 if has_config_file(cwd) {
328 return Ok(cwd.into());
329 }
330
331 for ancestor in cwd.ancestors() {
332 if is_checkout_root(ancestor) {
333 return Ok(ancestor.to_owned());
334 }
335 }
336
337 Err(PreciousError::CannotFindRoot {
338 cwd: cwd.to_string_lossy().to_string(),
339 }
340 .into())
341}
342
343fn has_config_file(dir: &Path) -> bool {
344 default_config_file(dir).exists()
345}
346
347const CONFIG_FILE_NAMES: &[&str] = &["precious.toml", ".precious.toml"];
348
349fn default_config_file(dir: &Path) -> PathBuf {
350 find_or_first(
354 CONFIG_FILE_NAMES.iter().map(|n| {
355 let mut path = dir.to_path_buf();
356 path.push(n);
357 path
358 }),
359 Path::exists,
360 )
361}
362
363fn find_or_first<I, P>(mut iter: I, pred: P) -> PathBuf
364where
365 I: Iterator<Item = PathBuf>,
366 P: Fn(&Path) -> bool,
367{
368 let first = iter.next().unwrap();
369 if pred(&first) {
370 return first;
371 }
372 iter.find(|i| pred(i)).unwrap_or(first)
373}
374
375fn is_checkout_root(dir: &Path) -> bool {
376 for subdir in vcs::DIRS {
377 let mut poss = PathBuf::from(dir);
378 poss.push(subdir);
379 if poss.exists() {
380 return true;
381 }
382 }
383 false
384}
385
386fn print_config(
387 mut output: impl std::io::Write,
388 config_file: &Path,
389 config: config::Config,
390) -> Result<()> {
391 writeln!(output, "Found config file at: {}", config_file.display())?;
392 writeln!(output)?;
393
394 let mut table = Table::new();
395 table
396 .load_preset(UTF8_FULL)
397 .set_content_arrangement(ContentArrangement::Dynamic)
398 .set_header(vec![
399 Cell::new("Name"),
400 Cell::new("Type"),
401 Cell::new("Runs"),
402 ]);
403
404 for (name, c) in config.command_info() {
405 table.add_row(vec![
406 Cell::new(name),
407 Cell::new(c.typ),
408 Cell::new(c.cmd.join(" ")),
409 ]);
410 }
411 writeln!(output, "{table}")?;
412
413 Ok(())
414}
415
416#[derive(Debug)]
417pub struct LintOrTidyRunner {
418 mode: paths::mode::Mode,
419 project_root: PathBuf,
420 cwd: PathBuf,
421 config: config::Config,
422 command: Option<String>,
423 chars: chars::Chars,
424 quiet: bool,
425 color: bool,
426 thread_pool: ThreadPool,
427 should_lint: bool,
428 paths: Vec<PathBuf>,
429 label: Option<String>,
430}
431
432macro_rules! maybe_println {
433 ($self:expr, $($arg:tt)*) => {
434 if !$self.quiet {
435 println!("{}", format!($($arg)*))
436 }
437 };
438}
439
440impl LintOrTidyRunner {
441 fn new(
442 app: App,
443 cwd: PathBuf,
444 project_root: PathBuf,
445 config: config::Config,
446 ) -> Result<LintOrTidyRunner> {
447 if log::log_enabled!(log::Level::Debug) {
448 if let Some(path) = env::var_os("PATH") {
449 debug!("PATH = {}", path.to_string_lossy());
450 }
451 }
452
453 let c = if app.ascii {
454 chars::BORING_CHARS
455 } else {
456 chars::FUN_CHARS
457 };
458
459 let mode = Self::mode(&app)?;
460 let quiet = app.quiet;
461 let jobs = app.jobs.unwrap_or_default();
462 let (should_lint, paths, command, label) = match app.subcommand {
463 Subcommand::Lint(a) => (true, a.paths, a.command, a.label),
464 Subcommand::Tidy(a) => (false, a.paths, a.command, a.label),
465 Subcommand::Config(_) => unreachable!("this is handled in App::run"),
466 };
467
468 Ok(LintOrTidyRunner {
469 mode,
470 project_root,
471 cwd,
472 config,
473 command,
474 chars: c,
475 quiet,
476 color: app.color,
477 thread_pool: ThreadPoolBuilder::new().num_threads(jobs).build()?,
478 should_lint,
479 paths,
480 label,
481 })
482 }
483
484 fn mode(app: &App) -> Result<paths::mode::Mode> {
485 let common = match &app.subcommand {
486 Subcommand::Lint(c) | Subcommand::Tidy(c) => c,
487 Subcommand::Config(_) => unreachable!("this is handled in App::run"),
488 };
489 if common.all {
490 return Ok(paths::mode::Mode::All);
491 } else if common.git {
492 return Ok(paths::mode::Mode::GitModified);
493 } else if common.staged {
494 return Ok(paths::mode::Mode::GitStaged);
495 } else if let Some(from) = &common.git_diff_from {
496 return Ok(paths::mode::Mode::GitDiffFrom(from.clone()));
497 } else if common.staged_with_stash {
498 return Ok(paths::mode::Mode::GitStagedWithStash);
499 }
500
501 if common.paths.is_empty() {
502 return Err(PreciousError::NoModeOrPathsInCliArgs.into());
503 }
504 Ok(paths::mode::Mode::FromCli)
505 }
506
507 fn run(&mut self) -> i8 {
508 match self.run_subcommand() {
509 Ok(e) => {
510 debug!("{e:?}");
511 if let Some(err) = e.error {
512 print!("{err}");
513 }
514 if let Some(msg) = e.message {
515 println!("{} {}", self.chars.empty, msg);
516 }
517 e.status
518 }
519 Err(e) => {
520 error!("Failed to run precious: {e}");
521 42
522 }
523 }
524 }
525
526 fn run_subcommand(&mut self) -> Result<Exit> {
527 if self.should_lint {
528 self.lint()
529 } else {
530 self.tidy()
531 }
532 }
533
534 fn tidy(&mut self) -> Result<Exit> {
535 maybe_println!(self, "{} Tidying {}", self.chars.ring, self.mode);
536
537 let tidiers = self
538 .config
539 .clone()
542 .into_tidy_commands(
543 &self.project_root,
544 self.command.as_deref(),
545 self.label.as_deref(),
546 )?;
547 self.run_all_commands("tidying", tidiers, Self::run_one_tidier)
548 }
549
550 fn lint(&mut self) -> Result<Exit> {
551 maybe_println!(self, "{} Linting {}", self.chars.ring, self.mode);
552
553 let linters = self
554 .config
555 .clone()
557 .into_lint_commands(
558 &self.project_root,
559 self.command.as_deref(),
560 self.label.as_deref(),
561 )?;
562 self.run_all_commands("linting", linters, Self::run_one_linter)
563 }
564
565 fn run_all_commands<R>(
566 &mut self,
567 action: &str,
568 commands: Vec<command::Command>,
569 run_command: R,
570 ) -> Result<Exit>
571 where
572 R: Fn(&mut Self, &[PathBuf], &command::Command) -> Result<Option<Vec<ActionFailure>>>,
573 {
574 if commands.is_empty() {
575 if let Some(c) = &self.command {
576 return Err(PreciousError::NoCommandsMatchCommandName {
577 what: action.into(),
578 name: c.into(),
579 }
580 .into());
581 }
582 if let Some(l) = &self.label {
583 return Err(PreciousError::NoCommandsMatchLabel {
584 what: action.into(),
585 label: l.into(),
586 }
587 .into());
588 }
589 return Err(PreciousError::NoCommands {
590 what: action.into(),
591 }
592 .into());
593 }
594
595 let cli_paths = match self.mode {
596 paths::mode::Mode::FromCli => self.paths.clone(),
597 _ => vec![],
598 };
599
600 match self.finder()?.files(cli_paths)? {
601 None => Ok(Self::no_files_exit()),
602 Some(files) => {
603 let mut all_failures: Vec<ActionFailure> = vec![];
604 for c in commands {
605 debug!(r"Command config for {}: {}", c.name, c.config_debug());
606 if let Some(mut failures) = run_command(self, &files, &c)? {
607 all_failures.append(&mut failures);
608 }
609 }
610
611 Ok(self.make_exit(&all_failures, action))
612 }
613 }
614 }
615
616 fn finder(&mut self) -> Result<Finder> {
617 Finder::new(
618 self.mode.clone(),
619 self.project_root.clone(),
620 self.cwd.clone(),
621 self.config.exclude.clone(),
622 )
623 }
624
625 fn make_exit(&self, failures: &[ActionFailure], action: &str) -> Exit {
626 let (status, error) = if failures.is_empty() {
627 (0, None)
628 } else {
629 let (red, ansi_off) = if self.color {
630 (format!("\x1B[{}m", Color::Red.to_fg_str()), "\x1B[0m")
631 } else {
632 (String::new(), "")
633 };
634 let plural = if failures.len() > 1 { 's' } else { '\0' };
635
636 let error = format!(
637 "{}Error{} when {} files:{}\n{}",
638 red,
639 plural,
640 action,
641 ansi_off,
642 failures.iter().fold(String::new(), |mut out, af| {
643 let _ = write!(
644 out,
645 " {} [{}] failed for [{}]\n {}\n",
646 self.chars.bullet,
647 af.config_key,
648 af.paths.iter().map(|p| p.to_string_lossy()).join(" "),
649 af.error,
650 );
651 out
652 }),
653 );
654 (1, Some(error))
655 };
656 Exit {
657 status,
658 message: None,
659 error,
660 }
661 }
662
663 fn run_one_tidier(
664 &mut self,
665 files: &[PathBuf],
666 t: &command::Command,
667 ) -> Result<Option<Vec<ActionFailure>>> {
668 let runner = |s: &Self,
669 actual_invoke: ActualInvoke,
670 files: &[&Path]|
671 -> Option<Result<(), ActionFailure>> {
672 match t.tidy(actual_invoke, files) {
673 Ok(Some(TidyOutcome::Changed)) => {
674 maybe_println!(
675 s,
676 "{} Tidied by {}: {}",
677 s.chars.tidied,
678 t.name,
679 t.paths_summary(actual_invoke, files),
680 );
681 Some(Ok(()))
682 }
683 Ok(Some(TidyOutcome::Unchanged)) => {
684 maybe_println!(
685 s,
686 "{} Unchanged by {}: {}",
687 s.chars.unchanged,
688 t.name,
689 t.paths_summary(actual_invoke, files),
690 );
691 Some(Ok(()))
692 }
693 Ok(Some(TidyOutcome::Unknown)) => {
694 maybe_println!(
695 s,
696 "{} Maybe changed by {}: {}",
697 s.chars.unknown,
698 t.name,
699 t.paths_summary(actual_invoke, files),
700 );
701 Some(Ok(()))
702 }
703 Ok(None) => None,
704 Err(e) => {
705 println!(
706 "{} Error from {}: {}",
707 s.chars.execution_error,
708 t.name,
709 t.paths_summary(actual_invoke, files),
710 );
711 Some(Err(ActionFailure {
712 error: format!("{e:#}"),
713 config_key: t.config_key(),
714 paths: files.iter().map(|f| f.to_path_buf()).collect(),
715 }))
716 }
717 }
718 };
719
720 self.run_parallel("Tidying", files, t, runner)
721 }
722
723 fn run_one_linter(
724 &mut self,
725 files: &[PathBuf],
726 l: &command::Command,
727 ) -> Result<Option<Vec<ActionFailure>>> {
728 let runner = |s: &Self,
729 actual_invoke: ActualInvoke,
730 files: &[&Path]|
731 -> Option<Result<(), ActionFailure>> {
732 match l.lint(actual_invoke, files) {
733 Ok(Some(lo)) => {
734 if lo.ok {
735 maybe_println!(
736 s,
737 "{} Passed {}: {}",
738 s.chars.lint_free,
739 l.name,
740 l.paths_summary(actual_invoke, files),
741 );
742 Some(Ok(()))
743 } else {
744 println!(
745 "{} Failed {}: {}",
746 s.chars.lint_dirty,
747 l.name,
748 l.paths_summary(actual_invoke, files),
749 );
750 if let Some(s) = lo.stdout {
751 println!("{s}");
752 }
753 if let Some(s) = lo.stderr {
754 println!("{s}");
755 }
756 if let Ok(ga) = env::var("GITHUB_ACTIONS") {
757 if !ga.is_empty() {
758 if files.len() == 1 {
759 println!(
760 "::error file={}::Linting with {} failed",
761 files[0].display(),
762 l.name
763 );
764 } else {
765 println!("::error::Linting with {} failed", l.name);
766 }
767 }
768 }
769
770 Some(Err(ActionFailure {
771 error: "linting failed".into(),
772 config_key: l.config_key(),
773 paths: files.iter().map(|f| f.to_path_buf()).collect(),
774 }))
775 }
776 }
777 Ok(None) => None,
778 Err(e) => {
779 println!(
780 "{} error {}: {}",
781 s.chars.execution_error,
782 l.name,
783 l.paths_summary(actual_invoke, files),
784 );
785 Some(Err(ActionFailure {
786 error: format!("{e:#}"),
787 config_key: l.config_key(),
788 paths: files.iter().map(|f| f.to_path_buf()).collect(),
789 }))
790 }
791 }
792 };
793
794 self.run_parallel("Linting", files, l, runner)
795 }
796
797 fn run_parallel<R>(
798 &mut self,
799 what: &str,
800 files: &[PathBuf],
801 c: &command::Command,
802 runner: R,
803 ) -> Result<Option<Vec<ActionFailure>>>
804 where
805 R: Fn(&Self, ActualInvoke, &[&Path]) -> Option<Result<(), ActionFailure>> + Sync,
806 {
807 let (sets, actual_invoke) = c.files_to_args_sets(files)?;
808
809 let start = Instant::now();
810 let results = self
811 .thread_pool
812 .install(|| -> Result<Vec<Result<(), ActionFailure>>> {
813 let mut res: Vec<Result<(), ActionFailure>> = vec![];
814 res.append(
815 &mut sets
816 .into_par_iter()
817 .filter_map(|set| runner(self, actual_invoke, &set))
818 .collect::<Vec<Result<(), ActionFailure>>>(),
819 );
820 Ok(res)
821 })?;
822
823 if !results.is_empty() {
824 info!(
825 "{} with {} on {} path{}, elapsed time = {}",
826 what,
827 c.name,
828 results.len(),
829 if results.len() > 1 { "s" } else { "" },
830 format_duration(&start.elapsed())
831 );
832 }
833
834 let failures = results
835 .into_iter()
836 .filter_map(Result::err)
837 .collect::<Vec<ActionFailure>>();
838 if failures.is_empty() {
839 Ok(None)
840 } else {
841 Ok(Some(failures))
842 }
843 }
844
845 fn no_files_exit() -> Exit {
846 Exit {
847 status: 0,
848 message: Some(String::from("No files found")),
849 error: None,
850 }
851 }
852}
853
854#[allow(
864 clippy::cast_possible_truncation,
865 clippy::cast_precision_loss,
866 clippy::cast_sign_loss
867)]
868fn format_duration(d: &Duration) -> String {
869 let s = (d.as_secs_f64() * 100.0).round() / 100.0;
870
871 if s >= 60.0 {
872 let minutes = (s / 60.0).floor() as u64;
873 let secs = s - (minutes as f64 * 60.0);
874 return format!("{minutes}m {secs:.2}s");
875 } else if s >= 0.01 {
876 return format!("{s:.2}s");
877 }
878
879 let n = d.as_nanos();
880 if n > 1_000_000 {
881 return format!("{:.2}ms", n as f64 / 1_000_000.0);
882 } else if n > 1_000 {
883 return format!("{:.2}us", n as f64 / 1_000.0);
884 }
885
886 format!("{n}ns")
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892 use itertools::Itertools;
893 use precious_testhelper::TestHelper;
894 use pretty_assertions::assert_eq;
895 use pushd::Pushd;
896 use serial_test::serial;
898 #[cfg(not(target_os = "windows"))]
899 use std::str::FromStr;
900 use std::{collections::HashMap, path::PathBuf};
901 use test_case::test_case;
902 #[cfg(not(target_os = "windows"))]
903 use which::which;
904
905 const SIMPLE_CONFIG: &str = r#"
906[commands.rustfmt]
907type = "both"
908include = "**/*.rs"
909cmd = ["rustfmt"]
910lint-flags = "--check"
911ok-exit-codes = [0]
912lint-failure-exit-codes = [1]
913"#;
914
915 const DEFAULT_CONFIG_FILE_NAME: &str = super::CONFIG_FILE_NAMES[0];
916
917 #[test]
918 #[serial]
919 fn new() -> Result<()> {
920 for name in super::CONFIG_FILE_NAMES {
921 let helper = TestHelper::new()?.with_config_file(name, SIMPLE_CONFIG)?;
922 let _pushd = helper.pushd_to_git_root()?;
923
924 let app = App::try_parse_from(["precious", "tidy", "--all"])?;
925
926 let (_, project_root, config_file, _) = app.load_config()?;
927 let mut expect_config_file = project_root;
928 expect_config_file.push(name);
929 assert_eq!(config_file, expect_config_file);
930 }
931
932 Ok(())
933 }
934
935 #[test]
936 #[serial]
937 fn new_with_ascii_flag() -> Result<()> {
938 let helper =
939 TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, SIMPLE_CONFIG)?;
940 let _pushd = helper.pushd_to_git_root()?;
941
942 let app = App::try_parse_from(["precious", "--ascii", "tidy", "--all"])?;
943
944 let lt = app.new_lint_or_tidy_runner()?;
945 assert_eq!(lt.chars, chars::BORING_CHARS);
946
947 Ok(())
948 }
949
950 #[test]
951 #[serial]
952 fn new_with_config_path() -> Result<()> {
953 let helper =
954 TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, SIMPLE_CONFIG)?;
955 let _pushd = helper.pushd_to_git_root()?;
956
957 let app = App::try_parse_from([
958 "precious",
959 "--config",
960 helper
961 .config_file(DEFAULT_CONFIG_FILE_NAME)
962 .to_str()
963 .unwrap(),
964 "tidy",
965 "--all",
966 ])?;
967
968 let (_, project_root, config_file, _) = app.load_config()?;
969 let mut expect_config_file = project_root;
970 expect_config_file.push(DEFAULT_CONFIG_FILE_NAME);
971 assert_eq!(config_file, expect_config_file);
972
973 Ok(())
974 }
975
976 #[test]
977 #[serial]
978 fn set_root_prefers_config_file() -> Result<()> {
979 let helper = TestHelper::new()?.with_git_repo()?;
980
981 let mut src_dir = helper.precious_root();
982 src_dir.push("src");
983 let mut subdir_config = src_dir.clone();
984 subdir_config.push(DEFAULT_CONFIG_FILE_NAME);
985 helper.write_file(&subdir_config, SIMPLE_CONFIG)?;
986 let _pushd = Pushd::new(src_dir.clone())?;
987
988 let app = App::try_parse_from(["precious", "--quiet", "tidy", "--all"])?;
989
990 let lt = app.new_lint_or_tidy_runner()?;
991 assert_eq!(lt.project_root, src_dir);
992
993 Ok(())
994 }
995
996 type FinderTestAction = Box<dyn Fn(&TestHelper) -> Result<()>>;
997
998 #[test_case(
999 "--all",
1000 &[],
1001 Box::new(|_| Ok(())),
1002 &[
1003 "README.md",
1004 "can_ignore.x",
1005 "merge-conflict-file",
1006 "precious.toml",
1007 "src/bar.rs",
1008 "src/can_ignore.rs",
1009 "src/main.rs",
1010 "src/module.rs",
1011 "src/sub/mod.rs",
1012 "tests/data/bar.txt",
1013 "tests/data/foo.txt",
1014 "tests/data/generated.txt",
1015 ] ;
1016 "--all"
1017 )]
1018 #[test_case(
1019 "--git",
1020 &[],
1021 Box::new(|th| {
1022 th.modify_files()?;
1023 Ok(())
1024 }),
1025 &["src/module.rs", "tests/data/foo.txt"] ;
1026 "--git"
1027 )]
1028 #[test_case(
1029 "--staged",
1030 &[],
1031 Box::new(|th| {
1032 th.modify_files()?;
1033 th.stage_all()?;
1034 Ok(())
1035 }),
1036 &["src/module.rs", "tests/data/foo.txt"] ;
1037 "--staged"
1038 )]
1039 #[test_case(
1040 "",
1041 &["main.rs", "module.rs"],
1042 Box::new(|_| Ok(())),
1043 &["src/main.rs", "src/module.rs"] ;
1044 "file paths from cli"
1045 )]
1046 #[test_case(
1047 "",
1048 &["."],
1049 Box::new(|_| Ok(())),
1050 &[
1051 "src/bar.rs",
1052 "src/can_ignore.rs",
1053 "src/main.rs",
1054 "src/module.rs",
1055 "src/sub/mod.rs",
1056 ] ;
1057 "dir paths from cli"
1058 )]
1059 #[serial]
1060 fn finder_uses_project_root(
1061 flag: &str,
1062 paths: &[&str],
1063 action: FinderTestAction,
1064 expect: &[&str],
1065 ) -> Result<()> {
1066 let helper = TestHelper::new()?
1067 .with_config_file(DEFAULT_CONFIG_FILE_NAME, SIMPLE_CONFIG)?
1068 .with_git_repo()?;
1069 (action)(&helper)?;
1070
1071 let mut src_dir = helper.precious_root();
1072 src_dir.push("src");
1073 let _pushd = Pushd::new(src_dir)?;
1074
1075 let mut cmd = vec!["precious", "--quiet", "tidy"];
1076 if !flag.is_empty() {
1077 cmd.push(flag);
1078 } else {
1079 cmd.append(&mut paths.to_vec());
1080 }
1081 let app = App::try_parse_from(&cmd)?;
1082
1083 let mut lt = app.new_lint_or_tidy_runner()?;
1084
1085 assert_eq!(
1086 lt.finder()?
1087 .files(paths.iter().map(PathBuf::from).collect())?,
1088 Some(expect.iter().map(PathBuf::from).collect::<Vec<_>>()),
1089 "finder_uses_project_root: {} [{}]",
1090 if flag.is_empty() { "<none>" } else { flag },
1091 paths.join(" ")
1092 );
1093
1094 Ok(())
1095 }
1096
1097 #[test]
1098 #[serial]
1099 #[cfg(not(target_os = "windows"))]
1100 fn tidy_succeeds() -> Result<()> {
1101 let config = r#"
1102 [commands.precious]
1103 type = "tidy"
1104 include = "**/*"
1105 cmd = ["true"]
1106 ok-exit-codes = [0]
1107 "#;
1108 let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, config)?;
1109 let _pushd = helper.pushd_to_git_root()?;
1110
1111 let app = App::try_parse_from(["precious", "--quiet", "tidy", "--all"])?;
1112
1113 let mut lt = app.new_lint_or_tidy_runner()?;
1114 let status = lt.run();
1115
1116 assert_eq!(status, 0);
1117
1118 Ok(())
1119 }
1120
1121 #[test]
1122 #[serial]
1123 #[cfg(not(target_os = "windows"))]
1124 fn tidy_fails() -> Result<()> {
1125 let config = r#"
1126 [commands.false]
1127 type = "tidy"
1128 include = "**/*"
1129 cmd = ["false"]
1130 ok-exit-codes = [0]
1131 "#;
1132 let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, config)?;
1133 let _pushd = helper.pushd_to_git_root()?;
1134
1135 let app = App::try_parse_from(["precious", "--quiet", "tidy", "--all"])?;
1136
1137 let mut lt = app.new_lint_or_tidy_runner()?;
1138 let status = lt.run();
1139
1140 assert_eq!(status, 1);
1141
1142 Ok(())
1143 }
1144
1145 #[test]
1146 #[serial]
1147 #[cfg(not(target_os = "windows"))]
1148 fn lint_succeeds() -> Result<()> {
1149 let config = r#"
1150 [commands.true]
1151 type = "lint"
1152 include = "**/*"
1153 cmd = ["true"]
1154 ok-exit-codes = [0]
1155 lint-failure-exit-codes = [1]
1156 "#;
1157 let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, config)?;
1158 let _pushd = helper.pushd_to_git_root()?;
1159
1160 let app = App::try_parse_from(["precious", "--quiet", "lint", "--all"])?;
1161
1162 let mut lt = app.new_lint_or_tidy_runner()?;
1163 let status = lt.run();
1164
1165 assert_eq!(status, 0);
1166
1167 Ok(())
1168 }
1169
1170 #[test]
1171 #[serial]
1172 fn one_command_given() -> Result<()> {
1173 let helper =
1174 TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, SIMPLE_CONFIG)?;
1175 let _pushd = helper.pushd_to_git_root()?;
1176
1177 let app = App::try_parse_from([
1178 "precious",
1179 "--quiet",
1180 "lint",
1181 "--command",
1182 "rustfmt",
1183 "--all",
1184 ])?;
1185
1186 let mut lt = app.new_lint_or_tidy_runner()?;
1187 let status = lt.run();
1188
1189 assert_eq!(status, 0);
1190
1191 Ok(())
1192 }
1193
1194 #[test]
1195 #[serial]
1196 fn one_command_given_which_does_not_exist() -> Result<()> {
1197 let helper =
1198 TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, SIMPLE_CONFIG)?;
1199 let _pushd = helper.pushd_to_git_root()?;
1200
1201 let app = App::try_parse_from([
1202 "precious",
1203 "--quiet",
1204 "lint",
1205 "--command",
1206 "no-such-command",
1207 "--all",
1208 ])?;
1209
1210 let mut lt = app.new_lint_or_tidy_runner()?;
1211 let status = lt.run();
1212
1213 assert_eq!(status, 42);
1214
1215 Ok(())
1216 }
1217
1218 #[test]
1219 #[serial]
1220 #[cfg(not(target_os = "windows"))]
1225 fn command_order_is_preserved_when_running() -> Result<()> {
1226 if which("perl").is_err() {
1227 println!("Skipping test since perl is not in path");
1228 return Ok(());
1229 }
1230
1231 let config = r#"
1232 [commands.perl-replace-a-with-b]
1233 type = "tidy"
1234 include = "test.replace"
1235 cmd = ["perl", "-pi", "-e", "s/a/b/i"]
1236 ok-exit-codes = [0]
1237
1238 [commands.perl-replace-a-with-c]
1239 type = "tidy"
1240 include = "test.replace"
1241 cmd = ["perl", "-pi", "-e", "s/a/c/i"]
1242 ok-exit-codes = [0]
1243 lint-failure-exit-codes = [1]
1244
1245 [commands.perl-replace-a-with-d]
1246 type = "tidy"
1247 include = "test.replace"
1248 cmd = ["perl", "-pi", "-e", "s/a/d/i"]
1249 ok-exit-codes = [0]
1250 lint-failure-exit-codes = [1]
1251 "#;
1252 let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, config)?;
1253 let test_replace = PathBuf::from_str("test.replace")?;
1254 helper.write_file(&test_replace, "The letter A")?;
1255 let _pushd = helper.pushd_to_git_root()?;
1256
1257 let app = App::try_parse_from(["precious", "--quiet", "tidy", "-a"])?;
1258
1259 let status = app.run()?;
1260
1261 assert_eq!(status, 0);
1262
1263 let content = helper.read_file(test_replace.as_ref())?;
1264 assert_eq!(content, "The letter b".to_string());
1265
1266 Ok(())
1267 }
1268
1269 #[test]
1270 fn print_config() -> Result<()> {
1271 let config = r#"
1272 [commands.foo]
1273 type = "lint"
1274 include = "*.foo"
1275 cmd = ["foo", "--lint", "--with-vigor"]
1276 ok-exit-codes = [0]
1277
1278 [commands.bar]
1279 type = "tidy"
1280 include = "*.bar"
1281 cmd = ["bar", "--fix-broken-things", "--aggressive"]
1282 ok-exit-codes = [0]
1283
1284 [commands.baz]
1285 type = "both"
1286 include = "*.baz"
1287 cmd = ["baz", "--fast-mode", "--no-verify"]
1288 lint-flags = "--lint"
1289 ok-exit-codes = [0]
1290 "#;
1291 let helper = TestHelper::new()?.with_config_file(DEFAULT_CONFIG_FILE_NAME, config)?;
1292 let _pushd = helper.pushd_to_git_root()?;
1293
1294 let app = App::try_parse_from(["precious", "config", "list"])?;
1295 let mut buffer = Vec::new();
1296 let status = app.run_with_output(&mut buffer)?;
1297
1298 assert_eq!(status, 0);
1299
1300 let output = String::from_utf8(buffer)?;
1301 let expect = format!(
1302 r#"Found config file at: {}
1303
1304┌──────┬──────┬──────────────────────────────────────┐
1305│ Name ┆ Type ┆ Runs │
1306╞══════╪══════╪══════════════════════════════════════╡
1307│ foo ┆ lint ┆ foo --lint --with-vigor │
1308├╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
1309│ bar ┆ tidy ┆ bar --fix-broken-things --aggressive │
1310├╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
1311│ baz ┆ both ┆ baz --fast-mode --no-verify │
1312└──────┴──────┴──────────────────────────────────────┘
1313"#,
1314 helper.config_file(DEFAULT_CONFIG_FILE_NAME).display(),
1315 );
1316 assert_eq!(output, expect);
1317
1318 Ok(())
1319 }
1320
1321 #[test]
1322 fn format_duration_output() {
1323 let mut tests: HashMap<Duration, &'static str> = HashMap::new();
1324 tests.insert(Duration::new(0, 24), "24ns");
1325 tests.insert(Duration::new(0, 124), "124ns");
1326 tests.insert(Duration::new(0, 1_243), "1.24us");
1327 tests.insert(Duration::new(0, 12_443), "12.44us");
1328 tests.insert(Duration::new(0, 124_439), "124.44us");
1329 tests.insert(Duration::new(0, 1_244_392), "1.24ms");
1330 tests.insert(Duration::new(0, 12_443_924), "0.01s");
1331 tests.insert(Duration::new(0, 124_439_246), "0.12s");
1332 tests.insert(Duration::new(1, 1), "1.00s");
1333 tests.insert(Duration::new(1, 12), "1.00s");
1334 tests.insert(Duration::new(1, 124), "1.00s");
1335 tests.insert(Duration::new(1, 1_243), "1.00s");
1336 tests.insert(Duration::new(1, 12_443), "1.00s");
1337 tests.insert(Duration::new(1, 124_439), "1.00s");
1338 tests.insert(Duration::new(1, 1_244_392), "1.00s");
1339 tests.insert(Duration::new(1, 12_443_926), "1.01s");
1340 tests.insert(Duration::new(1, 124_439_267), "1.12s");
1341 tests.insert(Duration::new(59, 1), "59.00s");
1342 tests.insert(Duration::new(59, 1_000_000), "59.00s");
1343 tests.insert(Duration::new(59, 10_000_000), "59.01s");
1344 tests.insert(Duration::new(59, 90_000_000), "59.09s");
1345 tests.insert(Duration::new(59, 99_999_999), "59.10s");
1346 tests.insert(Duration::new(59, 100_000_000), "59.10s");
1347 tests.insert(Duration::new(59, 900_000_000), "59.90s");
1348 tests.insert(Duration::new(59, 990_000_000), "59.99s");
1349 tests.insert(Duration::new(59, 999_000_000), "1m 0.00s");
1350 tests.insert(Duration::new(59, 999_999_999), "1m 0.00s");
1351 tests.insert(Duration::new(60, 0), "1m 0.00s");
1352 tests.insert(Duration::new(60, 10_000_000), "1m 0.01s");
1353 tests.insert(Duration::new(60, 100_000_000), "1m 0.10s");
1354 tests.insert(Duration::new(60, 110_000_000), "1m 0.11s");
1355 tests.insert(Duration::new(60, 990_000_000), "1m 0.99s");
1356 tests.insert(Duration::new(60, 999_000_000), "1m 1.00s");
1357 tests.insert(Duration::new(61, 10_000_000), "1m 1.01s");
1358 tests.insert(Duration::new(61, 100_000_000), "1m 1.10s");
1359 tests.insert(Duration::new(61, 120_000_000), "1m 1.12s");
1360 tests.insert(Duration::new(61, 990_000_000), "1m 1.99s");
1361 tests.insert(Duration::new(61, 999_000_000), "1m 2.00s");
1362 tests.insert(Duration::new(120, 99_000_000), "2m 0.10s");
1363 tests.insert(Duration::new(120, 530_000_000), "2m 0.53s");
1364 tests.insert(Duration::new(120, 990_000_000), "2m 0.99s");
1365 tests.insert(Duration::new(152, 240_123_456), "2m 32.24s");
1366
1367 for k in tests.keys().sorted() {
1368 let f = format_duration(k);
1369 let e = tests.get(k).unwrap().to_string();
1370 assert_eq!(f, e, "{}s {}ns", k.as_secs(), k.as_nanos());
1371 }
1372 }
1373}