use std::path::{Path, PathBuf};
use std::process::Command;
fn alint_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_alint"))
}
fn fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join(".alint.yml"),
"version: 1\nextends:\n - alint://bundled/oss-baseline@v1\n",
)
.unwrap();
std::fs::write(dir.path().join("bad.md"), "no trailing newline").unwrap();
dir
}
fn run(dir: &Path, args: &[&str]) -> std::process::Output {
Command::new(alint_bin())
.args(args)
.current_dir(dir)
.output()
.expect("spawn alint")
}
const JSON_STDOUT_SUBCOMMANDS: &[&[&str]] = &[
&["check"],
&["list"],
&["fix", "--dry-run"],
&["facts"],
&["suggest"],
&["validate-config"],
&["rules", "list"],
&["rules", "categories"],
];
#[test]
fn format_json_is_never_a_silent_human_fallthrough() {
let dir = fixture();
let list = run(dir.path(), &["list", "--format", "json"]);
let list_json: serde_json::Value =
serde_json::from_slice(&list.stdout).expect("list --format json must be JSON");
let some_id = list_json["rules"][0]["id"]
.as_str()
.expect("at least one rule in the fixture")
.to_string();
let mut cases: Vec<Vec<String>> = JSON_STDOUT_SUBCOMMANDS
.iter()
.map(|c| c.iter().map(|s| (*s).to_string()).collect())
.collect();
cases.push(vec!["explain".into(), some_id]);
for case in &cases {
let mut args: Vec<&str> = case.iter().map(String::as_str).collect();
args.push("--format");
args.push("json");
let out = run(dir.path(), &args);
if out.status.success() {
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.trim().is_empty() {
continue; }
assert!(
serde_json::from_slice::<serde_json::Value>(&out.stdout).is_ok(),
"`alint {} --format json` exited 0 but stdout was not JSON \
(silent --format no-op regression). stdout begins:\n{}",
case.join(" "),
stdout.chars().take(200).collect::<String>(),
);
}
}
}
fn alint_commands_in(prose: &str) -> Vec<Vec<String>> {
let mut cmds = Vec::new();
let mut rest = prose;
while let Some(start) = rest.find("`alint ") {
let after = &rest[start + 1..]; if let Some(end) = after.find('`') {
let cmd = &after[..end];
let argv: Vec<String> = cmd
.split_whitespace()
.skip(1) .map(str::to_string)
.collect();
if !argv.is_empty() {
cmds.push(argv);
}
rest = &after[end + 1..];
} else {
break;
}
}
cmds
}
fn assert_parses(dir: &Path, argv: &[String]) {
let mut invocation: Vec<&str> = argv.iter().map(String::as_str).collect();
if argv.first().map(String::as_str) == Some("fix") {
invocation.push("--dry-run");
}
let out = run(dir, &invocation);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.code() != Some(2) && !stderr.contains("unexpected argument"),
"agent format emitted a command the CLI rejects: `alint {}`\n\
exit={:?}\nstderr: {}",
argv.join(" "),
out.status.code(),
stderr.trim(),
);
}
#[test]
fn agent_format_only_emits_runnable_commands() {
let dir = fixture();
let out = run(dir.path(), &["check", "--format", "agent"]);
let report: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("agent format is JSON");
let violations = report["violations"].as_array().expect("violations array");
let mut checked_fix_command = false;
let mut checked_prose = false;
for v in violations {
if let Some(cmd) = v["fix_command"].as_array() {
let argv: Vec<String> = cmd
.iter()
.map(|s| s.as_str().expect("argv element is a string").to_string())
.collect();
assert_parses(dir.path(), &argv);
checked_fix_command = true;
assert_eq!(
v["fix_available"].as_bool(),
Some(true),
"fix_command present but fix_available is not true: {v}"
);
}
if let Some(instr) = v["agent_instruction"].as_str() {
for argv in alint_commands_in(instr) {
assert_parses(dir.path(), &argv);
checked_prose = true;
}
}
}
assert!(
checked_fix_command,
"fixture produced no fixable violation; gate did not exercise fix_command"
);
assert!(
checked_prose,
"fixture produced no agent_instruction with an `alint …` command"
);
}
#[test]
fn fix_rejects_unrenderable_formats_without_mutating() {
let dir = fixture();
let bad = dir.path().join("bad.md");
let original = std::fs::read(&bad).unwrap();
for fmt in ["sarif", "github", "junit", "gitlab", "agent"] {
let out = run(dir.path(), &["fix", "--format", fmt]);
assert_eq!(
out.status.code(),
Some(2),
"`alint fix --format {fmt}` must exit 2, not silently degrade to human"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("supports only"),
"`alint fix --format {fmt}` should name the supported set; stderr: {stderr}"
);
assert_eq!(
std::fs::read(&bad).unwrap(),
original,
"`alint fix --format {fmt}` mutated the tree before rejecting the format"
);
}
}
#[test]
fn fix_accepts_its_renderable_formats() {
let dir = fixture();
for fmt in ["human", "json", "markdown"] {
let out = run(dir.path(), &["fix", "--dry-run", "--format", fmt]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("supports only"),
"`alint fix --format {fmt}` was wrongly rejected; stderr: {stderr}"
);
if fmt == "json" {
assert!(
serde_json::from_slice::<serde_json::Value>(&out.stdout).is_ok(),
"`alint fix --dry-run --format json` stdout was not JSON"
);
}
}
}
#[test]
fn rules_are_config_independent_but_list_is_not() {
let dir = tempfile::tempdir().expect("tempdir"); for args in [&["rules", "list"][..], &["rules", "categories"][..]] {
let out = run(dir.path(), args);
assert!(
out.status.success(),
"`alint {args:?}` must succeed without a config; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!out.stdout.is_empty(),
"`alint {args:?}` produced no output"
);
}
let out = run(dir.path(), &["list"]);
assert!(!out.status.success(), "`alint list` must require a config");
let out = run(dir.path(), &["list", "--category", "naming"]);
assert!(
!out.status.success(),
"`alint list --category` must not relax the config requirement"
);
}
#[test]
fn rules_catalog_output() {
let dir = tempfile::tempdir().expect("tempdir");
let out = run(dir.path(), &["rules", "list"]);
let s = String::from_utf8_lossy(&out.stdout);
assert!(
s.contains("no_bidi_controls"),
"catalog missing a kind: {s}"
);
assert!(
s.contains("file_content_matches") && s.contains("(alias: content_matches)"),
"aliases must be annotated on their canonical row"
);
assert!(
!s.lines()
.any(|l| l.trim_start().starts_with("content_matches ")),
"an alias must not be its own catalog row"
);
let out = run(dir.path(), &["rules", "list", "--category", "naming"]);
let s = String::from_utf8_lossy(&out.stdout);
assert!(
s.contains("filename_case") && s.contains("filename_regex"),
"naming filter dropped a naming kind: {s}"
);
assert!(
!s.contains("no_bidi_controls"),
"naming filter leaked a non-naming kind"
);
let out = run(dir.path(), &["rules", "list", "--category", "nope"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown category"));
let out = run(dir.path(), &["rules", "categories"]);
let s = String::from_utf8_lossy(&out.stdout);
assert!(s.contains("security-unicode-sanity") && s.contains("Security / Unicode sanity"));
}
#[test]
fn list_category_filters_config_rules() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join(".alint.yml"),
r#"version: 1
rules:
- id: name_rule
kind: filename_case
paths: "**/*"
case: snake
level: warning
- id: content_rule
kind: content_matches
paths: "**/*.md"
pattern: "x"
level: warning
"#,
)
.unwrap();
let out = run(dir.path(), &["list", "--category", "naming"]);
let s = String::from_utf8_lossy(&out.stdout);
assert!(
s.contains("name_rule") && !s.contains("content_rule"),
"naming: {s}"
);
let out = run(dir.path(), &["list", "--category", "content"]);
let s = String::from_utf8_lossy(&out.stdout);
assert!(
s.contains("content_rule") && !s.contains("name_rule"),
"content (alias-kind): {s}"
);
let out = run(dir.path(), &["list", "--category", "git-hygiene"]);
let s = String::from_utf8_lossy(&out.stdout);
assert!(
s.contains("no loaded rules are in category"),
"empty-category message: {s}"
);
let out = run(
dir.path(),
&["list", "--category", "naming", "--format", "json"],
);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
let rules = v["rules"].as_array().expect("rules array");
assert_eq!(rules.len(), 1);
assert_eq!(rules[0]["kind"], "filename_case");
assert_eq!(rules[0]["categories"][0], "naming");
}
#[test]
fn init_and_lsp_reject_a_non_human_format() {
let dir = tempfile::tempdir().unwrap();
let out = run(dir.path(), &["init", ".", "--format", "sarif"]);
assert_eq!(
out.status.code(),
Some(2),
"`init --format sarif` must fail loud, not silently ignore the flag"
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("does not support"),
"the rejection should name the unsupported flag; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = Command::new(alint_bin())
.args(["lsp", "--format", "json"])
.current_dir(dir.path())
.stdin(std::process::Stdio::null())
.output()
.expect("spawn alint");
assert_eq!(
out.status.code(),
Some(2),
"`lsp --format json` must fail loud before serving"
);
}