1use std::collections::HashSet;
13
14use regex::Regex;
15use serde_norway::Value;
16
17use crate::frontmatter::Frontmatter;
18use crate::project_config::{AnyTerm, ProjectConfig, Rule};
19use crate::{body, refs};
20
21pub fn evaluate(
24 prj: &ProjectConfig,
25 type_name: &str,
26 status: &str,
27 fm: &Frontmatter,
28 body: &str,
29 doc_ids: &HashSet<String>,
30) -> Vec<String> {
31 let mut out = Vec::new();
32 if let Some(dt) = prj.types.get(type_name) {
35 if let Some(lr) = &dt.requires_link {
36 if resolved_links(prj, fm, &lr.to, doc_ids) < lr.min {
37 out.push(format!(
38 "must reference at least {} doc(s) of type '{}'",
39 lr.min, lr.to
40 ));
41 }
42 }
43 }
44 for rule in &prj.rules {
45 if applies(rule, type_name, status, fm) {
46 if let Some(msg) = check(prj, rule, fm, body, doc_ids) {
47 out.push(msg);
48 }
49 }
50 }
51 out
52}
53
54fn applies(rule: &Rule, type_name: &str, status: &str, fm: &Frontmatter) -> bool {
56 rule.when.doc_type.as_deref().is_none_or(|t| t == type_name)
57 && rule.when.status.as_deref().is_none_or(|s| s == status)
58 && rule.when.tag.as_deref().is_none_or(|t| fm.has_tag(t))
59}
60
61fn check(
63 prj: &ProjectConfig,
64 rule: &Rule,
65 fm: &Frontmatter,
66 body: &str,
67 doc_ids: &HashSet<String>,
68) -> Option<String> {
69 if let Some(f) = &rule.require_field {
70 if !field_present(fm, f) {
71 return Some(format!("field '{f}' is required"));
72 }
73 }
74 if let Some(m) = &rule.field_matches {
75 if let Some(s) = fm.get_str(&m.field) {
77 if Regex::new(&m.pattern)
78 .map(|re| !re.is_match(s))
79 .unwrap_or(false)
80 {
81 return Some(format!("field '{}' must match /{}/", m.field, m.pattern));
82 }
83 }
84 }
85 if let Some(h) = &rule.require_section {
86 if !body::has_section(body, h) {
87 return Some(format!("missing required '## {h}' section"));
88 }
89 }
90 if let Some(h) = &rule.require_checked_section {
91 if !body::checklist_items(body, h).iter().any(|i| i.checked) {
92 return Some(format!("'## {h}' needs at least one checked item"));
93 }
94 }
95 if let Some(lr) = &rule.require_link {
96 if resolved_links(prj, fm, &lr.to, doc_ids) < lr.min {
97 return Some(format!(
98 "must reference at least {} doc(s) of type '{}'",
99 lr.min, lr.to
100 ));
101 }
102 }
103 if let Some(terms) = &rule.require_any {
104 if !terms.iter().any(|t| any_term_holds(t, fm, body)) {
105 return Some(format!("requires one of: {}", describe_any(terms)));
106 }
107 }
108 None
109}
110
111fn field_present(fm: &Frontmatter, name: &str) -> bool {
113 match fm.get(name) {
114 None | Some(Value::Null) => false,
115 Some(Value::String(s)) => !s.trim().is_empty(),
116 Some(_) => true,
117 }
118}
119
120fn resolved_links(
122 prj: &ProjectConfig,
123 fm: &Frontmatter,
124 to_type: &str,
125 doc_ids: &HashSet<String>,
126) -> usize {
127 match prj.types.get(to_type) {
128 Some(dt) => refs::ids_with_prefix(fm, &dt.prefix)
129 .iter()
130 .filter(|id| doc_ids.contains(*id))
131 .count(),
132 None => 0,
133 }
134}
135
136fn any_term_holds(term: &AnyTerm, fm: &Frontmatter, body: &str) -> bool {
139 if let Some(f) = &term.field {
140 return field_present(fm, f);
141 }
142 if let Some(l) = &term.link {
143 return !refs::parse_in(fm, l).is_empty();
144 }
145 if let Some(s) = &term.section {
146 return body::has_section(body, s);
147 }
148 if let Some(t) = &term.tag {
149 return fm.has_tag(t);
150 }
151 false
152}
153
154fn describe_any(terms: &[AnyTerm]) -> String {
155 terms
156 .iter()
157 .map(|t| {
158 if let Some(f) = &t.field {
159 format!("field '{f}'")
160 } else if let Some(l) = &t.link {
161 format!("link '{l}'")
162 } else if let Some(s) = &t.section {
163 format!("section '{s}'")
164 } else if let Some(tag) = &t.tag {
165 format!("tag '{tag}'")
166 } else {
167 "?".to_string()
168 }
169 })
170 .collect::<Vec<_>>()
171 .join(", ")
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::project_config::ProjectConfig;
178 use crate::templates::DEFAULT_OPYS_CONFIG;
179
180 fn cfg() -> ProjectConfig {
181 toml::from_str(DEFAULT_OPYS_CONFIG).unwrap()
182 }
183 fn ids(list: &[&str]) -> HashSet<String> {
184 list.iter().map(|s| s.to_string()).collect()
185 }
186
187 #[test]
188 fn wontfix_requires_reason() {
189 let p = cfg();
190 let mut fm = Frontmatter::new();
191 let problems = evaluate(&p, "feature", "wontfix", &fm, "# F\n", &ids(&[]));
193 assert!(
194 problems.iter().any(|m| m.contains("wontfix_reason")),
195 "{problems:?}"
196 );
197 fm.set_str("wontfix_reason", "scope cut");
199 assert!(evaluate(&p, "feature", "wontfix", &fm, "# F\n", &ids(&[])).is_empty());
200 }
201
202 #[test]
203 fn implemented_requires_checked_test_plan() {
204 let p = cfg();
205 let fm = Frontmatter::new();
206 let bare = "# F\n\n## Test plan\n- [ ] todo\n";
207 assert!(evaluate(&p, "feature", "implemented", &fm, bare, &ids(&[]))
208 .iter()
209 .any(|m| m.contains("Test plan")));
210 let done = "# F\n\n## Test plan\n- [x] covered — `m::t`\n";
211 assert!(evaluate(&p, "feature", "implemented", &fm, done, &ids(&[])).is_empty());
212 }
213
214 #[test]
215 fn task_requires_feature_link_and_blocked_needs_reason_or_link() {
216 let p = cfg();
217 let mut fm = Frontmatter::new();
219 let problems = evaluate(&p, "task", "todo", &fm, "# T\n", &ids(&[]));
220 assert!(
221 problems.iter().any(|m| m.contains("type 'feature'")),
222 "{problems:?}"
223 );
224
225 refs::set(&mut fm, &[("FEAT-0001".into(), "F".into())]);
227 assert!(evaluate(&p, "task", "todo", &fm, "# T\n", &ids(&["FEAT-0001"])).is_empty());
228
229 let blocked = evaluate(&p, "task", "blocked", &fm, "# T\n", &ids(&["FEAT-0001"]));
231 assert!(
232 blocked.iter().any(|m| m.contains("requires one of")),
233 "{blocked:?}"
234 );
235 refs::set_in(&mut fm, "blocked_by", &[("FEAT-0002".into(), "B".into())]);
237 assert!(evaluate(
238 &p,
239 "task",
240 "blocked",
241 &fm,
242 "# T\n",
243 &ids(&["FEAT-0001", "FEAT-0002"])
244 )
245 .is_empty());
246 }
247
248 #[test]
249 fn when_tag_gates_a_rule_by_tag_key() {
250 let p: ProjectConfig = toml::from_str(
253 r#"
254[types.feature]
255prefix = "FEAT"
256statuses = ["planned"]
257default_status = "planned"
258[types.feature.fields.owner]
259type = "string"
260
261[[rules]]
262when = { tag = "area" }
263require_field = "owner"
264"#,
265 )
266 .unwrap();
267 assert!(p.validate().is_empty(), "{:?}", p.validate());
268
269 let mut fm = Frontmatter::new();
271 fm.set_tags(&["osc".into()]);
272 assert!(evaluate(&p, "feature", "planned", &fm, "# F\n", &ids(&[])).is_empty());
273
274 fm.set_tags(&["area:parsing".into()]);
276 let problems = evaluate(&p, "feature", "planned", &fm, "# F\n", &ids(&[]));
277 assert!(problems.iter().any(|m| m.contains("owner")), "{problems:?}");
278
279 fm.set_str("owner", "dan");
281 assert!(evaluate(&p, "feature", "planned", &fm, "# F\n", &ids(&[])).is_empty());
282 }
283
284 #[test]
285 fn require_any_accepts_a_tag_term() {
286 let p: ProjectConfig = toml::from_str(
287 r#"
288[types.feature]
289prefix = "FEAT"
290statuses = ["planned"]
291default_status = "planned"
292
293[[rules]]
294when = { status = "planned" }
295require_any = [ { tag = "area" }, { section = "Notes" } ]
296"#,
297 )
298 .unwrap();
299 assert!(p.validate().is_empty(), "{:?}", p.validate());
300
301 let mut fm = Frontmatter::new();
303 fm.set_tags(&["osc".into()]);
304 let problems = evaluate(&p, "feature", "planned", &fm, "# F\n", &ids(&[]));
305 assert!(
306 problems.iter().any(|m| m.contains("requires one of")),
307 "{problems:?}"
308 );
309
310 fm.set_tags(&["area=cli".into()]);
312 assert!(evaluate(&p, "feature", "planned", &fm, "# F\n", &ids(&[])).is_empty());
313 }
314
315 #[test]
316 fn rules_only_fire_for_their_type() {
317 let p = cfg();
318 let fm = Frontmatter::new();
319 let problems = evaluate(&p, "task", "wontfix", &fm, "# T\n", &ids(&["FEAT-0001"]));
322 assert!(
323 !problems.iter().any(|m| m.contains("wontfix_reason")),
324 "{problems:?}"
325 );
326 }
327}