use std::slice;
use alint_core::git::{
CommitRangeError, collect_changed_paths_checked, collect_changed_paths_filtered,
};
use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
add_glob: String,
#[serde(default)]
when_changed: Option<String>,
since: String,
}
#[derive(Debug)]
pub struct ChangesetRequiresPathRule {
id: String,
level: Level,
policy_url: Option<String>,
message_override: Option<String>,
add_glob: String,
add_scope: Scope,
when_changed: Option<Scope>,
since: String,
}
impl Rule for ChangesetRequiresPathRule {
alint_core::rule_common_impl!();
fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
let all_changed = match collect_changed_paths_checked(ctx.root, &self.since) {
Ok(Some(set)) => set,
Ok(None) => return Ok(Vec::new()), Err(CommitRangeError::BadRange { stderr }) => return Err(self.bad_range(&stderr)),
};
let applies = match &self.when_changed {
None => !all_changed.is_empty(),
Some(scope) => all_changed.iter().any(|p| scope.matches(p, ctx.index)),
};
if !applies {
return Ok(Vec::new());
}
let added = match collect_changed_paths_filtered(ctx.root, &self.since, "A") {
Ok(Some(set)) => set,
Ok(None) => return Ok(Vec::new()),
Err(CommitRangeError::BadRange { stderr }) => return Err(self.bad_range(&stderr)),
};
if added.iter().any(|p| self.add_scope.matches(p, ctx.index)) {
return Ok(Vec::new());
}
let msg = self.message_override.clone().unwrap_or_else(|| {
let gate = if self.when_changed.is_some() {
" (its `when_changed` glob changed)"
} else {
""
};
format!(
"the changeset `{}...HEAD`{gate} adds no file matching `{}`",
self.since, self.add_glob,
)
});
Ok(vec![Violation::new(msg)])
}
}
impl ChangesetRequiresPathRule {
fn bad_range(&self, stderr: &str) -> Error {
crate::commit_range::bad_diff_range(&self.id, &self.since, stderr)
}
}
pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
let opts: Options = spec
.deserialize_options()
.map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
if spec.fix.is_some() {
return Err(Error::rule_config(
&spec.id,
"changeset_requires_path has no fix op",
));
}
if opts.add_glob.trim().is_empty() {
return Err(Error::rule_config(&spec.id, "`add_glob` must not be empty"));
}
if opts.since.trim().is_empty() {
return Err(Error::rule_config(
&spec.id,
"`since` must not be empty — `changeset_requires_path` is diff-scoped and needs a \
base ref (typically `since: \"{{env.ALINT_BASE_SHA | default('origin/main')}}\"`)",
));
}
let add_scope = Scope::from_patterns(slice::from_ref(&opts.add_glob))
.map_err(|e| Error::rule_config(&spec.id, format!("invalid `add_glob`: {e}")))?;
let when_changed =
match &opts.when_changed {
None => None,
Some(g) if g.trim().is_empty() => {
return Err(Error::rule_config(
&spec.id,
"`when_changed` must not be empty (omit it to require the add on any change)",
));
}
Some(g) => Some(Scope::from_patterns(slice::from_ref(g)).map_err(|e| {
Error::rule_config(&spec.id, format!("invalid `when_changed`: {e}"))
})?),
};
Ok(Box::new(ChangesetRequiresPathRule {
id: spec.id.clone(),
level: spec.level,
policy_url: spec.policy_url.clone(),
message_override: spec.message.clone(),
add_glob: opts.add_glob,
add_scope,
when_changed,
since: opts.since,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(toml: &str) -> RuleSpec {
let mut full = String::from(
"id = \"needs-changelog\"\nkind = \"changeset_requires_path\"\nlevel = \"error\"\n",
);
full.push_str(toml);
toml::from_str(&full).unwrap()
}
#[test]
fn build_accepts_minimal() {
assert!(
build(&spec(
"add_glob = \".changeset/*.md\"\nsince = \"origin/main\"\n"
))
.is_ok()
);
}
#[test]
fn build_accepts_when_changed_gate() {
assert!(
build(&spec(
"add_glob = \".changeset/*.md\"\nwhen_changed = \"src/**\"\nsince = \"origin/main\"\n"
))
.is_ok()
);
}
#[test]
fn build_requires_since() {
let err = build(&spec("add_glob = \".changeset/*.md\"\n")).unwrap_err();
assert!(err.to_string().contains("since"), "{err}");
}
#[test]
fn build_rejects_empty_add_glob() {
let err = build(&spec("add_glob = \"\"\nsince = \"origin/main\"\n")).unwrap_err();
assert!(err.to_string().contains("add_glob"), "{err}");
}
#[test]
fn build_rejects_fix() {
assert!(
build(&spec(
"add_glob = \"x\"\nsince = \"main\"\nfix = { file_create = { content = \"x\" } }\n"
))
.is_err()
);
}
}