use mdlint::formatter;
use std::fs;
use std::process::{Command, Stdio};
use tempfile::TempDir;
fn assert_formats_to(input: &str, expected: &str) {
let got = formatter::format(input);
assert_eq!(
got, expected,
"format(input) did not match expected.\nInput:\n{input}\nExpected:\n{expected}\nGot:\n{got}"
);
let twice = formatter::format(expected);
assert_eq!(
twice, expected,
"format(expected) != expected — expected output is not idempotent.\nExpected:\n{expected}\nTwice:\n{twice}"
);
}
fn mdlint_bin() -> std::path::PathBuf {
let mut p = std::env::current_exe().unwrap();
p.pop(); if p.ends_with("deps") {
p.pop();
}
p.push("mdlint");
p
}
#[test]
fn setext_headings_become_atx() {
assert_formats_to(
"Title\n=====\n\nSection\n-------\n",
"# Title\n\n## Section\n",
);
}
#[test]
fn closed_atx_headings_stripped() {
assert_formats_to("## Heading ##\n\n### Sub ###\n", "## Heading\n\n### Sub\n");
}
#[test]
fn extra_spaces_after_hash_collapsed() {
assert_formats_to("# Too many\n\n### Lots\n", "# Too many\n\n### Lots\n");
}
#[test]
fn asterisk_and_plus_list_markers_become_dash() {
assert_formats_to(
"* Alpha\n* Beta\n\n+ Gamma\n+ Delta\n",
"- Alpha\n- Beta\n\n<!---->\n\n- Gamma\n- Delta\n",
);
}
#[test]
fn tilde_code_fences_become_backtick() {
assert_formats_to(
"~~~rust\nfn main() {}\n~~~\n",
"```rust\nfn main() {}\n```\n",
);
assert_formats_to("~~~\nplain\n~~~\n", "```\nplain\n```\n");
}
#[test]
fn underscore_emphasis_becomes_asterisk() {
assert_formats_to("_italic_ and __bold__\n", "*italic* and **bold**\n");
}
#[test]
fn horizontal_rules_normalised_to_dashes() {
assert_formats_to("***\n", "---\n");
assert_formats_to("___\n", "---\n");
assert_formats_to("* * *\n", "---\n");
assert_formats_to("- - -\n", "---\n");
}
#[test]
fn multiple_blank_lines_collapsed() {
assert_formats_to("First.\n\n\n\nSecond.\n", "First.\n\nSecond.\n");
}
#[test]
fn trailing_whitespace_removed() {
let input = "Text with trailing spaces. \n\nMore text. \n";
let out = formatter::format(input);
for line in out.lines() {
assert_eq!(
line,
line.trim_end(),
"line has trailing whitespace: {line:?}"
);
}
}
#[test]
fn trailing_newline_normalised() {
assert!(formatter::format("text").ends_with('\n'));
assert!(formatter::format("text\n\n\n").ends_with('\n'));
assert_eq!(formatter::format("text\n\n\n").matches('\n').count(), 1);
}
#[test]
fn empty_input_produces_empty_output() {
assert_eq!(formatter::format(""), "");
assert_eq!(formatter::format(" \n\n "), "");
}
#[test]
fn nested_lists_preserved() {
assert_formats_to(
"- Top\n - Nested\n - Deep\n- Back\n",
"- Top\n - Nested\n - Deep\n- Back\n",
);
}
#[test]
fn ordered_list_preserved() {
assert_formats_to(
"1. First\n2. Second\n3. Third\n",
"1. First\n2. Second\n3. Third\n",
);
}
#[test]
fn code_block_content_preserved_verbatim() {
let input = "```\n\tindented with tab\n four spaces\n```\n";
assert_formats_to(input, input);
}
#[test]
fn inline_code_content_preserved() {
assert_formats_to(
"Use `_underscores_` and `* asterisks` in code spans.\n",
"Use `_underscores_` and `* asterisks` in code spans.\n",
);
}
#[test]
fn link_and_image_preserved() {
assert_formats_to(
"[link](https://example.com) and \n",
"[link](https://example.com) and \n",
);
}
#[test]
fn blockquote_preserved() {
assert_formats_to(
"> quoted\n>\n> second para\n",
"> quoted\n>\n> second para\n",
);
}
#[test]
fn gfm_table_canonicalised() {
assert_formats_to(
"A | B\n--- | ---\n1 | 2\n",
"| A | B |\n| --- | --- |\n| 1 | 2 |\n",
);
}
#[test]
fn gfm_table_already_canonical_unchanged() {
let canonical = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
assert_formats_to(canonical, canonical);
}
#[test]
fn list_item_continuation_indented() {
assert_formats_to(
"- First line\n continuation here\n",
"- First line\n continuation here\n",
);
}
#[test]
fn idempotent_on_mixed_document() {
let input = "# Title\n\nIntro paragraph.\n\n## Section\n\n\
- Item one\n- Item two\n - Nested\n\n\
```rust\nfn main() {}\n```\n\n\
| Col A | Col B |\n| ----- | ----- |\n| val | val |\n\n\
> A blockquote\n\n\
Final paragraph.\n";
let once = formatter::format(input);
let twice = formatter::format(&once);
assert_eq!(once, twice, "formatter is not idempotent on mixed document");
}
#[test]
fn format_check_does_not_modify_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("doc.md");
let original = "Heading\n=======\n\n* item\n";
fs::write(&file, original).unwrap();
Command::new(mdlint_bin())
.args(["format", "--check", file.to_str().unwrap()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
let after = fs::read_to_string(&file).unwrap();
assert_eq!(after, original, "format --check must not modify the file");
}
#[test]
fn format_check_exits_0_when_already_formatted() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("clean.md");
fs::write(&file, "# Heading\n\nParagraph.\n").unwrap();
let status = Command::new(mdlint_bin())
.args(["format", "--check", file.to_str().unwrap()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
assert!(
status.success(),
"expected exit 0 for already-formatted file"
);
}
#[test]
fn format_check_exits_1_when_file_needs_formatting() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("dirty.md");
fs::write(&file, "Heading\n=======\n\nParagraph.\n").unwrap();
let status = Command::new(mdlint_bin())
.args(["format", "--check", file.to_str().unwrap()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
assert_eq!(
status.code(),
Some(1),
"expected exit 1 when file needs formatting"
);
}
#[test]
fn format_rewrites_file_in_place() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("doc.md");
fs::write(&file, "Heading\n=======\n\n* item\n").unwrap();
let status = Command::new(mdlint_bin())
.args(["format", file.to_str().unwrap()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
assert!(status.success());
let result = fs::read_to_string(&file).unwrap();
assert_eq!(result, "# Heading\n\n- item\n");
}
#[test]
fn check_without_fix_does_not_modify_file() {
let dir = TempDir::new().unwrap();
let config = dir.path().join("mdlint.toml");
fs::write(&config, "default_enabled = true\nfix = false\n").unwrap();
let file = dir.path().join("doc.md");
let content = "# Heading\n\nTrailing spaces. \n";
fs::write(&file, content).unwrap();
Command::new(mdlint_bin())
.args([
"check",
"--config",
config.to_str().unwrap(),
file.to_str().unwrap(),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
let after = fs::read_to_string(&file).unwrap();
assert_eq!(
after, content,
"check with fix=false must not modify the file"
);
}
#[test]
fn check_with_fix_corrects_violations_and_exits_1() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("doc.md");
fs::write(&file, "# Heading\n\nTrailing spaces. \n").unwrap();
let status = Command::new(mdlint_bin())
.args(["check", "--fix", file.to_str().unwrap()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
assert_eq!(
status.code(),
Some(1),
"check --fix should exit 1 when violations were found"
);
let after = fs::read_to_string(&file).unwrap();
assert_eq!(
after, "# Heading\n\nTrailing spaces.\n",
"trailing spaces should be removed by --fix"
);
}