use std::collections::BTreeSet;
use super::config::{GhState, SUPPORTED_VERSION, StateModel};
use super::state::same_label_name;
#[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,
#[error(
"label-less states `{first}` and `{second}` both map to gh_state \
`{gh_state}`; at most one label-less state per gh_state"
)]
AmbiguousLabellessState {
first: String,
second: String,
gh_state: &'static str,
},
#[error(
"states `{first}` and `{second}` share the label `{label}` (label names \
compare case-insensitively, as on GitHub); each labelled state needs its \
own label"
)]
DuplicateLabelName {
label: String,
first: String,
second: String,
},
}
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 {
let Some(label) = &s.label else { continue };
if !is_six_hex(&label.color) {
return Err(ModelError::BadColor {
label: label.name.clone(),
color: 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()));
}
}
let mut labelless: [Option<&str>; 2] = [None, None];
for s in &model.states {
if s.label.is_some() {
continue;
}
let slot = usize::from(s.gh_state == GhState::Closed);
match labelless[slot] {
None => labelless[slot] = Some(s.name.as_str()),
Some(first) => {
return Err(ModelError::AmbiguousLabellessState {
first: first.to_string(),
second: s.name.clone(),
gh_state: if slot == 0 { "open" } else { "closed" },
});
}
}
}
if model.assignee_model.strategy == "bot"
&& model.assignee_model.identity_pattern.is_none()
&& model.assignee_model.identity_example.is_none()
{
return Err(ModelError::BotStrategyMissingIdentity);
}
let labelled: Vec<(&str, &str)> = model
.states
.iter()
.filter_map(|s| s.label.as_ref().map(|l| (s.name.as_str(), l.name.as_str())))
.collect();
for (i, (second, label)) in labelled.iter().enumerate() {
if let Some((first, _)) = labelled[..i]
.iter()
.find(|(_, earlier)| same_label_name(earlier, label))
{
return Err(ModelError::DuplicateLabelName {
label: (*label).to_string(),
first: (*first).to_string(),
second: (*second).to_string(),
});
}
}
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
.as_mut()
.expect("every factory state is labelled")
.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,
requires_note: false,
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_rejects_two_labelless_states_on_one_gh_state() {
let yaml = r#"
version: 1
label_config: { base: x, approved: x:a, blast_prefix: "b:", status_prefix: "x:" }
states:
- { name: open, order: 0 }
- { name: also-open, order: 1 }
transitions:
- { from: open, to: also-open, trigger: human_label }
assignee_model: { strategy: unchanged, per_state: {} }
"#;
let m: StateModel = serde_yaml::from_str(yaml).expect("synthetic parses");
assert_eq!(
validate_model_inner(&m),
Err(ModelError::AmbiguousLabellessState {
first: "open".to_string(),
second: "also-open".to_string(),
gh_state: "open",
})
);
}
#[test]
fn validate_rejects_two_states_sharing_a_label_name_8703() {
let yaml = r#"
version: 1
label_config: { base: x, approved: x:a, blast_prefix: "b:", status_prefix: "x:" }
states:
- { name: open, order: 0 }
- { name: coded, order: 1, label: { name: "status:coded", color: "0e8a16" } }
- { name: shipped, order: 2, label: { name: "Status:Coded", color: "0e8a16" } }
transitions:
- { from: open, to: coded, trigger: human_label }
- { from: coded, to: shipped, trigger: human_label }
assignee_model: { strategy: unchanged, per_state: {} }
"#;
let m: StateModel = serde_yaml::from_str(yaml).expect("synthetic parses");
let err = validate_model_inner(&m).unwrap_err();
assert_eq!(
err,
ModelError::DuplicateLabelName {
label: "Status:Coded".to_string(),
first: "coded".to_string(),
second: "shipped".to_string(),
}
);
let message = err.to_string();
assert!(
message.contains("`coded`") && message.contains("`shipped`"),
"the error must name both states: {message}"
);
}
#[test]
fn validate_accepts_one_labelless_state_per_gh_state() {
let yaml = r#"
version: 1
label_config: { base: x, approved: x:a, blast_prefix: "b:", status_prefix: "x:" }
states:
- { name: open, gh_state: open, order: 0 }
- { name: closed, gh_state: closed, order: 1 }
transitions:
- { from: open, to: closed, trigger: human_label }
assignee_model: { strategy: unchanged, per_state: {} }
"#;
let m: StateModel = serde_yaml::from_str(yaml).expect("synthetic parses");
assert!(validate_model_inner(&m).is_ok());
}
#[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());
}
}