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