alint_rules/
changeset_requires_path.rs1use std::slice;
22
23use alint_core::git::{
24 CommitRangeError, collect_changed_paths_checked, collect_changed_paths_filtered,
25};
26use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
27use serde::Deserialize;
28
29#[derive(Debug, Deserialize)]
30#[serde(deny_unknown_fields)]
31struct Options {
32 add_glob: String,
35 #[serde(default)]
39 when_changed: Option<String>,
40 since: String,
43}
44
45#[derive(Debug)]
46pub struct ChangesetRequiresPathRule {
47 id: String,
48 level: Level,
49 policy_url: Option<String>,
50 message_override: Option<String>,
51 add_glob: String,
52 add_scope: Scope,
53 when_changed: Option<Scope>,
54 since: String,
55}
56
57impl Rule for ChangesetRequiresPathRule {
58 alint_core::rule_common_impl!();
59
60 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
61 let all_changed = match collect_changed_paths_checked(ctx.root, &self.since) {
62 Ok(Some(set)) => set,
63 Ok(None) => return Ok(Vec::new()), Err(CommitRangeError::BadRange { stderr }) => return Err(self.bad_range(&stderr)),
65 };
66
67 let applies = match &self.when_changed {
70 None => !all_changed.is_empty(),
71 Some(scope) => all_changed.iter().any(|p| scope.matches(p, ctx.index)),
72 };
73 if !applies {
74 return Ok(Vec::new());
75 }
76
77 let added = match collect_changed_paths_filtered(ctx.root, &self.since, "A") {
78 Ok(Some(set)) => set,
79 Ok(None) => return Ok(Vec::new()),
80 Err(CommitRangeError::BadRange { stderr }) => return Err(self.bad_range(&stderr)),
81 };
82 if added.iter().any(|p| self.add_scope.matches(p, ctx.index)) {
83 return Ok(Vec::new());
84 }
85
86 let msg = self.message_override.clone().unwrap_or_else(|| {
87 let gate = if self.when_changed.is_some() {
88 " (its `when_changed` glob changed)"
89 } else {
90 ""
91 };
92 format!(
93 "the changeset `{}...HEAD`{gate} adds no file matching `{}`",
94 self.since, self.add_glob,
95 )
96 });
97 Ok(vec![Violation::new(msg)])
98 }
99}
100
101impl ChangesetRequiresPathRule {
102 fn bad_range(&self, stderr: &str) -> Error {
103 crate::commit_range::bad_diff_range(&self.id, &self.since, stderr)
104 }
105}
106
107pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
108 let opts: Options = spec
109 .deserialize_options()
110 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
111 if spec.fix.is_some() {
112 return Err(Error::rule_config(
113 &spec.id,
114 "changeset_requires_path has no fix op",
115 ));
116 }
117 if opts.add_glob.trim().is_empty() {
118 return Err(Error::rule_config(&spec.id, "`add_glob` must not be empty"));
119 }
120 if opts.since.trim().is_empty() {
121 return Err(Error::rule_config(
122 &spec.id,
123 "`since` must not be empty — `changeset_requires_path` is diff-scoped and needs a \
124 base ref (typically `since: \"{{env.ALINT_BASE_SHA | default('origin/main')}}\"`)",
125 ));
126 }
127 let add_scope = Scope::from_patterns(slice::from_ref(&opts.add_glob))
128 .map_err(|e| Error::rule_config(&spec.id, format!("invalid `add_glob`: {e}")))?;
129 let when_changed =
130 match &opts.when_changed {
131 None => None,
132 Some(g) if g.trim().is_empty() => {
133 return Err(Error::rule_config(
134 &spec.id,
135 "`when_changed` must not be empty (omit it to require the add on any change)",
136 ));
137 }
138 Some(g) => Some(Scope::from_patterns(slice::from_ref(g)).map_err(|e| {
139 Error::rule_config(&spec.id, format!("invalid `when_changed`: {e}"))
140 })?),
141 };
142
143 Ok(Box::new(ChangesetRequiresPathRule {
144 id: spec.id.clone(),
145 level: spec.level,
146 policy_url: spec.policy_url.clone(),
147 message_override: spec.message.clone(),
148 add_glob: opts.add_glob,
149 add_scope,
150 when_changed,
151 since: opts.since,
152 }))
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 fn spec(toml: &str) -> RuleSpec {
160 let mut full = String::from(
161 "id = \"needs-changelog\"\nkind = \"changeset_requires_path\"\nlevel = \"error\"\n",
162 );
163 full.push_str(toml);
164 toml::from_str(&full).unwrap()
165 }
166
167 #[test]
168 fn build_accepts_minimal() {
169 assert!(
170 build(&spec(
171 "add_glob = \".changeset/*.md\"\nsince = \"origin/main\"\n"
172 ))
173 .is_ok()
174 );
175 }
176
177 #[test]
178 fn build_accepts_when_changed_gate() {
179 assert!(
180 build(&spec(
181 "add_glob = \".changeset/*.md\"\nwhen_changed = \"src/**\"\nsince = \"origin/main\"\n"
182 ))
183 .is_ok()
184 );
185 }
186
187 #[test]
188 fn build_requires_since() {
189 let err = build(&spec("add_glob = \".changeset/*.md\"\n")).unwrap_err();
190 assert!(err.to_string().contains("since"), "{err}");
192 }
193
194 #[test]
195 fn build_rejects_empty_add_glob() {
196 let err = build(&spec("add_glob = \"\"\nsince = \"origin/main\"\n")).unwrap_err();
197 assert!(err.to_string().contains("add_glob"), "{err}");
198 }
199
200 #[test]
201 fn build_rejects_fix() {
202 assert!(
203 build(&spec(
204 "add_glob = \"x\"\nsince = \"main\"\nfix = { file_create = { content = \"x\" } }\n"
205 ))
206 .is_err()
207 );
208 }
209}