precious_core/
precious.rs

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)]
80/// One code quality tool to rule them all
81pub struct App {
82    /// Path to the precious config file
83    #[clap(long, short)]
84    config: Option<PathBuf>,
85    /// Number of parallel jobs (threads) to run (defaults to one per core)
86    #[clap(long, short)]
87    jobs: Option<usize>,
88    /// Replace super-fun Unicode symbols with terribly boring ASCII
89    #[clap(long, global = true)]
90    ascii: bool,
91    /// Suppresses most output
92    #[clap(long, short, global = true)]
93    quiet: bool,
94    /// Pass this to disable the use of ANSI colors in the output
95    #[clap(long = "no-color", action = ArgAction::SetFalse, global = true)]
96    color: bool,
97
98    /// Enable verbose output
99    #[clap(long, short, global = true)]
100    verbose: bool,
101    /// Enable debugging output
102    #[clap(long, global = true)]
103    debug: bool,
104    /// Enable tracing output (maximum logging)
105    #[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    /// The command to run. If specified, only this command will be run. This
129    /// should match the command name in your config file.
130    #[clap(long)]
131    command: Option<String>,
132    /// Run against all files in the current directory and below
133    #[clap(long, short)]
134    all: bool,
135    /// Run against files that have been modified according to git
136    #[clap(long, short)]
137    git: bool,
138    /// Run against files that are staged for a git commit
139    #[clap(long, short)]
140    staged: bool,
141    /// Run against files that are different as compared with the given
142    /// `<REF>`. This can be a branch name, like `master`, or a ref name like
143    /// `HEAD~6` or `master@{2.days.ago}`. See `git help rev-parse` for more
144    /// options. Note that this will _not_ see files with uncommitted changes
145    /// in the local working directory.
146    #[clap(long, short = 'd', value_name = "REF")]
147    git_diff_from: Option<String>,
148    /// Run against file content that is staged for a git commit, stashing all
149    /// unstaged content first. The stash push/pop tends to do weird things to
150    /// the working directory, and is not recommended for scripting.
151    #[clap(long)]
152    staged_with_stash: bool,
153    /// If this is set, then only commands matching this label will be run. If
154    /// this isn't set then commands without a label or with the label
155    /// "default" will be run.
156    #[clap(long)]
157    label: Option<String>,
158    /// A list of paths on which to operate
159    #[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    // This exists to make writing tests of the runner easier.
286    #[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    // It'd be nicer to use the version of this provided by itertools, but
351    // that requires itertools 0.10.1, and we want to keep the version at
352    // 0.9.0 for the benefit of Debian.
353    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            // XXX - This clone can be removed if config is passed into this
540            // method instead of being a field of self.
541            .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            // XXX - same as above.
556            .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// I tried the humantime crate but it doesn't do what I want. It formats each
855// element separately ("1s 243ms 179us 984ns"), which is _way_ more detail
856// than I want for this. This algorithm will format to the most appropriate of:
857//
858//    Xm Y.YYs
859//    X.XXs
860//    X.XXms
861//    X.XXus
862//    X.XXns
863#[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    // Anything that does pushd must be run serially or else chaos ensues.
897    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    // This fails in CI on Windows with a confusing error - "Cannot complete
1221    // in-place edit of test.replace: Work file is missing - did you change
1222    // directory?" I don't know what this means, and it's not really important
1223    // to run this test on every OS.
1224    #[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}