use crate::finding::{at, Finding, Severity};
use crate::instance::Outcome;
use crate::lifecycle_state::{Standing, StateFacet, Stood};
use crate::patch::Patch;
use crate::scope::{EdgeCheck, EdgeUnit, EdgeView};
use crate::shape::Shape;
use headwater_graph::declarations::Relation;
use headwater_graph::Declarations;
use headwater_yaml::Mapping;
pub const RULE: &str = "lifecycle.state.not_set_by_edge";
const NO_HALF: &str = "the entry carries no declared half";
const NO_PAIR: &str = "this edge has no second document to read";
pub struct NotSetByEdge<'a> {
setting: Vec<&'a Relation>,
shape: &'a Shape,
facet: StateFacet,
entered: Option<String>,
}
impl<'a> NotSetByEdge<'a> {
pub fn over(declarations: &'a Declarations, shape: &'a Shape) -> Self {
NotSetByEdge {
setting: declarations
.relations
.iter()
.filter(|relation| relation.sets_target_state.is_some())
.collect(),
shape,
facet: StateFacet::of(shape),
entered: shape
.facet_in_role("state_entered")
.map(|facet| facet.name.clone()),
}
}
fn regime_states(&self, kind: &str) -> Option<Vec<&str>> {
self.shape.lifecycle_of(kind).map(|regime| regime.states())
}
}
fn scalar<'m>(facets: &'m Mapping, name: &str) -> Option<&'m str> {
facets
.entry(name)?
.value
.value
.as_scalar()
.map(|scalar| scalar.text.as_str())
.filter(|text| !text.is_empty())
}
impl EdgeCheck for NotSetByEdge<'_> {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 2;
const UNIT: EdgeUnit = EdgeUnit::Pair;
fn instantiates(&self, relation: &str) -> bool {
self.facet.name.is_some() && self.setting.iter().any(|known| known.name == relation)
}
fn evaluate(&self, view: &EdgeView<'_>) -> Outcome {
let Some(half) = view.declared_half().or_else(|| view.inverse_half()) else {
return Outcome::Skipped(NO_HALF.to_string());
};
let Some(relation) = self
.setting
.iter()
.find(|known| known.name == half.declared)
else {
return Outcome::Skipped(NO_PAIR.to_string());
};
let Some(set) = relation.sets_target_state.as_deref() else {
return Outcome::Skipped(NO_PAIR.to_string());
};
let Some((source, target)) = view.ends() else {
return Outcome::Skipped(NO_PAIR.to_string());
};
if target.generated() {
return Outcome::Skipped(format!(
"`{}` is a generated document, so this engine writes its state, and `{}` and \
`generate --check` read it",
target.id,
crate::state_set_twice::RULE
));
}
let (Some(source_facets), Some(target_facets)) = (source.facets(), target.facets()) else {
return Outcome::Skipped(
"the census parsed no document at one end of this edge, so there is no state to \
read there"
.to_string(),
);
};
let source_state = match self.facet.stood(source_facets) {
Stood::At(state) => state,
Stood::Undeclared => {
return Outcome::Skipped(crate::dependency::undeclared("source", source.id))
}
Stood::NotAState(value) => {
return Outcome::Skipped(crate::dependency::not_a_state("source", source.id, value))
}
};
let target_state = match self.facet.stood(target_facets) {
Stood::At(state) => state,
Stood::Undeclared => {
return Outcome::Skipped(crate::dependency::undeclared("target", target.id))
}
Stood::NotAState(value) => {
return Outcome::Skipped(crate::dependency::not_a_state("target", target.id, value))
}
};
if self.facet.standing(source_state) == Standing::Initial {
return Outcome::Passed;
}
if self.facet.standing(target_state) == Standing::Initial {
return Outcome::Passed;
}
if target_state == set {
return Outcome::Passed;
}
let states = self.regime_states(target.kind);
if !states.as_ref().is_some_and(|states| states.contains(&set)) {
return Outcome::Skipped(format!(
"`{}` declares `{set}` onto a `{}`, and no lifecycle regime of that kind names \
`{set}`. HW-OBL-0196 records that pair, and this rule does not read it",
relation.name, target.kind
));
}
if !states
.as_ref()
.is_some_and(|states| states.contains(&target_state))
{
return Outcome::Skipped(format!(
"`{}` stands at `{target_state}`, which the lifecycle regime of a `{}` does not \
name, and `{}` reports that",
target.id,
target.kind,
crate::lifecycle_state::RULE
));
}
let state_name = self.facet.name.as_deref().unwrap_or_default();
let anchor = target_facets.entry(state_name).map(|entry| entry.key.span);
let (line, column) = at(anchor);
let stamp = self
.entered
.as_deref()
.and_then(|name| scalar(source_facets, name));
let written = self
.entered
.as_deref()
.and_then(|name| scalar(target_facets, name).map(|value| (name, value)));
let severity = match self.facet.standing(target_state) {
Standing::Live => Severity::Error,
_ => Severity::Warn,
};
let (patch, why_not) = match (self.facet.standing(target_state), stamp, written) {
(Standing::Live, Some(stamp), Some((entered, was))) => (
Some(Patch::Facets {
path: target.path.to_string(),
set: vec![
(
state_name.to_string(),
target_state.to_string(),
set.to_string(),
),
(entered.to_string(), was.to_string(), stamp.to_string()),
],
}),
None,
),
(Standing::Live, None, _) => (
None,
Some(format!(
"{} declares no {}, so there is no landing date to copy, and a fix does not \
invent one",
source.path,
self.entered.as_deref().unwrap_or("state-entry date")
)),
),
(Standing::Live, Some(_), None) => (
None,
Some(format!(
"{} has no {} to rewrite, so write it beside the state",
target.path,
self.entered.as_deref().unwrap_or("state-entry date")
)),
),
_ => (
None,
Some(format!(
"{} is not live, and which terminal state it ends at is the author's call",
target.path
)),
),
};
let stamp_prose = match stamp {
Some(stamp) => format!(" and its state-entry date to `{stamp}`"),
None => String::new(),
};
let mut remediation = format!(
"set the state of {} to `{set}`{stamp_prose}, the date the supersession by `{}` \
landed; or remove the edge if `{}` does not replace it",
target.path, source.id, source.id
);
if let Some(why_not) = why_not {
remediation.push_str(&format!(". No fix is offered: {why_not}"));
}
Outcome::failed_with(Finding {
rule: self::RULE,
severity,
obligation: None,
path: target.path.to_string(),
line,
column,
message: format!(
"`{}` stands at `{target_state}`, and `{}` (at `{source_state}`) declares \
`{}` to it, which sets its target's state to `{set}`",
target.id, source.id, relation.name
),
remediation,
patch,
})
}
}