use super::{assert_has_diagnostic, manifest_dir, reorder_fixture_dir, run_command};
#[test]
fn csharp_doc001_flags_undocumented_non_private_members() {
let (stderr, exit) = run_csharp_fixture("doc001_missing_docs.cs");
assert_ne!(exit, 0, "DOC001 errors must fail the run");
for name in [
"Undocumented",
"Guarded",
"Cached",
"Shape",
"Kind",
"Notify",
"Changed",
"Alpha",
] {
assert_has_diagnostic(&stderr, "DOC001", Some(name));
}
for clean in [
"Hidden",
"InternalDefault",
"Documented",
"IBehavior",
"Apply",
] {
assert!(
!stderr.contains(&format!("`{clean}`")),
"`{clean}` must not be flagged:\n{stderr}"
);
}
assert_eq!(
stderr.matches("DOC001").count(),
8,
"expected exactly 8 DOC001 findings:\n{stderr}"
);
}
#[test]
fn csharp_doc002_errors_on_indirect_throwers() {
let (stderr, exit) = run_csharp_fixture("doc002_indirect_exception.cs");
assert_ne!(exit, 0, "DOC002 errors must fail the run:\n{stderr}");
assert!(
stderr.contains("error[DOC002]"),
"DOC002 carries error severity:\n{stderr}"
);
assert_has_diagnostic(&stderr, "DOC002", Some("Load"));
assert_has_diagnostic(&stderr, "DOC002", Some("LoadTwice"));
assert!(
!stderr.contains("`Validate`")
&& !stderr.contains("`Parse`")
&& !stderr.contains("`LoadGuarded`"),
"private throwers, framework calls, and tagged callers pass:\n{stderr}"
);
assert_eq!(
stderr.matches("DOC002").count(),
2,
"expected exactly 2 DOC002 findings:\n{stderr}"
);
let direct = stderr.find("(fn `Load`)").expect("Load must be named");
let transitive = stderr
.find("(fn `LoadTwice`)")
.expect("LoadTwice must be named");
assert!(
direct < transitive,
"findings stay in document order:\n{stderr}"
);
}
#[test]
fn csharp_doc002_errors_on_untagged_throwers() {
let (stderr, exit) = run_csharp_fixture("doc002_missing_exception.cs");
assert_ne!(exit, 0, "DOC002 errors must fail the run:\n{stderr}");
assert_has_diagnostic(&stderr, "DOC002", Some("Untagged"));
assert!(
stderr.contains("error[DOC002]"),
"DOC002 carries error severity:\n{stderr}"
);
assert!(
!stderr.contains("`Tagged`") && !stderr.contains("`Hidden`"),
"tagged and private throwers pass:\n{stderr}"
);
assert_eq!(
stderr.matches("DOC002").count(),
1,
"expected exactly 1 DOC002 finding:\n{stderr}"
);
}
#[test]
fn csharp_doc002_should_degrade_for_loose_files_and_keep_doc003_warning_exit() {
let caller = super::temp_file("cs");
let helper = super::temp_file("cs");
std::fs::write(
&caller,
"class A {\n/// <summary>Loads a value.</summary>\npublic void Load() { T.Helper(); }\n}",
)
.unwrap();
std::fs::write(&helper, "class T { void Helper() { throw new E(); } }").unwrap();
let loose = run_command(&["--include", "lints"], &caller);
std::fs::write(
&caller,
"class A {\n/// <exception>Failure.</exception>\npublic void Load() { T.Helper(); }\n}",
)
.unwrap();
let paired = std::process::Command::new(super::binary())
.args(["--no-config", "--include", "lints"])
.arg(&caller)
.arg(&helper)
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&paired.stderr);
std::fs::remove_file(caller).unwrap();
std::fs::remove_file(helper).unwrap();
assert!(loose.status.success());
assert!(loose.stderr.is_empty());
assert!(paired.status.success(), "{stderr}");
assert_eq!(stderr.matches("warning[DOC003]").count(), 1, "{stderr}");
assert_eq!(stderr.matches("warning[").count(), 1, "{stderr}");
assert!(!stderr.contains("error["), "{stderr}");
}
#[test]
fn csharp_doc002_should_find_project_throwers_from_single_or_multiple_inputs() {
let root = manifest_dir().join("tests/fixtures/doc/csharp/doc002_cross_file");
let caller = root.join("caller/Caller.cs");
let thrower = root.join("thrower/Thrower.cs");
for multiple in [false, true] {
let mut command = std::process::Command::new(super::binary());
command
.args(["--no-config", "--include", "lints"])
.arg(&caller);
if multiple {
command.arg(&thrower);
}
let output = command.output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "{stderr}");
assert_eq!(stderr.matches("error[DOC002]").count(), 1, "{stderr}");
assert_eq!(stderr.matches("(fn `Load`)").count(), 1, "{stderr}");
assert_eq!(stderr.matches("error[").count(), 1, "{stderr}");
assert!(!stderr.contains("warning["), "{stderr}");
}
}
#[test]
fn csharp_doc002_should_refresh_diagnostic_positions_after_reorder() {
let caller = super::temp_file("cs");
let helper = super::temp_file("cs");
let source = "class A\n{\n /// <summary>Loads first.</summary>\n public void First() { T.Helper(); }\n /// <summary>Loads second.</summary>\n public void Second() { First(); }\n}\n";
std::fs::write(&caller, source).unwrap();
std::fs::write(&helper, "class T { void Helper() { throw new E(); } }").unwrap();
let run = |include| {
std::process::Command::new(super::binary())
.args(["--no-config", "--output-mode", "json", "--include", include])
.arg(&caller)
.arg(&helper)
.output()
.unwrap()
};
let combined = std::process::Command::new(super::binary())
.args([
"--no-config",
"--output-mode",
"json",
"--include",
"reorder",
"--include",
"lints",
])
.arg(&caller)
.arg(&helper)
.output()
.unwrap();
let current = std::fs::read_to_string(&caller).unwrap();
let fresh = run("lints");
let diagnostics = |output: &[u8]| {
let records: Vec<serde_json::Value> = serde_json::from_slice(output).unwrap();
records
.into_iter()
.filter(|record| record["code"] == "DOC002")
.collect::<Vec<_>>()
};
let combined_records = diagnostics(&combined.stdout);
let fresh_records = diagnostics(&fresh.stdout);
std::fs::remove_file(&caller).unwrap();
std::fs::remove_file(&helper).unwrap();
assert_ne!(current, source);
assert!(current.find("void Second").unwrap() < current.find("void First").unwrap());
assert_eq!(combined.status.code(), fresh.status.code());
assert!(!combined.status.success());
assert_eq!(combined_records.len(), 2);
assert_eq!(combined_records, fresh_records);
assert_eq!(combined_records[0]["line"], 3);
assert_eq!(combined_records[1]["line"], 5);
}
#[test]
fn csharp_doc003_warns_on_vague_exception_crefs() {
let (stderr, exit) = run_csharp_fixture("doc003_vague_exception.cs");
assert_eq!(exit, 0, "DOC003 warnings must not fail the run");
assert_has_diagnostic(&stderr, "DOC003", Some("Vague"));
assert!(
!stderr.contains("`Concrete`"),
"a concrete cref passes:\n{stderr}"
);
assert_eq!(
stderr.matches("DOC003").count(),
1,
"expected exactly 1 DOC003 finding:\n{stderr}"
);
}
#[test]
fn csharp_doc004_warns_on_missing_param_tags() {
let (stderr, exit) = run_csharp_fixture("doc004_missing_param.cs");
assert_eq!(exit, 0, "DOC004 warnings must not fail the run");
assert_has_diagnostic(&stderr, "DOC004", Some("Greet"));
assert!(
!stderr.contains("`Greeted`") && !stderr.contains("`NoArgs`"),
"tagged and parameterless members pass:\n{stderr}"
);
assert_eq!(
stderr.matches("DOC004").count(),
1,
"expected exactly 1 DOC004 finding:\n{stderr}"
);
}
#[test]
fn csharp_doc005_names_the_undocumented_param() {
let (stderr, exit) = run_csharp_fixture("doc005_undocumented_param.cs");
assert_eq!(exit, 0, "DOC005 warnings must not fail the run");
assert_has_diagnostic(&stderr, "DOC005", Some("Build"));
assert!(
stderr.contains("`format`"),
"DOC005 must name the omitted parameter:\n{stderr}"
);
assert!(
!stderr.contains("`Built`"),
"fully documented members pass:\n{stderr}"
);
assert_eq!(
stderr.matches("DOC005").count(),
1,
"expected exactly 1 DOC005 finding:\n{stderr}"
);
}
#[test]
fn csharp_doc006_warns_on_placeholders() {
let (stderr, exit) = run_csharp_fixture("doc006_placeholders.cs");
assert_eq!(exit, 0, "DOC006 warnings must not fail the run");
for name in ["Todo", "Fixme", "Tbd"] {
assert_has_diagnostic(&stderr, "DOC006", Some(name));
}
assert!(
!stderr.contains("`Done`"),
"described members pass:\n{stderr}"
);
assert_eq!(
stderr.matches("DOC006").count(),
3,
"expected exactly 3 DOC006 findings:\n{stderr}"
);
}
#[test]
fn csharp_json_dry_run_records_the_member_reorder() {
let path = reorder_fixture_dir()
.join("csharp")
.join("reorder_cs_before.cs");
let output = run_command(
&["--include", "reorder", "--output-mode", "json", "--dry-run"],
&path,
);
assert!(
output.status.success(),
"JSON dry-run should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let records: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout must parse as JSON: {e}\n{stdout}"));
let array = records.as_array().expect("output must be an array");
assert_eq!(
array.len(),
2,
"expected the using hoist and the member reorder:\n{stdout}"
);
for rec in array {
assert_eq!(rec["severity"], "success");
assert_eq!(rec["code"], "REORDER");
assert!(
rec["title"].is_null(),
"change records carry no title:\n{stdout}"
);
}
assert!(
array
.iter()
.any(|r| r["item_kind"] == "class" && r["item_name"] == "OrderService"),
"one record names the reordered class:\n{stdout}"
);
assert!(
array.iter().any(|r| r["item_kind"] == "using"),
"one record names the hoisted using:\n{stdout}"
);
}
#[test]
fn csharp_json_output_matches_the_documented_record_shape() {
let path = csharp_fixture_dir().join("doc004_missing_param.cs");
let output = run_command(&["--include", "lints", "--output-mode", "json"], &path);
assert!(
output.status.success(),
"warnings-only JSON run should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let findings: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout must parse as JSON: {e}\n{stdout}"));
let array = findings.as_array().expect("output must be an array");
assert_eq!(
array.len(),
1,
"expected exactly the DOC004 finding:\n{stdout}"
);
let keys: std::collections::BTreeSet<&str> = array[0]
.as_object()
.expect("the finding is an object")
.keys()
.map(String::as_str)
.collect();
assert_eq!(
keys,
[
"path",
"line",
"severity",
"code",
"message",
"item_kind",
"item_name",
"title",
]
.into_iter()
.collect(),
"C# findings carry exactly the documented fields: {stdout}"
);
assert_eq!(array[0]["severity"], "warning");
assert_eq!(array[0]["code"], "DOC004");
assert_eq!(array[0]["title"], "missing `# Arguments` section");
assert_eq!(array[0]["item_kind"], "fn");
assert_eq!(array[0]["item_name"], "Greet");
assert!(array[0]["line"].as_u64().is_some_and(|l| l >= 1));
}
#[test]
fn csharp_test001_flags_discouraged_names() {
let (stderr, _exit) = run_csharp_fixture("test001_test_naming.cs");
for name in ["Test1", "Test_foo", "Case_1", "Test"] {
assert_has_diagnostic(&stderr, "TEST001", Some(name));
}
assert!(
!stderr
.lines()
.any(|l| l.contains("TEST001") && l.contains("ShouldReturnZeroWhenEmpty")),
"the behavioral name passes:\n{stderr}"
);
assert_eq!(
stderr.matches("TEST001").count(),
4,
"expected exactly 4 TEST001 findings:\n{stderr}"
);
}
#[test]
fn csharp_text_budgets_fire_with_original_lines() {
let (stderr, exit) = run_csharp_fixture("text-001_text-002_text_budgets.cs");
assert_ne!(exit, 0, "the TEXT001 error must fail the run:\n{stderr}");
assert!(
stderr.contains(":10: error[TEXT001]"),
"TEXT001 must report at the summary's first prose line:\n{stderr}"
);
assert!(
stderr.contains(":19: warning[TEXT002]"),
"TEXT002 must report at the over-long measured line:\n{stderr}"
);
assert_eq!(
stderr.matches("TEXT001").count(),
1,
"expected exactly 1 TEXT001 finding:\n{stderr}"
);
assert_eq!(
stderr.matches("TEXT002").count(),
1,
"expected exactly 1 TEXT002 finding:\n{stderr}"
);
assert!(
!stderr.contains("DOC001") && !stderr.contains("DOC004"),
"the fixture is otherwise documented:\n{stderr}"
);
}
#[test]
fn csharp_text_probes_stay_quiet() {
let (stderr, exit) = run_csharp_fixture("doc_text_quiet_probes.cs");
assert_eq!(
exit, 0,
"the probe fixture must be clean across every C# lint"
);
assert!(
stderr.is_empty(),
"idiomatic docs and string content must stay unmeasured:\n{stderr}"
);
}
fn run_csharp_fixture(name: &str) -> (String, i32) {
let path = csharp_fixture_dir().join(name);
let output = run_command(&["--include", "lints"], &path);
(
String::from_utf8_lossy(&output.stderr).to_string(),
output.status.code().unwrap_or(-1),
)
}
fn csharp_fixture_dir() -> std::path::PathBuf {
manifest_dir()
.join("tests")
.join("fixtures")
.join("doc")
.join("csharp")
}