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: {}",
327                self.kind,
328                output,
329            );
330            Some(path)
331        })
332    }
333
334    /// Create a message for the given paths.
335    #[allow(clippy::needless_collect)]
336    fn message_for_paths<P>(
337        &self,
338        results: &mut CheckResult,
339        content: &dyn Content,
340        paths: Vec<P>,
341        description: &str,
342    ) where
343        P: fmt::Display,
344    {
345        if !paths.is_empty() {
346            let mut all_paths = paths.into_iter();
347            // List at least a certain number of files by name.
348            let explicit_paths = all_paths
349                .by_ref()
350                .take(MAX_EXPLICIT_FILE_LIST)
351                .map(|path| format!("`{}`", path))
352                .collect::<Vec<_>>();
353            // Eline the remaining files...
354            let next_path = all_paths.next();
355            let tail_paths = if let Some(next_path) = next_path {
356                let remaining_paths = all_paths.count();
357                if remaining_paths == 0 {
358                    // ...but avoid saying `and 1 others`.
359                    iter::once(format!("`{}`", next_path)).collect::<Vec<_>>()
360                } else {
361                    iter::once(format!("and {} others", remaining_paths + 1)).collect::<Vec<_>>()
362                }
363            } else {
364                iter::empty().collect::<Vec<_>>()
365            }
366            .into_iter();
367            let paths = explicit_paths.into_iter().chain(tail_paths).join(", ");
368            let fix = self
369                .fix_message
370                .as_ref()
371                .map_or_else(String::new, |fix_message| format!(" {}", fix_message));
372            results.add_error(format!(
373                "{}the following files {} the '{}' check: {}.{}",
374                commit_prefix_str(content, "is not allowed because"),
375                description,
376                self.name.as_ref().unwrap_or(&self.kind),
377                paths,
378                fix,
379            ));
380        }
381    }
382}
383
384impl ContentCheck for Formatting {
385    fn name(&self) -> &str {
386        "formatting"
387    }
388
389    fn check(
390        &self,
391        ctx: &CheckGitContext,
392        content: &dyn Content,
393    ) -> Result<CheckResult, Box<dyn Error>> {
394        let changed_paths = content.modified_files();
395
396        let gitctx = GitContext::new(ctx.gitdir());
397        let mut workarea = content.workarea(&gitctx)?;
398
399        // Create the files necessary on the disk.
400        let files_to_checkout = changed_paths
401            .iter()
402            .map(|path| path.as_path())
403            .chain(self.config_files.iter().map(AsRef::as_ref))
404            .collect::<Vec<_>>();
405        workarea.checkout(&files_to_checkout)?;
406
407        let formatter = ctx
408            .configuration(&format!("formatter.{}.path", self.kind))
409            .map(Path::new)
410            .unwrap_or(&self.formatter);
411        let env_key = ctx.configuration("formatter.env_key").map(OsStr::new);
412
413        let attr = format!("format.{}", self.kind);
414        let failed_paths = changed_paths
415            .par_iter()
416            .map(|path| {
417                match ctx.check_attr(&attr, path.as_path())? {
418                    AttributeState::Set => {
419                        self.check_path(&workarea, formatter, env_key, path, None)
420                    },
421                    AttributeState::Value(v) => {
422                        self.check_path(&workarea, formatter, env_key, path, Some(v))
423                    },
424                    _ => Ok(None),
425                }
426            })
427            .collect::<Vec<Result<_, _>>>()
428            .into_iter()
429            .collect::<Result<Vec<_>, _>>()?
430            .into_iter()
431            .flatten()
432            .collect::<Vec<_>>();
433
434        let ls_files_m = workarea
435            .git()
436            .arg("ls-files")
437            .arg("-m")
438            .output()
439            .map_err(|err| GitError::subcommand("ls-files -m", err))?;
440        if !ls_files_m.status.success() {
441            return Err(
442                FormattingError::list_files(ListFilesReason::Modified, &ls_files_m.stderr).into(),
443            );
444        }
445        let modified_paths = String::from_utf8_lossy(&ls_files_m.stdout);
446
447        // It seems that the `HEAD` ref is used rather than the index for `ls-files -m`, so
448        // basically every file is considered `deleted` and therefore listed here. Not sure if this
449        // is a bug in Git or not.
450        let modified_paths_in_commit = modified_paths
451            .lines()
452            .filter(|&path| {
453                changed_paths
454                    .iter()
455                    .any(|diff_path| diff_path.as_str() == path)
456            })
457            .collect();
458
459        let ls_files_o = workarea
460            .git()
461            .arg("ls-files")
462            .arg("-o")
463            .output()
464            .map_err(|err| GitError::subcommand("ls-files -o", err))?;
465        if !ls_files_o.status.success() {
466            return Err(FormattingError::list_files(
467                ListFilesReason::Untracked,
468                &ls_files_o.stderr,
469            )
470            .into());
471        }
472        let untracked_paths = String::from_utf8_lossy(&ls_files_o.stdout);
473
474        let mut results = CheckResult::new();
475
476        self.message_for_paths(
477            &mut results,
478            content,
479            failed_paths,
480            "could not be formatted by",
481        );
482        self.message_for_paths(
483            &mut results,
484            content,
485            modified_paths_in_commit,
486            "are not formatted according to",
487        );
488        self.message_for_paths(
489            &mut results,
490            content,
491            untracked_paths.lines().collect(),
492            "were created by",
493        );
494
495        Ok(results)
496    }
497}
498
499#[cfg(feature = "config")]
500pub(crate) mod config {
501    #[cfg(test)]
502    use std::path::Path;
503    use std::time::Duration;
504
505    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
506    use serde::Deserialize;
507    #[cfg(test)]
508    use serde_json::json;
509
510    #[cfg(test)]
511    use crate::test;
512    use crate::Formatting;
513
514    /// Configuration for the `Formatting` check.
515    ///
516    /// The `kind` key is required and is a string. This is used to construct the name of the Git
517    /// attribute to look for to find files which are handled by this formatter. The `name` key is
518    /// optional, but is a string and defaults to the given `kind`. The `formatter` key is a string
519    /// containing the path to the formatter on the system running the checks. Some formatters may
520    /// work with configuration files committed to the repository. These will also be checked out
521    /// when using this formatter. These may be valid Git path specifications with globs. If
522    /// problems are found, the optional `fix_message` key (a string) will be added to the message.
523    /// This should describe how to fix the issues found by the formatter. The `timeout` key is an
524    /// optional positive integer. If given, formatters not completing within the specified time
525    /// are considered failures. Without a timeout, formatters which do not exit will cause the
526    /// formatting check to wait forever.
527    ///
528    /// This check is registered as a commit check with the name `"formatting"` and a topic check
529    /// with the name `"formatting/topic"`.
530    ///
531    /// # Example
532    ///
533    /// ```json
534    /// {
535    ///     "name": "formatter name",
536    ///     "kind": "kind",
537    ///     "formatter": "/path/to/formatter",
538    ///     "config_files": [
539    ///         "path/to/config/file"
540    ///     ],
541    ///     "fix_message": "instructions for fixing",
542    ///     "timeout": 10,
543    /// }
544    /// ```
545    #[derive(Deserialize, Debug)]
546    pub struct FormattingConfig {
547        #[serde(default)]
548        name: Option<String>,
549        kind: String,
550        formatter: String,
551        #[serde(default)]
552        config_files: Option<Vec<String>>,
553        #[serde(default)]
554        fix_message: Option<String>,
555        #[serde(default)]
556        timeout: Option<u64>,
557    }
558
559    impl IntoCheck for FormattingConfig {
560        type Check = Formatting;
561
562        fn into_check(self) -> Self::Check {
563            let mut builder = Formatting::builder();
564
565            builder.kind(self.kind).formatter(self.formatter);
566
567            if let Some(name) = self.name {
568                builder.name(name);
569            }
570
571            if let Some(config_files) = self.config_files {
572                builder.config_files(config_files);
573            }
574
575            if let Some(fix_message) = self.fix_message {
576                builder.fix_message(fix_message);
577            }
578
579            if let Some(timeout) = self.timeout {
580                builder.timeout(Duration::from_secs(timeout));
581            }
582
583            builder
584                .build()
585                .expect("configuration mismatch for `Formatting`")
586        }
587    }
588
589    register_checks! {
590        FormattingConfig {
591            "formatting" => CommitCheckConfig,
592            "formatting/topic" => TopicCheckConfig,
593        },
594    }
595
596    #[test]
597    fn test_formatting_config_empty() {
598        let json = json!({});
599        let err = serde_json::from_value::<FormattingConfig>(json).unwrap_err();
600        test::check_missing_json_field(err, "kind");
601    }
602
603    #[test]
604    fn test_formatting_config_kind_is_required() {
605        let exp_formatter = "/path/to/formatter";
606        let json = json!({
607            "formatter": exp_formatter,
608        });
609        let err = serde_json::from_value::<FormattingConfig>(json).unwrap_err();
610        test::check_missing_json_field(err, "kind");
611    }
612
613    #[test]
614    fn test_formatting_config_formatter_is_required() {
615        let exp_kind = "kind";
616        let json = json!({
617            "kind": exp_kind,
618        });
619        let err = serde_json::from_value::<FormattingConfig>(json).unwrap_err();
620        test::check_missing_json_field(err, "formatter");
621    }
622
623    #[test]
624    fn test_formatting_config_minimum_fields() {
625        let exp_kind = "kind";
626        let exp_formatter = "/path/to/formatter";
627        let json = json!({
628            "kind": exp_kind,
629            "formatter": exp_formatter,
630        });
631        let check: FormattingConfig = serde_json::from_value(json).unwrap();
632
633        assert_eq!(check.name, None);
634        assert_eq!(check.kind, exp_kind);
635        assert_eq!(check.formatter, exp_formatter);
636        assert_eq!(check.config_files, None);
637        assert_eq!(check.fix_message, None);
638        assert_eq!(check.timeout, None);
639
640        let check = check.into_check();
641
642        assert_eq!(check.name, None);
643        assert_eq!(check.kind, exp_kind);
644        assert_eq!(check.formatter, Path::new(exp_formatter));
645        itertools::assert_equal(&check.config_files, &[] as &[&str]);
646        assert_eq!(check.fix_message, None);
647        assert_eq!(check.timeout, None);
648    }
649
650    #[test]
651    fn test_formatting_config_all_fields() {
652        let exp_name: String = "formatter name".into();
653        let exp_kind = "kind";
654        let exp_formatter = "/path/to/formatter";
655        let exp_config: String = "path/to/config/file".into();
656        let exp_fix_message: String = "instructions for fixing".into();
657        let exp_timeout = 10;
658        let json = json!({
659            "name": exp_name,
660            "kind": exp_kind,
661            "formatter": exp_formatter,
662            "config_files": [exp_config],
663            "fix_message": exp_fix_message,
664            "timeout": exp_timeout,
665        });
666        let check: FormattingConfig = serde_json::from_value(json).unwrap();
667
668        assert_eq!(check.name, Some(exp_name.clone()));
669        assert_eq!(check.kind, exp_kind);
670        assert_eq!(check.formatter, exp_formatter);
671        itertools::assert_equal(
672            check.config_files.as_ref().unwrap(),
673            std::slice::from_ref(&exp_config),
674        );
675        assert_eq!(check.fix_message, Some(exp_fix_message.clone()));
676        assert_eq!(check.timeout, Some(exp_timeout));
677
678        let check = check.into_check();
679
680        assert_eq!(check.name, Some(exp_name));
681        assert_eq!(check.kind, exp_kind);
682        assert_eq!(check.formatter, Path::new(exp_formatter));
683        itertools::assert_equal(&check.config_files, &[exp_config]);
684        assert_eq!(check.fix_message, Some(exp_fix_message));
685        assert_eq!(check.timeout, Some(Duration::from_secs(exp_timeout)));
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use std::time::Duration;
692
693    use git_checks_core::{Check, TopicCheck};
694
695    use crate::builders::FormattingBuilder;
696    use crate::test::*;
697    use crate::Formatting;
698
699    const MISSING_CONFIG_COMMIT: &str = "220efbb4d0380fe932b70444fe15e787506080b0";
700    const ADD_CONFIG_COMMIT: &str = "e08e9ac1c5b6a0a67e0b2715cb0dbf99935d9cbf";
701    const BAD_FORMAT_COMMIT: &str = "e9a08d956553f94e9c8a0a02b11ca60f62de3c2b";
702    const FIX_BAD_FORMAT_COMMIT: &str = "7fe590bdb883e195812cae7602ce9115cbd269ee";
703    const OK_FORMAT_COMMIT: &str = "b77d2a5d63cd6afa599d0896dafff95f1ace50b6";
704    const ENV_FORMAT_COMMIT: &str = "11107007b2875ac2f98a89085ff8f4f631b369ae";
705    const ENV_BAD_FORMAT_COMMIT: &str = "8358d940dbe6cdd6ee75ad229dbaf210a604870a";
706    const IGNORE_UNTRACKED_COMMIT: &str = "c0154d1087906d50c5551ff8f60e544e9a492a48";
707    const DELETE_FORMAT_COMMIT: &str = "31446c81184df35498814d6aa3c7f933dddf91c2";
708    const MANY_BAD_FORMAT_COMMIT: &str = "f0d10d9385ef697175c48fa72324c33d5e973f4b";
709    const MANY_MORE_BAD_FORMAT_COMMIT: &str = "0e80ff6dd2495571b7d255e39fb3e7cc9f487fb7";
710    const TIMEOUT_CONFIG_COMMIT: &str = "62f5eac20c5021cf323c757a4d24234c81c9c7ad";
711    const WITH_ARG_COMMIT: &str = "dfa4c021e96f1e94634ad1787f31c2abdbeaff99";
712    const NOEXEC_CONFIG_COMMIT: &str = "1b5be48f8ce45b8fec155a1787cabb6995194ce5";
713
714    #[test]
715    fn test_exec_stage_display() {
716        assert_eq!(format!("{}", super::FormattingExecStage::Run), "execute");
717        assert_eq!(format!("{}", super::FormattingExecStage::Wait), "wait on");
718        assert_eq!(
719            format!("{}", super::FormattingExecStage::Kill),
720            "kill (timed out)"
721        );
722        assert_eq!(
723            format!("{}", super::FormattingExecStage::TimeoutWait),
724            "wait on (timed out)"
725        );
726    }
727
728    #[test]
729    fn test_list_files_reason_display() {
730        assert_eq!(format!("{}", super::ListFilesReason::Modified), "modified");
731        assert_eq!(
732            format!("{}", super::ListFilesReason::Untracked),
733            "untracked"
734        );
735    }
736
737    #[test]
738    fn test_formatting_builder_default() {
739        assert!(Formatting::builder().build().is_err());
740    }
741
742    #[test]
743    fn test_formatting_builder_kind_is_required() {
744        assert!(Formatting::builder()
745            .formatter("path/to/formatter")
746            .build()
747            .is_err());
748    }
749
750    #[test]
751    fn test_formatting_builder_formatter_is_required() {
752        assert!(Formatting::builder().kind("kind").build().is_err());
753    }
754
755    #[test]
756    fn test_formatting_builder_minimum_fields() {
757        assert!(Formatting::builder()
758            .formatter("path/to/formatter")
759            .kind("kind")
760            .build()
761            .is_ok());
762    }
763
764    #[test]
765    fn test_formatting_name_commit() {
766        let check = Formatting::builder()
767            .formatter("path/to/formatter")
768            .kind("kind")
769            .build()
770            .unwrap();
771        assert_eq!(Check::name(&check), "formatting");
772    }
773
774    #[test]
775    fn test_formatting_name_topic() {
776        let check = Formatting::builder()
777            .formatter("path/to/formatter")
778            .kind("kind")
779            .build()
780            .unwrap();
781        assert_eq!(TopicCheck::name(&check), "formatting");
782    }
783
784    fn formatting_check(kind: &str) -> FormattingBuilder {
785        let formatter = format!("{}/test/format.{}", env!("CARGO_MANIFEST_DIR"), kind);
786        let mut builder = Formatting::builder();
787        builder
788            .kind(kind)
789            .formatter(formatter)
790            .config_files(["format-config"].iter().cloned());
791        builder
792    }
793
794    #[test]
795    fn test_formatting_pass() {
796        let check = formatting_check("simple").build().unwrap();
797        let conf = make_check_conf(&check);
798
799        let result = test_check_base(
800            "test_formatting_pass",
801            OK_FORMAT_COMMIT,
802            BAD_FORMAT_COMMIT,
803            &conf,
804        );
805        test_result_ok(result);
806    }
807
808    #[test]
809    fn test_formatting_env() {
810        let check = formatting_check("env").build().unwrap();
811        let conf = {
812            let mut conf = make_check_conf(&check);
813            conf.add_configuration("formatter.env_key", "GIT_CHECKS_TEST_FORMATTING_ENV");
814            conf
815        };
816
817        let result = test_check_base(
818            "test_formatting_env",
819            ENV_FORMAT_COMMIT,
820            ENV_BAD_FORMAT_COMMIT,
821            &conf,
822        );
823        test_result_ok(result);
824    }
825
826    #[test]
827    fn test_formatting_env_missing() {
828        let check = formatting_check("env").build().unwrap();
829        let conf = make_check_conf(&check);
830
831        let result = test_check_base(
832            "test_formatting_env_missing",
833            ENV_FORMAT_COMMIT,
834            ENV_BAD_FORMAT_COMMIT,
835            &conf,
836        );
837        test_result_errors(result, &[
838            "commit 11107007b2875ac2f98a89085ff8f4f631b369ae is not allowed because the following \
839             files could not be formatted by the 'env' check: `ok.txt`.",
840        ]);
841    }
842
843    #[test]
844    fn test_formatting_pass_with_arg() {
845        let check = formatting_check("with_arg").build().unwrap();
846        let conf = make_check_conf(&check);
847
848        let result = test_check("test_formatting_pass_with_arg", WITH_ARG_COMMIT, &conf);
849        test_result_errors(result, &[
850            "commit dfa4c021e96f1e94634ad1787f31c2abdbeaff99 is not allowed because the following \
851             files are not formatted according to the 'with_arg' check: `with-arg.txt`.",
852        ]);
853    }
854
855    #[test]
856    fn test_formatting_formatter_fail() {
857        let check = formatting_check("simple").build().unwrap();
858        let result = run_check(
859            "test_formatting_formatter_fail",
860            MISSING_CONFIG_COMMIT,
861            check,
862        );
863        test_result_errors(result, &[
864            "commit 220efbb4d0380fe932b70444fe15e787506080b0 is not allowed because the following \
865             files could not be formatted by the 'simple' check: `empty.txt`.",
866        ]);
867    }
868
869    #[test]
870    fn test_formatting_formatter_fail_named() {
871        let check = formatting_check("simple").name("renamed").build().unwrap();
872        let result = run_check(
873            "test_formatting_formatter_fail_named",
874            MISSING_CONFIG_COMMIT,
875            check,
876        );
877        test_result_errors(result, &[
878            "commit 220efbb4d0380fe932b70444fe15e787506080b0 is not allowed because the following \
879             files could not be formatted by the 'renamed' check: `empty.txt`.",
880        ]);
881    }
882
883    #[test]
884    fn test_formatting_formatter_fail_fix_message() {
885        let check = formatting_check("simple")
886            .fix_message("These may be fixed by magic.")
887            .build()
888            .unwrap();
889        let result = run_check(
890            "test_formatting_formatter_fail_fix_message",
891            MISSING_CONFIG_COMMIT,
892            check,
893        );
894        test_result_errors(result, &[
895            "commit 220efbb4d0380fe932b70444fe15e787506080b0 is not allowed because the following \
896             files could not be formatted by the 'simple' check: `empty.txt`. These may be fixed \
897             by magic.",
898        ]);
899    }
900
901    #[test]
902    fn test_formatting_formatter_untracked_files() {
903        let check = formatting_check("untracked").build().unwrap();
904        let result = run_check(
905            "test_formatting_formatter_untracked_files",
906            MISSING_CONFIG_COMMIT,
907            check,
908        );
909        test_result_errors(result, &[
910            "commit 220efbb4d0380fe932b70444fe15e787506080b0 is not allowed because the following \
911             files were created by the 'untracked' check: `untracked`.",
912        ]);
913    }
914
915    #[test]
916    fn test_formatting_formatter_timeout() {
917        let check = formatting_check("timeout")
918            .timeout(Duration::from_secs(1))
919            .build()
920            .unwrap();
921        let result = run_check(
922            "test_formatting_formatter_timeout",
923            TIMEOUT_CONFIG_COMMIT,
924            check,
925        );
926        test_result_errors(result, &[
927            "commit 62f5eac20c5021cf323c757a4d24234c81c9c7ad is not allowed because the following \
928             files could not be formatted by the 'timeout' check: `empty.txt`.",
929        ]);
930    }
931
932    #[test]
933    fn test_formatting_formatter_untracked_files_ignored() {
934        let check = formatting_check("untracked").build().unwrap();
935        let conf = make_check_conf(&check);
936
937        let result = test_check_base(
938            "test_formatting_formatter_untracked_files_ignored",
939            IGNORE_UNTRACKED_COMMIT,
940            OK_FORMAT_COMMIT,
941            &conf,
942        );
943        test_result_ok(result);
944    }
945
946    #[test]
947    fn test_formatting_formatter_modified_files() {
948        let check = formatting_check("simple").build().unwrap();
949        let conf = make_check_conf(&check);
950
951        let result = test_check_base(
952            "test_formatting_formatter_modified_files",
953            BAD_FORMAT_COMMIT,
954            ADD_CONFIG_COMMIT,
955            &conf,
956        );
957        test_result_errors(result, &[
958            "commit e9a08d956553f94e9c8a0a02b11ca60f62de3c2b is not allowed because the following \
959             files are not formatted according to the 'simple' check: `bad.txt`.",
960        ]);
961    }
962
963    #[test]
964    fn test_formatting_formatter_modified_files_topic() {
965        let check = formatting_check("simple").build().unwrap();
966        let conf = make_topic_check_conf(&check);
967
968        let result = test_check_base(
969            "test_formatting_formatter_modified_files_topic",
970            BAD_FORMAT_COMMIT,
971            ADD_CONFIG_COMMIT,
972            &conf,
973        );
974        test_result_errors(
975            result,
976            &["the following files are not formatted according to the 'simple' check: `bad.txt`."],
977        );
978    }
979
980    #[test]
981    fn test_formatting_formatter_modified_files_topic_fixed() {
982        let check = formatting_check("simple").build().unwrap();
983        run_topic_check_ok(
984            "test_formatting_formatter_modified_files_topic_fixed",
985            FIX_BAD_FORMAT_COMMIT,
986            check,
987        );
988    }
989
990    #[test]
991    fn test_formatting_formatter_many_modified_files() {
992        let check = formatting_check("simple").build().unwrap();
993        let conf = make_check_conf(&check);
994
995        let result = test_check_base(
996            "test_formatting_formatter_many_modified_files",
997            MANY_BAD_FORMAT_COMMIT,
998            ADD_CONFIG_COMMIT,
999            &conf,
1000        );
1001        test_result_errors(result, &[
1002            "commit f0d10d9385ef697175c48fa72324c33d5e973f4b is not allowed because the following \
1003             files are not formatted according to the 'simple' check: `1.bad.txt`, `2.bad.txt`, \
1004             `3.bad.txt`, `4.bad.txt`, `5.bad.txt`, `6.bad.txt`.",
1005        ]);
1006    }
1007
1008    #[test]
1009    fn test_formatting_formatter_many_more_modified_files() {
1010        let check = formatting_check("simple").build().unwrap();
1011        let conf = make_check_conf(&check);
1012
1013        let result = test_check_base(
1014            "test_formatting_formatter_many_more_modified_files",
1015            MANY_MORE_BAD_FORMAT_COMMIT,
1016            ADD_CONFIG_COMMIT,
1017            &conf,
1018        );
1019        test_result_errors(result, &[
1020            "commit 0e80ff6dd2495571b7d255e39fb3e7cc9f487fb7 is not allowed because the following \
1021             files are not formatted according to the 'simple' check: `1.bad.txt`, `2.bad.txt`, \
1022             `3.bad.txt`, `4.bad.txt`, `5.bad.txt`, and 2 others.",
1023        ]);
1024    }
1025
1026    #[test]
1027    fn test_formatting_script_deleted_files() {
1028        let check = formatting_check("delete").build().unwrap();
1029        let result = run_check(
1030            "test_formatting_script_deleted_files",
1031            DELETE_FORMAT_COMMIT,
1032            check,
1033        );
1034        test_result_errors(result, &[
1035            "commit 31446c81184df35498814d6aa3c7f933dddf91c2 is not allowed because the following \
1036            files are not formatted according to the 'delete' check: `remove.txt`.",
1037        ]);
1038    }
1039
1040    #[test]
1041    fn test_formatting_formatter_noexec() {
1042        let check = formatting_check("noexec").build().unwrap();
1043        let result = run_check(
1044            "test_formatting_formatter_noexec",
1045            NOEXEC_CONFIG_COMMIT,
1046            check,
1047        );
1048
1049        assert_eq!(result.warnings().len(), 0);
1050        assert_eq!(result.alerts().len(), 1);
1051        assert_eq!(
1052            result.alerts()[0],
1053            "failed to run the formatting check on commit 1b5be48f8ce45b8fec155a1787cabb6995194ce5",
1054        );
1055        assert_eq!(result.errors().len(), 0);
1056        assert!(!result.temporary());
1057        assert!(!result.allowed());
1058        assert!(!result.pass());
1059    }
1060
1061    #[test]
1062    fn test_formatting_replace_path() {
1063        let check = formatting_check("simple").build().unwrap();
1064        let conf = {
1065            let mut conf = make_check_conf(&check);
1066            conf.add_configuration(
1067                "formatter.simple.path",
1068                format!("{}/test/noexist/format.simple", env!("CARGO_MANIFEST_DIR")),
1069            );
1070            conf
1071        };
1072
1073        let result = test_check_base(
1074            "test_formatting_replace_path",
1075            OK_FORMAT_COMMIT,
1076            BAD_FORMAT_COMMIT,
1077            &conf,
1078        );
1079
1080        assert_eq!(result.warnings().len(), 0);
1081        assert_eq!(result.alerts().len(), 1);
1082        assert_eq!(
1083            result.alerts()[0],
1084            "failed to run the formatting check on commit b77d2a5d63cd6afa599d0896dafff95f1ace50b6",
1085        );
1086        assert_eq!(result.errors().len(), 0);
1087        assert!(!result.temporary());
1088        assert!(!result.allowed());
1089        assert!(!result.pass());
1090    }
1091}