use crate::change::Prior;
use crate::finding::{at, Finding, Severity};
use crate::instance::Outcome;
use crate::lifecycle_state::{StateFacet, Stood};
use crate::scope::{DocumentCheck, DocumentView};
use crate::shape::Shape;
pub const RULE: &str = "lifecycle.transition.not_permitted";
pub struct Transition<'a> {
shape: &'a Shape,
facet: StateFacet,
}
impl<'a> Transition<'a> {
pub fn over(shape: &'a Shape) -> Self {
Transition {
shape,
facet: StateFacet::of(shape),
}
}
}
impl DocumentCheck for Transition<'_> {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 2;
const NEEDS_PRIOR: bool = true;
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(prior) = view.prior() else {
return Outcome::Passed;
};
let Prior::Committed { facets, .. } = prior else {
return Outcome::Passed;
};
let Some(regime) = self.shape.lifecycle_of(view.kind()) else {
return Outcome::Passed;
};
let (before, after) = (self.facet.stood(facets), self.facet.stood(view.facets()));
let (before, after) = match (before, after) {
(Stood::At(before), Stood::At(after)) => (before, after),
(Stood::Undeclared, _) | (_, Stood::Undeclared) => {
return Outcome::Skipped(UNDECLARED.to_string())
}
(Stood::NotAState(value), _) | (_, Stood::NotAState(value)) => {
return Outcome::Skipped(format!(
"one version of this document stands at `{value}`, which the state facet does \
not admit, and `facet.value.not_permitted` reports that"
))
}
};
let named = regime.states();
if let Some(unnamed) = [before, after]
.into_iter()
.find(|state| !named.contains(state))
{
return Outcome::Skipped(format!(
"one version of this document stands at `{unnamed}`, which the lifecycle regime \
`{}` does not name, and `lifecycle.state.not_admitted` reports that",
regime.name
));
}
if before == after {
return Outcome::Passed;
}
if regime.admits(before, after) {
return Outcome::Passed;
}
let name = view
.facets()
.get("id")
.and_then(|node| node.value.as_scalar())
.map(|scalar| scalar.text.clone())
.unwrap_or_else(|| view.path().to_string());
let exits = regime.exits(before);
let offered = match exits.is_empty() {
true => format!(
"`{before}` is terminal in `{}` and admits none",
regime.name
),
false => format!(
"`{before}` admits {}",
exits
.iter()
.map(|state| format!("`{state}`"))
.collect::<Vec<_>>()
.join(", ")
),
};
let (line, column) = at(self
.facet
.name
.as_deref()
.and_then(|name| view.facets().key_span(name)));
Outcome::Failed(vec![Finding {
rule: self::RULE,
severity: Severity::Error,
obligation: None,
path: view.path().to_string(),
line,
column,
message: format!(
"{name} moved from `{before}` to `{after}` in this change, and the lifecycle \
regime `{}` does not admit that movement: {offered}",
regime.name
),
remediation: format!(
"put the state of {} back to `{before}`, or move it through a state that `{}` \
joins to `{after}`",
view.path(),
regime.name
),
patch: None,
}])
}
}
const UNDECLARED: &str =
"one version of this document declares no value for the state facet, so there are not two \
states to read a movement between";
#[cfg(test)]
mod tests {
use super::*;
use headwater_yaml::Mapping;
fn shape() -> Shape {
let source = load(concat!(
"facets:\n",
" status:\n",
" role: state\n",
" values: [draft, current, superseded]\n",
"regimes:\n",
" lifecycle:\n",
" standard:\n",
" initial: draft\n",
" transitions: {draft: [current], current: [superseded]}\n",
"kinds:\n",
" decision: {lifecycle: standard}\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_admits_what_the_declaration_admits() {
let shape = shape();
let regime = shape.lifecycle_of("decision").expect("a regime");
assert!(regime.admits("draft", "current"));
assert!(!regime.admits("draft", "superseded"));
assert!(regime.admits("current", "superseded"));
assert!(regime.exits("superseded").is_empty());
assert!(!regime.admits("superseded", "current"));
assert_eq!(regime.initial, "draft");
}
#[test]
fn a_kind_that_binds_no_lifecycle_generates_no_instance() {
let shape = shape();
let check = Transition::over(&shape);
assert!(check.instantiates("decision"));
assert!(!check.instantiates("note"));
}
#[test]
fn one_version_is_read_by_the_component_that_owns_the_state_facet() {
let shape = shape();
let check = Transition::over(&shape);
assert_eq!(
check.facet.stood(&load("status: draft\n")),
Stood::At("draft")
);
assert_eq!(check.facet.stood(&load("id: A\n")), Stood::Undeclared);
}
}