use std::{path::Path, process::Command as ProcessCommand};
use assert_cmd::Command;
use rstest::rstest;
use tempfile::TempDir;
const RAGGED: &str = "|A|B|\n|---|---|\n|1|2|\n";
const CLEAN: &str = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
struct Run {
status: i32,
stdout: String,
stderr: String,
}
struct Fixture {
directory: TempDir,
}
impl Fixture {
fn new() -> Self {
let directory = tempfile::tempdir().expect("create temporary directory");
let fixture = Self { directory };
fixture.git(&["init", "--quiet", "-b", "main"]);
fixture
}
fn git(&self, args: &[&str]) {
let output = self
.git_command(args)
.output()
.expect("run git; the fixture needs it on PATH");
assert!(
output.status.success(),
"git {args:?} failed with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
fn git_command(&self, args: &[&str]) -> ProcessCommand {
let mut command = ProcessCommand::new("git");
command
.current_dir(self.root())
.args(args)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("HOME", self.root())
.env("LC_ALL", "C")
.env("LANGUAGE", "")
.env("GIT_AUTHOR_NAME", "mdtablefix tests")
.env("GIT_AUTHOR_EMAIL", "tests@example.invalid")
.env("GIT_COMMITTER_NAME", "mdtablefix tests")
.env("GIT_COMMITTER_EMAIL", "tests@example.invalid");
command
}
fn root(&self) -> &Path { self.directory.path() }
fn write(&self, name: &str, content: impl AsRef<[u8]>) {
let path = self.root().join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create the fixture's directory");
}
std::fs::write(&path, content).expect("write a fixture");
}
fn track(&self, name: &str, content: impl AsRef<[u8]>) {
self.write(name, content);
self.git(&["add", "--", name]);
}
fn read(&self, name: &str) -> String {
std::fs::read_to_string(self.root().join(name)).expect("read a fixture")
}
fn run(&self, args: &[&str]) -> Run { run_in(self.root(), args) }
}
fn run_in(directory: &Path, args: &[&str]) -> Run {
let output = Command::cargo_bin("mdtablefix")
.expect("cargo binary")
.current_dir(directory)
.args(args)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("HOME", directory)
.env("LC_ALL", "C")
.env("LANGUAGE", "")
.output()
.expect("run mdtablefix");
Run {
status: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}
}
#[rstest]
#[case(None, "docs/guide.md\nnotes.markdown\nrules.mdc\n")]
#[case(Some("mdc"), "rules.mdc\n")]
#[case(Some(".MD"), "docs/guide.md\n")]
#[case(Some(" .MD , mdc "), "docs/guide.md\nrules.mdc\n")]
fn md_exts_replaces_the_default_set(#[case] exts: Option<&str>, #[case] expected: &str) {
let fixture = Fixture::new();
fixture.track("docs/guide.md", RAGGED);
fixture.track("notes.markdown", RAGGED);
fixture.track("rules.mdc", RAGGED);
let mut args = vec!["--git", "--list-files"];
if let Some(exts) = exts {
args.extend_from_slice(&["--md-exts", exts]);
}
let run = fixture.run(&args);
assert_eq!(run.status, 0, "stderr: {}", run.stderr);
assert_eq!(run.stdout, expected, "with --md-exts {exts:?}");
}
#[rstest]
#[case("mdc.")]
#[case("tar.gz")]
fn a_dotted_extension_is_rejected(#[case] exts: &str) {
let fixture = Fixture::new();
fixture.track("docs/guide.md", RAGGED);
fixture.track("rules.mdc", RAGGED);
let run = fixture.run(&["--git", "--list-files", "--md-exts", exts]);
assert_eq!(run.status, 2, "stderr: {}", run.stderr);
assert!(run.stdout.is_empty(), "stdout: {:?}", run.stdout);
assert!(
run.stderr
.contains(&format!("extension \"{exts}\" contains a dot")),
"the rejection must name the value and the reason: {}",
run.stderr
);
}
#[test]
fn rejects_git_with_explicit_files() {
let fixture = Fixture::new();
fixture.track("notes.md", RAGGED);
let run = fixture.run(&["--git", "notes.md"]);
assert_eq!(run.status, 2, "stderr: {}", run.stderr);
assert!(run.stdout.is_empty(), "stdout: {:?}", run.stdout);
assert!(
run.stderr.contains("--git") && run.stderr.contains("cannot be used with"),
"the rejection must name the conflict: {}",
run.stderr
);
}
#[test]
fn in_place_without_inputs_is_rejected() {
let fixture = Fixture::new();
let run = fixture.run(&["--in-place"]);
assert_eq!(run.status, 2, "stderr: {}", run.stderr);
assert!(run.stdout.is_empty(), "stdout: {:?}", run.stdout);
}
#[test]
fn in_place_is_satisfied_by_git() {
let fixture = Fixture::new();
fixture.track("docs/guide.md", RAGGED);
fixture.track("src/lib.rs", RAGGED);
let run = fixture.run(&["--git", "--in-place"]);
assert_eq!(run.status, 0, "stderr: {}", run.stderr);
assert_eq!(fixture.read("docs/guide.md"), CLEAN);
assert_eq!(
fixture.read("src/lib.rs"),
RAGGED,
"a file outside the extension set must be left alone"
);
}
#[rstest]
#[case(&["--include-untracked"], "--include-untracked")]
#[case(&["--allow-conflicted"], "--allow-conflicted")]
#[case(&["--list-files", "notes.md"], "--list-files")]
#[case(&["--md-exts", "md"], "--md-exts")]
fn a_git_only_flag_without_git_is_rejected(#[case] args: &[&str], #[case] flag: &str) {
let fixture = Fixture::new();
let run = fixture.run(args);
assert_eq!(run.status, 2, "stderr: {}", run.stderr);
assert!(run.stdout.is_empty(), "stdout: {:?}", run.stdout);
assert!(
run.stderr.contains(&format!("{flag} requires --git")),
"the rejection must name the flag: {}",
run.stderr
);
}
#[test]
fn the_default_extension_set_is_md_mdc_and_markdown() {
let fixture = Fixture::new();
fixture.track("guide.md", RAGGED);
fixture.track("rules.mdc", RAGGED);
fixture.track("notes.markdown", RAGGED);
fixture.track("prose.txt", RAGGED);
let run = fixture.run(&["--git", "--list-files"]);
assert_eq!(run.status, 0, "stderr: {}", run.stderr);
assert_eq!(
run.stdout, "guide.md\nnotes.markdown\nrules.mdc\n",
"the default set is md, mdc, and markdown; `prose.txt` is tracked and must not be listed"
);
}
#[test]
fn listing_a_file_does_not_read_its_content() {
let fixture = Fixture::new();
fixture.track("docs/guide.md", b"\xff\xfe not UTF-8 \xff");
let run = fixture.run(&["--git", "--list-files"]);
assert_eq!(
run.status, 0,
"listing must not read the file: {}",
run.stderr
);
assert_eq!(run.stdout, "docs/guide.md\n");
}
#[cfg(unix)]
#[test]
fn a_git_failure_is_reported_as_one_deliberate_line() {
let elsewhere = tempfile::tempdir().expect("create temporary directory");
let run = run_in(elsewhere.path(), &["--git"]);
assert_eq!(run.status, 2, "stderr: {}", run.stderr);
assert!(run.stdout.is_empty(), "stdout: {:?}", run.stdout);
assert!(
run.stderr
.starts_with("mdtablefix: `git ls-files` failed with exit status: "),
"the diagnostic must open with this tool's own wording: {}",
run.stderr
);
assert_eq!(
run.stderr.lines().count(),
1,
"git's diagnostic must be relayed as one line: {}",
run.stderr
);
}
#[test]
fn help_documents_the_git_flags() {
let output = Command::cargo_bin("mdtablefix")
.expect("cargo binary")
.arg("--help")
.output()
.expect("run mdtablefix --help");
assert!(output.status.success(), "--help must succeed");
let help = String::from_utf8(output.stdout).expect("the help text is UTF-8");
let help = help.replace("mdtablefix.exe", "mdtablefix");
for flag in [
"--git",
"--include-untracked",
"--md-exts",
"--list-files",
"--allow-conflicted",
] {
assert!(help.contains(flag), "{flag} must appear in --help");
}
insta::with_settings!({
snapshot_path => "snapshots",
prepend_module_to_snapshot => false,
}, {
insta::assert_snapshot!("cli_git_help", help);
});
}