use std::collections::HashSet;
use regex::Regex;
use serde_norway::Value;
use crate::frontmatter::Frontmatter;
use crate::project_config::{AnyTerm, ProjectConfig, Rule};
use crate::{body, refs};
pub fn evaluate(
prj: &ProjectConfig,
type_name: &str,
status: &str,
fm: &Frontmatter,
body: &str,
doc_ids: &HashSet<String>,
) -> Vec<String> {
let mut out = Vec::new();
if let Some(dt) = prj.types.get(type_name) {
if let Some(lr) = &dt.requires_link {
if resolved_links(prj, fm, &lr.to, doc_ids) < lr.min {
out.push(format!(
"must reference at least {} doc(s) of type '{}'",
lr.min, lr.to
));
}
}
}
for rule in &prj.rules {
if applies(rule, type_name, status, fm) {
if let Some(msg) = check(prj, rule, fm, body, doc_ids) {
out.push(msg);
}
}
}
out
}
fn applies(rule: &Rule, type_name: &str, status: &str, fm: &Frontmatter) -> bool {
rule.when.doc_type.as_deref().is_none_or(|t| t == type_name)
&& rule.when.status.as_deref().is_none_or(|s| s == status)
&& rule.when.tag.as_deref().is_none_or(|t| fm.has_tag(t))
}
fn check(
prj: &ProjectConfig,
rule: &Rule,
fm: &Frontmatter,
body: &str,
doc_ids: &HashSet<String>,
) -> Option<String> {
if let Some(f) = &rule.require_field {
if !field_present(fm, f) {
return Some(format!("field '{f}' is required"));
}
}
if let Some(m) = &rule.field_matches {
if let Some(s) = fm.get_str(&m.field) {
if Regex::new(&m.pattern)
.map(|re| !re.is_match(s))
.unwrap_or(false)
{
return Some(format!("field '{}' must match /{}/", m.field, m.pattern));
}
}
}
if let Some(h) = &rule.require_section {
if !body::has_section(body, h) {
return Some(format!("missing required '## {h}' section"));
}
}
if let Some(h) = &rule.require_checked_section {
if !body::checklist_items(body, h).iter().any(|i| i.checked) {
return Some(format!("'## {h}' needs at least one checked item"));
}
}
if let Some(lr) = &rule.require_link {
if resolved_links(prj, fm, &lr.to, doc_ids) < lr.min {
return Some(format!(
"must reference at least {} doc(s) of type '{}'",
lr.min, lr.to
));
}
}
if let Some(terms) = &rule.require_any {
if !terms.iter().any(|t| any_term_holds(t, fm, body)) {
return Some(format!("requires one of: {}", describe_any(terms)));
}
}
None
}
fn field_present(fm: &Frontmatter, name: &str) -> bool {
match fm.get(name) {
None | Some(Value::Null) => false,
Some(Value::String(s)) => !s.trim().is_empty(),
Some(_) => true,
}
}
fn resolved_links(
prj: &ProjectConfig,
fm: &Frontmatter,
to_type: &str,
doc_ids: &HashSet<String>,
) -> usize {
match prj.types.get(to_type) {
Some(dt) => refs::ids_with_prefix(fm, &dt.prefix)
.iter()
.filter(|id| doc_ids.contains(*id))
.count(),
None => 0,
}
}
fn any_term_holds(term: &AnyTerm, fm: &Frontmatter, body: &str) -> bool {
if let Some(f) = &term.field {
return field_present(fm, f);
}
if let Some(l) = &term.link {
return !refs::parse_in(fm, l).is_empty();
}
if let Some(s) = &term.section {
return body::has_section(body, s);
}
if let Some(t) = &term.tag {
return fm.has_tag(t);
}
false
}
fn describe_any(terms: &[AnyTerm]) -> String {
terms
.iter()
.map(|t| {
if let Some(f) = &t.field {
format!("field '{f}'")
} else if let Some(l) = &t.link {
format!("link '{l}'")
} else if let Some(s) = &t.section {
format!("section '{s}'")
} else if let Some(tag) = &t.tag {
format!("tag '{tag}'")
} else {
"?".to_string()
}
})
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project_config::ProjectConfig;
use crate::templates::DEFAULT_OPYS_CONFIG;
fn cfg() -> ProjectConfig {
toml::from_str(DEFAULT_OPYS_CONFIG).unwrap()
}
fn ids(list: &[&str]) -> HashSet<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn wontfix_requires_reason() {
let p = cfg();
let mut fm = Frontmatter::new();
let problems = evaluate(&p, "feature", "wontfix", &fm, "# F\n", &ids(&[]));
assert!(
problems.iter().any(|m| m.contains("wontfix_reason")),
"{problems:?}"
);
fm.set_str("wontfix_reason", "scope cut");
assert!(evaluate(&p, "feature", "wontfix", &fm, "# F\n", &ids(&[])).is_empty());
}
#[test]
fn implemented_requires_checked_test_plan() {
let p = cfg();
let fm = Frontmatter::new();
let bare = "# F\n\n## Test plan\n- [ ] todo\n";
assert!(evaluate(&p, "feature", "implemented", &fm, bare, &ids(&[]))
.iter()
.any(|m| m.contains("Test plan")));
let done = "# F\n\n## Test plan\n- [x] covered — `m::t`\n";
assert!(evaluate(&p, "feature", "implemented", &fm, done, &ids(&[])).is_empty());
}
#[test]
fn task_requires_feature_link_and_blocked_needs_reason_or_link() {
let p = cfg();
let mut fm = Frontmatter::new();
let problems = evaluate(&p, "task", "todo", &fm, "# T\n", &ids(&[]));
assert!(
problems.iter().any(|m| m.contains("type 'feature'")),
"{problems:?}"
);
refs::set(&mut fm, &[("FEAT-0001".into(), "F".into())]);
assert!(evaluate(&p, "task", "todo", &fm, "# T\n", &ids(&["FEAT-0001"])).is_empty());
let blocked = evaluate(&p, "task", "blocked", &fm, "# T\n", &ids(&["FEAT-0001"]));
assert!(
blocked.iter().any(|m| m.contains("requires one of")),
"{blocked:?}"
);
refs::set_in(&mut fm, "blocked_by", &[("FEAT-0002".into(), "B".into())]);
assert!(evaluate(
&p,
"task",
"blocked",
&fm,
"# T\n",
&ids(&["FEAT-0001", "FEAT-0002"])
)
.is_empty());
}
#[test]
fn when_tag_gates_a_rule_by_tag_key() {
let p: ProjectConfig = toml::from_str(
r#"
[types.feature]
prefix = "FEAT"
statuses = ["planned"]
default_status = "planned"
[types.feature.fields.owner]
type = "string"
[[rules]]
when = { tag = "area" }
require_field = "owner"
"#,
)
.unwrap();
assert!(p.validate().is_empty(), "{:?}", p.validate());
let mut fm = Frontmatter::new();
fm.set_tags(&["osc".into()]);
assert!(evaluate(&p, "feature", "planned", &fm, "# F\n", &ids(&[])).is_empty());
fm.set_tags(&["area:parsing".into()]);
let problems = evaluate(&p, "feature", "planned", &fm, "# F\n", &ids(&[]));
assert!(problems.iter().any(|m| m.contains("owner")), "{problems:?}");
fm.set_str("owner", "dan");
assert!(evaluate(&p, "feature", "planned", &fm, "# F\n", &ids(&[])).is_empty());
}
#[test]
fn require_any_accepts_a_tag_term() {
let p: ProjectConfig = toml::from_str(
r#"
[types.feature]
prefix = "FEAT"
statuses = ["planned"]
default_status = "planned"
[[rules]]
when = { status = "planned" }
require_any = [ { tag = "area" }, { section = "Notes" } ]
"#,
)
.unwrap();
assert!(p.validate().is_empty(), "{:?}", p.validate());
let mut fm = Frontmatter::new();
fm.set_tags(&["osc".into()]);
let problems = evaluate(&p, "feature", "planned", &fm, "# F\n", &ids(&[]));
assert!(
problems.iter().any(|m| m.contains("requires one of")),
"{problems:?}"
);
fm.set_tags(&["area=cli".into()]);
assert!(evaluate(&p, "feature", "planned", &fm, "# F\n", &ids(&[])).is_empty());
}
#[test]
fn rules_only_fire_for_their_type() {
let p = cfg();
let fm = Frontmatter::new();
let problems = evaluate(&p, "task", "wontfix", &fm, "# T\n", &ids(&["FEAT-0001"]));
assert!(
!problems.iter().any(|m| m.contains("wontfix_reason")),
"{problems:?}"
);
}
}