use crate::finding::Severity;
use headwater_census::shelves::DeclarationError;
use headwater_yaml::{Mapping, Span, Value};
#[derive(Clone, Debug, Default)]
pub struct Shape {
pub facets: Vec<Facet>,
pub kinds: Vec<Kind>,
pub purposes: Vec<Purpose>,
pub voice: Vec<VoiceRegime>,
pub language: Vec<LanguageRegime>,
pub lifecycle: Vec<LifecycleRegime>,
pub identifier_schemes: Vec<IdentifierScheme>,
pub surface: Surface,
}
#[derive(Clone, Debug, Default)]
pub struct Surface {
pub adopter_documents: Vec<String>,
pub local_roots: Vec<String>,
pub commands: Vec<String>,
pub programs: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct IdentifierScheme {
pub name: String,
pub pattern: String,
pub namespace: String,
pub allocation: Option<String>,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct VoiceRegime {
pub name: String,
pub forbid: Vec<String>,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct LifecycleRegime {
pub name: String,
pub initial: String,
pub transitions: Vec<(String, Vec<String>)>,
pub retain_terminal: Option<bool>,
pub span: Span,
}
impl LifecycleRegime {
pub fn exits(&self, from: &str) -> &[String] {
self.transitions
.iter()
.find(|(state, _)| state == from)
.map(|(_, targets)| targets.as_slice())
.unwrap_or_default()
}
pub fn terminal(&self, state: &str) -> bool {
self.states().contains(&state) && self.exits(state).is_empty()
}
pub fn admits(&self, from: &str, to: &str) -> bool {
self.exits(from).iter().any(|state| state == to)
}
pub fn states(&self) -> Vec<&str> {
let mut states: Vec<&str> = Vec::new();
for state in std::iter::once(&self.initial).chain(
self.transitions
.iter()
.flat_map(|(from, targets)| std::iter::once(from).chain(targets.iter())),
) {
if !state.is_empty() && !states.contains(&state.as_str()) {
states.push(state.as_str());
}
}
states
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RetiredTerm {
pub term: String,
pub reason: String,
pub replacement: Option<String>,
}
#[derive(Clone, Debug)]
pub struct LanguageRegime {
pub name: String,
pub tag: String,
pub controlled: Option<String>,
pub profile: Option<String>,
pub source_form: Option<String>,
pub retired_terms: Vec<RetiredTerm>,
pub outside_root: Vec<String>,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct Facet {
pub name: String,
pub role: Option<String>,
pub value_type: Option<String>,
pub required: bool,
pub values: Vec<FacetValue>,
pub stale_after_days: Option<i64>,
pub span: Span,
}
impl Facet {
pub fn admitted(&self) -> Vec<&str> {
self.values
.iter()
.map(|value| value.value.as_str())
.collect()
}
pub fn role_of(&self, value: &str) -> Option<&str> {
self.values
.iter()
.find(|held| held.value == value)
.and_then(|held| held.role.as_deref())
}
}
#[derive(Clone, Debug)]
pub struct FacetValue {
pub value: String,
pub role: Option<String>,
}
#[derive(Clone, Debug)]
pub struct Purpose {
pub name: String,
pub intent: Option<String>,
pub answers: Vec<String>,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct Kind {
pub name: String,
pub is_a: Option<String>,
pub purpose: Option<String>,
pub require: Vec<String>,
pub forbid: Vec<String>,
pub narrows: Vec<(String, Vec<String>)>,
pub voice: Option<String>,
pub language: Option<String>,
pub lifecycle: Option<String>,
pub identifier_scheme: Option<String>,
pub sections: Vec<String>,
pub expectations: Vec<Expectation>,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct Expectation {
pub id: String,
pub relation: String,
pub to_kind: Option<String>,
pub when: Vec<(String, String)>,
pub within_days: Option<i64>,
pub since_role: String,
pub severity: Option<Severity>,
pub rationale: Option<String>,
}
impl Shape {
pub fn read(root: &Mapping) -> Result<Self, Vec<DeclarationError>> {
let mut errors = Vec::new();
let mut shape = Shape::default();
if let Some(facets) = root.get("facets") {
match &facets.value {
Value::Map(map) => {
for entry in map {
match read_facet(&entry.key.value, &entry.value.value, entry.key.span) {
Ok(facet) => shape.facets.push(facet),
Err(error) => errors.push(error),
}
}
}
other => errors.push(DeclarationError {
message: format!("`facets` is {}, and it names facets", other.kind_name()),
span: facets.span,
}),
}
}
if let Some(kinds) = root.get("kinds") {
match &kinds.value {
Value::Map(map) => {
for entry in map {
match read_kind(&entry.key.value, &entry.value.value, entry.key.span) {
Ok(kind) => shape.kinds.push(kind),
Err(error) => errors.push(error),
}
}
}
other => errors.push(DeclarationError {
message: format!("`kinds` is {}, and it names kinds", other.kind_name()),
span: kinds.span,
}),
}
}
if let Some(purposes) = root.get("purposes") {
match &purposes.value {
Value::Map(map) => {
for entry in map {
let body = entry.value.value.as_map();
shape.purposes.push(Purpose {
name: entry.key.value.clone(),
intent: body.and_then(|map| scalar(map, "intent")),
answers: body.map(|map| sequence(map, "answers")).unwrap_or_default(),
span: entry.key.span,
});
}
}
other => errors.push(DeclarationError {
message: format!(
"`purposes` is {}, and it names reader intents",
other.kind_name()
),
span: purposes.span,
}),
}
}
if let Some(schemes) = root.get("identifier_schemes") {
match &schemes.value {
Value::Map(map) => {
for entry in map {
let Some(body) = entry.value.value.as_map() else {
errors.push(DeclarationError {
message: format!(
"identifier scheme `{}` is {}, and a scheme is a mapping",
entry.key.value,
entry.value.value.kind_name()
),
span: entry.key.span,
});
continue;
};
shape.identifier_schemes.push(IdentifierScheme {
name: entry.key.value.clone(),
pattern: scalar(body, "pattern").unwrap_or_default(),
namespace: scalar(body, "namespace").unwrap_or_default(),
allocation: scalar(body, "allocation"),
span: entry.key.span,
});
}
}
other => errors.push(DeclarationError {
message: format!(
"`identifier_schemes` is {}, and it names identifier schemes",
other.kind_name()
),
span: schemes.span,
}),
}
}
if let Some(surface) = root.get("surface").and_then(|node| node.value.as_map()) {
shape.surface = Surface {
adopter_documents: sequence(surface, "adopter_documents"),
local_roots: sequence(surface, "local_roots")
.into_iter()
.map(|root| match root.ends_with('/') {
true => root,
false => format!("{root}/"),
})
.collect(),
commands: sequence(surface, "commands"),
programs: programs(surface),
};
}
if let Some(regimes) = root.get("regimes").and_then(|node| node.value.as_map()) {
if let Some(voice) = regimes.get("voice").and_then(|node| node.value.as_map()) {
for entry in voice {
let Some(map) = entry.value.value.as_map() else {
continue;
};
shape.voice.push(VoiceRegime {
name: entry.key.value.clone(),
forbid: sequence(map, "forbid"),
span: entry.key.span,
});
}
}
if let Some(language) = regimes.get("language").and_then(|node| node.value.as_map()) {
for entry in language {
let Some(map) = entry.value.value.as_map() else {
continue;
};
shape.language.push(LanguageRegime {
name: entry.key.value.clone(),
tag: scalar(map, "tag").unwrap_or_default(),
controlled: scalar(map, "controlled"),
profile: scalar(map, "profile"),
source_form: scalar(map, "source_form"),
retired_terms: retired_terms(map),
outside_root: sequence(map, "outside_root"),
span: entry.key.span,
});
}
}
if let Some(lifecycle) = regimes
.get("lifecycle")
.and_then(|node| node.value.as_map())
{
for entry in lifecycle {
let Some(map) = entry.value.value.as_map() else {
continue;
};
shape.lifecycle.push(LifecycleRegime {
name: entry.key.value.clone(),
initial: scalar(map, "initial").unwrap_or_default(),
transitions: transitions(map),
retain_terminal: headwater_yaml::core_schema::flag(map, "retain_terminal"),
span: entry.key.span,
});
}
}
}
if errors.is_empty() {
Ok(shape)
} else {
Err(errors)
}
}
pub fn voice_of(&self, kind: &str) -> Option<&VoiceRegime> {
let name = self
.ancestry(kind)
.iter()
.find_map(|step| step.voice.clone())?;
self.voice.iter().find(|regime| regime.name == name)
}
pub fn identifier_scheme_of(&self, kind: &str) -> Option<&IdentifierScheme> {
let name = self
.ancestry(kind)
.into_iter()
.find_map(|step| step.identifier_scheme.clone())?;
self.identifier_schemes
.iter()
.find(|scheme| scheme.name == name)
}
pub fn identifier_shaped(&self, target: &str) -> bool {
self.identifier_schemes.iter().any(|scheme| {
let Ok(template) =
headwater_meta::identifier::Template::parse(&scheme.pattern, &scheme.namespace)
else {
return false;
};
let prefix = template.prefix();
!prefix.is_empty() && target.starts_with(prefix.as_str())
})
}
pub fn lifecycle_of(&self, kind: &str) -> Option<&LifecycleRegime> {
let name = self
.ancestry(kind)
.iter()
.find_map(|step| step.lifecycle.clone())?;
self.lifecycle.iter().find(|regime| regime.name == name)
}
pub fn language_of(&self, kind: &str) -> Option<&LanguageRegime> {
let name = self
.ancestry(kind)
.iter()
.find_map(|step| step.language.clone())?;
self.language.iter().find(|regime| regime.name == name)
}
pub fn outside_root(&self) -> Vec<headwater_census::outside::Listed> {
self.language
.iter()
.filter(|regime| !regime.outside_root.is_empty())
.map(|regime| headwater_census::outside::Listed {
regime: regime.name.clone(),
patterns: regime.outside_root.clone(),
})
.collect()
}
pub fn admitted_values(&self, kind: &str, facet: &str) -> Option<Vec<&str>> {
let declared = self.facet(facet)?.admitted();
let mut admitted = declared;
for step in self.ancestry(kind) {
let Some((_, narrowed)) = step.narrows.iter().find(|(name, _)| name == facet) else {
continue;
};
admitted.retain(|value| narrowed.iter().any(|named| named == value));
}
Some(admitted)
}
pub fn required_sections(&self, kind: &str) -> Vec<String> {
let mut required: Vec<String> = Vec::new();
for step in self.ancestry(kind).iter().rev() {
for name in &step.sections {
if !required.iter().any(|known| known == name) {
required.push(name.clone());
}
}
}
required
}
pub fn facet(&self, name: &str) -> Option<&Facet> {
self.facets.iter().find(|facet| facet.name == name)
}
pub fn kind(&self, name: &str) -> Option<&Kind> {
self.kinds.iter().find(|kind| kind.name == name)
}
pub fn facet_in_role(&self, role: &str) -> Option<&Facet> {
self.facets
.iter()
.find(|facet| facet.role.as_deref() == Some(role))
}
pub fn ancestry(&self, name: &str) -> Vec<&Kind> {
let mut chain = Vec::new();
let mut next = Some(name.to_string());
while let Some(current) = next {
let Some(kind) = self.kind(¤t) else {
break;
};
if chain.len() >= self.kinds.len() {
break;
}
chain.push(kind);
next = kind.is_a.clone();
}
chain
}
pub fn purpose_of(&self, kind: &str) -> Option<&Purpose> {
let name = self
.ancestry(kind)
.into_iter()
.find_map(|step| step.purpose.clone())?;
self.purposes.iter().find(|purpose| purpose.name == name)
}
pub fn descends_from(&self, kind: &str, ancestor: &str) -> bool {
self.ancestry(kind).iter().any(|step| step.name == ancestor)
}
pub fn required_facets(&self, kind: &str) -> Vec<String> {
let ancestry = self.ancestry(kind);
let forbidden: Vec<&str> = ancestry
.iter()
.flat_map(|step| step.forbid.iter().map(String::as_str))
.collect();
let mut required: Vec<String> = Vec::new();
let owe = |name: &str, required: &mut Vec<String>| {
if !forbidden.contains(&name) && !required.iter().any(|known| known == name) {
required.push(name.to_string());
}
};
for facet in self.facets.iter().filter(|facet| facet.required) {
owe(&facet.name, &mut required);
}
for step in ancestry.iter().rev() {
for name in &step.require {
owe(name, &mut required);
}
}
required
}
}
fn read_facet(name: &str, value: &Value, span: Span) -> Result<Facet, DeclarationError> {
let map = value.as_map().ok_or_else(|| DeclarationError {
message: format!(
"facet `{name}` is {}, and a facet is a mapping",
value.kind_name()
),
span,
})?;
Ok(Facet {
name: name.to_string(),
role: scalar(map, "role"),
value_type: scalar(map, "type"),
required: headwater_yaml::core_schema::flag(map, "required").unwrap_or(false),
values: read_values(map),
stale_after_days: scalar(map, "stale_after_days")
.as_deref()
.and_then(|text| text.trim().parse().ok()),
span,
})
}
fn read_values(map: &Mapping) -> Vec<FacetValue> {
let Some(items) = map.get("values").and_then(|node| node.value.as_seq()) else {
return Vec::new();
};
items
.iter()
.filter_map(|item| match &item.value {
Value::Map(entry) => scalar(entry, "value").map(|value| FacetValue {
value,
role: scalar(entry, "role"),
}),
other => other.as_scalar().map(|scalar| FacetValue {
value: scalar.text.clone(),
role: None,
}),
})
.collect()
}
fn read_kind(name: &str, value: &Value, span: Span) -> Result<Kind, DeclarationError> {
let map = value.as_map().ok_or_else(|| DeclarationError {
message: format!(
"kind `{name}` is {}, and a kind is a mapping",
value.kind_name()
),
span,
})?;
let facets = map.get("facets").and_then(|node| node.value.as_map());
Ok(Kind {
name: name.to_string(),
is_a: scalar(map, "is_a"),
purpose: scalar(map, "purpose"),
require: facets
.map(|map| sequence(map, "require"))
.unwrap_or_default(),
forbid: facets
.map(|map| sequence(map, "forbid"))
.unwrap_or_default(),
narrows: facets.map(narrowings).unwrap_or_default(),
voice: scalar(map, "voice"),
language: scalar(map, "language"),
lifecycle: scalar(map, "lifecycle"),
identifier_scheme: map
.get("identifier")
.and_then(|node| node.value.as_map())
.and_then(|identifier| scalar(identifier, "scheme")),
sections: map
.get("sections")
.and_then(|node| node.value.as_map())
.map(|sections| sequence(sections, "require"))
.unwrap_or_default(),
expectations: read_expectations(map),
span,
})
}
fn transitions(regime: &Mapping) -> Vec<(String, Vec<String>)> {
let Some(map) = regime
.get("transitions")
.and_then(|node| node.value.as_map())
else {
return Vec::new();
};
map.iter()
.map(|entry| {
let targets = entry
.value
.value
.as_seq()
.unwrap_or_default()
.iter()
.filter_map(|item| item.value.as_scalar())
.map(|scalar| scalar.text.clone())
.collect();
(entry.key.value.clone(), targets)
})
.collect()
}
fn read_expectations(kind: &Mapping) -> Vec<Expectation> {
let Some(items) = kind
.get("relations")
.and_then(|node| node.value.as_map())
.and_then(|relations| relations.get("expect"))
.and_then(|node| node.value.as_seq())
else {
return Vec::new();
};
items
.iter()
.filter_map(|item| {
let map = item.value.as_map()?;
let relation = scalar(map, "relation")?;
let since_role = scalar(map, "since")?;
Some(Expectation {
id: scalar(map, "id").unwrap_or_else(|| relation.clone()),
relation,
to_kind: scalar(map, "to_kind"),
when: map
.get("when")
.and_then(|node| node.value.as_map())
.map(|when| {
when.iter()
.filter_map(|entry| {
let value = entry.value.value.as_scalar()?;
Some((entry.key.value.clone(), value.text.clone()))
})
.collect()
})
.unwrap_or_default(),
within_days: scalar(map, "within").as_deref().and_then(days),
since_role,
severity: match scalar(map, "severity") {
None => Some(Severity::Warn),
Some(word) => match word.as_str() {
"error" => Some(Severity::Error),
"warn" => Some(Severity::Warn),
"info" => Some(Severity::Info),
_ => None,
},
},
rationale: scalar(map, "rationale"),
})
})
.collect()
}
fn days(text: &str) -> Option<i64> {
let text = text.trim();
let digits = text.strip_suffix('d').unwrap_or(text);
digits.parse().ok().filter(|days| *days >= 0)
}
fn scalar(map: &Mapping, key: &str) -> Option<String> {
map.get(key)
.and_then(|node| node.value.as_scalar())
.map(|scalar| scalar.text.clone())
}
fn programs(surface: &Mapping) -> Vec<String> {
let mut found = sequence(surface, "commands");
found.extend(sequence(surface, "prerequisites"));
if let Some(points) = surface
.get("integration_points")
.and_then(|node| node.value.as_map())
{
for point in points {
if let Some(map) = point.value.value.as_map() {
found.extend(sequence(map, "depends_on"));
}
}
}
found.sort();
found.dedup();
found
}
fn sequence(map: &Mapping, key: &str) -> Vec<String> {
map.get(key)
.and_then(|node| node.value.as_seq())
.map(|items| {
items
.iter()
.filter_map(|item| item.value.as_scalar())
.map(|scalar| scalar.text.clone())
.collect()
})
.unwrap_or_default()
}
fn narrowings(facets: &Mapping) -> Vec<(String, Vec<String>)> {
let Some(values) = facets.get("values").and_then(|node| node.value.as_map()) else {
return Vec::new();
};
values
.iter()
.filter_map(|entry| {
let items = entry.value.value.as_seq()?;
Some((
entry.key.value.clone(),
items
.iter()
.filter_map(|item| item.value.as_scalar())
.map(|scalar| scalar.text.clone())
.collect(),
))
})
.collect()
}
fn retired_terms(map: &Mapping) -> Vec<RetiredTerm> {
let Some(items) = map
.get("retired_terms")
.and_then(|node| node.value.as_seq())
else {
return Vec::new();
};
items
.iter()
.filter_map(|item| {
let entry = item.value.as_map()?;
Some(RetiredTerm {
term: scalar(entry, "term")?,
reason: scalar(entry, "reason")?,
replacement: scalar(entry, "replacement"),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn shape(source: &str) -> Shape {
let root = headwater_yaml::load(source).expect("the source loads");
Shape::read(root.value.as_map().expect("a mapping")).expect("the shape reads")
}
const SOURCE: &str = "\
facets:
status:
role: state
required: true
values:
- {value: draft, role: initial}
- {value: current, role: live}
status_since:
role: state_entered
required: true
doc_type:
required: false
values: [design_spec, evaluation]
kinds:
governed_document:
abstract: true
facets: {require: [status, status_since]}
design_spec:
is_a: governed_document
facets: {require: [doc_type]}
evaluation:
is_a: governed_document
facets: {forbid: [doc_type]}
relations:
expect:
- id: evidence-cited
relation: cited_by
to_kind: decision_register
when: {status: current}
within: 30d
since: state_entered
severity: warn
rationale: an evaluation that no register cites closed nothing
";
#[test]
fn a_value_set_reads_from_a_vocabulary_and_from_a_plain_list() {
let shape = shape(SOURCE);
assert_eq!(
shape.facet("status").expect("declared").admitted(),
["draft", "current"]
);
assert_eq!(
shape.facet("doc_type").expect("declared").admitted(),
["design_spec", "evaluation"]
);
let status = shape.facet("status").expect("declared");
assert_eq!(status.values[0].role.as_deref(), Some("initial"));
assert_eq!(status.values[1].role.as_deref(), Some("live"));
assert!(shape.facet("doc_type").expect("declared").values[0]
.role
.is_none());
assert!(shape
.facet("status_since")
.expect("declared")
.values
.is_empty());
}
const SCHEMES: &str = "\
identifier_schemes:
decision_id:
pattern: \"{namespace}-DR-{seq:04d}\"
namespace: HW
spec_id:
pattern: \"{namespace}-SPEC-{slug}\"
namespace: HW
";
#[test]
fn a_target_shaped_like_a_declared_scheme_is_identifier_shaped_even_short_of_its_width() {
let shape = shape(SCHEMES);
assert!(shape.identifier_shaped("HW-DR-0040"));
assert!(shape.identifier_shaped("HW-DR-004"));
assert!(shape.identifier_shaped("HW-DR-9999"));
assert!(shape.identifier_shaped("HW-SPEC-glossary"));
assert!(!shape.identifier_shaped("docs/spec/09-decisions.md"));
assert!(!shape.identifier_shaped(""));
}
#[test]
fn a_kind_owes_what_its_ancestors_require() {
let shape = shape(SOURCE);
assert_eq!(
shape.required_facets("design_spec"),
["status", "status_since", "doc_type"]
);
assert_eq!(
shape.required_facets("governed_document"),
["status", "status_since"]
);
}
#[test]
fn a_forbidden_facet_is_never_owed() {
let shape = shape(&SOURCE.replace(" - {value: draft, role: initial}\n", ""));
assert!(!shape
.required_facets("evaluation")
.contains(&"doc_type".to_string()));
}
#[test]
fn a_kind_descends_from_its_ancestors_and_from_itself() {
let shape = shape(SOURCE);
assert!(shape.descends_from("design_spec", "governed_document"));
assert!(shape.descends_from("design_spec", "design_spec"));
assert!(!shape.descends_from("governed_document", "design_spec"));
assert!(!shape.descends_from("design_spec", "evaluation"));
}
#[test]
fn a_cycle_in_the_chain_terminates() {
let shape = shape("kinds:\n a: {is_a: b}\n b: {is_a: a}\n");
assert_eq!(shape.ancestry("a").len(), 2);
assert!(!shape.descends_from("a", "c"));
}
#[test]
fn an_expectation_reads_its_window_its_origin_and_its_severity() {
let shape = shape(SOURCE);
let expectation = &shape.kind("evaluation").expect("declared").expectations[0];
assert_eq!(expectation.id, "evidence-cited");
assert_eq!(expectation.relation, "cited_by");
assert_eq!(expectation.to_kind.as_deref(), Some("decision_register"));
assert_eq!(
expectation.when,
[("status".to_string(), "current".to_string())]
);
assert_eq!(expectation.within_days, Some(30));
assert_eq!(expectation.since_role, "state_entered");
assert_eq!(expectation.severity, Some(Severity::Warn));
assert_eq!(
shape
.facet_in_role("state_entered")
.map(|facet| facet.name.as_str()),
Some("status_since")
);
}
#[test]
fn an_unreadable_window_or_severity_is_kept_as_unreadable() {
let shape = shape(
"kinds:\n k:\n relations:\n expect:\n \
- {relation: r, since: state_entered, within: soon, severity: loud}\n",
);
let expectation = &shape.kind("k").expect("declared").expectations[0];
assert_eq!(expectation.within_days, None);
assert_eq!(expectation.severity, None);
assert_eq!(expectation.id, "r");
}
#[test]
fn a_kind_serves_the_purpose_it_declares_or_the_one_it_inherits() {
let shape = shape(
"purposes:\n \
rationale:\n intent: explain why a choice was made\n \
answers: [\"why is it this way\", \"what was rejected\"]\n\
kinds:\n \
governed_document: {abstract: true, purpose: rationale}\n \
decision: {is_a: governed_document}\n \
note: {purpose: nowhere}\n \
bare: {}\n",
);
let purpose = shape.purposes.first().expect("declared");
assert_eq!(purpose.name, "rationale");
assert_eq!(
purpose.intent.as_deref(),
Some("explain why a choice was made")
);
assert_eq!(purpose.answers.len(), 2);
assert_eq!(
shape.purpose_of("decision").map(|p| p.name.as_str()),
Some("rationale"),
"the parent declares it"
);
assert!(shape.purpose_of("note").is_none());
assert!(shape.purpose_of("bare").is_none());
}
}