2026-09-21 21:01:35 +02:00
//! Structure from the plan, words from the model.
//!
//! A `plan.yaml` is a needs spec that also says what each form collects.
//! From it, with no model involved, come `needs.yaml`,
//! `aggregates.yaml`, `site.yaml`, the front door, a followup page per
//! need, a directory per group (gated by `_section.yaml`) holding its
//! desk and the pages only that group may open, and every desk button
//! the state machine allows. What the business said determines all of
//! that; a model has nothing to decide there and much to get wrong
//! (measured: a dropped stage from a strong model, no loadable site at
//! all from a small one).
//!
//! The model writes the words: one small JSON object per page - the
//! question, headings, descriptions, field and button labels - in the
//! plan's language. Words are merged over built-in fallbacks key by
//! key, so a reply that is missing, malformed or partial costs wording,
//! never a working site.
use std ::collections ::{ BTreeMap , BTreeSet };
use std ::path ::Path ;
use anyhow ::{ anyhow , bail , Context };
use serde ::Deserialize ;
use serde_json ::{ json , Map , Value };
use crate ::simulate ::first_object ;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plan {
pub business : String ,
#[serde(default = "english" )]
pub language : String ,
pub contact : Contact ,
2026-09-23 21:18:14 +02:00
/// The sender of every mail the site sends (`mail:` in site.yaml).
/// Required as soon as any need has `mails:`.
#[serde(default)]
pub mail : Option < PlanMail > ,
2026-09-21 21:01:35 +02:00
/// What the business does, in its own words - context for the
/// model's wording, never structure.
#[serde(default)]
pub about : String ,
2026-09-23 12:31:51 +02:00
/// A first question before the forms. Each door is one alternative
/// on the front door leading to a page of its own, where the needs
/// that name it sit. Five or six needs on one page is more than a
/// visitor sorts through; two or three doors of two or three each
/// is what the page-load limit asks for.
#[serde(default)]
pub doors : Vec < Door > ,
/// Groups with a label the public may see: the committees a
/// person can ask to join. A select field with `options_from:
/// groups` offers exactly these, so the form and the desks can
/// never disagree about what the committees are.
#[serde(default)]
pub groups : Vec < GroupDef > ,
2026-09-23 12:42:23 +02:00
/// Groups that may invite people into every group with a desk (the
/// board), besides each group inviting into itself. An inviting
/// group's desk gets one invite form per group it may fill (portal
/// creates the account and a one-time credential link) and a list
/// of the invites sent. This is onboarding from the content side:
/// no admin session, a member of the business vouching for a person.
#[serde(default)]
pub invites_by : Vec < String > ,
2026-09-23 18:24:23 +02:00
/// What the business does, said plainly, one feature each: the
/// front door's first card, which asks for nothing. A stranger
/// reads it and decides whether to trust the place before any
/// button is offered. Empty means one feature per public need,
/// from its `wants`.
#[serde(default)]
pub does : Vec < Does > ,
/// Things a visitor may look at before asking for anything - the
/// window shopping: a list of what has been built, releases, a
/// live count - each a portal resource block, passed through as
/// written. Public views of buckets (`watched_by: [public]`) are
/// listed here on their own.
#[serde(default)]
pub shows : Vec < Show > ,
2026-09-21 21:01:35 +02:00
pub needs : Vec < PlanNeed > ,
#[serde(default)]
pub personas : Vec < Value > ,
}
2026-09-23 18:24:23 +02:00
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Does {
pub id : String ,
/// The fallback description, in the business's words.
pub text : String ,
/// The need this feature speaks for: one role, so the opener is
/// one or two roles meeting, not a catalogue.
#[serde(default, rename = "for" )]
pub for_need : Option < String > ,
}
/// Features the opener may carry. One or two, each for a role: past
/// that the opener is a menu again, and a menu is what it is there
/// to spare the visitor.
const MAX_OPENER : usize = 2 ;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Show {
pub id : String ,
#[serde(default)]
pub text : String ,
/// Portal's `resource:` block for a feature, verbatim (source,
/// jq, public, empty). The lint holds it to portal's schema.
pub resource : Value ,
}
2026-09-21 21:01:35 +02:00
fn english () -> String {
"en" . to_string ()
}
2026-09-23 12:31:51 +02:00
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupDef {
pub id : String ,
pub label : String ,
2026-09-23 12:42:23 +02:00
/// Groups that may invite people into this one, besides itself and
/// the plan's `invites_by`.
#[serde(default)]
pub invited_by : Vec < String > ,
2026-09-23 12:31:51 +02:00
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Door {
pub id : String ,
/// Who comes through it, in plain words: context for the model's
/// heading, and the fallback description.
pub who : String ,
}
2026-09-21 21:01:35 +02:00
#[derive(Debug, Deserialize)]
pub struct Contact {
pub name : String ,
pub email : String ,
}
2026-09-23 21:18:14 +02:00
/// Who the site's mail comes from: portal sends nothing without it,
/// and gdo on the host has to be allowed to send for the domain.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanMail {
pub from : String ,
#[serde(default)]
pub reply_to : Option < String > ,
}
2026-09-21 21:01:35 +02:00
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanNeed {
pub id : String ,
pub who : String ,
pub wants : String ,
#[serde(default)]
pub enters_as : Option < String > ,
2026-09-23 12:31:51 +02:00
/// The door this public need sits behind; none means straight on
/// the front door.
#[serde(default)]
pub door : Option < String > ,
2026-09-21 21:01:35 +02:00
pub lands_in : String ,
pub handled_by : String ,
/// Every stage, first to last, finished states included.
pub stages : Vec < String > ,
pub done_when : Vec < String > ,
#[serde(default)]
pub success : Vec < String > ,
/// Stages a case may pass over ("awaiting papers" when the papers
/// were complete).
#[serde(default)]
pub skippable : Vec < String > ,
2026-09-21 21:46:12 +02:00
/// Who presses the button that moves a case INTO a stage, where it
/// is not `handled_by`: another group, several groups, or
/// `submitter` - the person who sent the form, through their own
/// link (the form must then collect a required `email`). This is
/// how roles overlap on one aggregate: a project manager prices a
/// change, the client approves it, a site manager completes it.
#[serde(default)]
pub moves_by : BTreeMap < String , Vec < String >> ,
/// Groups that see these cases on their desk and move nothing.
#[serde(default)]
pub watched_by : Vec < String > ,
2026-09-23 14:49:25 +02:00
/// The event name a stage is entered by, where it must not be the
/// builder's default (the stage's own name; `received` for the
/// first). A live site replays its records by event name, so a
/// rebuild of a site that already holds records has to keep the
/// names its aggregates.yaml has, or every record vanishes from
/// its desk.
#[serde(default)]
pub events : BTreeMap < String , String > ,
/// Who moves cases that no desk button moves - an automation
/// listening on the bucket's events. Written into aggregates.yaml
/// for the reader, as portal's `attended_by` annotation.
#[serde(default)]
pub attended_by : Option < String > ,
2026-09-23 21:18:14 +02:00
/// Who hears when a case enters a stage: `submitter` (the person
/// who sent the form, at the email it collected) or one address
/// (the treasurer, the board). Portal sends it the moment the
/// case moves; the words model writes the subject and body.
#[serde(default)]
pub mails : BTreeMap < String , String > ,
2026-09-21 21:01:35 +02:00
pub collects : Vec < Field > ,
2026-09-23 12:31:51 +02:00
/// How many required fields this person will tolerate: what the
/// business said about them, not what the form happens to ask.
/// The form is refused when it asks for more, and the verifier is
/// told this number, so the check means something after handover.
pub max_fields : usize ,
2026-09-21 21:01:35 +02:00
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Field {
pub name : String ,
#[serde(default = "text" , rename = "type" )]
pub kind : String ,
#[serde(default)]
pub optional : bool ,
#[serde(default)]
pub options : Vec < String > ,
2026-09-23 12:31:51 +02:00
/// `select` only: pick any number instead of exactly one.
#[serde(default)]
pub multiple : bool ,
/// `select` only: `groups` takes the options from the plan's
/// labelled groups instead of a list here.
#[serde(default)]
pub options_from : Option < String > ,
/// Never shown on a public view of the bucket. `email` and `tel`
/// fields are private whether or not this is set.
#[serde(default)]
pub private : bool ,
2026-09-21 21:01:35 +02:00
}
2026-09-23 12:31:51 +02:00
impl Field {
/// What a public listing may show: everything but contact details
/// and what the plan marks private.
pub fn is_public ( & self ) -> bool {
! self . private && self . kind != "email" && self . kind != "tel"
}
}
/// The name a plan uses in `watched_by` for everyone: a read-only page
/// of the bucket, no login.
pub const PUBLIC : & str = "public" ;
2026-09-21 21:01:35 +02:00
fn text () -> String {
"text" . to_string ()
}
const FIELD_TYPES : & [ & str ] = & [ "text" , "email" , "tel" , "textarea" , "date" , "select" ];
fn is_id ( s : & str ) -> bool {
! s . is_empty () && s . chars (). all ( | c | c . is_ascii_lowercase () || c . is_ascii_digit () || c == '_' || c == '-' )
}
impl Plan {
pub fn validate ( & self ) -> anyhow ::Result < () > {
2026-09-23 12:31:51 +02:00
let mut doors = BTreeSet ::new ();
for door in & self . doors {
if ! is_id ( & door . id ) || ! doors . insert ( & door . id ) {
bail! ( "door {:?}: id must be unique ASCII lowercase with - or _" , door . id );
}
if ! self . needs . iter (). any ( | n | n . door . as_ref () == Some ( & door . id )) {
bail! ( "door {:?} leads nowhere: no need names it" , door . id );
}
if groups ( self ). contains ( & door . id ) {
bail! ( "door {:?} is also a group; a door's page is public and a group's is gated" , door . id );
}
if door . id == "status" || door . id == "received" || door . id == "decide" {
bail! ( "door {:?}: that path is taken by the builder's own pages" , door . id );
}
}
2026-09-23 18:24:23 +02:00
let mut cards = BTreeSet ::new ();
for id in self . does . iter (). map ( | d | & d . id ). chain ( self . shows . iter (). map ( | s | & s . id )) {
if ! is_id ( id ) || ! cards . insert ( id ) {
bail! ( "does/shows {id:?}: id must be unique ASCII lowercase with - or _" );
}
}
for show in & self . shows {
if ! show . resource . is_object () || show . resource . get ( "source" ). is_none () {
bail! ( "shows {:?}: resource must be portal's resource block, with a source" , show . id );
}
}
if self . does . len () > MAX_OPENER {
bail! ( "does: the opener advertises one or two features, one role each; {} is a menu" , self . does . len ());
}
for d in & self . does {
if let Some ( need ) = & d . for_need {
if ! self . needs . iter (). any ( | n | & n . id == need ) {
bail! ( "does {:?}: `for` names {need:?}, which is not one of the needs" , d . id );
}
}
}
2026-09-23 12:31:51 +02:00
let mut labelled = BTreeSet ::new ();
for g in & self . groups {
if ! is_id ( & g . id ) || ! labelled . insert ( & g . id ) || g . label . trim (). is_empty () {
bail! ( "group {:?}: id must be unique ASCII lowercase with - or _, and it needs a label" , g . id );
}
}
2026-09-23 12:42:23 +02:00
for g in self . invites_by . iter (). chain ( self . groups . iter (). flat_map ( | g | g . invited_by . iter ())) {
if ! groups ( self ). contains ( g ) {
bail! ( "{g:?} may invite, but has no desk to invite from: it must handle, watch or move something" );
}
}
2026-09-21 21:01:35 +02:00
let mut seen = BTreeSet ::new ();
for need in & self . needs {
let at = | what : & str | format! ( "need {:?} : {what} " , need . id );
if ! is_id ( & need . id ) || ! seen . insert ( & need . id ) {
bail! ( at ( "id must be unique ASCII lowercase with - or _" ));
}
for id in [ & need . lands_in , & need . handled_by ]. into_iter (). chain ( & need . enters_as ). chain ( & need . stages ) {
if ! is_id ( id ) {
bail! ( at ( & format! ( " {id:?} is an identifier and must be ASCII lowercase with - or _" )));
}
}
if need . stages . len () < 2 {
bail! ( at ( "stages needs a first stage and at least one finished one" ));
}
2026-09-23 12:31:51 +02:00
if let Some ( door ) = & need . door {
if ! doors . contains ( door ) {
bail! ( at ( & format! ( "door {door:?} is not one of the plan's doors" )));
}
if need . enters_as . is_some () {
bail! ( at ( "a door is a public way in; a need with enters_as has its group's page instead" ));
}
}
2026-09-21 21:46:12 +02:00
for ( state , movers ) in & need . moves_by {
if ! need . stages [ 1 .. ]. contains ( state ) {
bail! ( at ( & format! ( "moves_by names {state:?} , which is not a stage a case can move into" )));
}
if movers . is_empty () || movers . iter (). any ( | m | ! is_id ( m )) {
bail! ( at ( & format! ( "moves_by {state:?} must list groups or `submitter`" )));
}
if movers . iter (). any ( | m | m == SUBMITTER )
&& ! need . collects . iter (). any ( | f | f . name == "email" && f . kind == "email" && ! f . optional )
{
bail! ( at ( "a move by the submitter is authorized by their email, so the form must collect a required `email` of type email" ));
}
}
if need . watched_by . iter (). any ( | g | ! is_id ( g )) {
2026-09-23 12:31:51 +02:00
bail! ( at ( "watched_by must list group identifiers, or `public`" ));
}
if need . watched_by . iter (). any ( | g | g == PUBLIC ) && need . enters_as . is_some () {
bail! ( at ( "a public view is for what the public sent; an internal need has its group's desk" ));
2026-09-21 21:46:12 +02:00
}
2026-09-21 21:01:35 +02:00
for state in need . done_when . iter (). chain ( & need . skippable ) {
if ! need . stages . contains ( state ) {
bail! ( at ( & format! ( " {state:?} is not one of its stages" )));
}
}
2026-09-23 21:18:14 +02:00
for ( state , to ) in & need . mails {
if ! need . stages . contains ( state ) {
bail! ( at ( & format! ( "mails names {state:?} , which is not one of its stages" )));
}
if to == SUBMITTER {
if ! need . collects . iter (). any ( | f | f . name == "email" && f . kind == "email" && ! f . optional ) {
bail! ( at ( "a mail to the submitter goes to the email the form collected, so the form must collect a required `email` of type email" ));
}
} else if ! to . contains ( '@' ) || to . contains ( char ::is_whitespace ) {
bail! ( at ( & format! ( "mails {state:?} must go to `submitter` or one address, not {to:?} " )));
}
if self . mail . is_none () {
bail! ( at ( "has mails, so the plan needs `mail: { from: ... }` saying who they come from" ));
}
}
2026-09-23 14:49:25 +02:00
for ( state , event ) in & need . events {
if ! need . stages . contains ( state ) {
bail! ( at ( & format! ( "events names {state:?} , which is not one of its stages" )));
}
if ! is_id ( event ) {
bail! ( at ( & format! ( "event {event:?} for {state:?} must be ASCII lowercase with - or _" )));
}
}
2026-09-21 21:01:35 +02:00
for state in & need . success {
if ! need . done_when . contains ( state ) {
bail! ( at ( & format! ( "success {state:?} is not one of done_when" )));
}
}
if need . done_when . is_empty () || need . done_when . contains ( & need . stages [ 0 ]) {
bail! ( at ( "done_when must name finished states, and the first stage cannot be one" ));
}
2026-09-23 12:31:51 +02:00
let required = need . collects . iter (). filter ( | f | ! f . optional ). count ();
if need . max_fields == 0 {
bail! ( at ( "max_fields is how many required fields this person will tolerate, and must be at least 1" ));
}
if required > need . max_fields {
bail! ( at ( & format! ( "the form asks for {required} required field(s) and this person will tolerate {} ; mark some optional or drop them" , need . max_fields )));
}
2026-09-21 21:01:35 +02:00
for field in & need . collects {
if ! is_id ( & field . name ) || ! FIELD_TYPES . contains ( & field . kind . as_str ()) {
bail! ( at ( & format! ( "field {:?} : name must be an identifier and type one of {FIELD_TYPES:?} " , field . name )));
}
2026-09-23 12:31:51 +02:00
match ( field . kind . as_str (), field . options_from . as_deref ()) {
( "select" , Some ( "groups" )) => {
if self . groups . is_empty () {
bail! ( at ( & format! ( "field {:?} takes its options from groups, and the plan has no labelled `groups`" , field . name )));
}
if ! field . options . is_empty () {
bail! ( at ( & format! ( "field {:?} has both options and options_from" , field . name )));
}
}
( "select" , None ) if field . options . is_empty () => {
bail! ( at ( & format! ( "field {:?} is a select and lists no options" , field . name )));
}
( "select" , None ) => {}
( _ , Some ( _ )) => bail! ( at ( & format! ( "field {:?} : options_from is for select fields, and only `groups`" , field . name ))),
( kind , None ) if field . multiple && kind != "select" => {
bail! ( at ( & format! ( "field {:?} : multiple is for select fields" , field . name )))
}
_ => {}
2026-09-21 21:01:35 +02:00
}
}
}
// Needs sharing a bucket share its machine.
for ( bucket , needs ) in self . by_bucket () {
let first = needs [ 0 ];
for other in & needs [ 1 .. ] {
2026-09-21 21:46:12 +02:00
if other . stages != first . stages
|| other . done_when != first . done_when
|| other . skippable != first . skippable
|| other . moves_by != first . moves_by
|| other . watched_by != first . watched_by
|| other . handled_by != first . handled_by
2026-09-23 14:49:25 +02:00
|| other . events != first . events
|| other . attended_by != first . attended_by
2026-09-23 21:18:14 +02:00
|| other . mails != first . mails
2026-09-21 21:46:12 +02:00
{
2026-09-23 21:18:14 +02:00
bail! ( "needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by, mails and who moves and watches it" , first . id , other . id );
2026-09-21 21:01:35 +02:00
}
}
}
Ok (())
}
2026-09-23 18:24:23 +02:00
/// The house and its players: a role the opener invites in has to
/// see its win, and the machine has to give it something to do
/// after it sits down - a move of its own (`moves_by: submitter`)
/// or at least a view of the table (`watched_by: [public]`). A
/// featured role with neither is a spectator, and the build says
/// so; it does not refuse, since the plan may mean it.
pub fn spectators ( & self ) -> Vec < String > {
self . does
. iter ()
. filter_map ( | d | d . for_need . as_ref (). and_then ( | id | self . needs . iter (). find ( | n | & n . id == id )))
. filter ( | n | ! n . moves_by . values (). any ( | m | m . iter (). any ( | w | w == SUBMITTER )) && ! n . watched_by . iter (). any ( | g | g == PUBLIC ))
. map ( | n | {
format! (
"the opener invites {} in, and after sending they play nothing: give need {:?} a move of theirs (moves_by: {{ <stage>: [submitter, ...] }} ) or a public view (watched_by: [public])" ,
n . who , n . id
)
})
. collect ()
}
2026-09-23 12:31:51 +02:00
/// What sits on the front door: each door, and each public need
/// with no door.
fn front_door_count ( & self ) -> usize {
self . doors . len () + self . needs . iter (). filter ( | n | n . enters_as . is_none () && n . door . is_none ()). count ()
}
fn needs_behind ( & self , door : & str ) -> Vec <& PlanNeed > {
self . needs . iter (). filter ( | n | n . door . as_deref () == Some ( door )). collect ()
}
2026-09-21 21:01:35 +02:00
fn by_bucket ( & self ) -> BTreeMap <& str , Vec <& PlanNeed >> {
let mut map : BTreeMap <& str , Vec <& PlanNeed >> = BTreeMap ::new ();
for need in & self . needs {
map . entry ( & need . lands_in ). or_default (). push ( need );
}
map
}
}
/// The moves out of every unfinished stage. The unfinished stages form
/// the line a case walks; from each, the next stage, plus whatever lies
/// beyond a run of skippable ones, plus every finished state that is
/// not a success (a case can be turned down at any point). Success is
/// only reachable from the end of the line.
pub fn machine ( need : & PlanNeed ) -> BTreeMap < String , Vec < String >> {
let line : Vec <& String > = need . stages . iter (). filter ( | s | ! need . done_when . contains ( s )). collect ();
let ends_well : Vec <& String > = if need . success . is_empty () { need . done_when . iter (). collect () } else { need . success . iter (). collect () };
let ends_badly : Vec <& String > = need . done_when . iter (). filter ( | s | ! ends_well . contains ( s )). collect ();
let mut moves = BTreeMap ::new ();
for ( i , stage ) in line . iter (). enumerate () {
let mut to : Vec < String > = Vec ::new ();
let mut j = i + 1 ;
loop {
match line . get ( j ) {
Some ( next ) => {
to . push (( * next ). clone ());
if ! need . skippable . contains ( next ) {
break ;
}
j += 1 ;
}
None => {
to . extend ( ends_well . iter (). map ( | s | ( * s ). clone ()));
break ;
}
}
}
to . extend ( ends_badly . iter (). map ( | s | ( * s ). clone ()));
moves . insert (( * stage ). clone (), to );
}
moves
}
2026-09-23 14:49:25 +02:00
/// The event that enters a stage: what the plan says, else the stage's
/// own name, with the first stage entered by `received` (or `submitted`
/// when a stage is itself called received).
pub fn event_for ( need : & PlanNeed , stage : & str ) -> String {
if let Some ( event ) = need . events . get ( stage ) {
return event . clone ();
}
if stage == need . stages [ 0 ] {
return if need . stages . iter (). any ( | st | st == "received" ) { "submitted" } else { "received" }. to_string ();
}
stage . to_string ()
}
2026-09-23 12:31:51 +02:00
/// The stages a case goes through, as one sentence: "It is received,
/// then surveyed, then quoted, and ends won or lost."
fn stages_sentence ( need : & PlanNeed ) -> String {
let line : Vec < String > = need . stages . iter (). filter ( | s | ! need . done_when . contains ( s )). map ( | s | humanize ( s ). to_lowercase ()). collect ();
let ends : Vec < String > = need . done_when . iter (). map ( | s | humanize ( s ). to_lowercase ()). collect ();
let mut out = String ::from ( "It is " );
out . push_str ( & line . join ( ", then " ));
if ends . len () == 1 {
out . push_str ( & format! ( ", and ends {} ." , ends [ 0 ]));
} else {
out . push_str ( & format! ( ", and ends {} or {} ." , ends [ .. ends . len () - 1 ]. join ( ", " ), ends [ ends . len () - 1 ]));
}
out
}
2026-09-21 21:01:35 +02:00
fn humanize ( id : & str ) -> String {
let mut words = id . replace ([ '_' , '-' ], " " );
if let Some ( first ) = words . get_mut ( 0 .. 1 ) {
first . make_ascii_uppercase ();
}
words
}
fn public_followup ( need : & PlanNeed ) -> String {
format! ( "/received/ {} " , need . id )
}
fn internal_page ( need : & PlanNeed , group : & str ) -> String {
format! ( "/ {group} / {} " , need . id )
}
/// The words every page needs, as built-in fallbacks: plain, English,
/// made from the plan's own text. Keyed by file path, then the same
/// JSON shape the model is asked for.
pub fn fallback_words ( plan : & Plan ) -> BTreeMap < String , Value > {
2026-09-23 12:31:51 +02:00
// Every part of the card portal renders has a slot: the model
// gets to write the button, an encouragement, the section's icon
// and guidance line, and a placeholder per field, not only the
// heading and description. Empty fallbacks render as absent.
2026-09-21 21:01:35 +02:00
let form = | need : & PlanNeed | {
json! ({
"heading" : need . wants ,
"description" : format ! ( "For {}." , need . who ),
"button" : "Send" ,
2026-09-23 12:31:51 +02:00
"encouragement" : "" ,
"icon" : "" ,
// Two sections of context the plan already knows: the road
// a case travels, and who is at the other end. The
// fallbacks are built from the plan, so they are true
// before a model has said a word.
"road_title" : "What happens next" ,
"road_description" : stages_sentence ( need ),
"road_icon" : "route" ,
"who_title" : "Who reads it" ,
"who_description" : format ! ( "{}. Questions go to {} at {}." , humanize ( & need . handled_by ), plan . contact . name , plan . contact . email ),
"who_icon" : "users" ,
2026-09-21 21:01:35 +02:00
"form_title" : "Details" ,
2026-09-23 12:31:51 +02:00
"form_description" : "" ,
2026-09-21 21:01:35 +02:00
"labels" : need . collects . iter (). map ( | f | ( f . name . clone (), Value ::String ( humanize ( & f . name )))). collect ::< Map < _ , _ >> (),
2026-09-23 12:31:51 +02:00
"hints" : need . collects . iter (). map ( | f | ( f . name . clone (), Value ::String ( String ::new ()))). collect ::< Map < _ , _ >> (),
2026-09-21 21:01:35 +02:00
})
};
let mut words = BTreeMap ::new ();
2026-09-23 12:31:51 +02:00
let direct : Vec <& PlanNeed > = plan . needs . iter (). filter ( | n | n . enters_as . is_none () && n . door . is_none ()). collect ();
2026-09-23 18:24:23 +02:00
// The opener: what the business does, asking for nothing. From
// the plan's `does`, or one feature per public need.
let does : Map < String , Value > = if plan . does . is_empty () {
// One or two roles, the first the plan names: the visitor the
// business exists for, and the next one it serves.
plan . needs
. iter ()
. filter ( | n | n . enters_as . is_none ())
. take ( MAX_OPENER )
. map ( | n | ( n . id . clone (), json! ({ "heading" : humanize ( & n . wants ), "description" : format ! ( "For {}." , n . who ), "icon" : "" })))
. collect ()
} else {
// A feature for a role is headed by that role's win: what
// the need says they want.
plan . does
. iter ()
. map ( | d | {
let heading = d
. for_need
. as_ref ()
. and_then ( | id | plan . needs . iter (). find ( | n | & n . id == id ))
. map ( | n | humanize ( & n . wants ))
. unwrap_or_else ( || humanize ( & d . id ));
( d . id . clone (), json! ({ "heading" : heading , "description" : d . text , "icon" : "" }))
})
. collect ()
};
let mut shopping : Map < String , Value > =
plan . shows . iter (). map ( | s | ( s . id . clone (), json! ({ "heading" : humanize ( & s . id ), "description" : s . text , "icon" : "" }))). collect ();
for ( bucket , needs ) in public_buckets ( plan ) {
shopping . insert (
format! ( "status- {bucket} " ),
json! ({
"heading" : format ! ( "See all {}" , humanize ( bucket ). to_lowercase ()),
"description" : format ! ( "Everything sent in by {}, and where each one stands." , needs . iter (). map ( | n | n . who . as_str ()). collect ::< Vec < _ >> (). join ( " or " )),
"icon" : "list" ,
}),
);
}
2026-09-21 21:01:35 +02:00
words . insert (
"questions/index.yaml" . to_string (),
json! ({
"question" : "What brings you here?" ,
"description" : plan . about ,
2026-09-23 18:24:23 +02:00
"opener" : { "heading" : "What we do" , "description" : "" , "features" : does },
"shopping" : { "heading" : "Have a look around" , "description" : "" , "items" : shopping },
2026-09-23 12:31:51 +02:00
"doors" : plan . doors . iter (). map ( | d | ( d . id . clone (), json! ({
"heading" : humanize ( & d . id ), "description" : format ! ( "For {}." , d . who ), "button" : "Continue" ,
"icon" : "" , "for_whom" : "" ,
}))). collect ::< Map < _ , _ >> (),
"needs" : direct . iter (). map ( | n | ( n . id . clone (), form ( n ))). collect ::< Map < _ , _ >> (),
2026-09-21 21:01:35 +02:00
}),
);
2026-09-23 12:31:51 +02:00
for door in & plan . doors {
words . insert (
door_path ( door ),
json! ({
"question" : "What do you need?" ,
"description" : format ! ( "For {}." , door . who ),
"needs" : plan . needs_behind ( & door . id ). iter (). map ( | n | ( n . id . clone (), form ( n ))). collect ::< Map < _ , _ >> (),
}),
);
}
2026-09-21 21:01:35 +02:00
for need in & plan . needs {
let ( path , back ) = match & need . enters_as {
None => ( format! ( "questions/received/ {} .yaml" , need . id ), "/" . to_string ()),
Some ( group ) => ( format! ( "questions/ {group} / {} -sent.yaml" , need . id ), format! ( "/ {group} " )),
};
let _ = back ;
2026-09-23 12:31:51 +02:00
let mut page = json! ({
"question" : "What happens now?" ,
"description" : format ! ( "It has reached our {}." , humanize ( & need . handled_by ). to_lowercase ()),
"next_heading" : "We read it and come back to you" ,
"next_description" : format ! ( "Questions in the meantime go to {} at {}." , plan . contact . name , plan . contact . email ),
"back" : "Back" ,
});
if need . watched_by . iter (). any ( | g | g == PUBLIC ) {
page [ "see_all" ] = json! ({ "heading" : format ! ( "See all {}" , humanize ( & need . lands_in ). to_lowercase ()), "description" : "" , "button" : "Open" });
}
words . insert ( path , page );
2026-09-21 21:01:35 +02:00
if let Some ( group ) = & need . enters_as {
let mut page = form ( need );
page [ "question" ] = json! ( format! ( " {} ?" , humanize ( & need . id )));
page [ "page_description" ] = json! ( "" );
words . insert ( format! ( "questions/ {group} / {} .yaml" , need . id ), page );
}
}
for group in groups ( plan ) {
let buckets : Map < String , Value > = plan
. by_bucket ()
. into_iter ()
2026-09-21 21:46:12 +02:00
. filter ( | ( _ , needs ) | on_desk ( needs [ 0 ], & group ))
2026-09-21 21:01:35 +02:00
. map ( | ( bucket , needs ) | {
2026-09-21 21:46:12 +02:00
// Only the buttons this group presses; a watcher gets none.
let moves : Map < String , Value > = moves_of ( needs [ 0 ], & group )
2026-09-21 21:01:35 +02:00
. into_iter ()
2026-09-21 21:46:12 +02:00
. map ( | ( from , to ) | ( format! ( " {from} -> {to} " ), Value ::String ( humanize ( & to ))))
2026-09-21 21:01:35 +02:00
. collect ();
( bucket . to_string (), json! ({ "heading" : humanize ( bucket ), "description" : "" , "moves" : moves }))
})
. collect ();
let links : Map < String , Value > = plan
. needs
. iter ()
. filter ( | n | n . enters_as . as_deref () == Some ( group . as_str ()))
. map ( | n | ( n . id . clone (), json! ({ "heading" : n . wants , "description" : "" , "button" : "Open" })))
. collect ();
2026-09-23 12:42:23 +02:00
// One invite form per group this desk may fill, and the list of
// invites sent, when it may fill any.
let invites : Map < String , Value > = groups ( plan )
. iter ()
. filter ( | g | inviters ( plan , g ). contains ( & group ))
. map ( | g | {
( g . clone (), json! ({
"heading" : format ! ( "Invite someone into {}" , label_of ( plan , g )),
"description" : format ! ( "They get an account, a place in {}, and a one-time link to set their own password or passkey." , label_of ( plan , g )),
"button" : "Invite" ,
"form_title" : "Who" ,
"labels" : { "name" : "Name" , "email" : "Email" },
}))
})
. collect ();
let invited = if invites . is_empty () {
Value ::Null
} else {
json! ({ "heading" : "Invitations sent" , "description" : "Each invite with the link it made. Mark it done when the person is in." , "done" : "Is in" })
};
2026-09-21 21:01:35 +02:00
words . insert (
format! ( "questions/ {group} /index.yaml" ),
2026-09-23 12:42:23 +02:00
json! ({ "question" : "What needs doing?" , "description" : "" , "confirm" : "Confirm" , "buckets" : buckets , "links" : links , "invites" : invites , "invited" : invited }),
2026-09-21 21:01:35 +02:00
);
}
2026-09-23 12:31:51 +02:00
// A public view per bucket the plan opens: a read-only list.
for ( bucket , needs ) in public_buckets ( plan ) {
words . insert (
public_view_path ( bucket ),
json! ({
"question" : format ! ( "What has been sent in about {}?" , humanize ( bucket ). to_lowercase ()),
"description" : format ! ( "Everything sent in by {}, and where each one stands." , needs . iter (). map ( | n | n . who . as_str ()). collect ::< Vec < _ >> (). join ( " or " )),
"heading" : humanize ( bucket ),
"list_description" : "" ,
"back" : "Back" ,
}),
);
}
2026-09-23 21:18:14 +02:00
// The mails: one subject and body per stage that sends one, keyed
// by bucket. Templates in portal's terms: {field} for a field of
// the record, {site}, {chain}, {email}. The fallback says what
// happened and, where the submitter has a move from that stage,
// where to make it.
let mut mails : Map < String , Value > = Map ::new ();
for ( bucket , needs ) in plan . by_bucket () {
let need = needs [ 0 ];
if need . mails . is_empty () {
continue ;
}
// The fallback is true in the plan's language before a model
2026-09-23 22:09:04 +02:00
// has written a word; the phrases live in one table below.
let t = Phrases ::for_language ( & plan . language );
let greeting = match need . collects . iter (). find ( | f | f . name == "name" ) {
Some ( f ) => format! ( " {} {{ {} }} , \n\n " , t . hello , f . name ),
None => format! ( " {} , \n\n " , t . hello ),
2026-09-23 21:18:14 +02:00
};
let mut per_stage : Map < String , Value > = Map ::new ();
for ( stage , to ) in & need . mails {
let stage_word = humanize ( stage ). to_lowercase ();
2026-09-23 22:09:04 +02:00
let what = if stage == & need . stages [ 0 ] {
format! ( " {} {} ." , t . received_what_you_sent_about , need . wants )
} else {
format! ( " {} {} {} {stage_word} ." , t . what_you_sent_about , need . wants , t . is_now )
2026-09-23 21:18:14 +02:00
};
let mut body = if to == SUBMITTER {
format! ( " {greeting}{what} " )
} else {
2026-09-23 22:09:04 +02:00
format! ( " {} {} {} {stage_word} : {{ chain }} ." , t . a_case_in , humanize ( bucket ). to_lowercase (), t . is_now )
2026-09-23 21:18:14 +02:00
};
if to == SUBMITTER && moves_of ( need , SUBMITTER ). iter (). any ( | ( from , _ ) | from == stage ) {
2026-09-23 22:09:04 +02:00
body . push_str ( & format! ( " \n\n {} : {{ site }} /decide/ {} ?chain= {{ chain }} &email= {{ email }} " , t . what_you_can_do_next , need . id ));
2026-09-23 21:18:14 +02:00
}
2026-09-23 22:09:04 +02:00
body . push_str ( & format! ( " \n\n {} {} {} " , plan . contact . name , t . at , plan . contact . email ));
2026-09-23 21:18:14 +02:00
per_stage . insert ( stage . clone (), json! ({ "subject" : format ! ( "{}: {}" , plan . business , humanize ( stage )), "body" : body }));
}
mails . insert ( bucket . to_string (), Value ::Object ( per_stage ));
}
if ! mails . is_empty () {
words . insert ( "aggregates.yaml" . to_string (), Value ::Object ( mails ));
}
2026-09-21 21:46:12 +02:00
// The submitter's own page: reached by their private link, it
// offers the moves that are theirs to make.
for need in & plan . needs {
let theirs = moves_of ( need , SUBMITTER );
if theirs . is_empty () {
continue ;
}
let targets : BTreeSet <& String > = theirs . iter (). map ( | ( _ , to ) | to ). collect ();
words . insert (
decide_path ( need ),
json! ({
"question" : "What would you like to do?" ,
"description" : "" ,
"moves" : targets . iter (). map ( | to | (( * to ). clone (), json! ({
"heading" : humanize ( to ), "description" : "" , "button" : humanize ( to ),
}))). collect ::< Map < _ , _ >> (),
}),
);
}
2026-09-21 21:01:35 +02:00
words
}
2026-09-23 12:31:51 +02:00
fn door_path ( door : & Door ) -> String {
format! ( "questions/ {} /index.yaml" , door . id )
}
2026-09-21 21:46:12 +02:00
fn decide_path ( need : & PlanNeed ) -> String {
format! ( "questions/decide/ {} .yaml" , need . id )
}
const SUBMITTER : & str = "submitter" ;
2026-09-23 22:09:04 +02:00
/// The words of the fallback mails, per language the plans come in.
/// The only place a language other than English appears in this
/// code: everything else the site says in its own language is a word
/// slot the model or a person fills.
struct Phrases {
hello : & 'static str ,
received_what_you_sent_about : & 'static str ,
what_you_sent_about : & 'static str ,
is_now : & 'static str ,
a_case_in : & 'static str ,
what_you_can_do_next : & 'static str ,
at : & 'static str ,
}
impl Phrases {
fn for_language ( lang : & str ) -> Phrases {
if lang . starts_with ( "no" ) || lang . starts_with ( "nb" ) || lang . starts_with ( "nn" ) {
Phrases {
hello : "Hei" ,
received_what_you_sent_about : "Vi har mottatt det du sendte om" ,
what_you_sent_about : "Det du sendte om" ,
is_now : "er nå" ,
a_case_in : "En sak i" ,
what_you_can_do_next : "Det du kan gjøre nå" ,
at : "på" ,
}
} else {
Phrases {
hello : "Hello" ,
received_what_you_sent_about : "We have received what you sent about" ,
what_you_sent_about : "What you sent about" ,
is_now : "is now" ,
a_case_in : "A case in" ,
what_you_can_do_next : "What you can do next" ,
at : "at" ,
}
}
}
}
2026-09-23 12:31:51 +02:00
/// Alternatives on one page before a visitor stops reading them.
2026-09-23 18:24:23 +02:00
const MAX_FRONT_DOOR : usize = 7 ;
2026-09-23 12:31:51 +02:00
2026-09-21 21:46:12 +02:00
/// Who presses the button into `to`.
fn movers < 'a > ( need : & 'a PlanNeed , to : & str ) -> Vec <& 'a str > {
match need . moves_by . get ( to ) {
Some ( list ) => list . iter (). map ( String ::as_str ). collect (),
None => vec! [ need . handled_by . as_str ()],
}
}
/// The (from, to) moves of a bucket that `who` may make.
fn moves_of ( need : & PlanNeed , who : & str ) -> Vec < ( String , String ) > {
machine ( need )
. into_iter ()
. flat_map ( | ( from , tos ) | tos . into_iter (). map ( move | to | ( from . clone (), to )))
. filter ( | ( _ , to ) | movers ( need , to ). contains ( & who ))
. collect ()
}
/// A group's desk shows a bucket when it moves something in it, is
/// answerable for it, or watches it.
fn on_desk ( need : & PlanNeed , group : & str ) -> bool {
2026-09-23 12:31:51 +02:00
group != PUBLIC && ( need . handled_by == group || need . watched_by . iter (). any ( | g | g == group ) || ! moves_of ( need , group ). is_empty ())
2026-09-21 21:46:12 +02:00
}
2026-09-21 21:01:35 +02:00
fn groups ( plan : & Plan ) -> BTreeSet < String > {
2026-09-21 21:46:12 +02:00
plan . needs
. iter ()
. flat_map ( | n | {
[ Some ( n . handled_by . clone ()), n . enters_as . clone ()]
. into_iter ()
. flatten ()
2026-09-23 12:31:51 +02:00
. chain ( n . watched_by . iter (). filter ( | g | * g != PUBLIC ). cloned ())
2026-09-21 21:46:12 +02:00
. chain ( n . moves_by . values (). flatten (). filter ( | m | * m != SUBMITTER ). cloned ())
})
. collect ()
2026-09-21 21:01:35 +02:00
}
2026-09-23 12:42:23 +02:00
/// The groups whose desks carry an invite form into `group`: itself,
/// the plan's `invites_by`, and its own `invited_by`, each of which
/// must have a desk to carry it on.
fn inviters ( plan : & Plan , group : & str ) -> BTreeSet < String > {
let desks = groups ( plan );
std ::iter ::once ( group . to_string ())
. chain ( plan . invites_by . iter (). cloned ())
. chain ( plan . groups . iter (). filter ( | g | g . id == group ). flat_map ( | g | g . invited_by . iter (). cloned ()))
. filter ( | g | desks . contains ( g ))
. collect ()
}
fn label_of ( plan : & Plan , group : & str ) -> String {
plan . groups . iter (). find ( | g | g . id == group ). map ( | g | g . label . clone ()). unwrap_or_else ( || humanize ( group ))
}
2026-09-23 12:31:51 +02:00
/// Buckets the public may read, with the needs that fill them.
fn public_buckets ( plan : & Plan ) -> Vec < ( & str , Vec <& PlanNeed > ) > {
plan . by_bucket (). into_iter (). filter ( | ( _ , needs ) | needs . iter (). any ( | n | n . watched_by . iter (). any ( | g | g == PUBLIC ))). collect ()
}
fn public_view_path ( bucket : & str ) -> String {
format! ( "questions/status/ {bucket} /index.yaml" )
}
/// The options of a select: its own list, or the plan's labelled groups.
fn select_options < 'a > ( plan : & 'a Plan , field : & 'a Field ) -> Vec <& 'a str > {
match field . options_from . as_deref () {
Some ( "groups" ) => plan . groups . iter (). map ( | g | g . label . as_str ()). collect (),
_ => field . options . iter (). map ( String ::as_str ). collect (),
}
}
2026-09-21 21:01:35 +02:00
/// Takes the model's strings where it gave them, key by key, and keeps
/// the fallback everywhere else. The shape always comes from the
/// fallback, so a reply can never add, drop or restructure anything.
pub fn merge_words ( fallback : & Value , given : & Value ) -> Value {
match ( fallback , given ) {
( Value ::Object ( f ), Value ::Object ( g )) => {
Value ::Object ( f . iter (). map ( | ( k , v ) | ( k . clone (), g . get ( k ). map_or_else ( || v . clone (), | gv | merge_words ( v , gv )))). collect ())
}
2026-09-21 21:08:08 +02:00
// An unfilled slot echoed back is no answer.
( Value ::String ( _ ), Value ::String ( s )) if ! s . trim (). is_empty () && ! s . contains ( SLOT ) => {
Value ::String ( s . trim (). to_string ())
}
2026-09-21 21:01:35 +02:00
_ => fallback . clone (),
}
}
2026-09-21 21:08:08 +02:00
const SLOT : & str = "<WRITE" ;
2026-09-23 23:35:32 +02:00
/// A model asked for Norwegian sometimes answers in English, and the
/// reverse; the words then look fine and read wrong. Counts the
/// commonest function words of each language across every string of
/// a reply and names the language that won when it is not the plan's.
/// Only the two languages the plans come in; any other language is
/// left unchecked.
fn wrong_language ( lang : & str , reply : & Value ) -> Option <& 'static str > {
const NORWEGIAN : & [ & str ] = & [ "og" , "er" , "du" , "det" , "ikke" , "til" , "som" , "med" , "på" , "har" , "vi" , "deg" , "en" , "et" ];
const ENGLISH : & [ & str ] = & [ "the" , "and" , "you" , "is" , "are" , "we" , "to" , "of" , "your" , "it" , "in" , "with" , "for" , "a" ];
fn collect ( v : & Value , out : & mut String ) {
match v {
Value ::String ( s ) => {
out . push ( ' ' );
out . push_str ( s );
}
Value ::Object ( m ) => m . values (). for_each ( | v | collect ( v , out )),
Value ::Array ( a ) => a . iter (). for_each ( | v | collect ( v , out )),
_ => {}
}
}
let mut text = String ::new ();
collect ( reply , & mut text );
let words : Vec < String > = text . split ( | c : char | ! c . is_alphabetic ()). filter ( | w | ! w . is_empty ()). map ( | w | w . to_lowercase ()). collect ();
if words . len () < 8 {
return None ;
}
let no = words . iter (). filter ( | w | NORWEGIAN . contains ( & w . as_str ())). count ();
let en = words . iter (). filter ( | w | ENGLISH . contains ( & w . as_str ())). count ();
let plan_is_norwegian = lang . starts_with ( "no" ) || lang . starts_with ( "nb" ) || lang . starts_with ( "nn" );
let plan_is_english = lang . starts_with ( "en" );
match ( plan_is_norwegian , plan_is_english ) {
( true , _ ) if en > no * 2 && en >= 4 => Some ( "English" ),
( _ , true ) if no > en * 2 && no >= 4 => Some ( "Norwegian" ),
_ => None ,
}
}
2026-09-23 23:02:48 +02:00
/// A mail's link to the submitter's own move is structure, not
/// wording: a model told to keep it still drops it (Sonnet replaced
/// the whole line with a bare `{chain}`). Wherever the fallback body
/// had a `{site}/decide/…` line and the model's has none, the line is
/// put back at the end, and a `{chain}` left on a line of its own is
/// removed.
fn keep_links ( fallback : & Value , merged : & mut Value ) {
let ( Some ( fb ), Some ( mb )) = ( fallback . as_object (), merged . as_object_mut ()) else { return };
for ( bucket , stages ) in fb {
let Some ( ms ) = mb . get_mut ( bucket ). and_then ( Value ::as_object_mut ) else { continue };
for ( stage , spec ) in stages . as_object (). into_iter (). flatten () {
let Some ( link ) = spec [ "body" ]. as_str (). and_then ( | b | b . lines (). find ( | l | l . contains ( "{site}/decide/" ))) else { continue };
let Some ( body ) = ms . get_mut ( stage ). and_then ( | m | m . get_mut ( "body" )) else { continue };
let Some ( text ) = body . as_str () else { continue };
if text . contains ( "/decide/" ) {
continue ;
}
let kept : Vec <& str > = text . lines (). filter ( | l | l . trim () != "{chain}" ). collect ();
* body = Value ::String ( format! ( " {} \n\n {link} " , kept . join ( " \n " ). trim_end ()));
}
}
}
2026-09-21 21:08:08 +02:00
/// The page's wording with every string replaced by a slot that says
/// what belongs there. Shown to the model in place of the fallbacks:
/// fallbacks read like finished copy, and a small model hands finished
/// copy straight back (measured: nemotron returned the front door
/// unchanged). A slot cannot be mistaken for an answer.
fn slots ( value : & Value , key : & str ) -> Value {
2026-09-23 12:31:51 +02:00
slots_in ( value , key , "" )
}
fn slots_in ( value : & Value , key : & str , parent : & str ) -> Value {
2026-09-21 21:08:08 +02:00
match value {
2026-09-23 12:31:51 +02:00
Value ::Object ( map ) => Value ::Object ( map . iter (). map ( | ( k , v ) | ( k . clone (), slots_in ( v , k , key ))). collect ()),
2026-09-21 21:08:08 +02:00
Value ::String ( _ ) => Value ::String ( format! (
" {SLOT} : {} >" ,
2026-09-23 12:31:51 +02:00
match ( parent , key ) {
( _ , "question" ) => "the one question this page asks its reader" . to_string (),
( _ , "heading" | "next_heading" ) => "a heading, at most eight words, saying what the reader gets" . to_string (),
( _ , "description" | "page_description" | "next_description" ) => "one or two short sentences" . to_string (),
2026-09-23 20:02:30 +02:00
( _ , "button" | "back" ) => "a button, one to three words, saying what pressing it does" . to_string (),
// One slot serves every list on the desk, so it cannot
// name any one of them: a model once wrote "Send invite"
// under the cases list.
( _ , "confirm" ) => "the one button under every list on this desk, confirming a move the person has chosen; one word, such as Confirm" . to_string (),
2026-09-23 12:31:51 +02:00
( _ , "encouragement" ) => "one reassuring sentence: what happens with what they send, or how little it takes; or empty" . to_string (),
( _ , "icon" ) => "an icon name from the list at the end, the one that fits this best; or empty" . to_string (),
( _ , "for_whom" ) => "a few words naming who this is for, addressed to them; or empty" . to_string (),
( _ , "form_title" ) => "a title for the form, two to four words" . to_string (),
( _ , "form_description" ) => "one short line of guidance above the form, or empty" . to_string (),
( _ , "road_title" ) => "a short title, two to four words, for the line that says what happens after sending" . to_string (),
( _ , "road_description" ) => "one or two sentences walking the reader through the stages this goes through, in order (the stages are listed above), in their words" . to_string (),
( _ , "road_icon" | "who_icon" ) => "an icon name from the list at the end that fits this section; or empty" . to_string (),
( _ , "who_title" ) => "a short title, two to four words, for the line that says who reads it" . to_string (),
( _ , "list_description" ) => "one line over a public list of what people have sent in, or empty" . to_string (),
( _ , "who_description" ) => "one sentence: who reads what they send, and how to reach them meanwhile" . to_string (),
( "hints" , field ) => format! ( "an example answer for the field ` {field} `, as a placeholder; or empty" ),
( _ , other ) if other . contains ( "->" ) => format! ( "a button, two to four words, for a case going {other} " ),
( _ , other ) => format! ( "a form label for the field ` {other} `" ),
2026-09-21 21:08:08 +02:00
}
)),
other => other . clone (),
}
}
2026-09-23 12:31:51 +02:00
/// Icons the words may name: a fixed set from Iconify's lucide pack,
/// so a reply can only pick one that exists. Anything else is dropped.
pub const ICONS : & [ & str ] = & [
"house" , "building-2" , "store" , "school" , "map-pin" , "route" , "footprints" , "trees" , "leaf" , "snowflake" , "sun" ,
"users" , "user" , "user-plus" , "user-check" , "baby" , "heart-handshake" , "hand-helping" , "handshake" , "smile" ,
"mail" , "phone" , "message-circle" , "megaphone" , "bell" , "send" , "inbox" ,
"alert-triangle" , "siren" , "shield" , "construction" , "hammer" , "wrench" , "ruler" , "package" , "truck" , "car" , "bus" , "train-front" , "bike" ,
"calendar" , "clock" , "file-text" , "clipboard-list" , "receipt" , "wallet" , "coins" , "banknote" , "credit-card" ,
"key" , "lock" , "search" , "star" , "gift" , "shopping-cart" , "lightbulb" , "flag" , "thumbs-up" , "check-circle" ,
"briefcase" , "stethoscope" , "activity" , "camera" , "pencil" , "refresh-cw" , "list" , "layers" , "vote" , "scale" ,
];
/// `lucide:<name>` for a name on the list, otherwise nothing.
pub fn icon_name ( given : & str ) -> Option < String > {
2026-09-23 12:42:23 +02:00
// A lift token (see `lift_words`) has to reach the rendered file to
// be matched against the repo's own icon.
if given . starts_with ( '\u{1}' ) {
return Some ( given . to_string ());
}
2026-09-23 12:31:51 +02:00
let name = given . trim (). trim_start_matches ( "lucide:" );
ICONS . contains ( & name ). then ( || format! ( "lucide: {name} " ))
}
2026-09-21 21:01:35 +02:00
fn s ( v : & Value ) -> & str {
v . as_str (). unwrap_or_default ()
}
2026-09-23 12:31:51 +02:00
fn form_alternative ( plan : & Plan , need : & PlanNeed , w : & Value , action : & str ) -> Value {
let mut feature = json! ({
"name" : s ( & w [ "form_title" ]),
"requirements" : need . collects . iter (). map ( | f | {
let mut r = json! ({ "name" : f . name , "label" : s ( & w [ "labels" ][ & f . name ]), "type" : f . kind });
if f . optional { r [ "optional" ] = json! ( true ); }
if f . kind == "select" { r [ "options" ] = json! ( select_options ( plan , f )); }
if f . multiple { r [ "multiple" ] = json! ( true ); }
let hint = s ( & w [ "hints" ][ & f . name ]). trim ();
if ! hint . is_empty () && f . kind != "select" { r [ "placeholder" ] = json! ( hint ); }
r
}). collect ::< Vec < _ >> (),
});
if ! s ( & w [ "form_description" ]). trim (). is_empty () {
feature [ "description" ] = json! ( s ( & w [ "form_description" ]). trim ());
}
if let Some ( icon ) = icon_name ( s ( & w [ "icon" ])) {
feature [ "icon" ] = json! ( icon );
}
// Context first, then the form: the reader learns where this goes
// and who is at the other end before being asked for anything.
let strip = | title : & str , text : & str , icon : & str | {
let mut f = json! ({ "name" : title . trim (), "description" : text . trim () });
if let Some ( icon ) = icon_name ( icon ) { f [ "icon" ] = json! ( icon ); }
f
};
let mut features = Vec ::new ();
if ! s ( & w [ "road_description" ]). trim (). is_empty () {
features . push ( strip ( s ( & w [ "road_title" ]), s ( & w [ "road_description" ]), s ( & w [ "road_icon" ])));
}
if ! s ( & w [ "who_description" ]). trim (). is_empty () {
features . push ( strip ( s ( & w [ "who_title" ]), s ( & w [ "who_description" ]), s ( & w [ "who_icon" ])));
}
features . push ( feature );
let mut alt = json! ({
2026-09-21 21:01:35 +02:00
"name" : s ( & w [ "heading" ]),
"description" : s ( & w [ "description" ]),
"action" : action ,
"consequence" : [ s ( & w [ "button" ])],
"record_as" : need . lands_in ,
2026-09-23 12:31:51 +02:00
"features" : features ,
});
if ! s ( & w [ "encouragement" ]). trim (). is_empty () {
alt [ "encouragements" ] = json! ([ s ( & w [ "encouragement" ]). trim ()]);
}
alt
2026-09-21 21:01:35 +02:00
}
/// Every file of the content repo, from the plan and a set of words.
pub fn render ( plan : & Plan , words : & BTreeMap < String , Value > ) -> anyhow ::Result < BTreeMap < String , String >> {
let mut files : BTreeMap < String , Value > = BTreeMap ::new ();
let responsible = json! ({ "name" : plan . contact . name , "contact" : plan . contact . email });
2026-09-23 21:18:14 +02:00
let mut site = json! ({ "title" : plan . business , "lang" : plan . language });
if let Some ( mail ) = & plan . mail {
site [ "mail" ] = json! ({ "from" : mail . from });
if let Some ( reply_to ) = & mail . reply_to {
site [ "mail" ][ "reply_to" ] = json! ( reply_to );
}
}
files . insert ( "site.yaml" . into (), site );
2026-09-21 21:01:35 +02:00
2026-09-23 21:18:14 +02:00
let mail_words = words . get ( "aggregates.yaml" ). cloned (). unwrap_or ( Value ::Null );
2026-09-21 21:01:35 +02:00
files . insert (
"aggregates.yaml" . into (),
json! ({ "aggregates" : plan . by_bucket (). into_iter (). map ( | ( bucket , needs ) | {
let need = needs [ 0 ];
2026-09-23 14:49:25 +02:00
let mut aggregate = json! ({
2026-09-21 21:01:35 +02:00
"bucket" : bucket ,
2026-09-23 14:49:25 +02:00
"initial" : & need . stages [ 0 ],
2026-09-23 21:18:14 +02:00
"states" : need . stages . iter (). map ( | st | {
let mut state = json! ({ "event" : event_for ( need , st ) });
if let Some ( to ) = need . mails . get ( st ) {
let w = & mail_words [ bucket ][ st ];
state [ "mail" ] = json! ({ "to" : to , "subject" : s ( & w [ "subject" ]), "body" : s ( & w [ "body" ]) });
}
( st . clone (), state )
}). collect ::< Map < _ , _ >> (),
2026-09-21 21:01:35 +02:00
"transitions" : machine ( need ),
2026-09-23 14:49:25 +02:00
});
if let Some ( who ) = & need . attended_by {
aggregate [ "attended_by" ] = json! ( who );
}
aggregate
2026-09-21 21:01:35 +02:00
}). collect ::< Vec < _ >> () }),
);
2026-09-23 18:24:23 +02:00
// The front door opens modestly: first what the business does,
// in features and no button; then what a visitor may look at;
// and only then, further down, one alternative per door and one
// per need the public brings straight to it. A stranger decides
// to trust the place before being asked for anything.
2026-09-21 21:01:35 +02:00
let front = & words [ "questions/index.yaml" ];
2026-09-23 18:24:23 +02:00
let card = | w : & Value | {
let mut f = json! ({ "name" : s ( & w [ "heading" ]), "description" : s ( & w [ "description" ]) });
if let Some ( icon ) = icon_name ( s ( & w [ "icon" ])) {
f [ "icon" ] = json! ( icon );
}
f
};
let does : Vec < Value > = front [ "opener" ][ "features" ]. as_object (). into_iter (). flatten (). map ( | ( _ , w ) | card ( w )). collect ();
let mut alternatives : Vec < Value > = vec! [ json! ({
"name" : s ( & front [ "opener" ][ "heading" ]),
"description" : s ( & front [ "opener" ][ "description" ]),
"features" : does ,
})];
let mut shopping : Vec < Value > = plan
. shows
. iter ()
. map ( | show | {
let mut f = card ( & front [ "shopping" ][ "items" ][ & show . id ]);
f [ "resource" ] = show . resource . clone ();
f
})
. collect ();
for ( bucket , _ ) in public_buckets ( plan ) {
let mut f = card ( & front [ "shopping" ][ "items" ][ & format! ( "status- {bucket} " )]);
f [ "description" ] = json! ( format! ( " {} [ {} ](/status/ {bucket} )" , s ( & f [ "description" ]). trim (), s ( & front [ "shopping" ][ "items" ][ & format! ( "status- {bucket} " )][ "heading" ])));
shopping . push ( f );
}
if ! shopping . is_empty () {
alternatives . push ( json! ({
"name" : s ( & front [ "shopping" ][ "heading" ]),
"description" : s ( & front [ "shopping" ][ "description" ]),
"features" : shopping ,
}));
}
alternatives . extend ( plan
2026-09-23 12:31:51 +02:00
. doors
. iter ()
. map ( | d | {
let dw = & front [ "doors" ][ & d . id ];
let mut alt = json! ({
"name" : s ( & dw [ "heading" ]),
"description" : s ( & dw [ "description" ]),
"action" : format ! ( "/{}" , d . id ),
"consequence" : [ s ( & dw [ "button" ])],
});
// A door carries no form; its icon and "for whom" line
// ride an empty section, which portal renders as a
// labelled strip on the card.
let ( icon , for_whom ) = ( icon_name ( s ( & dw [ "icon" ])), s ( & dw [ "for_whom" ]). trim ());
if icon . is_some () || ! for_whom . is_empty () {
let mut f = json! ({ "name" : for_whom , "description" : "" });
if let Some ( icon ) = icon { f [ "icon" ] = json! ( icon ); }
alt [ "features" ] = json! ([ f ]);
}
alt
2026-09-23 18:24:23 +02:00
}));
2026-09-23 12:31:51 +02:00
alternatives . extend (
plan . needs
. iter ()
. filter ( | n | n . enters_as . is_none () && n . door . is_none ())
. map ( | n | form_alternative ( plan , n , & front [ "needs" ][ & n . id ], & public_followup ( n ))),
);
if plan . front_door_count () > MAX_FRONT_DOOR {
eprintln! (
2026-09-23 18:24:23 +02:00
"build: the front door offers {} alternatives after the opener; past {MAX_FRONT_DOOR} a visitor stops reading. Fewer offers, or needs that share one." ,
2026-09-23 12:31:51 +02:00
plan . front_door_count ()
);
}
2026-09-21 21:01:35 +02:00
files . insert (
"questions/index.yaml" . into (),
2026-09-23 12:31:51 +02:00
json! ({ "name" : s ( & front [ "question" ]), "description" : s ( & front [ "description" ]), "responsible" : responsible , "alternatives" : alternatives }),
2026-09-21 21:01:35 +02:00
);
2026-09-23 12:31:51 +02:00
// A door's page: a nav page (index.yaml), public, holding the
// forms of the needs behind it.
for door in & plan . doors {
let path = door_path ( door );
let w = & words [ & path ];
files . insert (
path ,
json! ({
"name" : s ( & w [ "question" ]),
"description" : s ( & w [ "description" ]),
"responsible" : responsible ,
"alternatives" : plan . needs_behind ( & door . id ). iter (). map ( | n | form_alternative ( plan , n , & w [ "needs" ][ & n . id ], & public_followup ( n ))). collect ::< Vec < _ >> (),
}),
);
}
2026-09-21 21:01:35 +02:00
for need in & plan . needs {
let ( path , back ) = match & need . enters_as {
None => ( format! ( "questions/received/ {} .yaml" , need . id ), "/" . to_string ()),
Some ( group ) => ( format! ( "questions/ {group} / {} -sent.yaml" , need . id ), format! ( "/ {group} " )),
};
let w = & words [ & path ];
2026-09-23 12:31:51 +02:00
let mut alternatives = vec! [
json! ({ "name" : s ( & w [ "next_heading" ]), "description" : s ( & w [ "next_description" ]) }),
];
if need . watched_by . iter (). any ( | g | g == PUBLIC ) {
alternatives . push ( json! ({
"name" : s ( & w [ "see_all" ][ "heading" ]), "description" : s ( & w [ "see_all" ][ "description" ]),
"action" : format ! ( "/status/{}" , need . lands_in ), "consequence" : [ s ( & w [ "see_all" ][ "button" ])],
}));
}
alternatives . push ( json! ({ "name" : s ( & w [ "back" ]), "description" : "" , "action" : back , "consequence" : [ s ( & w [ "back" ])] }));
2026-09-21 21:01:35 +02:00
// Nested and not an index: portal infers a followup page.
2026-09-23 12:31:51 +02:00
files . insert ( path , json! ({ "name" : s ( & w [ "question" ]), "description" : s ( & w [ "description" ]), "alternatives" : alternatives }));
2026-09-21 21:01:35 +02:00
if let Some ( group ) = & need . enters_as {
let path = format! ( "questions/ {group} / {} .yaml" , need . id );
let w = & words [ & path ];
files . insert (
path ,
json! ({
// A way in, not an outcome, though it sits in a directory.
"followup" : false ,
"name" : s ( & w [ "question" ]),
"description" : s ( & w [ "page_description" ]),
"responsible" : responsible ,
2026-09-23 12:31:51 +02:00
"alternatives" : [ form_alternative ( plan , need , w , & format! ( "/ {group} / {} -sent" , need . id ))],
2026-09-21 21:01:35 +02:00
}),
);
}
}
for group in groups ( plan ) {
let w = & words [ & format! ( "questions/ {group} /index.yaml" )];
let mut alternatives = Vec ::new ();
for ( bucket , needs ) in plan . by_bucket () {
2026-09-21 21:46:12 +02:00
if ! on_desk ( needs [ 0 ], & group ) {
2026-09-21 21:01:35 +02:00
continue ;
}
let bw = & w [ "buckets" ][ bucket ];
alternatives . push ( json! ({
"name" : s ( & bw [ "heading" ]),
"description" : s ( & bw [ "description" ]),
"action" : format ! ( "/{group}" ),
"consequence" : [ s ( & w [ "confirm" ])],
"features" : [{ "name" : "" , "description" : "" , "resource" : {
"source" : { "kind" : "kv" , "bucket" : bucket },
"requires_group" : group ,
2026-09-21 21:46:12 +02:00
// This group's buttons only. The same bucket sits on
// other groups' desks with theirs; a watcher's list
// is empty, which portal renders read-only.
"transitions" : moves_of ( needs [ 0 ], & group ). into_iter (). map ( | ( from , to ) | {
json! ({ "from" : from , "to" : to , "label" : s ( & bw [ "moves" ][ & format! ( " {from} -> {to} " )]) })
2026-09-21 21:01:35 +02:00
}). collect ::< Vec < _ >> (),
}}],
}));
}
for need in plan . needs . iter (). filter ( | n | n . enters_as . as_deref () == Some ( group . as_str ())) {
let lw = & w [ "links" ][ & need . id ];
alternatives . push ( json! ({
"name" : s ( & lw [ "heading" ]),
"description" : s ( & lw [ "description" ]),
"action" : internal_page ( need , & group ),
"consequence" : [ s ( & lw [ "button" ])],
}));
}
2026-09-23 12:42:23 +02:00
// Invites this desk may extend: `invite: {group}` is portal's
// onboarding (an account, the group, a one-time credential
// link), gated by this page's `qualifies`; what it made is
// listed from the runtime's portal_invites bucket.
if let Some ( inv ) = w [ "invites" ]. as_object (). filter ( | m | ! m . is_empty ()) {
let mut targets : Vec <& String > = inv . keys (). collect ();
targets . sort ();
for target in targets {
let iw = & inv [ target ];
alternatives . push ( json! ({
"name" : s ( & iw [ "heading" ]),
"description" : s ( & iw [ "description" ]),
"action" : format ! ( "/{group}" ),
"consequence" : [ s ( & iw [ "button" ])],
"invite" : { "group" : target },
"features" : [{ "name" : s ( & iw [ "form_title" ]), "description" : "" , "requirements" : [
{ "name" : "name" , "label" : s ( & iw [ "labels" ][ "name" ]) },
{ "name" : "email" , "label" : s ( & iw [ "labels" ][ "email" ]), "type" : "email" },
]}],
}));
}
let lw = & w [ "invited" ];
alternatives . push ( json! ({
"name" : s ( & lw [ "heading" ]),
"description" : s ( & lw [ "description" ]),
"action" : format ! ( "/{group}" ),
"consequence" : [ s ( & w [ "confirm" ])],
"features" : [{ "name" : "" , "description" : "" , "resource" : {
"source" : { "kind" : "kv" , "bucket" : "portal_invites" },
"requires_group" : group ,
"transitions" : [{ "from" : "open" , "to" : "done" , "label" : s ( & lw [ "done" ]) }],
}}],
}));
}
2026-09-21 21:01:35 +02:00
files . insert ( format! ( "questions/ {group} /_section.yaml" ), json! ({ "qualifies" : group }));
files . insert (
format! ( "questions/ {group} /index.yaml" ),
json! ({ "name" : s ( & w [ "question" ]), "description" : s ( & w [ "description" ]), "responsible" : responsible , "alternatives" : alternatives }),
);
}
2026-09-23 12:31:51 +02:00
// Public views: an ungated nav page per opened bucket, listing it
// read-only, with contact details and private fields projected
// away. Anyone can see what was sent in and where it stands.
for ( bucket , needs ) in public_buckets ( plan ) {
let path = public_view_path ( bucket );
let w = & words [ & path ];
let mut shown : BTreeSet <& str > = BTreeSet ::new ();
for n in & needs {
shown . extend ( n . collects . iter (). filter ( | f | f . is_public ()). map ( | f | f . name . as_str ()));
}
// Records keep their shape (state, timestamps) so portal
// renders them as records; only the answers are pruned. One
// record per yielded value: portal collects what the filter
// yields into the list, so `map` would nest it once more
// (measured: an empty bucket rendered as `[[]]`).
let projection = format! (
".[] | .responses |= {{ {} }} " ,
shown . iter (). map ( | f | format! ( " {f} : . {f} " )). collect ::< Vec < _ >> (). join ( ", " )
);
files . insert (
path ,
json! ({
"name" : s ( & w [ "question" ]),
"description" : s ( & w [ "description" ]),
"responsible" : responsible ,
"alternatives" : [
{
"name" : s ( & w [ "heading" ]),
"description" : s ( & w [ "list_description" ]),
"features" : [{ "name" : "" , "description" : "" , "resource" : {
"source" : { "kind" : "kv" , "bucket" : bucket },
"public" : true ,
"jq" : projection ,
}}],
},
{ "name" : s ( & w [ "back" ]), "description" : "" , "action" : "/" , "consequence" : [ s ( & w [ "back" ])] },
],
}),
);
}
2026-09-21 21:46:12 +02:00
// A submitter's own moves: a followup page (nested, not an index)
// whose alternatives are self_transitions, authorized at runtime by
// the record's link plus the email they gave.
for need in & plan . needs {
let theirs = moves_of ( need , SUBMITTER );
if theirs . is_empty () {
continue ;
}
let path = decide_path ( need );
let w = & words [ & path ];
let targets : BTreeSet <& String > = theirs . iter (). map ( | ( _ , to ) | to ). collect ();
files . insert (
path ,
json! ({
"name" : s ( & w [ "question" ]),
"description" : s ( & w [ "description" ]),
"alternatives" : targets . iter (). map ( | to | json! ({
"name" : s ( & w [ "moves" ][ to . as_str ()][ "heading" ]),
"description" : s ( & w [ "moves" ][ to . as_str ()][ "description" ]),
"self_transition" : { "bucket" : need . lands_in , "to" : to , "label" : s ( & w [ "moves" ][ to . as_str ()][ "button" ]) },
})). collect ::< Vec < _ >> (),
}),
);
}
2026-09-21 21:01:35 +02:00
// The spec the verifier holds the site to. `via` is the heading the
// words gave that need, filled in mechanically.
files . insert (
"needs.yaml" . into (),
json! ({
"needs" : plan . needs . iter (). map ( | n | {
2026-09-23 12:31:51 +02:00
// `via` pins the alternative on the entry page: the
// door's heading for a need behind one, else the form's.
let ( enters , heading , steps ) = match ( & n . enters_as , & n . door ) {
( Some ( group ), _ ) => ( internal_page ( n , group ), s ( & words [ & format! ( "questions/ {group} / {} .yaml" , n . id )][ "heading" ]). to_string (), 1 ),
( None , Some ( door )) => ( "/" . to_string (), s ( & front [ "doors" ][ door ][ "heading" ]). to_string (), 2 ),
( None , None ) => ( "/" . to_string (), s ( & front [ "needs" ][ & n . id ][ "heading" ]). to_string (), 1 ),
2026-09-21 21:01:35 +02:00
};
let mut need = json! ({
"id" : n . id , "who" : n . who , "wants" : n . wants , "enters" : enters , "via" : heading ,
"lands_in" : n . lands_in , "handled_by" : n . handled_by ,
"stages" : n . stages , "done_when" : n . done_when , "success" : n . success ,
2026-09-23 12:31:51 +02:00
"max_steps" : steps , "max_fields" : n . max_fields ,
2026-09-21 21:01:35 +02:00
});
if let Some ( group ) = & n . enters_as { need [ "enters_as" ] = json! ( group ); }
2026-09-21 21:46:12 +02:00
let others : BTreeSet <& String > = n . moves_by . values (). flatten (). filter ( | m | * m != SUBMITTER && ** m != n . handled_by ). collect ();
if ! others . is_empty () { need [ "also_moved_by" ] = json! ( others ); }
2026-09-21 21:01:35 +02:00
need
}). collect ::< Vec < _ >> (),
"personas" : plan . personas ,
}),
);
files . into_iter (). map ( | ( path , value ) | Ok (( path , serde_yaml ::to_string ( & value ) ? ))). collect ()
}
fn words_prompt ( plan : & Plan , path : & str , fallback : & Value ) -> String {
2026-09-23 21:18:14 +02:00
let purpose = if path == "aggregates.yaml" {
"the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, short, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it."
} else if path . contains ( "/decide/" ) {
2026-09-21 21:46:12 +02:00
"a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide."
} else if path == "questions/index.yaml" {
2026-09-23 18:24:23 +02:00
"the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card, things a visitor may look at before asking for anything. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it."
2026-09-23 12:31:51 +02:00
} else if path . starts_with ( "questions/status/" ) {
"a public page anyone can open, listing everything sent in to one bucket and where each one stands, read-only. `question` is what this page asks; `heading` names the list."
} else if plan . doors . iter (). any ( | d | door_path ( d ) == path ) {
"a page one kind of visitor reaches from the front door. `question` is what this page asks them; under `needs`, each entry is one alternative they can pick, with the form behind it."
2026-09-21 21:01:35 +02:00
} else if path . ends_with ( "/index.yaml" ) {
2026-09-23 12:42:23 +02:00
"a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent."
2026-09-21 21:01:35 +02:00
} else if path . contains ( "/received/" ) || path . ends_with ( "-sent.yaml" ) {
"a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next."
} else {
"a page only one role inside the business can open, holding one form."
};
2026-09-23 12:31:51 +02:00
let mut context : Vec < String > = plan
. doors
2026-09-21 21:01:35 +02:00
. iter ()
2026-09-23 12:31:51 +02:00
. map ( | d | format! ( "- door {} : for {} ; behind it: {} " , d . id , d . who , plan . needs_behind ( & d . id ). iter (). map ( | n | n . id . as_str ()). collect ::< Vec < _ >> (). join ( ", " )))
2026-09-21 21:01:35 +02:00
. collect ();
2026-09-23 12:31:51 +02:00
context . extend (
plan . needs
. iter ()
. map ( | n | format! ( "- {} : {} - wants: {} . Handled by {} . Stages: {} ." , n . id , n . who , n . wants , n . handled_by , n . stages . join ( " -> " ))),
);
2026-09-21 21:01:35 +02:00
format! (
2026-09-23 12:31:51 +02:00
"You write the words for one page of a website for this business. \n\n Business: {} \n About: {} \n Contact: {} < {} > \n Language to write in: {} \n\n Everyone the site serves: \n {} \n\n The page: {path} \n It is {purpose} \n\n Below is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer. \n\n Rules for the words: \n - `question` is a question the reader could ask themselves. \n - A heading says what the reader gets, in the reader's own words, and must work without its description. \n - Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use. \n - Descriptions are one or two short sentences. Labels and buttons are a few words. \n - The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words. \n - An icon is one name from this list, chosen for what the reader is doing, or empty: {} \n - Write the way people in this trade and country actually speak. \n\n {} \n\n Reply with the JSON object only." ,
2026-09-21 21:01:35 +02:00
plan . business ,
plan . about . trim (),
plan . contact . name ,
plan . contact . email ,
plan . language ,
context . join ( " \n " ),
2026-09-23 12:31:51 +02:00
ICONS . join ( ", " ),
2026-09-21 21:08:08 +02:00
serde_json ::to_string_pretty ( & slots ( fallback , "" )). unwrap_or_default (),
2026-09-21 21:01:35 +02:00
)
}
pub fn run ( argv : & [ String ]) -> anyhow ::Result < () > {
let value = | flag : & str | argv . iter (). position ( | a | a == flag ). and_then ( | i | argv . get ( i + 1 )). cloned ();
2026-09-23 20:02:30 +02:00
let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--words-from <existing repo>] [--words <words.json>]... [--llm 'cmd'] [--lint question_lint]" ;
2026-09-21 21:01:35 +02:00
let plan_path = value ( "--plan" ). ok_or_else ( || anyhow! ( usage )) ? ;
let out = std ::path ::PathBuf ::from ( value ( "--out" ). ok_or_else ( || anyhow! ( usage )) ? );
let plan : Plan = serde_yaml ::from_str ( & std ::fs ::read_to_string ( & plan_path ). with_context ( || format! ( "reading {plan_path} " )) ? )
. with_context ( || format! ( "parsing {plan_path} " )) ? ;
plan . validate () ? ;
2026-09-23 18:24:23 +02:00
for warning in plan . spectators () {
eprintln! ( "build: {warning} " );
}
2026-09-21 21:01:35 +02:00
2026-09-23 12:42:23 +02:00
// Words, in layers: the fallbacks; what an existing repo already
// says (hand edits survive a rebuild); explicit overrides for new
// slots; and only then a model, for whatever is still fallback.
let mut words = match value ( "--words-from" ) {
Some ( repo ) => lift_words ( & plan , Path ::new ( & repo )) ? ,
None => fallback_words ( & plan ),
};
2026-09-23 20:02:30 +02:00
// `--words` may repeat: the words a previous build used, then a
// round of hand overrides on top, in the order given. Each is
// merged key by key over what is there, so a file names only the
// slots it changes.
let words_files : Vec <& String > = argv . iter (). enumerate (). filter ( | ( _ , a ) | * a == "--words" ). filter_map ( | ( i , _ ) | argv . get ( i + 1 )). collect ();
for path in words_files {
let given : BTreeMap < String , Value > = serde_json ::from_str ( & std ::fs ::read_to_string ( path ). with_context ( || format! ( "reading {path} " )) ? )
2026-09-23 12:42:23 +02:00
. with_context ( || format! ( "parsing {path} " )) ? ;
for ( page , v ) in given {
match words . get ( & page ) {
Some ( base ) => {
let merged = merge_words ( base , & v );
words . insert ( page , merged );
}
None => eprintln! ( "words: {page} is not a page of this plan, ignored" ),
}
}
}
2026-09-21 21:01:35 +02:00
if let Some ( cmd ) = value ( "--llm" ) {
std ::fs ::create_dir_all ( out . join ( "llm" )) ? ;
2026-09-23 23:00:37 +02:00
// --llm-for <prefix>: the model writes only the pages whose
// path starts with it (`aggregates.yaml` for the mails alone,
// `questions/received/` for the followups), and everything
// else keeps the words it has.
let only = value ( "--llm-for" );
2026-09-21 21:01:35 +02:00
for ( n , ( path , fallback )) in words . clone (). iter (). enumerate () {
2026-09-23 23:00:37 +02:00
if only . as_ref (). is_some_and ( | p | ! path . starts_with ( p . as_str ())) {
continue ;
}
2026-09-21 21:01:35 +02:00
let prompt = words_prompt ( & plan , path , fallback );
let reply = crate ::llm_with ( & cmd , & prompt , & [( "FORMAT" , "json" ), ( "TEMPERATURE" , "0.4" )]). unwrap_or_default ();
std ::fs ::write ( out . join ( format! ( "llm/ {:02} - {} .md" , n + 1 , path . replace ( '/' , "_" ))), format! ( " {prompt} \n\n === REPLY === \n {reply} " )) ? ;
match first_object ( & reply ) {
Some ( given ) => {
2026-09-23 23:02:48 +02:00
let mut merged = merge_words ( fallback , & given );
if path == "aggregates.yaml" {
keep_links ( fallback , & mut merged );
}
2026-09-23 23:35:32 +02:00
match wrong_language ( & plan . language , & given ) {
Some ( found ) => eprintln! ( "words: {path} - the reply reads as {found} for a {} plan, keeping the words it had" , plan . language ),
None => {
words . insert ( path . clone (), merged );
eprintln! ( "words: {path} " );
}
}
2026-09-21 21:01:35 +02:00
}
None => eprintln! ( "words: {path} - no usable reply, keeping the fallback" ),
}
}
}
write_repo ( & out , render ( & plan , & words ) ? ) ? ;
2026-09-23 12:42:23 +02:00
// The words as used, so the next rebuild can start from them.
std ::fs ::write ( out . join ( "words.json" ), serde_json ::to_string_pretty ( & words ) ? ) ? ;
2026-09-21 21:01:35 +02:00
println! ( "content repo written to {} " , out . display ());
if let Some ( lint ) = value ( "--lint" ) {
let output = std ::process ::Command ::new ( & lint ). arg ( "--path" ). arg ( out . join ( "questions" )). output (). with_context ( || format! ( "running {lint} " )) ? ;
2026-09-24 01:55:39 +02:00
let stderr = String ::from_utf8_lossy ( & output . stderr );
print! ( " {}{} " , String ::from_utf8_lossy ( & output . stdout ), stderr );
2026-09-21 21:01:35 +02:00
if ! output . status . success () {
2026-09-24 01:55:39 +02:00
// Personas are attached from a file and name needs by id; a
// planned site whose model chose other ids fails only on
// that mapping, which is the reader's to fix, not the
// builder's.
let unmapped = stderr . lines (). filter ( | l | l . starts_with ( "FAIL" )). all ( | l | l . contains ( "points at a need that is not declared" ) || l . contains ( "unmet business need finding" ));
if unmapped && stderr . contains ( "points at a need that is not declared" ) {
bail! ( "the site is sound, but the personas name needs this plan does not declare: map each persona's `need:` to one of the plan's ids, or rename the plan's needs, then build again" );
}
2026-09-21 21:01:35 +02:00
bail! ( "the verifier rejected a deterministically built site - that is a bug in the builder, not in the words" );
}
}
Ok (())
}
fn write_repo ( out : & Path , files : BTreeMap < String , String > ) -> anyhow ::Result < () > {
// The questions tree is rebuilt whole, so a page from an earlier
// plan never lingers.
let _ = std ::fs ::remove_dir_all ( out . join ( "questions" ));
for ( path , body ) in files {
let target = out . join ( & path );
if let Some ( parent ) = target . parent () {
std ::fs ::create_dir_all ( parent ) ? ;
}
std ::fs ::write ( target , body ) ? ;
}
Ok (())
}
#[cfg(test)]
mod tests {
use super ::* ;
fn need ( stages : & [ & str ], done : & [ & str ], success : & [ & str ], skippable : & [ & str ]) -> PlanNeed {
let v = | xs : & [ & str ] | xs . iter (). map ( | x | x . to_string ()). collect ::< Vec < _ >> ();
PlanNeed {
2026-09-23 12:31:51 +02:00
id : "n" . into (), who : "w" . into (), wants : "x" . into (), enters_as : None , door : None ,
2026-09-21 21:01:35 +02:00
lands_in : "b" . into (), handled_by : "g" . into (),
stages : v ( stages ), done_when : v ( done ), success : v ( success ), skippable : v ( skippable ),
2026-09-21 21:46:12 +02:00
moves_by : BTreeMap ::new (), watched_by : vec ! [],
2026-09-23 21:18:14 +02:00
events : BTreeMap ::new (), attended_by : None , mails : BTreeMap ::new (),
2026-09-23 12:31:51 +02:00
collects : vec ! [], max_fields : 3 ,
2026-09-21 21:01:35 +02:00
}
}
2026-09-23 21:18:14 +02:00
#[test]
fn a_stage_may_mail_the_submitter_or_an_address_and_the_words_fill_it () {
let yaml = "business: B \n contact: { name: I, email: i@b.example } \n mail: { from: \" B <post@b.example> \" , reply_to: i@b.example } \n needs: \n - id: leak \n who: owners \n wants: a dry house \n lands_in: leaks \n handled_by: roofers \n stages: [open, fixed, dropped] \n done_when: [fixed, dropped] \n success: [fixed] \n moves_by: { dropped: [submitter, roofers] } \n mails: { open: submitter, fixed: submitter, dropped: board@b.example } \n max_fields: 2 \n collects: \n - { name: name } \n - { name: email, type: email } \n " ;
let plan : Plan = serde_yaml ::from_str ( yaml ). unwrap ();
plan . validate (). unwrap ();
let words = fallback_words ( & plan );
let open_body = words [ "aggregates.yaml" ][ "leaks" ][ "open" ][ "body" ]. as_str (). unwrap ();
// The submitter has a move from open, so the fallback says where.
assert! ( open_body . starts_with ( "Hello {name}," ));
assert! ( open_body . contains ( "{site}/decide/leak?chain={chain}&email={email}" ));
assert! ( ! words [ "aggregates.yaml" ][ "leaks" ][ "fixed" ][ "body" ]. as_str (). unwrap (). contains ( "/decide/" ));
let files = render ( & plan , & words ). unwrap ();
let site : Value = serde_yaml ::from_str ( & files [ "site.yaml" ]). unwrap ();
assert_eq! ( site [ "mail" ][ "from" ], "B <post@b.example>" );
let agg : Value = serde_yaml ::from_str ( & files [ "aggregates.yaml" ]). unwrap ();
let states = & agg [ "aggregates" ][ 0 ][ "states" ];
assert_eq! ( states [ "open" ][ "mail" ][ "to" ], "submitter" );
assert_eq! ( states [ "dropped" ][ "mail" ][ "to" ], "board@b.example" );
assert! ( states [ "fixed" ][ "mail" ][ "subject" ]. as_str (). unwrap (). contains ( "Fixed" ));
// A submitter mail needs the email; mails need a sender.
let no_email = yaml . replace ( " - { name: email, type: email } \n " , "" ). replace ( " moves_by: { dropped: [submitter, roofers] } \n " , "" );
assert! ( serde_yaml ::from_str ::< Plan > ( & no_email ). unwrap (). validate (). unwrap_err (). to_string (). contains ( "required `email`" ));
let no_from = yaml . replace ( "mail: { from: \" B <post@b.example> \" , reply_to: i@b.example } \n " , "" );
assert! ( serde_yaml ::from_str ::< Plan > ( & no_from ). unwrap (). validate (). unwrap_err (). to_string (). contains ( "mail: { from" ));
}
2026-09-23 23:35:32 +02:00
#[test]
fn a_reply_in_the_wrong_language_is_named () {
let english = json! ({ "a" : { "body" : "We have received what you sent and we will read it in full before we reply to you." } });
let norwegian = json! ({ "a" : { "body" : "Vi har mottatt det du sendte og leser det i sin helhet før vi svarer deg på det." } });
assert_eq! ( wrong_language ( "no" , & english ), Some ( "English" ));
assert_eq! ( wrong_language ( "no" , & norwegian ), None );
assert_eq! ( wrong_language ( "en" , & norwegian ), Some ( "Norwegian" ));
assert_eq! ( wrong_language ( "en" , & english ), None );
// Too short to tell, or a language nobody checks: left alone.
assert_eq! ( wrong_language ( "no" , & json! ({ "a" : "Send" })), None );
assert_eq! ( wrong_language ( "de" , & english ), None );
}
2026-09-23 23:02:48 +02:00
#[test]
fn a_models_mail_keeps_the_submitters_link () {
let fallback = json! ({ "leaks" : { "open" : { "subject" : "s" , "body" : "Hello, \n\n Got it. \n\n What you can do next: {site}/decide/leak?chain={chain}&email={email} \n\n I at i@b.example" } } });
let mut merged = json! ({ "leaks" : { "open" : { "subject" : "Got it" , "body" : "Hi, \n\n We have it. \n\n {chain}" } } });
keep_links ( & fallback , & mut merged );
let body = merged [ "leaks" ][ "open" ][ "body" ]. as_str (). unwrap ();
assert! ( body . ends_with ( "What you can do next: {site}/decide/leak?chain={chain}&email={email}" ));
assert! ( ! body . contains ( " \n {chain}" ));
// A body that kept its link is left alone.
let mut fine = json! ({ "leaks" : { "open" : { "subject" : "s" , "body" : "Withdraw here: {site}/decide/leak?chain={chain}&email={email}" } } });
keep_links ( & fallback , & mut fine );
assert_eq! ( fine [ "leaks" ][ "open" ][ "body" ], "Withdraw here: {site}/decide/leak?chain={chain}&email={email}" );
}
2026-09-23 14:49:25 +02:00
#[test]
fn a_live_sites_event_names_survive_a_rebuild () {
let mut n = need ( & [ "open" , "invited" , "active" , "departed" , "declined" ], & [ "departed" , "declined" ], & [ "departed" ], & []);
assert_eq! ( event_for ( & n , "open" ), "received" );
assert_eq! ( event_for ( & n , "active" ), "active" );
n . events = [( "open" , "applied" ), ( "active" , "onboarded" )]. iter (). map ( | ( s , e ) | ( s . to_string (), e . to_string ())). collect ();
assert_eq! ( event_for ( & n , "open" ), "applied" );
assert_eq! ( event_for ( & n , "active" ), "onboarded" );
assert_eq! ( event_for ( & n , "invited" ), "invited" );
}
2026-09-21 21:01:35 +02:00
#[test]
fn the_machine_walks_the_line_and_can_be_turned_down_anywhere () {
let m = machine ( & need ( & [ "open" , "surveyed" , "quoted" , "won" , "lost" ], & [ "won" , "lost" ], & [ "won" ], & []));
assert_eq! ( m [ "open" ], [ "surveyed" , "lost" ]);
assert_eq! ( m [ "surveyed" ], [ "quoted" , "lost" ]);
assert_eq! ( m [ "quoted" ], [ "won" , "lost" ]);
assert! ( ! m . contains_key ( "won" ));
}
#[test]
fn a_skippable_stage_can_be_passed_over () {
let m = machine ( & need ( & [ "open" , "awaiting_papers" , "approved" , "declined" ], & [ "approved" , "declined" ], & [ "approved" ], & [ "awaiting_papers" ]));
assert_eq! ( m [ "open" ], [ "awaiting_papers" , "approved" , "declined" ]);
assert_eq! ( m [ "awaiting_papers" ], [ "approved" , "declined" ]);
}
#[test]
fn a_line_with_one_ending_has_no_way_to_be_turned_down () {
let m = machine ( & need ( & [ "open" , "in_hand" , "closed" ], & [ "closed" ], & [ "closed" ], & []));
assert_eq! ( m [ "open" ], [ "in_hand" ]);
assert_eq! ( m [ "in_hand" ], [ "closed" ]);
}
2026-09-23 12:31:51 +02:00
#[test]
fn the_card_carries_button_encouragement_icon_guidance_and_placeholders () {
let plan : Plan = serde_yaml ::from_str ( PLAN ). unwrap ();
let mut words = fallback_words ( & plan );
let front = words . get_mut ( "questions/index.yaml" ). unwrap ();
front [ "needs" ][ "survey" ][ "button" ] = json! ( "Book a look" );
front [ "needs" ][ "survey" ][ "encouragement" ] = json! ( "We call within two days." );
front [ "needs" ][ "survey" ][ "icon" ] = json! ( "house" );
front [ "needs" ][ "survey" ][ "form_description" ] = json! ( "The address is enough to start." );
front [ "needs" ][ "survey" ][ "hints" ][ "email" ] = json! ( "you@example.no" );
let files = render ( & plan , & words ). unwrap ();
let page : Value = serde_yaml ::from_str ( & files [ "questions/index.yaml" ]). unwrap ();
2026-09-23 18:24:23 +02:00
let alt = & page [ "alternatives" ][ 1 ];
2026-09-23 12:31:51 +02:00
assert_eq! ( alt [ "consequence" ][ 0 ], "Book a look" );
assert_eq! ( alt [ "encouragements" ][ 0 ], "We call within two days." );
// Two context sections from the plan come before the form.
let f = alt [ "features" ]. as_array (). unwrap ();
assert_eq! ( f . len (), 3 );
assert_eq! ( f [ 0 ][ "name" ], "What happens next" );
assert_eq! ( f [ 0 ][ "description" ], "It is open, then surveyed, then quoted, and ends won or lost." );
assert_eq! ( f [ 0 ][ "icon" ], "lucide:route" );
assert_eq! ( f [ 1 ][ "name" ], "Who reads it" );
assert_eq! ( f [ 1 ][ "description" ], "Project managers. Questions go to Ingrid at i@n.example." );
assert_eq! ( f [ 2 ][ "icon" ], "lucide:house" );
assert_eq! ( f [ 2 ][ "description" ], "The address is enough to start." );
assert_eq! ( f [ 2 ][ "requirements" ][ 0 ][ "placeholder" ], "you@example.no" );
// An icon off the list is dropped, not passed through.
assert_eq! ( icon_name ( "skull" ), None );
assert_eq! ( icon_name ( "lucide:house" ). as_deref (), Some ( "lucide:house" ));
// Empty slots leave nothing behind: no encouragement key, no form icon.
let plain = render ( & plan , & fallback_words ( & plan )). unwrap ();
let page : Value = serde_yaml ::from_str ( & plain [ "questions/index.yaml" ]). unwrap ();
assert! ( page [ "alternatives" ][ 0 ]. get ( "encouragements" ). is_none ());
assert! ( page [ "alternatives" ][ 0 ][ "features" ][ 2 ]. get ( "icon" ). is_none ());
}
2026-09-21 21:01:35 +02:00
#[test]
fn words_take_the_models_strings_and_never_its_structure () {
let fallback = json! ({ "question" : "What?" , "needs" : { "a" : { "heading" : "A" , "labels" : { "email" : "Email" }}}});
2026-09-23 22:09:04 +02:00
let given = json! ({ "question" : " What do you need? " , "needs" : { "a" : { "heading" : "" , "labels" : { "email" : 7 , "extra" : "x" }}, "b" : { "heading" : "B" }}, "injected" : "y" });
2026-09-21 21:01:35 +02:00
let merged = merge_words ( & fallback , & given );
2026-09-23 22:09:04 +02:00
assert_eq! ( merged , json! ({ "question" : "What do you need?" , "needs" : { "a" : { "heading" : "A" , "labels" : { "email" : "Email" }}}}));
2026-09-21 21:01:35 +02:00
}
2026-09-23 12:31:51 +02:00
const PLAN : & str = "business: Nordvik Bygg \n contact: { name: Ingrid, email: i@n.example } \n about: We fix old buildings. \n needs: \n - id: survey \n who: owners \n wants: Someone to look at the building \n lands_in: enquiries \n handled_by: project_managers \n stages: [open, surveyed, quoted, won, lost] \n done_when: [won, lost] \n success: [won] \n max_fields: 4 \n collects: \n - { name: email, type: email } \n - { name: problem, type: textarea } \n - { name: address, optional: true } \n - id: report \n who: crew \n wants: Report a hazard \n enters_as: crew \n lands_in: site_issues \n handled_by: hse \n stages: [open, in_hand, closed] \n done_when: [closed] \n success: [closed] \n max_fields: 3 \n collects: \n - { name: kind, type: select, options: [Hazard, Near miss] } \n " ;
2026-09-21 21:01:35 +02:00
#[test]
fn a_plan_renders_every_file_the_site_needs () {
let plan : Plan = serde_yaml ::from_str ( PLAN ). unwrap ();
plan . validate (). unwrap ();
let files = render ( & plan , & fallback_words ( & plan )). unwrap ();
let paths : Vec <& str > = files . keys (). map ( String ::as_str ). collect ();
assert_eq! ( paths , [
"aggregates.yaml" , "needs.yaml" ,
"questions/crew/_section.yaml" , "questions/crew/index.yaml" , "questions/crew/report-sent.yaml" , "questions/crew/report.yaml" ,
"questions/hse/_section.yaml" , "questions/hse/index.yaml" ,
"questions/index.yaml" ,
"questions/project_managers/_section.yaml" , "questions/project_managers/index.yaml" ,
"questions/received/survey.yaml" , "site.yaml" ,
]);
// The crew's form is gated, a way in, and recorded for the safety officer's desk.
assert! ( files [ "questions/crew/_section.yaml" ]. contains ( "qualifies: crew" ));
assert! ( files [ "questions/crew/report.yaml" ]. contains ( "followup: false" ));
assert! ( files [ "questions/hse/index.yaml" ]. contains ( "bucket: site_issues" ));
assert! ( files [ "questions/hse/index.yaml" ]. contains ( "to: closed" ));
// The spec names the heading the words gave, and the budget the form implies.
assert! ( files [ "needs.yaml" ]. contains ( "via: Someone to look at the building" ));
2026-09-23 12:31:51 +02:00
assert! ( files [ "needs.yaml" ]. contains ( "max_fields: 4" ));
2026-09-21 21:01:35 +02:00
assert! ( files [ "needs.yaml" ]. contains ( "enters: /crew/report" ));
}
2026-09-21 21:46:12 +02:00
#[test]
fn roles_overlap_on_one_aggregate_each_with_their_own_buttons () {
2026-09-23 12:31:51 +02:00
let yaml = "business: B \n contact: { name: I, email: i@b.example } \n needs: \n - id: change \n who: a client \n wants: a change priced and done \n lands_in: change_orders \n handled_by: project_managers \n stages: [open, priced, approved, completed, declined] \n done_when: [completed, declined] \n success: [completed] \n moves_by: \n approved: [submitter] \n declined: [submitter, project_managers] \n completed: [site_managers] \n watched_by: [accounts] \n max_fields: 3 \n collects: \n - { name: email, type: email } \n - { name: change, type: textarea } \n " ;
2026-09-21 21:46:12 +02:00
let plan : Plan = serde_yaml ::from_str ( yaml ). unwrap ();
plan . validate (). unwrap ();
let files = render ( & plan , & fallback_words ( & plan )). unwrap ();
// The project manager prices and may decline; approving and completing are not theirs.
let pm = & files [ "questions/project_managers/index.yaml" ];
assert! ( pm . contains ( "to: priced" ) && pm . contains ( "to: declined" ));
assert! ( ! pm . contains ( "to: approved" ) && ! pm . contains ( "to: completed" ));
// The site manager completes, and nothing else.
let site = & files [ "questions/site_managers/index.yaml" ];
assert! ( site . contains ( "to: completed" ) && ! site . contains ( "to: priced" ));
// Accounts see the bucket and move nothing.
let accounts = & files [ "questions/accounts/index.yaml" ];
assert! ( accounts . contains ( "bucket: change_orders" ) && accounts . contains ( "transitions: []" ));
// The client decides through their own link.
let decide = & files [ "questions/decide/change.yaml" ];
assert! ( decide . contains ( "self_transition" ) && decide . contains ( "to: approved" ) && decide . contains ( "to: declined" ));
// The spec tells the verifier whose desks count.
assert! ( files [ "needs.yaml" ]. contains ( "also_moved_by: \n - site_managers" ));
// A submitter's move without an email to authorize it is refused.
let no_email = yaml . replace ( " - { name: email, type: email } \n " , "" );
let plan : Plan = serde_yaml ::from_str ( & no_email ). unwrap ();
assert! ( plan . validate (). unwrap_err (). to_string (). contains ( "required `email`" ));
}
2026-09-23 18:24:23 +02:00
#[test]
fn the_front_door_opens_with_what_we_do_and_a_look_around_before_any_button () {
let yaml = "business: B \n contact: { name: I, email: i@b.example } \n does: \n - { id: fix, text: We fix roofs., for: leak } \n - { id: keep, text: We keep them fixed. } \n shows: \n - id: built \n text: Roofs we have done. \n resource: { public: true, source: { kind: url, url: https://b.example/roofs.json } } \n needs: \n - id: leak \n who: owners \n wants: a dry house \n lands_in: leaks \n handled_by: roofers \n watched_by: [public] \n stages: [open, fixed] \n done_when: [fixed] \n max_fields: 1 \n collects: \n - { name: where } \n - id: apply \n who: workers \n wants: a job \n lands_in: applicants \n handled_by: roofers \n stages: [open, hired] \n done_when: [hired] \n max_fields: 1 \n collects: \n - { name: name } \n " ;
let plan : Plan = serde_yaml ::from_str ( yaml ). unwrap ();
plan . validate (). unwrap ();
let files = render ( & plan , & fallback_words ( & plan )). unwrap ();
let front : Value = serde_yaml ::from_str ( & files [ "questions/index.yaml" ]). unwrap ();
let alts = front [ "alternatives" ]. as_array (). unwrap ();
// The opener: two features, no action, no form.
assert_eq! ( alts [ 0 ][ "name" ], "What we do" );
assert! ( alts [ 0 ]. get ( "action" ). is_none ());
assert_eq! ( alts [ 0 ][ "features" ]. as_array (). unwrap (). len (), 2 );
assert_eq! ( alts [ 0 ][ "features" ][ 0 ][ "description" ], "We fix roofs." );
// The look around: the resource verbatim, and the public view linked.
assert_eq! ( alts [ 1 ][ "name" ], "Have a look around" );
assert! ( alts [ 1 ]. get ( "action" ). is_none ());
assert_eq! ( alts [ 1 ][ "features" ][ 0 ][ "resource" ][ "source" ][ "kind" ], "url" );
assert! ( alts [ 1 ][ "features" ][ 1 ][ "description" ]. as_str (). unwrap (). contains ( "](/status/leaks)" ));
// Only then the offers.
assert_eq! ( alts [ 2 ][ "record_as" ], "leaks" );
assert_eq! ( alts [ 3 ][ "record_as" ], "applicants" );
// The role the opener invites in is headed by its win, and
// must have something to play: the owner watches the public
// list, so no warning; take that away and the build says so.
assert_eq! ( alts [ 0 ][ "features" ][ 0 ][ "name" ], "A dry house" );
assert! ( plan . spectators (). is_empty ());
let spectator : Plan = serde_yaml ::from_str ( & yaml . replace ( " watched_by: [public] \n " , "" )). unwrap ();
assert! ( spectator . spectators ()[ 0 ]. contains ( "play nothing" ));
// Three features is a menu; a `for` must name a need.
let three = yaml . replace ( " - { id: keep, text: We keep them fixed. } \n " , " - { id: keep, text: x } \n - { id: more, text: y } \n " );
assert! ( serde_yaml ::from_str ::< Plan > ( & three ). unwrap (). validate (). unwrap_err (). to_string (). contains ( "one or two" ));
let bad = yaml . replace ( "for: leak" , "for: nothing" );
assert! ( serde_yaml ::from_str ::< Plan > ( & bad ). unwrap (). validate (). unwrap_err (). to_string (). contains ( "nothing" ));
}
2026-09-23 12:31:51 +02:00
#[test]
fn a_door_is_a_first_question_with_its_needs_on_a_page_of_their_own () {
let yaml = "business: B \n contact: { name: I, email: i@b.example } \n doors: \n - { id: your-building, who: an owner with a building } \n needs: \n - id: survey \n who: owners \n wants: a look \n door: your-building \n lands_in: enquiries \n handled_by: pms \n stages: [open, won, lost] \n done_when: [won, lost] \n max_fields: 2 \n collects: \n - { name: email, type: email } \n - id: build \n who: owners \n wants: a price \n door: your-building \n lands_in: enquiries \n handled_by: pms \n stages: [open, won, lost] \n done_when: [won, lost] \n max_fields: 2 \n collects: \n - { name: email, type: email } \n - id: apply \n who: workers \n wants: a job \n lands_in: applicants \n handled_by: pms \n stages: [open, hired] \n done_when: [hired] \n max_fields: 1 \n collects: \n - { name: name } \n " ;
let plan : Plan = serde_yaml ::from_str ( yaml ). unwrap ();
plan . validate (). unwrap ();
let files = render ( & plan , & fallback_words ( & plan )). unwrap ();
2026-09-23 18:24:23 +02:00
// The front door: the opener, then the door, then the direct need.
2026-09-23 12:31:51 +02:00
let front : Value = serde_yaml ::from_str ( & files [ "questions/index.yaml" ]). unwrap ();
let names : Vec <& str > = front [ "alternatives" ]. as_array (). unwrap (). iter (). map ( | a | a [ "name" ]. as_str (). unwrap ()). collect ();
2026-09-23 18:24:23 +02:00
assert_eq! ( names , [ "What we do" , "Your building" , "a job" ]);
assert_eq! ( front [ "alternatives" ][ 1 ][ "action" ], "/your-building" );
assert! ( front [ "alternatives" ][ 1 ]. get ( "record_as" ). is_none ());
2026-09-23 12:31:51 +02:00
// The door's page holds both forms and is not gated.
let door : Value = serde_yaml ::from_str ( & files [ "questions/your-building/index.yaml" ]). unwrap ();
assert_eq! ( door [ "alternatives" ]. as_array (). unwrap (). len (), 2 );
assert_eq! ( door [ "alternatives" ][ 0 ][ "record_as" ], "enquiries" );
assert! ( ! files . contains_key ( "questions/your-building/_section.yaml" ));
// The spec pins the door as the way in, two submissions deep.
let needs : Value = serde_yaml ::from_str ( & files [ "needs.yaml" ]). unwrap ();
assert_eq! ( needs [ "needs" ][ 0 ][ "via" ], "Your building" );
assert_eq! ( needs [ "needs" ][ 0 ][ "max_steps" ], 2 );
assert_eq! ( needs [ "needs" ][ 2 ][ "max_steps" ], 1 );
assert_eq! ( plan . front_door_count (), 2 );
// A door nobody is behind, a door on an internal need, and a
// door that is also a group are refused.
let lonely : Plan = serde_yaml ::from_str ( & yaml . replace ( " door: your-building \n " , "" )). unwrap ();
assert! ( lonely . validate (). unwrap_err (). to_string (). contains ( "leads nowhere" ));
let internal : Plan = serde_yaml ::from_str ( & yaml . replacen ( " door: your-building \n " , " door: your-building \n enters_as: pms \n " , 1 )). unwrap ();
assert! ( internal . validate (). unwrap_err (). to_string (). contains ( "public way in" ));
let clash : Plan = serde_yaml ::from_str ( & yaml . replace ( "your-building" , "pms" )). unwrap ();
assert! ( clash . validate (). unwrap_err (). to_string (). contains ( "also a group" ));
}
#[test]
fn selects_can_take_many_and_draw_their_options_from_the_labelled_groups () {
2026-09-23 22:09:04 +02:00
let yaml = "business: B \n contact: { name: I, email: i@b.example } \n groups: \n - { id: traffic, label: Traffic } \n - { id: elvesti, label: Riverside } \n needs: \n - id: committee \n who: someone who wants a seat on a committee \n wants: a seat \n lands_in: applications \n handled_by: board \n stages: [open, placed, declined] \n done_when: [placed, declined] \n max_fields: 3 \n collects: \n - { name: name } \n - { name: committee, type: select, options_from: groups, multiple: true } \n " ;
2026-09-23 12:31:51 +02:00
let plan : Plan = serde_yaml ::from_str ( yaml ). unwrap ();
plan . validate (). unwrap ();
let files = render ( & plan , & fallback_words ( & plan )). unwrap ();
let page : Value = serde_yaml ::from_str ( & files [ "questions/index.yaml" ]). unwrap ();
2026-09-23 18:24:23 +02:00
let field = & page [ "alternatives" ][ 1 ][ "features" ][ 2 ][ "requirements" ][ 1 ];
2026-09-23 22:09:04 +02:00
assert_eq! ( field [ "options" ], json! ([ "Traffic" , "Riverside" ]));
2026-09-23 12:31:51 +02:00
assert_eq! ( field [ "multiple" ], true );
// Without labelled groups there is nothing to draw from.
2026-09-23 22:09:04 +02:00
let no_groups : Plan = serde_yaml ::from_str ( & yaml . replace ( "groups: \n - { id: traffic, label: Traffic } \n - { id: elvesti, label: Riverside } \n " , "" )). unwrap ();
2026-09-23 12:31:51 +02:00
assert! ( no_groups . validate (). unwrap_err (). to_string (). contains ( "no labelled" ));
// `multiple` on a text field is a mistake, not a no-op.
2026-09-23 22:09:04 +02:00
let bad : Plan = serde_yaml ::from_str ( & yaml . replace ( "{ name: name }" , "{ name: name, multiple: true }" )). unwrap ();
2026-09-23 12:31:51 +02:00
assert! ( bad . validate (). unwrap_err (). to_string (). contains ( "multiple is for select" ));
}
#[test]
fn a_public_watcher_gets_a_read_only_page_without_contact_details () {
2026-09-23 22:09:04 +02:00
let yaml = "business: B \n contact: { name: I, email: i@b.example } \n needs: \n - id: traffic \n who: someone who saw a hazard \n wants: at det tas opp \n lands_in: meldinger \n handled_by: traffic \n stages: [open, fixed] \n done_when: [fixed] \n watched_by: [board, public] \n max_fields: 2 \n collects: \n - { name: where } \n - { name: what, type: textarea } \n - { name: epost, type: email, optional: true } \n - { name: name, optional: true, private: true } \n " ;
2026-09-23 12:31:51 +02:00
let plan : Plan = serde_yaml ::from_str ( yaml ). unwrap ();
plan . validate (). unwrap ();
let files = render ( & plan , & fallback_words ( & plan )). unwrap ();
// `public` is not a group: no desk directory for it.
assert! ( ! files . contains_key ( "questions/public/index.yaml" ));
2026-09-23 22:09:04 +02:00
assert! ( files . contains_key ( "questions/board/index.yaml" ));
2026-09-23 12:31:51 +02:00
let view : Value = serde_yaml ::from_str ( & files [ "questions/status/meldinger/index.yaml" ]). unwrap ();
let resource = & view [ "alternatives" ][ 0 ][ "features" ][ 0 ][ "resource" ];
assert_eq! ( resource [ "public" ], true );
assert! ( resource . get ( "requires_group" ). is_none ());
2026-09-23 22:09:04 +02:00
assert_eq! ( resource [ "jq" ], ".[] | .responses |= {what: .what, where: .where}" );
2026-09-23 12:31:51 +02:00
assert! ( view . get ( "qualifies" ). is_none ());
// The followup page points people at the list.
2026-09-23 22:09:04 +02:00
let followup : Value = serde_yaml ::from_str ( & files [ "questions/received/traffic.yaml" ]). unwrap ();
2026-09-23 12:31:51 +02:00
assert_eq! ( followup [ "alternatives" ][ 1 ][ "action" ], "/status/meldinger" );
// An internal need cannot be public.
2026-09-23 22:09:04 +02:00
let internal : Plan = serde_yaml ::from_str ( & yaml . replace ( " lands_in:" , " enters_as: board \n lands_in:" )). unwrap ();
2026-09-23 12:31:51 +02:00
assert! ( internal . validate (). unwrap_err (). to_string (). contains ( "public view" ));
}
#[test]
fn a_form_that_asks_more_than_the_person_will_tolerate_is_refused () {
// Two required fields under a budget of one: the plan is refused
// before any page exists, and the message says what to do.
let plan : Plan = serde_yaml ::from_str ( & PLAN . replace ( "max_fields: 4" , "max_fields: 1" )). unwrap ();
let err = plan . validate (). unwrap_err (). to_string ();
assert! ( err . contains ( "2 required field(s)" ) && err . contains ( "tolerate 1" ), "{err}" );
// A budget of zero is not a budget.
let plan : Plan = serde_yaml ::from_str ( & PLAN . replace ( "max_fields: 3" , "max_fields: 0" )). unwrap ();
assert! ( plan . validate (). unwrap_err (). to_string (). contains ( "at least 1" ));
// The budget is what the spec hands the verifier, not the form's size.
let plan : Plan = serde_yaml ::from_str ( PLAN ). unwrap ();
let files = render ( & plan , & fallback_words ( & plan )). unwrap ();
assert! ( files [ "needs.yaml" ]. contains ( "max_fields: 4" ));
assert! ( files [ "needs.yaml" ]. contains ( "max_fields: 3" ));
// A plan that says nothing about tolerance is not a plan.
assert! ( serde_yaml ::from_str ::< Plan > ( & PLAN . replace ( " max_fields: 4 \n " , "" )). is_err ());
}
2026-09-21 21:01:35 +02:00
#[test]
fn a_plan_with_a_bad_identifier_or_a_disagreeing_bucket_is_refused () {
let bad : Plan = serde_yaml ::from_str ( & PLAN . replace ( "lost]" , "avslått]" )). unwrap ();
assert! ( bad . validate (). unwrap_err (). to_string (). contains ( "ASCII" ));
2026-09-23 12:31:51 +02:00
let clash = format! ( " {PLAN} - id: other \n who: w \n wants: x \n lands_in: enquiries \n handled_by: project_managers \n stages: [open, won] \n done_when: [won] \n max_fields: 1 \n collects: [] \n " );
2026-09-21 21:01:35 +02:00
let plan : Plan = serde_yaml ::from_str ( & clash ). unwrap ();
assert! ( plan . validate (). unwrap_err (). to_string (). contains ( "must agree" ));
}
}
2026-09-23 12:42:23 +02:00
/// The words a repo already carries, lifted back into the words map by
/// structure: the plan is rendered with a token in every word slot,
/// each rendered file is walked alongside the repo's file of the same
/// path, and wherever a token meets a string, that string is the word.
/// Pages the repo does not have, and slots it never had (a new invite
/// form), keep their fallback. Hand-edited wording survives a rebuild.
pub fn lift_words ( plan : & Plan , repo : & Path ) -> anyhow ::Result < BTreeMap < String , Value >> {
let fallback = fallback_words ( plan );
let tokened : BTreeMap < String , Value > =
fallback . iter (). map ( | ( path , v ) | ( path . clone (), tokenize ( v , path , "" ))). collect ();
let rendered = render ( plan , & tokened ) ? ;
let mut found : std ::collections ::HashMap < String , String > = std ::collections ::HashMap ::new ();
for ( path , text ) in & rendered {
2026-09-23 22:01:55 +02:00
// The spec is derived from the words (a need's `via` is its
// heading), never a source of them: behind a door it named the
// door, and a positional match would hand that to the form.
if path == "needs.yaml" {
continue ;
}
2026-09-23 12:42:23 +02:00
let Ok ( live ) = std ::fs ::read_to_string ( repo . join ( path )) else { continue };
let ( Ok ( a ), Ok ( b )) = (
serde_yaml ::from_str ::< serde_yaml ::Value > ( text ),
serde_yaml ::from_str ::< serde_yaml ::Value > ( & live ),
) else {
continue ;
};
collect_words ( & a , & b , & mut found );
}
2026-09-23 22:01:55 +02:00
// A second pass across the whole repo: a card that moved page (a
// door taken away, a need brought onto the front door) is still
// the same card, and keeps its words. Every alternative the repo
// has goes into one pool; a rendered card none of whose slots
// were found on its own page takes the pool's card with the same
// strong identity - the bucket it records into, the page it leads
// to, the group it invites into - never a bare `action` like `/`.
let mut pool : Vec < serde_yaml ::Value > = Vec ::new ();
for live in yaml_files ( & repo . join ( "questions" )) {
let Ok ( page ) = serde_yaml ::from_str ::< serde_yaml ::Value > ( & live ) else { continue };
if let Some ( alts ) = page . get ( "alternatives" ). and_then ( | a | a . as_sequence ()) {
pool . extend ( alts . iter (). cloned ());
}
}
let mut used = vec! [ false ; pool . len ()];
for text in rendered . values () {
let Ok ( page ) = serde_yaml ::from_str ::< serde_yaml ::Value > ( text ) else { continue };
let Some ( alts ) = page . get ( "alternatives" ). and_then ( | a | a . as_sequence ()) else { continue };
for alt in alts {
let ida : Vec < ( & str , String ) > = identity ( alt ). into_iter (). filter ( | ( k , v ) | strong_identity ( k , v )). collect ();
if ida . is_empty () || tokens_in ( alt ). iter (). any ( | t | found . contains_key ( t )) {
continue ;
}
if let Some ( j ) = ( 0 .. pool . len ()). find ( |& j | ! used [ j ] && identity ( & pool [ j ]). iter (). any ( | k | ida . contains ( k ))) {
used [ j ] = true ;
collect_words ( alt , & pool [ j ], & mut found );
}
}
}
2026-09-23 12:42:23 +02:00
Ok ( fallback . iter (). map ( | ( path , v ) | ( path . clone (), untokenize ( v , path , "" , & found ))). collect ())
}
2026-09-23 22:01:55 +02:00
/// An identity that survives a move between pages: what a card does,
/// not where a nav link points. `/received/<need>` and `/status/…`
/// are one need's own pages; `/`, `/owners` and the like are shared
/// by every "back" and every desk card.
fn strong_identity ( key : & str , value : & str ) -> bool {
match key {
"action" => value . starts_with ( "/received/" ) || value . starts_with ( "/status/" ) || value . starts_with ( "/decide/" ),
"name" => false ,
_ => true ,
}
}
/// Every token slot inside a rendered item.
fn tokens_in ( v : & serde_yaml ::Value ) -> Vec < String > {
let mut out = Vec ::new ();
match v {
serde_yaml ::Value ::String ( s ) if s . starts_with ( '\u{1}' ) => out . push ( s . clone ()),
serde_yaml ::Value ::Mapping ( m ) => out . extend ( m . values (). flat_map ( tokens_in )),
serde_yaml ::Value ::Sequence ( s ) => out . extend ( s . iter (). flat_map ( tokens_in )),
_ => {}
}
out
}
/// The text of every `.yaml` under `dir`, any depth.
fn yaml_files ( dir : & Path ) -> Vec < String > {
let mut out = Vec ::new ();
let Ok ( entries ) = std ::fs ::read_dir ( dir ) else { return out };
for entry in entries . flatten () {
let path = entry . path ();
if path . is_dir () {
out . extend ( yaml_files ( & path ));
} else if path . extension (). is_some_and ( | e | e == "yaml" ) {
if let Ok ( text ) = std ::fs ::read_to_string ( & path ) {
out . push ( text );
}
}
}
out
}
2026-09-23 20:48:47 +02:00
/// What identifies an item of a page regardless of its position: the
/// structural strings portal itself keys on. Word slots (tokens on
/// the rendered side) are never an identity.
fn identity ( v : & serde_yaml ::Value ) -> Vec < ( & 'static str , String ) > {
const KEYS : & [ & [ & str ]] = & [
2026-09-23 21:18:14 +02:00
& [ "bucket" ],
2026-09-23 20:48:47 +02:00
& [ "record_as" ],
& [ "action" ],
& [ "name" ],
& [ "invite" , "group" ],
& [ "resource" , "source" , "bucket" ],
& [ "self_transition" , "bucket" ],
];
let mut id = Vec ::new ();
for path in KEYS {
let mut at = v ;
for key in path . iter () {
match at . get ( * key ) {
Some ( next ) => at = next ,
None => {
at = & serde_yaml ::Value ::Null ;
break ;
}
}
}
if let Some ( s ) = at . as_str () {
if ! s . starts_with ( '\u{1}' ) && ! s . is_empty () {
id . push (( path [ 0 ], s . to_string ()));
}
}
}
id
}
2026-09-23 12:42:23 +02:00
fn token ( path : & str , pointer : & str ) -> String {
format! ( " \u{1} {path} \u{2} {pointer} \u{3} " )
}
fn tokenize ( v : & Value , path : & str , pointer : & str ) -> Value {
match v {
Value ::Object ( m ) => Value ::Object ( m . iter (). map ( | ( k , x ) | ( k . clone (), tokenize ( x , path , & format! ( " {pointer} / {k} " )))). collect ()),
Value ::String ( _ ) => Value ::String ( token ( path , pointer )),
other => other . clone (),
}
}
fn untokenize ( v : & Value , path : & str , pointer : & str , found : & std ::collections ::HashMap < String , String > ) -> Value {
match v {
Value ::Object ( m ) => Value ::Object ( m . iter (). map ( | ( k , x ) | ( k . clone (), untokenize ( x , path , & format! ( " {pointer} / {k} " ), found ))). collect ()),
Value ::String ( _ ) => match found . get ( & token ( path , pointer )) {
Some ( word ) => Value ::String ( word . clone ()),
None => v . clone (),
},
other => other . clone (),
}
}
fn collect_words ( a : & serde_yaml ::Value , b : & serde_yaml ::Value , found : & mut std ::collections ::HashMap < String , String > ) {
match ( a , b ) {
( serde_yaml ::Value ::Mapping ( x ), serde_yaml ::Value ::Mapping ( y )) => {
for ( k , va ) in x {
if let Some ( vb ) = y . get ( k ) {
collect_words ( va , vb , found );
}
}
}
2026-09-23 20:48:47 +02:00
// Items are matched by what they are, not where they sit: the
// bucket an alternative records into, the page it leads to, a
// field's name, the group it invites into. A rebuild that
// reorders or reshapes a page still hands each card its own
// words; on uhhm.no a positional match once put the old front
// door's three headings on three unrelated new cards. Items
// with no identity (an information feature) fall back to
// position.
2026-09-23 12:42:23 +02:00
( serde_yaml ::Value ::Sequence ( x ), serde_yaml ::Value ::Sequence ( y )) => {
2026-09-23 20:48:47 +02:00
let mut used = vec! [ false ; y . len ()];
for ( i , va ) in x . iter (). enumerate () {
let ida = identity ( va );
let hit = if ida . is_empty () {
2026-09-23 22:01:55 +02:00
// A name is a word, never an identity that blocks a
// positional match: a feature always carries one.
( i < y . len () && ! used [ i ] && identity ( & y [ i ]). iter (). all ( | ( k , _ ) | * k == "name" )). then_some ( i )
2026-09-23 20:48:47 +02:00
} else {
( 0 .. y . len ()). find ( |& j | ! used [ j ] && identity ( & y [ j ]). iter (). any ( | k | ida . contains ( k )))
};
if let Some ( j ) = hit {
used [ j ] = true ;
collect_words ( va , & y [ j ], found );
}
2026-09-23 12:42:23 +02:00
}
}
( serde_yaml ::Value ::String ( t ), serde_yaml ::Value ::String ( live )) if t . starts_with ( '\u{1}' ) => {
// An icon slot holds the bare name; the file carries `lucide:`.
let word = if t . ends_with ( "/icon \u{3} " ) { live . trim_start_matches ( "lucide:" ) } else { live . as_str () };
found . insert ( t . clone (), word . to_string ());
}
_ => {}
}
}
#[cfg(test)]
mod invite_tests {
use super ::* ;
2026-09-23 22:09:04 +02:00
const PLAN : & str = "business: Vel \n contact: { name: S, email: s@v.example } \n groups: \n - { id: traffic, label: Trafikk } \n invites_by: [board] \n needs: \n - id: report \n who: a neighbour \n wants: a hazard seen to \n lands_in: hazards \n handled_by: traffic \n stages: [open, fixed] \n done_when: [fixed] \n max_fields: 3 \n collects: \n - { name: where } \n - id: expenses \n who: a committee member \n wants: money back \n enters_as: traffic \n lands_in: expenses \n handled_by: board \n stages: [open, betalt, avvist] \n done_when: [betalt, avvist] \n max_fields: 3 \n collects: \n - { name: amount } \n " ;
2026-09-23 12:42:23 +02:00
2026-09-23 22:01:55 +02:00
#[test]
fn words_follow_a_card_that_moves_to_another_page () {
// Built once behind a door and hand-worded; rebuilt flat, the
// form's words come along even though its page is gone.
2026-09-23 22:09:04 +02:00
let doored = "business: Vel \n contact: { name: S, email: s@v.example } \n doors: \n - { id: report-something, who: someone who saw something } \n needs: \n - id: report \n who: a neighbour \n wants: a hazard seen to \n door: report-something \n lands_in: hazards \n handled_by: traffic \n stages: [open, fixed] \n done_when: [fixed] \n max_fields: 3 \n collects: \n - { name: where } \n " ;
2026-09-23 22:01:55 +02:00
let plan : Plan = serde_yaml ::from_str ( doored ). unwrap ();
let mut words = fallback_words ( & plan );
2026-09-23 22:09:04 +02:00
let door_page = words . get_mut ( "questions/report-something/index.yaml" ). unwrap ();
door_page [ "needs" ][ "report" ][ "heading" ] = json! ( "Report a traffic hazard" );
door_page [ "needs" ][ "report" ][ "hints" ][ "where" ] = json! ( "The bend by the school" );
2026-09-23 22:01:55 +02:00
let repo = std ::env ::temp_dir (). join ( format! ( "trainer-lift- {} " , std ::process ::id ()));
let _ = std ::fs ::remove_dir_all ( & repo );
write_repo ( & repo , render ( & plan , & words ). unwrap ()). unwrap ();
2026-09-23 22:09:04 +02:00
let flat : Plan = serde_yaml ::from_str ( & doored . replace ( "doors: \n - { id: report-something, who: someone who saw something } \n " , "" ). replace ( " door: report-something \n " , "" )). unwrap ();
2026-09-23 22:01:55 +02:00
let lifted = lift_words ( & flat , & repo ). unwrap ();
2026-09-23 22:09:04 +02:00
assert_eq! ( lifted [ "questions/index.yaml" ][ "needs" ][ "report" ][ "heading" ], "Report a traffic hazard" );
assert_eq! ( lifted [ "questions/index.yaml" ][ "needs" ][ "report" ][ "hints" ][ "where" ], "The bend by the school" );
2026-09-23 22:01:55 +02:00
let _ = std ::fs ::remove_dir_all ( & repo );
}
2026-09-23 12:42:23 +02:00
#[test]
fn inviting_desks_get_a_form_per_group_and_a_list_of_invites_sent () {
let plan : Plan = serde_yaml ::from_str ( PLAN ). unwrap ();
plan . validate (). unwrap ();
let files = render ( & plan , & fallback_words ( & plan )). unwrap ();
2026-09-23 22:09:04 +02:00
// The board invites into both groups; traffic only into itself.
let board = & files [ "questions/board/index.yaml" ];
assert_eq! ( board . matches ( "invite:" ). count (), 2 , "{board}" );
assert! ( board . contains ( "bucket: portal_invites" ));
let traffic = & files [ "questions/traffic/index.yaml" ];
assert_eq! ( traffic . matches ( "invite:" ). count (), 1 );
assert! ( traffic . contains ( "group: traffic" ) && ! traffic . contains ( "group: board" ));
2026-09-23 12:42:23 +02:00
// The plan is refused when an inviter has no desk.
2026-09-23 22:09:04 +02:00
let bad : Plan = serde_yaml ::from_str ( & PLAN . replace ( "invites_by: [board]" , "invites_by: [nobody]" )). unwrap ();
2026-09-23 12:42:23 +02:00
assert! ( bad . validate (). unwrap_err (). to_string (). contains ( "no desk" ));
}
#[test]
fn words_lift_back_out_of_a_rendered_repo_and_new_slots_keep_their_fallback () {
let plan : Plan = serde_yaml ::from_str ( PLAN ). unwrap ();
let mut words = fallback_words ( & plan );
// A hand-edited desk: Norwegian words in the live repo.
2026-09-23 22:09:04 +02:00
let desk = words . get_mut ( "questions/traffic/index.yaml" ). unwrap ();
desk [ "question" ] = json! ( "What is waiting?" );
desk [ "buckets" ][ "hazards" ][ "heading" ] = json! ( "Reported hazards" );
desk [ "buckets" ][ "hazards" ][ "moves" ][ "open->fixed" ] = json! ( "Fixed" );
2026-09-23 12:42:23 +02:00
let dir = std ::env ::temp_dir (). join ( format! ( "lift- {} " , std ::process ::id ()));
write_repo ( & dir , render ( & plan , & words ). unwrap ()). unwrap ();
let lifted = lift_words ( & plan , & dir ). unwrap ();
2026-09-23 22:09:04 +02:00
let desk = & lifted [ "questions/traffic/index.yaml" ];
assert_eq! ( desk [ "question" ], "What is waiting?" );
assert_eq! ( desk [ "buckets" ][ "hazards" ][ "heading" ], "Reported hazards" );
assert_eq! ( desk [ "buckets" ][ "hazards" ][ "moves" ][ "open->fixed" ], "Fixed" );
2026-09-23 12:42:23 +02:00
// A slot the repo never had keeps the fallback.
2026-09-23 22:09:04 +02:00
let plan2 : Plan = serde_yaml ::from_str ( & PLAN . replace ( "invites_by: [board]" , "invites_by: [board, traffic]" )). unwrap ();
2026-09-23 12:42:23 +02:00
let lifted2 = lift_words ( & plan2 , & dir ). unwrap ();
2026-09-23 22:09:04 +02:00
assert_eq! ( lifted2 [ "questions/traffic/index.yaml" ][ "question" ], "What is waiting?" );
assert! ( lifted2 [ "questions/traffic/index.yaml" ][ "invites" ][ "board" ][ "heading" ]. as_str (). unwrap (). starts_with ( "Invite someone" ));
2026-09-23 12:42:23 +02:00
let _ = std ::fs ::remove_dir_all ( & dir );
}
2026-09-23 20:48:47 +02:00
#[test]
fn words_follow_their_card_when_the_live_page_is_ordered_differently () {
let plan : Plan = serde_yaml ::from_str ( PLAN ). unwrap ();
let mut words = fallback_words ( & plan );
2026-09-23 22:09:04 +02:00
words . get_mut ( "questions/index.yaml" ). unwrap ()[ "needs" ][ "report" ][ "heading" ] = json! ( "Report a hazard" );
2026-09-23 20:48:47 +02:00
let dir = std ::env ::temp_dir (). join ( format! ( "lift-order- {} " , std ::process ::id ()));
write_repo ( & dir , render ( & plan , & words ). unwrap ()). unwrap ();
// The live front door has an extra first card the plan knows
// nothing about, so the hazard form is no longer first.
let front = dir . join ( "questions/index.yaml" );
let mut page : serde_yaml ::Value = serde_yaml ::from_str ( & std ::fs ::read_to_string ( & front ). unwrap ()). unwrap ();
let alternatives = page [ "alternatives" ]. as_sequence_mut (). unwrap ();
let stranger : serde_yaml ::Value = serde_yaml ::from_str ( "name: About us \n description: Founded in 1947. \n record_as: newsletter \n features: [] \n " ). unwrap ();
alternatives . insert ( 0 , stranger );
std ::fs ::write ( & front , serde_yaml ::to_string ( & page ). unwrap ()). unwrap ();
let lifted = lift_words ( & plan , & dir ). unwrap ();
2026-09-23 22:09:04 +02:00
assert_eq! ( lifted [ "questions/index.yaml" ][ "needs" ][ "report" ][ "heading" ], "Report a hazard" );
2026-09-23 20:48:47 +02:00
let _ = std ::fs ::remove_dir_all ( & dir );
}
2026-09-23 12:42:23 +02:00
}