use std::fs;
use std::path::Path;
use std::process::Command;
fn run(dir: &Path, args: &[&str]) -> String {
run_with_status(dir, args).1
}
fn run_with_status(dir: &Path, args: &[&str]) -> (bool, String) {
let exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(exe)
.current_dir(dir)
.env("RUMDL_CACHE_DIR", dir.join(".rumdl_cache"))
.args(args)
.output()
.expect("failed to execute rumdl");
let mut combined = String::from_utf8_lossy(&output.stdout).to_string();
combined.push_str(&String::from_utf8_lossy(&output.stderr));
(output.status.success(), combined)
}
fn delete_workspace_index(dir: &Path) {
let path = dir.join(".rumdl_cache").join("workspace_index.bin");
assert!(
path.exists(),
"expected workspace index cache at {} after first run",
path.display()
);
fs::remove_file(&path).expect("failed to remove workspace_index.bin");
}
fn write_target(dir: &Path) {
fs::write(dir.join("b.md"), "# Target\n\n## Real Heading\n").unwrap();
}
#[test]
fn md057_cache_misses_when_a_missing_target_appears() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
fs::write(dir.join("a.md"), "# Source\n\n[link](b.md)\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(!first_success, "missing target must fail on the first run:\n{first}");
assert!(first.contains("Relative link 'b.md' does not exist"), "got:\n{first}");
fs::write(dir.join("b.md"), "# Target\n").unwrap();
let (second_success, second) = run_with_status(dir, &["check", "a.md", "b.md", "--enable", "MD057"]);
assert!(
second_success,
"creating the target must invalidate the cached MD057 warning:\n{second}"
);
assert!(!second.contains("MD057"), "got:\n{second}");
}
#[test]
fn md057_cache_misses_when_an_existing_target_disappears() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
fs::write(dir.join("a.md"), "# Source\n\n[link](b.md)\n").unwrap();
fs::write(dir.join("b.md"), "# Target\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "b.md", "--enable", "MD057"]);
assert!(first_success, "existing target must pass on the first run:\n{first}");
fs::remove_file(dir.join("b.md")).unwrap();
let (second_success, second) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(!second_success, "removing the target must invalidate the cached pass");
assert!(second.contains("Relative link 'b.md' does not exist"), "got:\n{second}");
}
#[test]
fn md057_file_without_relative_links_keeps_hitting_the_cache() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
fs::write(dir.join("a.md"), "# Source\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(first_success, "first run must populate a clean cache entry:\n{first}");
let (second_success, second) = run_with_status(dir, &["check", "a.md", "--enable", "MD057", "--verbose"]);
assert!(second_success, "second run must remain clean:\n{second}");
assert!(
second.contains("Cache hit for"),
"expected a measured cache hit:\n{second}"
);
assert!(!second.contains("Cache miss for"), "unexpected cache miss:\n{second}");
}
#[test]
fn md057_cache_misses_when_workspace_link_data_is_unavailable() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
fs::write(dir.join("a.md"), "# Source\n\n[link](b.md)\n").unwrap();
fs::write(dir.join("b.md"), "# Target\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "b.md", "--enable", "MD057"]);
assert!(first_success, "first run must populate both caches:\n{first}");
delete_workspace_index(dir);
let (second_success, second) = run_with_status(dir, &["check", "a.md", "b.md", "--enable", "MD057", "--verbose"]);
assert!(
second_success,
"fallback lint must preserve the clean verdict:\n{second}"
);
assert!(
second.contains("cross-file dependency state is unavailable"),
"missing workspace data must force a cache miss:\n{second}"
);
}
#[test]
fn cache_hit_does_not_require_workspace_data_when_md057_is_disabled() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
fs::write(dir.join("a.md"), "# Source\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "--enable", "MD013"]);
assert!(first_success, "first run must populate a clean cache entry:\n{first}");
assert!(!dir.join(".rumdl_cache/workspace_index.bin").exists());
let (second_success, second) = run_with_status(dir, &["check", "a.md", "--enable", "MD013", "--verbose"]);
assert!(second_success, "second run must remain clean:\n{second}");
assert!(
second.contains("Cache hit for"),
"MD057-disabled run must hit:\n{second}"
);
}
#[test]
fn md057_cache_misses_when_target_changes_from_file_to_directory() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
fs::write(dir.join("a.md"), "# Source\n\n[link](b.md)\n").unwrap();
fs::write(dir.join("b.md"), "# Target\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "b.md", "--enable", "MD057"]);
assert!(first_success, "file target must pass:\n{first}");
fs::remove_file(dir.join("b.md")).unwrap();
fs::create_dir(dir.join("b.md")).unwrap();
let (second_success, second) = run_with_status(dir, &["check", "a.md", "--enable", "MD057", "--verbose"]);
assert!(second_success, "directory target is also valid:\n{second}");
assert!(
second.contains("cross-file dependency state changed"),
"target kind change must invalidate the cached identity:\n{second}"
);
}
#[test]
fn md057_cache_misses_after_a_case_only_rename() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
fs::write(dir.join("a.md"), "# Source\n\n[link](B.md)\n").unwrap();
fs::write(dir.join("b.md"), "# Target\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "b.md", "--enable", "MD057"]);
assert!(
!first_success,
"a link naming a spelling the filesystem does not store must fail:\n{first}"
);
assert!(first.contains("Relative link 'B.md' does not exist"), "got:\n{first}");
fs::rename(dir.join("b.md"), dir.join("B.md")).unwrap();
let (second_success, second) = run_with_status(dir, &["check", "a.md", "B.md", "--enable", "MD057", "--verbose"]);
assert!(
second_success,
"renaming the target to the spelling the link uses must invalidate the cached warning:\n{second}"
);
assert!(!second.contains("[MD057]"), "got:\n{second}");
assert!(
second.contains("cross-file dependency state changed"),
"a case-only rename must invalidate the cached identity:\n{second}"
);
}
#[test]
fn md057_cache_tracks_configured_search_paths() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "[MD057]\nsearch-paths = [\"assets\"]\n").unwrap();
fs::write(dir.join("a.md"), "# Source\n\n\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(!first_success, "missing search-path target must fail:\n{first}");
fs::create_dir(dir.join("assets")).unwrap();
fs::write(dir.join("assets/photo.png"), "image").unwrap();
let (second_success, second) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(
second_success,
"search-path target must invalidate the warning:\n{second}"
);
}
#[test]
fn md057_cache_tracks_obsidian_attachment_folder() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
fs::create_dir(dir.join(".obsidian")).unwrap();
fs::write(
dir.join(".obsidian/app.json"),
r#"{"attachmentFolderPath":"Attachments"}"#,
)
.unwrap();
fs::write(dir.join("a.md"), "# Source\n\n\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "--enable", "MD057", "--flavor", "obsidian"]);
assert!(!first_success, "missing attachment must fail:\n{first}");
fs::create_dir(dir.join("Attachments")).unwrap();
fs::write(dir.join("Attachments/photo.png"), "image").unwrap();
let (second_success, second) =
run_with_status(dir, &["check", "a.md", "--enable", "MD057", "--flavor", "obsidian"]);
assert!(
second_success,
"attachment target must invalidate the warning:\n{second}"
);
}
#[test]
fn md057_cache_tracks_absolute_link_roots() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(
dir.join(".rumdl.toml"),
"[MD057]\nabsolute-links = \"relative_to_roots\"\nroots = [\"content\"]\n",
)
.unwrap();
fs::write(dir.join("a.md"), "# Source\n\n[guide](/guide.md)\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(!first_success, "missing absolute target must fail:\n{first}");
fs::create_dir(dir.join("content")).unwrap();
fs::write(dir.join("content/guide.md"), "# Guide\n").unwrap();
let (second_success, second) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(second_success, "root target must invalidate the warning:\n{second}");
}
#[test]
fn md057_cache_tracks_mkdocs_docs_dir() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(
dir.join(".rumdl.toml"),
"[MD057]\nabsolute-links = \"relative_to_docs\"\n",
)
.unwrap();
fs::write(dir.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
fs::create_dir(dir.join("docs")).unwrap();
fs::write(dir.join("a.md"), "# Source\n\n[guide](/guide.md)\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(!first_success, "missing docs-dir target must fail:\n{first}");
fs::write(dir.join("docs/guide.md"), "# Guide\n").unwrap();
let (second_success, second) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(second_success, "docs-dir target must invalidate the warning:\n{second}");
}
#[test]
fn md057_cache_tracks_reference_definition_targets() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
fs::write(
dir.join("a.md"),
"# Source\n\n[download][asset]\n\n[asset]: artifact.zip\n",
)
.unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(!first_success, "missing reference target must fail:\n{first}");
fs::write(dir.join("artifact.zip"), "archive").unwrap();
let (second_success, second) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(
second_success,
"reference target must invalidate the warning:\n{second}"
);
}
#[test]
fn md057_cache_tracks_checked_frontmatter_targets() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "[MD057]\ncheck-frontmatter = true\n").unwrap();
fs::write(dir.join("a.md"), "---\nmanual: docs/guide.pdf\n---\n\n# Source\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(!first_success, "missing frontmatter target must fail:\n{first}");
fs::create_dir(dir.join("docs")).unwrap();
fs::write(dir.join("docs/guide.pdf"), "guide").unwrap();
let (second_success, second) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(
second_success,
"frontmatter target must invalidate the warning:\n{second}"
);
}
#[test]
fn md057_fmt_does_not_replay_a_stale_warning() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
fs::write(dir.join("a.md"), "# Source\n\n[link](b.md)\n").unwrap();
let (first_success, first) = run_with_status(dir, &["check", "a.md", "--enable", "MD057"]);
assert!(
!first_success,
"missing target must warm a failing cache entry:\n{first}"
);
fs::write(dir.join("b.md"), "# Target\n").unwrap();
let (_, formatted) = run_with_status(dir, &["fmt", "a.md", "b.md", "--enable", "MD057"]);
assert!(
!formatted.contains("MD057"),
"fmt must not replay the stale warning:\n{formatted}"
);
}
#[test]
fn cache_hit_respects_inline_disable_for_cross_file_rule() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
write_target(dir);
fs::write(
dir.join("a.md"),
"# Source\n\n\
<!-- rumdl-disable MD051 -->\n\
[link](b.md#nonexistent)\n\
<!-- rumdl-enable MD051 -->\n",
)
.unwrap();
let first = run(dir, &["check", "."]);
assert!(
!first.contains("MD051"),
"baseline: inline disable should suppress MD051, got:\n{first}"
);
delete_workspace_index(dir);
let second = run(dir, &["check", "."]);
assert!(
!second.contains("MD051"),
"MD051 must stay suppressed on a cache hit (inline disable), got:\n{second}"
);
}
#[test]
fn md051_checks_reference_definition_fragments_without_cache() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
write_target(dir);
fs::write(
dir.join("a.md"),
"# Source\n\n## Local\n\nSee [the target][t].\n\n\
[t]: b.md#nonexistent\n[unused]: #locl\n[fine]: b.md#real-heading\n[here]: #local\n",
)
.unwrap();
let (success, out) = run_with_status(dir, &["check", ".", "--no-cache", "--isolated"]);
assert!(!success, "broken definition fragments must fail:\n{out}");
let mut md051: Vec<&str> = out.lines().filter(|line| line.contains("[MD051]")).collect();
md051.sort_unstable();
assert_eq!(
md051,
[
"a.md:7:6: [MD051] Link fragment 'nonexistent' not found in 'b.md'",
"a.md:8:11: [MD051] Link anchor '#locl' does not exist in document headings (did you mean '#local'?)",
],
"{out}"
);
}
#[test]
fn md051_reference_definition_fragment_survives_the_cache() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
write_target(dir);
fs::write(dir.join("a.md"), "# Source\n\nSee [later][t].\n\n[t]: b.md#later\n").unwrap();
let first = run(dir, &["check", "."]);
assert!(first.contains("Link fragment 'later' not found in 'b.md'"), "{first}");
delete_workspace_index(dir);
let second = run(dir, &["check", "."]);
assert!(
second.contains("Link fragment 'later' not found in 'b.md'"),
"a lint-cache hit must rebuild the definition's index entry:\n{second}"
);
fs::write(dir.join("b.md"), "# Target\n\n## Later\n").unwrap();
let (third_success, third) = run_with_status(dir, &["check", "."]);
assert!(third_success, "the new heading must clear the warning:\n{third}");
}
#[test]
fn per_file_ignores_suppresses_cross_file_rule_without_cache() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "[per-file-ignores]\n\"a.md\" = [\"MD051\"]\n").unwrap();
write_target(dir);
fs::write(dir.join("a.md"), "# Source\n\n[link](b.md#nonexistent)\n").unwrap();
let out = run(dir, &["check", ".", "--no-cache"]);
assert!(
!out.contains("MD051"),
"per-file-ignores must suppress cross-file MD051 for a.md, got:\n{out}"
);
}
#[test]
fn per_file_ignores_suppresses_cross_file_rule_on_cache_hit() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "[per-file-ignores]\n\"a.md\" = [\"MD051\"]\n").unwrap();
write_target(dir);
fs::write(dir.join("a.md"), "# Source\n\n[link](b.md#nonexistent)\n").unwrap();
let _ = run(dir, &["check", "."]);
delete_workspace_index(dir);
let out = run(dir, &["check", "."]);
assert!(
!out.contains("MD051"),
"per-file-ignores must suppress cross-file MD051 on a cache hit, got:\n{out}"
);
}
#[cfg(unix)]
fn workspace_index_inode(dir: &Path) -> u64 {
use std::os::unix::fs::MetadataExt;
fs::metadata(dir.join(".rumdl_cache").join("workspace_index.bin"))
.expect("workspace index cache must exist")
.ino()
}
#[cfg(unix)]
#[test]
fn workspace_index_cache_is_kept_when_no_file_changed() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
write_target(dir);
fs::write(dir.join("a.md"), "# Source\n\n[link](b.md#nonexistent)\n").unwrap();
let first = run(dir, &["check", "."]);
assert!(first.contains("MD051"), "broken fragment must be reported:\n{first}");
let saved = workspace_index_inode(dir);
let second = run(dir, &["check", "."]);
assert_eq!(
second.matches("MD051").count(),
first.matches("MD051").count(),
"got:\n{second}"
);
assert_eq!(
workspace_index_inode(dir),
saved,
"an unchanged workspace must not rewrite the index cache"
);
}
#[cfg(unix)]
#[test]
fn workspace_index_cache_is_saved_when_files_change() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join(".rumdl.toml"), "").unwrap();
write_target(dir);
fs::write(dir.join("a.md"), "# Source\n\n[link](b.md#added-heading)\n").unwrap();
fs::write(dir.join("c.md"), "# Other\n").unwrap();
let first = run(dir, &["check", "."]);
assert!(first.contains("MD051"), "missing heading must be reported:\n{first}");
let saved = workspace_index_inode(dir);
fs::write(dir.join("b.md"), "# Target\n\n## Added Heading\n").unwrap();
let edited = run(dir, &["check", "."]);
assert!(!edited.contains("MD051"), "the added heading must resolve:\n{edited}");
let after_edit = workspace_index_inode(dir);
assert_ne!(after_edit, saved, "an edited file must be saved to the index cache");
let cached = run(dir, &["check", "."]);
assert!(
!cached.contains("MD051"),
"the saved index must hold the edit:\n{cached}"
);
fs::remove_file(dir.join("c.md")).unwrap();
let pruned = run(dir, &["check", "."]);
assert!(!pruned.contains("MD051"), "got:\n{pruned}");
assert_ne!(
workspace_index_inode(dir),
after_edit,
"a pruned file must be saved to the index cache"
);
}