use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn copy_dir(src: &Path, dst: &Path) {
fs::create_dir_all(dst).unwrap();
for entry in fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let to = dst.join(entry.file_name());
if entry.file_type().unwrap().is_dir() {
copy_dir(&entry.path(), &to);
} else {
fs::copy(entry.path(), &to).unwrap();
}
}
}
fn lint_fixture_copy(tag: &str) -> PathBuf {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let workspace = manifest.parent().unwrap().parent().unwrap(); let fixture = workspace.join("fixtures/site-lint");
let tmp = std::env::temp_dir().join(format!("docgen_lint_cli_{tag}_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
copy_dir(&fixture, &tmp);
tmp
}
fn lint(root: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_docgen"))
.arg("lint")
.args(args)
.arg(root)
.output()
.unwrap()
}
fn stdout(out: &Output) -> String {
String::from_utf8(out.stdout.clone()).unwrap()
}
#[test]
fn default_pretty_run_reports_findings_and_exits_1() {
let tmp = lint_fixture_copy("default");
let out = lint(&tmp, &[]);
assert_eq!(out.status.code(), Some(1));
let text = stdout(&out);
for rule in [
"error[broken-wikilink]",
"error[missing-asset]",
"error[unknown-component]",
"error[invalid-frontmatter]",
"error[duplicate-slug]",
"error[plantuml-src-missing]",
"warn[broken-anchor]",
"warn[mermaid-empty]",
"warn[mermaid-unknown-type]",
"warn[empty-page]",
"warn[missing-title]",
"info[orphan-page]",
"info[unused-partial]",
] {
assert!(text.contains(rule), "missing `{rule}` in:\n{text}");
}
assert!(text.contains("warn[heading-level-jump]"), "{text}");
assert!(!text.contains("info[heading-level-jump]"), "{text}");
assert!(!text.contains("drafts/"), "{text}");
assert!(!text.contains("nowhere-at-all"), "{text}");
assert!(
text.contains("6 errors, 7 warnings, 2 infos · 12 files checked"),
"{text}"
);
assert!(!text.contains('\x1b'), "{text}");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn json_format_matches_summary_and_sorts_files() {
let tmp = lint_fixture_copy("json");
let out = lint(&tmp, &["--format", "json"]);
assert_eq!(out.status.code(), Some(1));
let parsed: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
let diags = parsed["diagnostics"].as_array().unwrap();
assert!(!diags.is_empty());
let count = |sev: &str| diags.iter().filter(|d| d["severity"] == sev).count() as u64;
assert_eq!(parsed["summary"]["errors"].as_u64(), Some(count("error")));
assert_eq!(parsed["summary"]["warnings"].as_u64(), Some(count("warn")));
assert_eq!(parsed["summary"]["infos"].as_u64(), Some(count("info")));
assert_eq!(parsed["summary"]["files_checked"].as_u64(), Some(12));
let rules: Vec<&str> = diags.iter().map(|d| d["rule"].as_str().unwrap()).collect();
for rule in ["broken-wikilink", "duplicate-slug", "orphan-page"] {
assert!(rules.contains(&rule), "missing {rule} in {rules:?}");
}
let files: Vec<&str> = diags.iter().map(|d| d["file"].as_str().unwrap()).collect();
let mut sorted = files.clone();
sorted.sort();
assert_eq!(files, sorted);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn github_format_emits_workflow_commands() {
let tmp = lint_fixture_copy("github");
let out = lint(&tmp, &["--format", "github"]);
assert_eq!(out.status.code(), Some(1));
let text = stdout(&out);
let lines: Vec<&str> = text.lines().collect();
assert!(!lines.is_empty());
for line in &lines {
assert!(
line.starts_with("::error ")
|| line.starts_with("::warning ")
|| line.starts_with("::notice "),
"unexpected line: {line}"
);
}
assert!(text.contains("[broken-wikilink]"), "{text}");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn gitlab_format_emits_code_quality_issues() {
let tmp = lint_fixture_copy("gitlab");
let out = lint(&tmp, &["--format", "gitlab"]);
assert_eq!(out.status.code(), Some(1));
let parsed: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
let issues = parsed.as_array().unwrap();
assert!(!issues.is_empty());
for issue in issues {
assert!(issue["check_name"].is_string(), "{issue}");
let fp = issue["fingerprint"].as_str().unwrap();
assert_eq!(fp.len(), 64);
assert!(issue["location"]["path"]
.as_str()
.unwrap()
.starts_with("docs/"));
assert!(issue["location"]["lines"]["begin"].is_u64());
}
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn rules_flag_selects_a_single_rule() {
let tmp = lint_fixture_copy("rules_single");
let out = lint(&tmp, &["--rules", "broken-wikilink", "--format", "json"]);
assert_eq!(out.status.code(), Some(1)); let parsed: serde_json::Value = serde_json::from_str(&stdout(&out)).unwrap();
let diags = parsed["diagnostics"].as_array().unwrap();
assert!(!diags.is_empty());
assert!(
diags.iter().all(|d| d["rule"] == "broken-wikilink"),
"{diags:?}"
);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn unknown_rule_id_exits_2_and_names_it_on_stderr() {
let tmp = lint_fixture_copy("rules_unknown");
let out = lint(&tmp, &["--rules", "no-such-rule"]);
assert_eq!(out.status.code(), Some(2));
let err = String::from_utf8(out.stderr).unwrap();
assert!(err.contains("no-such-rule"), "{err}");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn deny_warnings_flips_a_warnings_only_run_to_exit_1() {
let tmp = lint_fixture_copy("deny_warnings");
let out = lint(&tmp, &["--rules", "missing-title"]);
assert_eq!(out.status.code(), Some(0), "{}", stdout(&out));
assert!(stdout(&out).contains("warn[missing-title]"));
let out = lint(&tmp, &["--rules", "missing-title", "--deny-warnings"]);
assert_eq!(out.status.code(), Some(1));
assert!(
stdout(&out).contains("error[missing-title]"),
"{}",
stdout(&out)
);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn list_rules_prints_all_23_and_exits_0() {
let out = Command::new(env!("CARGO_BIN_EXE_docgen"))
.arg("lint")
.arg("--list-rules")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let text = stdout(&out);
assert_eq!(text.lines().count(), 23);
for id in [
"broken-wikilink",
"duplicate-slug",
"orphan-page",
"unused-partial",
"external-url",
] {
assert!(
text.lines().any(|l| l.starts_with(id)),
"missing {id} in:\n{text}"
);
}
}
#[test]
fn clean_site_exits_0_with_no_problems() {
let tmp = std::env::temp_dir().join(format!("docgen_lint_cli_clean_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(tmp.join("docs")).unwrap();
fs::write(tmp.join("docs/index.md"), "# Home\n\nSee [[a]].\n").unwrap();
fs::write(tmp.join("docs/a.md"), "# A\n\nBack to [[index]].\n").unwrap();
let out = lint(&tmp, &[]);
assert_eq!(out.status.code(), Some(0), "{}", stdout(&out));
assert!(stdout(&out).contains("no problems found · 2 files checked"));
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn quiet_hides_warn_and_info_but_keeps_true_summary_counts() {
let tmp = lint_fixture_copy("quiet");
let out = lint(&tmp, &["--quiet"]);
assert_eq!(out.status.code(), Some(1));
let text = stdout(&out);
assert!(text.contains("error[broken-wikilink]"), "{text}");
assert!(!text.contains("warn["), "{text}");
assert!(!text.contains("info["), "{text}");
assert!(
text.contains("6 errors, 7 warnings, 2 infos · 12 files checked"),
"{text}"
);
let _ = fs::remove_dir_all(&tmp);
}