Files
portal/src/build.rs
T

2400 lines
125 KiB
Rust
Raw Normal View History

//! 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,
/// 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>,
/// What the business does, in its own words - context for the
/// model's wording, never structure.
#[serde(default)]
pub about: String,
/// 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>,
/// 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>,
/// 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>,
pub needs: Vec<PlanNeed>,
#[serde(default)]
pub personas: Vec<Value>,
}
#[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,
}
fn english() -> String {
"en".to_string()
}
/// `moves_by: { checking: submitter }` is what a model writes for a
/// single mover, and means the same as `[submitter]`; a bare word or
/// a list are both read.
fn one_or_many_by_key<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BTreeMap<String, Vec<String>>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
let raw: BTreeMap<String, OneOrMany> = BTreeMap::deserialize(d)?;
Ok(raw
.into_iter()
.map(|(k, v)| (k, match v { OneOrMany::One(s) => vec![s], OneOrMany::Many(l) => l }))
.collect())
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupDef {
pub id: String,
/// The name the public knows the group by; empty means the id
/// made readable.
#[serde(default)]
pub label: String,
/// Groups that may invite people into this one, besides itself and
/// the plan's `invites_by`.
#[serde(default)]
pub invited_by: Vec<String>,
}
#[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,
}
#[derive(Debug, Deserialize)]
pub struct Contact {
pub name: String,
pub email: String,
}
/// 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>,
}
#[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>,
/// The door this public need sits behind; none means straight on
/// the front door.
#[serde(default)]
pub door: Option<String>,
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>,
/// The unfinished stages a case may still be turned down from;
/// empty means every one. An applicant is declined before they
/// start and not after, where they depart instead.
#[serde(default)]
pub turned_down_from: Vec<String>,
/// What the person must already have or be for this alternative to
/// work: a membership, a reference number, the link from a mail,
/// an account. Stated on the card before the form, because a
/// condition a reader meets only after filling something in has
/// already cost them the filling in. Empty when there is none.
#[serde(default)]
pub requires: String,
/// 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)]
#[serde(deserialize_with = "one_or_many_by_key")]
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>,
/// 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>,
2026-09-24 05:17:18 +02:00
/// Each stage in plain words, in the plan's language, where the
/// id will not do: ids are ASCII, so a Norwegian `ikke_lost` reads
/// as English in a mail, a road line or a desk score. Used by the
/// fallback mails, the "what happens next" line, the desk
/// buttons, and written into needs.yaml for the scorer.
#[serde(default)]
pub stages_text: 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>,
/// 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>,
pub collects: Vec<Field>,
/// 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,
}
#[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>,
/// `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,
}
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";
fn text() -> String {
"text".to_string()
}
2026-09-24 21:35:45 +02:00
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location"];
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<()> {
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);
}
}
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);
}
}
}
let mut labelled = BTreeSet::new();
for g in &self.groups {
if !is_id(&g.id) || !labelled.insert(&g.id) {
bail!("group {:?}: id must be unique ASCII lowercase with - or _", g.id);
}
}
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");
}
}
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"));
}
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"));
}
}
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)) {
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"));
}
for state in &need.turned_down_from {
if need.done_when.contains(state) || !need.stages.contains(state) {
bail!(at(&format!("turned_down_from names {state:?}, which is not one of its unfinished stages")));
}
}
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")));
}
}
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-24 05:17:18 +02:00
for (state, text) in &need.stages_text {
if !need.stages.contains(state) {
bail!(at(&format!("stages_text names {state:?}, which is not one of its stages")));
}
if text.trim().is_empty() {
bail!(at(&format!("stages_text for {state:?} is empty")));
}
}
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 _")));
}
}
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"));
}
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)));
}
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)));
}
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)))
}
_ => {}
}
}
}
// Needs sharing a bucket share its machine.
for (bucket, needs) in self.by_bucket() {
let first = needs[0];
for other in &needs[1..] {
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
|| other.events != first.events
|| other.attended_by != first.attended_by
|| other.mails != first.mails
2026-09-24 05:17:18 +02:00
|| other.stages_text != first.stages_text
|| other.turned_down_from != first.turned_down_from
{
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);
}
}
}
Ok(())
}
/// 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()
}
/// 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()
}
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;
}
}
}
if need.turned_down_from.is_empty() || need.turned_down_from.contains(stage) {
to.extend(ends_badly.iter().map(|s| (*s).clone()));
}
moves.insert((*stage).clone(), to);
}
moves
}
/// 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()
}
/// The stages a case goes through, as one sentence: "It is received,
/// then surveyed, then quoted, and ends won or lost."
2026-09-24 05:17:18 +02:00
/// A stage in words: what the plan says, else its id made readable.
pub fn stage_text(need: &PlanNeed, stage: &str) -> String {
need.stages_text.get(stage).cloned().unwrap_or_else(|| humanize(stage).to_lowercase())
}
fn stages_sentence(need: &PlanNeed) -> String {
2026-09-24 05:17:18 +02:00
let line: Vec<String> = need.stages.iter().filter(|s| !need.done_when.contains(s)).map(|s| stage_text(need, s)).collect();
let ends: Vec<String> = need.done_when.iter().map(|s| stage_text(need, s)).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-24 05:17:18 +02:00
fn capitalize(text: &str) -> String {
let mut chars = text.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
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> {
// 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.
let form = |need: &PlanNeed| {
json!({
"heading": need.wants,
"description": format!("For {}.", need.who),
"button": "Send",
"encouragement": "",
"icon": "",
// Three sections of context the plan already knows: what
// the person must have for this to work, 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.
"requires_title": if need.requires.trim().is_empty() { String::new() } else { "What you need".to_string() },
"requires_description": need.requires.trim(),
"requires_icon": if need.requires.trim().is_empty() { "" } else { "key" },
"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",
"form_title": "Details",
"form_description": "",
"labels": need.collects.iter().map(|f| (f.name.clone(), Value::String(humanize(&f.name)))).collect::<Map<_, _>>(),
"hints": need.collects.iter().map(|f| (f.name.clone(), Value::String(String::new()))).collect::<Map<_, _>>(),
})
};
let mut words = BTreeMap::new();
let direct: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.is_none() && n.door.is_none()).collect();
// 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",
}),
);
}
words.insert(
"questions/index.yaml".to_string(),
json!({
"question": "What brings you here?",
"description": plan.about,
"opener": { "heading": "What we do", "description": "", "features": does },
"shopping": { "heading": "Have a look around", "description": "", "items": shopping },
// A door's feature says what is behind it, from the needs
// that sit there: a card that leads somewhere has to say
// where, or it is a button with decoration around it.
"doors": plan.doors.iter().map(|d| (d.id.clone(), json!({
"heading": humanize(&d.id), "description": format!("For {}.", d.who), "button": "Continue",
"icon": "", "for_whom": "",
"behind": format!("{}.", capitalize(&plan.needs_behind(&d.id).iter().map(|n| n.wants.as_str()).collect::<Vec<_>>().join(", "))),
}))).collect::<Map<_, _>>(),
"needs": direct.iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
}),
);
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<_, _>>(),
}),
);
}
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;
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);
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()
.filter(|(_, needs)| on_desk(needs[0], &group))
.map(|(bucket, needs)| {
// Only the buttons this group presses; a watcher gets none.
let moves: Map<String, Value> = moves_of(needs[0], &group)
.into_iter()
2026-09-24 05:17:18 +02:00
.map(|(from, to)| (format!("{from}->{to}"), Value::String(capitalize(&stage_text(needs[0], &to)))))
.collect();
// The list itself says what a row is and what the
// buttons under it do; the card's own description says
// what the cases are.
let note = if moves.is_empty() {
"Each row is one case and the stage it has reached. Another desk moves these.".to_string()
} else {
"Each row is one case. The buttons record what you have just done with it.".to_string()
};
let from_whom = format!("Sent in by {}.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or "));
(bucket.to_string(), json!({ "heading": humanize(bucket), "description": from_whom, "moves": moves, "list_title": humanize(bucket), "list_note": note }))
})
.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": format!("For {}.", n.who), "button": "Open" })))
.collect();
// 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",
"form_description": "Their name, and the address the account and its one-time link go to.".to_string(),
"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",
"list_title": "Who has been invited",
"list_note": "Each row is one invitation, with the one-time link while it is unused.",
})
};
words.insert(
format!("questions/{group}/index.yaml"),
json!({ "question": "What needs doing?", "description": format!("Everything {} looks after.", label_of(plan, &group).to_lowercase()), "confirm": "Confirm", "buckets": buckets, "links": links, "invites": invites, "invited": invited }),
);
}
// 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": "",
// A public list says what a row is and what it leaves
// out, since the reader cannot know what was pruned.
"list_title": humanize(bucket),
"list_note": "Each row is one case and the stage it has reached. Names and contact details are not shown here.",
"back": "Back",
}),
);
}
// 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
// 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),
};
let mut per_stage: Map<String, Value> = Map::new();
for (stage, to) in &need.mails {
2026-09-24 05:17:18 +02:00
let stage_word = stage_text(need, stage);
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)
};
let mut body = if to == SUBMITTER {
format!("{greeting}{what}")
} else {
format!("{} {} {} {stage_word}: {{chain}}.", t.a_case_in, humanize(bucket).to_lowercase(), t.is_now)
};
if to == SUBMITTER && moves_of(need, SUBMITTER).iter().any(|(from, _)| from == stage) {
body.push_str(&format!("\n\n{}: {{site}}/decide/{}?chain={{chain}}&email={{email}}", t.what_you_can_do_next, need.id));
}
body.push_str(&format!("\n\n{} {} {}", plan.contact.name, t.at, plan.contact.email));
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));
}
// 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<_, _>>(),
}),
);
}
words
}
fn door_path(door: &Door) -> String {
format!("questions/{}/index.yaml", door.id)
}
fn decide_path(need: &PlanNeed) -> String {
format!("questions/decide/{}.yaml", need.id)
}
const SUBMITTER: &str = "submitter";
/// 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",
}
}
}
}
/// Alternatives on one page before a visitor stops reading them.
const MAX_FRONT_DOOR: usize = 7;
/// 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 {
group != PUBLIC && (need.handled_by == group || need.watched_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty())
}
fn groups(plan: &Plan) -> BTreeSet<String> {
plan.needs
.iter()
.flat_map(|n| {
[Some(n.handled_by.clone()), n.enters_as.clone()]
.into_iter()
.flatten()
.chain(n.watched_by.iter().filter(|g| *g != PUBLIC).cloned())
.chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned())
})
.collect()
}
/// 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.trim().to_string()).filter(|l| !l.is_empty()).unwrap_or_else(|| humanize(group))
}
/// 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(plan: &Plan, field: &Field) -> Vec<String> {
match field.options_from.as_deref() {
Some("groups") => plan.groups.iter().map(|g| label_of(plan, &g.id)).collect(),
_ => field.options.clone(),
}
}
/// 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())
}
// 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())
}
_ => fallback.clone(),
}
}
const SLOT: &str = "<WRITE";
/// 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,
}
}
/// 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 link = spec["body"].as_str().and_then(|b| b.lines().find(|l| l.contains("{site}/decide/")));
let Some(body) = ms.get_mut(stage).and_then(|m| m.get_mut("body")) else { continue };
let Some(text) = body.as_str() else { continue };
// A bare `{chain}` outside a link is the record id pasted
// into prose, which a model does when told to keep
// placeholders; it goes, on its own line or inline.
let cleaned: Vec<String> = text
.lines()
.map(|l| if l.contains("/decide/") { l.to_string() } else { l.replace("{chain}", "").split_whitespace().collect::<Vec<_>>().join(" ") })
.collect();
let mut cleaned = cleaned.join("\n").trim_end().to_string();
if let Some(link) = link {
if !cleaned.contains("/decide/") {
cleaned = format!("{cleaned}\n\n{link}");
}
}
*body = Value::String(cleaned);
}
}
}
/// 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 {
slots_in(value, key, "")
}
fn slots_in(value: &Value, key: &str, parent: &str) -> Value {
match value {
Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), slots_in(v, k, key))).collect()),
Value::String(_) => Value::String(format!(
"{SLOT}: {}>",
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(),
(_, "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(),
(_, "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(),
(_, "list_title") => "a short title, two to four words, naming the list itself".to_string(),
(_, "list_note") => "one sentence saying what a single row is, and what the buttons under it do or what the list leaves out".to_string(),
(_, "behind") => "one sentence naming what a reader can do behind this door, in their words".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}`"),
}
)),
other => other.clone(),
}
}
/// 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> {
// 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());
}
let name = given.trim().trim_start_matches("lucide:");
ICONS.contains(&name).then(|| format!("lucide:{name}"))
}
fn s(v: &Value) -> &str {
v.as_str().unwrap_or_default()
}
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();
// What the person must already have or be for this to work at all,
// first of everything: a card that advertises something conditional
// and says so only after the form has taken their time has already
// wasted it.
// Whether the section exists is the plan's to say, never the
// wording's: under a lift every slot holds a token, and a token is
// never empty, so a word-driven condition would conjure a section
// the live page does not have and shift every feature after it.
if !need.requires.trim().is_empty() {
features.push(strip(s(&w["requires_title"]), s(&w["requires_description"]), s(&w["requires_icon"])));
}
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!({
"name": s(&w["heading"]),
"description": s(&w["description"]),
"action": action,
"consequence": [s(&w["button"])],
"record_as": need.lands_in,
"features": features,
});
if !s(&w["encouragement"]).trim().is_empty() {
alt["encouragements"] = json!([s(&w["encouragement"]).trim()]);
}
alt
}
/// 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 });
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);
let mail_words = words.get("aggregates.yaml").cloned().unwrap_or(Value::Null);
files.insert(
"aggregates.yaml".into(),
json!({ "aggregates": plan.by_bucket().into_iter().map(|(bucket, needs)| {
let need = needs[0];
let mut aggregate = json!({
"bucket": bucket,
"initial": &need.stages[0],
"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<_, _>>(),
"transitions": machine(need),
});
if let Some(who) = &need.attended_by {
aggregate["attended_by"] = json!(who);
}
aggregate
}).collect::<Vec<_>>() }),
);
// 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.
let front = &words["questions/index.yaml"];
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
.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());
let behind = s(&dw["behind"]).trim();
if icon.is_some() || !for_whom.is_empty() || !behind.is_empty() {
let mut f = json!({ "name": for_whom, "description": behind });
if let Some(icon) = icon { f["icon"] = json!(icon); }
alt["features"] = json!([f]);
}
alt
}));
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!(
"build: the front door offers {} alternatives after the opener; past {MAX_FRONT_DOOR} a visitor stops reading. Fewer offers, or needs that share one.",
plan.front_door_count()
);
}
files.insert(
"questions/index.yaml".into(),
json!({ "name": s(&front["question"]), "description": s(&front["description"]), "responsible": responsible, "alternatives": alternatives }),
);
// 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<_>>(),
}),
);
}
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];
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"])] }));
// Nested and not an index: portal infers a followup page.
files.insert(path, json!({ "name": s(&w["question"]), "description": s(&w["description"]), "alternatives": alternatives }));
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,
"alternatives": [form_alternative(plan, need, w, &format!("/{group}/{}-sent", need.id))],
}),
);
}
}
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() {
if !on_desk(needs[0], &group) {
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": s(&bw["list_title"]), "description": s(&bw["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": bucket },
"requires_group": group,
// 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}")]) })
}).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"])],
}));
}
// 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": s(&iw["form_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": s(&lw["list_title"]), "description": s(&lw["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": "portal_invites" },
"requires_group": group,
"transitions": [{ "from": "open", "to": "done", "label": s(&lw["done"]) }],
}}],
}));
}
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 }),
);
}
// 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": s(&w["list_title"]), "description": s(&w["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": bucket },
"public": true,
"jq": projection,
}}],
},
{ "name": s(&w["back"]), "description": "", "action": "/", "consequence": [s(&w["back"])] },
],
}),
);
}
// 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<_>>(),
}),
);
}
// 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| {
// `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),
};
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,
"max_steps": steps, "max_fields": n.max_fields,
2026-09-24 05:17:18 +02:00
"stage_words": n.stages.iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::<Map<_, _>>(),
});
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
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); }
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 {
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, at most three short sentences plus the link line, no greeting longer than one word, 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/") {
"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" {
"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."
} 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."
} else if path.ends_with("/index.yaml") {
"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."
} 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."
};
let mut context: Vec<String> = plan
.doors
.iter()
.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(", ")))
.collect();
context.extend(
plan.needs
.iter()
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "))),
);
format!(
"You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow 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\nRules 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\nReply with the JSON object only.",
plan.business,
plan.about.trim(),
plan.contact.name,
plan.contact.email,
plan.language,
context.join("\n"),
ICONS.join(", "),
serde_json::to_string_pretty(&slots(fallback, "")).unwrap_or_default(),
)
}
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();
let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--words-from <existing repo>] [--words <words.json>]... [--llm 'cmd'] [--lint question_lint]";
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()?;
for warning in plan.spectators() {
eprintln!("build: {warning}");
}
// 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),
};
// `--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}"))?)
.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"),
}
}
}
if let Some(cmd) = value("--llm") {
std::fs::create_dir_all(out.join("llm"))?;
// --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");
for (n, (path, fallback)) in words.clone().iter().enumerate() {
if only.as_ref().is_some_and(|p| !path.starts_with(p.as_str())) {
continue;
}
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) => {
let merged = merge_words(fallback, &given);
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}");
}
}
}
None => eprintln!("words: {path} - no usable reply, keeping the fallback"),
}
}
}
// Last, whatever the words came from - a model, an overrides file,
// a lift from a live repo: a mail keeps the link to the sender's
// own move, and a placeholder left loose in its prose goes.
if let Some(mails) = words.get("aggregates.yaml").cloned() {
let mut mails = mails;
keep_links(&fallback_words(&plan)["aggregates.yaml"], &mut mails);
words.insert("aggregates.yaml".to_string(), mails);
}
write_repo(&out, render(&plan, &words)?)?;
// 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)?)?;
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}"))?;
let stderr = String::from_utf8_lossy(&output.stderr);
print!("{}{}", String::from_utf8_lossy(&output.stdout), stderr);
if !output.status.success() {
// 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");
}
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 {
id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None, door: None,
lands_in: "b".into(), handled_by: "g".into(),
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
moves_by: BTreeMap::new(), watched_by: vec![],
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], requires: String::new(),
collects: vec![], max_fields: 3,
}
}
#[test]
fn a_stage_may_mail_the_submitter_or_an_address_and_the_words_fill_it() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: \"B <post@b.example>\", reply_to: i@b.example }\nneeds:\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"));
}
#[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);
}
#[test]
fn a_models_mail_keeps_the_submitters_link() {
let fallback = json!({ "leaks": { "open": { "subject": "s", "body": "Hello,\n\nGot it.\n\nWhat you can do next: {site}/decide/leak?chain={chain}&email={email}\n\nI at i@b.example" } } });
let mut merged = json!({ "leaks": { "open": { "subject": "Got it", "body": "Hi,\n\nWe have it. {chain}\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("it. {chain}") && !body.contains("\n{chain}"));
assert!(body.starts_with("Hi,\n\nWe have it."));
// 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}");
}
#[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");
}
#[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_stated_prerequisite_comes_before_the_form() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: renew\n who: members\n wants: another year\n requires: You need your membership number from last year's receipt.\n lands_in: renewals\n handled_by: office\n stages: [open, done]\n done_when: [done]\n max_fields: 2\n collects:\n - { name: number }\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 features = front["alternatives"][1]["features"].as_array().unwrap();
assert_eq!(features[0]["name"], "What you need");
assert_eq!(features[0]["description"], "You need your membership number from last year's receipt.");
assert_eq!(features[1]["name"], "What happens next");
// No prerequisite, no section.
let none: Plan = serde_yaml::from_str(&yaml.replace(" requires: You need your membership number from last year's receipt.\n", "")).unwrap();
let files = render(&none, &fallback_words(&none)).unwrap();
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
assert_eq!(front["alternatives"][1]["features"][0]["name"], "What happens next");
}
#[test]
fn a_case_is_turned_down_only_where_the_plan_says() {
let mut n = need(&["open", "invited", "active", "departed", "declined"], &["departed", "declined"], &["departed"], &[]);
n.turned_down_from = vec!["open".into(), "invited".into()];
let m = machine(&n);
assert_eq!(m["open"], ["invited", "declined"]);
assert_eq!(m["invited"], ["active", "declined"]);
assert_eq!(m["active"], ["departed"]);
}
#[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"]);
}
#[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();
let alt = &page["alternatives"][1];
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());
}
#[test]
fn words_take_the_models_strings_and_never_its_structure() {
let fallback = json!({"question": "What?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}});
let given = json!({"question": " What do you need? ", "needs": {"a": {"heading": "", "labels": {"email": 7, "extra": "x"}}, "b": {"heading": "B"}}, "injected": "y"});
let merged = merge_words(&fallback, &given);
assert_eq!(merged, json!({"question": "What do you need?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}}));
}
const PLAN: &str = "business: Nordvik Bygg\ncontact: { name: Ingrid, email: i@n.example }\nabout: We fix old buildings.\nneeds:\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";
#[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"));
assert!(files["needs.yaml"].contains("max_fields: 4"));
assert!(files["needs.yaml"].contains("enters: /crew/report"));
}
#[test]
fn roles_overlap_on_one_aggregate_each_with_their_own_buttons() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\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";
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`"));
}
#[test]
fn the_front_door_opens_with_what_we_do_and_a_look_around_before_any_button() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ndoes:\n - { id: fix, text: We fix roofs., for: leak }\n - { id: keep, text: We keep them fixed. }\nshows:\n - id: built\n text: Roofs we have done.\n resource: { public: true, source: { kind: url, url: https://b.example/roofs.json } }\nneeds:\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"));
}
#[test]
fn a_door_is_a_first_question_with_its_needs_on_a_page_of_their_own() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ndoors:\n - { id: your-building, who: an owner with a building }\nneeds:\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();
// The front door: the opener, then the door, then the direct need.
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();
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());
// 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() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ngroups:\n - { id: traffic, label: Traffic }\n - { id: elvesti, label: Riverside }\nneeds:\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";
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();
let field = &page["alternatives"][1]["features"][2]["requirements"][1];
assert_eq!(field["options"], json!(["Traffic", "Riverside"]));
assert_eq!(field["multiple"], true);
// Without labelled groups there is nothing to draw from.
let no_groups: Plan = serde_yaml::from_str(&yaml.replace("groups:\n - { id: traffic, label: Traffic }\n - { id: elvesti, label: Riverside }\n", "")).unwrap();
assert!(no_groups.validate().unwrap_err().to_string().contains("no labelled"));
// `multiple` on a text field is a mistake, not a no-op.
let bad: Plan = serde_yaml::from_str(&yaml.replace("{ name: name }", "{ name: name, multiple: true }")).unwrap();
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() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\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";
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"));
assert!(files.contains_key("questions/board/index.yaml"));
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());
assert_eq!(resource["jq"], ".[] | .responses |= {what: .what, where: .where}");
assert!(view.get("qualifies").is_none());
// The followup page points people at the list.
let followup: Value = serde_yaml::from_str(&files["questions/received/traffic.yaml"]).unwrap();
assert_eq!(followup["alternatives"][1]["action"], "/status/meldinger");
// An internal need cannot be public.
let internal: Plan = serde_yaml::from_str(&yaml.replace(" lands_in:", " enters_as: board\n lands_in:")).unwrap();
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());
}
#[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"));
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");
let plan: Plan = serde_yaml::from_str(&clash).unwrap();
assert!(plan.validate().unwrap_err().to_string().contains("must agree"));
}
}
/// 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 {
// 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;
}
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);
}
// 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);
}
}
}
Ok(fallback.iter().map(|(path, v)| (path.clone(), untokenize(v, path, "", &found))).collect())
}
/// 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
}
/// 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]] = &[
&["bucket"],
&["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
}
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);
}
}
}
// 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.
(serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) => {
let mut used = vec![false; y.len()];
for (i, va) in x.iter().enumerate() {
let ida = identity(va);
let hit = if ida.is_empty() {
// 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)
} 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);
}
}
}
(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() };
// A lift carries words across, not absences: where the live
// page said nothing, the fallback stands. Otherwise a slot
// that has since been given something to say would be
// emptied again by every rebuild.
if !word.trim().is_empty() {
found.insert(t.clone(), word.to_string());
}
}
_ => {}
}
}
#[cfg(test)]
mod invite_tests {
use super::*;
const PLAN: &str = "business: Vel\ncontact: { name: S, email: s@v.example }\ngroups:\n - { id: traffic, label: Trafikk }\ninvites_by: [board]\nneeds:\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";
#[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.
let doored = "business: Vel\ncontact: { name: S, email: s@v.example }\ndoors:\n - { id: report-something, who: someone who saw something }\nneeds:\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";
let plan: Plan = serde_yaml::from_str(doored).unwrap();
let mut words = fallback_words(&plan);
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");
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();
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();
let lifted = lift_words(&flat, &repo).unwrap();
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");
let _ = std::fs::remove_dir_all(&repo);
}
#[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();
// 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"));
// The plan is refused when an inviter has no desk.
let bad: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]", "invites_by: [nobody]")).unwrap();
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.
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");
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();
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");
// A slot the repo never had keeps the fallback.
let plan2: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]", "invites_by: [board, traffic]")).unwrap();
let lifted2 = lift_words(&plan2, &dir).unwrap();
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"));
let _ = std::fs::remove_dir_all(&dir);
}
#[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);
words.get_mut("questions/index.yaml").unwrap()["needs"]["report"]["heading"] = json!("Report a hazard");
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\ndescription: Founded in 1947.\nrecord_as: newsletter\nfeatures: []\n").unwrap();
alternatives.insert(0, stranger);
std::fs::write(&front, serde_yaml::to_string(&page).unwrap()).unwrap();
let lifted = lift_words(&plan, &dir).unwrap();
assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["heading"], "Report a hazard");
let _ = std::fs::remove_dir_all(&dir);
}
}