2026-09-22 16:51:15 +02:00
//! What a content repo must satisfy before it serves: every action
//! lands on a page, every transition is one the state machine allows,
//! every recorded bucket is read back somewhere, and the built-in
//! aggregates the runtime owns are always present.
#[allow(unused_imports)] // the browser build sees fewer items
use super ::* ;
/// A relay must be a ws:// or wss:// URL - shared between site.yaml's
/// hero and `Requirement.relay` validation.
#[cfg(feature = "ssr" )]
pub fn validate_relay_url ( relay : & str ) -> anyhow ::Result < () > {
let parsed = url ::Url ::parse ( relay )
. map_err ( | e | anyhow ::anyhow! ( "relay {relay:?} is not a valid URL: {e}" )) ? ;
if ! matches! ( parsed . scheme (), "ws" | "wss" ) {
anyhow ::bail! ( "relay {relay:?} must use the ws:// or wss:// scheme" );
}
Ok (())
}
/// Validates every declared transition target (`SelfTransition.to`,
/// `ResourceSpec.transitions[].to`) against `aggregates` - the real
/// state graph loaded from `aggregates.yaml` - for every bucket that
/// has one declared. A bucket with no entry in `aggregates` is left
/// alone entirely (no validation applied): not every bucket needs to
/// be event-sourced to keep working. Called on every content
/// load/reload (`watch_for_reload`, `main.rs`'s boot path) and by the
/// standalone `question-lint` binary, so a YAML typo becomes a caught,
/// logged rejection instead of a silently-accepted, later-broken
/// string.
/// Bucket the runtime keeps for announced pages (`Question.event`):
/// one record per event question, `announced` while the window is
/// open, `awaiting_summary` once it closes - the "post what happened"
/// task a desk reading this bucket picks up - and `summarized` when an
/// owner marks it done. Declared here, not in content, because the
/// runtime's sweeper (`announce.rs`) is what moves the records.
pub const EVENTS_BUCKET : & str = "portal_events" ;
#[cfg(feature = "ssr" )]
const BUILTIN_AGGREGATES_YAML : & str = " \
aggregates:
- bucket: portal_events
initial: announced
states:
announced: { event: announced }
awaiting_summary: { event: ended }
summarized: { event: summarized }
transitions:
announced: [awaiting_summary, summarized]
awaiting_summary: [summarized]
2026-09-23 11:44:39 +02:00
- bucket: portal_invites
attended_by: the inviting group's own desk, or the link in the invite event
initial: open
states:
open: { event: invited }
done: { event: done }
transitions:
open: [done]
2026-09-22 16:51:15 +02:00
" ;
/// Adds the runtime's own state graphs to a content repo's. Content
/// wins if it declares the same bucket (say, to add states).
#[cfg(feature = "ssr" )]
pub fn with_builtin_aggregates (
mut aggregates : std ::collections ::HashMap < String , crate ::aggregates ::AggregateSchema > ,
) -> std ::collections ::HashMap < String , crate ::aggregates ::AggregateSchema > {
let builtin = crate ::aggregates ::parse_aggregates_yaml ( BUILTIN_AGGREGATES_YAML )
. expect ( "builtin aggregates yaml is valid" );
for ( bucket , schema ) in builtin {
aggregates . entry ( bucket ). or_insert ( schema );
}
aggregates
}
/// True when some kv resource in the content reads `bucket` - the
/// attended-bucket check, exposed so the lint can warn about the
/// runtime's own `portal_events` (a warning, not a failure: a repo
/// with no event pages has nothing to attend).
#[cfg(feature = "ssr" )]
pub fn bucket_is_read ( questions : & std ::collections ::HashMap < String , Question > , bucket : & str ) -> bool {
questions . values (). any ( | q | {
q . alternatives . iter (). any ( | a | {
a . features
. iter ()
. any ( | f | f . resource . as_ref (). and_then ( | r | r . bucket ()). is_some_and ( | b | b == bucket ))
})
})
}
#[cfg(feature = "ssr" )]
pub fn validate_questions (
questions : & std ::collections ::HashMap < String , Question > ,
aggregates : & std ::collections ::HashMap < String , crate ::aggregates ::AggregateSchema > ,
) -> anyhow ::Result < () > {
2026-09-23 19:45:48 +02:00
// A mail to the submitter needs an address to go to: every form
// that records into that bucket has to collect a required email.
for ( bucket , schema ) in aggregates {
let submitter_states : Vec <& String > = schema . mail_for_state . iter (). filter ( | ( _ , m ) | m . to_submitter ()). map ( | ( s , _ ) | s ). collect ();
if submitter_states . is_empty () {
continue ;
}
for question in questions . values () {
for alternative in question . alternatives . iter (). filter ( | a | a . record_as . as_deref () == Some ( bucket . as_str ())) {
let collects_email = alternative
. features
. iter ()
. flat_map ( | f | & f . requirements )
. any ( | r | r . name == "email" && r . kind == "email" && ! r . optional );
if ! collects_email {
anyhow ::bail! (
"question {:?} alternative {:?}: records into {bucket:?}, whose state(s) {:?} mail the submitter, so it must collect a required `email` field of type email" ,
question . id , alternative . name , submitter_states
);
}
}
}
}
2026-09-22 16:51:15 +02:00
for question in questions . values () {
if let Some ( event ) = & question . event {
if question . is_dynamic () {
anyhow ::bail! ( "question {:?}: a dynamic page can't carry an event" , question . id );
}
if let Err ( e ) = event . window_ms () {
anyhow ::bail! ( "question {:?}: event: {e}" , question . id );
}
}
2026-09-24 21:17:28 +02:00
if let Some ( place ) = & question . place {
if let Err ( e ) = place . validate () {
anyhow ::bail! ( "question {:?}: place: {e}" , question . id );
}
}
2026-09-22 16:51:15 +02:00
if let Some ( target ) = & question . requires_chain {
if ! questions . contains_key ( target ) {
anyhow ::bail! (
"question {:?}: requires_chain {:?} does not match any declared question id" ,
question . id ,
target
);
}
}
for alternative in & question . alternatives {
// A dangling action is a literal dead end: the submit
// button navigates to "Nothing here".
if let Some ( action ) = & alternative . action {
if action . contains ( '{' ) {
// Placeholders are filled at submit time; the
// shape must land on a dynamic page once filled.
let probe = template_action ( action , & serde_json ::json! ({}));
let filled = {
let mut a = action . clone ();
while let ( Some ( i ), Some ( j )) = ( a . find ( '{' ), a . find ( '}' )) {
a . replace_range ( i ..= j , "x" );
}
a
};
let _ = probe ;
// Match the pattern itself: resolve_question would
// hand back a clone whose id is already concrete.
let lands = questions
. values ()
. any ( | q | q . is_dynamic () && path_matches ( & q . id , & filled ). is_some ());
if lands {
continue ;
}
anyhow ::bail! (
"question {:?} alternative {:?}: templated action {:?} must land on a dynamic page ([name].yaml)" ,
question . id , alternative . name , action
);
}
match questions . get ( action ) {
None => anyhow ::bail! (
"question {:?} alternative {:?}: action {:?} does not match any declared question id" ,
question . id , alternative . name , action
),
// A dynamic page needs a concrete segment value to
// be a URL - a submit button can't supply one.
Some ( target ) if target . is_dynamic () => anyhow ::bail! (
"question {:?} alternative {:?}: action {:?} targets a dynamic page - actions must name a concrete question" ,
question . id , alternative . name , action
),
Some ( _ ) => {}
}
}
2026-09-23 11:44:39 +02:00
if let Some ( invite ) = & alternative . invite {
if question . qualifies . is_none () {
anyhow ::bail! (
"question {:?} alternative {:?}: an invite into {:?} must sit on a page gated by `qualifies` - that group is who may extend it" ,
question . id , alternative . name , invite . group
);
}
let has = | name : & str | {
alternative
. features
. iter ()
. flat_map ( | f | & f . requirements )
. any ( | r | r . name == name && ! r . optional )
};
if ! has ( "name" ) || ! has ( "email" ) {
anyhow ::bail! (
"question {:?} alternative {:?}: an invite must collect required `name` and `email` fields" ,
question . id , alternative . name
);
}
}
2026-09-22 16:51:15 +02:00
if let Some ( st ) = & alternative . self_transition {
if let Some ( schema ) = aggregates . get ( & st . bucket ) {
if ! schema . has_state ( & st . to ) {
anyhow ::bail! (
"question {:?} alternative {:?}: self_transition.to {:?} is not a declared state for bucket {:?}" ,
question . id , alternative . name , st . to , st . bucket
);
}
}
}
// Bind targets resolve across the whole alternative, same
// scope as the signal maps the renderer builds.
let sibling_names : std ::collections ::HashSet <& str > = alternative
. features
. iter ()
. flat_map ( | f | f . requirements . iter ())
. map ( | r | r . name . as_str ())
. collect ();
for feature in & alternative . features {
for requirement in & feature . requirements {
if requirement . kind == "select"
&& requirement . resource . is_none ()
&& requirement . options . is_empty ()
{
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares neither a resource nor inline options" ,
question . id , alternative . name , feature . name , requirement . name
);
}
if let Some ( relay ) = & requirement . relay {
if requirement . kind != "gesture" && requirement . kind != "voice" {
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture or voice" ,
question . id , alternative . name , feature . name , requirement . name , requirement . kind
);
}
validate_relay_url ( relay ). map_err ( | e | anyhow ::anyhow! (
"question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}" ,
question . id , alternative . name , feature . name , requirement . name
)) ? ;
}
if requirement . kind == "gesture"
&& ( requirement . resource . is_some () || requirement . bind . is_some ())
{
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: gesture - it draws its value, it can't also load one from a resource or bind" ,
question . id , alternative . name , feature . name , requirement . name
);
}
2026-09-24 21:17:28 +02:00
if requirement . kind == "location" {
if requirement . resource . is_some () || requirement . bind . is_some () {
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - the visitor puts the pin, it can't also load one from a resource or bind" ,
question . id , alternative . name , feature . name , requirement . name
);
}
// `value` opens the map somewhere useful. It is
// the content author's own coordinates, so a
// typo here is a map of the Atlantic.
if let Some ( value ) = & requirement . value {
let parts : Vec <& str > = value . split ( ',' ). map ( str ::trim ). collect ();
let ok = ( 2 ..= 3 ). contains ( & parts . len ())
&& parts . iter (). all ( | p | p . parse ::< f64 > (). is_ok ())
&& parts [ 0 ]. parse ::< f64 > (). is_ok_and ( | lat | ( - 90. 0 ..= 90.0 ). contains ( & lat ))
&& parts [ 1 ]. parse ::< f64 > (). is_ok_and ( | lon | ( - 180. 0 ..= 180.0 ). contains ( & lon ));
if ! ok {
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - its `value` opens the map and must be `lat,lon` or `lat,lon,zoom`, not {value:?}" ,
question . id , alternative . name , feature . name , requirement . name
);
}
}
}
2026-09-22 16:51:15 +02:00
if let Some ( bind ) = & requirement . bind {
if bind . field == requirement . name
|| ! sibling_names . contains ( bind . field . as_str ())
{
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: requirement {:?} binds to {:?}, which is not another requirement on this alternative" ,
question . id , alternative . name , feature . name , requirement . name , bind . field
);
}
}
}
let Some ( resource ) = & feature . resource else {
continue ;
};
let Some ( bucket ) = resource . bucket () else {
continue ;
};
let Some ( schema ) = aggregates . get ( bucket ) else {
continue ;
};
for transition in & resource . transitions {
if ! schema . has_state ( & transition . from ) {
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}" ,
question . id , alternative . name , feature . name , transition . from , bucket
);
}
if ! schema . has_state ( & transition . to ) {
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}" ,
question . id , alternative . name , feature . name , transition . to , bucket
);
}
if ! schema . allowed ( & transition . from ). iter (). any ( | s | s == & transition . to ) {
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}" ,
question . id , alternative . name , feature . name , transition . from , transition . to , bucket
);
}
}
}
}
}
// Attended buckets: no publicly collected answer may land somewhere
// nothing reads. Every `record_as` bucket must either be read back
// by some Kv resource in this same content repo (a desk or listing)
// or carry an explicit `attended_by:` annotation in aggregates.yaml
// naming the automation that consumes it. This is a contract, not a
// convention, because convention already failed once: a question
// was dropped and its bucket - answers included - silently fell out
// of every reader's view.
let mut read_buckets = std ::collections ::HashSet ::new ();
let note_resource = | spec : & ResourceSpec , set : & mut std ::collections ::HashSet < String >| {
if let ResourceSource ::Kv { bucket } = & spec . source {
set . insert ( bucket . clone ());
}
};
for question in questions . values () {
for alternative in & question . alternatives {
for feature in & alternative . features {
if let Some ( spec ) = & feature . resource {
note_resource ( spec , & mut read_buckets );
}
for requirement in & feature . requirements {
if let Some ( spec ) = & requirement . resource {
note_resource ( spec , & mut read_buckets );
}
if let Some ( bind ) = & requirement . bind {
note_resource ( & bind . resource , & mut read_buckets );
}
}
}
}
}
for question in questions . values () {
for alternative in & question . alternatives {
if let Some ( bucket ) = & alternative . record_as {
let attended = read_buckets . contains ( bucket )
|| aggregates
. get ( bucket )
. is_some_and ( | schema | schema . attended_by . is_some ());
if ! attended {
anyhow ::bail! (
"question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it." ,
question . id , alternative . name
);
}
}
}
}
Ok (())
}