use crate::finding::{at, Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{DocumentCheck, DocumentView};
use crate::shape::{FacetValue, Shape};
use headwater_yaml::Mapping;
pub const RULE: &str = "lifecycle.state.not_admitted";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stood<'a> {
At(&'a str),
Undeclared,
NotAState(&'a str),
}
pub struct StateFacet {
pub(crate) name: Option<String>,
pub(crate) values: Vec<FacetValue>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Standing {
Live,
Terminal,
Initial,
Neither,
}
const LIVE: &str = "live";
const INITIAL: &str = "initial";
impl StateFacet {
pub fn of(shape: &Shape) -> Self {
let facet = shape.facet_in_role("state");
StateFacet {
name: facet.map(|facet| facet.name.clone()),
values: facet.map(|facet| facet.values.clone()).unwrap_or_default(),
}
}
pub fn standing(&self, state: &str) -> Standing {
match self
.values
.iter()
.find(|held| held.value == state)
.and_then(|held| held.role.as_deref())
{
Some(role) if headwater_resolve::core::role_is_terminal(role) => Standing::Terminal,
Some(LIVE) => Standing::Live,
Some(INITIAL) => Standing::Initial,
_ => Standing::Neither,
}
}
pub fn stood<'b>(&self, facets: &'b Mapping) -> Stood<'b> {
let Some(name) = self.name.as_deref() else {
return Stood::Undeclared;
};
let Some(entry) = facets.entry(name) else {
return Stood::Undeclared;
};
let Some(scalar) = entry.value.value.as_scalar() else {
return Stood::Undeclared;
};
match self.values.iter().any(|held| held.value == scalar.text) {
true => Stood::At(&scalar.text),
false => Stood::NotAState(&scalar.text),
}
}
}
pub struct StateAdmitted<'a> {
shape: &'a Shape,
facet: StateFacet,
}
impl<'a> StateAdmitted<'a> {
pub fn over(shape: &'a Shape) -> Self {
StateAdmitted {
shape,
facet: StateFacet::of(shape),
}
}
}
impl DocumentCheck for StateAdmitted<'_> {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 1;
fn instantiates(&self, kind: &str) -> bool {
self.facet.name.is_some() && self.shape.lifecycle_of(kind).is_some()
}
fn evaluate(&self, view: &DocumentView<'_>) -> Outcome {
let Some(regime) = self.shape.lifecycle_of(view.kind()) else {
return Outcome::Passed;
};
let standing = match self.facet.stood(view.facets()) {
Stood::At(state) => state,
Stood::Undeclared => return Outcome::Skipped(UNDECLARED.to_string()),
Stood::NotAState(value) => {
return Outcome::Skipped(format!(
"this document stands at `{value}`, which the state facet does not admit, and \
`facet.value.not_permitted` reports that"
))
}
};
let admitted = regime.states();
if admitted.is_empty() {
return Outcome::Skipped(format!(
"the lifecycle regime `{}` names no state, and `taxonomy validate` reports that \
against the declaration",
regime.name
));
}
if admitted.contains(&standing) {
return Outcome::Passed;
}
let offers = admitted
.iter()
.map(|state| format!("`{state}`"))
.collect::<Vec<_>>()
.join(", ");
let facet = self.facet.name.as_deref().unwrap_or_default();
let (line, column) = at(view.facets().key_span(facet));
Outcome::Failed(vec![Finding {
rule: self::RULE,
severity: Severity::Error,
obligation: None,
path: view.path().to_string(),
line,
column,
message: format!(
"a `{}` stands at `{standing}`, and the lifecycle regime `{}` that this kind binds \
names no such state: a `{}` stands at one of {offers}",
view.kind(),
regime.name,
view.kind()
),
remediation: format!(
"change `{facet}` in {} to one of: {}, or bind `{}` to a lifecycle regime that \
names `{standing}`",
view.path(),
admitted.join(", "),
view.kind()
),
patch: None,
}])
}
}
const UNDECLARED: &str =
"this document declares no value for the state facet, so there is no state to hold against \
the ones its kind admits";
#[cfg(test)]
mod tests {
use super::*;
fn shape() -> Shape {
let source = load(concat!(
"facets:\n",
" status:\n",
" role: state\n",
" values:\n",
" - {value: draft, role: initial}\n",
" - {value: current, role: live}\n",
" - {value: superseded, role: terminal-retained}\n",
" - {value: deprecated, role: terminal-retained}\n",
" - {value: discharged, role: terminal-retained}\n",
"regimes:\n",
" lifecycle:\n",
" narrow:\n",
" initial: draft\n",
" transitions: {draft: [current, deprecated], current: [superseded, deprecated]}\n",
" wide:\n",
" initial: draft\n",
" transitions: {draft: [current], current: [discharged]}\n",
" empty:\n",
" transitions: {}\n",
"kinds:\n",
" decision: {lifecycle: narrow}\n",
" obligation_record: {lifecycle: wide}\n",
" broken: {lifecycle: empty}\n",
" note: {}\n",
));
Shape::read(&source).expect("a shape")
}
fn load(source: &str) -> Mapping {
headwater_yaml::load(source)
.expect("the source loads")
.value
.as_map()
.expect("a mapping")
.clone()
}
#[test]
fn a_regime_names_the_states_its_kind_admits() {
let shape = shape();
let narrow = shape.lifecycle_of("decision").expect("a regime");
assert_eq!(
narrow.states(),
vec!["draft", "current", "deprecated", "superseded"]
);
assert!(!narrow.states().contains(&"discharged"));
let wide = shape.lifecycle_of("obligation_record").expect("a regime");
assert_eq!(wide.states(), vec!["draft", "current", "discharged"]);
}
#[test]
fn a_regime_that_declares_no_state_names_none() {
let shape = shape();
let empty = shape.lifecycle_of("broken").expect("a regime");
assert!(empty.states().is_empty());
}
#[test]
fn a_kind_that_binds_no_lifecycle_generates_no_instance() {
let shape = shape();
let check = StateAdmitted::over(&shape);
assert!(check.instantiates("decision"));
assert!(check.instantiates("obligation_record"));
assert!(!check.instantiates("note"));
}
#[test]
fn a_role_says_whether_a_state_is_live_terminal_or_the_one_a_document_opens_at() {
let facet = StateFacet::of(&shape());
assert_eq!(facet.standing("current"), Standing::Live);
assert_eq!(facet.standing("superseded"), Standing::Terminal);
assert_eq!(facet.standing("deprecated"), Standing::Terminal);
assert_eq!(facet.standing("discharged"), Standing::Terminal);
assert_eq!(facet.standing("draft"), Standing::Initial);
assert_eq!(facet.standing("retired"), Standing::Neither);
}
#[test]
fn a_renamed_vocabulary_that_keeps_its_roles_reads_the_same() {
let source = load(concat!(
"facets:\n",
" phase:\n",
" role: state\n",
" values:\n",
" - {value: opened, role: initial}\n",
" - {value: ratified, role: live}\n",
" - {value: withdrawn, role: terminal-retained}\n",
));
let facet = StateFacet::of(&Shape::read(&source).expect("a shape"));
assert_eq!(facet.standing("ratified"), Standing::Live);
assert_eq!(facet.standing("withdrawn"), Standing::Terminal);
assert_eq!(facet.standing("opened"), Standing::Initial);
assert_eq!(facet.standing("current"), Standing::Neither);
}
#[test]
fn the_role_and_the_machine_name_one_terminal_set_over_this_repository() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..");
let repository = headwater_resolve::repository(&root).expect("this repository resolves");
let shape = Shape::read(&repository.resolution.taxonomy).expect("a shape");
let facet = StateFacet::of(&shape);
let mut read: Vec<(&str, &str, bool, bool)> = Vec::new();
for regime in &shape.lifecycle {
for state in regime.states() {
read.push((
regime.name.as_str(),
state,
regime.terminal(state),
facet.standing(state) == Standing::Terminal,
));
}
}
let split: Vec<String> = read
.iter()
.filter(|(_, _, machine, role)| machine != role)
.map(|(regime, state, machine, role)| {
format!(
"`{regime}` reaches `{state}`: the machine says {machine} and the role \
says {role}"
)
})
.collect();
assert!(
split.is_empty(),
"two readings of terminal disagree over this repository:\n {}",
split.join("\n ")
);
assert_eq!(read.len(), 9, "the readings ran over {read:?}");
assert!(read.iter().any(|(_, _, machine, _)| *machine));
assert!(read.iter().any(|(_, _, machine, _)| !*machine));
}
#[test]
fn a_shape_built_by_hand_still_holds_two_readings_that_differ() {
let source = load(concat!(
"facets:\n",
" status:\n",
" role: state\n",
" values:\n",
" - {value: draft, role: initial}\n",
" - {value: current, role: live}\n",
" - {value: leaves, role: terminal-retained}\n",
" - {value: sealed}\n",
"regimes:\n",
" lifecycle:\n",
" standard:\n",
" initial: draft\n",
" transitions: {draft: [current], current: [leaves, sealed], leaves: [sealed]}\n",
"kinds:\n",
" decision: {lifecycle: standard}\n",
));
let shape = Shape::read(&source).expect("a shape");
let facet = StateFacet::of(&shape);
let regime = shape.lifecycle_of("decision").expect("a regime");
assert_eq!(facet.standing("leaves"), Standing::Terminal);
assert!(!regime.terminal("leaves"));
assert!(regime.terminal("sealed"));
assert_eq!(facet.standing("sealed"), Standing::Neither);
}
#[test]
fn a_state_a_document_does_not_declare_is_not_the_state_it_declares_wrongly() {
let facet = StateFacet::of(&shape());
assert_eq!(facet.stood(&load("status: draft\n")), Stood::At("draft"));
assert_eq!(facet.stood(&load("id: A\n")), Stood::Undeclared);
assert_eq!(
facet.stood(&load("status: retired\n")),
Stood::NotAState("retired")
);
}
}