Skip to main content

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