use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use std::process::{Command, Output};
const DOCUMENT: &str = "# Title\n\na\n\n\n\nb\n";
const FILES: &[&str] = &[
"dir/a.md",
"dir/sub/b.md",
"other/dir/c.md",
"a/b/d.md",
"a/dir/e.md",
"top.md",
"other/top.md",
];
fn project(config: &str) -> tempfile::TempDir {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join(".rumdl.toml"), config).unwrap();
for file in FILES {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, DOCUMENT).unwrap();
}
temp
}
fn canonical(root: &Path) -> String {
root.canonicalize().unwrap().to_string_lossy().replace('\\', "/")
}
fn run(cwd: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_rumdl"))
.current_dir(cwd)
.args(["check", "--no-cache", "--output-format", "concise"])
.args(args)
.output()
.expect("failed to execute rumdl")
}
fn stdout_of(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).replace('\\', "/")
}
fn flagged(output: &Output) -> BTreeSet<String> {
let stdout = stdout_of(output);
FILES
.iter()
.filter(|file| {
stdout
.lines()
.any(|line| line.trim_start_matches("./").starts_with(&format!("{file}:")) && line.contains("MD012"))
})
.map(|file| file.to_string())
.collect()
}
fn all_but(excluded: &[&str]) -> BTreeSet<String> {
FILES
.iter()
.filter(|file| !excluded.contains(file))
.map(|file| file.to_string())
.collect()
}
fn only(files: &[&str]) -> BTreeSet<String> {
files.iter().map(|file| file.to_string()).collect()
}
fn assert_exclude(pattern: &str, expected: &BTreeSet<String>) {
let temp = project(&format!("[global]\nexclude = ['{pattern}']\n"));
let walk = run(temp.path(), &["."]);
assert_eq!(
&flagged(&walk),
expected,
"walk, exclude {pattern}\n{}",
stdout_of(&walk)
);
let named = run(temp.path(), FILES);
assert_eq!(
&flagged(&named),
expected,
"named files, exclude {pattern}\n{}",
stdout_of(&named)
);
assert_eq!(flagged(&run(temp.path(), &["."])), flagged(&walk), "idempotent");
let cli = project("");
let walk = run(cli.path(), &[".", "--exclude", pattern]);
assert_eq!(
&flagged(&walk),
expected,
"walk, --exclude {pattern}\n{}",
stdout_of(&walk)
);
}
const DIR: [&str; 2] = ["dir/a.md", "dir/sub/b.md"];
#[test]
fn plain_directory_exclude_is_the_control() {
assert_exclude("dir/**", &all_but(&DIR));
assert!(all_but(&DIR).contains("other/dir/c.md"));
}
#[test]
fn dot_components_in_a_relative_exclude_are_ignored() {
for pattern in ["./dir/**", "dir/./**", "./dir", "./dir/", "dir/.", "./dir/./sub/../**"] {
assert_exclude(pattern, &all_but(&DIR));
}
assert_exclude("dir/./sub/b.md", &all_but(&["dir/sub/b.md"]));
}
#[test]
fn dot_dot_components_in_a_relative_exclude_cancel_a_literal_component() {
for pattern in ["dir/../dir/**", "nonexist/../dir/**", "a/b/../../dir/**", "a/../dir"] {
assert_exclude(pattern, &all_but(&DIR));
}
assert_exclude("a/b/../b/d.md", &all_but(&["a/b/d.md"]));
}
#[test]
fn a_dotted_exclude_stays_anchored() {
assert_exclude("./top.md", &all_but(&["top.md"]));
assert_exclude("./dir", &all_but(&DIR));
assert_exclude("top.md", &all_but(&["top.md", "other/top.md"]));
assert_exclude(
"dir",
&all_but(&["dir/a.md", "dir/sub/b.md", "other/dir/c.md", "a/dir/e.md"]),
);
}
#[test]
fn unresolved_dot_dot_in_a_relative_exclude_stays_as_written() {
for pattern in ["../dir/**", "*/../dir/**"] {
assert_exclude(pattern, &all_but(&[]));
}
}
#[test]
fn dot_components_in_an_absolute_exclude_are_ignored() {
for suffix in [
"dir/**",
"./dir/**",
"dir/./**",
"dir/../dir/**",
"nonexist/../dir/**",
"a/b/../../dir/**",
] {
let temp = project("");
let pattern = format!("{}/{suffix}", canonical(temp.path()));
fs::write(
temp.path().join(".rumdl.toml"),
format!("[global]\nexclude = ['{pattern}']\n"),
)
.unwrap();
let walk = run(temp.path(), &["."]);
assert_eq!(
flagged(&walk),
all_but(&DIR),
"walk, exclude {pattern}\n{}",
stdout_of(&walk)
);
let named = run(temp.path(), FILES);
assert_eq!(
flagged(&named),
all_but(&DIR),
"named, exclude {pattern}\n{}",
stdout_of(&named)
);
}
}
#[test]
fn dot_components_in_an_include_are_ignored() {
for pattern in ["dir/**", "./dir/**", "dir/./**", "dir/../dir/**", "nonexist/../dir/**"] {
let temp = project(&format!("[global]\ninclude = ['{pattern}']\n"));
let walk = run(temp.path(), &["."]);
assert_eq!(flagged(&walk), only(&DIR), "include {pattern}\n{}", stdout_of(&walk));
assert_eq!(flagged(&run(temp.path(), &["."])), flagged(&walk));
let cli = project("");
let walk = run(cli.path(), &[".", "--include", pattern]);
assert_eq!(flagged(&walk), only(&DIR), "--include {pattern}\n{}", stdout_of(&walk));
}
}
#[test]
fn a_dotted_include_stays_anchored() {
let temp = project("[global]\ninclude = ['./top.md']\n");
let walk = run(temp.path(), &["."]);
assert_eq!(flagged(&walk), only(&["top.md"]), "{}", stdout_of(&walk));
let temp = project("[global]\ninclude = ['top.md']\n");
let walk = run(temp.path(), &["."]);
assert_eq!(
flagged(&walk),
only(&["top.md", "other/top.md"]),
"{}",
stdout_of(&walk)
);
}
#[test]
fn dot_components_in_an_absolute_include_are_ignored() {
for suffix in ["dir/**", "./dir/**", "nonexist/../dir/**", "a/../dir/**"] {
let temp = project("");
let pattern = format!("{}/{suffix}", canonical(temp.path()));
fs::write(
temp.path().join(".rumdl.toml"),
format!("[global]\ninclude = ['{pattern}']\n"),
)
.unwrap();
let walk = run(temp.path(), &["."]);
assert_eq!(flagged(&walk), only(&DIR), "include {pattern}\n{}", stdout_of(&walk));
}
}
#[test]
fn dot_components_in_a_per_file_ignores_key_are_ignored() {
for key in ["dir/a.md", "./dir/a.md", "dir/./a.md", "x/../dir/a.md"] {
let temp = project(&format!("[per-file-ignores]\n'{key}' = [\"MD012\"]\n"));
let output = run(temp.path(), &["."]);
assert_eq!(
flagged(&output),
all_but(&["dir/a.md"]),
"key {key}\n{}",
stdout_of(&output)
);
}
}
#[test]
fn per_file_ignores_without_a_dot_keeps_other_files() {
let temp = project("[per-file-ignores]\n'./a.md' = [\"MD012\"]\n");
let output = run(temp.path(), &["."]);
assert_eq!(flagged(&output), all_but(&[]), "{}", stdout_of(&output));
}
#[cfg(unix)]
#[test]
fn dot_dot_after_a_symlink_follows_the_filesystem() {
let temp = project("");
std::os::unix::fs::symlink(temp.path().join("a/b"), temp.path().join("link")).unwrap();
let root = canonical(temp.path());
let pattern = format!("{root}/link/../dir/**");
fs::write(
temp.path().join(".rumdl.toml"),
format!("[global]\nexclude = ['{pattern}']\n"),
)
.unwrap();
let walk = run(temp.path(), &["."]);
assert_eq!(flagged(&walk), all_but(&["a/dir/e.md"]), "{}", stdout_of(&walk));
let named = run(temp.path(), FILES);
assert_eq!(flagged(&named), all_but(&["a/dir/e.md"]), "{}", stdout_of(&named));
}
#[cfg(windows)]
#[test]
fn backslash_is_a_separator_in_an_exclude_on_windows() {
for pattern in [r"dir\**", r"dir\**\", r"dir\sub\..\**", r".\dir\**"] {
assert_exclude(pattern, &all_but(&DIR));
}
assert_exclude(r"a\b\*.md", &all_but(&["a/b/d.md"]));
assert_exclude(
r"dir\",
&all_but(&["dir/a.md", "dir/sub/b.md", "other/dir/c.md", "a/dir/e.md"]),
);
}
#[cfg(not(windows))]
#[test]
fn backslash_is_an_escape_in_an_exclude_off_windows() {
assert_exclude(r"dir\**", &all_but(&[]));
assert_exclude(r"a\b\*.md", &all_but(&[]));
}
#[test]
fn an_absolute_include_of_a_root_file_does_not_float() {
for suffix in ["top.md", "./top.md", "docs/../top.md", "nonexist/../top.md"] {
let temp = project("");
let pattern = format!("{}/{suffix}", canonical(temp.path()));
fs::write(
temp.path().join(".rumdl.toml"),
format!("[global]\ninclude = ['{pattern}']\n"),
)
.unwrap();
fs::create_dir_all(temp.path().join("docs")).unwrap();
let walk = run(temp.path(), &["."]);
assert_eq!(
flagged(&walk),
only(&["top.md"]),
"include {pattern}\n{}",
stdout_of(&walk)
);
let cli = project("");
fs::create_dir_all(cli.path().join("docs")).unwrap();
let pattern = format!("{}/{suffix}", canonical(cli.path()));
let walk = run(cli.path(), &[".", "--include", &pattern]);
assert_eq!(
flagged(&walk),
only(&["top.md"]),
"--include {pattern}\n{}",
stdout_of(&walk)
);
}
}
#[test]
fn an_absolute_include_with_a_wildcard_name_stays_at_the_root() {
let temp = project("");
let pattern = format!("{}/*.md", canonical(temp.path()));
fs::write(
temp.path().join(".rumdl.toml"),
format!("[global]\ninclude = ['{pattern}']\n"),
)
.unwrap();
let walk = run(temp.path(), &["."]);
assert_eq!(flagged(&walk), only(&["top.md"]), "{pattern}\n{}", stdout_of(&walk));
}
#[test]
fn relative_names_float_and_absolute_exclude_and_keys_do_not() {
for name in ["top.md", "*.md"] {
let temp = project(&format!("[global]\ninclude = ['{name}']\n"));
let walk = run(temp.path(), &["."]);
assert!(flagged(&walk).contains("other/top.md"), "include {name} floats");
}
let temp = project("");
let abs = format!("{}/top.md", canonical(temp.path()));
for config in [
format!("[global]\nexclude = ['{abs}']\n"),
format!("[per-file-ignores]\n'{abs}' = [\"MD012\"]\n"),
] {
fs::write(temp.path().join(".rumdl.toml"), &config).unwrap();
let walk = run(temp.path(), &["."]);
assert_eq!(flagged(&walk), all_but(&["top.md"]), "{config}\n{}", stdout_of(&walk));
}
}