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