use std::fs;
use std::process::{Command, Output};
use tempfile::TempDir;
fn check(dir: &std::path::Path, args: &[&str]) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_rumdl"));
command
.current_dir(dir)
.arg("check")
.arg("--no-config")
.arg("--no-cache");
command.args(args);
command.output().expect("failed to execute rumdl")
}
fn check_configured(dir: &std::path::Path, args: &[&str]) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_rumdl"));
command.current_dir(dir).arg("check").arg("--no-cache");
command.args(args);
command.output().expect("failed to execute rumdl")
}
fn stdout_of(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr_of(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
fn tree(extra: &[(&str, &str)]) -> TempDir {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
for (path, contents) in extra {
let full_path = temp_dir.path().join(path);
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(full_path, contents).unwrap();
}
temp_dir
}
#[test]
fn ignore_file_swallowing_every_file_is_reported() {
let temp_dir = tree(&[(".gitignore", "*.md\n")]);
let output = check(temp_dir.path(), &["."]);
let stderr = stderr_of(&output);
assert!(
stderr.contains("No markdown files left to check: 1 file found was filtered out."),
"stderr: {stderr}"
);
assert!(
stderr.contains("1 by ignore files"),
"should attribute the emptiness to ignore files. stderr: {stderr}"
);
assert!(
stderr.contains("--respect-gitignore=false"),
"should name the flag that undoes it. stderr: {stderr}"
);
}
#[test]
fn include_pattern_matching_nothing_is_named() {
let temp_dir = tree(&[]);
let output = check(temp_dir.path(), &[".", "--include", "dcos/**/*.md"]);
let stderr = stderr_of(&output);
assert!(
stderr.contains("1 by include patterns"),
"should attribute the emptiness to the include patterns. stderr: {stderr}"
);
assert!(
stderr.contains("include pattern 'dcos/**/*.md' matches no file"),
"should name the pattern that selected nothing. stderr: {stderr}"
);
}
#[test]
fn include_pattern_that_matches_is_not_reported_as_unmatched() {
let temp_dir = tree(&[("docs/api.md", "# API\n")]);
let output = check(
temp_dir.path(),
&[".", "--include", "docs/**/*.md,guide.md", "--exclude", "**/*.md"],
);
let stderr = stderr_of(&output);
assert!(stderr.contains("2 by exclude patterns"), "stderr: {stderr}");
assert!(
!stderr.contains("matches no file"),
"no include pattern is unmatched here. stderr: {stderr}"
);
}
#[test]
fn a_non_markdown_include_target_counts_as_a_filtered_file() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("notes.txt"), "not markdown\n").unwrap();
let checked = check(temp_dir.path(), &[".", "--include", "*.txt"]);
assert!(
stdout_of(&checked).contains("notes.txt"),
"the include should reach the file. stdout: {}",
stdout_of(&checked)
);
let output = check(
temp_dir.path(),
&[
".",
"--include",
"*.txt",
"--exclude",
"*.txt",
"--deny-config-warnings",
],
);
let stderr = stderr_of(&output);
assert!(
stderr.contains("No markdown files left to check: 1 file found was filtered out."),
"stderr: {stderr}"
);
assert!(stderr.contains("1 by exclude patterns"), "stderr: {stderr}");
assert_eq!(
output.status.code(),
Some(2),
"an all-filtered run is a configuration problem whatever the extension. stderr: {stderr}"
);
}
#[test]
fn a_named_file_excluded_beside_a_directory_still_counts() {
let temp_dir = tree(&[]);
fs::create_dir(temp_dir.path().join("empty")).unwrap();
let mixed = check(
temp_dir.path(),
&["--exclude", "guide.md", "guide.md", "empty", "--deny-config-warnings"],
);
let stderr = stderr_of(&mixed);
assert!(
stderr.contains("No markdown files left to check: 1 file found was filtered out."),
"stderr: {stderr}"
);
assert_eq!(mixed.status.code(), Some(2), "stderr: {stderr}");
let overlapping = check(
temp_dir.path(),
&["--exclude", "guide.md", "guide.md", ".", "--deny-config-warnings"],
);
let stderr = stderr_of(&overlapping);
assert!(
stderr.contains("1 file found was filtered out."),
"a file counted twice would read as 2. stderr: {stderr}"
);
}
#[test]
fn a_filtered_file_under_a_vendor_directory_still_counts() {
let temp_dir = TempDir::new().unwrap();
for vendor_dir in ["node_modules/pkg", "target/debug", ".git/hooks"] {
let path = temp_dir.path().join(vendor_dir);
fs::create_dir_all(&path).unwrap();
fs::write(path.join("readme.md"), "# Readme\n").unwrap();
}
let checked = check(temp_dir.path(), &["."]);
assert!(
stdout_of(&checked).contains("3 files"),
"the walk should reach every vendor directory. stdout: {}",
stdout_of(&checked)
);
let output = check(
temp_dir.path(),
&[".", "--exclude", "**/*.md", "--deny-config-warnings"],
);
let stderr = stderr_of(&output);
assert!(
stderr.contains("No markdown files left to check: 3 files found were filtered out."),
"stderr: {stderr}"
);
assert_eq!(output.status.code(), Some(2), "stderr: {stderr}");
}
#[test]
fn a_config_include_pinning_no_extension_does_not_invent_a_filtered_file() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir(temp_dir.path().join("docs")).unwrap();
fs::write(temp_dir.path().join("docs/notes.txt"), "plain text\n").unwrap();
fs::write(
temp_dir.path().join(".rumdl.toml"),
"[global]\ninclude = [\"docs/**\"]\n",
)
.unwrap();
let output = check_configured(temp_dir.path(), &[".", "--deny-config-warnings"]);
let stderr = stderr_of(&output);
assert_eq!(
stderr.trim(),
"No markdown files found to check.",
"the file was never lintable, so nothing was filtered out"
);
assert_eq!(output.status.code(), Some(0), "stderr: {stderr}");
fs::write(
temp_dir.path().join(".rumdl.toml"),
"[global]\ninclude = [\"docs/*.txt\"]\n",
)
.unwrap();
let checked = check_configured(temp_dir.path(), &["."]);
assert!(
stdout_of(&checked).contains("notes.txt"),
"the pinned pattern should reach the file. stdout: {}",
stdout_of(&checked)
);
let excluded = check_configured(
temp_dir.path(),
&[".", "--exclude", "docs/*.txt", "--deny-config-warnings"],
);
let stderr = stderr_of(&excluded);
assert!(
stderr.contains("No markdown files left to check: 1 file found was filtered out."),
"stderr: {stderr}"
);
assert_eq!(excluded.status.code(), Some(2), "stderr: {stderr}");
}
#[test]
fn a_directory_without_markdown_reports_plain_absence() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("notes.txt"), "not markdown\n").unwrap();
let output = check(temp_dir.path(), &["."]);
let stderr = stderr_of(&output);
assert_eq!(stderr.trim(), "No markdown files found to check.");
}
#[test]
fn the_notice_goes_to_stderr_and_never_to_stdout() {
let temp_dir = tree(&[(".gitignore", "*.md\n")]);
let output = check(temp_dir.path(), &["."]);
assert!(
!stdout_of(&output).contains("No markdown files"),
"stdout carries machine-readable output. stdout: {}",
stdout_of(&output)
);
assert!(stderr_of(&output).contains("No markdown files"));
}
#[test]
fn quiet_keeps_the_notice_and_silent_suppresses_it() {
let temp_dir = tree(&[(".gitignore", "*.md\n")]);
let quiet = check(temp_dir.path(), &[".", "-q"]);
assert!(
stderr_of(&quiet).contains("No markdown files left to check"),
"stderr: {}",
stderr_of(&quiet)
);
for flag in ["-s", "--silent"] {
let silent = check(temp_dir.path(), &[".", flag]);
assert_eq!(stderr_of(&silent), "", "{flag} should suppress the notice");
assert_eq!(stdout_of(&silent), "", "{flag} should suppress all output");
}
}
#[test]
fn an_empty_run_still_exits_zero_by_default() {
for extra in [vec![(".gitignore", "*.md\n")], vec![]] {
let temp_dir = tree(&extra);
let output = check(temp_dir.path(), &["."]);
assert_eq!(
output.status.code(),
Some(0),
"checking nothing is not a violation. stderr: {}",
stderr_of(&output)
);
}
}
#[test]
fn deny_config_warnings_separates_misconfiguration_from_absence() {
let filtered = tree(&[(".gitignore", "*.md\n")]);
let output = check(filtered.path(), &[".", "--deny-config-warnings"]);
assert_eq!(
output.status.code(),
Some(2),
"an all-filtered run is a configuration problem. stderr: {}",
stderr_of(&output)
);
let empty = TempDir::new().unwrap();
fs::write(empty.path().join("notes.txt"), "not markdown\n").unwrap();
let output = check(empty.path(), &[".", "--deny-config-warnings"]);
assert_eq!(
output.status.code(),
Some(0),
"an absence of markdown is not a configuration problem. stderr: {}",
stderr_of(&output)
);
}
#[test]
fn machine_readable_output_stays_a_valid_empty_document() {
let temp_dir = tree(&[(".gitignore", "*.md\n")]);
for format in ["json", "gitlab"] {
let output = check(temp_dir.path(), &[".", "--output-format", format]);
let stdout = stdout_of(&output);
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("{format} output is not valid JSON ({e}): {stdout:?}"));
assert_eq!(
parsed.as_array().map(Vec::len),
Some(0),
"{format} output should be an empty array: {stdout:?}"
);
}
let sarif = stdout_of(&check(temp_dir.path(), &[".", "--output-format", "sarif"]));
let parsed: serde_json::Value =
serde_json::from_str(&sarif).unwrap_or_else(|e| panic!("sarif output is not valid JSON ({e}): {sarif:?}"));
assert!(parsed.get("runs").is_some(), "sarif output should be a full document");
}
#[test]
fn an_include_pattern_takes_the_blame_over_the_ignore_file_it_outranks() {
let temp_dir = tree(&[(".gitignore", "*.md\n")]);
let reached = check(temp_dir.path(), &[".", "--include", "guide.md"]);
assert!(
stdout_of(&reached).contains("1 file"),
"an include should outrank the ignore file. stdout: {}",
stdout_of(&reached)
);
let output = check(temp_dir.path(), &[".", "--include", "dcos/**/*.md"]);
let stderr = stderr_of(&output);
assert!(
stderr.contains("1 by include patterns"),
"the typo'd include is what keeps the run empty. stderr: {stderr}"
);
assert!(
!stderr.contains("ignore files"),
"--respect-gitignore=false would leave the run just as empty. stderr: {stderr}"
);
let output = check(temp_dir.path(), &["."]);
let stderr = stderr_of(&output);
assert!(stderr.contains("1 by ignore files"), "stderr: {stderr}");
let kept = check(temp_dir.path(), &[".", "--respect-gitignore=false"]);
assert!(
stdout_of(&kept).contains("1 file"),
"the named remedy should check the file. stdout: {}",
stdout_of(&kept)
);
}
#[test]
fn the_notice_yields_the_stream_the_output_was_routed_to() {
let temp_dir = tree(&[(".gitignore", "*.md\n")]);
for format in ["json", "gitlab"] {
let output = check(temp_dir.path(), &[".", "--output-format", format, "--stderr"]);
let stderr = stderr_of(&output);
let parsed: serde_json::Value = serde_json::from_str(&stderr)
.unwrap_or_else(|e| panic!("{format} output on stderr is not valid JSON ({e}): {stderr:?}"));
assert_eq!(parsed.as_array().map(Vec::len), Some(0), "{format}: {stderr:?}");
assert!(
stdout_of(&output).contains("No markdown files left to check"),
"the notice should move to the free stream. stdout: {}",
stdout_of(&output)
);
}
let output = check(temp_dir.path(), &[".", "--output-format", "json"]);
assert!(
stdout_of(&output).trim().starts_with('['),
"stdout: {}",
stdout_of(&output)
);
assert!(
stderr_of(&output).contains("No markdown files left to check"),
"stderr: {}",
stderr_of(&output)
);
let quiet = check(
temp_dir.path(),
&[".", "--output-format", "json", "--stderr", "--silent"],
);
assert_eq!(stdout_of(&quiet), "", "stderr: {}", stderr_of(&quiet));
assert_eq!(stderr_of(&quiet), "");
}
#[test]
fn overlapping_roots_count_a_file_once() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir(temp_dir.path().join("docs")).unwrap();
fs::write(temp_dir.path().join("docs/a.md"), "# A\n").unwrap();
let checked = check(temp_dir.path(), &[".", "docs"]);
assert!(
stdout_of(&checked).contains("1 file"),
"the walk should count it once. stdout: {}",
stdout_of(&checked)
);
let overlapping = check(temp_dir.path(), &["--exclude", "*.md", ".", "docs"]);
let stderr = stderr_of(&overlapping);
assert!(
stderr.contains("No markdown files left to check: 1 file found was filtered out."),
"stderr: {stderr}"
);
fs::create_dir(temp_dir.path().join("spec")).unwrap();
fs::write(temp_dir.path().join("spec/b.md"), "# B\n").unwrap();
let disjoint = check(temp_dir.path(), &["--exclude", "*.md", "docs", "spec"]);
let stderr = stderr_of(&disjoint);
assert!(
stderr.contains("No markdown files left to check: 2 files found were filtered out."),
"stderr: {stderr}"
);
fs::write(temp_dir.path().join(".gitignore"), "*.md\n").unwrap();
let ignored = check(temp_dir.path(), &[".", "docs"]);
let stderr = stderr_of(&ignored);
assert!(
stderr.contains("No markdown files left to check: 2 files found were filtered out."),
"docs/a.md is reached by both roots and spec/b.md by one. stderr: {stderr}"
);
assert!(stderr.contains("2 by ignore files"), "stderr: {stderr}");
}
#[test]
fn an_include_reaching_past_an_ignore_file_hands_the_blame_to_the_exclude() {
let temp_dir = tree(&[(".gitignore", "*.md\n")]);
let output = check(
temp_dir.path(),
&[".", "--include", "guide.md", "--exclude", "guide.md"],
);
let stderr = stderr_of(&output);
assert!(
stderr.contains("1 by exclude patterns"),
"the exclude is what the walk applied last. stderr: {stderr}"
);
assert!(
!stderr.contains("by ignore files"),
"the include overrode the ignore file. stderr: {stderr}"
);
let remedied = check(temp_dir.path(), &[".", "--include", "guide.md", "--no-exclude"]);
assert!(
stdout_of(&remedied).contains("No issues found in 1 file"),
"stdout: {}",
stdout_of(&remedied)
);
let no_include = check(temp_dir.path(), &[".", "--exclude", "guide.md"]);
assert!(
stderr_of(&no_include).contains("1 by ignore files"),
"stderr: {}",
stderr_of(&no_include)
);
}
#[test]
fn an_include_cannot_reach_into_a_directory_the_ignore_file_pruned() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir(temp_dir.path().join("docs")).unwrap();
fs::write(temp_dir.path().join("docs/guide.md"), "# Guide\n").unwrap();
fs::write(temp_dir.path().join(".gitignore"), "docs/\n").unwrap();
let output = check(temp_dir.path(), &[".", "--include", "docs/**/*.md"]);
let stderr = stderr_of(&output);
assert!(
stderr.contains("1 by ignore files"),
"the pruned directory is the ignore file's doing. stderr: {stderr}"
);
assert!(
!stderr.contains("by exclude patterns"),
"no exclude pattern was involved. stderr: {stderr}"
);
let remedied = check(
temp_dir.path(),
&[".", "--include", "docs/**/*.md", "--respect-gitignore=false"],
);
assert!(
stdout_of(&remedied).contains("in 1 file"),
"stdout: {}",
stdout_of(&remedied)
);
let file_pattern = TempDir::new().unwrap();
fs::create_dir(file_pattern.path().join("docs")).unwrap();
fs::write(file_pattern.path().join("docs/guide.md"), "# Guide\n").unwrap();
fs::write(file_pattern.path().join(".gitignore"), "*.md\n").unwrap();
let rescued = check(file_pattern.path(), &[".", "--include", "docs/**/*.md"]);
assert!(
stdout_of(&rescued).contains("in 1 file"),
"stdout: {}",
stdout_of(&rescued)
);
}
#[test]
fn a_capitalized_extension_is_checked_rather_than_declared_filtered() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("README.MD"), "# Shouting\n").unwrap();
let output = check(temp_dir.path(), &[".", "--deny-config-warnings"]);
let stderr = stderr_of(&output);
assert!(
!stderr.contains("filtered out"),
"nothing filtered this file. stderr: {stderr}"
);
assert_eq!(output.status.code(), Some(1), "stderr: {stderr}");
assert!(
stdout_of(&output).contains("README.MD"),
"the file should be linted. stdout: {}",
stdout_of(&output)
);
let named = check(temp_dir.path(), &["README.MD"]);
assert_eq!(named.status.code(), Some(1), "stderr: {}", stderr_of(&named));
let other = TempDir::new().unwrap();
fs::write(other.path().join("README.MDX.txt"), "# Shouting\n").unwrap();
assert!(
stderr_of(&check(other.path(), &["."])).contains("No markdown files found to check."),
"stderr: {}",
stderr_of(&check(other.path(), &["."]))
);
}
#[test]
fn a_hidden_file_is_not_blamed_on_a_filter_that_never_saw_it() {
let temp_dir = tree(&[(".gitignore", "*.md\n")]);
let output = check(temp_dir.path(), &[".", "--exclude", "*.md"]);
let stderr = stderr_of(&output);
assert!(
stderr.contains("1 by ignore files"),
"the ignore file is what the walk actually applied. stderr: {stderr}"
);
assert!(
!stderr.contains("by exclude patterns"),
"the exclude never saw the file. stderr: {stderr}"
);
let visible = check(
temp_dir.path(),
&[".", "--exclude", "*.md", "--respect-gitignore=false"],
);
assert!(
stderr_of(&visible).contains("1 by exclude patterns"),
"stderr: {}",
stderr_of(&visible)
);
let missed = check(temp_dir.path(), &[".", "--include", "dcos/**/*.md"]);
assert!(
stderr_of(&missed).contains("1 by include patterns"),
"stderr: {}",
stderr_of(&missed)
);
}
#[test]
fn naming_the_excluded_file_does_not_change_the_diagnosis() {
let temp_dir = tree(&[("ignored.md", "# Ignored\n"), (".gitignore", "ignored.md\n")]);
let named = check(temp_dir.path(), &["--exclude", "guide.md", "guide.md", "."]);
let walked = check(temp_dir.path(), &["--exclude", "guide.md", "."]);
assert_eq!(
stderr_of(&named),
stderr_of(&walked),
"naming the excluded file should not change what the run reports"
);
assert!(
stderr_of(&named).contains("1 by exclude patterns"),
"stderr: {}",
stderr_of(&named)
);
let undone = check(
temp_dir.path(),
&["--exclude", "guide.md", "guide.md", ".", "--no-exclude"],
);
assert!(
stdout_of(&undone).contains("1 file"),
"--no-exclude should end the emptiness. stdout: {}",
stdout_of(&undone)
);
}
#[test]
fn naming_one_excluded_file_repeatedly_still_counts_one_file() {
let temp_dir = tree(&[]);
let output = check(
temp_dir.path(),
&["--exclude", "guide.md", "guide.md", "guide.md", "./guide.md"],
);
let stderr = stderr_of(&output);
assert!(
stderr.contains("No markdown files left to check: 1 file found was filtered out."),
"stderr: {stderr}"
);
fs::write(temp_dir.path().join("notes.md"), "# Notes\n").unwrap();
let output = check(temp_dir.path(), &["--exclude", "*.md", "guide.md", "notes.md"]);
let stderr = stderr_of(&output);
assert!(
stderr.contains("No markdown files left to check: 2 files found were filtered out."),
"stderr: {stderr}"
);
}