Skip to main content

opys_engine/
rules.rs

1//! The conditional validation-rules engine for the universal config.
2//!
3//! [`evaluate`] runs a project's `[[rules]]` against one document and returns a
4//! problem message for each failed assertion. It is the single place the
5//! configured guards are checked; later phases call it from `new`,
6//! `set-status`, `import`, and `verify` (today nothing in production calls it —
7//! it ships unit-tested, ready to wire in).
8//!
9//! A rule fires when its `when { type?, status?, tag? }` matches the document,
10//! then its one assertion (from the closed set) is checked.
11
12use 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
21/// Evaluate every applicable rule against one document. `doc_ids` is the set of
22/// ids a link may resolve to (the live docs), exactly as `verify` builds it.
23pub 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    // The type-level `requires_link` shorthand (sugar for an always-on
33    // require_link rule on this type).
34    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
54/// A rule applies when its (optional) type, status, and tag guards all match.
55fn 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
61/// Check a rule's single assertion; `Some(msg)` if it fails.
62fn 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        // Validate the value only when present (presence is require_field's job).
76        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
111/// A field is "set" when present and not null / not an empty string.
112fn 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
120/// Count references (in the `references` map) to live docs of `to_type`.
121fn 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
136/// One `require_any` term holds: a set field, a non-empty relation link, or a
137/// present section.
138fn 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        // No wontfix_reason → fails.
192        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        // With a reason → clean.
198        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        // No references → the require_link rule fails.
218        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        // Link a live feature → require_link satisfied.
226        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        // blocked with neither reason nor blocker link → require_any fails.
230        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        // A blocked_by link satisfies it.
236        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        // The rule fires only for docs carrying an `area` tag (exact or key),
251        // and then requires the `owner` field.
252        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        // No `area` tag → rule does not fire, even without `owner`.
270        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        // `area:parsing` matches the `area` key → rule fires, `owner` missing.
275        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        // Add `owner` → clean.
280        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        // Neither a matching tag nor the section → fails.
302        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        // An `area=cli` tag satisfies the tag term.
311        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        // The feature wontfix rule must not fire for a task in status "wontfix"
320        // (tasks don't even have that status, but the engine just checks `when`).
321        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}