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>(),
);
}
}
}
#[test]
fn explain_surfaces_configured_rule_detail() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join(".alint.yml"),
"version: 1\n\
rules:\n\
\x20 - id: needs-readme\n\
\x20 kind: file_exists\n\
\x20 paths: README.md\n\
\x20 level: error\n\
\x20 message: \"README.md must exist at the repository root.\"\n",
)
.unwrap();
let out = run(dir.path(), &["explain", "needs-readme", "--format", "json"]);
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("explain --format json must be JSON");
assert_eq!(
v["rule_kind"], "file_exists",
"explain json dropped the rule's kind: {v}"
);
assert_eq!(
v["message"], "README.md must exist at the repository root.",
"explain json dropped the author message: {v}"
);
assert_eq!(
v["paths"]["include"][0], "README.md",
"explain json dropped the rule's paths: {v}"
);
assert!(
v["categories"].as_array().is_some_and(|c| !c.is_empty()),
"explain json dropped the kind's categories: {v}"
);
let out = run(dir.path(), &["explain", "needs-readme"]);
let s = String::from_utf8_lossy(&out.stdout);
for needle in [
"kind:",
"file_exists",
"paths:",
"README.md",
"message:",
"README.md must exist at the repository root.",
] {
assert!(
s.contains(needle),
"explain human output is missing {needle:?}:\n{s}"
);
}
}
#[test]
fn explain_handles_negation_paths_and_blank_message() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join(".alint.yml"),
r#"version: 1
rules:
- id: neg
kind: file_content_forbidden
paths: ["src/**", "!src/gen/**"]
pattern: "TODO"
level: warning
- id: blank
kind: file_exists
paths: X
level: error
message: ""
"#,
)
.unwrap();
let v: serde_json::Value =
serde_json::from_slice(&run(dir.path(), &["explain", "neg", "--format", "json"]).stdout)
.expect("json");
assert_eq!(v["paths"]["include"][0], "src/**");
assert_eq!(
v["paths"]["exclude"][0], "src/gen/**",
"inline `!` negation must report as an exclude, not an include: {v}"
);
let human = String::from_utf8_lossy(&run(dir.path(), &["explain", "neg"]).stdout).into_owned();
assert!(
human.contains("(excluding src/gen/**)"),
"human paths must show the negation as an exclusion:\n{human}"
);
let v: serde_json::Value =
serde_json::from_slice(&run(dir.path(), &["explain", "blank", "--format", "json"]).stdout)
.expect("json");
assert!(
v["message"].is_null(),
"blank message must be json null: {v}"
);
let human =
String::from_utf8_lossy(&run(dir.path(), &["explain", "blank"]).stdout).into_owned();
assert!(
!human.contains("message:"),
"blank message must not print a dangling label:\n{human}"
);
}
#[test]
fn explain_covers_every_registered_kind() {
let fixture = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../alint-dsl/tests/fixtures/all_kinds.yaml"
))
.expect("read all_kinds.yaml");
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".alint.yml"), &fixture).unwrap();
let list: serde_json::Value =
serde_json::from_slice(&run(dir.path(), &["list", "--format", "json"]).stdout)
.expect("list --format json must be JSON");
let rules = list["rules"].as_array().expect("rules array");
assert!(
rules.len() > 60,
"all_kinds fixture should exercise many kinds (got {})",
rules.len()
);
for r in rules {
let id = r["id"].as_str().expect("rule id");
let kind = r["kind"].as_str().expect("rule kind");
let out = run(dir.path(), &["explain", id, "--format", "json"]);
let ev: serde_json::Value = serde_json::from_slice(&out.stdout)
.unwrap_or_else(|_| panic!("explain {id} --format json must be valid JSON"));
assert_eq!(ev["id"], id, "explain mis-reported the id for {id}: {ev}");
assert_eq!(
ev["rule_kind"], kind,
"explain mis-reported the kind for {id}: {ev}"
);
assert!(
ev["categories"].as_array().is_some(),
"explain dropped categories for {id}: {ev}"
);
}
}
#[test]
fn list_human_surfaces_kind_and_markers() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join(".alint.yml"),
r#"version: 1
rules:
- id: rule-a
kind: file_exists
paths: .editorconfig
level: error
fix: { file_create: { content: "x\n" } }
- id: rule-b
kind: file_exists
paths: rust-toolchain.toml
level: info
when: "facts.has_rust"
- id: rule-c
kind: file_absent
paths: "**/*.bak"
level: warning
"#,
)
.unwrap();
let s = String::from_utf8_lossy(&run(dir.path(), &["list"]).stdout).into_owned();
assert!(
s.contains("file_exists") && s.contains("file_absent"),
"list human dropped the kind:\n{s}"
);
let line = |id: &str| s.lines().find(|l| l.contains(id)).unwrap_or("").to_string();
assert!(
line("rule-a").contains("[fix]"),
"fixable rule missing [fix]: {:?}",
line("rule-a")
);
assert!(
!line("rule-c").contains("[fix]"),
"non-fixable rule has a stray [fix]: {:?}",
line("rule-c")
);
assert!(
line("rule-b").contains("[when]"),
"conditional rule missing [when]: {:?}",
line("rule-b")
);
}
#[test]
fn explain_surfaces_kind_options() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join(".alint.yml"),
r#"version: 1
rules:
- id: hdr
kind: file_header
paths: "src/**/*.rs"
level: warning
pattern: "first\nsecond"
lines: 3
"#,
)
.unwrap();
let v: serde_json::Value =
serde_json::from_slice(&run(dir.path(), &["explain", "hdr", "--format", "json"]).stdout)
.expect("json");
assert_eq!(
v["options"]["pattern"], "first\nsecond",
"explain json dropped/mangled a kind option: {v}"
);
assert_eq!(
v["options"]["lines"], 3,
"explain json dropped a kind option: {v}"
);
let human = String::from_utf8_lossy(&run(dir.path(), &["explain", "hdr"]).stdout).into_owned();
let opt_line = human
.lines()
.find(|l| l.contains("options:"))
.unwrap_or("")
.to_string();
assert!(
opt_line.contains("pattern:") && opt_line.contains(r"first\nsecond"),
"multi-line option must render inline as escaped JSON: {opt_line:?}"
);
assert!(
!human.lines().any(|l| l == "second"),
"multi-line option leaked a column-0 continuation line:\n{human}"
);
assert!(
human.lines().any(|l| l.trim_start() == "lines: 3"),
"the second option must be indented on its own line:\n{human}"
);
}
#[test]
fn explain_surfaces_when_source() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join(".alint.yml"),
"version: 1\n\
rules:\n\
\x20 - id: gated\n\
\x20 kind: file_exists\n\
\x20 paths: rust-toolchain.toml\n\
\x20 level: info\n\
\x20 when: \"facts.has_rust\"\n",
)
.unwrap();
let v: serde_json::Value =
serde_json::from_slice(&run(dir.path(), &["explain", "gated", "--format", "json"]).stdout)
.expect("explain --format json must be JSON");
assert_eq!(
v["when"], "facts.has_rust",
"explain json dropped the authored when source: {v}"
);
assert_eq!(
v["conditional"], true,
"explain json must report a gated rule as conditional: {v}"
);
let human =
String::from_utf8_lossy(&run(dir.path(), &["explain", "gated"]).stdout).into_owned();
assert!(
human
.lines()
.any(|l| l.contains("when:") && l.contains("facts.has_rust")),
"explain human must show the when: source expression:\n{human}"
);
}
#[test]
fn explain_surfaces_fix_for_fixable_rule() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join(".alint.yml"),
"version: 1\n\
rules:\n\
\x20 - id: needs-editorconfig\n\
\x20 kind: file_exists\n\
\x20 paths: .editorconfig\n\
\x20 level: error\n\
\x20 fix: { file_create: { content: \"root = true\\n\" } }\n",
)
.unwrap();
let v: serde_json::Value = serde_json::from_slice(
&run(
dir.path(),
&["explain", "needs-editorconfig", "--format", "json"],
)
.stdout,
)
.expect("explain --format json must be JSON");
assert_eq!(
v["fixable"], true,
"explain json must report a fixable rule: {v}"
);
assert!(
v["fix"].as_str().is_some_and(|s| !s.is_empty()),
"explain json must describe the fix: {v}"
);
let human =
String::from_utf8_lossy(&run(dir.path(), &["explain", "needs-editorconfig"]).stdout)
.into_owned();
assert!(
human.lines().any(|l| l.trim_start().starts_with("fix:")),
"explain human must show the fix: line:\n{human}"
);
std::fs::write(
dir.path().join(".alint.yml"),
"version: 1\n\
rules:\n\
\x20 - id: no-bak\n\
\x20 kind: file_absent\n\
\x20 paths: \"**/*.bak\"\n\
\x20 level: warning\n",
)
.unwrap();
let v: serde_json::Value =
serde_json::from_slice(&run(dir.path(), &["explain", "no-bak", "--format", "json"]).stdout)
.expect("explain --format json must be JSON");
assert_eq!(
v["fixable"], false,
"explain json must report a non-fixable rule as fixable:false: {v}"
);
assert!(
v["fix"].is_null(),
"explain json must null the fix for a non-fixable rule: {v}"
);
let human =
String::from_utf8_lossy(&run(dir.path(), &["explain", "no-bak"]).stdout).into_owned();
assert!(
!human.lines().any(|l| l.trim_start().starts_with("fix:")),
"a non-fixable rule must not render a fix: line:\n{human}"
);
}
#[test]
fn export_agents_md_keeps_scope_for_messageless_rule() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join(".alint.yml"),
"version: 1\n\
rules:\n\
\x20 - id: needs-readme\n\
\x20 kind: file_exists\n\
\x20 paths: README.md\n\
\x20 level: error\n",
)
.unwrap();
let out = String::from_utf8_lossy(&run(dir.path(), &["export-agents-md"]).stdout).into_owned();
assert!(
out.contains("file_exists rule on README.md"),
"a message-less rule must keep its scope in the directive:\n{out}"
);
}
#[test]
fn help_wraps_to_narrow_terminal_width() {
let widest = |sub: &[&str]| -> (usize, String) {
let out = Command::new(alint_bin())
.args(sub)
.arg("--help")
.env("COLUMNS", "40")
.output()
.expect("spawn alint");
let text = String::from_utf8_lossy(&out.stdout).into_owned();
let w = text.lines().map(|l| l.chars().count()).max().unwrap_or(0);
(w, text)
};
let (top, text) = widest(&[]);
assert!(
text.contains("explain") && text.lines().count() > 10,
"narrow top-level --help looks truncated:\n{text}"
);
assert!(
top <= 40,
"no top-level `--help` line may exceed COLUMNS=40, but the widest is {top}:\n{text}"
);
for sub in [
"check",
"list",
"explain",
"fix",
"baseline",
"facts",
"init",
"export-agents-md",
"suggest",
"validate-config",
"lsp",
"rules",
] {
let (w, text) = widest(&[sub]);
assert!(w > 10, "`alint {sub} --help` looks truncated:\n{text}");
assert!(
w <= 60,
"`alint {sub} --help` has a {w}-wide line at COLUMNS=40 — a wrap_help \
regression would put whole option descriptions (100+ chars) on one line:\n{text}"
);
}
}
#[test]
fn explain_surfaces_summary_and_docs_for_every_kind() {
let fixture = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../alint-dsl/tests/fixtures/all_kinds.yaml"
))
.expect("read all_kinds.yaml");
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".alint.yml"), &fixture).unwrap();
let list: serde_json::Value =
serde_json::from_slice(&run(dir.path(), &["list", "--format", "json"]).stdout)
.expect("list --format json must be JSON");
for r in list["rules"].as_array().expect("rules array") {
let id = r["id"].as_str().expect("rule id");
let v: serde_json::Value =
serde_json::from_slice(&run(dir.path(), &["explain", id, "--format", "json"]).stdout)
.unwrap_or_else(|_| panic!("explain {id} --format json must be JSON"));
assert!(
v["summary"].as_str().is_some_and(|s| s.trim().len() >= 5),
"explain {id} json has no summary: {v}"
);
let family = v["categories"][0].as_str().unwrap_or_default();
let docs = v["docs"].as_str().unwrap_or_default();
assert!(
docs.starts_with("https://alint.org/docs/rules/") && docs.contains(family),
"explain {id} json docs link is wrong (family {family:?}): {docs:?}"
);
let human = String::from_utf8_lossy(&run(dir.path(), &["explain", id]).stdout).into_owned();
assert!(
human.lines().any(|l| {
l.trim_start()
.strip_prefix("summary:")
.is_some_and(|rest| rest.trim().len() >= 5)
}),
"explain {id} human has no summary:\n{human}"
);
assert!(
human.contains(docs),
"explain {id} human is missing its docs link:\n{human}"
);
}
}
#[test]
fn rules_show_and_search_over_summaries() {
let dir = tempfile::tempdir().expect("tempdir");
let out =
String::from_utf8_lossy(&run(dir.path(), &["rules", "show", "content_matches"]).stdout)
.into_owned();
assert!(
out.contains("file_content_matches")
&& out.contains("https://alint.org/docs/rules/content/file_content_matches/"),
"rules show must resolve the alias and print the canonical docs link:\n{out}"
);
let v: serde_json::Value = serde_json::from_slice(
&run(
dir.path(),
&["rules", "show", "file_hash", "--format", "json"],
)
.stdout,
)
.expect("rules show --format json must be JSON");
assert!(
v["summary"].as_str().is_some_and(|s| !s.is_empty()),
"rules show json must carry a summary: {v}"
);
assert!(
v["docs"]
.as_str()
.is_some_and(|s| s.starts_with("https://")),
"rules show json must carry a docs link: {v}"
);
let v: serde_json::Value = serde_json::from_slice(
&run(
dir.path(),
&["rules", "list", "--search", "digest", "--format", "json"],
)
.stdout,
)
.expect("rules list --format json must be JSON");
let hits: Vec<&str> = v["rules"]
.as_array()
.expect("rules")
.iter()
.filter_map(|r| r["kind"].as_str())
.collect();
assert!(
hits.contains(&"file_hash"),
"list --search over summaries must match file_hash on 'digest': {hits:?}"
);
assert!(
!run(dir.path(), &["rules", "show", "not_a_kind"])
.status
.success(),
"rules show on an unknown kind must fail"
);
}
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"
);
}
#[test]
fn explain_surfaces_manifest_scope_filter() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join("pkg")).unwrap();
std::fs::write(
dir.path().join("pkg/Cargo.toml"),
"[workspace]\nmembers = [\"a\"]\n",
)
.unwrap();
std::fs::write(
dir.path().join(".alint.yml"),
"version: 1\n\
rules:\n\
\x20 - id: rs-members\n\
\x20 kind: no_trailing_whitespace\n\
\x20 paths: \"**/*.rs\"\n\
\x20 level: warning\n\
\x20 scope_filter:\n\
\x20 include_manifest_paths:\n\
\x20 source: pkg/Cargo.toml\n\
\x20 extract: { toml: \"$.workspace.members[*]\" }\n",
)
.unwrap();
let out = run(dir.path(), &["explain", "rs-members", "--format", "json"]);
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("explain --format json must be JSON");
let scopes = v["scope_filter"]["manifest_scopes"]
.as_array()
.expect("explain json dropped scope_filter.manifest_scopes");
assert_eq!(scopes.len(), 1, "expected one manifest scope: {v}");
assert_eq!(scopes[0]["predicate"], "include_manifest_paths");
assert_eq!(scopes[0]["source"], "pkg/Cargo.toml");
assert!(
scopes[0]["paths"]
.as_array()
.is_some_and(|p| p.iter().any(|x| x == "pkg/a")),
"explain json didn't resolve the manifest scope to pkg/a: {v}"
);
let out = run(dir.path(), &["explain", "rs-members"]);
let s = String::from_utf8_lossy(&out.stdout);
for needle in ["include_manifest_paths", "pkg/Cargo.toml", "pkg/a"] {
assert!(s.contains(needle), "explain human dropped {needle:?}:\n{s}");
}
}