Skip to main content

git_checks/
formatting.rs

1// Copyright Kitware, Inc.
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9use std::ffi::OsStr;
10use std::fmt;
11use std::io::{self, BufReader, Read};
12use std::iter;
13use std::os::unix::process::ExitStatusExt;
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16use std::time::Duration;
17
18use derive_builder::Builder;
19use git_checks_core::impl_prelude::*;
20use git_checks_core::AttributeError;
21use git_workarea::{GitContext, GitWorkArea};
22use itertools::Itertools;
23use log::{debug, info, warn};
24use rayon::prelude::*;
25use thiserror::Error;
26use wait_timeout::ChildExt;
27
28#[derive(Debug, Clone, Copy)]
29enum FormattingExecStage {
30    Run,
31    Wait,
32    Kill,
33    TimeoutWait,
34}
35
36impl fmt::Display for FormattingExecStage {
37    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
38        let what = match self {
39            FormattingExecStage::Run => "execute",
40            FormattingExecStage::Wait => "wait on",
41            FormattingExecStage::Kill => "kill (timed out)",
42            FormattingExecStage::TimeoutWait => "wait on (timed out)",
43        };
44
45        write!(f, "{}", what)
46    }
47}
48
49#[derive(Debug, Clone, Copy)]
50enum ListFilesReason {
51    Modified,
52    Untracked,
53}
54
55impl fmt::Display for ListFilesReason {
56    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
57        let what = match self {
58            ListFilesReason::Modified => "modified",
59            ListFilesReason::Untracked => "untracked",
60        };
61
62        write!(f, "{}", what)
63    }
64}
65
66#[derive(Debug, Error)]
67enum FormattingError {
68    #[error("failed to {} the {} formatter: {}", stage, command.display(), source)]
69    ExecFormatter {
70        command: PathBuf,
71        stage: FormattingExecStage,
72        #[source]
73        source: io::Error,
74    },
75    #[error("failed to collect stderr from the {} formatter: {}", command.display(), source)]
76    CollectStderr {
77        command: PathBuf,
78        #[source]
79        source: io::Error,
80    },
81    #[error("failed to list {} file in the work area: {}", reason, output)]
82    ListFiles {
83        reason: ListFilesReason,
84        output: String,
85    },
86    #[error("attribute extraction error: {}", source)]
87    Attribute {
88        #[from]
89        source: AttributeError,
90    },
91}
92
93impl FormattingError {
94    fn exec_formatter(command: PathBuf, stage: FormattingExecStage, source: io::Error) -> Self {
95        FormattingError::ExecFormatter {
96            command,
97            stage,
98            source,
99        }
100    }
101
102    fn collect_stderr(command: PathBuf, source: io::Error) -> Self {
103        FormattingError::CollectStderr {
104            command,
105            source,
106        }
107    }
108
109    fn list_files(reason: ListFilesReason, output: &[u8]) -> Self {
110        FormattingError::ListFiles {
111            reason,
112            output: String::from_utf8_lossy(output).into(),
113        }
114    }
115}
116
117/// Run a formatter in the repository to check commits for formatting.
118///
119/// The formatter is passed the following arguments:
120///
121/// - the path to the file which should be checked.
122/// - if provided, the value of the controlling attribute.
123///
124/// At runtime, the following configuration keys may be used to amend the check's behavior:
125///
126/// - `formatter.{kind}.path`: the path to the formatter executable
127/// - `formatter.env_key`: an environment variable to set to the value `check` so that the
128///   formatter knows it is being run for a check.
129///
130/// The formatter is expected to exit with success whether the path passed to it has a valid format
131/// in it or not. A failure exit status is considered a failure of the formatter itself. If any
132/// changes (including untracked files) are left inside of the worktree, it is considered to have
133/// failed the checks.
134///
135/// The formatter is run with its current working directory being the top-level of the work tree,
136/// but not the proper `GIT_` context. This is because the setup for the workarea is not completely
137/// isolated and `git` commands may not behave as expected. The worktree it is working from is only
138/// guaranteed to have the files which have changed in the commit being checked on disk, so
139/// additional files which should be available for the command to run must be specified with
140/// `Formatting::add_config_files`.
141#[derive(Builder, Debug, Clone)]
142#[builder(field(private))]
143pub struct Formatting {
144    /// The "name" of the formatter.
145    ///
146    /// This is used to refer to the formatter in use in error messages.
147    ///
148    /// Configuration: Optional
149    /// Default: the `kind` is used
150    #[builder(setter(into, strip_option), default)]
151    name: Option<String>,
152    /// The "kind" of formatting being performed.
153    ///
154    /// This is used in the name of the attribute which uses this check.
155    ///
156    /// Configuration: Required
157    #[builder(setter(into))]
158    kind: String,
159    /// The path to the formatter.
160    ///
161    /// This may be a command that exists in `PATH` if absolute paths are not wanted.
162    ///
163    /// Configuration: Required
164    #[builder(setter(into))]
165    formatter: PathBuf,
166    #[builder(private)]
167    #[builder(setter(name = "_config_files"))]
168    #[builder(default)]
169    config_files: Vec<String>,
170    /// A message to add when failures occur.
171    ///
172    /// Projects which check formatting may have a way to fix it automatically. This is here so
173    /// that those projects can mention their specific instructions.
174    ///
175    /// Configuration: Optional
176    /// Default: Unused if not provided.
177    #[builder(setter(into, strip_option), default)]
178    fix_message: Option<String>,
179    /// A timeout for running the formatter.
180    ///
181    /// If the formatter exceeds this timeout, it is considered to have failed.
182    ///
183    /// Configuration: Optional
184    /// Default: No timeout
185    #[builder(setter(into, strip_option), default)]
186    timeout: Option<Duration>,
187}
188
189/// This is the maximum number of files to list in the error message. Beyond this, the number of
190/// other files with formatting issues in them are handled by a "and so many other files" note.
191const MAX_EXPLICIT_FILE_LIST: usize = 5;
192/// How long to wait for a timed-out formatter to respond to `SIGKILL` before leaving it as a
193/// zombie process.
194const ZOMBIE_TIMEOUT: Duration = Duration::from_secs(1);
195
196impl FormattingBuilder {
197    /// Configuration files within the repository the formatter
198    ///
199    /// Configuration: Optional
200    /// Default: `Vec::new()`
201    pub fn config_files<I, F>(&mut self, files: I) -> &mut Self
202    where
203        I: IntoIterator<Item = F>,
204        F: Into<String>,
205    {
206        self.config_files = Some(files.into_iter().map(Into::into).collect());
207        self
208    }
209}
210
211impl Formatting {
212    /// Create a new builder.
213    pub fn builder() -> FormattingBuilder {
214        Default::default()
215    }
216
217    /// Check a path using the formatter.
218    fn check_path<'a>(
219        &self,
220        ctx: &GitWorkArea,
221        formatter: &Path,
222        env_key: Option<&OsStr>,
223        path: &'a FileName,
224        attr_value: Option<String>,
225    ) -> Result<Option<&'a FileName>, FormattingError> {
226        let mut cmd = Command::new(formatter);
227        ctx.cd_to_work_tree(&mut cmd);
228        cmd.arg(path.as_path());
229        if let Some(attr_value) = attr_value {
230            cmd.arg(attr_value);
231        }
232        if let Some(env_key) = env_key {
233            cmd.env(env_key, "check");
234        }
235
236        let (success, output) = if let Some(timeout) = self.timeout {
237            let mut child = cmd
238                // Formatters should not read anything.
239                .stdin(Stdio::null())
240                // The output goes nowhere.
241                .stdout(Stdio::null())
242                // But we want any error messages from them (for logging purposes). If this pipe
243                // fills up buffers, it will deadlock and the timeout will "save" us. Any process
244                // outputting this much error messages probably is very unhappy anyways.
245                .stderr(Stdio::piped())
246                .spawn()
247                .map_err(|err| {
248                    FormattingError::exec_formatter(
249                        formatter.to_path_buf(),
250                        FormattingExecStage::Run,
251                        err,
252                    )
253                })?;
254            let check = child.wait_timeout(timeout).map_err(|err| {
255                FormattingError::exec_formatter(
256                    formatter.to_path_buf(),
257                    FormattingExecStage::Wait,
258                    err,
259                )
260            })?;
261
262            if let Some(status) = check {
263                let stderr = child.stderr.expect("spawned with stderr");
264                let stderr = BufReader::new(stderr);
265                let bytes_output = stderr
266                    .bytes()
267                    .collect::<Result<Vec<u8>, _>>()
268                    .map_err(|err| FormattingError::collect_stderr(formatter.to_path_buf(), err))?;
269                (
270                    status.success(),
271                    format!(
272                        "failed with exit code {:?}, signal {:?}, output: {:?}",
273                        status.code(),
274                        status.signal(),
275                        String::from_utf8_lossy(&bytes_output),
276                    ),
277                )
278            } else {
279                child.kill().map_err(|err| {
280                    FormattingError::exec_formatter(
281                        formatter.to_path_buf(),
282                        FormattingExecStage::Kill,
283                        err,
284                    )
285                })?;
286                let timed_out_status = child.wait_timeout(ZOMBIE_TIMEOUT).map_err(|err| {
287                    FormattingError::exec_formatter(
288                        formatter.to_path_buf(),
289                        FormattingExecStage::TimeoutWait,
290                        err,
291                    )
292                })?;
293                if timed_out_status.is_none() {
294                    warn!(
295                        target: "git-checks/formatting",
296                        "leaving a zombie '{}' process; it did not respond to kill",
297                        self.kind,
298                    );
299                }
300                (false, "timeout reached".into())
301            }
302        } else {
303            let check = cmd.output().map_err(|err| {
304                FormattingError::exec_formatter(
305                    formatter.to_path_buf(),
306                    FormattingExecStage::Run,
307                    err,
308                )
309            })?;
310            (
311                check.status.success(),
312                String::from_utf8_lossy(&check.stderr).into_owned(),
313            )
314        };
315
316        Ok(if success {
317            debug!(
318                target: "git-checks/formatting",
319                "succeeded at running the {} formatting command: {output}",
320                self.kind,
321            );
322            None
323        } else {
324            info!(
325                target: "git-checks/formatting",
326                "failed to run the {} formatting command: {output}",
327                self.kind,
328            );
329            Some(path)
330        })
331    }
332
333    /// Create a message for the given paths.
334    #[allow(clippy::needless_collect)]
335    fn message_for_paths<P>(
336        &self,
337        results: &mut CheckResult,
338        content: &dyn Content,
339        paths: Vec<P>,
340        description: &str,
341    ) where
342        P: fmt::Display,
343    {
344        if !paths.is_empty() {
345            let mut all_paths = paths.into_iter();
346            // List at least a certain number of files by name.
347            let explicit_paths = all_paths
348                .by_ref()
349                .take(MAX_EXPLICIT_FILE_LIST)
350                .map(|path| format!("`{path}`"))
351                .collect::<Vec<_>>();
352            // Eline the remaining files...
353            let next_path = all_paths.next();
354            let tail_paths = if let Some(next_path) = next_path {
355                let remaining_paths = all_paths.count();
356                if remaining_paths == 0 {
357                    // ...but avoid saying `and 1 others`.
358                    iter::once(format!("`{next_path}`")).collect::<Vec<_>>()
359                } else {
360                    iter::once(format!("and {} others", remaining_paths + 1)).collect::<Vec<_>>()
361                }
362            } else {
363                iter::empty().collect::<Vec<_>>()
364            }
365            .into_iter();
366            let paths = explicit_paths.into_iter().chain(tail_paths).join(", ");
367            let fix = self
368                .fix_message
369                .as_ref()
370                .map_or_else(String::new, |fix_message| format!(" {fix_message}"));
371            results.add_error(format!(
372                "{}the following files {description} the '{}' check: {paths}.{fix}",
373                commit_prefix_str(content, "is not allowed because"),
374                self.name.as_ref().unwrap_or(&self.kind),
375            ));
376        }
377    }
378}
379
380impl ContentCheck for Formatting {
381    fn name(&self) -> &str {
382        "formatting"
383    }
384
385    fn check(
386        &self,
387        ctx: &CheckGitContext,
388        content: &dyn Content,
389    ) -> Result<CheckResult, Box<dyn Error>> {
390        let changed_paths = content.modified_files();
391
392        let gitctx = GitContext::new(ctx.gitdir());
393        let mut workarea = content.workarea(&gitctx)?;
394
395        // Create the files necessary on the disk.
396        let files_to_checkout = changed_paths
397            .iter()
398            .map(|path| path.as_path())
399            .chain(self.config_files.iter().map(AsRef::as_ref))
400            .collect::<Vec<_>>();
401        workarea.checkout(&files_to_checkout)?;
402
403        let formatter = ctx
404            .configuration(&format!("formatter.{}.path", self.kind))
405            .map(Path::new)
406            .unwrap_or(&self.formatter);
407        let env_key = ctx.configuration("formatter.env_key").map(OsStr::new);
408
409        let attr = format!("format.{}", self.kind);
410        let failed_paths = changed_paths
411            .par_iter()
412            .map(|path| {
413                match ctx.check_attr(&attr, path.as_path())? {
414                    AttributeState::Set => {
415                        self.check_path(&workarea, formatter, env_key, path, None)
416                    },
417                    AttributeState::Value(v) => {
418                        self.check_path(&workarea, formatter, env_key, path, Some(v))
419                    },
420                    _ => Ok(None),
421                }
422            })
423            .collect::<Vec<Result<_, _>>>()
424            .into_iter()
425            .collect::<Result<Vec<_>, _>>()?
426            .into_iter()
427            .flatten()
428            .collect::<Vec<_>>();
429
430        let ls_files_m = workarea
431            .git()
432            .arg("ls-files")
433            .arg("-m")
434            .output()
435            .map_err(|err| GitError::subcommand("ls-files -m", err))?;
436        if !ls_files_m.status.success() {
437            return Err(
438                FormattingError::list_files(ListFilesReason::Modified, &ls_files_m.stderr).into(),
439            );
440        }
441        let modified_paths = String::from_utf8_lossy(&ls_files_m.stdout);
442
443        // It seems that the `HEAD` ref is used rather than the index for `ls-files -m`, so
444        // basically every file is considered `deleted` and therefore listed here. Not sure if this
445        // is a bug in Git or not.
446        let modified_paths_in_commit = modified_paths
447            .lines()
448            .filter(|&path| {
449                changed_paths
450                    .iter()
451                    .any(|diff_path| diff_path.as_str() == path)
452            })
453            .collect();
454
455        let ls_files_o = workarea
456            .git()
457            .arg("ls-files")
458            .arg("-o")
459            .output()
460            .map_err(|err| GitError::subcommand("ls-files -o", err))?;
461        if !ls_files_o.status.success() {
462            return Err(FormattingError::list_files(
463                ListFilesReason::Untracked,
464                &ls_files_o.stderr,
465            )
466            .into());
467        }
468        let untracked_paths = String::from_utf8_lossy(&ls_files_o.stdout);
469
470        let mut results = CheckResult::new();
471
472        self.message_for_paths(
473            &mut results,
474            content,
475            failed_paths,
476            "could not be formatted by",
477        );
478        self.message_for_paths(
479            &mut results,
480            content,
481            modified_paths_in_commit,
482            "are not formatted according to",
483        );
484        self.message_for_paths(
485            &mut results,
486            content,
487            untracked_paths.lines().collect(),
488            "were created by",
489        );
490
491        Ok(results)
492    }
493}
494
495#[cfg(feature = "config")]
496pub(crate) mod config {
497    #[cfg(test)]
498    use std::path::Path;
499    use std::time::Duration;
500
501    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
502    use serde::Deserialize;
503    #[cfg(test)]
504    use serde_json::json;
505
506    #[cfg(test)]
507    use crate::test;
508    use crate::Formatting;
509
510    /// Configuration for the `Formatting` check.
511    ///
512    /// The `kind` key is required and is a string. This is used to construct the name of the Git
513    /// attribute to look for to find files which are handled by this formatter. The `name` key is
514    /// optional, but is a string and defaults to the given `kind`. The `formatter` key is a string
515    /// containing the path to the formatter on the system running the checks. Some formatters may
516    /// work with configuration files committed to the repository. These will also be checked out
517    /// when using this formatter. These may be valid Git path specifications with globs. If
518    /// problems are found, the optional `fix_message` key (a string) will be added to the message.
519    /// This should describe how to fix the issues found by the formatter. The `timeout` key is an
520    /// optional positive integer. If given, formatters not completing within the specified time
521    /// are considered failures. Without a timeout, formatters which do not exit will cause the
522    /// formatting check to wait forever.
523    ///
524    /// This check is registered as a commit check with the name `"formatting"` and a topic check
525    /// with the name `"formatting/topic"`.
526    ///
527    /// # Example
528    ///
529    /// ```json
530    /// {
531    ///     "name": "formatter name",
532    ///     "kind": "kind",
533    ///     "formatter": "/path/to/formatter",
534    ///     "config_files": [
535    ///         "path/to/config/file"
536    ///     ],
537    ///     "fix_message": "instructions for fixing",
538    ///     "timeout": 10,
539    /// }
540    /// ```
541    #[derive(Deserialize, Debug)]
542    pub struct FormattingConfig {
543        #[serde(default)]
544        name: Option<String>,
545        kind: String,
546        formatter: String,
547        #[serde(default)]
548        config_files: Option<Vec<String>>,
549        #[serde(default)]
550        fix_message: Option<String>,
551        #[serde(default)]
552        timeout: Option<u64>,
553    }
554
555    impl IntoCheck for FormattingConfig {
556        type Check = Formatting;
557
558        fn into_check(self) -> Self::Check {
559            let mut builder = Formatting::builder();
560
561            builder.kind(self.kind).formatter(self.formatter);
562
563            if let Some(name) = self.name {
564                builder.name(name);
565            }
566
567            if let Some(config_files) = self.config_files {
568                builder.config_files(config_files);
569            }
570
571            if let Some(fix_message) = self.fix_message {
572                builder.fix_message(fix_message);
573            }
574
575            if let Some(timeout) = self.timeout {
576                builder.timeout(Duration::from_secs(timeout));
577            }
578
579            builder
580                .build()
581                .expect("configuration mismatch for `Formatting`")
582        }
583    }
584
585    register_checks! {
586        FormattingConfig {
587            "formatting" => CommitCheckConfig,
588            "formatting/topic" => TopicCheckConfig,
589        },
590    }
591
592    #[test]
593    fn test_formatting_config_empty() {
594        let json = json!({});
595        let err = serde_json::from_value::<FormattingConfig>(json).unwrap_err();
596        test::check_missing_json_field(err, "kind");
597    }
598
599    #[test]
600    fn test_formatting_config_kind_is_required() {
601        let exp_formatter = "/path/to/formatter";
602        let json = json!({
603            "formatter": exp_formatter,
604        });
605        let err = serde_json::from_value::<FormattingConfig>(json).unwrap_err();
606        test::check_missing_json_field(err, "kind");
607    }
608
609    #[test]
610    fn test_formatting_config_formatter_is_required() {
611        let exp_kind = "kind";
612        let json = json!({
613            "kind": exp_kind,
614        });
615        let err = serde_json::from_value::<FormattingConfig>(json).unwrap_err();
616        test::check_missing_json_field(err, "formatter");
617    }
618
619    #[test]
620    fn test_formatting_config_minimum_fields() {
621        let exp_kind = "kind";
622        let exp_formatter = "/path/to/formatter";
623        let json = json!({
624            "kind": exp_kind,
625            "formatter": exp_formatter,
626        });
627        let check: FormattingConfig = serde_json::from_value(json).unwrap();
628
629        assert_eq!(check.name, None);
630        assert_eq!(check.kind, exp_kind);
631        assert_eq!(check.formatter, exp_formatter);
632        assert_eq!(check.config_files, None);
633        assert_eq!(check.fix_message, None);
634        assert_eq!(check.timeout, None);
635
636        let check = check.into_check();
637
638        assert_eq!(check.name, None);
639        assert_eq!(check.kind, exp_kind);
640        assert_eq!(check.formatter, Path::new(exp_formatter));
641        itertools::assert_equal(&check.config_files, &[] as &[&str]);
642        assert_eq!(check.fix_message, None);
643        assert_eq!(check.timeout, None);
644    }
645
646    #[test]
647    fn test_formatting_config_all_fields() {
648        let exp_name: String = "formatter name".into();
649        let exp_kind = "kind";
650        let exp_formatter = "/path/to/formatter";
651        let exp_config: String = "path/to/config/file".into();
652        let exp_fix_message: String = "instructions for fixing".into();
653        let exp_timeout = 10;
654        let json = json!({
655            "name": exp_name,
656            "kind": exp_kind,
657            "formatter": exp_formatter,
658            "config_files": [exp_config],
659            "fix_message": exp_fix_message,
660            "timeout": exp_timeout,
661        });
662        let check: FormattingConfig = serde_json::from_value(json).unwrap();
663
664        assert_eq!(check.name, Some(exp_name.clone()));
665        assert_eq!(check.kind, exp_kind);
666        assert_eq!(check.formatter, exp_formatter);
667        itertools::assert_equal(
668            check.config_files.as_ref().unwrap(),
669            std::slice::from_ref(&exp_config),
670        );
671        assert_eq!(check.fix_message, Some(exp_fix_message.clone()));
672        assert_eq!(check.timeout, Some(exp_timeout));
673
674        let check = check.into_check();
675
676        assert_eq!(check.name, Some(exp_name));
677        assert_eq!(check.kind, exp_kind);
678        assert_eq!(check.formatter, Path::new(exp_formatter));
679        itertools::assert_equal(&check.config_files, &[exp_config]);
680        assert_eq!(check.fix_message, Some(exp_fix_message));
681        assert_eq!(check.timeout, Some(Duration::from_secs(exp_timeout)));
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use std::time::Duration;
688
689    use git_checks_core::{Check, TopicCheck};
690
691    use crate::builders::FormattingBuilder;
692    use crate::test::*;
693    use crate::Formatting;
694
695    const MISSING_CONFIG_COMMIT: &str = "220efbb4d0380fe932b70444fe15e787506080b0";
696    const ADD_CONFIG_COMMIT: &str = "e08e9ac1c5b6a0a67e0b2715cb0dbf99935d9cbf";
697    const BAD_FORMAT_COMMIT: &str = "e9a08d956553f94e9c8a0a02b11ca60f62de3c2b";
698    const FIX_BAD_FORMAT_COMMIT: &str = "7fe590bdb883e195812cae7602ce9115cbd269ee";
699    const OK_FORMAT_COMMIT: &str = "b77d2a5d63cd6afa599d0896dafff95f1ace50b6";
700    const ENV_FORMAT_COMMIT: &str = "11107007b2875ac2f98a89085ff8f4f631b369ae";
701    const ENV_BAD_FORMAT_COMMIT: &str = "8358d940dbe6cdd6ee75ad229dbaf210a604870a";
702    const IGNORE_UNTRACKED_COMMIT: &str = "c0154d1087906d50c5551ff8f60e544e9a492a48";
703    const DELETE_FORMAT_COMMIT: &str = "31446c81184df35498814d6aa3c7f933dddf91c2";
704    const MANY_BAD_FORMAT_COMMIT: &str = "f0d10d9385ef697175c48fa72324c33d5e973f4b";
705    const MANY_MORE_BAD_FORMAT_COMMIT: &str = "0e80ff6dd2495571b7d255e39fb3e7cc9f487fb7";
706    const TIMEOUT_CONFIG_COMMIT: &str = "62f5eac20c5021cf323c757a4d24234c81c9c7ad";
707    const WITH_ARG_COMMIT: &str = "dfa4c021e96f1e94634ad1787f31c2abdbeaff99";
708    const NOEXEC_CONFIG_COMMIT: &str = "1b5be48f8ce45b8fec155a1787cabb6995194ce5";
709
710    #[test]
711    fn test_exec_stage_display() {
712        assert_eq!(format!("{}", super::FormattingExecStage::Run), "execute");
713        assert_eq!(format!("{}", super::FormattingExecStage::Wait), "wait on");
714        assert_eq!(
715            format!("{}", super::FormattingExecStage::Kill),
716            "kill (timed out)",
717        );
718        assert_eq!(
719            format!("{}", super::FormattingExecStage::TimeoutWait),
720            "wait on (timed out)",
721        );
722    }
723
724    #[test]
725    fn test_list_files_reason_display() {
726        assert_eq!(format!("{}", super::ListFilesReason::Modified), "modified");
727        assert_eq!(
728            format!("{}", super::ListFilesReason::Untracked),
729            "untracked",
730        );
731    }
732
733    #[test]
734    fn test_formatting_builder_default() {
735        assert!(Formatting::builder().build().is_err());
736    }
737
738    #[test]
739    fn test_formatting_builder_kind_is_required() {
740        assert!(Formatting::builder()
741            .formatter("path/to/formatter")
742            .build()
743            .is_err());
744    }
745
746    #[test]
747    fn test_formatting_builder_formatter_is_required() {
748        assert!(Formatting::builder().kind("kind").build().is_err());
749    }
750
751    #[test]
752    fn test_formatting_builder_minimum_fields() {
753        assert!(Formatting::builder()
754            .formatter("path/to/formatter")
755            .kind("kind")
756            .build()
757            .is_ok());
758    }
759
760    #[test]
761    fn test_formatting_name_commit() {
762        let check = Formatting::builder()
763            .formatter("path/to/formatter")
764            .kind("kind")
765            .build()
766            .unwrap();
767        assert_eq!(Check::name(&check), "formatting");
768    }
769
770    #[test]
771    fn test_formatting_name_topic() {
772        let check = Formatting::builder()
773            .formatter("path/to/formatter")
774            .kind("kind")
775            .build()
776            .unwrap();
777        assert_eq!(TopicCheck::name(&check), "formatting");
778    }
779
780    fn formatting_check(kind: &str) -> FormattingBuilder {
781        let formatter = format!("{}/test/format.{kind}", env!("CARGO_MANIFEST_DIR"));
782        let mut builder = Formatting::builder();
783        builder
784            .kind(kind)
785            .formatter(formatter)
786            .config_files(["format-config"].iter().cloned());
787        builder
788    }
789
790    #[test]
791    fn test_formatting_pass() {
792        let check = formatting_check("simple").build().unwrap();
793        let conf = make_check_conf(&check);
794
795        let result = test_check_base(
796            "test_formatting_pass",
797            OK_FORMAT_COMMIT,
798            BAD_FORMAT_COMMIT,
799            &conf,
800        );
801        test_result_ok(result);
802    }
803
804    #[test]
805    fn test_formatting_env() {
806        let check = formatting_check("env").build().unwrap();
807        let conf = {
808            let mut conf = make_check_conf(&check);
809            conf.add_configuration("formatter.env_key", "GIT_CHECKS_TEST_FORMATTING_ENV");
810            conf
811        };
812
813        let result = test_check_base(
814            "test_formatting_env",
815            ENV_FORMAT_COMMIT,
816            ENV_BAD_FORMAT_COMMIT,
817            &conf,
818        );
819        test_result_ok(result);
820    }
821
822    #[test]
823    fn test_formatting_env_missing() {
824        let check = formatting_check("env").build().unwrap();
825        let conf = make_check_conf(&check);
826
827        let result = test_check_base(
828            "test_formatting_env_missing",
829            ENV_FORMAT_COMMIT,
830            ENV_BAD_FORMAT_COMMIT,
831            &conf,
832        );
833        test_result_errors(result, &[
834            "commit 11107007b2875ac2f98a89085ff8f4f631b369ae is not allowed because the following \
835             files could not be formatted by the 'env' check: `ok.txt`.",
836        ]);
837    }
838
839    #[test]
840    fn test_formatting_pass_with_arg() {
841        let check = formatting_check("with_arg").build().unwrap();
842        let conf = make_check_conf(&check);
843
844        let result = test_check("test_formatting_pass_with_arg", WITH_ARG_COMMIT, &conf);
845        test_result_errors(result, &[
846            "commit dfa4c021e96f1e94634ad1787f31c2abdbeaff99 is not allowed because the following \
847             files are not formatted according to the 'with_arg' check: `with-arg.txt`.",
848        ]);
849    }
850
851    #[test]
852    fn test_formatting_formatter_fail() {
853        let check = formatting_check("simple").build().unwrap();
854        let result = run_check(
855            "test_formatting_formatter_fail",
856            MISSING_CONFIG_COMMIT,
857            check,
858        );
859        test_result_errors(result, &[
860            "commit 220efbb4d0380fe932b70444fe15e787506080b0 is not allowed because the following \
861             files could not be formatted by the 'simple' check: `empty.txt`.",
862        ]);
863    }
864
865    #[test]
866    fn test_formatting_formatter_fail_named() {
867        let check = formatting_check("simple").name("renamed").build().unwrap();
868        let result = run_check(
869            "test_formatting_formatter_fail_named",
870            MISSING_CONFIG_COMMIT,
871            check,
872        );
873        test_result_errors(result, &[
874            "commit 220efbb4d0380fe932b70444fe15e787506080b0 is not allowed because the following \
875             files could not be formatted by the 'renamed' check: `empty.txt`.",
876        ]);
877    }
878
879    #[test]
880    fn test_formatting_formatter_fail_fix_message() {
881        let check = formatting_check("simple")
882            .fix_message("These may be fixed by magic.")
883            .build()
884            .unwrap();
885        let result = run_check(
886            "test_formatting_formatter_fail_fix_message",
887            MISSING_CONFIG_COMMIT,
888            check,
889        );
890        test_result_errors(result, &[
891            "commit 220efbb4d0380fe932b70444fe15e787506080b0 is not allowed because the following \
892             files could not be formatted by the 'simple' check: `empty.txt`. These may be fixed \
893             by magic.",
894        ]);
895    }
896
897    #[test]
898    fn test_formatting_formatter_untracked_files() {
899        let check = formatting_check("untracked").build().unwrap();
900        let result = run_check(
901            "test_formatting_formatter_untracked_files",
902            MISSING_CONFIG_COMMIT,
903            check,
904        );
905        test_result_errors(result, &[
906            "commit 220efbb4d0380fe932b70444fe15e787506080b0 is not allowed because the following \
907             files were created by the 'untracked' check: `untracked`.",
908        ]);
909    }
910
911    #[test]
912    fn test_formatting_formatter_timeout() {
913        let check = formatting_check("timeout")
914            .timeout(Duration::from_secs(1))
915            .build()
916            .unwrap();
917        let result = run_check(
918            "test_formatting_formatter_timeout",
919            TIMEOUT_CONFIG_COMMIT,
920            check,
921        );
922        test_result_errors(result, &[
923            "commit 62f5eac20c5021cf323c757a4d24234c81c9c7ad is not allowed because the following \
924             files could not be formatted by the 'timeout' check: `empty.txt`.",
925        ]);
926    }
927
928    #[test]
929    fn test_formatting_formatter_untracked_files_ignored() {
930        let check = formatting_check("untracked").build().unwrap();
931        let conf = make_check_conf(&check);
932
933        let result = test_check_base(
934            "test_formatting_formatter_untracked_files_ignored",
935            IGNORE_UNTRACKED_COMMIT,
936            OK_FORMAT_COMMIT,
937            &conf,
938        );
939        test_result_ok(result);
940    }
941
942    #[test]
943    fn test_formatting_formatter_modified_files() {
944        let check = formatting_check("simple").build().unwrap();
945        let conf = make_check_conf(&check);
946
947        let result = test_check_base(
948            "test_formatting_formatter_modified_files",
949            BAD_FORMAT_COMMIT,
950            ADD_CONFIG_COMMIT,
951            &conf,
952        );
953        test_result_errors(result, &[
954            "commit e9a08d956553f94e9c8a0a02b11ca60f62de3c2b is not allowed because the following \
955             files are not formatted according to the 'simple' check: `bad.txt`.",
956        ]);
957    }
958
959    #[test]
960    fn test_formatting_formatter_modified_files_topic() {
961        let check = formatting_check("simple").build().unwrap();
962        let conf = make_topic_check_conf(&check);
963
964        let result = test_check_base(
965            "test_formatting_formatter_modified_files_topic",
966            BAD_FORMAT_COMMIT,
967            ADD_CONFIG_COMMIT,
968            &conf,
969        );
970        test_result_errors(
971            result,
972            &["the following files are not formatted according to the 'simple' check: `bad.txt`."],
973        );
974    }
975
976    #[test]
977    fn test_formatting_formatter_modified_files_topic_fixed() {
978        let check = formatting_check("simple").build().unwrap();
979        run_topic_check_ok(
980            "test_formatting_formatter_modified_files_topic_fixed",
981            FIX_BAD_FORMAT_COMMIT,
982            check,
983        );
984    }
985
986    #[test]
987    fn test_formatting_formatter_many_modified_files() {
988        let check = formatting_check("simple").build().unwrap();
989        let conf = make_check_conf(&check);
990
991        let result = test_check_base(
992            "test_formatting_formatter_many_modified_files",
993            MANY_BAD_FORMAT_COMMIT,
994            ADD_CONFIG_COMMIT,
995            &conf,
996        );
997        test_result_errors(result, &[
998            "commit f0d10d9385ef697175c48fa72324c33d5e973f4b is not allowed because the following \
999             files are not formatted according to the 'simple' check: `1.bad.txt`, `2.bad.txt`, \
1000             `3.bad.txt`, `4.bad.txt`, `5.bad.txt`, `6.bad.txt`.",
1001        ]);
1002    }
1003
1004    #[test]
1005    fn test_formatting_formatter_many_more_modified_files() {
1006        let check = formatting_check("simple").build().unwrap();
1007        let conf = make_check_conf(&check);
1008
1009        let result = test_check_base(
1010            "test_formatting_formatter_many_more_modified_files",
1011            MANY_MORE_BAD_FORMAT_COMMIT,
1012            ADD_CONFIG_COMMIT,
1013            &conf,
1014        );
1015        test_result_errors(result, &[
1016            "commit 0e80ff6dd2495571b7d255e39fb3e7cc9f487fb7 is not allowed because the following \
1017             files are not formatted according to the 'simple' check: `1.bad.txt`, `2.bad.txt`, \
1018             `3.bad.txt`, `4.bad.txt`, `5.bad.txt`, and 2 others.",
1019        ]);
1020    }
1021
1022    #[test]
1023    fn test_formatting_script_deleted_files() {
1024        let check = formatting_check("delete").build().unwrap();
1025        let result = run_check(
1026            "test_formatting_script_deleted_files",
1027            DELETE_FORMAT_COMMIT,
1028            check,
1029        );
1030        test_result_errors(result, &[
1031            "commit 31446c81184df35498814d6aa3c7f933dddf91c2 is not allowed because the following \
1032            files are not formatted according to the 'delete' check: `remove.txt`.",
1033        ]);
1034    }
1035
1036    #[test]
1037    fn test_formatting_formatter_noexec() {
1038        let check = formatting_check("noexec").build().unwrap();
1039        let result = run_check(
1040            "test_formatting_formatter_noexec",
1041            NOEXEC_CONFIG_COMMIT,
1042            check,
1043        );
1044
1045        assert_eq!(result.warnings().len(), 0);
1046        assert_eq!(result.alerts().len(), 1);
1047        assert_eq!(
1048            result.alerts()[0],
1049            "failed to run the formatting check on commit 1b5be48f8ce45b8fec155a1787cabb6995194ce5",
1050        );
1051        assert_eq!(result.errors().len(), 0);
1052        assert!(!result.temporary());
1053        assert!(!result.allowed());
1054        assert!(!result.pass());
1055    }
1056
1057    #[test]
1058    fn test_formatting_replace_path() {
1059        let check = formatting_check("simple").build().unwrap();
1060        let conf = {
1061            let mut conf = make_check_conf(&check);
1062            conf.add_configuration(
1063                "formatter.simple.path",
1064                format!("{}/test/noexist/format.simple", env!("CARGO_MANIFEST_DIR")),
1065            );
1066            conf
1067        };
1068
1069        let result = test_check_base(
1070            "test_formatting_replace_path",
1071            OK_FORMAT_COMMIT,
1072            BAD_FORMAT_COMMIT,
1073            &conf,
1074        );
1075
1076        assert_eq!(result.warnings().len(), 0);
1077        assert_eq!(result.alerts().len(), 1);
1078        assert_eq!(
1079            result.alerts()[0],
1080            "failed to run the formatting check on commit b77d2a5d63cd6afa599d0896dafff95f1ace50b6",
1081        );
1082        assert_eq!(result.errors().len(), 0);
1083        assert!(!result.temporary());
1084        assert!(!result.allowed());
1085        assert!(!result.pass());
1086    }
1087}