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)]
83pub struct App {
85 #[clap(long, short)]
87 config: Option<PathBuf>,
88 #[clap(long, short)]
90 jobs: Option<usize>,
91 #[clap(long, global = true)]
93 ascii: bool,
94 #[clap(long, short, global = true)]
96 quiet: bool,
97 #[clap(long = "no-color", action = ArgAction::SetFalse, global = true)]
99 color: bool,
100
101 #[clap(long, short, global = true)]
103 verbose: bool,
104 #[clap(long, global = true)]
106 debug: bool,
107 #[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 #[clap(long)]
134 command: Option<String>,
135 #[clap(long, short)]
137 all: bool,
138 #[clap(long, short)]
140 git: bool,
141 #[clap(long, short)]
143 staged: bool,
144 #[clap(long, short = 'd', value_name = "REF")]
150 git_diff_from: Option<String>,
151 #[clap(long)]
155 staged_with_stash: bool,
156 #[clap(long)]
160 label: Option<String>,
161 #[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 #[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 .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 .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#[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 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 #[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}