//! 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, /// 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, /// 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, /// 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, /// 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, /// 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, pub needs: Vec, #[serde(default)] pub personas: Vec, } #[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, } /// 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>, D::Error> { #[derive(Deserialize)] #[serde(untagged)] enum OneOrMany { One(String), Many(Vec), } let raw: BTreeMap = 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, } #[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, } #[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, /// The door this public need sits behind; none means straight on /// the front door. #[serde(default)] pub door: Option, pub lands_in: String, pub handled_by: String, /// Every stage, first to last, finished states included. pub stages: Vec, pub done_when: Vec, #[serde(default)] pub success: Vec, /// Stages a case may pass over ("awaiting papers" when the papers /// were complete). #[serde(default)] pub skippable: Vec, /// 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, /// 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>, /// Groups that see these cases on their desk and move nothing. #[serde(default)] pub watched_by: Vec, /// 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, /// 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, /// 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, /// 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, pub collects: Vec, /// 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, /// `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, /// 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() } const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select"]; fn is_id(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') } impl Plan { pub fn validate(&self) -> anyhow::Result<()> { 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")); } } 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 || 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 { 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: {{: [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> { 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 = 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." /// 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 { let line: Vec = need.stages.iter().filter(|s| !need.done_when.contains(s)).map(|s| stage_text(need, s)).collect(); let ends: Vec = 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 } fn capitalize(text: &str) -> String { let mut chars = text.chars(); match chars.next() { Some(first) => first.to_uppercase().collect::() + 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 { // 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": "", // Two sections of context the plan already knows: the road // a case travels, and who is at the other end. The // fallbacks are built from the plan, so they are true // before a model has said a word. "road_title": "What happens next", "road_description": stages_sentence(need), "road_icon": "route", "who_title": "Who reads it", "who_description": format!("{}. Questions go to {} at {}.", humanize(&need.handled_by), plan.contact.name, plan.contact.email), "who_icon": "users", "form_title": "Details", "form_description": "", "labels": need.collects.iter().map(|f| (f.name.clone(), Value::String(humanize(&f.name)))).collect::>(), "hints": need.collects.iter().map(|f| (f.name.clone(), Value::String(String::new()))).collect::>(), }) }; 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 = 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 = 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::>().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 }, "doors": plan.doors.iter().map(|d| (d.id.clone(), json!({ "heading": humanize(&d.id), "description": format!("For {}.", d.who), "button": "Continue", "icon": "", "for_whom": "", }))).collect::>(), "needs": direct.iter().map(|n| (n.id.clone(), form(n))).collect::>(), }), ); 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::>(), }), ); } 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 = 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 = moves_of(needs[0], &group) .into_iter() .map(|(from, to)| (format!("{from}->{to}"), Value::String(capitalize(&stage_text(needs[0], &to))))) .collect(); (bucket.to_string(), json!({ "heading": humanize(bucket), "description": "", "moves": moves })) }) .collect(); let links: Map = plan .needs .iter() .filter(|n| n.enters_as.as_deref() == Some(group.as_str())) .map(|n| (n.id.clone(), json!({ "heading": n.wants, "description": "", "button": "Open" }))) .collect(); // One invite form per group this desk may fill, and the list of // invites sent, when it may fill any. let invites: Map = groups(plan) .iter() .filter(|g| inviters(plan, g).contains(&group)) .map(|g| { (g.clone(), json!({ "heading": format!("Invite someone into {}", label_of(plan, g)), "description": format!("They get an account, a place in {}, and a one-time link to set their own password or passkey.", label_of(plan, g)), "button": "Invite", "form_title": "Who", "labels": { "name": "Name", "email": "Email" }, })) }) .collect(); let invited = if invites.is_empty() { Value::Null } else { json!({ "heading": "Invitations sent", "description": "Each invite with the link it made. Mark it done when the person is in.", "done": "Is in" }) }; words.insert( format!("questions/{group}/index.yaml"), json!({ "question": "What needs doing?", "description": "", "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::>().join(" or ")), "heading": humanize(bucket), "list_description": "", "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 = 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 = Map::new(); for (stage, to) in &need.mails { 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::>(), }), ); } 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 { 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 { 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 { 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 = " 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 = 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 Some(link) = spec["body"].as_str().and_then(|b| b.lines().find(|l| l.contains("{site}/decide/"))) else { continue }; let Some(body) = ms.get_mut(stage).and_then(|m| m.get_mut("body")) else { continue }; let Some(text) = body.as_str() else { continue }; if text.contains("/decide/") { continue; } let kept: Vec<&str> = text.lines().filter(|l| l.trim() != "{chain}").collect(); *body = Value::String(format!("{}\n\n{link}", kept.join("\n").trim_end())); } } } /// 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(), (_, "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:` for a name on the list, otherwise nothing. pub fn icon_name(given: &str) -> Option { // 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::>(), }); if !s(&w["form_description"]).trim().is_empty() { feature["description"] = json!(s(&w["form_description"]).trim()); } if let Some(icon) = icon_name(s(&w["icon"])) { feature["icon"] = json!(icon); } // Context first, then the form: the reader learns where this goes // and who is at the other end before being asked for anything. let strip = |title: &str, text: &str, icon: &str| { let mut f = json!({ "name": title.trim(), "description": text.trim() }); if let Some(icon) = icon_name(icon) { f["icon"] = json!(icon); } f }; let mut features = Vec::new(); if !s(&w["road_description"]).trim().is_empty() { features.push(strip(s(&w["road_title"]), s(&w["road_description"]), s(&w["road_icon"]))); } if !s(&w["who_description"]).trim().is_empty() { features.push(strip(s(&w["who_title"]), s(&w["who_description"]), s(&w["who_icon"]))); } features.push(feature); let mut alt = json!({ "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) -> anyhow::Result> { let mut files: BTreeMap = 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::>(), "transitions": machine(need), }); if let Some(who) = &need.attended_by { aggregate["attended_by"] = json!(who); } aggregate }).collect::>() }), ); // 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 = front["opener"]["features"].as_object().into_iter().flatten().map(|(_, w)| card(w)).collect(); let mut alternatives: Vec = vec![json!({ "name": s(&front["opener"]["heading"]), "description": s(&front["opener"]["description"]), "features": does, })]; let mut shopping: Vec = 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()); if icon.is_some() || !for_whom.is_empty() { let mut f = json!({ "name": for_whom, "description": "" }); if let Some(icon) = icon { f["icon"] = json!(icon); } alt["features"] = json!([f]); } alt })); 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::>(), }), ); } 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": "", "description": "", "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::>(), }}], })); } 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": "", "requirements": [ { "name": "name", "label": s(&iw["labels"]["name"]) }, { "name": "email", "label": s(&iw["labels"]["email"]), "type": "email" }, ]}], })); } let lw = &w["invited"]; alternatives.push(json!({ "name": s(&lw["heading"]), "description": s(&lw["description"]), "action": format!("/{group}"), "consequence": [s(&w["confirm"])], "features": [{ "name": "", "description": "", "resource": { "source": { "kind": "kv", "bucket": "portal_invites" }, "requires_group": group, "transitions": [{ "from": "open", "to": "done", "label": s(&lw["done"]) }], }}], })); } 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::>().join(", ") ); files.insert( path, json!({ "name": s(&w["question"]), "description": s(&w["description"]), "responsible": responsible, "alternatives": [ { "name": s(&w["heading"]), "description": s(&w["list_description"]), "features": [{ "name": "", "description": "", "resource": { "source": { "kind": "kv", "bucket": bucket }, "public": true, "jq": projection, }}], }, { "name": s(&w["back"]), "description": "", "action": "/", "consequence": [s(&w["back"])] }, ], }), ); } // 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::>(), }), ); } // 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, "stage_words": n.stages.iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::>(), }); 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::>(), "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, short, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it." } else if path.contains("/decide/") { "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 = 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::>().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 --out [--words-from ] [--words ]... [--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 = 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 : 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 mut merged = merge_words(fallback, &given); if path == "aggregates.yaml" { keep_links(fallback, &mut merged); } 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"), } } } 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) -> 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::>(); 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![], 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 \", 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 "); 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::(&no_email).unwrap().validate().unwrap_err().to_string().contains("required `email`")); let no_from = yaml.replace("mail: { from: \"B \", reply_to: i@b.example }\n", ""); assert!(serde_yaml::from_str::(&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.\n\n{chain}" } } }); keep_links(&fallback, &mut merged); let body = merged["leaks"]["open"]["body"].as_str().unwrap(); assert!(body.ends_with("What you can do next: {site}/decide/leak?chain={chain}&email={email}")); assert!(!body.contains("\n{chain}")); // A body that kept its link is left alone. let mut fine = json!({ "leaks": { "open": { "subject": "s", "body": "Withdraw here: {site}/decide/leak?chain={chain}&email={email}" } } }); keep_links(&fallback, &mut fine); assert_eq!(fine["leaks"]["open"]["body"], "Withdraw here: {site}/decide/leak?chain={chain}&email={email}"); } #[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_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::(&three).unwrap().validate().unwrap_err().to_string().contains("one or two")); let bad = yaml.replace("for: leak", "for: nothing"); assert!(serde_yaml::from_str::(&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.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> { let fallback = fallback_words(plan); let tokened: BTreeMap = fallback.iter().map(|(path, v)| (path.clone(), tokenize(v, path, ""))).collect(); let rendered = render(plan, &tokened)?; let mut found: std::collections::HashMap = 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::(text), serde_yaml::from_str::(&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 = Vec::new(); for live in yaml_files(&repo.join("questions")) { let Ok(page) = serde_yaml::from_str::(&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::(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/` 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 { 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 { 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) -> 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) { 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() }; 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); } }