use std::collections::BTreeSet;
use super::config::{SUPPORTED_VERSION, StateModel};
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub(crate) enum ModelError {
#[error("unsupported schema version {found}; this build supports version {supported}")]
UnsupportedVersion {
found: u32,
supported: u32,
},
#[error("state model has no states; at least one state is required")]
NoStates,
#[error("duplicate state name `{0}`; state names must be unique")]
DuplicateState(String),
#[error("transition {from} → `{to}` references unknown state `{missing}`")]
DanglingTransition {
from: String,
to: String,
missing: String,
},
#[error("label `{label}` has invalid color `{color}`; expected 6 hex digits with no `#`")]
BadColor {
label: String,
color: String,
},
#[error("terminal state `{0}` must have no outbound transitions")]
TerminalHasOutbound(String),
#[error("assignee strategy `bot` requires an `identity_pattern` (or `identity_example`)")]
BotStrategyMissingIdentity,
}
fn is_six_hex(s: &str) -> bool {
s.len() == 6 && s.chars().all(|c| c.is_ascii_hexdigit())
}
pub(crate) fn validate_model(model: &StateModel) -> anyhow::Result<()> {
validate_model_inner(model).map_err(|e| anyhow::anyhow!(e))
}
fn validate_model_inner(model: &StateModel) -> Result<(), ModelError> {
if model.version != SUPPORTED_VERSION {
return Err(ModelError::UnsupportedVersion {
found: model.version,
supported: SUPPORTED_VERSION,
});
}
if model.states.is_empty() {
return Err(ModelError::NoStates);
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
for s in &model.states {
if !seen.insert(s.name.as_str()) {
return Err(ModelError::DuplicateState(s.name.clone()));
}
}
for t in &model.transitions {
if let Some(from) = &t.from
&& !seen.contains(from.as_str())
{
return Err(ModelError::DanglingTransition {
from: format!("`{from}`"),
to: t.to.clone(),
missing: from.clone(),
});
}
if !seen.contains(t.to.as_str()) {
return Err(ModelError::DanglingTransition {
from: t
.from
.as_ref()
.map(|f| format!("`{f}`"))
.unwrap_or_else(|| "null".to_string()),
to: t.to.clone(),
missing: t.to.clone(),
});
}
}
for s in &model.states {
if !is_six_hex(&s.label.color) {
return Err(ModelError::BadColor {
label: s.label.name.clone(),
color: s.label.color.clone(),
});
}
}
for l in &model.extra_labels {
if !is_six_hex(&l.color) {
return Err(ModelError::BadColor {
label: l.name.clone(),
color: l.color.clone(),
});
}
}
for s in &model.states {
if s.terminal
&& model
.transitions
.iter()
.any(|t| t.from.as_deref() == Some(s.name.as_str()))
{
return Err(ModelError::TerminalHasOutbound(s.name.clone()));
}
}
if model.assignee_model.strategy == "bot"
&& model.assignee_model.identity_pattern.is_none()
&& model.assignee_model.identity_example.is_none()
{
return Err(ModelError::BotStrategyMissingIdentity);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::super::config::DEFAULT_MODEL_YAML;
use super::*;
fn default_model() -> StateModel {
serde_yaml::from_str(DEFAULT_MODEL_YAML).expect("default parses")
}
#[test]
fn validate_default_ok() {
assert!(validate_model_inner(&default_model()).is_ok());
}
#[test]
fn validate_rejects_unknown_version() {
let mut m = default_model();
m.version = 99;
assert_eq!(
validate_model_inner(&m),
Err(ModelError::UnsupportedVersion {
found: 99,
supported: SUPPORTED_VERSION
})
);
}
#[test]
fn validate_rejects_duplicate_states() {
let mut m = default_model();
let dup = m.states[0].clone();
m.states.push(dup);
let name = m.states[0].name.clone();
assert_eq!(
validate_model_inner(&m),
Err(ModelError::DuplicateState(name))
);
}
#[test]
fn validate_rejects_dangling_transition_to() {
let mut m = default_model();
m.transitions[1].to = "nonexistent".to_string();
let err = validate_model_inner(&m).unwrap_err();
assert!(
matches!(err, ModelError::DanglingTransition { ref missing, .. } if missing == "nonexistent"),
"got: {err:?}"
);
}
#[test]
fn validate_rejects_dangling_transition_from() {
let mut m = default_model();
m.transitions[1].from = Some("ghost".to_string());
let err = validate_model_inner(&m).unwrap_err();
assert!(
matches!(err, ModelError::DanglingTransition { ref missing, .. } if missing == "ghost"),
"got: {err:?}"
);
}
#[test]
fn validate_rejects_bad_hex() {
let mut m = default_model();
m.states[0].label.color = "ZZZ".to_string();
let err = validate_model_inner(&m).unwrap_err();
assert!(matches!(err, ModelError::BadColor { .. }), "got: {err:?}");
}
#[test]
fn validate_rejects_bad_hex_in_extra_label() {
let mut m = default_model();
m.extra_labels[0].color = "12345".to_string(); let err = validate_model_inner(&m).unwrap_err();
assert!(matches!(err, ModelError::BadColor { .. }), "got: {err:?}");
}
#[test]
fn validate_rejects_terminal_with_outbound_edge() {
let mut m = default_model();
m.transitions.push(super::super::config::Transition {
from: Some("done".to_string()),
to: "queued".to_string(),
trigger: super::super::config::Trigger::HumanLabel,
description: String::new(),
});
assert_eq!(
validate_model_inner(&m),
Err(ModelError::TerminalHasOutbound("done".to_string()))
);
}
#[test]
fn validate_rejects_terminal_with_outbound_edge_for_any_state() {
let mut m = default_model();
for s in &mut m.states {
if s.name == "approved" {
s.terminal = true;
}
}
assert_eq!(
validate_model_inner(&m),
Err(ModelError::TerminalHasOutbound("approved".to_string()))
);
}
#[test]
fn validate_rejects_bot_strategy_without_identity() {
let mut m = default_model();
m.assignee_model.strategy = "bot".to_string();
m.assignee_model.identity_pattern = None;
m.assignee_model.identity_example = None;
assert_eq!(
validate_model_inner(&m),
Err(ModelError::BotStrategyMissingIdentity)
);
}
#[test]
fn validate_accepts_bot_strategy_with_identity() {
let mut m = default_model();
m.assignee_model.strategy = "bot".to_string();
m.assignee_model.identity_pattern = Some("{user}-bot".to_string());
m.assignee_model.identity_example = None;
assert!(validate_model_inner(&m).is_ok());
}
}