use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::tempdir;
use super::fixtures;
fn setup_test_files() -> tempfile::TempDir {
let temp_dir = tempfile::tempdir().unwrap();
fixtures::create_test_files(temp_dir.path(), "basic").unwrap();
temp_dir
}
fn create_config(dir: &Path, content: &str) {
fs::write(dir.join(".rumdl.toml"), content).unwrap();
}
#[test]
fn test_cli_include_exclude() {
let temp_dir = setup_test_files();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let run_cmd = |args: &[&str]| -> (bool, String, String) {
let output = Command::new(rumdl_exe)
.current_dir(base_path)
.args(args)
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(output.status.success(), stdout, stderr)
};
let normalize = |s: &str| s.replace(r"\", "/");
println!("--- Running CLI Include Test ---");
let (success_incl, stdout_incl, _) = run_cmd(&["check", ".", "--include", "docs/doc1.md", "--verbose"]);
assert!(success_incl, "CLI Include Test failed");
let norm_stdout_incl = normalize(&stdout_incl);
assert!(
norm_stdout_incl.contains("Processing file: docs/doc1.md"),
"CLI Include: docs/doc1.md missing"
);
assert!(
!norm_stdout_incl.contains("Processing file: README.md"),
"CLI Include: README.md should be excluded"
);
assert!(
!norm_stdout_incl.contains("Processing file: docs/temp/temp.md"),
"CLI Include: temp.md should be excluded"
);
println!("--- Running CLI Exclude Test ---");
let (success_excl, stdout_excl, _) = run_cmd(&["check", ".", "--exclude", "docs/temp", "--verbose"]);
assert!(success_excl, "CLI Exclude Test failed");
let norm_stdout_excl = normalize(&stdout_excl);
assert!(
norm_stdout_excl.contains("Processing file: README.md"),
"CLI Exclude: README.md missing"
);
assert!(
norm_stdout_excl.contains("Processing file: docs/doc1.md"),
"CLI Exclude: docs/doc1.md missing"
);
assert!(
norm_stdout_excl.contains("Processing file: src/test.md"),
"CLI Exclude: src/test.md missing"
);
assert!(
!norm_stdout_excl.contains("Processing file: docs/temp/temp.md"),
"CLI Exclude: temp.md should be excluded"
);
println!("--- Running CLI Include/Exclude Test ---");
let (success_comb, stdout_comb, _) = run_cmd(&[
"check",
".",
"--include",
"docs/*.md",
"--exclude",
"docs/temp",
"--verbose",
]);
assert!(success_comb, "CLI Include/Exclude Test failed");
let norm_stdout_comb = normalize(&stdout_comb);
assert!(
norm_stdout_comb.contains("Processing file: docs/doc1.md"),
"CLI Combo: docs/doc1.md missing"
);
assert!(
!norm_stdout_comb.contains("Processing file: docs/temp/temp.md"),
"CLI Combo: temp.md should be excluded"
);
assert!(
!norm_stdout_comb.contains("Processing file: README.md"),
"CLI Combo: README.md should be excluded"
);
}
#[test]
fn test_config_include_exclude() {
let temp_dir = setup_test_files();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let run_cmd = |args: &[&str]| -> (bool, String, String) {
let output = Command::new(rumdl_exe)
.current_dir(base_path)
.args(args)
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(output.status.success(), stdout, stderr)
};
let normalize = |s: &str| s.replace(r"\", "/");
println!("--- Running Config Include Test ---");
let config_incl = r#"
[global]
include = ["docs/doc1.md"]
"#;
create_config(base_path, config_incl);
let (success_incl, stdout_incl, _) = run_cmd(&["check", ".", "--verbose"]);
assert!(success_incl, "Config Include Test failed");
let norm_stdout_incl = normalize(&stdout_incl);
assert!(
norm_stdout_incl.contains("Processing file: docs/doc1.md"),
"Config Include: docs/doc1.md missing"
);
assert!(
!norm_stdout_incl.contains("Processing file: README.md"),
"Config Include: README.md should be excluded"
);
assert!(
!norm_stdout_incl.contains("Processing file: docs/temp/temp.md"),
"Config Include: temp.md should be excluded"
);
println!("--- Running Config Include/Exclude Test ---");
let config_comb = r#"
[global]
include = ["docs/**/*.md"] # Include all md in docs recursively
exclude = ["docs/temp"]
"#;
create_config(base_path, config_comb);
let (success_comb, stdout_comb, _) = run_cmd(&["check", ".", "--verbose"]);
assert!(success_comb, "Config Include/Exclude Test failed");
let norm_stdout_comb = normalize(&stdout_comb);
assert!(
norm_stdout_comb.contains("Processing file: docs/doc1.md"),
"Config Combo: docs/doc1.md missing"
);
assert!(
!norm_stdout_comb.contains("Processing file: docs/temp/temp.md"),
"Config Combo: temp.md should be excluded"
);
assert!(
!norm_stdout_comb.contains("Processing file: README.md"),
"Config Combo: README.md should be excluded"
);
}
#[test]
fn test_cli_override_config() {
let temp_dir = setup_test_files();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let run_cmd = |args: &[&str]| -> (bool, String, String) {
let output = Command::new(rumdl_exe)
.current_dir(base_path)
.args(args)
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(output.status.success(), stdout, stderr)
};
let normalize = |s: &str| s.replace(r"\", "/");
let config = r#"
[global]
include = ["src/**/*.md"] # Config includes only src/test.md
"#;
create_config(base_path, config);
println!("--- Running CLI Override Config Test ---");
let (success, stdout, _) = run_cmd(&["check", ".", "--include", "docs/doc1.md", "--verbose"]);
assert!(success, "CLI Override Config Test failed");
let norm_stdout = normalize(&stdout);
assert!(
norm_stdout.contains("Processing file: docs/doc1.md"),
"CLI Override: docs/doc1.md missing"
);
assert!(
!norm_stdout.contains("Processing file: src/test.md"),
"CLI Override: src/test.md should be excluded due to CLI override"
);
assert!(
!norm_stdout.contains("Processing file: README.md"),
"CLI Override: README.md should be excluded"
);
}
#[test]
fn test_readme_pattern_scope() {
let temp_dir = setup_test_files();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let run_cmd = |args: &[&str]| -> (bool, String, String) {
let output = Command::new(rumdl_exe)
.current_dir(base_path)
.args(args)
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(output.status.success(), stdout, stderr)
};
let normalize = |s: &str| s.replace(r"\", "/");
println!("--- Running README Pattern Scope Test ---");
let config = r#"
[global]
include = ["README.md"] # Reverted pattern
"#;
create_config(base_path, config);
let (success, stdout, _) = run_cmd(&["check", ".", "--verbose"]);
assert!(success, "README Pattern Scope Test failed");
let norm_stdout = normalize(&stdout);
assert!(
norm_stdout.contains("Processing file: README.md"),
"README Scope: Root README.md missing"
);
assert!(
norm_stdout.contains("Processing file: subfolder/README.md"),
"README Scope: Subfolder README.md ALSO included (known behavior)"
);
assert!(
!norm_stdout.contains("Processing file: docs/doc1.md"),
"README Scope: docs/doc1.md should be excluded"
);
}
#[test]
fn test_cli_filter_behavior() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempdir()?;
let base_path = temp_dir.path();
fixtures::create_test_files(base_path, "basic")?;
let run_cmd = |args: &[&str]| -> (bool, String, String) {
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.current_dir(temp_dir.path())
.args(args)
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(output.status.success(), stdout, stderr)
};
let normalize = |s: &str| s.replace(r"\", "/");
println!("--- Running Test Case 1: Exclude directory ---");
let (success1, stdout1, stderr1) = run_cmd(&["check", ".", "--exclude", "docs/temp", "--verbose"]);
println!("Test Case 1 Stdout:\\n{stdout1}");
println!("Test Case 1 Stderr:\\n{stderr1}");
assert!(success1, "Test Case 1 failed");
let norm_stdout1 = normalize(&stdout1);
assert!(
norm_stdout1.contains("Processing file: README.md"),
"Expected file README.md missing in Test Case 1"
);
assert!(
norm_stdout1.contains("Processing file: docs/doc1.md"),
"Expected file docs/doc1.md missing in Test Case 1"
);
assert!(
norm_stdout1.contains("Processing file: src/test.md"),
"Expected file src/test.md missing in Test Case 1"
);
assert!(
norm_stdout1.contains("Processing file: subfolder/README.md"),
"Expected file subfolder/README.md missing in Test Case 1"
);
println!("--- Running Test Case 2: Include specific file ---");
let (success2, stdout2, stderr2) = run_cmd(&["check", ".", "--include", "docs/doc1.md", "--verbose"]);
println!("Test Case 2 Stdout:\\n{stdout2}");
println!("Test Case 2 Stderr:\\n{stderr2}");
assert!(success2, "Test Case 2 failed");
let norm_stdout2 = normalize(&stdout2);
assert!(
norm_stdout2.contains("Processing file: docs/doc1.md"),
"Expected file docs/doc1.md missing in Test Case 2"
);
assert!(
!norm_stdout2.contains("Processing file: README.md"),
"File README.md should not be processed in Test Case 2"
);
assert!(
!norm_stdout2.contains("Processing file: docs/temp/temp.md"),
"File docs/temp/temp.md should not be processed in Test Case 2"
);
assert!(
!norm_stdout2.contains("Processing file: src/test.md"),
"File src/test.md should not be processed in Test Case 2"
);
assert!(
!norm_stdout2.contains("Processing file: subfolder/README.md"),
"File subfolder/README.md should not be processed in Test Case 2"
);
println!("--- Running Test Case 3: Exclude glob pattern ---");
let (success3, stdout3, stderr3) = run_cmd(&["check", ".", "--exclude", "**/README.md", "--verbose"]);
println!("Test Case 3 Stdout:\\n{stdout3}");
println!("Test Case 3 Stderr:\\n{stderr3}");
assert!(success3, "Test Case 3 failed");
let norm_stdout3 = normalize(&stdout3);
assert!(
!norm_stdout3.contains("Processing file: README.md"),
"Root README.md should be excluded in Test Case 3"
);
assert!(
!norm_stdout3.contains("Processing file: subfolder/README.md"),
"Subfolder README.md should be excluded in Test Case 3"
);
assert!(
norm_stdout3.contains("Processing file: docs/doc1.md"),
"Expected file docs/doc1.md missing in Test Case 3"
);
assert!(
norm_stdout3.contains("Processing file: docs/temp/temp.md"),
"Expected file docs/temp/temp.md missing in Test Case 3"
);
assert!(
norm_stdout3.contains("Processing file: src/test.md"),
"Expected file src/test.md missing in Test Case 3"
);
println!("--- Running Test Case 4: Include glob pattern ---");
let (success4, stdout4, stderr4) = run_cmd(&["check", ".", "--include", "docs/*.md", "--verbose"]);
println!("Test Case 4 Stdout:\\n{stdout4}");
println!("Test Case 4 Stderr:\\n{stderr4}");
assert!(success4, "Test Case 4 failed");
let norm_stdout4 = normalize(&stdout4);
assert!(
norm_stdout4.contains("Processing file: docs/doc1.md"),
"Expected file docs/doc1.md missing in Test Case 4"
);
assert!(
!norm_stdout4.contains("Processing file: docs/temp/temp.md"),
"File docs/temp/temp.md should not be processed in Test Case 4"
);
assert!(
!norm_stdout4.contains("Processing file: README.md"),
"File README.md should not be processed in Test Case 4"
);
assert!(
!norm_stdout4.contains("Processing file: src/test.md"),
"File src/test.md should not be processed in Test Case 4"
);
assert!(
!norm_stdout4.contains("Processing file: subfolder/README.md"),
"File subfolder/README.md should not be processed in Test Case 4"
);
println!("--- Running Test Case 5: Glob Include + Specific Exclude ---");
let (success5, stdout5, stderr5) = run_cmd(&[
"check",
".",
"--include",
"docs/**/*.md",
"--exclude",
"docs/temp/temp.md",
"--verbose",
]);
println!("Test Case 5 Stdout:\\n{stdout5}");
println!("Test Case 5 Stderr:\\n{stderr5}");
assert!(success5, "Test Case 5 failed");
let norm_stdout5 = normalize(&stdout5);
assert!(
norm_stdout5.contains("Processing file: docs/doc1.md"),
"Expected file docs/doc1.md missing in Test Case 5"
);
assert!(
!norm_stdout5.contains("Processing file: docs/temp/temp.md"),
"File docs/temp/temp.md should be excluded in Test Case 5"
);
assert!(
!norm_stdout5.contains("Processing file: README.md"),
"File README.md should not be processed in Test Case 5"
);
assert!(
!norm_stdout5.contains("Processing file: src/test.md"),
"File src/test.md should not be processed in Test Case 5"
);
assert!(
!norm_stdout5.contains("Processing file: subfolder/README.md"),
"File subfolder/README.md should not be processed in Test Case 5"
);
println!("--- Running Test Case 6: Specific Exclude Overrides Broader Include ---");
let (success6, stdout6, stderr6) = run_cmd(&[
"check",
".",
"--include",
"subfolder/*.md",
"--exclude",
"subfolder/README.md",
]); println!("Test Case 6 Stdout:\n{stdout6}");
println!("Test Case 6 Stderr:{stderr6}");
assert!(success6, "Case 6: Command failed"); assert!(
stdout6.contains("No markdown files found to check."),
"Case 6: Should find no files"
);
assert!(
!stdout6.contains("Processing file: subfolder/README.md"),
"File subfolder/README.md should be excluded in Test Case 6"
);
println!("--- Running Test Case 7: Root Exclude ---");
let (success7, stdout7, stderr7) = run_cmd(&["check", ".", "--exclude", "README.md", "--verbose"]); println!("Test Case 7 Stdout:\\n{stdout7}");
println!("Test Case 7 Stderr:{stderr7}");
assert!(success7, "Test Case 7 failed");
let norm_stdout7 = normalize(&stdout7);
assert!(
!norm_stdout7.contains("Processing file: README.md"),
"Root README.md should be excluded in Test Case 7"
);
assert!(
!norm_stdout7.contains("Processing file: subfolder/README.md"),
"Subfolder README.md should ALSO be excluded in Test Case 7"
);
assert!(
norm_stdout7.contains("Processing file: docs/doc1.md"),
"File docs/doc1.md should be included in Test Case 7"
);
println!("--- Running Test Case 8: Deep Glob Exclude ---");
let (success8, stdout8, stderr8) = run_cmd(&["check", ".", "--exclude", "**/*", "--verbose"]);
println!("Test Case 8 Stdout:\\n{stdout8}");
println!("Test Case 8 Stderr:\\n{stderr8}");
assert!(success8, "Test Case 8 failed");
let norm_stdout8 = normalize(&stdout8);
assert!(
!norm_stdout8.contains("Processing file:"),
"No files should be processed in Test Case 8"
);
println!("--- Running Test Case 9: Exclude multiple patterns ---");
let (success9, stdout9, stderr9) = run_cmd(&["check", ".", "--exclude", "README.md,src/*", "--verbose"]);
println!("Test Case 9 Stdout:\n{stdout9}");
println!("Test Case 9 Stderr:{stderr9}\n");
assert!(success9, "Test Case 9 failed");
let norm_stdout9 = normalize(&stdout9);
assert!(
!norm_stdout9.contains("Processing file: README.md"),
"Root README.md should be excluded in Test Case 9"
);
assert!(
!norm_stdout9.contains("Processing file: subfolder/README.md"),
"Subfolder README.md should be excluded in Test Case 9"
);
assert!(
!norm_stdout9.contains("Processing file: src/test.md"),
"File src/test.md should be excluded in Test Case 9"
);
assert!(
norm_stdout9.contains("Processing file: docs/doc1.md"),
"Expected file docs/doc1.md missing in Test Case 9"
);
println!("--- Running Test Case 10: Include multiple patterns ---");
let (success10, stdout10, stderr10) = run_cmd(&["check", ".", "--include", "README.md,src/*", "--verbose"]);
println!("Test Case 10 Stdout:\n{stdout10}");
println!("Test Case 10 Stderr:{stderr10}\n");
assert!(success10, "Test Case 10 failed");
let norm_stdout10 = normalize(&stdout10);
assert!(
norm_stdout10.contains("Processing file: README.md"),
"Root README.md should be included in Test Case 10"
);
assert!(
norm_stdout10.contains("Processing file: src/test.md"),
"File src/test.md should be included in Test Case 10"
);
assert!(
!norm_stdout10.contains("Processing file: docs/doc1.md"),
"File docs/doc1.md should not be processed in Test Case 10"
);
assert!(
norm_stdout10.contains("Processing file: subfolder/README.md"),
"File subfolder/README.md SHOULD be processed in Test Case 10"
);
println!("--- Running Test Case 11: Explicit Path (File) Ignores Config Include ---");
let config11 = r#"[global]
include=["src/*.md"]
"#;
create_config(temp_dir.path(), config11);
let (success11, stdout11, _) = run_cmd(&["check", "docs/doc1.md", "--verbose"]);
assert!(success11, "Test Case 11 failed");
let norm_stdout11 = normalize(&stdout11);
assert!(
norm_stdout11.contains("Processing file: docs/doc1.md"),
"Explicit path docs/doc1.md should be processed in Test Case 11"
);
assert!(
!norm_stdout11.contains("Processing file: src/test.md"),
"src/test.md should not be processed in Test Case 11"
);
fs::remove_file(temp_dir.path().join(".rumdl.toml"))?;
println!("--- Running Test Case 12: Explicit Path (Dir) Ignores Config Include ---");
let config12 = r#"[global]
include=["src/*.md"]
"#;
create_config(temp_dir.path(), config12);
let (success12, stdout12, _) = run_cmd(&["check", "docs", "--verbose"]); assert!(success12, "Test Case 12 failed");
let norm_stdout12 = normalize(&stdout12);
assert!(
norm_stdout12.contains("Processing file: docs/doc1.md"),
"docs/doc1.md should be processed in Test Case 12"
);
assert!(
norm_stdout12.contains("Processing file: docs/temp/temp.md"),
"docs/temp/temp.md should be processed in Test Case 12"
);
assert!(
!norm_stdout12.contains("Processing file: src/test.md"),
"src/test.md should not be processed in Test Case 12"
);
fs::remove_file(temp_dir.path().join(".rumdl.toml"))?;
println!("--- Running Test Case 13: Explicit Path (Dir) Respects Config Exclude ---");
let config13 = r#"[global]
exclude=["docs/temp"]
"#;
create_config(temp_dir.path(), config13);
let (success13, stdout13, _) = run_cmd(&["check", "docs", "--verbose"]); assert!(success13, "Test Case 13 failed");
let norm_stdout13 = normalize(&stdout13);
assert!(
norm_stdout13.contains("Processing file: docs/doc1.md"),
"docs/doc1.md should be processed in Test Case 13"
);
assert!(
!norm_stdout13.contains("Processing file: docs/temp/temp.md"),
"docs/temp/temp.md should be excluded by config in Test Case 13"
);
fs::remove_file(temp_dir.path().join(".rumdl.toml"))?;
println!("--- Running Test Case 14: Explicit Path (Dir) Respects CLI Exclude ---");
let (success14, stdout14, _) = run_cmd(&["check", "docs", "--exclude", "docs/temp", "--verbose"]); assert!(success14, "Test Case 14 failed");
let norm_stdout14 = normalize(&stdout14);
assert!(
norm_stdout14.contains("Processing file: docs/doc1.md"),
"docs/doc1.md should be processed in Test Case 14"
);
assert!(
!norm_stdout14.contains("Processing file: docs/temp/temp.md"),
"docs/temp/temp.md should be excluded by CLI in Test Case 14"
);
println!("--- Running Test Case 15: Multiple Explicit Paths ---");
let (success15, stdout15, _) = run_cmd(&["check", "docs/doc1.md", "src/test.md", "--verbose"]); assert!(success15, "Test Case 15 failed");
let norm_stdout15 = normalize(&stdout15);
assert!(
norm_stdout15.contains("Processing file: docs/doc1.md"),
"docs/doc1.md was not processed in Test Case 15"
);
assert!(
norm_stdout15.contains("Processing file: src/test.md"),
"src/test.md was not processed in Test Case 15"
);
assert!(
!norm_stdout15.contains("Processing file: README.md"),
"README.md should not be processed in Test Case 15"
);
assert!(
!norm_stdout15.contains("Processing file: docs/temp/temp.md"),
"docs/temp/temp.md should not be processed in Test Case 15"
);
println!("--- Running Test Case 16: CLI Exclude Overrides Config Include ---");
let config16 = r#"[global]
include=["docs/**/*.md"]
"#;
create_config(temp_dir.path(), config16);
let (success16, stdout16, _) = run_cmd(&["check", ".", "--exclude", "docs/temp/temp.md", "--verbose"]); assert!(success16, "Test Case 16 failed");
let norm_stdout16 = normalize(&stdout16);
assert!(
norm_stdout16.contains("Processing file: docs/doc1.md"),
"docs/doc1.md should be included by config in Test Case 16"
);
assert!(
!norm_stdout16.contains("Processing file: docs/temp/temp.md"),
"docs/temp/temp.md should be excluded by CLI in Test Case 16"
);
assert!(
!norm_stdout16.contains("Processing file: README.md"),
"README.md should not be included by config in Test Case 16"
);
fs::remove_file(temp_dir.path().join(".rumdl.toml"))?;
println!("--- Running Test Case 17: Exclude Wins Over Include in Discovery Mode ---");
fs::write(
temp_dir.path().join(".rumdl.toml"),
r#"
exclude = ["docs/*"]
"#,
)?;
let (success17, stdout17, stderr17) = run_cmd(&["check", ".", "--include", "docs/doc1.md", "--verbose"]);
println!("Test Case 17 Stdout:\n{stdout17}");
println!("Test Case 17 Stderr:{stderr17}\n");
assert!(success17, "Test Case 17 failed");
let norm_stdout17 = normalize(&stdout17);
assert!(
!norm_stdout17.contains("Processing file: docs/doc1.md"),
"docs/doc1.md should be excluded by config in Test Case 17 (exclude wins over include)"
);
assert!(
!norm_stdout17.contains("Processing file: docs/temp/temp.md"),
"docs/temp/temp.md should be excluded by config in Test Case 17"
);
assert!(
!norm_stdout17.contains("Processing file: README.md"),
"README.md should NOT be included in Test Case 17"
);
assert!(
!norm_stdout17.contains("Processing file: src/test.md"),
"src/test.md should NOT be included in Test Case 17"
);
assert!(
!norm_stdout17.contains("Processing file: subfolder/README.md"),
"subfolder/README.md should NOT be included in Test Case 17"
);
Ok(())
}
#[test]
fn test_force_exclude() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let dir_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
fs::create_dir_all(dir_path.join("excluded"))?;
fs::write(dir_path.join("included.md"), "# Included\n")?;
fs::write(dir_path.join("excluded.md"), "# Should be excluded\n")?;
fs::write(dir_path.join("excluded/test.md"), "# In excluded dir\n")?;
let run_cmd = |args: &[&str]| -> (bool, String, String) {
let output = Command::new(rumdl_exe)
.current_dir(dir_path)
.args(args)
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(output.status.success(), stdout, stderr)
};
let normalize = |s: &str| s.replace(r"\", "/");
let config = r#"[global]
exclude = ["excluded.md", "excluded/**"]
"#;
fs::write(dir_path.join(".rumdl.toml"), config)?;
println!("--- Test 1: Default behavior (always respect excludes) ---");
let (success1, stdout1, stderr1) = run_cmd(&["check", "excluded.md", "--verbose"]);
assert!(success1, "Test 1 failed");
let norm_stdout1 = normalize(&stdout1);
let norm_stderr1 = normalize(&stderr1);
assert!(
norm_stderr1.contains("warning:")
&& norm_stderr1.contains("excluded.md")
&& norm_stderr1.contains("ignored because of exclude pattern"),
"Default behavior: excluded.md should show warning about exclusion. stderr: {norm_stderr1}"
);
assert!(
!norm_stdout1.contains("Processing file: excluded.md"),
"Default behavior: excluded.md should NOT be processed"
);
println!("--- Test 2: Non-excluded files are processed ---");
let (success2, stdout2, _) = run_cmd(&["check", "included.md", "--verbose"]);
assert!(success2, "Test 2 failed");
let norm_stdout2 = normalize(&stdout2);
assert!(
norm_stdout2.contains("Processing file: included.md"),
"included.md should be processed"
);
println!("--- Test 3: Multiple files with excludes ---");
let (success3, stdout3, stderr3) = run_cmd(&["check", "included.md", "excluded.md", "--verbose"]);
assert!(success3, "Test 3 failed");
let norm_stdout3 = normalize(&stdout3);
let norm_stderr3 = normalize(&stderr3);
assert!(
norm_stdout3.contains("Processing file: included.md"),
"included.md should be processed"
);
assert!(
norm_stderr3.contains("warning:")
&& norm_stderr3.contains("excluded.md")
&& norm_stderr3.contains("ignored because of exclude pattern"),
"excluded.md should show warning about exclusion"
);
assert!(
!norm_stdout3.contains("Processing file: excluded.md"),
"excluded.md should NOT be processed"
);
println!("--- Test 4: Directory patterns with excludes ---");
let (success4, stdout4, stderr4) = run_cmd(&["check", "excluded/test.md", "--verbose"]);
assert!(success4, "Test 4 failed");
let norm_stdout4 = normalize(&stdout4);
let norm_stderr4 = normalize(&stderr4);
assert!(
norm_stderr4.contains("warning:")
&& norm_stderr4.contains("excluded/test.md")
&& norm_stderr4.contains("ignored because of exclude pattern"),
"Files in excluded dir should show warning about exclusion"
);
assert!(
!norm_stdout4.contains("Processing file: excluded/test.md"),
"excluded/test.md should NOT be processed"
);
Ok(())
}
#[test]
fn test_default_discovery_includes_only_markdown() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let dir_path = temp_dir.path();
fs::write(dir_path.join("test.md"), "# Valid Markdown\n")?;
fs::write(dir_path.join("test.txt"), "This is a text file.")?;
let mut cmd = cargo_bin_cmd!("rumdl");
cmd.arg("check")
.arg(".")
.arg("--verbose") .current_dir(dir_path);
cmd.assert()
.success() .stdout(predicates::str::contains("Processing file: test.md"))
.stdout(predicates::str::contains("Processing file: test.txt").not());
Ok(())
}
#[test]
fn test_markdown_extension_handling() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempdir()?;
let dir_path = temp_dir.path();
fs::write(dir_path.join("test.md"), "# MD File\n")?;
fs::write(dir_path.join("test.markdown"), "# MARKDOWN File\n")?;
fs::write(dir_path.join("other.txt"), "Text file")?;
let mut cmd1 = cargo_bin_cmd!("rumdl");
cmd1.arg("check").arg(".").arg("--verbose").current_dir(dir_path);
cmd1.assert()
.success()
.stdout(predicates::str::contains("Processing file: test.md"))
.stdout(predicates::str::contains("Processing file: test.markdown"))
.stdout(predicates::str::contains("Processing file: other.txt").not());
let mut cmd2 = cargo_bin_cmd!("rumdl");
cmd2.arg("check")
.arg(".")
.arg("--include")
.arg("*.markdown")
.arg("--verbose")
.current_dir(dir_path);
cmd2.assert()
.success()
.stdout(predicates::str::contains("Processing file: test.markdown"))
.stdout(predicates::str::contains("Processing file: test.md").not());
Ok(())
}
#[test]
fn test_type_filter_precedence() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempdir()?;
let dir_path = temp_dir.path();
fs::write(dir_path.join("test.md"), "# MD File\n")?;
fs::write(dir_path.join("test.txt"), "Text file")?;
let mut cmd1 = cargo_bin_cmd!("rumdl");
cmd1.arg("check")
.arg(".")
.arg("--include")
.arg("*.txt")
.arg("--verbose")
.current_dir(dir_path);
cmd1.assert()
.code(1) .stdout(predicates::str::contains("Processing file: test.txt"))
.stdout(predicates::str::contains("MD041")) .stdout(predicates::str::contains("MD047"));
let mut cmd2 = cargo_bin_cmd!("rumdl");
cmd2.arg("check")
.arg(".")
.arg("--exclude")
.arg("*.md")
.arg("--verbose")
.current_dir(dir_path);
cmd2.assert()
.success()
.stdout(predicates::str::contains("No markdown files found to check."))
.stdout(predicates::str::contains("Processing file:").not());
fs::write(dir_path.join("test.markdown"), "# MARKDOWN File\n")?;
let mut cmd3 = cargo_bin_cmd!("rumdl");
cmd3.arg("check")
.arg(".")
.arg("--exclude")
.arg("*.md,*.markdown")
.arg("--verbose")
.current_dir(dir_path);
cmd3.assert()
.success()
.stdout(predicates::str::contains("No markdown files found to check."))
.stdout(predicates::str::contains("Processing file:").not());
Ok(())
}
#[test]
fn test_check_subcommand_works() {
let temp_dir = setup_test_files();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = std::process::Command::new(rumdl_exe)
.current_dir(base_path)
.args(["check", "README.md"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert!(output.status.success(), "check subcommand failed: {stderr}");
assert!(
stdout.contains("Success:") || stdout.contains("Issues:"),
"Output missing summary"
);
assert!(
!stderr.contains("Deprecation warning"),
"Should not print deprecation warning for subcommand"
);
}
#[test]
fn test_legacy_cli_works_and_warns() {
let temp_dir = setup_test_files();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = std::process::Command::new(rumdl_exe)
.current_dir(base_path)
.args(["README.md"])
.output()
.expect("Failed to execute command");
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert!(!output.status.success(), "legacy CLI should fail");
assert!(
stderr.contains("error:") || stderr.contains("Usage:"),
"Should show error or usage for invalid subcommand"
);
let output = std::process::Command::new(rumdl_exe)
.current_dir(base_path)
.args(["check", "README.md"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(output.status.success(), "new CLI with check should work");
assert!(
stdout.contains("Success:") || stdout.contains("Issues:"),
"Output missing summary"
);
}
#[test]
fn test_rule_command_lists_all_rules() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.arg("rule")
.output()
.expect("Failed to execute 'rumdl rule'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(output.status.success(), "'rumdl rule' did not exit successfully");
assert!(stdout.contains("Available rules:"), "Output missing 'Available rules:'");
assert!(stdout.contains("MD013"), "Output missing rule MD013");
}
#[test]
fn test_rule_command_shows_specific_rule() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "MD013"])
.output()
.expect("Failed to execute 'rumdl rule MD013'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(output.status.success(), "'rumdl rule MD013' did not exit successfully");
assert!(stdout.contains("MD013"), "Output missing rule name MD013");
assert!(
stdout.contains("Name:") || stdout.contains("Description"),
"Output missing expected field"
);
}
#[test]
fn test_rule_command_json_output_all_rules() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "--output-format", "json"])
.output()
.expect("Failed to execute 'rumdl rule --output-format json'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(
output.status.success(),
"'rumdl rule --output-format json' did not exit successfully"
);
let rules: serde_json::Value = serde_json::from_str(&stdout).expect("Failed to parse JSON output");
assert!(rules.is_array(), "Expected JSON array");
let rules_array = rules.as_array().unwrap();
assert!(!rules_array.is_empty(), "Expected at least one rule");
let first_rule = &rules_array[0];
assert!(first_rule.get("code").is_some(), "Missing 'code' field");
assert!(first_rule.get("name").is_some(), "Missing 'name' field");
assert!(first_rule.get("aliases").is_some(), "Missing 'aliases' field");
assert!(first_rule.get("summary").is_some(), "Missing 'summary' field");
assert!(first_rule.get("category").is_some(), "Missing 'category' field");
assert!(first_rule.get("fix").is_some(), "Missing 'fix' field");
assert!(
first_rule.get("fix_availability").is_some(),
"Missing 'fix_availability' field"
);
assert!(first_rule.get("url").is_some(), "Missing 'url' field");
let md001 = rules_array
.iter()
.find(|r| r.get("code").and_then(|c| c.as_str()) == Some("MD001"));
assert!(md001.is_some(), "MD001 not found in rules");
let md001 = md001.unwrap();
assert_eq!(md001.get("name").and_then(|n| n.as_str()), Some("heading-increment"));
assert_eq!(md001.get("category").and_then(|c| c.as_str()), Some("heading"));
assert!(
md001.get("url").and_then(|u| u.as_str()).unwrap().contains("rumdl.dev"),
"URL should contain rumdl.dev"
);
}
#[test]
fn test_rule_command_json_output_single_rule() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "MD041", "--output-format", "json"])
.output()
.expect("Failed to execute 'rumdl rule MD041 --output-format json'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(
output.status.success(),
"'rumdl rule MD041 --output-format json' did not exit successfully"
);
let rule: serde_json::Value = serde_json::from_str(&stdout).expect("Failed to parse JSON output");
assert!(rule.is_object(), "Expected JSON object for single rule");
assert_eq!(rule.get("code").and_then(|c| c.as_str()), Some("MD041"));
assert_eq!(rule.get("name").and_then(|n| n.as_str()), Some("first-line-h1"));
let aliases = rule.get("aliases").and_then(|a| a.as_array()).unwrap();
assert!(aliases.iter().any(|a| a.as_str() == Some("first-line-heading")));
assert_eq!(
rule.get("url").and_then(|u| u.as_str()),
Some("https://rumdl.dev/md041/")
);
}
#[test]
fn test_rule_command_json_fix_availability_values() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "--output-format", "json"])
.output()
.expect("Failed to execute 'rumdl rule --output-format json'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let rules: Vec<serde_json::Value> = serde_json::from_str(&stdout).expect("Failed to parse JSON");
for rule in &rules {
let fix_avail = rule.get("fix_availability").and_then(|f| f.as_str()).unwrap();
assert!(
matches!(fix_avail, "Always" | "Sometimes" | "None"),
"Unexpected fix_availability value: {} for rule {}",
fix_avail,
rule.get("code").and_then(|c| c.as_str()).unwrap_or("unknown")
);
}
let md033 = rules
.iter()
.find(|r| r.get("code").and_then(|c| c.as_str()) == Some("MD033"));
assert!(md033.is_some(), "MD033 not found");
assert_eq!(
md033.unwrap().get("fix_availability").and_then(|f| f.as_str()),
Some("None")
);
}
#[test]
fn test_rule_command_fixable_filter() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "--fixable", "--output-format", "json"])
.output()
.expect("Failed to execute 'rumdl rule --fixable'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let rules: Vec<serde_json::Value> = serde_json::from_str(&stdout).expect("Failed to parse JSON");
for rule in &rules {
let fix_avail = rule.get("fix_availability").and_then(|f| f.as_str()).unwrap();
assert!(
matches!(fix_avail, "Always" | "Sometimes"),
"Non-fixable rule {} returned with --fixable filter",
rule.get("code").and_then(|c| c.as_str()).unwrap_or("unknown")
);
}
let has_md033 = rules
.iter()
.any(|r| r.get("code").and_then(|c| c.as_str()) == Some("MD033"));
assert!(!has_md033, "MD033 should not be included with --fixable filter");
}
#[test]
fn test_rule_command_category_filter() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "--category", "heading", "--output-format", "json"])
.output()
.expect("Failed to execute 'rumdl rule --category heading'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let rules: Vec<serde_json::Value> = serde_json::from_str(&stdout).expect("Failed to parse JSON");
assert!(!rules.is_empty(), "Should return at least one heading rule");
for rule in &rules {
let category = rule.get("category").and_then(|c| c.as_str()).unwrap();
assert_eq!(
category,
"heading",
"Rule {} has category {} instead of heading",
rule.get("code").and_then(|c| c.as_str()).unwrap_or("unknown"),
category
);
}
let has_md001 = rules
.iter()
.any(|r| r.get("code").and_then(|c| c.as_str()) == Some("MD001"));
assert!(has_md001, "MD001 should be included with --category heading");
}
#[test]
fn test_rule_command_combined_filters() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "--fixable", "--category", "heading", "--output-format", "json"])
.output()
.expect("Failed to execute 'rumdl rule --fixable --category heading'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let rules: Vec<serde_json::Value> = serde_json::from_str(&stdout).expect("Failed to parse JSON");
assert!(!rules.is_empty(), "Should return at least one fixable heading rule");
for rule in &rules {
let fix_avail = rule.get("fix_availability").and_then(|f| f.as_str()).unwrap();
let category = rule.get("category").and_then(|c| c.as_str()).unwrap();
assert!(
matches!(fix_avail, "Always" | "Sometimes"),
"Rule {} should be fixable",
rule.get("code").and_then(|c| c.as_str()).unwrap_or("unknown")
);
assert_eq!(
category,
"heading",
"Rule {} should have category heading",
rule.get("code").and_then(|c| c.as_str()).unwrap_or("unknown")
);
}
}
#[test]
fn test_rule_command_json_lines_format() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "--output-format", "json-lines"])
.output()
.expect("Failed to execute 'rumdl rule --output-format json-lines'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let lines: Vec<&str> = stdout.lines().collect();
assert!(!lines.is_empty(), "Should output at least one line");
for (i, line) in lines.iter().enumerate() {
let rule: serde_json::Value =
serde_json::from_str(line).unwrap_or_else(|e| panic!("Line {i} is not valid JSON: {e}"));
assert!(rule.get("code").is_some(), "Line {i} missing 'code' field");
assert!(rule.get("name").is_some(), "Line {i} missing 'name' field");
}
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(
first.get("code").and_then(|c| c.as_str()),
Some("MD001"),
"First line should be MD001"
);
}
#[test]
fn test_rule_command_explain_flag() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "MD001", "--output-format", "json", "--explain"])
.output()
.expect("Failed to execute 'rumdl rule MD001 --explain'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let rule: serde_json::Value = serde_json::from_str(&stdout).expect("Failed to parse JSON");
let explanation = rule.get("explanation").and_then(|e| e.as_str());
assert!(explanation.is_some(), "Should have explanation field with --explain");
assert!(
explanation.unwrap().contains("heading"),
"Explanation should contain 'heading'"
);
let output_no_explain = Command::new(rumdl_exe)
.args(["rule", "MD001", "--output-format", "json"])
.output()
.expect("Failed to execute 'rumdl rule MD001'");
let stdout_no_explain = String::from_utf8_lossy(&output_no_explain.stdout).to_string();
let rule_no_explain: serde_json::Value = serde_json::from_str(&stdout_no_explain).expect("Failed to parse JSON");
assert!(
rule_no_explain.get("explanation").is_none(),
"Should not have explanation field without --explain"
);
}
#[test]
fn test_rule_command_text_output_with_filters() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "--fixable", "--category", "heading"])
.output()
.expect("Failed to execute 'rumdl rule --fixable --category heading'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(stdout.contains("fixable"), "Output should mention fixable filter");
assert!(stdout.contains("heading"), "Output should mention category filter");
assert!(stdout.contains("Total:"), "Output should show total count");
assert!(stdout.contains("MD001"), "Should include MD001 in output");
}
#[test]
fn test_rule_command_list_categories() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "--list-categories"])
.output()
.expect("Failed to execute 'rumdl rule --list-categories'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(output.status.success(), "Should exit successfully");
assert!(stdout.contains("Available categories:"), "Should show header");
assert!(stdout.contains("heading"), "Should list heading category");
assert!(stdout.contains("whitespace"), "Should list whitespace category");
assert!(stdout.contains("rules)"), "Should show rule counts");
}
#[test]
fn test_rule_command_invalid_category_error() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "--category", "nonexistent"])
.output()
.expect("Failed to execute 'rumdl rule --category nonexistent'");
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert!(!output.status.success(), "Should exit with error");
assert!(stderr.contains("Invalid category"), "Should mention invalid category");
assert!(stderr.contains("Valid categories:"), "Should list valid categories");
assert!(stderr.contains("heading"), "Should show heading as valid option");
}
#[test]
fn test_rule_command_short_flags() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.args(["rule", "-f", "-c", "heading", "-o", "json"])
.output()
.expect("Failed to execute 'rumdl rule -f -c heading -o json'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let rules: Vec<serde_json::Value> = serde_json::from_str(&stdout).expect("Failed to parse JSON");
assert!(!rules.is_empty(), "Should return at least one rule");
for rule in &rules {
let fix_avail = rule.get("fix_availability").and_then(|f| f.as_str()).unwrap();
let category = rule.get("category").and_then(|c| c.as_str()).unwrap();
assert!(matches!(fix_avail, "Always" | "Sometimes"), "Rule should be fixable");
assert_eq!(category, "heading", "Rule should be in heading category");
}
}
#[test]
fn test_config_command_lists_options() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.arg("config")
.output()
.expect("Failed to execute 'rumdl config'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(output.status.success(), "'rumdl config' did not exit successfully");
assert!(stdout.contains("[global]"), "Output missing [global] section");
assert!(
stdout.contains("enable =") || stdout.contains("disable =") || stdout.contains("exclude ="),
"Output missing expected config keys"
);
}
#[test]
fn test_version_command_prints_version() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let output = Command::new(rumdl_exe)
.arg("version")
.output()
.expect("Failed to execute 'rumdl version'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(output.status.success(), "'rumdl version' did not exit successfully");
assert!(stdout.contains("rumdl"), "Output missing 'rumdl' in version output");
assert!(stdout.contains("."), "Output missing version number");
}
#[test]
fn test_config_get_subcommand() {
use std::fs;
use std::process::Command;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join(".rumdl.toml");
let config_content = r#"
[global]
exclude = ["docs/temp", "node_modules"]
[MD013]
line_length = 123
"#;
fs::write(&config_path, config_content).unwrap();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let run_cmd = |args: &[&str]| -> (bool, String, String) {
let output = Command::new(rumdl_exe)
.current_dir(temp_dir.path())
.args(args)
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(output.status.success(), stdout, stderr)
};
let (success, stdout, stderr) = run_cmd(&["config", "get", "global.exclude"]);
assert!(success, "config get global.exclude should succeed, stderr: {stderr}");
assert!(
stdout.contains("global.exclude = [\"docs/temp\", \"node_modules\"] [from project config]"),
"Unexpected output: {stdout}. Stderr: {stderr}"
);
let (success, stdout, stderr) = run_cmd(&["config", "get", "MD013.line_length"]);
assert!(success, "config get MD013.line_length should succeed, stderr: {stderr}");
assert!(
stdout.contains("MD013.line-length = 123 [from project config]"),
"Unexpected output: {stdout}. Stderr: {stderr}"
);
let (success, _stdout, stderr) = run_cmd(&["config", "get", "global.unknown"]);
assert!(!success, "config get global.unknown should fail");
assert!(
stderr.contains("Unknown global key: unknown"),
"Unexpected stderr: {stderr}"
);
let (success, _stdout, stderr) = run_cmd(&["config", "get", "MD999.line_length"]);
assert!(!success, "config get MD999.line_length should fail");
assert!(
stderr.contains("Unknown config key: MD999.line-length"),
"Unexpected stderr: {stderr}"
);
let (success, _stdout, stderr) = run_cmd(&["config", "get", "notavalidkey"]);
assert!(!success, "config get notavalidkey should fail");
assert!(
stderr.contains("Key must be in the form global.key or MDxxx.key"),
"Unexpected stderr: {stderr}"
);
}
#[test]
fn test_config_command_defaults_prints_only_defaults() {
let temp_dir = setup_test_files();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let config_content = r#"
[global]
enable = ["MD013"]
exclude = ["docs/temp"]
"#;
create_config(base_path, config_content);
let output = Command::new(rumdl_exe)
.current_dir(base_path)
.args(["config", "--defaults"])
.output()
.expect("Failed to execute 'rumdl config --defaults'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert!(
output.status.success(),
"'rumdl config --defaults' did not exit successfully: {stderr}"
);
assert!(
stdout.trim_start().starts_with("[global]"),
"Output should start with [global], got: {}",
&stdout[..stdout.find('\n').unwrap_or(stdout.len())]
);
assert!(
stdout.contains("[from default]"),
"Output should contain provenance annotation [from default]"
);
assert!(!stdout.contains(".rumdl.toml"), "Output should not mention .rumdl.toml");
assert!(
stdout.contains("enable = ["),
"Output should contain default enable = []"
);
assert!(
!stdout.contains("enable = [\"MD013\"]"),
"Output should not contain custom config values from .rumdl.toml"
);
}
#[test]
fn test_config_command_defaults_output_toml_is_valid() {
use toml::Value;
let temp_dir = setup_test_files();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let config_content = r#"
[global]
enable = ["MD013"]
exclude = ["docs/temp"]
"#;
create_config(base_path, config_content);
let output = Command::new(rumdl_exe)
.current_dir(base_path)
.args(["config", "--defaults", "--output", "toml"])
.output()
.expect("Failed to execute 'rumdl config --defaults --output toml'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert!(
output.status.success(),
"'rumdl config --defaults --output toml' did not exit successfully: {stderr}"
);
assert!(
stdout.trim_start().starts_with("[global]"),
"Output should start with [global], got: {}",
&stdout[..stdout.find('\n').unwrap_or(stdout.len())]
);
assert!(
!stdout.contains("[from default]"),
"Output should NOT contain provenance annotation [from default] in TOML output"
);
assert!(!stdout.contains(".rumdl.toml"), "Output should not mention .rumdl.toml");
assert!(
stdout.contains("enable = ["),
"Output should contain default enable = []"
);
assert!(
!stdout.contains("enable = [\"MD013\"]"),
"Output should not contain custom config values from .rumdl.toml"
);
let mut current = String::new();
for line in stdout.lines() {
if line.starts_with('[') && !current.is_empty() {
toml::from_str::<Value>(¤t).expect("Section is not valid TOML");
current.clear();
}
current.push_str(line);
current.push('\n');
}
if !current.trim().is_empty() {
toml::from_str::<Value>(¤t).expect("Section is not valid TOML");
}
}
#[test]
fn test_config_command_defaults_provenance_annotation_colored() {
let temp_dir = setup_test_files();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let config_content = r#"
[global]
enable = ["MD013"]
exclude = ["docs/temp"]
"#;
create_config(base_path, config_content);
let output = Command::new(rumdl_exe)
.current_dir(base_path)
.args(["config", "--defaults", "--color", "always"])
.output()
.expect("Failed to execute 'rumdl config --defaults --color always'");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert!(
output.status.success(),
"'rumdl config --defaults --color always' did not exit successfully: {stderr}"
);
assert!(
stdout.contains("[from default]"),
"Output should contain provenance annotation [from default]"
);
let provenance_colored = "\x1b[2m[from default]\x1b[0m";
assert!(
stdout.contains(provenance_colored),
"Provenance annotation [from default] should be colored dim/gray (found: {stdout:?})"
);
}
#[test]
fn test_stdin_formatting() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "# Test \n\nTest paragraph ";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("check").arg("--stdin").arg("--fix").arg("--quiet");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(stdout, "# Test\n\nTest paragraph\n");
assert_eq!(stderr, "");
assert!(output.status.success());
}
#[test]
fn test_stdin_formatting_with_remaining_issues() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "# Test \n## Test\n# Test";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("check").arg("--stdin").arg("--fix");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(stdout, "# Test\n\n## Test\n\n## Test\n");
assert!(
stderr.contains("[fixed]"),
"Stdin text fix mode must show [fixed] labels on stderr. stderr: {stderr}"
);
assert!(stderr.contains("MD024"));
assert!(stderr.contains("remaining"));
assert!(!output.status.success());
}
#[test]
fn test_stdin_fix_with_json_output_format() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "# Test \n## Test\n# Test";
let mut cmd = Command::new(rumdl_exe);
cmd.args(["check", "--stdin", "--fix", "--output-format", "json"]);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!stdout.is_empty(), "Fixed content should appear on stdout");
assert!(
!stdout.starts_with('['),
"Stdout should be fixed markdown, not JSON. Got: {stdout}"
);
let json_part = stderr
.lines()
.take_while(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("\n");
if !json_part.is_empty() {
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&json_part);
assert!(
parsed.is_ok(),
"Remaining warnings on stderr should be valid JSON. Got: {stderr}"
);
let arr = parsed.unwrap();
assert!(arr.is_array(), "JSON output should be an array");
let warnings = arr.as_array().unwrap();
for w in warnings {
let rule = w["rule"].as_str().unwrap_or("");
assert_ne!(
rule, "MD009",
"Fixed rule MD009 should not appear in remaining JSON output"
);
}
}
assert!(!output.status.success());
}
#[test]
fn test_stdin_check_without_fix() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "# Test \n\nTest ";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("check").arg("--stdin");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(stdout, "");
assert!(stderr.contains("MD009"));
assert!(stderr.contains("trailing spaces"));
assert!(stderr.contains("Found 3 issue(s)"));
assert!(!output.status.success());
}
#[test]
fn test_stdin_formatting_no_issues() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "# Clean Markdown\n\nThis markdown has no issues.\n";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("fmt").arg("-").arg("--quiet");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(stdout, input, "fmt should output original content when no issues found");
assert_eq!(stderr, "");
assert!(output.status.success());
}
#[test]
fn test_stdin_dash_syntax() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "# Test \n\nTest ";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("check").arg("-");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(stdout, "");
assert!(stderr.contains("MD009"));
assert!(stderr.contains("trailing spaces"));
assert!(stderr.contains("Found 3 issue(s)"));
}
#[test]
fn test_stdin_filename_flag() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "# Test \n\nTest paragraph ";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("check").arg("-").arg("--stdin-filename").arg("test-file.md");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(stdout, "");
assert!(
stderr.contains("test-file.md:1:"),
"Error message should contain custom filename"
);
assert!(
stderr.contains("test-file.md:3:"),
"Error message should contain custom filename for line 3"
);
assert!(stderr.contains("in test-file.md"), "Summary should use custom filename");
assert!(stderr.contains("MD009"));
}
#[test]
fn test_stdin_filename_in_fmt_mode() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "# Clean Markdown\n\nNo issues here.\n";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("fmt")
.arg("-")
.arg("--stdin-filename")
.arg("custom-file.md")
.arg("--quiet");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(stdout, input, "Should output original content");
assert_eq!(stderr, "");
assert!(output.status.success());
}
#[test]
fn test_fmt_dash_syntax() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "# Test \n\nTest ";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("fmt").arg("-");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout, "# Test\n\nTest\n");
assert!(output.status.success());
}
#[test]
fn test_fmt_command() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "# Test \n\nTest paragraph ";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("fmt").arg("--stdin").arg("--quiet");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(stdout, "# Test\n\nTest paragraph\n");
assert_eq!(stderr, "");
assert!(output.status.success());
}
#[test]
fn test_fmt_vs_check_fix_exit_codes() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "Some text\n# Title\n";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("fmt").arg("-").arg("--quiet");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn fmt command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for fmt command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout, "Some text\n\n# Title\n");
assert!(output.status.success(), "fmt should exit 0 on successful formatting");
let mut cmd = Command::new(rumdl_exe);
cmd.arg("check").arg("--fix").arg("-").arg("--quiet");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn check --fix command");
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child
.wait_with_output()
.expect("Failed to wait for check --fix command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout, "Some text\n\n# Title\n");
assert!(
!output.status.success(),
"check --fix should exit 1 when unfixable violations remain"
);
assert_eq!(output.status.code(), Some(1), "check --fix should exit with code 1");
}
#[test]
fn test_include_nonstandard_extensions() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempdir()?;
let dir_path = temp_dir.path();
fs::write(
dir_path.join("template.md.jinja"),
"# Template\n\nThis is a Jinja2 template.\n",
)?;
fs::write(
dir_path.join("regular.md"),
"# Regular\n\nThis is a regular markdown file.\n",
)?;
fs::write(dir_path.join("config.yml.j2"), "# Not markdown\n\nThis is YAML.\n")?;
let mut cmd = cargo_bin_cmd!("rumdl");
cmd.arg("check").arg(".").arg("--verbose").current_dir(dir_path);
cmd.assert()
.success()
.stdout(predicates::str::contains("Processing file: regular.md"))
.stdout(predicates::str::contains("template.md.jinja").not())
.stdout(predicates::str::contains("config.yml.j2").not());
let mut cmd = cargo_bin_cmd!("rumdl");
cmd.arg("check")
.arg(".")
.arg("--include")
.arg("**/*.md.jinja")
.arg("--verbose")
.current_dir(dir_path);
cmd.assert()
.success()
.stdout(predicates::str::contains("Processing file: template.md.jinja"));
let mut cmd = cargo_bin_cmd!("rumdl");
cmd.arg("check")
.arg(".")
.arg("--include")
.arg("**/*.md.jinja")
.arg("--verbose")
.current_dir(dir_path);
cmd.assert()
.success()
.stdout(predicates::str::contains("config.yml.j2").not());
Ok(())
}
#[test]
fn test_explicit_path_nonstandard_extensions() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempdir()?;
let dir_path = temp_dir.path();
let jinja_file = dir_path.join("template.md.jinja");
fs::write(&jinja_file, "# Jinja Template\n\nThis should be checked.\n")?;
let mut cmd = cargo_bin_cmd!("rumdl");
cmd.arg("check").arg(&jinja_file).arg("--verbose");
cmd.assert()
.success()
.stdout(predicates::str::contains("Processing file:"))
.stdout(predicates::str::contains("template.md.jinja"));
Ok(())
}
#[test]
fn test_include_multiple_nonstandard_extensions() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempdir()?;
let dir_path = temp_dir.path();
fs::write(dir_path.join("template.md.jinja"), "# Jinja\n")?;
fs::write(dir_path.join("readme.md.tmpl"), "# Template\n")?;
fs::write(dir_path.join("doc.md.erb"), "# ERB\n")?;
fs::write(dir_path.join("regular.md"), "# Regular\n")?;
let mut cmd = cargo_bin_cmd!("rumdl");
cmd.arg("check")
.arg(".")
.arg("--include")
.arg("**/*.md.jinja,**/*.md.tmpl,**/*.md.erb")
.arg("--verbose")
.current_dir(dir_path);
let output = cmd.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("template.md.jinja"), "Should find .md.jinja file");
assert!(stdout.contains("readme.md.tmpl"), "Should find .md.tmpl file");
assert!(stdout.contains("doc.md.erb"), "Should find .md.erb file");
assert!(
!stdout.contains("regular.md"),
"Should not find regular.md when using specific --include"
);
Ok(())
}
mod issue197_exit_code {
use std::fs;
use std::process::Command;
use tempfile::tempdir;
#[test]
fn test_exit_code_after_all_fixes() {
let temp_dir = tempdir().unwrap();
let test_file = temp_dir.path().join("test.md");
fs::write(
&test_file,
"# Heading\n\n- list item\n - nested item (4 spaces, should be 2)\n",
)
.unwrap();
let config_file = temp_dir.path().join(".rumdl.toml");
fs::write(&config_file, "[MD007]\nindent = 2\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.arg("check")
.arg("--fix")
.arg(test_file.to_str().unwrap())
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let exit_code = output.status.code().unwrap_or(-1);
assert!(
stdout.contains("[fixed]") || stdout.contains("Fixed:"),
"Should show that issues were fixed. stdout: {stdout}\nstderr: {stderr}"
);
assert_eq!(
exit_code, 0,
"Exit code should be 0 when all issues are fixed. stdout: {stdout}\nstderr: {stderr}\nexit_code: {exit_code}"
);
assert!(
stdout.contains("Fixed:") && (stdout.contains("Fixed 1/1") || stdout.contains("Fixed: 1/1")),
"Should show 'Fixed: Fixed 1/1 issues' message. stdout: {stdout}"
);
}
#[test]
fn test_exit_code_with_remaining_issues() {
let temp_dir = tempdir().unwrap();
let test_file = temp_dir.path().join("test.md");
fs::write(
&test_file,
"This is not a heading (MD041 violation - unfixable)\n\n- list item\n - nested item (MD007 violation - fixable)\n",
)
.unwrap();
let config_file = temp_dir.path().join(".rumdl.toml");
fs::write(&config_file, "[MD007]\nindent = 2\n").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.arg("check")
.arg("--fix")
.arg(test_file.to_str().unwrap())
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let exit_code = output.status.code().unwrap_or(-1);
assert_eq!(
exit_code, 1,
"Exit code should be 1 when unfixable issues remain. stdout: {stdout}\nstderr: {stderr}\nexit_code: {exit_code}"
);
}
#[test]
fn test_relint_after_fix_catches_remaining_issues() {
let temp_dir = tempdir().unwrap();
let test_file = temp_dir.path().join("test.md");
fs::write(&test_file, "Not a heading\n\n- item\n - nested\n").unwrap();
let config_file = temp_dir.path().join(".rumdl.toml");
fs::write(&config_file, "[MD007]\nindent = 2\n").unwrap();
let check_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.arg("check")
.arg(test_file.to_str().unwrap())
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute rumdl");
let check_stdout = String::from_utf8_lossy(&check_output.stdout);
assert!(
check_stdout.contains("MD007") && check_stdout.contains("MD041"),
"File should have both MD007 and MD041 issues. stdout: {check_stdout}"
);
let fix_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.arg("check")
.arg("--fix")
.arg(test_file.to_str().unwrap())
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute rumdl");
let fix_stdout = String::from_utf8_lossy(&fix_output.stdout);
let fix_stderr = String::from_utf8_lossy(&fix_output.stderr);
let exit_code = fix_output.status.code().unwrap_or(-1);
assert!(
fix_stdout.contains("MD007"),
"MD007 should appear in output. stdout: {fix_stdout}"
);
if let Some(md007_line) = fix_stdout.lines().find(|l| l.contains("MD007")) {
assert!(
md007_line.contains("[fixed]"),
"MD007 should have [fixed] label. line: {md007_line}"
);
}
assert!(
fix_stdout.contains("MD041"),
"MD041 should remain in output (unfixable). stdout: {fix_stdout}"
);
if let Some(md041_line) = fix_stdout.lines().find(|l| l.contains("MD041")) {
assert!(
!md041_line.contains("[fixed]"),
"MD041 should NOT have [fixed] label. line: {md041_line}"
);
}
assert_eq!(
exit_code, 1,
"Exit code should be 1 when issues remain after fix (re-lint catches them). \
stdout: {fix_stdout}\nstderr: {fix_stderr}"
);
let fixed_content = fs::read_to_string(&test_file).unwrap();
assert!(
fixed_content.contains(" - nested"),
"Content should be fixed (2 spaces). Got: {fixed_content}"
);
}
}
#[test]
fn test_fmt_files_fixed_count_reports_actual_modified_files() {
let temp_dir = tempdir().unwrap();
let file_a = temp_dir.path().join("file_a.md");
fs::write(
&file_a,
r#"# Heading A
#Bad heading A1
#Bad heading A2
"#,
)
.unwrap();
let file_b = temp_dir.path().join("file_b.md");
fs::write(
&file_b,
r#"# Heading B
#Bad heading B1
"#,
)
.unwrap();
let file_c = temp_dir.path().join("file_c.md");
fs::write(
&file_c,
r#"# Clean file
This file has no issues.
"#,
)
.unwrap();
let file_d = temp_dir.path().join("file_d.md");
fs::write(
&file_d,
r#"# Heading D
#Bad heading D1
#Bad heading D2
#Bad heading D3
"#,
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["fmt", "--no-cache", "."])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("in 3 files") || stdout.contains("in 3 file"),
"Summary should report 3 files fixed (not 0 or 4). Got:\n{stdout}"
);
let content_a = fs::read_to_string(&file_a).unwrap();
assert!(
content_a.contains("## Bad heading A1"),
"File A should be modified. Got:\n{content_a}"
);
let content_c = fs::read_to_string(&file_c).unwrap();
assert!(
content_c.contains("# Clean file"),
"File C should not be modified. Got:\n{content_c}"
);
}
#[test]
fn test_fmt_files_fixed_count_with_unfixable_issues() {
let temp_dir = tempdir().unwrap();
let file_a = temp_dir.path().join("file_a.md");
fs::write(
&file_a,
r#"# Heading A
#Bad heading A1
"#,
)
.unwrap();
let file_b = temp_dir.path().join("file_b.md");
fs::write(
&file_b,
r#"This file starts with text, not a heading.
# Later heading
"#,
)
.unwrap();
let file_c = temp_dir.path().join("file_c.md");
fs::write(
&file_c,
r#"# Clean file
This file has no issues.
"#,
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["fmt", "--no-cache", "."])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("in 1 file"),
"Summary should report 1 file fixed (only file_a). Got:\n{stdout}"
);
let content_a = fs::read_to_string(&file_a).unwrap();
assert!(
content_a.contains("## Bad heading A1"),
"File A should be modified. Got:\n{content_a}"
);
}
#[test]
fn test_fmt_files_fixed_count_zero_when_no_changes() {
let temp_dir = tempdir().unwrap();
let file_a = temp_dir.path().join("file_a.md");
fs::write(
&file_a,
r#"# Clean file A
This file has no issues.
"#,
)
.unwrap();
let file_b = temp_dir.path().join("file_b.md");
fs::write(
&file_b,
r#"# Clean file B
This file also has no issues.
"#,
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["fmt", "--no-cache", "."])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("No issues found") || stdout.contains("Success"),
"Summary should indicate no issues found. Got:\n{stdout}"
);
}
#[test]
fn test_md033_not_counted_as_fixable() {
let temp_dir = tempdir().unwrap();
let file = temp_dir.path().join("test.md");
fs::write(
&file,
r#"# Test
This has <b>inline HTML</b> which triggers MD033.
"#,
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["check", "--no-cache", "."])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stdout.contains("MD033") || stderr.contains("MD033"),
"Should detect MD033 violation. Got stdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
!stdout.contains("fixable"),
"MD033 should NOT be counted as fixable. Got:\n{stdout}"
);
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["fmt", "--no-cache", "."])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.contains("Fixed"), "MD033 should NOT be fixed. Got:\n{stdout}");
let content = fs::read_to_string(&file).unwrap();
assert!(
content.contains("<b>inline HTML</b>"),
"File should not be modified. Got:\n{content}"
);
}
#[test]
fn test_capability_based_fixable_count() {
let temp_dir = tempdir().unwrap();
let file = temp_dir.path().join("test.md");
fs::write(
&file,
r#"#Missing space after hash
This has <b>inline HTML</b> which triggers MD033.
"#,
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["check", "--no-cache", "."])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("MD018") || stdout.contains("no-missing-space-atx"),
"Should detect MD018 violation. Got:\n{stdout}"
);
assert!(
stdout.contains("MD033") || stdout.contains("no-inline-html"),
"Should detect MD033 violation. Got:\n{stdout}"
);
assert!(
stdout.contains("fix 1 of the 2 issues") || stdout.contains("1 fixable"),
"Should report 1 fixable issue (MD018 only). Got:\n{stdout}"
);
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["fmt", "--no-cache", "."])
.current_dir(temp_dir.path())
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Fixed 1/2 issues") || stdout.contains("Fixed 1 issue"),
"Should report 1 issue fixed. Got:\n{stdout}"
);
let content = fs::read_to_string(&file).unwrap();
assert!(
content.contains("# Missing space"),
"MD018 should be fixed. Got:\n{content}"
);
assert!(
content.contains("<b>inline HTML</b>"),
"HTML should remain unchanged. Got:\n{content}"
);
}
#[test]
fn test_completions_list_shells() {
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["completions", "--list"])
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(stdout.contains("bash"), "Should list bash");
assert!(stdout.contains("zsh"), "Should list zsh");
assert!(stdout.contains("fish"), "Should list fish");
assert!(stdout.contains("powershell"), "Should list powershell");
assert!(stdout.contains("elvish"), "Should list elvish");
}
#[test]
fn test_completions_bash_generates_script() {
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["completions", "bash"])
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(stdout.contains("_rumdl()"), "Should generate bash completion function");
assert!(stdout.contains("COMPREPLY"), "Should use COMPREPLY for completions");
}
#[test]
fn test_completions_zsh_generates_script() {
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["completions", "zsh"])
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(stdout.contains("#compdef rumdl"), "Should have zsh compdef directive");
assert!(stdout.contains("_rumdl()"), "Should generate zsh completion function");
}
#[test]
fn test_completions_fish_generates_script() {
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["completions", "fish"])
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
stdout.contains("complete -c rumdl"),
"Should generate fish complete commands"
);
}
#[test]
fn test_completions_powershell_generates_script() {
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["completions", "powershell"])
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
stdout.contains("Register-ArgumentCompleter"),
"Should register argument completer"
);
}
#[test]
fn test_completions_elvish_generates_script() {
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["completions", "elvish"])
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed");
assert!(
stdout.contains("set edit:completion:arg-completer[rumdl]"),
"Should set elvish completion handler"
);
}
#[test]
fn test_completions_auto_detect_from_shell_env() {
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["completions"])
.env("SHELL", "/bin/bash")
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed with SHELL=bash");
assert!(
stdout.contains("_rumdl()"),
"Should auto-detect bash and generate bash completions"
);
}
#[test]
fn test_completions_unknown_shell_error() {
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["completions"])
.env("SHELL", "/bin/unknown")
.output()
.expect("Failed to execute rumdl");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "Command should fail with unknown shell");
assert!(
stderr.contains("Could not detect shell"),
"Should show helpful error message"
);
assert!(
stderr.contains("rumdl completions bash"),
"Should suggest explicit shell argument"
);
}
#[test]
fn test_completions_short_list_flag() {
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["completions", "-l"])
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "Command should succeed with -l flag");
assert!(stdout.contains("bash"), "Should list shells with short flag");
}
#[test]
fn test_completions_clean_piping_stdout_only_has_script() {
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["completions", "zsh"])
.output()
.expect("Failed to execute rumdl");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stdout.contains("#compdef rumdl"),
"stdout should contain the zsh script"
);
assert!(
!stdout.contains("Installation"),
"stdout should NOT contain installation instructions"
);
assert!(stderr.is_empty(), "stderr should be empty for clean eval usage");
}
#[test]
fn test_stdin_inline_disable_suppresses_warnings() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "# Heading\n\n<!-- rumdl-disable MD009 -->\n\nTrailing spaces \nMore trailing \n";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("check").arg("--stdin").arg("--quiet");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("MD009"),
"MD009 should be suppressed by inline disable directive, but got: {stderr}"
);
}
#[test]
fn test_stdin_inline_disable_next_line_is_scoped() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input =
"# Heading\n\n<!-- rumdl-disable-next-line MD009 -->\nSuppressed trailing \nUnsuppressed trailing \n";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("check").arg("--stdin").arg("--quiet");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stderr = String::from_utf8_lossy(&output.stderr);
let md009_warnings: Vec<&str> = stderr.lines().filter(|l| l.contains("MD009")).collect();
assert_eq!(
md009_warnings.len(),
1,
"Expected exactly 1 MD009 warning (line 5 only), got {}: {stderr}",
md009_warnings.len()
);
assert!(
md009_warnings[0].contains(":5:"),
"MD009 warning should be for line 5, but got: {}",
md009_warnings[0]
);
}
#[test]
fn test_stdin_inline_markdownlint_disable_compat() {
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
let input = "# Heading\n\n<!-- markdownlint-disable MD009 -->\n\nTrailing spaces \n";
let mut cmd = Command::new(rumdl_exe);
cmd.arg("check").arg("--stdin").arg("--quiet");
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("Failed to spawn command");
use std::io::Write;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
drop(stdin);
let output = child.wait_with_output().expect("Failed to wait for command");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("MD009"),
"MD009 should be suppressed by markdownlint-disable directive, but got: {stderr}"
);
}
#[test]
fn test_config_include_discovers_rs_files() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
fs::write(
base_path.join("lib.rs"),
"/// # Example\n///\n/// Clean doc comment.\npub fn example() {}\n",
)
.unwrap();
fs::write(base_path.join("test.md"), "# Test\n\nSome text.\n").unwrap();
fs::write(
base_path.join(".rumdl.toml"),
"[global]\ninclude = [\"**/*.md\", \"**/*.rs\"]\n",
)
.unwrap();
let output = Command::new(rumdl_exe)
.current_dir(base_path)
.args(["check", "--no-cache", ".", "--verbose"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(
stdout.contains("Processing file: lib.rs"),
"Config include should discover .rs files, stdout: {stdout}"
);
assert!(
stdout.contains("Processing file: test.md"),
"Config include should still discover .md files, stdout: {stdout}"
);
assert!(stdout.contains("2 file"), "Should process 2 files, stdout: {stdout}");
}
#[test]
fn test_config_include_directory_pattern_does_not_discover_non_lintable_files() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
fs::create_dir_all(base_path.join("docs")).unwrap();
fs::write(base_path.join("docs/guide.md"), "# Guide\n\nSome text.\n").unwrap();
fs::write(base_path.join("docs/script.py"), "print('hello')\n").unwrap();
fs::write(base_path.join("docs/image.png"), [0u8; 8]).unwrap();
fs::write(base_path.join(".rumdl.toml"), "[global]\ninclude = [\"docs/**\"]\n").unwrap();
let output = Command::new(rumdl_exe)
.current_dir(base_path)
.args(["check", "--no-cache", ".", "--verbose"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(
stdout.contains("Processing file: docs/guide.md") || stdout.contains("1 file"),
"Should discover markdown file in docs/, stdout: {stdout}"
);
assert!(
!stdout.contains("script.py"),
"Should NOT discover .py files, stdout: {stdout}"
);
assert!(
!stdout.contains("image.png"),
"Should NOT discover .png files, stdout: {stdout}"
);
}
#[test]
fn test_config_include_with_rs_and_directory_pattern() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
fs::create_dir_all(base_path.join("src")).unwrap();
fs::write(
base_path.join("src/lib.rs"),
"/// # Example\n///\n/// Clean doc.\npub fn example() {}\n",
)
.unwrap();
fs::write(base_path.join("src/notes.md"), "# Notes\n\nSome notes.\n").unwrap();
fs::write(base_path.join("src/data.json"), "{}\n").unwrap();
fs::write(
base_path.join(".rumdl.toml"),
"[global]\ninclude = [\"**/*.md\", \"**/*.rs\"]\n",
)
.unwrap();
let output = Command::new(rumdl_exe)
.current_dir(base_path)
.args(["check", "--no-cache", ".", "--verbose"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(stdout.contains("lib.rs"), "Should discover .rs files, stdout: {stdout}");
assert!(
stdout.contains("notes.md"),
"Should discover .md files, stdout: {stdout}"
);
assert!(
!stdout.contains("data.json"),
"Should NOT discover .json files, stdout: {stdout}"
);
assert!(
stdout.contains("2 file"),
"Should process exactly 2 files, stdout: {stdout}"
);
}
#[test]
fn test_default_discovery_does_not_include_rs_files() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let rumdl_exe = env!("CARGO_BIN_EXE_rumdl");
fs::write(base_path.join("lib.rs"), "/// Some doc.\npub fn example() {}\n").unwrap();
fs::write(base_path.join("test.md"), "# Test\n\nSome text.\n").unwrap();
let output = Command::new(rumdl_exe)
.current_dir(base_path)
.args(["check", "--no-cache", ".", "--verbose"])
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
assert!(
!stdout.contains("Processing file: lib.rs"),
"Default discovery should NOT include .rs files, stdout: {stdout}"
);
assert!(
stdout.contains("Processing file: test.md"),
"Default discovery should include .md files, stdout: {stdout}"
);
assert!(
stdout.contains("1 file"),
"Should process only 1 file, stdout: {stdout}"
);
}