use crates::git_workarea::{GitContext, GitWorkArea};
use crates::itertools::Itertools;
use crates::rayon::prelude::*;
use crates::wait_timeout::ChildExt;
use impl_prelude::*;
use std::fmt;
use std::iter;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct Formatting {
name: Option<String>,
kind: String,
formatter: PathBuf,
config_files: Vec<String>,
fix_message: Option<String>,
timeout: Option<Duration>,
}
const MAX_EXPLICIT_FILE_LIST: usize = 5;
lazy_static! {
static ref ZOMBIE_TIMEOUT: Duration = Duration::from_secs(1);
}
impl Formatting {
pub fn new<K, F>(kind: K, formatter: F) -> Self
where K: ToString,
F: AsRef<Path>,
{
Self {
name: None,
kind: kind.to_string(),
formatter: formatter.as_ref().to_path_buf(),
config_files: Vec::new(),
fix_message: None,
timeout: None,
}
}
pub fn named<N>(&mut self, name: N) -> &mut Self
where N: ToString,
{
self.name = Some(name.to_string());
self
}
pub fn add_config_files<I, F>(&mut self, files: I) -> &mut Self
where I: IntoIterator<Item = F>,
F: ToString,
{
self.config_files.extend(files.into_iter().map(|file| file.to_string()));
self
}
pub fn with_fix_message<F>(&mut self, fix_message: F) -> &mut Self
where F: ToString,
{
self.fix_message = Some(fix_message.to_string());
self
}
pub fn with_timeout(&mut self, timeout: Duration) -> &mut Self {
self.timeout = Some(timeout);
self
}
fn check_path<'a>(&self, ctx: &GitWorkArea, path: &'a FileName)
-> Result<Option<&'a FileName>> {
let mut cmd = Command::new(&self.formatter);
ctx.cd_to_work_tree(&mut cmd);
cmd.arg(path.as_path());
let (success, output) = if let Some(timeout) = self.timeout {
let mut child = cmd
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.chain_err(|| "failed to construct formatter command")?;
let check = child.wait_timeout(timeout)
.chain_err(|| "failed to wait on the formatter command")?;
if let Some(status) = check {
(status.success(), format!("failed with exit code {:?}, signal {:?}",
status.code(),
status.unix_signal()))
} else {
child.kill().chain_err(|| "failed to kill a timed-out formatter")?;
let timed_out_status = child.wait_timeout(*ZOMBIE_TIMEOUT)
.chain_err(|| "failed to wait on a timed-out formatter")?;
if timed_out_status.is_none() {
warn!(target: "git-checks/formatting",
"leaving a zombie '{}' process; it did not respond to kill",
self.kind);
}
(false, "timeout reached".to_string())
}
} else {
let check = cmd.output()
.chain_err(|| "failed to construct formatter command")?;
(check.status.success(), String::from_utf8_lossy(&check.stderr).into_owned())
};
Ok(if success {
None
} else {
info!(target: "git-checks/formatting",
"failed to run the {} formatting command: {}",
self.kind,
output);
Some(path)
})
}
fn message_for_paths<P>(&self, results: &mut CheckResult, content: &Content,
paths: Vec<P>, description: &str)
where P: fmt::Display,
{
if !paths.is_empty() {
let mut all_paths = paths.into_iter();
let explicit_paths = all_paths.by_ref()
.take(MAX_EXPLICIT_FILE_LIST)
.map(|path| format!("`{}`", path))
.collect::<Vec<_>>();
let next_path = all_paths.next();
let tail_paths = if let Some(next_path) = next_path {
let remaining_paths = all_paths.count();
if remaining_paths == 0 {
iter::once(format!("`{}`", next_path)).collect::<Vec<_>>()
} else {
iter::once(format!("and {} others", remaining_paths + 1))
.collect::<Vec<_>>()
}
} else {
iter::empty().collect::<Vec<_>>()
}
.into_iter();
let paths = explicit_paths.into_iter()
.chain(tail_paths)
.join(", ");
let fix = self.fix_message
.as_ref()
.map_or_else(String::new, |fix_message| format!(" {}", fix_message));
results.add_error(format!("{}the following files {} the '{}' check: {}.{}",
commit_prefix_str(content, "is not allowed because"),
description,
self.name.as_ref().unwrap_or(&self.kind),
paths,
fix));
}
}
}
impl ContentCheck for Formatting {
fn name(&self) -> &str {
"formatting"
}
fn check(&self, ctx: &CheckGitContext, content: &Content) -> Result<CheckResult> {
let changed_paths = content.modified_files();
let gitctx = GitContext::new(ctx.gitdir());
let workarea = content.workarea(&gitctx)?;
let files_to_checkout = changed_paths.iter()
.map(|path| path.as_path())
.chain(self.config_files.iter().map(AsRef::as_ref))
.collect::<Vec<_>>();
workarea.checkout(&files_to_checkout)?;
let attr = format!("format.{}", self.kind);
let failed_paths = changed_paths.par_iter()
.map(|path| {
if let AttributeState::Set = ctx.check_attr(&attr, path.as_path())? {
self.check_path(&workarea, path)
} else {
Ok(None)
}
})
.collect::<Vec<Result<_>>>()
.into_iter()
.collect::<Result<Vec<_>>>()?
.into_iter()
.filter_map(|path| path)
.collect::<Vec<_>>();
let ls_files_m = workarea.git()
.arg("ls-files")
.arg("-m")
.output()
.chain_err(|| "failed to construct ls-files command")?;
if !ls_files_m.status.success() {
bail!(ErrorKind::Git(format!("failed to list modified files in the work area: {}",
String::from_utf8_lossy(&ls_files_m.stderr))));
}
let modified_paths = String::from_utf8_lossy(&ls_files_m.stdout);
let modified_paths_in_commit = modified_paths.lines()
.filter(|&path| changed_paths.iter().any(|diff_path| diff_path.as_str() == path))
.collect();
let ls_files_o = workarea.git()
.arg("ls-files")
.arg("-o")
.output()
.chain_err(|| "failed to construct ls-files command")?;
if !ls_files_o.status.success() {
bail!(ErrorKind::Git(format!("failed to list untracked files in the work area: {}",
String::from_utf8_lossy(&ls_files_o.stderr))));
}
let untracked_paths = String::from_utf8_lossy(&ls_files_o.stdout);
let mut results = CheckResult::new();
self.message_for_paths(&mut results,
content,
failed_paths,
"could not be formatted by");
self.message_for_paths(&mut results,
content,
modified_paths_in_commit,
"are not formatted according to");
self.message_for_paths(&mut results,
content,
untracked_paths.lines().collect(),
"were created by");
Ok(results)
}
}
#[cfg(test)]
mod tests {
use checks::Formatting;
use checks::test::*;
use std::time::Duration;
fn formatting_check(kind: &str) -> Formatting {
let formatter = format!("{}/test/format.{}", env!("CARGO_MANIFEST_DIR"), kind);
let mut check = Formatting::new(kind, formatter);
check.add_config_files(&["format-config"]);
check
}
const MISSING_CONFIG_COMMIT: &str = "220efbb4d0380fe932b70444fe15e787506080b0";
const ADD_CONFIG_COMMIT: &str = "e08e9ac1c5b6a0a67e0b2715cb0dbf99935d9cbf";
const BAD_FORMAT_COMMIT: &str = "e9a08d956553f94e9c8a0a02b11ca60f62de3c2b";
const FIX_BAD_FORMAT_COMMIT: &str = "7fe590bdb883e195812cae7602ce9115cbd269ee";
const OK_FORMAT_COMMIT: &str = "b77d2a5d63cd6afa599d0896dafff95f1ace50b6";
const IGNORE_UNTRACKED_COMMIT: &str = "c0154d1087906d50c5551ff8f60e544e9a492a48";
const DELETE_FORMAT_COMMIT: &str = "31446c81184df35498814d6aa3c7f933dddf91c2";
const MANY_BAD_FORMAT_COMMIT: &str = "f0d10d9385ef697175c48fa72324c33d5e973f4b";
const MANY_MORE_BAD_FORMAT_COMMIT: &str = "0e80ff6dd2495571b7d255e39fb3e7cc9f487fb7";
const TIMEOUT_CONFIG_COMMIT: &str = "62f5eac20c5021cf323c757a4d24234c81c9c7ad";
#[test]
fn test_formatting_pass() {
let check = formatting_check("simple");
let conf = make_check_conf(&check);
let result = test_check_base("test_formatting_pass",
OK_FORMAT_COMMIT,
BAD_FORMAT_COMMIT,
&conf);
test_result_ok(result);
}
#[test]
fn test_formatting_formatter_fail() {
let check = formatting_check("simple");
let result = run_check("test_formatting_formatter_fail",
MISSING_CONFIG_COMMIT,
check);
test_result_errors(result, &[
"commit 220efbb4d0380fe932b70444fe15e787506080b0 is not allowed because the following \
files could not be formatted by the 'simple' check: `empty.txt`.",
]);
}
#[test]
fn test_formatting_formatter_fail_named() {
let mut check = formatting_check("simple");
check.named("renamed");
let result = run_check("test_formatting_formatter_fail_named",
MISSING_CONFIG_COMMIT,
check);
test_result_errors(result, &[
"commit 220efbb4d0380fe932b70444fe15e787506080b0 is not allowed because the following \
files could not be formatted by the 'renamed' check: `empty.txt`.",
]);
}
#[test]
fn test_formatting_formatter_fail_fix_message() {
let mut check = formatting_check("simple");
check.with_fix_message("These may be fixed by magic.");
let result = run_check("test_formatting_formatter_fail_fix_message",
MISSING_CONFIG_COMMIT,
check);
test_result_errors(result, &[
"commit 220efbb4d0380fe932b70444fe15e787506080b0 is not allowed because the following \
files could not be formatted by the 'simple' check: `empty.txt`. These may be fixed \
by magic.",
]);
}
#[test]
fn test_formatting_formatter_untracked_files() {
let check = formatting_check("untracked");
let result = run_check("test_formatting_formatter_untracked_files",
MISSING_CONFIG_COMMIT,
check);
test_result_errors(result, &[
"commit 220efbb4d0380fe932b70444fe15e787506080b0 is not allowed because the following \
files were created by the 'untracked' check: `untracked`.",
]);
}
#[test]
fn test_formatting_formatter_timeout() {
let mut check = formatting_check("timeout");
check.with_timeout(Duration::from_secs(1));
let result = run_check("test_formatting_formatter_timeout",
TIMEOUT_CONFIG_COMMIT,
check);
test_result_errors(result, &[
"commit 62f5eac20c5021cf323c757a4d24234c81c9c7ad is not allowed because the following \
files could not be formatted by the 'timeout' check: `empty.txt`.",
]);
}
#[test]
fn test_formatting_formatter_untracked_files_ignored() {
let check = formatting_check("untracked");
let conf = make_check_conf(&check);
let result = test_check_base("test_formatting_formatter_untracked_files_ignored",
IGNORE_UNTRACKED_COMMIT,
OK_FORMAT_COMMIT,
&conf);
test_result_ok(result);
}
#[test]
fn test_formatting_formatter_modified_files() {
let check = formatting_check("simple");
let conf = make_check_conf(&check);
let result = test_check_base("test_formatting_formatter_modified_files",
BAD_FORMAT_COMMIT,
ADD_CONFIG_COMMIT,
&conf);
test_result_errors(result, &[
"commit e9a08d956553f94e9c8a0a02b11ca60f62de3c2b is not allowed because the following \
files are not formatted according to the 'simple' check: `bad.txt`.",
]);
}
#[test]
fn test_formatting_formatter_modified_files_topic() {
let check = formatting_check("simple");
let conf = make_topic_check_conf(&check);
let result = test_check_base("test_formatting_formatter_modified_files_topic",
BAD_FORMAT_COMMIT,
ADD_CONFIG_COMMIT,
&conf);
test_result_errors(result, &[
"the following files are not formatted according to the 'simple' check: `bad.txt`.",
]);
}
#[test]
fn test_formatting_formatter_modified_files_topic_fixed() {
let check = formatting_check("simple");
run_topic_check_ok("test_formatting_formatter_modified_files_topic_fixed",
FIX_BAD_FORMAT_COMMIT,
check);
}
#[test]
fn test_formatting_formatter_many_modified_files() {
let check = formatting_check("simple");
let conf = make_check_conf(&check);
let result = test_check_base("test_formatting_formatter_many_modified_files",
MANY_BAD_FORMAT_COMMIT,
ADD_CONFIG_COMMIT,
&conf);
test_result_errors(result, &[
"commit f0d10d9385ef697175c48fa72324c33d5e973f4b is not allowed because the following \
files are not formatted according to the 'simple' check: `1.bad.txt`, `2.bad.txt`, \
`3.bad.txt`, `4.bad.txt`, `5.bad.txt`, `6.bad.txt`.",
]);
}
#[test]
fn test_formatting_formatter_many_more_modified_files() {
let check = formatting_check("simple");
let conf = make_check_conf(&check);
let result = test_check_base("test_formatting_formatter_many_more_modified_files",
MANY_MORE_BAD_FORMAT_COMMIT,
ADD_CONFIG_COMMIT,
&conf);
test_result_errors(result, &[
"commit 0e80ff6dd2495571b7d255e39fb3e7cc9f487fb7 is not allowed because the following \
files are not formatted according to the 'simple' check: `1.bad.txt`, `2.bad.txt`, \
`3.bad.txt`, `4.bad.txt`, `5.bad.txt`, and 2 others.",
]);
}
#[test]
fn test_formatting_script_deleted_files() {
let check = formatting_check("delete");
let result = run_check("test_formatting_script_deleted_files",
DELETE_FORMAT_COMMIT,
check);
test_result_errors(result, &[
"commit 31446c81184df35498814d6aa3c7f933dddf91c2 is not allowed because the following \
files are not formatted according to the 'delete' check: `remove.txt`.",
]);
}
}