use crate::error::{BeadsError, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
pub const POLICY_FILE_NAME: &str = "policy.yaml";
pub const ENV_AGENT_NAME: &str = "BR_AGENT_NAME";
pub const ENV_HARNESS: &str = "BR_HARNESS";
pub const ENV_MODEL: &str = "BR_MODEL";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct PolicyDocument {
pub close_policy: ClosePolicy,
pub workflow: Workflow,
#[serde(default = "default_true")]
pub allow_bypass: bool,
}
impl Default for PolicyDocument {
fn default() -> Self {
Self {
close_policy: ClosePolicy::default(),
workflow: Workflow::default(),
allow_bypass: default_true(),
}
}
}
const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ClosePolicy {
pub require_close_reason: RequireCloseReason,
pub require_acceptance_criteria_satisfied: ToggleGate,
pub forbid_self_close_after_in_progress: ToggleGate,
pub forbid_close_with_deferred_dependents: ToggleGate,
pub attribution: Attribution,
pub require_typed_references: RequireTypedReferences,
}
impl ClosePolicy {
#[must_use]
pub fn is_active(&self) -> bool {
self.require_close_reason.enabled
|| self.require_acceptance_criteria_satisfied.enabled
|| self.forbid_self_close_after_in_progress.enabled
|| self.forbid_close_with_deferred_dependents.enabled
|| self.require_typed_references.enabled
|| self.attribution.tier != AttributionTier::Off
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Workflow {
pub strict: bool,
#[serde(default)]
pub statuses: Vec<String>,
}
impl Workflow {
#[must_use]
pub fn is_enforced(&self) -> bool {
self.strict && !self.statuses.is_empty()
}
#[must_use]
pub fn allows(&self, status: &str) -> bool {
let target = status.to_lowercase();
self.statuses
.iter()
.any(|allowed| allowed.to_lowercase() == target)
}
#[must_use]
pub fn allowed_list(&self) -> String {
self.statuses.join(", ")
}
pub fn validate_status(&self, status: &str) -> Result<()> {
if !self.is_enforced() || self.allows(status) {
return Ok(());
}
Err(BeadsError::validation(
"status",
format!(
"status '{status}' is not permitted by the project workflow policy \
(.beads/policy.yaml workflow.strict). Allowed statuses: {}.",
self.allowed_list()
),
))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct RequireTypedReferences {
pub enabled: bool,
#[serde(default)]
pub required_kinds: Vec<String>,
}
const BUILTIN_TYPED_REFERENCE_KINDS: &[&str] = &[
"commit",
"pr",
"reviewer",
"investigation",
"agent-mail",
"dashboard",
];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ToggleGate {
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct RequireCloseReason {
pub enabled: bool,
pub min_length: usize,
pub regex: Option<String>,
}
impl Default for RequireCloseReason {
fn default() -> Self {
Self {
enabled: false,
min_length: 20,
regex: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Attribution {
pub tier: AttributionTier,
#[serde(default)]
pub fields: Vec<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AttributionTier {
#[default]
Off,
Capture,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AttributionValues {
pub agent_name: Option<String>,
pub harness: Option<String>,
pub model: Option<String>,
}
impl AttributionValues {
#[must_use]
pub fn resolve(
cli_agent_name: Option<&str>,
cli_harness: Option<&str>,
cli_model: Option<&str>,
env_lookup: &dyn Fn(&str) -> Option<String>,
) -> Self {
Self {
agent_name: cli_agent_name
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.or_else(|| env_lookup(ENV_AGENT_NAME).filter(|s| !s.trim().is_empty())),
harness: cli_harness
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.or_else(|| env_lookup(ENV_HARNESS).filter(|s| !s.trim().is_empty())),
model: cli_model
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.or_else(|| env_lookup(ENV_MODEL).filter(|s| !s.trim().is_empty())),
}
}
#[must_use]
pub fn resolve_from_env(
cli_agent_name: Option<&str>,
cli_harness: Option<&str>,
cli_model: Option<&str>,
) -> Self {
Self::resolve(cli_agent_name, cli_harness, cli_model, &|key| {
std::env::var(key).ok()
})
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.agent_name.is_none() && self.harness.is_none() && self.model.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyViolation {
pub gate: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default)]
pub struct CloseEvidence<'a> {
pub issue_id: &'a str,
pub close_reason: Option<&'a str>,
pub description: Option<&'a str>,
pub design: Option<&'a str>,
pub acceptance_criteria: Option<&'a str>,
pub notes: Option<&'a str>,
pub close_actor: &'a str,
pub in_progress_actor: Option<&'a str>,
}
#[must_use]
pub fn evaluate(policy: &ClosePolicy, evidence: &CloseEvidence<'_>) -> Vec<PolicyViolation> {
let mut violations = Vec::new();
if policy.require_close_reason.enabled {
evaluate_close_reason(&policy.require_close_reason, evidence, &mut violations);
}
if policy.require_acceptance_criteria_satisfied.enabled {
evaluate_acceptance_criteria(evidence, &mut violations);
}
if policy.forbid_self_close_after_in_progress.enabled {
evaluate_self_close(evidence, &mut violations);
}
if policy.require_typed_references.enabled {
evaluate_typed_references(&policy.require_typed_references, evidence, &mut violations);
}
violations
}
fn extract_typed_references(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
let preceding_ok =
i == 0 || matches!(bytes[i - 1], b' ' | b'\n' | b'\t' | b'(' | b'[' | b',');
if !preceding_ok {
i += 1;
continue;
}
if !bytes[i].is_ascii_lowercase() {
i += 1;
continue;
}
let kind_start = i;
while i < bytes.len()
&& (bytes[i].is_ascii_lowercase() || bytes[i] == b'-' || bytes[i].is_ascii_digit())
{
i += 1;
}
if i == kind_start || i >= bytes.len() || bytes[i] != b':' {
i += 1;
continue;
}
let kind = &text[kind_start..i];
i += 1; let value_start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b',' {
i += 1;
}
let value = &text[value_start..i];
if !value.is_empty() && kind.len() >= 2 {
out.push((kind.to_string(), value.to_string()));
}
}
out
}
fn required_typed_reference_description(rule: &RequireTypedReferences) -> String {
if rule.required_kinds.is_empty() {
BUILTIN_TYPED_REFERENCE_KINDS.join(", ")
} else {
rule.required_kinds.join(", ")
}
}
fn typed_reference_kind_satisfies_rule(kind: &str, rule: &RequireTypedReferences) -> bool {
if rule.required_kinds.is_empty() {
return BUILTIN_TYPED_REFERENCE_KINDS.contains(&kind);
}
rule.required_kinds.iter().any(|required| required == kind)
}
fn evaluate_typed_references(
rule: &RequireTypedReferences,
evidence: &CloseEvidence<'_>,
out: &mut Vec<PolicyViolation>,
) {
let reason_text = evidence.close_reason.unwrap_or("");
let refs = extract_typed_references(reason_text);
let required_description = required_typed_reference_description(rule);
if refs.is_empty() {
out.push(PolicyViolation {
gate: "typed_references_required".to_string(),
message: format!(
"close_reason has no typed references; policy requires at least one of: {}",
required_description
),
detail: Some(serde_json::json!({
"required_kinds": &rule.required_kinds,
"issue_id": evidence.issue_id,
})),
});
return;
}
let found_kinds: Vec<&str> = refs
.iter()
.map(|(kind, _)| kind.as_str())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let satisfied = found_kinds
.iter()
.any(|kind| typed_reference_kind_satisfies_rule(kind, rule));
if !satisfied {
out.push(PolicyViolation {
gate: "typed_references_required_kind_missing".to_string(),
message: format!(
"close_reason has typed refs ({}) but none satisfy the required kinds: {}",
found_kinds.join(", "),
required_description
),
detail: Some(serde_json::json!({
"required_kinds": &rule.required_kinds,
"found_kinds": found_kinds,
"issue_id": evidence.issue_id,
})),
});
}
}
fn evaluate_close_reason(
rule: &RequireCloseReason,
evidence: &CloseEvidence<'_>,
out: &mut Vec<PolicyViolation>,
) {
let reason_text = evidence.close_reason.map(str::trim).unwrap_or("");
let actual_len = reason_text.chars().count();
if rule.min_length > 0 && actual_len < rule.min_length {
out.push(PolicyViolation {
gate: "close_reason_min_length".to_string(),
message: format!(
"close_reason fails policy: minimum length is {} chars (got {})",
rule.min_length, actual_len
),
detail: Some(serde_json::json!({
"min_length": rule.min_length,
"actual_length": actual_len,
"issue_id": evidence.issue_id,
})),
});
}
if let Some(pattern) = rule.regex.as_deref() {
match Regex::new(pattern) {
Ok(re) => {
if !re.is_match(reason_text) {
out.push(PolicyViolation {
gate: "close_reason_regex".to_string(),
message: format!(
"close_reason fails policy: must match pattern '{pattern}'"
),
detail: Some(serde_json::json!({
"pattern": pattern,
"issue_id": evidence.issue_id,
})),
});
}
}
Err(err) => {
out.push(PolicyViolation {
gate: "close_reason_regex_invalid".to_string(),
message: format!(
"policy.yaml close_reason regex is invalid ('{pattern}'): {err}"
),
detail: Some(serde_json::json!({
"pattern": pattern,
"parse_error": err.to_string(),
})),
});
}
}
}
}
fn evaluate_acceptance_criteria(evidence: &CloseEvidence<'_>, out: &mut Vec<PolicyViolation>) {
let candidates = [
evidence.acceptance_criteria,
evidence.description,
evidence.design,
evidence.notes,
];
let mut unchecked: Vec<String> = Vec::new();
for body in candidates.into_iter().flatten() {
unchecked.extend(find_unchecked_acceptance_criteria(body));
}
unchecked.sort();
unchecked.dedup();
if !unchecked.is_empty() {
let preview: Vec<String> = unchecked.iter().take(3).cloned().collect();
let suffix = if unchecked.len() > preview.len() {
format!(" (+{} more)", unchecked.len() - preview.len())
} else {
String::new()
};
out.push(PolicyViolation {
gate: "acceptance_criteria_unchecked".to_string(),
message: format!(
"acceptance criteria policy: {} unchecked criteria remain: {}{}",
unchecked.len(),
preview.join("; "),
suffix
),
detail: Some(serde_json::json!({
"unchecked_count": unchecked.len(),
"unchecked_items": unchecked,
"issue_id": evidence.issue_id,
})),
});
}
}
fn evaluate_self_close(evidence: &CloseEvidence<'_>, out: &mut Vec<PolicyViolation>) {
let Some(in_progress_actor) = evidence.in_progress_actor else {
return;
};
if in_progress_actor.is_empty() || evidence.close_actor.is_empty() {
return;
}
if in_progress_actor == evidence.close_actor {
out.push(PolicyViolation {
gate: "forbid_self_close_after_in_progress".to_string(),
message: format!(
"actor policy: close.actor '{}' matches the actor who set in_progress; cross-validation required",
evidence.close_actor
),
detail: Some(serde_json::json!({
"close_actor": evidence.close_actor,
"in_progress_actor": in_progress_actor,
"issue_id": evidence.issue_id,
})),
});
}
}
pub const GATE_FORBID_CLOSE_WITH_DEFERRED_DEPENDENTS: &str =
"forbid_close_with_deferred_dependents";
#[must_use]
pub fn deferred_dependents_violation(
issue_id: &str,
deferred_dependent_ids: &[String],
) -> Option<PolicyViolation> {
if deferred_dependent_ids.is_empty() {
return None;
}
let mut ids: Vec<String> = deferred_dependent_ids.to_vec();
ids.sort();
ids.dedup();
let id_list = ids.join(", ");
let message = format!(
"deferred-dependents policy: cannot close {issue_id}: it has {count} deferred dependent(s): {id_list}. \
Reopen each (`br update <dep> --status=open`) or close-as-superseded with a duplicate_of edge \
before closing {issue_id}.",
count = ids.len(),
);
Some(PolicyViolation {
gate: GATE_FORBID_CLOSE_WITH_DEFERRED_DEPENDENTS.to_string(),
message,
detail: Some(serde_json::json!({
"issue_id": issue_id,
"deferred_dependents": ids,
"deferred_dependent_count": ids.len(),
})),
})
}
#[must_use]
pub fn find_unchecked_acceptance_criteria(body: &str) -> Vec<String> {
let body = body.trim();
if body.is_empty() {
return Vec::new();
}
let mut in_section = false;
let mut found_header = false;
let mut out: Vec<String> = Vec::new();
let has_any_header = has_markdown_heading_outside_fences(body);
if !has_any_header {
in_section = true;
}
let mut fence_marker = None;
for line in body.lines() {
if update_code_fence(line, &mut fence_marker) || fence_marker.is_some() {
continue;
}
let trimmed = line.trim_start();
if let Some(header_text) = markdown_heading_text(trimmed) {
let lower = header_text.to_ascii_lowercase();
if lower.contains("acceptance criteria") {
in_section = true;
found_header = true;
continue;
}
if found_header {
in_section = false;
}
continue;
}
if !in_section {
continue;
}
if let Some(item) = parse_unchecked_box(trimmed) {
out.push(item);
}
}
out
}
fn has_markdown_heading_outside_fences(body: &str) -> bool {
let mut fence_marker = None;
for line in body.lines() {
if update_code_fence(line, &mut fence_marker) || fence_marker.is_some() {
continue;
}
if markdown_heading_text(line).is_some() {
return true;
}
}
false
}
fn update_code_fence(line: &str, fence_marker: &mut Option<char>) -> bool {
let trimmed = line.trim_start();
let Some(marker @ ('`' | '~')) = trimmed.chars().next() else {
return false;
};
let marker_len = trimmed.chars().take_while(|ch| *ch == marker).count();
if marker_len < 3 {
return false;
}
if fence_marker.is_some_and(|open_marker| open_marker == marker) {
*fence_marker = None;
} else if fence_marker.is_none() {
*fence_marker = Some(marker);
}
true
}
fn markdown_heading_text(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
let level = trimmed
.as_bytes()
.iter()
.take_while(|byte| **byte == b'#')
.count();
if !(1..=6).contains(&level) {
return None;
}
let rest = trimmed.get(level..)?;
if rest.chars().next().is_some_and(|ch| !ch.is_whitespace()) {
return None;
}
Some(rest.trim())
}
fn parse_unchecked_box(line: &str) -> Option<String> {
let mut chars = line.chars().peekable();
let bullet = chars.next()?;
if !matches!(bullet, '-' | '*' | '+') {
return None;
}
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else {
break;
}
}
if chars.next()? != '[' {
return None;
}
let inner = chars.next()?;
let inner_is_unchecked = inner.is_whitespace() || inner == ' ';
if !inner_is_unchecked {
return None;
}
if chars.next()? != ']' {
return None;
}
let rest: String = chars.collect();
let rest = rest.trim().to_string();
Some(rest)
}
pub fn load_for_beads_dir(beads_dir: &Path) -> Result<PolicyDocument> {
let path = beads_dir.join(POLICY_FILE_NAME);
if !path.exists() {
return Ok(PolicyDocument::default());
}
let raw = fs::read_to_string(&path).map_err(BeadsError::from)?;
let document: PolicyDocument = serde_yml::from_str(&raw).map_err(|err| {
BeadsError::Config(format!("failed to parse {}: {}", path.display(), err))
})?;
if let Ok(raw_value) = serde_yml::from_str::<serde_yml::Value>(&raw) {
let unknown = detect_unknown_policy_fields(&raw_value);
if !unknown.is_empty() {
tracing::warn!(
policy_path = %path.display(),
unknown_fields = ?unknown,
"policy.yaml contains {} unknown field(s) under close_policy structs; \
these were ignored (beads_rust#302). Check for typos: {}",
unknown.len(),
unknown.join(", "),
);
}
}
Ok(document)
}
#[must_use]
pub fn detect_unknown_policy_fields(root: &serde_yml::Value) -> Vec<String> {
let mut unknown = Vec::new();
walk_policy_node(root, PolicyNode::Document, "", &mut unknown);
unknown.sort();
unknown.dedup();
unknown
}
#[derive(Clone, Copy, Debug)]
enum PolicyNode {
Document,
ClosePolicy,
RequireCloseReason,
ToggleGate,
Attribution,
RequireTypedReferences,
Workflow,
Scalar,
}
impl PolicyNode {
const fn child_table(self) -> &'static [(&'static str, Self)] {
match self {
Self::Document => &[
("close_policy", Self::ClosePolicy),
("workflow", Self::Workflow),
("allow_bypass", Self::Scalar),
],
Self::ClosePolicy => &[
("require_close_reason", Self::RequireCloseReason),
("require_acceptance_criteria_satisfied", Self::ToggleGate),
("forbid_self_close_after_in_progress", Self::ToggleGate),
("forbid_close_with_deferred_dependents", Self::ToggleGate),
("attribution", Self::Attribution),
("require_typed_references", Self::RequireTypedReferences),
],
Self::RequireCloseReason => &[
("enabled", Self::Scalar),
("min_length", Self::Scalar),
("regex", Self::Scalar),
],
Self::ToggleGate => &[("enabled", Self::Scalar)],
Self::Attribution => &[("tier", Self::Scalar), ("fields", Self::Scalar)],
Self::RequireTypedReferences => {
&[("enabled", Self::Scalar), ("required_kinds", Self::Scalar)]
}
Self::Workflow => &[("strict", Self::Scalar), ("statuses", Self::Scalar)],
Self::Scalar => &[],
}
}
}
fn walk_policy_node(
value: &serde_yml::Value,
node: PolicyNode,
scope: &str,
out: &mut Vec<String>,
) {
if matches!(node, PolicyNode::Scalar) {
return;
}
let Some(map) = value.as_mapping() else {
return;
};
let table = node.child_table();
for (key, sub) in map {
let Some(key_str) = key.as_str() else {
continue;
};
let path = if scope.is_empty() {
key_str.to_string()
} else {
format!("{scope}.{key_str}")
};
match table.iter().find(|(k, _)| *k == key_str) {
Some((_, child)) => walk_policy_node(sub, *child, &path, out),
None => out.push(path),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn evidence_with_reason<'a>(reason: &'a str, issue_id: &'a str) -> CloseEvidence<'a> {
CloseEvidence {
issue_id,
close_reason: Some(reason),
close_actor: "alice",
..Default::default()
}
}
#[test]
fn default_policy_is_inactive() {
let policy = ClosePolicy::default();
assert!(!policy.is_active());
let evidence = evidence_with_reason("anything", "bd-1");
assert!(evaluate(&policy, &evidence).is_empty());
}
#[test]
fn close_reason_min_length_rejects_short_reason() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 20;
let violations = evaluate(&policy, &evidence_with_reason("too short", "bd-1"));
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "close_reason_min_length");
assert!(violations[0].message.contains("minimum length is 20"));
assert!(violations[0].message.contains("got 9"));
}
#[test]
fn close_reason_min_length_counts_unicode_chars_not_bytes() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 4;
let violations = evaluate(&policy, &evidence_with_reason("😀😀😀😀", "bd-1"));
assert!(violations.is_empty(), "{:?}", violations);
}
#[test]
fn close_reason_min_length_zero_disables_length_check() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 0;
let violations = evaluate(&policy, &evidence_with_reason("", "bd-1"));
assert!(violations.is_empty());
}
#[test]
fn close_reason_min_length_treats_missing_reason_as_empty() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 5;
let evidence = CloseEvidence {
issue_id: "bd-1",
close_reason: None,
close_actor: "alice",
..Default::default()
};
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "close_reason_min_length");
}
#[test]
fn close_reason_regex_rejects_non_match() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 0;
policy.require_close_reason.regex = Some(r"^[A-Z][a-z]+: ".to_string());
let violations = evaluate(&policy, &evidence_with_reason("done", "bd-1"));
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "close_reason_regex");
}
#[test]
fn close_reason_regex_accepts_match() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 0;
policy.require_close_reason.regex = Some(r"^Fix: ".to_string());
let violations = evaluate(&policy, &evidence_with_reason("Fix: race in foo", "bd-1"));
assert!(violations.is_empty());
}
#[test]
fn close_reason_invalid_regex_surfaces_a_violation() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 0;
policy.require_close_reason.regex = Some("(unclosed".to_string());
let violations = evaluate(&policy, &evidence_with_reason("anything goes here", "bd-1"));
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "close_reason_regex_invalid");
}
#[test]
fn acceptance_criteria_unchecked_blocks_close() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let body = "## Acceptance Criteria\n- [x] First\n- [ ] Second\n- [ ] Third\n";
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some(body);
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "acceptance_criteria_unchecked");
assert!(
violations[0].message.contains("2 unchecked"),
"{}",
violations[0].message
);
}
#[test]
fn acceptance_criteria_passes_when_all_checked() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let body = "## Acceptance Criteria\n- [x] First\n- [X] Second\n";
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some(body);
assert!(evaluate(&policy, &evidence).is_empty());
}
#[test]
fn acceptance_criteria_only_scans_section_under_header() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let body = "## Notes\n- [ ] random todo not under AC\n## Acceptance Criteria\n- [x] all good\n## Out of section\n- [ ] this is ignored\n";
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some(body);
assert!(
evaluate(&policy, &evidence).is_empty(),
"TODO outside AC section should NOT block close"
);
}
#[test]
fn acceptance_criteria_does_not_treat_hash_references_as_section_headers() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let body =
"## Acceptance Criteria\n#123 tracks the rollout\n- [ ] Finish after the reference\n";
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some(body);
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "acceptance_criteria_unchecked");
assert!(violations[0].message.contains("Finish after the reference"));
}
#[test]
fn acceptance_criteria_ignores_section_headers_inside_fenced_code() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let body = "## Notes\n```markdown\n## Acceptance Criteria\n- [ ] Example only\n```\n## Acceptance Criteria\n- [x] Real criterion\n";
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some(body);
assert!(
evaluate(&policy, &evidence).is_empty(),
"unchecked boxes inside fenced examples should not block close"
);
}
#[test]
fn acceptance_criteria_ignores_unchecked_boxes_inside_fenced_code() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let body = "## Acceptance Criteria\n- [x] Real criterion\n```sh\n- [ ] Example only\n```\n";
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some(body);
assert!(
evaluate(&policy, &evidence).is_empty(),
"unchecked boxes inside fenced examples should not block close"
);
}
#[test]
fn acceptance_criteria_without_markdown_headers_scans_hash_prefixed_body() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.acceptance_criteria = Some("#123 follow-up\n- [ ] Finish referenced work\n");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "acceptance_criteria_unchecked");
assert!(violations[0].message.contains("Finish referenced work"));
}
#[test]
fn acceptance_criteria_dedupes_across_fields() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.description = Some("## Acceptance Criteria\n- [ ] First\n");
evidence.acceptance_criteria = Some("- [ ] First\n");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
let detail = violations[0].detail.as_ref().unwrap();
assert_eq!(detail["unchecked_count"], 1);
}
#[test]
fn acceptance_criteria_handles_acceptance_criteria_column_without_header() {
let mut policy = ClosePolicy::default();
policy.require_acceptance_criteria_satisfied.enabled = true;
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.acceptance_criteria = Some("- [x] First\n- [ ] Second\n");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("Second"));
}
#[test]
fn forbid_self_close_blocks_when_actors_match() {
let mut policy = ClosePolicy::default();
policy.forbid_self_close_after_in_progress.enabled = true;
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.close_actor = "alice";
evidence.in_progress_actor = Some("alice");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "forbid_self_close_after_in_progress");
}
#[test]
fn forbid_self_close_passes_when_actors_differ() {
let mut policy = ClosePolicy::default();
policy.forbid_self_close_after_in_progress.enabled = true;
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.close_actor = "alice";
evidence.in_progress_actor = Some("bob");
assert!(evaluate(&policy, &evidence).is_empty());
}
#[test]
fn forbid_self_close_passes_when_no_in_progress_recorded() {
let mut policy = ClosePolicy::default();
policy.forbid_self_close_after_in_progress.enabled = true;
let mut evidence = evidence_with_reason("done done done done done", "bd-1");
evidence.close_actor = "alice";
evidence.in_progress_actor = None;
assert!(evaluate(&policy, &evidence).is_empty());
}
#[test]
fn deferred_dependents_gate_default_off() {
let policy = ClosePolicy::default();
assert!(!policy.forbid_close_with_deferred_dependents.enabled);
assert!(!policy.is_active());
}
#[test]
fn deferred_dependents_gate_makes_policy_active_when_enabled() {
let policy = ClosePolicy {
forbid_close_with_deferred_dependents: ToggleGate { enabled: true },
..Default::default()
};
assert!(policy.is_active());
}
#[test]
fn deferred_dependents_violation_none_when_empty() {
assert!(deferred_dependents_violation("bd-1", &[]).is_none());
}
#[test]
fn deferred_dependents_violation_names_offending_ids() {
let violation =
deferred_dependents_violation("bd-1", &["bd-3".to_string(), "bd-2".to_string()])
.expect("violation expected");
assert_eq!(violation.gate, GATE_FORBID_CLOSE_WITH_DEFERRED_DEPENDENTS);
assert!(violation.message.contains("bd-2"), "{}", violation.message);
assert!(violation.message.contains("bd-3"), "{}", violation.message);
assert!(
violation.message.contains("2 deferred dependent"),
"{}",
violation.message
);
assert!(
violation.message.contains("br update <dep> --status=open"),
"{}",
violation.message
);
assert!(
violation.message.contains("duplicate_of"),
"{}",
violation.message
);
let detail = violation.detail.as_ref().unwrap();
assert_eq!(detail["issue_id"], "bd-1");
assert_eq!(detail["deferred_dependent_count"], 2);
assert_eq!(
detail["deferred_dependents"],
serde_json::json!(["bd-2", "bd-3"])
);
}
#[test]
fn deferred_dependents_violation_dedupes_ids() {
let violation = deferred_dependents_violation(
"bd-1",
&["bd-2".to_string(), "bd-2".to_string(), "bd-3".to_string()],
)
.expect("violation expected");
let detail = violation.detail.as_ref().unwrap();
assert_eq!(detail["deferred_dependent_count"], 2);
assert!(violation.message.contains("2 deferred dependent"));
}
#[test]
fn attribution_resolve_prefers_cli_over_env() {
let env = |key: &str| match key {
ENV_AGENT_NAME => Some("env-agent".to_string()),
ENV_HARNESS => Some("env-harness".to_string()),
ENV_MODEL => Some("env-model".to_string()),
_ => None,
};
let values = AttributionValues::resolve(Some("cli-agent"), Some("cli-harness"), None, &env);
assert_eq!(values.agent_name.as_deref(), Some("cli-agent"));
assert_eq!(values.harness.as_deref(), Some("cli-harness"));
assert_eq!(values.model.as_deref(), Some("env-model"));
}
#[test]
fn attribution_resolve_treats_blank_strings_as_absent() {
let env = |key: &str| {
if key == ENV_HARNESS {
Some(" ".to_string())
} else {
None
}
};
let values = AttributionValues::resolve(Some(""), None, None, &env);
assert!(values.agent_name.is_none());
assert!(
values.harness.is_none(),
"blank env var should not populate"
);
assert!(values.model.is_none());
assert!(values.is_empty());
}
#[test]
fn multiple_gates_aggregate_violations() {
let mut policy = ClosePolicy::default();
policy.require_close_reason.enabled = true;
policy.require_close_reason.min_length = 50;
policy.forbid_self_close_after_in_progress.enabled = true;
policy.require_acceptance_criteria_satisfied.enabled = true;
let body = "## Acceptance Criteria\n- [ ] Outstanding\n";
let mut evidence = evidence_with_reason("short", "bd-1");
evidence.description = Some(body);
evidence.close_actor = "alice";
evidence.in_progress_actor = Some("alice");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 3);
let gates: Vec<&str> = violations.iter().map(|v| v.gate.as_str()).collect();
assert!(gates.contains(&"close_reason_min_length"));
assert!(gates.contains(&"acceptance_criteria_unchecked"));
assert!(gates.contains(&"forbid_self_close_after_in_progress"));
}
#[test]
fn loader_returns_default_when_file_absent() {
let dir = tempfile::tempdir().expect("tempdir");
let policy = load_for_beads_dir(dir.path()).expect("load");
assert_eq!(policy, PolicyDocument::default());
assert!(!policy.close_policy.is_active());
assert!(policy.allow_bypass);
}
#[test]
fn loader_parses_full_document() {
let dir = tempfile::tempdir().expect("tempdir");
let yaml = r#"
close_policy:
require_close_reason:
enabled: true
min_length: 30
regex: "^Fix: "
require_acceptance_criteria_satisfied:
enabled: true
forbid_self_close_after_in_progress:
enabled: true
require_typed_references:
enabled: true
required_kinds: ["commit", "reviewer"]
attribution:
tier: capture
fields: ["agent_name", "harness", "model"]
allow_bypass: false
"#;
std::fs::write(dir.path().join(POLICY_FILE_NAME), yaml).unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load");
assert!(policy.close_policy.require_close_reason.enabled);
assert_eq!(policy.close_policy.require_close_reason.min_length, 30);
assert_eq!(
policy.close_policy.require_close_reason.regex.as_deref(),
Some("^Fix: ")
);
assert!(
policy
.close_policy
.require_acceptance_criteria_satisfied
.enabled
);
assert!(
policy
.close_policy
.forbid_self_close_after_in_progress
.enabled
);
assert!(policy.close_policy.require_typed_references.enabled);
assert_eq!(
policy.close_policy.require_typed_references.required_kinds,
vec!["commit".to_string(), "reviewer".to_string()]
);
assert_eq!(
policy.close_policy.attribution.tier,
AttributionTier::Capture
);
assert!(!policy.allow_bypass);
assert!(policy.close_policy.is_active());
}
#[test]
fn loader_tolerates_unknown_top_level_keys() {
let dir = tempfile::tempdir().expect("tempdir");
let yaml = "unknown_key: 1\nclose_policy:\n require_close_reason:\n enabled: true\n";
std::fs::write(dir.path().join(POLICY_FILE_NAME), yaml).unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load must succeed");
assert!(
policy.close_policy.require_close_reason.enabled,
"known fields must still parse"
);
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
let unknown = detect_unknown_policy_fields(&raw);
assert_eq!(unknown, vec!["unknown_key".to_string()]);
}
#[test]
fn loader_tolerates_unknown_field_under_close_policy() {
let dir = tempfile::tempdir().expect("tempdir");
let yaml = r"
close_policy:
require_close_reason:
enabled: true
min_length: 20
require_new_experimental_field:
enabled: true
";
std::fs::write(dir.path().join(POLICY_FILE_NAME), yaml).unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load must succeed");
assert!(policy.close_policy.require_close_reason.enabled);
assert_eq!(policy.close_policy.require_close_reason.min_length, 20);
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
let unknown = detect_unknown_policy_fields(&raw);
assert_eq!(
unknown,
vec!["close_policy.require_new_experimental_field".to_string()]
);
}
#[test]
fn detect_unknown_policy_fields_walks_nested_structs() {
let yaml = r#"
close_policy:
require_close_reason:
enabled: true
min_lenght: 20 # typo: should be min_length
attribution:
tier: capture
fileds: ["agent_name"] # typo: should be fields
"#;
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
let unknown = detect_unknown_policy_fields(&raw);
assert_eq!(
unknown,
vec![
"close_policy.attribution.fileds".to_string(),
"close_policy.require_close_reason.min_lenght".to_string(),
]
);
}
#[test]
fn detect_unknown_policy_fields_is_empty_for_canonical_doc() {
let yaml = r#"
allow_bypass: false
close_policy:
require_close_reason:
enabled: true
min_length: 30
regex: "^Fix: "
require_acceptance_criteria_satisfied:
enabled: true
forbid_self_close_after_in_progress:
enabled: true
require_typed_references:
enabled: true
required_kinds: ["commit", "reviewer"]
attribution:
tier: capture
fields: ["agent_name", "harness", "model"]
"#;
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
assert!(detect_unknown_policy_fields(&raw).is_empty());
}
#[test]
fn loader_accepts_empty_document() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join(POLICY_FILE_NAME), "{}\n").unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load");
assert_eq!(policy, PolicyDocument::default());
}
#[test]
fn parse_unchecked_box_recognises_variants() {
assert_eq!(
parse_unchecked_box("- [ ] todo item").as_deref(),
Some("todo item")
);
assert_eq!(
parse_unchecked_box("* [ ] starred").as_deref(),
Some("starred")
);
assert_eq!(parse_unchecked_box("+ [ ] plus").as_deref(), Some("plus"));
assert!(parse_unchecked_box("- [x] checked").is_none());
assert!(parse_unchecked_box("- [X] checked").is_none());
assert!(parse_unchecked_box("plain text").is_none());
assert!(parse_unchecked_box("- not a box").is_none());
}
#[test]
fn extract_typed_references_finds_kind_value_pairs() {
let refs = extract_typed_references("Fixed in commit:abc123 per reviewer:bob");
assert_eq!(
refs,
vec![
("commit".to_string(), "abc123".to_string()),
("reviewer".to_string(), "bob".to_string()),
]
);
}
#[test]
fn extract_typed_references_handles_hyphenated_kinds() {
let refs = extract_typed_references("see agent-mail:thread-xyz for context");
assert_eq!(
refs,
vec![("agent-mail".to_string(), "thread-xyz".to_string())]
);
}
#[test]
fn extract_typed_references_skips_prose_with_colons() {
let refs = extract_typed_references("note: this is a regular sentence");
assert!(refs.is_empty(), "got {refs:?}");
}
#[test]
fn extract_typed_references_requires_letter_start() {
let refs = extract_typed_references("bad refs: 12:abc and -kind:value");
assert!(refs.is_empty(), "got {refs:?}");
}
#[test]
fn typed_references_gate_rejects_when_none_present() {
let policy = ClosePolicy {
require_typed_references: RequireTypedReferences {
enabled: true,
required_kinds: vec![],
},
..Default::default()
};
let evidence = evidence_with_reason("just plain prose with no refs at all", "bd-1");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "typed_references_required");
}
#[test]
fn typed_references_gate_empty_required_kinds_accepts_builtin_kind() {
let policy = ClosePolicy {
require_typed_references: RequireTypedReferences {
enabled: true,
required_kinds: vec![],
},
..Default::default()
};
let evidence = evidence_with_reason("Fixed in reviewer:bob", "bd-1");
assert!(evaluate(&policy, &evidence).is_empty());
}
#[test]
fn typed_references_gate_empty_required_kinds_rejects_unknown_kind() {
let policy = ClosePolicy {
require_typed_references: RequireTypedReferences {
enabled: true,
required_kinds: vec![],
},
..Default::default()
};
let evidence = evidence_with_reason("Captured in tracker:ABC-123", "bd-1");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "typed_references_required_kind_missing");
}
#[test]
fn typed_references_gate_empty_required_kinds_rejects_bare_url() {
let policy = ClosePolicy {
require_typed_references: RequireTypedReferences {
enabled: true,
required_kinds: vec![],
},
..Default::default()
};
let evidence = evidence_with_reason("Evidence: https://example.invalid/report", "bd-1");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "typed_references_required_kind_missing");
}
#[test]
fn typed_references_gate_accepts_when_kind_matches() {
let policy = ClosePolicy {
require_typed_references: RequireTypedReferences {
enabled: true,
required_kinds: vec!["commit".to_string()],
},
..Default::default()
};
let evidence = evidence_with_reason("Fixed in commit:abc12345", "bd-1");
assert!(evaluate(&policy, &evidence).is_empty());
}
#[test]
fn typed_references_gate_rejects_wrong_kind() {
let policy = ClosePolicy {
require_typed_references: RequireTypedReferences {
enabled: true,
required_kinds: vec!["commit".to_string()],
},
..Default::default()
};
let evidence = evidence_with_reason("see investigation:linear-XYZ-42 for details", "bd-1");
let violations = evaluate(&policy, &evidence);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].gate, "typed_references_required_kind_missing");
}
#[test]
fn policy_node_child_table_covers_every_typed_struct_field() {
fn field_names_of<T: serde::Serialize + Default>() -> Vec<String> {
let value =
serde_yml::to_value(T::default()).expect("default struct must serialise to value");
let mapping = value
.as_mapping()
.expect("default struct must serialise as a mapping");
mapping
.iter()
.filter_map(|(k, _)| k.as_str().map(String::from))
.collect()
}
fn assert_table_covers(node: PolicyNode, struct_fields: &[String], struct_name: &str) {
let table_keys: std::collections::HashSet<&'static str> =
node.child_table().iter().map(|(k, _)| *k).collect();
for field in struct_fields {
assert!(
table_keys.contains(field.as_str()),
"PolicyNode::{node:?}::child_table() is missing key `{field}` declared on \
struct `{struct_name}`. `detect_unknown_policy_fields` would emit a \
FALSE-POSITIVE 'unknown field' warning on every canonical policy.yaml that \
uses this field. Add the entry to `child_table()` (see beads_rust#302).",
);
}
}
assert_table_covers(
PolicyNode::Document,
&field_names_of::<PolicyDocument>(),
"PolicyDocument",
);
assert_table_covers(
PolicyNode::ClosePolicy,
&field_names_of::<ClosePolicy>(),
"ClosePolicy",
);
assert_table_covers(
PolicyNode::RequireCloseReason,
&field_names_of::<RequireCloseReason>(),
"RequireCloseReason",
);
assert_table_covers(
PolicyNode::ToggleGate,
&field_names_of::<ToggleGate>(),
"ToggleGate",
);
assert_table_covers(
PolicyNode::Attribution,
&field_names_of::<Attribution>(),
"Attribution",
);
assert_table_covers(
PolicyNode::RequireTypedReferences,
&field_names_of::<RequireTypedReferences>(),
"RequireTypedReferences",
);
assert_table_covers(
PolicyNode::Workflow,
&field_names_of::<Workflow>(),
"Workflow",
);
}
#[test]
fn policy_node_child_table_has_no_stale_entries() {
fn field_names_of<T: serde::Serialize + Default>() -> std::collections::HashSet<String> {
let value =
serde_yml::to_value(T::default()).expect("default struct must serialise to value");
let mapping = value
.as_mapping()
.expect("default struct must serialise as a mapping");
mapping
.iter()
.filter_map(|(k, _)| k.as_str().map(String::from))
.collect()
}
fn assert_no_stale(
node: PolicyNode,
struct_fields: &std::collections::HashSet<String>,
struct_name: &str,
) {
for (key, _) in node.child_table() {
assert!(
struct_fields.contains(*key),
"PolicyNode::{node:?}::child_table() lists key `{key}` that does not exist \
on struct `{struct_name}`. A typo of this key in policy.yaml would NOT be \
reported as unknown even though it is silently ignored by the typed parse \
(see beads_rust#302).",
);
}
}
assert_no_stale(
PolicyNode::Document,
&field_names_of::<PolicyDocument>(),
"PolicyDocument",
);
assert_no_stale(
PolicyNode::ClosePolicy,
&field_names_of::<ClosePolicy>(),
"ClosePolicy",
);
assert_no_stale(
PolicyNode::RequireCloseReason,
&field_names_of::<RequireCloseReason>(),
"RequireCloseReason",
);
assert_no_stale(
PolicyNode::ToggleGate,
&field_names_of::<ToggleGate>(),
"ToggleGate",
);
assert_no_stale(
PolicyNode::Attribution,
&field_names_of::<Attribution>(),
"Attribution",
);
assert_no_stale(
PolicyNode::RequireTypedReferences,
&field_names_of::<RequireTypedReferences>(),
"RequireTypedReferences",
);
assert_no_stale(
PolicyNode::Workflow,
&field_names_of::<Workflow>(),
"Workflow",
);
}
fn strict_workflow() -> Workflow {
Workflow {
strict: true,
statuses: vec![
"open".to_string(),
"in_progress".to_string(),
"closed".to_string(),
],
}
}
#[test]
fn workflow_default_is_not_enforced() {
let workflow = Workflow::default();
assert!(!workflow.is_enforced());
assert!(workflow.validate_status("anything-at-all").is_ok());
}
#[test]
fn workflow_strict_but_empty_statuses_is_not_enforced() {
let workflow = Workflow {
strict: true,
statuses: vec![],
};
assert!(!workflow.is_enforced());
assert!(workflow.validate_status("bogus").is_ok());
}
#[test]
fn workflow_rejects_status_outside_the_set() {
let workflow = strict_workflow();
let err = workflow
.validate_status("completed")
.expect_err("out-of-set status must be rejected");
let message = err.to_string();
assert!(message.contains("completed"), "{message}");
assert!(message.contains("open"), "{message}");
assert!(message.contains("in_progress"), "{message}");
assert!(message.contains("closed"), "{message}");
}
#[test]
fn workflow_allows_status_in_the_set() {
let workflow = strict_workflow();
assert!(workflow.validate_status("open").is_ok());
assert!(workflow.validate_status("in_progress").is_ok());
assert!(workflow.validate_status("closed").is_ok());
}
#[test]
fn workflow_status_match_is_case_insensitive() {
let workflow = Workflow {
strict: true,
statuses: vec!["In_Progress".to_string()],
};
assert!(workflow.allows("in_progress"));
assert!(workflow.validate_status("in_progress").is_ok());
}
#[test]
fn workflow_supports_custom_statuses() {
let workflow = Workflow {
strict: true,
statuses: vec!["open".to_string(), "in_review".to_string()],
};
assert!(workflow.validate_status("in_review").is_ok());
assert!(workflow.validate_status("blocked").is_err());
}
#[test]
fn loader_parses_workflow_section() {
let dir = tempfile::tempdir().expect("tempdir");
let yaml = r#"
workflow:
strict: true
statuses: ["open", "in_progress", "closed"]
"#;
std::fs::write(dir.path().join(POLICY_FILE_NAME), yaml).unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load");
assert!(policy.workflow.is_enforced());
assert_eq!(
policy.workflow.statuses,
vec![
"open".to_string(),
"in_progress".to_string(),
"closed".to_string()
]
);
assert!(policy.workflow.validate_status("open").is_ok());
assert!(policy.workflow.validate_status("completed").is_err());
}
#[test]
fn loader_absent_workflow_section_is_permissive() {
let dir = tempfile::tempdir().expect("tempdir");
let yaml = "close_policy:\n forbid_self_close_after_in_progress:\n enabled: true\n";
std::fs::write(dir.path().join(POLICY_FILE_NAME), yaml).unwrap();
let policy = load_for_beads_dir(dir.path()).expect("load");
assert!(!policy.workflow.is_enforced());
assert!(policy.workflow.validate_status("whatever").is_ok());
}
#[test]
fn detect_unknown_policy_fields_walks_workflow_typos() {
let yaml = r#"
workflow:
strict: true
statusses: ["open"] # typo: should be statuses
"#;
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
let unknown = detect_unknown_policy_fields(&raw);
assert_eq!(unknown, vec!["workflow.statusses".to_string()]);
}
#[test]
fn detect_unknown_policy_fields_accepts_canonical_workflow() {
let yaml = r#"
workflow:
strict: true
statuses: ["open", "closed"]
"#;
let raw: serde_yml::Value = serde_yml::from_str(yaml).unwrap();
assert!(detect_unknown_policy_fields(&raw).is_empty());
}
}