Files
portal/src/trainer/build.rs
T

3620 lines
199 KiB
Rust
Raw Normal View History

//! Structure from the plan, words from the model.
//!
//! A `plan.yaml` is a needs spec that also says what each form collects.
//! From it, with no model involved, come `needs.yaml`,
//! `aggregates.yaml`, `site.yaml`, the front door, a followup page per
//! need, a directory per group (gated by `_section.yaml`) holding its
//! desk and the pages only that group may open, and every desk button
//! the state machine allows. What the business said determines all of
//! that; a model has nothing to decide there and much to get wrong
//! (measured: a dropped stage from a strong model, no loadable site at
//! all from a small one).
//!
//! The model writes the words: one small JSON object per page - the
//! question, headings, descriptions, field and button labels - in the
//! plan's language. Words are merged over built-in fallbacks key by
//! key, so a reply that is missing, malformed or partial costs wording,
//! never a working site.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use anyhow::{anyhow, bail, Context};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use crate::trainer::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,
/// Where the site keeps its people: `kanidm` (the default) makes an
/// account per invite and holds the groups; `memberships` keeps the
/// groups in portal (its `portal_memberships` bucket) and lets any
/// sign-in provider say who someone is - a person is then their
/// phone number first, so invite forms ask for one.
#[serde(default = "kanidm")]
pub people: String,
/// The storylines the site is told in, one tone of voice each:
/// "plain and brief, like a good notice on the door", "warm, as a
/// neighbour would put it". The words model writes every slot once
/// per tone, the content carries the words as lists, and portal
/// gives each visitor one storyline and counts what each brought
/// in (portal docs, Storylines). One tone, or none, is one story.
#[serde(default)]
pub tones: Vec<String>,
/// The sender of every mail the site sends (`mail:` in site.yaml).
/// Required as soon as any need has `mails:`.
#[serde(default)]
pub mail: Option<PlanMail>,
/// What the business does, in its own words - context for the
/// model's wording, never structure.
#[serde(default)]
pub about: String,
/// A first question before the forms. Each door is one alternative
/// on the front door leading to a page of its own, where the needs
/// that name it sit. Five or six needs on one page is more than a
/// visitor sorts through; two or three doors of two or three each
/// is what the page-load limit asks for.
#[serde(default)]
pub doors: Vec<Door>,
/// Texts the site carries word for word - bylaws, terms - each as
/// a page of its own where every section of the text is one
/// alternative: its heading the card's, its paragraphs the card's
/// words. No model touches them and no form hangs off them.
#[serde(default)]
pub documents: Vec<Document>,
/// Groups with a label the public may see: the committees a
/// person can ask to join. A select field with `options_from:
/// groups` offers exactly these, so the form and the desks can
/// never disagree about what the committees are.
#[serde(default)]
pub groups: Vec<GroupDef>,
/// Groups that may invite people into every group with a desk (the
/// board), besides each group inviting into itself. An inviting
/// group's desk gets one invite form per group it may fill (portal
/// creates the account and a one-time credential link) and a list
/// of the invites sent. This is onboarding from the content side:
/// no admin session, a member of the business vouching for a person.
#[serde(default)]
pub invites_by: Vec<String>,
/// What the business does, said plainly, one feature each: the
/// front door's first card, which asks for nothing. A stranger
/// reads it and decides whether to trust the place before any
/// button is offered. Empty means one feature per public need,
/// from its `wants`.
#[serde(default)]
pub does: Vec<Does>,
/// Things a visitor may look at before asking for anything - the
/// window shopping: a list of what has been built, releases, a
/// live count - each a portal resource block, passed through as
/// written. Public views of buckets (`watched_by: [public]`) are
/// listed here on their own.
#[serde(default)]
pub shows: Vec<Show>,
pub needs: Vec<PlanNeed>,
#[serde(default)]
pub personas: Vec<Value>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Does {
pub id: String,
/// The fallback description, in the business's words.
pub text: String,
/// The need this feature speaks for: one role, so the opener is
/// one or two roles meeting, not a catalogue.
#[serde(default, rename = "for")]
pub for_need: Option<String>,
}
/// Features the opener may carry. One or two, each for a role: past
/// that the opener is a menu again, and a menu is what it is there
/// to spare the visitor.
const MAX_OPENER: usize = 2;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Show {
pub id: String,
#[serde(default)]
pub text: String,
/// Portal's `resource:` block for a feature, verbatim (source,
/// jq, public, empty). The lint holds it to portal's schema.
pub resource: Value,
}
fn english() -> String {
"en".to_string()
}
/// `moves_by: { checking: submitter }` is what a model writes for a
/// single mover, and means the same as `[submitter]`; a bare word or
/// a list are both read.
fn one_or_many_by_key<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BTreeMap<String, Vec<String>>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
let raw: BTreeMap<String, OneOrMany> = BTreeMap::deserialize(d)?;
Ok(raw
.into_iter()
.map(|(k, v)| (k, match v { OneOrMany::One(s) => vec![s], OneOrMany::Many(l) => l }))
.collect())
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupDef {
pub id: String,
/// The name the public knows the group by; empty means the id
/// made readable.
#[serde(default)]
pub label: String,
/// Groups that may invite people into this one, besides itself and
/// the plan's `invites_by`.
#[serde(default)]
pub invited_by: Vec<String>,
}
/// A text carried word for word (`Plan.documents`).
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Document {
/// The page's path: `vedtekter` is served at `/vedtekter`.
pub id: String,
/// The question the page asks, which the text answers.
pub question: String,
#[serde(default)]
pub description: String,
/// A markdown file beside the plan: every heading with text under
/// it is a section. Read into `sections` when the plan is loaded.
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub sections: Vec<Section>,
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Section {
pub heading: String,
/// The section's paragraphs, exactly as written.
pub text: Vec<String>,
}
/// A markdown text as sections: each heading with text under it, and
/// that text as paragraphs. A heading with nothing under it is a title
/// and no section; text before the first heading belongs to nobody and
/// is left out. Nothing is reworded: a paragraph's lines are joined
/// with one space, and that is all.
pub fn sections_of(markdown: &str) -> Vec<Section> {
let mut out: Vec<Section> = Vec::new();
let mut heading: Option<String> = None;
let mut paragraphs: Vec<String> = Vec::new();
let mut current: Vec<&str> = Vec::new();
let mut close = |heading: &mut Option<String>, paragraphs: &mut Vec<String>, out: &mut Vec<Section>| {
if let Some(h) = heading.take() {
if !paragraphs.is_empty() {
out.push(Section { heading: h, text: std::mem::take(paragraphs) });
}
}
paragraphs.clear();
};
for line in markdown.lines().chain(std::iter::once("")) {
let trimmed = line.trim();
let is_heading = trimmed.starts_with('#') && trimmed.trim_start_matches('#').starts_with(' ');
if trimmed.is_empty() || is_heading {
if !current.is_empty() {
paragraphs.push(current.join(" "));
current.clear();
}
}
if is_heading {
close(&mut heading, &mut paragraphs, &mut out);
heading = Some(trimmed.trim_start_matches('#').trim().trim_end_matches('.').to_string());
} else if !trimmed.is_empty() {
current.push(trimmed);
}
}
close(&mut heading, &mut paragraphs, &mut out);
out
}
impl Plan {
/// Reads every document's `file`, relative to `dir`, into its
/// sections. Done once, when a plan is loaded from disk.
pub fn load_documents(&mut self, dir: &Path) -> anyhow::Result<()> {
for doc in &mut self.documents {
if let Some(file) = &doc.file {
let path = dir.join(file);
let raw = std::fs::read_to_string(&path).with_context(|| format!("document {:?}: reading {}", doc.id, path.display()))?;
doc.sections = sections_of(&raw);
}
}
Ok(())
}
}
#[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,
/// The group the contact is: a board's inbox, not a person. Then
/// portal makes no account for the address, and every member of
/// the group may suggest changes to the pages it answers for.
#[serde(default)]
pub group: Option<String>,
}
/// Who the site's mail comes from: portal sends nothing without it,
/// and gdo on the host has to be allowed to send for the domain.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanMail {
pub from: String,
#[serde(default)]
pub reply_to: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanNeed {
pub id: String,
pub who: String,
pub wants: String,
#[serde(default)]
pub enters_as: Option<String>,
/// The door this public need sits behind; none means straight on
/// the front door. With `enters_as` too, the door carries a card
/// that leads to the group's page, where the form is: a seat on a
/// committee is offered to everyone and open to members.
#[serde(default)]
pub door: Option<String>,
/// One answer per record of another need: a neighbour answering a
/// notice on the board. The form sits on a page of its own per
/// record (`/status/<bucket>/[key]`), reached from that record's
/// row on the public list, and carries the record's id back in,
/// so `mails: { open: owner }` reaches whoever sent that record.
/// The other need must be `watched_by: [public]` and collect an
/// `email` for `owner` to go anywhere.
#[serde(default)]
pub about: Option<String>,
pub lands_in: String,
pub handled_by: String,
/// Every stage, first to last, finished states included.
pub stages: Vec<String>,
pub done_when: Vec<String>,
#[serde(default)]
pub success: Vec<String>,
/// Stages a case may pass over ("awaiting papers" when the papers
/// were complete).
#[serde(default)]
pub skippable: Vec<String>,
/// The unfinished stages a case may still be turned down from;
/// empty means every one. An applicant is declined before they
/// start and not after, where they depart instead.
#[serde(default)]
pub turned_down_from: Vec<String>,
/// Where a stage is entered from, when the line does not say it:
/// `{ incomplete: [complete, on_hearing] }` sends a case back for
/// papers it was already past, and `{ changed_by_us: [reconsidered] }`
/// puts a good ending somewhere other than the end of the line.
/// Naming a stage here replaces the moves into it that the line
/// would have given, and only those - every other stage keeps its
/// own. A regulated case needs both: an authority may refuse only
/// once the papers are complete, while the applicant may withdraw
/// from anywhere, and the two cannot be one list.
#[serde(default)]
#[serde(deserialize_with = "one_or_many_by_key")]
pub reached_from: BTreeMap<String, Vec<String>>,
/// What the person must already have or be for this alternative to
/// work: a membership, a reference number, the link from a mail,
/// an account. Stated on the card before the form, because a
/// condition a reader meets only after filling something in has
/// already cost them the filling in. Empty when there is none.
#[serde(default)]
pub requires: String,
/// Who presses the button that moves a case INTO a stage, where it
/// is not `handled_by`: another group, several groups, or
/// `submitter` - the person who sent the form, through their own
/// link (the form must then collect a required `email`). This is
/// how roles overlap on one aggregate: a project manager prices a
/// change, the client approves it, a site manager completes it.
#[serde(default)]
#[serde(deserialize_with = "one_or_many_by_key")]
pub moves_by: BTreeMap<String, Vec<String>>,
/// Groups that see these cases on their desk and move nothing.
#[serde(default)]
pub watched_by: Vec<String>,
/// The event name a stage is entered by, where it must not be the
/// builder's default (the stage's own name; `received` for the
/// first). A live site replays its records by event name, so a
/// rebuild of a site that already holds records has to keep the
/// names its aggregates.yaml has, or every record vanishes from
/// its desk.
#[serde(default)]
pub events: BTreeMap<String, String>,
2026-09-24 05:17:18 +02:00
/// Each stage in plain words, in the plan's language, where the
/// id will not do: ids are ASCII, so a Norwegian `ikke_lost` reads
/// as English in a mail, a road line or a desk score. Used by the
/// fallback mails, the "what happens next" line, the desk
/// buttons, and written into needs.yaml for the scorer.
#[serde(default)]
pub stages_text: BTreeMap<String, String>,
/// Who moves cases that no desk button moves - an automation
/// listening on the bucket's events. Written into aggregates.yaml
/// for the reader, as portal's `attended_by` annotation.
#[serde(default)]
pub attended_by: Option<String>,
/// Who hears when a case enters a stage: `submitter` (the person
/// who sent the form, at the email it collected), `owner` (whoever
/// sent the record this need is `about`) or one address (the
/// treasurer, the board). Portal sends it the moment the case
/// moves; the words model writes the subject and body.
#[serde(default)]
pub mails: BTreeMap<String, String>,
/// A stage that makes the person a member of a group when their
/// case arrives there (portal's `grants:`): an approval that means
/// membership, with no second step to forget. The form must then
/// collect a required `name` and `email`, since by the time a desk
/// presses approve there is nobody left to ask.
#[serde(default)]
pub grants: BTreeMap<String, String>,
/// A stage a clock moves on from (portal's `after:`): a case left
/// in it that many days, measured from its last move, goes on to
/// `to`. Only along a move the state machine already has; a clock
/// may do only what a person could have done.
#[serde(default)]
pub deadlines: BTreeMap<String, Deadline>,
/// A stage a case may not sit in unattended: after that many days
/// it goes up to another group. `{ open: { days: 7, to: styret } }`
/// adds a stage `open_escalated`, entered by the clock, with the
/// same moves out of it as `open` has, and a card on styret's desk
/// showing exactly those cases with those buttons. The desk that
/// had it keeps it too, and may press the move itself. A stage can
/// escalate or end on a deadline, not both.
#[serde(default)]
pub escalate: BTreeMap<String, Escalation>,
/// With `watched_by: [public]`, the stages the public list shows;
/// empty shows all. A noticeboard shows what is up, not what was
/// taken down; a register shows what was decided.
#[serde(default)]
pub public_states: Vec<String>,
pub collects: Vec<Field>,
/// How many required fields this person will tolerate: what the
/// business said about them, not what the form happens to ask.
/// The form is refused when it asks for more, and the verifier is
/// told this number, so the check means something after handover.
pub max_fields: usize,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Field {
pub name: String,
#[serde(default = "text_kind", rename = "type")]
pub kind: String,
#[serde(default)]
pub optional: bool,
#[serde(default)]
pub options: Vec<String>,
/// `select` only: pick any number instead of exactly one.
#[serde(default)]
pub multiple: bool,
/// `select` only: `groups` takes the options from the plan's
/// labelled groups instead of a list here.
#[serde(default)]
pub options_from: Option<String>,
/// Never shown on a public view of the bucket. `email` and `tel`
/// fields are private whether or not this is set.
#[serde(default)]
pub private: bool,
}
impl Field {
/// What a public listing may show: everything but contact details
/// and what the plan marks private.
pub fn is_public(&self) -> bool {
!self.private && self.kind != "email" && self.kind != "tel"
}
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Deadline {
pub days: u32,
pub to: String,
}
/// After `days` in a stage, up to group `to` (see `PlanNeed.escalate`).
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Escalation {
pub days: u32,
pub to: String,
}
/// The stage a case escalates into from `stage`.
pub fn escalated_stage(stage: &str) -> String {
format!("{stage}_escalated")
}
/// 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_kind() -> String {
"text".to_string()
}
fn kanidm() -> String {
"kanidm".to_string()
}
impl Plan {
fn memberships(&self) -> bool {
self.people == "memberships"
}
}
2026-09-24 21:35:45 +02:00
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location"];
fn is_id(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
}
impl Plan {
pub fn validate(&self) -> anyhow::Result<()> {
if !["kanidm", "memberships"].contains(&self.people.as_str()) {
bail!("people must be `kanidm` or `memberships`, not {:?}", self.people);
}
if self.tones.iter().any(|t| t.trim().is_empty()) {
bail!("tones: every tone says something about the voice; an empty one says nothing");
}
// A contact that is a group has to be one with a desk, or
// nobody could sign in as it.
if let Some(group) = &self.contact.group {
if !groups(self).contains(group) {
bail!("contact: group {group:?} has no desk: it must handle, watch or move something");
}
}
for doc in &self.documents {
if !is_id(&doc.id) || ["status", "received", "decide"].contains(&doc.id.as_str()) || groups(self).contains(&doc.id) || self.doors.iter().any(|d| d.id == doc.id) {
bail!("document {:?}: id must be ASCII lowercase with - or _, and a path no door, group or builder page has", doc.id);
}
if doc.question.trim().is_empty() {
bail!("document {:?}: the page needs the question its text answers", doc.id);
}
if doc.sections.is_empty() {
bail!("document {:?} has no sections: give it a `file` with headings, or `sections`", doc.id);
}
}
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")));
}
// With enters_as as well, the door only points the way:
// the form sits on the group's page, behind sign-in.
}
if let Some(about) = &need.about {
let Some(parent) = self.needs.iter().find(|n| &n.id == about) else {
bail!(at(&format!("about {about:?} is not one of the plan's needs")));
};
if parent.about.is_some() {
bail!(at("about names a need that itself answers another; one level is what a board is"));
}
if !parent.watched_by.iter().any(|g| g == PUBLIC) {
bail!(at(&format!("about {about:?}: its records are answered from the public list, so it needs watched_by: [public]")));
}
if need.door.is_some() || need.enters_as.is_some() {
bail!(at("a need that answers another is reached from that one's list, not a door or a group's page"));
}
if need.mails.values().any(|to| to == "owner") && !parent.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) {
bail!(at(&format!("mails owner, but {about:?} does not collect a required email to reach them at")));
}
} else if need.mails.values().any(|to| to == "owner") {
bail!(at("mails owner, but answers no need: owner is whoever sent the record this need is about"));
}
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`"));
}
// A public view is of a bucket, not of a need. A role's own
// need may be watched by the public when the public files
// into the same bucket: a permit register holds the owner's
// small application and the firm's large one, and is one
// page either way. A bucket only a role ever writes to has
// its group's desk and nothing to publish.
if need.watched_by.iter().any(|g| g == PUBLIC)
&& need.enters_as.is_some()
&& !self
.needs
.iter()
.any(|n| n.lands_in == need.lands_in && n.enters_as.is_none())
{
bail!(at("a public view is for what the public sent; an internal need whose bucket the public never files into has its group's desk"));
}
for (target, froms) in &need.reached_from {
if !need.stages.contains(target) || target == &need.stages[0] {
bail!(at(&format!("reached_from names {target:?}, which is not one of its stages after the first")));
}
if froms.is_empty() {
bail!(at(&format!("reached_from {target:?}: a stage reached from nowhere can never be entered")));
}
for from in froms {
if !need.stages.contains(from) || need.done_when.contains(from) {
bail!(at(&format!("reached_from {target:?} names {from:?}, which is not one of its unfinished stages")));
}
if from == target {
bail!(at(&format!("reached_from {target:?} names itself")));
}
}
}
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 == "owner" {
// Checked with `about` below: owner is whoever sent
// the record this need answers.
} else if !to.contains('@') || to.contains(char::is_whitespace) {
bail!(at(&format!("mails {state:?} must go to `submitter`, `owner` 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, group) in &need.grants {
if self.mail.is_none() {
bail!(at("grants a group, so the plan needs `mail: { from: ... }`: the new member's sign-in link is sent by mail, and without a sender it reaches nobody"));
}
if !need.stages[1..].contains(state) {
bail!(at(&format!("grants names {state:?}, which is not a stage a case can move into")));
}
if !is_id(group) || group == PUBLIC || group == SUBMITTER {
bail!(at(&format!("grants {state:?} must name a group, not {group:?}")));
}
let required = |name: &str, kind: &str| need.collects.iter().any(|f| f.name == name && f.kind == kind && !f.optional);
if self.memberships() {
if !required("name", "text") || !(required("email", "email") || required("phone", "tel")) {
bail!(at("a stage that grants a group makes the person a member, so the form must collect a required `name` (text) and a required `phone` (type tel) or `email` (type email)"));
}
} else if !required("name", "text") || !required("email", "email") {
bail!(at("a stage that grants a group makes an account for the person, so the form must collect a required `name` (text) and a required `email` (type email)"));
}
}
for (stage, e) in &need.escalate {
if e.days == 0 {
bail!(at(&format!("escalate from {stage:?} must wait at least one day")));
}
if !need.stages.contains(stage) || need.done_when.contains(stage) {
bail!(at(&format!("escalate names {stage:?}, which is not a stage a case waits in")));
}
if !is_id(&e.to) || e.to == need.handled_by {
bail!(at(&format!("escalate from {stage:?} must go up to another group, not {:?}", e.to)));
}
if need.deadlines.contains_key(stage) {
bail!(at(&format!("{stage:?} has both a deadline and an escalation; a stage ends on the clock or goes up on it, not both")));
}
}
for (state, d) in &need.deadlines {
if d.days == 0 {
bail!(at(&format!("deadline on {state:?} must wait at least one day")));
}
if !machine(need).get(state).is_some_and(|tos| tos.contains(&d.to)) {
bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to)));
}
}
if !need.public_states.is_empty() {
if !need.watched_by.iter().any(|g| g == PUBLIC) {
bail!(at("public_states says what the public list shows, and the need has no `public` in watched_by"));
}
if let Some(bad) = need.public_states.iter().find(|s| !need.stages.contains(s)) {
bail!(at(&format!("public_states names {bad:?}, which is not one of its stages")));
}
}
2026-09-24 05:17:18 +02:00
for (state, text) in &need.stages_text {
if !all_stages(need).contains(state) {
2026-09-24 05:17:18 +02:00
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.grants != first.grants
|| other.deadlines != first.deadlines
|| other.public_states != first.public_states
2026-09-24 05:17:18 +02:00
|| other.stages_text != first.stages_text
|| other.turned_down_from != first.turned_down_from
|| other.reached_from != first.reached_from
{
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by, mails, grants, deadlines, public_states and who moves and watches it", first.id, other.id);
}
}
}
Ok(())
}
/// The house and its players: a role the opener invites in has to
/// see its win, and the machine has to give it something to do
/// after it sits down - a move of its own (`moves_by: submitter`)
/// or at least a view of the table (`watched_by: [public]`). A
/// featured role with neither is a spectator, and the build says
/// so; it does not refuse, since the plan may mean it.
pub fn spectators(&self) -> Vec<String> {
self.does
.iter()
.filter_map(|d| d.for_need.as_ref().and_then(|id| self.needs.iter().find(|n| &n.id == id)))
.filter(|n| !n.moves_by.values().any(|m| m.iter().any(|w| w == SUBMITTER)) && !n.watched_by.iter().any(|g| g == PUBLIC))
.map(|n| {
format!(
"the opener invites {} in, and after sending they play nothing: give need {:?} a move of theirs (moves_by: {{<stage>: [submitter, ...]}}) or a public view (watched_by: [public])",
n.who, n.id
)
})
.collect()
}
/// What sits on the front door: each door, and each public need
/// with no door.
fn front_door_count(&self) -> usize {
self.doors.len() + self.needs.iter().filter(|n| n.on_front_door()).count()
}
/// The need this one answers records of, if any.
fn parent_of<'a>(&'a self, need: &PlanNeed) -> Option<&'a PlanNeed> {
let id = need.about.as_deref()?;
self.needs.iter().find(|n| n.id == id)
}
/// The needs that answer records of `need`, in plan order.
fn answers_to<'a>(&'a self, need: &PlanNeed) -> Vec<&'a PlanNeed> {
self.needs.iter().filter(|n| n.about.as_deref() == Some(need.id.as_str())).collect()
}
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.
///
/// That line is what most work looks like, and it is not what a
/// regulated case looks like. `reached_from` says where a named stage
/// is entered from instead: back to an earlier one when a case is sent
/// for the papers it was missing, or into a good ending partway along
/// when an authority changes its own mind rather than passing the case
/// on. A stage named there is entered from exactly those stages and
/// nowhere else; every stage not named keeps the line's own answer.
pub fn machine(need: &PlanNeed) -> BTreeMap<String, Vec<String>> {
let line: Vec<&String> = need.stages.iter().filter(|s| !need.done_when.contains(s)).collect();
let ends_well: Vec<&String> = if need.success.is_empty() { need.done_when.iter().collect() } else { need.success.iter().collect() };
let ends_badly: Vec<&String> = need.done_when.iter().filter(|s| !ends_well.contains(s)).collect();
let mut moves = BTreeMap::new();
for (i, stage) in line.iter().enumerate() {
let mut to: Vec<String> = Vec::new();
let mut j = i + 1;
loop {
match line.get(j) {
Some(next) => {
to.push((*next).clone());
if !need.skippable.contains(next) {
break;
}
j += 1;
}
None => {
to.extend(ends_well.iter().map(|s| (*s).clone()));
break;
}
}
}
if need.turned_down_from.is_empty() || need.turned_down_from.contains(stage) {
to.extend(ends_badly.iter().map(|s| (*s).clone()));
}
moves.insert((*stage).clone(), to);
}
// A stage the plan places by hand is entered from exactly the
// stages it names: take it out of the line's answer everywhere
// first, so that saying where a refusal may come from also says
// where it may not.
for (target, froms) in &need.reached_from {
for tos in moves.values_mut() {
tos.retain(|to| to != target);
}
for from in froms {
if let Some(tos) = moves.get_mut(from) {
if !tos.contains(target) {
tos.push(target.clone());
}
}
}
}
// An escalated stage is the stage it came from, seen by one more
// group: the same moves out of it, and one move in, the clock's.
for stage in need.escalate.keys() {
let up = escalated_stage(stage);
let Some(tos) = moves.get(stage).cloned() else { continue };
moves.entry(stage.clone()).or_default().push(up.clone());
moves.insert(up, tos);
}
moves
}
/// Every stage a case can be in: the plan's, and the escalated ones.
pub fn all_stages(need: &PlanNeed) -> Vec<String> {
let mut out = Vec::new();
for stage in &need.stages {
out.push(stage.clone());
if need.escalate.contains_key(stage) {
out.push(escalated_stage(stage));
}
}
out
}
/// The group a stage escalated to, if it is an escalated stage.
fn escalated_to<'a>(need: &'a PlanNeed, stage: &str) -> Option<&'a str> {
need.escalate.iter().find(|(from, _)| escalated_stage(from) == stage).map(|(_, e)| e.to.as_str())
}
/// The event that enters a stage: what the plan says, else the stage's
/// own name, with the first stage entered by `received` (or `submitted`
/// when a stage is itself called received).
pub fn event_for(need: &PlanNeed, stage: &str) -> String {
if let Some(event) = need.events.get(stage) {
return event.clone();
}
if stage == need.stages[0] {
return if need.stages.iter().any(|st| st == "received") { "submitted" } else { "received" }.to_string();
}
stage.to_string()
}
/// The stages a case goes through, as one sentence: "It is received,
/// then surveyed, then quoted, and ends won or lost."
2026-09-24 05:17:18 +02:00
/// A stage in words: what the plan says, else its id made readable.
pub fn stage_text(need: &PlanNeed, stage: &str) -> String {
if let Some(text) = need.stages_text.get(stage) {
return text.clone();
}
match escalated_to(need, stage) {
Some(group) => format!("escalated to {}", humanize(group).to_lowercase()),
None => humanize(stage).to_lowercase(),
}
2026-09-24 05:17:18 +02:00
}
fn stages_sentence(need: &PlanNeed) -> String {
2026-09-24 05:17:18 +02:00
let line: Vec<String> = need.stages.iter().filter(|s| !need.done_when.contains(s)).map(|s| stage_text(need, s)).collect();
let ends: Vec<String> = need.done_when.iter().map(|s| stage_text(need, s)).collect();
let mut out = String::from("It is ");
out.push_str(&line.join(", then "));
if ends.len() == 1 {
out.push_str(&format!(", and ends {}.", ends[0]));
} else {
out.push_str(&format!(", and ends {} or {}.", ends[..ends.len() - 1].join(", "), ends[ends.len() - 1]));
}
out
}
2026-09-24 05:17:18 +02:00
fn capitalize(text: &str) -> String {
let mut chars = text.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn humanize(id: &str) -> String {
let mut words = id.replace(['_', '-'], " ");
if let Some(first) = words.get_mut(0..1) {
first.make_ascii_uppercase();
}
words
}
impl PlanNeed {
/// A public need that is not behind a door and answers nothing:
/// its form is on the front door itself.
fn on_front_door(&self) -> bool {
self.enters_as.is_none() && self.door.is_none() && self.about.is_none()
}
}
/// The page where one record of `bucket` is answered: under the
/// public list, so the way down the tree leads back to the board.
fn answer_page(bucket: &str) -> String {
format!("/status/{bucket}/[key]")
}
fn answer_page_path(bucket: &str) -> String {
format!("questions/status/{bucket}/[key].yaml")
}
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.
/// A generic line in the plan's language: English, or Norwegian for
/// `no`/`nb`/`nn`. For the few fallbacks no model is asked about
/// before a page ships (list notes, empty lists), so a Norwegian site
/// does not show English where its own words have not reached yet.
fn loc(lang: &str, en: &str, no: &str) -> String {
if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") { no } else { en }.to_string()
}
pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
let lang = plan.language.as_str();
// Every part of the card portal renders has a slot: the model
// gets to write the button, an encouragement, the section's icon
// and guidance line, and a placeholder per field, not only the
// heading and description. Empty fallbacks render as absent.
let form = |need: &PlanNeed| {
json!({
"heading": need.wants,
"description": format!("For {}.", need.who),
"button": "Send",
"encouragement": "",
"icon": "",
// Three sections of context the plan already knows: what
// the person must have for this to work, the road a case
// travels, and who is at the other end. The fallbacks are
// built from the plan, so they are true before a model has
// said a word.
"requires_title": if need.requires.trim().is_empty() { String::new() } else { "What you need".to_string() },
"requires_description": need.requires.trim(),
"requires_icon": if need.requires.trim().is_empty() { "" } else { "key" },
"road_title": "What happens next",
"road_description": stages_sentence(need),
"road_icon": "route",
"who_title": "Who reads it",
"who_description": format!("{}. Questions go to {} at {}.", humanize(&need.handled_by), plan.contact.name, plan.contact.email),
"who_icon": "users",
"form_title": "Details",
"form_description": "",
"labels": need.collects.iter().map(|f| (f.name.clone(), Value::String(humanize(&f.name)))).collect::<Map<_, _>>(),
"hints": need.collects.iter().map(|f| (f.name.clone(), Value::String(String::new()))).collect::<Map<_, _>>(),
})
};
let mut words = BTreeMap::new();
let direct: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.on_front_door()).collect();
// The opener: what the business does, asking for nothing. From
// the plan's `does`, or one feature per public need.
let does: Map<String, Value> = if plan.does.is_empty() {
// One or two roles, the first the plan names: the visitor the
// business exists for, and the next one it serves.
plan.needs
.iter()
.filter(|n| n.enters_as.is_none())
.take(MAX_OPENER)
.map(|n| (n.id.clone(), json!({ "heading": humanize(&n.wants), "description": format!("For {}.", n.who), "icon": "" })))
.collect()
} else {
// A feature for a role is headed by that role's win: what
// the need says they want.
plan.does
.iter()
.map(|d| {
let heading = d
.for_need
.as_ref()
.and_then(|id| plan.needs.iter().find(|n| &n.id == id))
.map(|n| humanize(&n.wants))
.unwrap_or_else(|| humanize(&d.id));
(d.id.clone(), json!({ "heading": heading, "description": d.text, "icon": "" }))
})
.collect()
};
let mut shopping: Map<String, Value> =
plan.shows.iter().map(|s| (s.id.clone(), json!({ "heading": humanize(&s.id), "description": s.text, "icon": "" }))).collect();
for (bucket, needs) in public_buckets(plan) {
shopping.insert(
format!("status-{bucket}"),
json!({
"heading": format!("{} {}", loc(lang, "Latest", "Siste"), humanize(bucket).to_lowercase()),
"description": public_blurb(&needs),
"link": format!("{} {}", loc(lang, "All", "Alle"), humanize(bucket).to_lowercase()),
"empty": loc(lang, "Nothing here yet.", "Ingenting her ennå."),
"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 },
// Named for what it shows, never an invitation to look.
"shopping": { "heading": format!("{} {}", loc(lang, "Latest from", "Siste fra"), plan.business), "description": "", "items": shopping },
// A door's feature says what is behind it, from the needs
// that sit there: a card that leads somewhere has to say
// where, or it is a button with decoration around it.
"doors": plan.doors.iter().map(|d| (d.id.clone(), json!({
"heading": humanize(&d.id), "description": format!("For {}.", d.who), "button": "Continue",
"icon": "", "for_whom": "",
"behind": format!("{}.", capitalize(&plan.needs_behind(&d.id).iter().map(|n| n.wants.as_str()).collect::<Vec<_>>().join(", "))),
}))).collect::<Map<_, _>>(),
"needs": direct.iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
}),
);
for door in &plan.doors {
words.insert(
door_path(door),
json!({
"question": "What do you need?",
"description": format!("For {}.", door.who),
"needs": plan.needs_behind(&door.id).iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
}),
);
}
// A page per record of a public need, where other needs answer it
// - an objection and an appeal both belong to the application. The
// record's own words come from the record, so the page only has to
// ask and hold the forms, one per answering need.
for parent in &plan.needs {
let answers = plan.answers_to(parent);
let Some(first) = answers.first() else { continue };
words.insert(
answer_page_path(&parent.lands_in),
json!({
"question": format!("{}?", capitalize(&first.wants)),
"description": format!("For {}.", answers.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(", or ")),
"forms": answers.iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
}),
);
}
for need in &plan.needs {
let path = match &need.enters_as {
None => format!("questions/received/{}.yaml", need.id),
Some(group) => format!("questions/{group}/{}-sent.yaml", need.id),
};
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),
});
if need.watched_by.iter().any(|g| g == PUBLIC) {
page["see_all"] = json!({ "heading": format!("See all {}", humanize(&need.lands_in).to_lowercase()), "description": "", "button": "Open" });
}
words.insert(path, page);
if let Some(group) = &need.enters_as {
let mut page = form(need);
page["question"] = json!(format!("{}?", humanize(&need.id)));
page["page_description"] = json!("");
words.insert(format!("questions/{group}/{}.yaml", need.id), page);
}
}
for group in groups(plan) {
let buckets: Map<String, Value> = plan
.by_bucket()
.into_iter()
.filter(|(_, needs)| on_desk(needs[0], &group))
.map(|(bucket, needs)| {
// Only the buttons this group presses; a watcher gets none.
let moves: Map<String, Value> = moves_of(needs[0], &group)
.into_iter()
2026-09-24 05:17:18 +02:00
.map(|(from, to)| (format!("{from}->{to}"), Value::String(capitalize(&stage_text(needs[0], &to)))))
.collect();
// The list itself says what a row is and what the
// buttons under it do; the card's own description says
// what the cases are.
let note = if moves.is_empty() {
loc(lang, "Each row is one case and the stage it has reached. Another desk moves these.", "Hver rad er én sak og hvor langt den har kommet. En annen pult flytter disse.")
} else {
loc(lang, "Each row is one case. The buttons record what you have just done with it.", "Hver rad er én sak. Knappene fører opp det du nettopp har gjort med den.")
};
let from_whom = format!("Sent in by {}.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or "));
(bucket.to_string(), json!({ "heading": humanize(bucket), "description": from_whom, "moves": moves, "list_title": humanize(bucket), "list_note": note }))
})
.collect();
let links: Map<String, Value> = plan
.needs
.iter()
.filter(|n| n.enters_as.as_deref() == Some(group.as_str()))
.map(|n| (n.id.clone(), json!({ "heading": n.wants, "description": format!("For {}.", n.who), "button": "Open" })))
.collect();
// One invite form per group this desk may fill, and the list of
// invites sent, when it may fill any.
let invites: Map<String, Value> = groups(plan)
.iter()
.filter(|g| inviters(plan, g).contains(&group))
.map(|g| {
(g.clone(), json!({
"heading": format!("Invite someone into {}", label_of(plan, g)),
"description": format!("They get an account, a place in {}, and a one-time link to set their own password or passkey.", label_of(plan, g)),
"button": "Invite",
"form_title": "Who",
"form_description": if plan.memberships() {
loc(lang, "Their name and phone number; an address too, if they have one. Their place is there when they sign in.", "Navnet og telefonnummeret deres, og e-post om de har. Plassen er der når de logger inn.")
} else {
loc(lang, "Their name, and the address the account and its one-time link go to.", "Navnet deres, og adressen kontoen og engangslenken går til.")
},
"labels": { "name": "Name", "phone": loc(lang, "Phone", "Telefon"), "email": "Email" },
}))
})
.collect();
let invited = if invites.is_empty() {
Value::Null
} else if plan.memberships() {
json!({
"heading": loc(lang, "Who is in", "Hvem som er med"),
"description": loc(lang, "Everyone invited into a group this desk may fill, and whether they have come.", "Alle som er invitert inn i en gruppe denne pulten kan fylle, og om de har kommet."),
"done": loc(lang, "Take out", "Ta ut"),
"withdraw": loc(lang, "Withdraw", "Trekk tilbake"),
"again": loc(lang, "Invite again", "Inviter igjen"),
"list_title": loc(lang, "Members and invitations", "Medlemmer og invitasjoner"),
"list_note": loc(lang, "Each row is one person in one group: invited until they sign in, then in. Taking someone out keeps the row, so they can be invited again.", "Hver rad er én person i én gruppe: invitert til de logger inn, så med. Å ta noen ut beholder raden, så de kan inviteres igjen."),
})
} else {
json!({
"heading": "Invitations sent",
"description": loc(lang, "Each invite and where it stands. A link that runs out unused can be sent again.", "Hver invitasjon og hvor den står. En lenke som går ut ubrukt, kan sendes på nytt."),
"done": "Is in",
// Portal moves an invite whose link ran out to
// `expired`; from there the desk may send it again.
"again": loc(lang, "Send again", "Send på nytt"),
"list_title": loc(lang, "Who has been invited", "Hvem som er invitert"),
"list_note": loc(lang, "Each row is one invitation. It is done by itself when the person signs in, and expired when the link ran out unused.", "Hver rad er én invitasjon. Den blir ferdig av seg selv når personen logger inn, og står som utløpt når lenken gikk ut ubrukt."),
})
};
// What the people named as responsible for a page suggest
// changing: portal files each suggestion as a case, and the
// desk that brings people in is the one that settles them.
let suggestions = if suggestions_desk(plan).as_deref() == Some(group.as_str()) {
json!({
"heading": loc(lang, "Suggested changes to the site", "Forslag til endringer på siden"),
"description": loc(lang, "What the people listed as responsible for a question have suggested changing. They are told each time a suggestion moves.", "Det de som står som ansvarlige for et spørsmål, har foreslått å endre. De får beskjed når forslaget flyttes."),
"list_title": loc(lang, "Suggestions", "Forslag"),
"list_note": loc(lang, "Each row is one suggestion, with the page it is about. The buttons record what you have just done with it.", "Hver rad er ett forslag, med siden det gjelder. Knappene fører opp det du nettopp har gjort med det."),
"looking": loc(lang, "We are looking at it", "Vi ser på det"),
"changed": loc(lang, "The change is made", "Endringen er gjort"),
"declined": loc(lang, "Will not be done", "Gjøres ikke"),
})
} else {
Value::Null
};
words.insert(
format!("questions/{group}/index.yaml"),
json!({ "question": "What needs doing?", "description": format!("Everything {} looks after.", label_of(plan, &group).to_lowercase()), "confirm": "Confirm", "buckets": buckets, "links": links, "invites": invites, "invited": invited, "suggestions": suggestions }),
);
}
// 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": public_blurb(&needs),
"heading": humanize(bucket),
"list_description": "",
// A public list says what a row is and what it leaves
// out, since the reader cannot know what was pruned.
"list_title": humanize(bucket),
"list_note": loc(lang, "Each row is one case and the stage it has reached. Names and contact details are not shown here.", "Hver rad er én sak og hvor langt den har kommet. Navn og kontaktopplysninger vises ikke her."),
"empty": loc(lang, "Nothing here yet.", "Ingenting her ennå."),
// The link on every row to the page where another
// need answers it, when one does: what the reader
// presses, so it says what they get to do.
"answer": needs.iter().flat_map(|n| plan.answers_to(n)).next().map(|a| capitalize(&a.wants)).unwrap_or_default(),
}),
);
}
// The invite mail, when people are brought in through the site
// and it sends mail: the first thing a new member reads from it.
if plan.mail.is_some() && has_onboarding(plan) {
words.insert("site.yaml".to_string(), json!({ "invite": invite_fallback(&plan.language) }));
}
// The mails: one subject and body per stage that sends one, keyed
// by bucket. Templates in portal's terms: {field} for a field of
// the record, {site}, {chain}, {email}. The fallback says what
// happened and, where the submitter has a move from that stage,
// where to make it.
let mut mails: Map<String, Value> = Map::new();
for (bucket, needs) in plan.by_bucket() {
let need = needs[0];
if need.mails.is_empty() {
continue;
}
// The fallback is true in the plan's language before a model
// has written a word; the phrases live in one table below.
let t = Phrases::for_language(&plan.language);
let greeting = match need.collects.iter().find(|f| f.name == "name") {
Some(f) => format!("{} {{{}}},\n\n", t.hello, f.name),
None => format!("{},\n\n", t.hello),
};
let mut per_stage: Map<String, Value> = Map::new();
for (stage, to) in &need.mails {
2026-09-24 05:17:18 +02:00
let stage_word = stage_text(need, stage);
let what = if stage == &need.stages[0] {
format!("{} {}.", t.received_what_you_sent_about, need.wants)
} else {
format!("{} {} {} {stage_word}.", t.what_you_sent_about, need.wants, t.is_now)
};
let mut body = if to == SUBMITTER {
format!("{greeting}{what}")
} else {
format!("{} {} {} {stage_word}: {{chain}}.", t.a_case_in, humanize(bucket).to_lowercase(), t.is_now)
};
if to == SUBMITTER && moves_of(need, SUBMITTER).iter().any(|(from, _)| from == stage) {
body.push_str(&format!("\n\n{}: {{site}}/decide/{}?chain={{chain}}&email={{email}}", t.what_you_can_do_next, need.id));
}
body.push_str(&format!("\n\n{} {} {}", plan.contact.name, t.at, plan.contact.email));
per_stage.insert(stage.clone(), json!({ "subject": format!("{}: {}", plan.business, humanize(stage)), "body": body }));
}
mails.insert(bucket.to_string(), Value::Object(per_stage));
}
if !mails.is_empty() {
words.insert("aggregates.yaml".to_string(), Value::Object(mails));
}
// The submitter's own page: reached by their private link, it
// offers the moves that are theirs to make.
for need in &plan.needs {
let theirs = moves_of(need, SUBMITTER);
if theirs.is_empty() {
continue;
}
let targets: BTreeSet<&String> = theirs.iter().map(|(_, to)| to).collect();
words.insert(
decide_path(need),
json!({
"question": "What would you like to do?",
"description": "",
"moves": targets.iter().map(|to| ((*to).clone(), json!({
"heading": humanize(to), "description": "", "button": humanize(to),
}))).collect::<Map<_, _>>(),
}),
);
}
words
}
/// Whether anyone is brought in through the site: invites from a desk,
/// or a stage that grants a group. Both send portal's invite mail.
fn has_onboarding(plan: &Plan) -> bool {
!plan.invites_by.is_empty() || plan.groups.iter().any(|g| !g.invited_by.is_empty()) || plan.needs.iter().any(|n| !n.grants.is_empty())
}
/// The invite mail's fallback words, in the plan's language: the
/// person, the group, the one-time link and when it runs out. Portal
/// fills `{name}`, `{username}`, `{email}`, `{group}`, `{link}`,
/// `{site}`, `{site_name}` and `{expires}`.
fn invite_fallback(lang: &str) -> Value {
if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") {
json!({
"subject": "Din konto hos {site_name}",
"body": "Hei {name},\n\n{site_name} har laget en konto til deg og lagt deg til i {group}. Brukernavnet ditt er {username}.\n\nVelg passord her før {expires}:\n{link}\n\nEtterpå logger du inn på {site}.",
})
} else {
json!({
"subject": "Your account at {site_name}",
"body": "Hello {name},\n\n{site_name} has made you an account and added you to {group}. Your username is {username}.\n\nSet your password here before {expires}:\n{link}\n\nAfter that you sign in at {site}.",
})
}
}
/// The invite mail without its link cannot be used: a model's reword
/// that dropped `{link}` gets the fallback's link line back.
fn keep_invite_link(lang: &str, merged: &mut Value) {
let Some(body) = merged.pointer_mut("/invite/body") else { return };
let Some(text) = body.as_str() else { return };
if text.contains("{link}") {
return;
}
let fallback = invite_fallback(lang);
let link_line = s(&fallback["body"]).lines().filter(|l| l.contains("{link}") || l.contains("{expires}")).collect::<Vec<_>>().join("\n");
*body = Value::String(format!("{}\n\n{link_line}", text.trim_end()));
}
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(|(from, to)| movers(need, to).contains(&who) || escalated_to(need, from) == Some(who))
.collect()
}
/// The stages of a bucket a group sees only because they escalated to
/// it: its card lists those alone. Empty for a group that has the
/// bucket for its own reasons, whose card lists everything.
fn escalated_states_for(need: &PlanNeed, group: &str) -> Vec<String> {
let own = need.handled_by == group
|| need.watched_by.iter().any(|g| g == group)
|| need.moves_by.values().flatten().any(|m| m == group);
if own {
return Vec::new();
}
need.escalate.iter().filter(|(_, e)| e.to == group).map(|(stage, _)| escalated_stage(stage)).collect()
}
/// A group's desk shows a bucket when it moves something in it, is
/// answerable for it, or watches it.
fn on_desk(need: &PlanNeed, group: &str) -> bool {
group != PUBLIC && (need.handled_by == group || need.watched_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty())
}
fn groups(plan: &Plan) -> BTreeSet<String> {
plan.needs
.iter()
.flat_map(|n| {
[Some(n.handled_by.clone()), n.enters_as.clone()]
.into_iter()
.flatten()
.chain(n.watched_by.iter().filter(|g| *g != PUBLIC).cloned())
.chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned())
.chain(n.escalate.values().map(|e| e.to.clone()))
})
.collect()
}
/// The groups whose desks carry an invite form into `group`: itself,
/// the plan's `invites_by`, and its own `invited_by`, each of which
/// must have a desk to carry it on.
fn inviters(plan: &Plan, group: &str) -> BTreeSet<String> {
let desks = groups(plan);
// A group a case grants is entered by that case, not by a member
// waving somebody in: members do not make members.
let granted = plan.needs.iter().any(|n| n.grants.values().any(|g| g == group));
(if granted { None } else { Some(group.to_string()) })
.into_iter()
.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()
}
/// The desk that settles suggested changes: the one that may invite
/// into the most groups, which is the group portal seeds the first
/// person into. The first by name on a tie; none on a site with no
/// desk.
fn suggestions_desk(plan: &Plan) -> Option<String> {
let desks = groups(plan);
desks
.iter()
.map(|desk| (desks.iter().filter(|g| inviters(plan, g).contains(desk)).count(), desk))
.filter(|(reach, _)| *reach > 0)
.max_by(|(a, x), (b, y)| a.cmp(b).then_with(|| y.cmp(x)))
.map(|(_, desk)| desk.clone())
}
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()
}
/// The stages a public list is limited to, in words: "approved", or
/// "approved or refused". Empty when it shows every stage.
fn shown_stages(need: &PlanNeed) -> String {
let words: Vec<String> = need.public_states.iter().map(|st| stage_text(need, st)).collect();
match words.len() {
0 => String::new(),
1 => words[0].clone(),
n => format!("{} or {}", words[..n - 1].join(", "), words[n - 1]),
}
}
/// How many of a public list's newest records the front door shows.
const FRONT_DOOR_LATEST: usize = 3;
/// The read-only resource over a public bucket: newest first, only the
/// stages the plan opens, contact details and private fields pruned,
/// and at most `limit` records when given. Portal filters by `states`
/// while listing, before `jq` runs, so the limit counts shown records.
fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option<usize>, answer: &str) -> Value {
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()));
}
let take = limit.map(|n| format!(" | .[:{n}]")).unwrap_or_default();
// 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 jq = format!(
"sort_by(.submitted_ms) | reverse{take} | .[] | .responses |= {{{}}}",
shown.iter().map(|f| format!("{f}: .{f}")).collect::<Vec<_>>().join(", ")
);
let mut resource = json!({
"source": { "kind": "kv", "bucket": bucket },
"public": true,
"states": needs[0].public_states,
"empty": empty,
"jq": jq,
});
// Every row links to the page where a record is answered, when a
// need answers this one's records.
if !answer.trim().is_empty() {
resource["link"] = json!({ "to": answer_page(bucket).replace("[key]", "{id}"), "label": answer.trim() });
}
resource
}
/// What a public list holds, in one sentence, honest about its filter.
fn public_blurb(needs: &[&PlanNeed]) -> String {
match shown_stages(needs[0]).as_str() {
"" => format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or ")),
only => format!("What {} sent in that is now {only}.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or ")),
}
}
fn public_view_path(bucket: &str) -> String {
format!("questions/status/{bucket}/index.yaml")
}
/// The options of a select: its own list, or the plan's labelled groups.
fn select_options(plan: &Plan, field: &Field) -> Vec<String> {
match field.options_from.as_deref() {
Some("groups") => plan.groups.iter().map(|g| label_of(plan, &g.id)).collect(),
_ => field.options.clone(),
}
}
/// Takes the model's strings where it gave them, key by key, and keeps
/// the fallback everywhere else. The shape always comes from the
/// fallback, so a reply can never add, drop or restructure anything.
pub fn merge_words(fallback: &Value, given: &Value) -> Value {
match (fallback, given) {
(Value::Object(f), Value::Object(g)) => {
Value::Object(f.iter().map(|(k, v)| (k.clone(), g.get(k).map_or_else(|| v.clone(), |gv| merge_words(v, gv)))).collect())
}
// An unfilled slot echoed back is no answer.
(Value::String(_), Value::String(s)) if !s.trim().is_empty() && !s.contains(SLOT) => {
Value::String(s.trim().to_string())
}
// One string per storyline, every one of them said.
(Value::String(_) | Value::Array(_), Value::Array(items)) if !items.is_empty() && items.iter().all(|i| i.as_str().is_some_and(|x| !x.trim().is_empty() && !x.contains(SLOT))) => {
Value::Array(items.iter().map(|i| json!(i.as_str().unwrap_or_default().trim())).collect())
}
(Value::Array(_), Value::String(s)) if !s.trim().is_empty() && !s.contains(SLOT) => {
Value::String(s.trim().to_string())
}
_ => fallback.clone(),
}
}
/// One page's words as told in each tone, folded into one: a slot the
/// tones agree on stays one string, a slot they tell differently
/// becomes the list, in the tones' order.
pub fn fold_tones(told: &[Value]) -> Value {
match told.first() {
Some(Value::Object(first)) => Value::Object(
first
.keys()
.map(|k| (k.clone(), fold_tones(&told.iter().map(|t| t.get(k).cloned().unwrap_or(Value::Null)).collect::<Vec<_>>())))
.collect(),
),
Some(Value::String(_)) => {
let each: Vec<&str> = told.iter().map(|t| t.as_str().unwrap_or_default()).collect();
if each.iter().all(|x| *x == each[0]) {
json!(each[0])
} else {
Value::Array(each.iter().map(|x| json!(x)).collect())
}
}
Some(other) => other.clone(),
None => Value::Null,
}
}
const SLOT: &str = "<WRITE";
/// A model asked for Norwegian sometimes answers in English, and the
/// reverse; the words then look fine and read wrong. Counts the
/// commonest function words of each language across every string of
/// a reply and names the language that won when it is not the plan's.
/// Only the two languages the plans come in; any other language is
/// left unchecked.
fn wrong_language(lang: &str, reply: &Value) -> Option<&'static str> {
const NORWEGIAN: &[&str] = &["og", "er", "du", "det", "ikke", "til", "som", "med", "på", "har", "vi", "deg", "en", "et"];
const ENGLISH: &[&str] = &["the", "and", "you", "is", "are", "we", "to", "of", "your", "it", "in", "with", "for", "a"];
fn collect(v: &Value, out: &mut String) {
match v {
Value::String(s) => {
out.push(' ');
out.push_str(s);
}
Value::Object(m) => m.values().for_each(|v| collect(v, out)),
Value::Array(a) => a.iter().for_each(|v| collect(v, out)),
_ => {}
}
}
let mut text = String::new();
collect(reply, &mut text);
let words: Vec<String> = text.split(|c: char| !c.is_alphabetic()).filter(|w| !w.is_empty()).map(|w| w.to_lowercase()).collect();
if words.len() < 8 {
return None;
}
let no = words.iter().filter(|w| NORWEGIAN.contains(&w.as_str())).count();
let en = words.iter().filter(|w| ENGLISH.contains(&w.as_str())).count();
let plan_is_norwegian = lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn");
let plan_is_english = lang.starts_with("en");
match (plan_is_norwegian, plan_is_english) {
(true, _) if en > no * 2 && en >= 4 => Some("English"),
(_, true) if no > en * 2 && no >= 4 => Some("Norwegian"),
_ => None,
}
}
/// A mail's link to the submitter's own move is structure, not
/// wording: a model told to keep it still drops it (Sonnet replaced
/// the whole line with a bare `{chain}`). Wherever the fallback body
/// had a `{site}/decide/…` line and the model's has none, the line is
/// put back at the end, and a `{chain}` left on a line of its own is
/// removed.
fn keep_links(fallback: &Value, merged: &mut Value) {
let (Some(fb), Some(mb)) = (fallback.as_object(), merged.as_object_mut()) else { return };
for (bucket, stages) in fb {
let Some(ms) = mb.get_mut(bucket).and_then(Value::as_object_mut) else { continue };
for (stage, spec) in stages.as_object().into_iter().flatten() {
let link = spec["body"].as_str().and_then(|b| b.lines().find(|l| l.contains("{site}/decide/")));
let Some(body) = ms.get_mut(stage).and_then(|m| m.get_mut("body")) else { continue };
let Some(text) = body.as_str() else { continue };
// A bare `{chain}` outside a link is the record id pasted
// into prose, which a model does when told to keep
// placeholders; it goes, on its own line or inline.
let cleaned: Vec<String> = text
.lines()
.map(|l| if l.contains("/decide/") { l.to_string() } else { l.replace("{chain}", "").split_whitespace().collect::<Vec<_>>().join(" ") })
.collect();
let mut cleaned = cleaned.join("\n").trim_end().to_string();
if let Some(link) = link {
if !cleaned.contains("/decide/") {
cleaned = format!("{cleaned}\n\n{link}");
}
}
*body = Value::String(cleaned);
}
}
}
/// The page's wording with every string replaced by a slot that says
/// what belongs there. Shown to the model in place of the fallbacks:
/// fallbacks read like finished copy, and a small model hands finished
/// copy straight back (measured: nemotron returned the front door
/// unchanged). A slot cannot be mistaken for an answer.
fn slots(value: &Value, key: &str) -> Value {
slots_in(value, key, "")
}
fn slots_in(value: &Value, key: &str, parent: &str) -> Value {
match value {
Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), slots_in(v, k, key))).collect()),
Value::String(_) => Value::String(format!(
"{SLOT}: {}>",
match (parent, key) {
(_, "question") => "the one question this page asks its reader".to_string(),
(_, "heading" | "next_heading") => "a heading, at most eight words, saying what the reader gets".to_string(),
(_, "description" | "page_description" | "next_description") => "one or two short sentences".to_string(),
(_, "button" | "back") => "a button, one to three words, saying what pressing it does".to_string(),
// One slot serves every list on the desk, so it cannot
// name any one of them: a model once wrote "Send invite"
// under the cases list.
(_, "confirm") => "the one button under every list on this desk, confirming a move the person has chosen; one word, such as Confirm".to_string(),
(_, "encouragement") => "one reassuring sentence: what happens with what they send, or how little it takes; or empty".to_string(),
(_, "icon") => "an icon name from the list at the end, the one that fits this best; or empty".to_string(),
(_, "for_whom") => "a few words naming who this is for, addressed to them; or empty".to_string(),
(_, "form_title") => "a title for the form, two to four words".to_string(),
(_, "form_description") => "one short line of guidance above the form, or empty".to_string(),
(_, "road_title") => "a short title, two to four words, for the line that says what happens after sending".to_string(),
(_, "road_description") => "one or two sentences walking the reader through the stages this goes through, in order (the stages are listed above), in their words".to_string(),
(_, "road_icon" | "who_icon") => "an icon name from the list at the end that fits this section; or empty".to_string(),
(_, "who_title") => "a short title, two to four words, for the line that says who reads it".to_string(),
(_, "list_description") => "one line over a public list of what people have sent in, or empty".to_string(),
(_, "list_title") => "a short title, two to four words, naming the list itself".to_string(),
(_, "link") => "the words of a link to the whole list, two to four words, naming it".to_string(),
(_, "empty") => "one short sentence shown when the list has nothing in it yet, inviting rather than apologising".to_string(),
(_, "list_note") => "one sentence saying what a single row is, and what the buttons under it do or what the list leaves out".to_string(),
(_, "behind") => "one sentence naming what a reader can do behind this door, in their words".to_string(),
(_, "who_description") => "one sentence: who reads what they send, and how to reach them meanwhile".to_string(),
("hints", field) => format!("an example answer for the field `{field}`, as a placeholder; or empty"),
(_, other) if other.contains("->") => format!("a button, two to four words, for a case going {other}"),
(_, other) => format!("a form label for the field `{other}`"),
}
)),
other => other.clone(),
}
}
/// Icons the words may name: a fixed set from Iconify's lucide pack,
/// so a reply can only pick one that exists. Anything else is dropped.
pub const ICONS: &[&str] = &[
"house", "building-2", "store", "school", "map-pin", "route", "footprints", "trees", "leaf", "snowflake", "sun",
"users", "user", "user-plus", "user-check", "baby", "heart-handshake", "hand-helping", "handshake", "smile",
"mail", "phone", "message-circle", "megaphone", "bell", "send", "inbox",
"alert-triangle", "siren", "shield", "construction", "hammer", "wrench", "ruler", "package", "truck", "car", "bus", "train-front", "bike",
"calendar", "clock", "file-text", "clipboard-list", "receipt", "wallet", "coins", "banknote", "credit-card",
"key", "lock", "search", "star", "gift", "shopping-cart", "lightbulb", "flag", "thumbs-up", "check-circle",
"briefcase", "stethoscope", "activity", "camera", "pencil", "refresh-cw", "list", "layers", "vote", "scale",
];
/// `lucide:<name>` for a name on the list, otherwise nothing.
pub fn icon_name(given: &str) -> Option<String> {
// A lift token (see `lift_words`) has to reach the rendered file to
// be matched against the repo's own icon.
if given.starts_with('\u{1}') {
return Some(given.to_string());
}
let name = given.trim().trim_start_matches("lucide:");
ICONS.contains(&name).then(|| format!("lucide:{name}"))
}
/// A slot's words for the machinery's purposes: the string, or the
/// first storyline's when the slot holds several (see `text`).
fn s(v: &Value) -> &str {
match v {
Value::Array(items) => items.first().and_then(|x| x.as_str()).unwrap_or_default(),
other => other.as_str().unwrap_or_default(),
}
}
/// A slot's words as the content carries them: one string, or one
/// per storyline - portal reads a list as its storylines
/// (`docs/storylines.md`). Trimmed either way.
fn text(v: &Value) -> Value {
match v {
Value::Array(items) => Value::Array(items.iter().map(|x| json!(x.as_str().unwrap_or_default().trim())).collect()),
other => json!(other.as_str().unwrap_or_default().trim()),
}
}
/// The same slot, each storyline put through `f`.
fn text_map(v: &Value, f: impl Fn(&str) -> String) -> Value {
match v {
Value::Array(items) => Value::Array(items.iter().map(|x| json!(f(x.as_str().unwrap_or_default()))).collect()),
other => json!(f(other.as_str().unwrap_or_default())),
}
}
/// A one-button `consequence`: `[Send]`, or one list per storyline.
fn button(v: &Value) -> Value {
match v {
Value::Array(items) => Value::Array(items.iter().map(|x| json!([x.as_str().unwrap_or_default().trim()])).collect()),
other => json!([other.as_str().unwrap_or_default().trim()]),
}
}
fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Value {
let parent = plan.parent_of(need);
let mut requirements: Vec<Value> = need.collects.iter().map(|f| {
let mut r = json!({ "name": f.name, "label": text(&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); }
// Someone signed in is not asked for what the site knows: on a
// group's page they always are, and answering a record they
// may be.
if parent.is_some() || need.enters_as.is_some() {
if f.kind == "email" && matches!(f.name.as_str(), "email" | "epost" | "e-post") { r["value"] = json!("{user.email}"); }
if f.kind == "text" && matches!(f.name.as_str(), "name" | "navn") { r["value"] = json!("{user.name}"); }
}
r
}).collect();
// The record this answer is about, carried in from the page's own
// address: nobody types a record id.
if let Some(parent) = parent {
requirements.push(json!({ "name": parent.lands_in, "type": "record", "of": parent.lands_in, "value": "{key}" }));
}
let mut feature = json!({
"name": text(&w["form_title"]),
"requirements": requirements,
});
if !s(&w["form_description"]).trim().is_empty() {
feature["description"] = text(&w["form_description"]);
}
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: &Value, body: &Value, icon: &str| {
let mut f = json!({ "name": text(title), "description": text(body) });
if let Some(icon) = icon_name(icon) { f["icon"] = json!(icon); }
f
};
let mut features = Vec::new();
// What the person must already have or be for this to work at all,
// first of everything: a card that advertises something conditional
// and says so only after the form has taken their time has already
// wasted it.
// Whether the section exists is the plan's to say, never the
// wording's: under a lift every slot holds a token, and a token is
// never empty, so a word-driven condition would conjure a section
// the live page does not have and shift every feature after it.
if !need.requires.trim().is_empty() {
features.push(strip(&w["requires_title"], &w["requires_description"], s(&w["requires_icon"])));
}
if !s(&w["road_description"]).trim().is_empty() {
features.push(strip(&w["road_title"], &w["road_description"], s(&w["road_icon"])));
}
if !s(&w["who_description"]).trim().is_empty() {
features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"])));
}
features.push(feature);
let mut alt = json!({
"name": text(&w["heading"]),
"description": text(&w["description"]),
"action": action,
"consequence": button(&w["button"]),
"record_as": need.lands_in,
"features": features,
});
if !s(&w["encouragement"]).trim().is_empty() {
alt["encouragements"] = button(&w["encouragement"]);
}
alt
}
/// Every file of the content repo, from the plan and a set of words.
pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BTreeMap<String, String>> {
let mut files: BTreeMap<String, Value> = BTreeMap::new();
let mut responsible = json!({ "name": plan.contact.name, "contact": plan.contact.email });
if let Some(group) = &plan.contact.group {
responsible["group"] = json!(group);
}
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);
}
if let Some(invite) = words.get("site.yaml").map(|w| &w["invite"]).filter(|i| i.is_object()) {
site["mail"]["invite"] = json!({ "subject": text(&invite["subject"]), "body": text(&invite["body"]) });
}
}
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": all_stages(need).iter().map(|st| {
let mut state = json!({ "event": event_for(need, st) });
if let Some(e) = need.escalate.get(st) {
state["after"] = json!({ "days": e.days, "to": escalated_stage(st) });
}
if let Some(to) = need.mails.get(st) {
let w = &mail_words[bucket][st];
// `owner` is whoever sent the record this need
// answers: the address that record holds, reached
// through the record field the form carries.
let to = match (to.as_str(), plan.parent_of(need)) {
("owner", Some(parent)) => format!("{{{}.email}}", parent.lands_in),
_ => to.clone(),
};
state["mail"] = json!({ "to": to, "subject": text(&w["subject"]), "body": text(&w["body"]) });
}
if let Some(group) = need.grants.get(st) {
state["grants"] = json!(group);
}
if let Some(d) = need.deadlines.get(st) {
state["after"] = json!({ "days": d.days, "to": d.to });
}
(st.clone(), state)
}).collect::<Map<_, _>>(),
"transitions": machine(need),
});
if let Some(who) = &need.attended_by {
aggregate["attended_by"] = json!(who);
}
aggregate
}).collect::<Vec<_>>() }),
);
// The front door opens modestly: first what the business does,
// in features and no button; then what a visitor may look at;
// and only then, further down, one alternative per door and one
// per need the public brings straight to it. A stranger decides
// to trust the place before being asked for anything.
let front = &words["questions/index.yaml"];
let card = |w: &Value| {
let mut f = json!({ "name": text(&w["heading"]), "description": text(&w["description"]) });
if let Some(icon) = icon_name(s(&w["icon"])) {
f["icon"] = json!(icon);
}
f
};
let does: Vec<Value> = front["opener"]["features"].as_object().into_iter().flatten().map(|(_, w)| card(w)).collect();
let mut alternatives: Vec<Value> = vec![json!({
"name": text(&front["opener"]["heading"]),
"description": text(&front["opener"]["description"]),
"features": does,
})];
let mut shopping: Vec<Value> = plan
.shows
.iter()
.map(|show| {
let mut f = card(&front["shopping"]["items"][&show.id]);
f["resource"] = show.resource.clone();
f
})
.collect();
// A public list is shown, not pointed at: its newest few records
// inline, and a link to the whole list.
for (bucket, needs) in public_buckets(plan) {
let w = &front["shopping"]["items"][&format!("status-{bucket}")];
let mut f = card(w);
let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim().to_string(), l => l.to_string() };
f["description"] = text_map(&w["description"], |d| format!("{} [{link}](/status/{bucket})", d.trim()).trim().to_string());
f["resource"] = public_resource(bucket, &needs, s(&w["empty"]), Some(FRONT_DOOR_LATEST), s(&words[&public_view_path(bucket)]["answer"]));
shopping.push(f);
}
if !shopping.is_empty() {
alternatives.push(json!({
"name": text(&front["shopping"]["heading"]),
"description": text(&front["shopping"]["description"]),
"features": shopping,
}));
}
alternatives.extend(plan
.doors
.iter()
.map(|d| {
let dw = &front["doors"][&d.id];
let mut alt = json!({
"name": text(&dw["heading"]),
"description": text(&dw["description"]),
"action": format!("/{}", d.id),
"consequence": button(&dw["button"]),
});
// A door carries no form; its icon and "for whom" line
// ride an empty section, which portal renders as a
// labelled strip on the card.
let (icon, for_whom) = (icon_name(s(&dw["icon"])), s(&dw["for_whom"]).trim());
let behind = s(&dw["behind"]).trim();
if icon.is_some() || !for_whom.is_empty() || !behind.is_empty() {
let mut f = json!({ "name": for_whom, "description": behind });
if let Some(icon) = icon { f["icon"] = json!(icon); }
alt["features"] = json!([f]);
}
alt
}));
alternatives.extend(
plan.needs
.iter()
.filter(|n| n.on_front_door())
.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": text(&front["question"]), "description": text(&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": text(&w["question"]),
"description": text(&w["description"]),
"responsible": responsible,
"alternatives": plan.needs_behind(&door.id).iter().map(|n| match &n.enters_as {
None => form_alternative(plan, n, &w["needs"][&n.id], &public_followup(n)),
// The way to the group's page: portal shows the
// sign-in gate there to whoever is not in the group.
Some(group) => json!({
"name": text(&w["needs"][&n.id]["heading"]),
"description": text(&w["needs"][&n.id]["description"]),
"action": internal_page(n, group),
"consequence": button(&w["needs"][&n.id]["button"]),
}),
}).collect::<Vec<_>>(),
}),
);
}
// A page per record of a board, holding the form that answers it.
// Dynamic: portal serves `/status/<bucket>/<id>` from it, and the
// form's record field carries that id.
for parent in &plan.needs {
let answers = plan.answers_to(parent);
if answers.is_empty() {
continue;
}
let path = answer_page_path(&parent.lands_in);
let w = &words[&path];
files.insert(
path,
json!({
"name": text(&w["question"]),
"description": text(&w["description"]),
"responsible": responsible,
"alternatives": answers.iter().map(|n| form_alternative(plan, n, &w["forms"][&n.id], &public_followup(n))).collect::<Vec<_>>(),
}),
);
}
for need in &plan.needs {
// No "back" alternative: portal's way down the tree (the link at
// the foot of every page) takes a visitor one level down from
// any page, here to the front door or, after an internal form,
// to the group's desk.
let path = match &need.enters_as {
None => format!("questions/received/{}.yaml", need.id),
Some(group) => format!("questions/{group}/{}-sent.yaml", need.id),
};
let w = &words[&path];
let mut alternatives = vec![
json!({ "name": text(&w["next_heading"]), "description": text(&w["next_description"]) }),
];
if need.watched_by.iter().any(|g| g == PUBLIC) {
alternatives.push(json!({
"name": text(&w["see_all"]["heading"]), "description": text(&w["see_all"]["description"]),
"action": format!("/status/{}", need.lands_in), "consequence": button(&w["see_all"]["button"]),
}));
}
// Nested and not an index: portal infers a followup page.
files.insert(path, json!({ "name": text(&w["question"]), "description": text(&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": text(&w["question"]),
"description": text(&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];
let mut resource = json!({
"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": text(&bw["moves"][&format!("{from}->{to}")]) })
}).collect::<Vec<_>>(),
});
// A group that has the bucket only because cases escalate
// to it sees those cases and no others.
let escalated = escalated_states_for(needs[0], &group);
if !escalated.is_empty() {
resource["states"] = json!(escalated);
}
alternatives.push(json!({
"name": text(&bw["heading"]),
"description": text(&bw["description"]),
"action": format!("/{group}"),
"consequence": button(&w["confirm"]),
"features": [{ "name": text(&bw["list_title"]), "description": text(&bw["list_note"]), "resource": resource }],
}));
}
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": text(&lw["heading"]),
"description": text(&lw["description"]),
"action": internal_page(need, &group),
"consequence": button(&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": text(&iw["heading"]),
"description": text(&iw["description"]),
"action": format!("/{group}"),
"consequence": button(&iw["button"]),
"invite": { "group": target },
"features": [{ "name": text(&iw["form_title"]), "description": text(&iw["form_description"]), "requirements": if plan.memberships() {
json!([
{ "name": "name", "label": text(&iw["labels"]["name"]) },
{ "name": "phone", "label": text(&iw["labels"]["phone"]), "type": "tel" },
{ "name": "email", "label": text(&iw["labels"]["email"]), "type": "email", "optional": true },
])
} else {
json!([
{ "name": "name", "label": text(&iw["labels"]["name"]) },
{ "name": "email", "label": text(&iw["labels"]["email"]), "type": "email" },
])
}}],
}));
}
let lw = &w["invited"];
// Through Kanidm an invite is its own case with a link that
// runs out; with memberships the membership is the case.
let (bucket, transitions) = if plan.memberships() {
("portal_memberships", json!([
{ "from": "invited", "to": "removed", "label": text(&lw["withdraw"]) },
{ "from": "active", "to": "removed", "label": text(&lw["done"]) },
{ "from": "removed", "to": "invited", "label": text(&lw["again"]) },
]))
} else {
("portal_invites", json!([
{ "from": "open", "to": "done", "label": text(&lw["done"]) },
{ "from": "expired", "to": "open", "label": text(&lw["again"]) },
{ "from": "expired", "to": "done", "label": text(&lw["done"]) },
]))
};
alternatives.push(json!({
"name": text(&lw["heading"]),
"description": text(&lw["description"]),
"action": format!("/{group}"),
"consequence": button(&w["confirm"]),
"features": [{ "name": text(&lw["list_title"]), "description": text(&lw["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": bucket },
"requires_group": group,
"transitions": transitions,
}}],
}));
}
// Suggested changes, from portal's own bucket: open, discussed,
// done or declined.
if let Some(sw) = w["suggestions"].as_object() {
alternatives.push(json!({
"name": text(&sw["heading"]),
"description": text(&sw["description"]),
"action": format!("/{group}"),
"consequence": button(&w["confirm"]),
"features": [{ "name": text(&sw["list_title"]), "description": text(&sw["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": "portal_edit_suggestions" },
"requires_group": group,
"transitions": [
{ "from": "open", "to": "discussed", "label": text(&sw["looking"]) },
{ "from": "open", "to": "done", "label": text(&sw["changed"]) },
{ "from": "open", "to": "declined", "label": text(&sw["declined"]) },
{ "from": "discussed", "to": "done", "label": text(&sw["changed"]) },
{ "from": "discussed", "to": "declined", "label": text(&sw["declined"]) },
],
}}],
}));
}
files.insert(format!("questions/{group}/_section.yaml"), json!({ "qualifies": group }));
files.insert(
format!("questions/{group}/index.yaml"),
json!({ "name": text(&w["question"]), "description": text(&w["description"]), "responsible": responsible, "alternatives": alternatives }),
);
}
// Documents: a public page per text, one alternative per section,
// every word the plan's own. The first paragraph is the card's
// description; any further ones follow as unnamed features.
for doc in &plan.documents {
files.insert(
format!("questions/{}.yaml", doc.id),
json!({
"name": doc.question.trim(),
"description": doc.description.trim(),
"responsible": responsible,
"alternatives": doc.sections.iter().map(|section| {
let mut alt = json!({ "name": section.heading, "description": section.text.first().cloned().unwrap_or_default() });
if section.text.len() > 1 {
alt["features"] = json!(section.text[1..].iter().map(|p| json!({ "name": "", "description": p })).collect::<Vec<_>>());
}
alt
}).collect::<Vec<_>>(),
}),
);
}
// 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];
files.insert(
path,
json!({
"name": text(&w["question"]),
"description": text(&w["description"]),
"responsible": responsible,
"alternatives": [
{
"name": text(&w["heading"]),
"description": text(&w["list_description"]),
"features": [{ "name": text(&w["list_title"]), "description": text(&w["list_note"]), "resource": public_resource(bucket, &needs, s(&w["empty"]), None, s(&w["answer"])) }],
},
],
}),
);
}
// 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();
// Where each of their moves may be made from. A person is in
// dialogue with their case for as long as it runs: the
// applicant answers a request for papers from `incomplete`,
// and withdraws from wherever the case has got to by then.
// Portal compiles one policy rule per state named here, and
// without them it assumes the day the case arrived.
let from_for = |to: &String| -> Vec<&String> {
theirs.iter().filter(|(_, t)| t == to).map(|(from, _)| from).collect()
};
files.insert(
path,
json!({
"name": text(&w["question"]),
"description": text(&w["description"]),
"alternatives": targets.iter().map(|to| json!({
"name": text(&w["moves"][to.as_str()]["heading"]),
"description": text(&w["moves"][to.as_str()]["description"]),
"self_transition": { "bucket": need.lands_in, "to": to, "from": from_for(to), "label": text(&w["moves"][to.as_str()]["button"]) },
})).collect::<Vec<_>>(),
}),
);
}
// The spec the verifier holds the site to. `via` is the heading the
// words gave that need, filled in mechanically.
files.insert(
"needs.yaml".into(),
json!({
"needs": plan.needs.iter().map(|n| {
// `via` pins the alternative on the entry page: the
// door's heading for a need behind one, else the form's.
let (enters, heading, steps) = match (&n.enters_as, &n.door, plan.parent_of(n)) {
(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),
// An answer enters on the record's own page, which
// the board's row links to.
(None, None, Some(parent)) => (answer_page(&parent.lands_in), s(&words[&answer_page_path(&parent.lands_in)]["forms"][&n.id]["heading"]).to_string(), 1),
(None, 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": all_stages(n), "done_when": n.done_when, "success": n.success,
"max_steps": steps, "max_fields": n.max_fields,
"stage_words": all_stages(n).iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::<Map<_, _>>(),
});
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect();
if !others.is_empty() { need["also_moved_by"] = json!(others); }
need
}).collect::<Vec<_>>(),
"personas": plan.personas,
"tones": plan.tones,
}),
);
files.into_iter().map(|(path, value)| Ok((path, serde_yaml::to_string(&value)?))).collect()
}
fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -> String {
let voice = match tone {
Some(tone) => format!("\n\nTone of voice for every word on this page: {tone}. The site is told in more than one tone, each written in a pass of its own, so keep to this one throughout and do not hedge between tones."),
None => String::new(),
};
let shown_only = public_buckets(plan)
.into_iter()
.find(|(bucket, _)| public_view_path(bucket) == path)
.map(|(_, needs)| shown_stages(needs[0]))
.unwrap_or_default();
let status_purpose = if shown_only.is_empty() {
"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.".to_string()
} else {
format!("a public page anyone can open, read-only, listing ONLY the cases that have reached: {shown_only}. Nothing else is shown - do not promise the reader anything waiting or turned down. `question` is what this page asks; `heading` names the list.")
};
let purpose = if path == "site.yaml" {
"the invite mail: the first mail a person gets when a member of the business brings them in to help (onto a desk, into a committee), carrying the one-time link that lets them set a password. `subject` and `body` under `invite`. They did not ask for it, so say who brought them in and to what, then the link and when it runs out, then where they sign in afterwards. Plain text, short lines. Keep every `{...}` placeholder exactly as it is: `{name}`, `{username}`, `{group}`, `{link}`, `{expires}`, `{site}`, `{site_name}` are filled in by the site, and `{link}` must stay on a line of its own."
} else if path == "aggregates.yaml" {
"the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, at most three short sentences plus the link line, no greeting longer than one word, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it. The person reads the mail alone, days later, maybe among others, so what works is a mail that says what it is about in their terms - a field they filled in, as a `{placeholder}`, names their case far better than `this` or `your message` (each bucket's fields are listed under the needs above) - and ends with the business's name so they know who wrote. Short fields (a name, a place, a choice) make good subjects; a field marked `(long)` is a whole answer and a field marked `(optional)` may be blank, so those serve better elsewhere or not at all. A desk's decision carries no written reason, but a reply to the mail reaches the people who decided, so inviting a reply is the honest way to offer an explanation."
} 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: the site's live content, presented as it is - the latest records, a figure that matters, what has been built. It works best presented plainly: a heading that names what it shows ('Latest from the noticeboard', 'What we have built'), each item headed by its own content, `link` naming the full list. The content does the persuading; the card only has to label it, so it needs no invitation to look around and no word about forms. 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/") {
status_purpose.as_str()
} 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 - or, for a need that is members' only, the card that leads to the members' page, where the form is behind sign-in: say so in its description."
} 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, where `again` is the button that sends an invite whose link ran out once more. `suggestions`, on the desk that has it, is the list of changes the people responsible for a page have suggested: `looking`, `changed` and `declined` are its buttons."
} else if path.ends_with("/[key].yaml") {
"a page one record of a public list opens to. This is NOT where a notice is posted: the visitor has read somebody else's record on the list (a notice, an application) and is answering it. `question` asks the visitor what they have to say to it; under `forms`, each entry is one kind of answer (an objection, an appeal, an offer to lend), with the form behind it. An answer that mails the record's owner goes straight to them; nobody at a desk passes it on, so never say so."
} else if path.contains("/received/") || path.ends_with("-sent.yaml") {
"a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next."
} else {
"a page only one role inside the business can open, holding one form."
};
let mut context: Vec<String> = plan
.doors
.iter()
.map(|d| format!("- door {}: for {}; behind it: {}", d.id, d.who, plan.needs_behind(&d.id).iter().map(|n| n.id.as_str()).collect::<Vec<_>>().join(", ")))
.collect();
context.extend(
plan.needs
.iter()
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().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.{voice}\n\n{}\n\nReply with the JSON object only.",
plan.business,
plan.about.trim(),
plan.contact.name,
plan.contact.email,
plan.language,
context.join("\n"),
ICONS.join(", "),
serde_json::to_string_pretty(&slots(fallback, "")).unwrap_or_default(),
)
}
pub fn run(argv: &[String]) -> anyhow::Result<()> {
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--words-from <existing repo>] [--words <words.json>]... [--llm 'cmd'] [--lint question_lint]";
let plan_path = value("--plan").ok_or_else(|| anyhow!(usage))?;
let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?);
let mut 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.load_documents(Path::new(&plan_path).parent().unwrap_or_else(|| Path::new(".")))?;
plan.validate()?;
for warning in plan.spectators() {
eprintln!("build: {warning}");
}
// Words, in layers: the fallbacks; what an existing repo already
// says (hand edits survive a rebuild); explicit overrides for new
// slots; and only then a model, for whatever is still fallback.
let mut words = match value("--words-from") {
Some(repo) => lift_words(&plan, Path::new(&repo))?,
None => fallback_words(&plan),
};
// `--words` may repeat: the words a previous build used, then a
// round of hand overrides on top, in the order given. Each is
// merged key by key over what is there, so a file names only the
// slots it changes.
let words_files: Vec<&String> = argv.iter().enumerate().filter(|(_, a)| *a == "--words").filter_map(|(i, _)| argv.get(i + 1)).collect();
for path in words_files {
let given: BTreeMap<String, Value> = serde_json::from_str(&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?)
.with_context(|| format!("parsing {path}"))?;
for (page, v) in given {
match words.get(&page) {
Some(base) => {
let merged = merge_words(base, &v);
words.insert(page, merged);
}
None => eprintln!("words: {page} is not a page of this plan, ignored"),
}
}
}
if let Some(cmd) = value("--llm") {
std::fs::create_dir_all(out.join("llm"))?;
// --llm-for <prefix>: the model writes only the pages whose
// path starts with it (`aggregates.yaml` for the mails alone,
// `questions/received/` for the followups), and everything
// else keeps the words it has.
let only = value("--llm-for");
for (n, (path, fallback)) in words.clone().iter().enumerate() {
if only.as_ref().is_some_and(|p| !path.starts_with(p.as_str())) {
continue;
}
// Once per tone, or once: each tone's page is written whole,
// so a storyline is consistent within itself, and the tones
// are folded into lists where they differ.
let tones: Vec<Option<&str>> = if plan.tones.is_empty() { vec![None] } else { plan.tones.iter().map(|t| Some(t.as_str())).collect() };
let mut told: Vec<Value> = Vec::new();
for (t, tone) in tones.iter().enumerate() {
let prompt = words_prompt(&plan, path, fallback, *tone);
let reply = crate::trainer::llm_with(&cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.4")]).unwrap_or_default();
let suffix = if tones.len() > 1 { format!("-tone{}", t + 1) } else { String::new() };
std::fs::write(out.join(format!("llm/{:02}-{}{suffix}.md", n + 1, path.replace('/', "_"))), format!("{prompt}\n\n=== REPLY ===\n{reply}"))?;
match first_object(&reply) {
Some(given) => match wrong_language(&plan.language, &given) {
Some(found) => {
eprintln!("words: {path} - the reply reads as {found} for a {} plan, keeping the words it had", plan.language);
told.clear();
break;
}
None => told.push(merge_words(fallback, &given)),
},
None => {
eprintln!("words: {path} - no usable reply, keeping the fallback");
told.clear();
break;
}
}
}
if !told.is_empty() {
words.insert(path.clone(), fold_tones(&told));
eprintln!("words: {path}");
}
}
}
// Last, whatever the words came from - a model, an overrides file,
// a lift from a live repo: a mail keeps the link to the sender's
// own move, and a placeholder left loose in its prose goes.
if let Some(mails) = words.get("aggregates.yaml").cloned() {
let mut mails = mails;
keep_links(&fallback_words(&plan)["aggregates.yaml"], &mut mails);
words.insert("aggregates.yaml".to_string(), mails);
}
if let Some(site_words) = words.get_mut("site.yaml") {
keep_invite_link(&plan.language, site_words);
}
write_repo(&out, render(&plan, &words)?)?;
// The words as used, so the next rebuild can start from them.
std::fs::write(out.join("words.json"), serde_json::to_string_pretty(&words)?)?;
println!("content repo written to {}", out.display());
if let Some(lint) = value("--lint") {
let output = std::process::Command::new(&lint).arg("--path").arg(out.join("questions")).output().with_context(|| format!("running {lint}"))?;
let stderr = String::from_utf8_lossy(&output.stderr);
print!("{}{}", String::from_utf8_lossy(&output.stdout), stderr);
if !output.status.success() {
// Personas are attached from a file and name needs by id; a
// planned site whose model chose other ids fails only on
// that mapping, which is the reader's to fix, not the
// builder's.
let unmapped = stderr.lines().filter(|l| l.starts_with("FAIL")).all(|l| l.contains("points at a need that is not declared") || l.contains("unmet business need finding"));
if unmapped && stderr.contains("points at a need that is not declared") {
bail!("the site is sound, but the personas name needs this plan does not declare: map each persona's `need:` to one of the plan's ids, or rename the plan's needs, then build again");
}
bail!("the verifier rejected a deterministically built site - that is a bug in the builder, not in the words");
}
}
Ok(())
}
fn write_repo(out: &Path, files: BTreeMap<String, String>) -> anyhow::Result<()> {
// The questions tree is rebuilt whole, so a page from an earlier
// plan never lingers.
let _ = std::fs::remove_dir_all(out.join("questions"));
for (path, body) in files {
let target = out.join(&path);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(target, body)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn need(stages: &[&str], done: &[&str], success: &[&str], skippable: &[&str]) -> PlanNeed {
let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::<Vec<_>>();
PlanNeed {
id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None, door: None, about: 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![], reached_from: BTreeMap::new(), requires: String::new(),
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![],
collects: vec![], max_fields: 3,
}
}
fn map(pairs: &[(&str, &[&str])]) -> BTreeMap<String, Vec<String>> {
pairs.iter().map(|(k, vs)| (k.to_string(), vs.iter().map(|v| v.to_string()).collect())).collect()
}
fn m_of(need: &PlanNeed, stage: &str) -> Vec<String> {
machine(need).remove(stage).unwrap_or_default()
}
fn parse(yaml: &str) -> anyhow::Result<Plan> {
let plan: Plan = serde_yaml::from_str(yaml)?;
plan.validate()?;
Ok(plan)
}
#[test]
fn a_stage_may_mail_the_submitter_or_an_address_and_the_words_fill_it() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: \"B <post@b.example>\", reply_to: i@b.example }\nneeds:\n - id: leak\n who: owners\n wants: a dry house\n lands_in: leaks\n handled_by: roofers\n stages: [open, fixed, dropped]\n done_when: [fixed, dropped]\n success: [fixed]\n moves_by: { dropped: [submitter, roofers] }\n mails: { open: submitter, fixed: submitter, dropped: board@b.example }\n max_fields: 2\n collects:\n - { name: name }\n - { name: email, type: email }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let words = fallback_words(&plan);
let open_body = words["aggregates.yaml"]["leaks"]["open"]["body"].as_str().unwrap();
// The submitter has a move from open, so the fallback says where.
assert!(open_body.starts_with("Hello {name},"));
assert!(open_body.contains("{site}/decide/leak?chain={chain}&email={email}"));
assert!(!words["aggregates.yaml"]["leaks"]["fixed"]["body"].as_str().unwrap().contains("/decide/"));
let files = render(&plan, &words).unwrap();
let site: Value = serde_yaml::from_str(&files["site.yaml"]).unwrap();
assert_eq!(site["mail"]["from"], "B <post@b.example>");
let agg: Value = serde_yaml::from_str(&files["aggregates.yaml"]).unwrap();
let states = &agg["aggregates"][0]["states"];
assert_eq!(states["open"]["mail"]["to"], "submitter");
assert_eq!(states["dropped"]["mail"]["to"], "board@b.example");
assert!(states["fixed"]["mail"]["subject"].as_str().unwrap().contains("Fixed"));
// A submitter mail needs the email; mails need a sender.
let no_email = yaml.replace(" - { name: email, type: email }\n", "").replace(" moves_by: { dropped: [submitter, roofers] }\n", "");
assert!(serde_yaml::from_str::<Plan>(&no_email).unwrap().validate().unwrap_err().to_string().contains("required `email`"));
let no_from = yaml.replace("mail: { from: \"B <post@b.example>\", reply_to: i@b.example }\n", "");
assert!(serde_yaml::from_str::<Plan>(&no_from).unwrap().validate().unwrap_err().to_string().contains("mail: { from"));
}
#[test]
fn a_reply_in_the_wrong_language_is_named() {
let english = json!({ "a": { "body": "We have received what you sent and we will read it in full before we reply to you." } });
let norwegian = json!({ "a": { "body": "Vi har mottatt det du sendte og leser det i sin helhet før vi svarer deg på det." } });
assert_eq!(wrong_language("no", &english), Some("English"));
assert_eq!(wrong_language("no", &norwegian), None);
assert_eq!(wrong_language("en", &norwegian), Some("Norwegian"));
assert_eq!(wrong_language("en", &english), None);
// Too short to tell, or a language nobody checks: left alone.
assert_eq!(wrong_language("no", &json!({ "a": "Send" })), None);
assert_eq!(wrong_language("de", &english), None);
}
#[test]
fn a_models_mail_keeps_the_submitters_link() {
let fallback = json!({ "leaks": { "open": { "subject": "s", "body": "Hello,\n\nGot it.\n\nWhat you can do next: {site}/decide/leak?chain={chain}&email={email}\n\nI at i@b.example" } } });
let mut merged = json!({ "leaks": { "open": { "subject": "Got it", "body": "Hi,\n\nWe have it. {chain}\n\n{chain}" } } });
keep_links(&fallback, &mut merged);
let body = merged["leaks"]["open"]["body"].as_str().unwrap();
assert!(body.ends_with("What you can do next: {site}/decide/leak?chain={chain}&email={email}"));
assert!(!body.contains("it. {chain}") && !body.contains("\n{chain}"));
assert!(body.starts_with("Hi,\n\nWe have it."));
// A body that kept its link is left alone.
let mut fine = json!({ "leaks": { "open": { "subject": "s", "body": "Withdraw here: {site}/decide/leak?chain={chain}&email={email}" } } });
keep_links(&fallback, &mut fine);
assert_eq!(fine["leaks"]["open"]["body"], "Withdraw here: {site}/decide/leak?chain={chain}&email={email}");
}
#[test]
fn a_live_sites_event_names_survive_a_rebuild() {
let mut n = need(&["open", "invited", "active", "departed", "declined"], &["departed", "declined"], &["departed"], &[]);
assert_eq!(event_for(&n, "open"), "received");
assert_eq!(event_for(&n, "active"), "active");
n.events = [("open", "applied"), ("active", "onboarded")].iter().map(|(s, e)| (s.to_string(), e.to_string())).collect();
assert_eq!(event_for(&n, "open"), "applied");
assert_eq!(event_for(&n, "active"), "onboarded");
assert_eq!(event_for(&n, "invited"), "invited");
}
#[test]
fn the_machine_walks_the_line_and_can_be_turned_down_anywhere() {
let m = machine(&need(&["open", "surveyed", "quoted", "won", "lost"], &["won", "lost"], &["won"], &[]));
assert_eq!(m["open"], ["surveyed", "lost"]);
assert_eq!(m["surveyed"], ["quoted", "lost"]);
assert_eq!(m["quoted"], ["won", "lost"]);
assert!(!m.contains_key("won"));
}
// A building case is sent back for the drawing it was missing and
// walks the same stretch again. The line alone only goes forward,
// so the plan says where `incomplete` is entered from.
#[test]
fn a_case_can_be_sent_back_for_what_it_was_missing() {
let mut n = need(
&["open", "incomplete", "complete", "on_hearing", "approved", "refused"],
&["approved", "refused"],
&["approved"],
&["incomplete"],
);
assert_eq!(m_of(&n, "on_hearing"), ["approved", "refused"]);
assert!(!m_of(&n, "on_hearing").contains(&"incomplete".to_string()));
n.reached_from = map(&[("incomplete", &["complete", "on_hearing"][..])]);
let m = machine(&n);
assert_eq!(m["on_hearing"], ["approved", "refused", "incomplete"]);
assert_eq!(m["complete"], ["on_hearing", "refused", "incomplete"]);
// And nowhere else: `open` no longer reaches it, because saying
// where a stage is entered from says where it is not.
assert_eq!(m["open"], ["complete", "refused"]);
}
// The authority that changes its own mind ends the case at
// `changed_by_us`, which is a good ending in the middle of the
// line, not at the end of it.
#[test]
fn a_good_ending_can_sit_partway_along() {
let mut n = need(
&["open", "reconsidered", "before_the_board", "changed_by_us", "upheld", "overturned"],
&["changed_by_us", "upheld", "overturned"],
&["changed_by_us", "upheld"],
&[],
);
// By the line alone, we change our own mind only once the board
// already has the case - which is backwards.
assert!(m_of(&n, "before_the_board").contains(&"changed_by_us".to_string()));
assert!(!m_of(&n, "reconsidered").contains(&"changed_by_us".to_string()));
n.reached_from = map(&[
("changed_by_us", &["reconsidered"][..]),
("overturned", &["before_the_board"][..]),
]);
let m = machine(&n);
assert_eq!(m["reconsidered"], ["before_the_board", "changed_by_us"]);
assert_eq!(m["before_the_board"], ["upheld", "overturned"]);
assert!(!m["open"].contains(&"overturned".to_string()));
}
#[test]
fn a_stage_reached_from_nowhere_real_is_refused() {
let base = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: apply\n who: owners\n wants: permission\n lands_in: cases\n handled_by: office\n stages: [open, checked, approved, refused]\n done_when: [approved, refused]\n success: [approved]\n max_fields: 2\n collects:\n - { name: what }\n";
let with = |line: &str| format!("{base} reached_from: {line}\n");
assert!(parse(&with("{ approved: [checked] }")).is_ok());
assert!(parse(&with("{ signed: [checked] }")).unwrap_err().to_string().contains("not one of its stages"));
assert!(parse(&with("{ approved: [approved] }")).unwrap_err().to_string().contains("not one of its unfinished stages"));
assert!(parse(&with("{ open: [checked] }")).unwrap_err().to_string().contains("after the first"));
assert!(parse(&with("{ approved: [] }")).unwrap_err().to_string().contains("never be entered"));
}
#[test]
fn a_stated_prerequisite_comes_before_the_form() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: renew\n who: members\n wants: another year\n requires: You need your membership number from last year's receipt.\n lands_in: renewals\n handled_by: office\n stages: [open, done]\n done_when: [done]\n max_fields: 2\n collects:\n - { name: number }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let features = front["alternatives"][1]["features"].as_array().unwrap();
assert_eq!(features[0]["name"], "What you need");
assert_eq!(features[0]["description"], "You need your membership number from last year's receipt.");
assert_eq!(features[1]["name"], "What happens next");
// No prerequisite, no section.
let none: Plan = serde_yaml::from_str(&yaml.replace(" requires: You need your membership number from last year's receipt.\n", "")).unwrap();
let files = render(&none, &fallback_words(&none)).unwrap();
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
assert_eq!(front["alternatives"][1]["features"][0]["name"], "What happens next");
}
#[test]
fn a_case_is_turned_down_only_where_the_plan_says() {
let mut n = need(&["open", "invited", "active", "departed", "declined"], &["departed", "declined"], &["departed"], &[]);
n.turned_down_from = vec!["open".into(), "invited".into()];
let m = machine(&n);
assert_eq!(m["open"], ["invited", "declined"]);
assert_eq!(m["invited"], ["active", "declined"]);
assert_eq!(m["active"], ["departed"]);
}
#[test]
fn a_skippable_stage_can_be_passed_over() {
let m = machine(&need(&["open", "awaiting_papers", "approved", "declined"], &["approved", "declined"], &["approved"], &["awaiting_papers"]));
assert_eq!(m["open"], ["awaiting_papers", "approved", "declined"]);
assert_eq!(m["awaiting_papers"], ["approved", "declined"]);
}
#[test]
fn a_line_with_one_ending_has_no_way_to_be_turned_down() {
let m = machine(&need(&["open", "in_hand", "closed"], &["closed"], &["closed"], &[]));
assert_eq!(m["open"], ["in_hand"]);
assert_eq!(m["in_hand"], ["closed"]);
}
#[test]
fn the_card_carries_button_encouragement_icon_guidance_and_placeholders() {
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
let mut words = fallback_words(&plan);
let front = words.get_mut("questions/index.yaml").unwrap();
front["needs"]["survey"]["button"] = json!("Book a look");
front["needs"]["survey"]["encouragement"] = json!("We call within two days.");
front["needs"]["survey"]["icon"] = json!("house");
front["needs"]["survey"]["form_description"] = json!("The address is enough to start.");
front["needs"]["survey"]["hints"]["email"] = json!("you@example.no");
let files = render(&plan, &words).unwrap();
let page: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let alt = &page["alternatives"][1];
assert_eq!(alt["consequence"][0], "Book a look");
assert_eq!(alt["encouragements"][0], "We call within two days.");
// Two context sections from the plan come before the form.
let f = alt["features"].as_array().unwrap();
assert_eq!(f.len(), 3);
assert_eq!(f[0]["name"], "What happens next");
assert_eq!(f[0]["description"], "It is open, then surveyed, then quoted, and ends won or lost.");
assert_eq!(f[0]["icon"], "lucide:route");
assert_eq!(f[1]["name"], "Who reads it");
assert_eq!(f[1]["description"], "Project managers. Questions go to Ingrid at i@n.example.");
assert_eq!(f[2]["icon"], "lucide:house");
assert_eq!(f[2]["description"], "The address is enough to start.");
assert_eq!(f[2]["requirements"][0]["placeholder"], "you@example.no");
// An icon off the list is dropped, not passed through.
assert_eq!(icon_name("skull"), None);
assert_eq!(icon_name("lucide:house").as_deref(), Some("lucide:house"));
// Empty slots leave nothing behind: no encouragement key, no form icon.
let plain = render(&plan, &fallback_words(&plan)).unwrap();
let page: Value = serde_yaml::from_str(&plain["questions/index.yaml"]).unwrap();
assert!(page["alternatives"][0].get("encouragements").is_none());
assert!(page["alternatives"][0]["features"][2].get("icon").is_none());
}
#[test]
fn words_take_the_models_strings_and_never_its_structure() {
let fallback = json!({"question": "What?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}});
let given = json!({"question": " What do you need? ", "needs": {"a": {"heading": "", "labels": {"email": 7, "extra": "x"}}, "b": {"heading": "B"}}, "injected": "y"});
let merged = merge_words(&fallback, &given);
assert_eq!(merged, json!({"question": "What do you need?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}}));
}
const PLAN: &str = "business: Nordvik Bygg\ncontact: { name: Ingrid, email: i@n.example }\nabout: We fix old buildings.\nneeds:\n - id: survey\n who: owners\n wants: Someone to look at the building\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, surveyed, quoted, won, lost]\n done_when: [won, lost]\n success: [won]\n max_fields: 4\n collects:\n - { name: email, type: email }\n - { name: problem, type: textarea }\n - { name: address, optional: true }\n - id: report\n who: crew\n wants: Report a hazard\n enters_as: crew\n lands_in: site_issues\n handled_by: hse\n stages: [open, in_hand, closed]\n done_when: [closed]\n success: [closed]\n max_fields: 3\n collects:\n - { name: kind, type: select, options: [Hazard, Near miss] }\n";
#[test]
fn a_plan_renders_every_file_the_site_needs() {
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let paths: Vec<&str> = files.keys().map(String::as_str).collect();
assert_eq!(paths, [
"aggregates.yaml", "needs.yaml",
"questions/crew/_section.yaml", "questions/crew/index.yaml", "questions/crew/report-sent.yaml", "questions/crew/report.yaml",
"questions/hse/_section.yaml", "questions/hse/index.yaml",
"questions/index.yaml",
"questions/project_managers/_section.yaml", "questions/project_managers/index.yaml",
"questions/received/survey.yaml", "site.yaml",
]);
// The crew's form is gated, a way in, and recorded for the safety officer's desk.
assert!(files["questions/crew/_section.yaml"].contains("qualifies: crew"));
assert!(files["questions/crew/report.yaml"].contains("followup: false"));
assert!(files["questions/hse/index.yaml"].contains("bucket: site_issues"));
assert!(files["questions/hse/index.yaml"].contains("to: closed"));
// The spec names the heading the words gave, and the budget the form implies.
assert!(files["needs.yaml"].contains("via: Someone to look at the building"));
assert!(files["needs.yaml"].contains("max_fields: 4"));
assert!(files["needs.yaml"].contains("enters: /crew/report"));
}
#[test]
fn roles_overlap_on_one_aggregate_each_with_their_own_buttons() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: change\n who: a client\n wants: a change priced and done\n lands_in: change_orders\n handled_by: project_managers\n stages: [open, priced, approved, completed, declined]\n done_when: [completed, declined]\n success: [completed]\n moves_by:\n approved: [submitter]\n declined: [submitter, project_managers]\n completed: [site_managers]\n watched_by: [accounts]\n max_fields: 3\n collects:\n - { name: email, type: email }\n - { name: change, type: textarea }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
// The project manager prices and may decline; approving and completing are not theirs.
let pm = &files["questions/project_managers/index.yaml"];
assert!(pm.contains("to: priced") && pm.contains("to: declined"));
assert!(!pm.contains("to: approved") && !pm.contains("to: completed"));
// The site manager completes, and nothing else.
let site = &files["questions/site_managers/index.yaml"];
assert!(site.contains("to: completed") && !site.contains("to: priced"));
// Accounts see the bucket and move nothing.
let accounts = &files["questions/accounts/index.yaml"];
assert!(accounts.contains("bucket: change_orders") && accounts.contains("transitions: []"));
// The client decides through their own link.
let decide = &files["questions/decide/change.yaml"];
assert!(decide.contains("self_transition") && decide.contains("to: approved") && decide.contains("to: declined"));
// The spec tells the verifier whose desks count.
assert!(files["needs.yaml"].contains("also_moved_by:\n - site_managers"));
// A submitter's move without an email to authorize it is refused.
let no_email = yaml.replace(" - { name: email, type: email }\n", "");
let plan: Plan = serde_yaml::from_str(&no_email).unwrap();
assert!(plan.validate().unwrap_err().to_string().contains("required `email`"));
}
#[test]
fn the_front_door_opens_with_what_we_do_and_a_look_around_before_any_button() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ndoes:\n - { id: fix, text: We fix roofs., for: leak }\n - { id: keep, text: We keep them fixed. }\nshows:\n - id: built\n text: Roofs we have done.\n resource: { public: true, source: { kind: url, url: https://b.example/roofs.json } }\nneeds:\n - id: leak\n who: owners\n wants: a dry house\n lands_in: leaks\n handled_by: roofers\n watched_by: [public]\n stages: [open, fixed]\n done_when: [fixed]\n max_fields: 1\n collects:\n - { name: where }\n - id: apply\n who: workers\n wants: a job\n lands_in: applicants\n handled_by: roofers\n stages: [open, hired]\n done_when: [hired]\n max_fields: 1\n collects:\n - { name: name }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let alts = front["alternatives"].as_array().unwrap();
// The opener: two features, no action, no form.
assert_eq!(alts[0]["name"], "What we do");
assert!(alts[0].get("action").is_none());
assert_eq!(alts[0]["features"].as_array().unwrap().len(), 2);
assert_eq!(alts[0]["features"][0]["description"], "We fix roofs.");
// The live content, named for what it is: the resource verbatim,
// and the public list shown inline - its newest three - with a
// link to the whole of it.
assert!(alts[1]["name"].as_str().unwrap().starts_with("Latest from"));
assert!(alts[1].get("action").is_none());
assert_eq!(alts[1]["features"][0]["resource"]["source"]["kind"], "url");
let listed = &alts[1]["features"][1];
assert!(listed["description"].as_str().unwrap().contains("](/status/leaks)"));
assert_eq!(listed["resource"]["source"]["bucket"], "leaks");
assert_eq!(listed["resource"]["public"], true);
assert!(listed["resource"]["jq"].as_str().unwrap().starts_with("sort_by(.submitted_ms) | reverse | .[:3] | .[] |"));
// Only then the offers.
assert_eq!(alts[2]["record_as"], "leaks");
assert_eq!(alts[3]["record_as"], "applicants");
// The role the opener invites in is headed by its win, and
// must have something to play: the owner watches the public
// list, so no warning; take that away and the build says so.
assert_eq!(alts[0]["features"][0]["name"], "A dry house");
assert!(plan.spectators().is_empty());
let spectator: Plan = serde_yaml::from_str(&yaml.replace(" watched_by: [public]\n", "")).unwrap();
assert!(spectator.spectators()[0].contains("play nothing"));
// Three features is a menu; a `for` must name a need.
let three = yaml.replace(" - { id: keep, text: We keep them fixed. }\n", " - { id: keep, text: x }\n - { id: more, text: y }\n");
assert!(serde_yaml::from_str::<Plan>(&three).unwrap().validate().unwrap_err().to_string().contains("one or two"));
let bad = yaml.replace("for: leak", "for: nothing");
assert!(serde_yaml::from_str::<Plan>(&bad).unwrap().validate().unwrap_err().to_string().contains("nothing"));
}
#[test]
fn a_door_is_a_first_question_with_its_needs_on_a_page_of_their_own() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ndoors:\n - { id: your-building, who: an owner with a building }\nneeds:\n - id: survey\n who: owners\n wants: a look\n door: your-building\n lands_in: enquiries\n handled_by: pms\n stages: [open, won, lost]\n done_when: [won, lost]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: build\n who: owners\n wants: a price\n door: your-building\n lands_in: enquiries\n handled_by: pms\n stages: [open, won, lost]\n done_when: [won, lost]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: apply\n who: workers\n wants: a job\n lands_in: applicants\n handled_by: pms\n stages: [open, hired]\n done_when: [hired]\n max_fields: 1\n collects:\n - { name: name }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
// The front door: the opener, then the door, then the direct need.
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let names: Vec<&str> = front["alternatives"].as_array().unwrap().iter().map(|a| a["name"].as_str().unwrap()).collect();
assert_eq!(names, ["What we do", "Your building", "a job"]);
assert_eq!(front["alternatives"][1]["action"], "/your-building");
assert!(front["alternatives"][1].get("record_as").is_none());
// The door's page holds both forms and is not gated.
let door: Value = serde_yaml::from_str(&files["questions/your-building/index.yaml"]).unwrap();
assert_eq!(door["alternatives"].as_array().unwrap().len(), 2);
assert_eq!(door["alternatives"][0]["record_as"], "enquiries");
assert!(!files.contains_key("questions/your-building/_section.yaml"));
// The spec pins the door as the way in, two submissions deep.
let needs: Value = serde_yaml::from_str(&files["needs.yaml"]).unwrap();
assert_eq!(needs["needs"][0]["via"], "Your building");
assert_eq!(needs["needs"][0]["max_steps"], 2);
assert_eq!(needs["needs"][2]["max_steps"], 1);
assert_eq!(plan.front_door_count(), 2);
// A door nobody is behind, and a door that is also a group, are
// refused. A door on a need that enters as a group is the way
// to that group's page: a card on the door, the form behind
// sign-in, and the spec entering on the group's page.
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 members: Plan = serde_yaml::from_str(&yaml.replacen(" door: your-building\n", " door: your-building\n enters_as: pms\n", 1)).unwrap();
members.validate().unwrap();
let files = render(&members, &fallback_words(&members)).unwrap();
let door: Value = serde_yaml::from_str(&files["questions/your-building/index.yaml"]).unwrap();
assert_eq!(door["alternatives"][0]["action"], "/pms/survey");
assert!(door["alternatives"][0].get("record_as").is_none());
let page: Value = serde_yaml::from_str(&files["questions/pms/survey.yaml"]).unwrap();
assert_eq!(page["alternatives"][0]["record_as"], "enquiries");
let needs: Value = serde_yaml::from_str(&files["needs.yaml"]).unwrap();
assert_eq!(needs["needs"][0]["enters"], "/pms/survey");
assert_eq!(needs["needs"][0]["enters_as"], "pms");
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"], "sort_by(.submitted_ms) | reverse | .[] | .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_stage_can_grant_a_group_a_clock_can_move_a_case_and_a_public_list_can_show_some_stages() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: post@b.example }\nneeds:\n - id: komite\n who: en som vil sitte i en komité\n wants: en plass\n lands_in: soknader\n handled_by: styret\n stages: [open, plassert, avslatt]\n done_when: [plassert, avslatt]\n grants: { plassert: komiteer }\n max_fields: 3\n collects:\n - { name: name }\n - { name: email, type: email }\n - id: oppslag\n who: en nabo\n wants: et oppslag\n lands_in: oppslag\n handled_by: styret\n stages: [open, lagt_ut, tatt_ned]\n done_when: [tatt_ned]\n watched_by: [public]\n public_states: [lagt_ut]\n deadlines: { lagt_ut: { days: 30, to: tatt_ned } }\n max_fields: 2\n collects:\n - { name: hva, type: textarea }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let agg: Value = serde_yaml::from_str(&files["aggregates.yaml"]).unwrap();
let bucket = |name: &str| agg["aggregates"].as_array().unwrap().iter().find(|a| a["bucket"] == name).unwrap().clone();
assert_eq!(bucket("soknader")["states"]["plassert"]["grants"], "komiteer");
assert_eq!(bucket("oppslag")["states"]["lagt_ut"]["after"], json!({"days": 30, "to": "tatt_ned"}));
let view: Value = serde_yaml::from_str(&files["questions/status/oppslag/index.yaml"]).unwrap();
let resource = &view["alternatives"][0]["features"][0]["resource"];
assert_eq!(resource["states"], json!(["lagt_ut"]));
assert_eq!(resource["empty"], "Nothing here yet.");
// A new member's sign-in link goes by mail: the site writes its
// invite mail, and a reword that loses the link gets it back.
let site: Value = serde_yaml::from_str(&files["site.yaml"]).unwrap();
assert!(s(&site["mail"]["invite"]["body"]).contains("{link}"));
let mut words = fallback_words(&plan);
words.insert("site.yaml".into(), json!({ "invite": { "subject": "Velkommen", "body": "Hei {name}, du er med i {group}." } }));
keep_invite_link("no", words.get_mut("site.yaml").unwrap());
let body = s(&words["site.yaml"]["invite"]["body"]).to_string();
assert!(body.starts_with("Hei {name}, du er med i {group}.") && body.contains("{link}") && body.contains("{expires}"), "{body}");
// Without a sender, a grant is refused: the link would reach nobody.
let no_sender: Plan = serde_yaml::from_str(&yaml.replace("mail: { from: post@b.example }\n", "")).unwrap();
assert!(no_sender.validate().unwrap_err().to_string().contains("reaches nobody"));
// A grant needs the person's name and address on the form.
let no_name: Plan = serde_yaml::from_str(&yaml.replace(" - { name: name }\n", "")).unwrap();
assert!(no_name.validate().unwrap_err().to_string().contains("required `name`"));
// A clock may only make a move the machine has.
let off_graph: Plan = serde_yaml::from_str(&yaml.replace("{ days: 30, to: tatt_ned }", "{ days: 30, to: open }")).unwrap();
assert!(off_graph.validate().unwrap_err().to_string().contains("not a move"));
// public_states without a public list says nothing.
let not_public: Plan = serde_yaml::from_str(&yaml.replace(" watched_by: [public]\n", "")).unwrap();
assert!(not_public.validate().unwrap_err().to_string().contains("no `public`"));
}
#[test]
fn a_form_that_asks_more_than_the_person_will_tolerate_is_refused() {
// Two required fields under a budget of one: the plan is refused
// before any page exists, and the message says what to do.
let plan: Plan = serde_yaml::from_str(&PLAN.replace("max_fields: 4", "max_fields: 1")).unwrap();
let err = plan.validate().unwrap_err().to_string();
assert!(err.contains("2 required field(s)") && err.contains("tolerate 1"), "{err}");
// A budget of zero is not a budget.
let plan: Plan = serde_yaml::from_str(&PLAN.replace("max_fields: 3", "max_fields: 0")).unwrap();
assert!(plan.validate().unwrap_err().to_string().contains("at least 1"));
// The budget is what the spec hands the verifier, not the form's size.
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
assert!(files["needs.yaml"].contains("max_fields: 4"));
assert!(files["needs.yaml"].contains("max_fields: 3"));
// A plan that says nothing about tolerance is not a plan.
assert!(serde_yaml::from_str::<Plan>(&PLAN.replace(" max_fields: 4\n", "")).is_err());
}
#[test]
fn a_plan_with_a_bad_identifier_or_a_disagreeing_bucket_is_refused() {
let bad: Plan = serde_yaml::from_str(&PLAN.replace("lost]", "avslått]")).unwrap();
assert!(bad.validate().unwrap_err().to_string().contains("ASCII"));
let clash = format!("{PLAN} - id: other\n who: w\n wants: x\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, won]\n done_when: [won]\n max_fields: 1\n collects: []\n");
let plan: Plan = serde_yaml::from_str(&clash).unwrap();
assert!(plan.validate().unwrap_err().to_string().contains("must agree"));
}
}
/// The words a repo already carries, lifted back into the words map by
/// structure: the plan is rendered with a token in every word slot,
/// each rendered file is walked alongside the repo's file of the same
/// path, and wherever a token meets a string, that string is the word.
/// Pages the repo does not have, and slots it never had (a new invite
/// form), keep their fallback. Hand-edited wording survives a rebuild.
pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result<BTreeMap<String, Value>> {
let fallback = fallback_words(plan);
let tokened: BTreeMap<String, Value> =
fallback.iter().map(|(path, v)| (path.clone(), tokenize(v, path, ""))).collect();
let rendered = render(plan, &tokened)?;
let mut found: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
for (path, text) in &rendered {
// The spec is derived from the words (a need's `via` is its
// heading), never a source of them: behind a door it named the
// door, and a positional match would hand that to the form.
if path == "needs.yaml" {
continue;
}
let Ok(live) = std::fs::read_to_string(repo.join(path)) else { continue };
let (Ok(a), Ok(b)) = (
serde_yaml::from_str::<serde_yaml::Value>(text),
serde_yaml::from_str::<serde_yaml::Value>(&live),
) else {
continue;
};
collect_words(&a, &b, &mut found);
}
// A second pass across the whole repo: a card that moved page (a
// door taken away, a need brought onto the front door) is still
// the same card, and keeps its words. Every alternative the repo
// has goes into one pool; a rendered card none of whose slots
// were found on its own page takes the pool's card with the same
// strong identity - the bucket it records into, the page it leads
// to, the group it invites into - never a bare `action` like `/`.
let mut pool: Vec<serde_yaml::Value> = Vec::new();
for live in yaml_files(&repo.join("questions")) {
let Ok(page) = serde_yaml::from_str::<serde_yaml::Value>(&live) else { continue };
if let Some(alts) = page.get("alternatives").and_then(|a| a.as_sequence()) {
pool.extend(alts.iter().cloned());
}
}
let mut used = vec![false; pool.len()];
for text in rendered.values() {
let Ok(page) = serde_yaml::from_str::<serde_yaml::Value>(text) else { continue };
let Some(alts) = page.get("alternatives").and_then(|a| a.as_sequence()) else { continue };
for alt in alts {
let ida: Vec<(&str, String)> = identity(alt).into_iter().filter(|(k, v)| strong_identity(k, v)).collect();
if ida.is_empty() || tokens_in(alt).iter().any(|t| found.contains_key(t)) {
continue;
}
if let Some(j) = (0..pool.len()).find(|&j| !used[j] && identity(&pool[j]).iter().any(|k| ida.contains(k))) {
used[j] = true;
collect_words(alt, &pool[j], &mut found);
}
}
}
Ok(fallback.iter().map(|(path, v)| (path.clone(), untokenize(v, path, "", &found))).collect())
}
/// An identity that survives a move between pages: what a card does,
/// not where a nav link points. `/received/<need>` and `/status/…`
/// are one need's own pages; `/`, `/owners` and the like are shared
/// by every "back" and every desk card.
fn strong_identity(key: &str, value: &str) -> bool {
match key {
"action" => value.starts_with("/received/") || value.starts_with("/status/") || value.starts_with("/decide/"),
"name" => false,
_ => true,
}
}
/// Every token slot inside a rendered item.
fn tokens_in(v: &serde_yaml::Value) -> Vec<String> {
let mut out = Vec::new();
match v {
serde_yaml::Value::String(s) if s.starts_with('\u{1}') => out.push(s.clone()),
serde_yaml::Value::Mapping(m) => out.extend(m.values().flat_map(tokens_in)),
serde_yaml::Value::Sequence(s) => out.extend(s.iter().flat_map(tokens_in)),
_ => {}
}
out
}
/// The text of every `.yaml` under `dir`, any depth.
fn yaml_files(dir: &Path) -> Vec<String> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else { return out };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(yaml_files(&path));
} else if path.extension().is_some_and(|e| e == "yaml") {
if let Ok(text) = std::fs::read_to_string(&path) {
out.push(text);
}
}
}
out
}
/// What identifies an item of a page regardless of its position: the
/// structural strings portal itself keys on. Word slots (tokens on
/// the rendered side) are never an identity.
fn identity(v: &serde_yaml::Value) -> Vec<(&'static str, String)> {
const KEYS: &[&[&str]] = &[
&["bucket"],
&["record_as"],
&["action"],
&["name"],
&["invite", "group"],
&["resource", "source", "bucket"],
&["self_transition", "bucket"],
];
let mut id = Vec::new();
for path in KEYS {
let mut at = v;
for key in path.iter() {
match at.get(*key) {
Some(next) => at = next,
None => {
at = &serde_yaml::Value::Null;
break;
}
}
}
if let Some(s) = at.as_str() {
if !s.starts_with('\u{1}') && !s.is_empty() {
id.push((path[0], s.to_string()));
}
}
}
// A desk card is its list: the bucket its feature reads is what
// the card is about, and the only thing that tells two cards on
// one desk apart, since they all lead back to the desk.
if let Some(features) = v.get("features").and_then(|f| f.as_sequence()) {
for feature in features {
if let Some(bucket) = feature.get("resource").and_then(|r| r.get("source")).and_then(|s| s.get("bucket")).and_then(|b| b.as_str()) {
if !bucket.starts_with('\u{1}') && !bucket.is_empty() {
id.push(("bucket", bucket.to_string()));
}
}
}
}
id
}
fn token(path: &str, pointer: &str) -> String {
format!("\u{1}{path}\u{2}{pointer}\u{3}")
}
fn tokenize(v: &Value, path: &str, pointer: &str) -> Value {
match v {
Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), tokenize(x, path, &format!("{pointer}/{k}")))).collect()),
Value::String(_) => Value::String(token(path, pointer)),
other => other.clone(),
}
}
fn untokenize(v: &Value, path: &str, pointer: &str, found: &std::collections::HashMap<String, Value>) -> 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) => word.clone(),
None => v.clone(),
},
other => other.clone(),
}
}
fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::collections::HashMap<String, Value>) {
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.
// A live list of lists under a rendered list of tokens is the
// storylines of a button list (`consequence: [[Send], [Meld meg]]`):
// each token gets one word per storyline.
(serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y))
if x.iter().all(|t| t.as_str().is_some_and(|t| t.starts_with('\u{1}'))) && !y.is_empty() && y.iter().all(|v| v.as_sequence().is_some_and(|l| l.iter().all(|w| w.as_str().is_some()))) =>
{
for (k, t) in x.iter().enumerate() {
let words: Vec<&str> = y.iter().filter_map(|l| l.as_sequence()?.get(k)?.as_str()).collect();
if words.len() == y.len() && words.iter().all(|w| !w.trim().is_empty()) {
found.insert(t.as_str().unwrap_or_default().to_string(), Value::Array(words.iter().map(|w| json!(w)).collect()));
}
}
}
(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);
// What a card does is matched before where it points:
// on a desk every card's action is the desk itself, so
// a card with a bucket or a group of its own must not
// take the words of whichever card shares that action
// - when the vel's notices left the board's desk, the
// invite after them was handed their heading that way.
let strong: Vec<(&str, String)> = ida.iter().filter(|(k, v)| strong_identity(k, v)).cloned().collect();
let matching = |keys: &[(&str, String)]| (0..y.len()).find(|&j| !used[j] && identity(&y[j]).iter().any(|k| keys.contains(k)));
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 if !strong.is_empty() {
matching(&strong)
} else {
matching(&ida)
};
if let Some(j) = hit {
used[j] = true;
collect_words(va, &y[j], found);
}
}
}
(serde_yaml::Value::String(t), serde_yaml::Value::String(live)) if t.starts_with('\u{1}') => {
// An icon slot holds the bare name; the file carries `lucide:`.
let word = if t.ends_with("/icon\u{3}") { live.trim_start_matches("lucide:") } else { live.as_str() };
// A lift carries words across, not absences: where the live
// page said nothing, the fallback stands. Otherwise a slot
// that has since been given something to say would be
// emptied again by every rebuild.
if !word.trim().is_empty() {
found.insert(t.clone(), json!(word));
}
}
// A live list of strings under one token: the slot's storylines.
(serde_yaml::Value::String(t), serde_yaml::Value::Sequence(live)) if t.starts_with('\u{1}') && !live.is_empty() && live.iter().all(|w| w.as_str().is_some_and(|w| !w.trim().is_empty())) => {
found.insert(t.clone(), Value::Array(live.iter().map(|w| json!(w.as_str().unwrap_or_default())).collect()));
}
_ => {}
}
}
#[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);
}
const BOARD: &str = "business: Vel\ncontact: { name: S, email: s@v.example }\nmail: { from: v@v.example }\nlanguage: no\nneeds:\n - id: notice\n who: en nabo som har noe å låne ut\n wants: et oppslag naboene ser\n lands_in: oppslag\n handled_by: redaktor\n stages: [open, tatt_ned]\n done_when: [tatt_ned]\n moves_by: { tatt_ned: [submitter, redaktor] }\n mails: { open: s@v.example }\n watched_by: [public]\n public_states: [open]\n max_fields: 3\n collects:\n - { name: email, type: email }\n - { name: hva, type: textarea }\n - id: answer\n who: en nabo som kan låne ut det\n wants: at den som la ut oppslaget får vite det\n about: notice\n lands_in: svar\n handled_by: redaktor\n stages: [open, lest]\n done_when: [lest]\n mails: { open: owner }\n max_fields: 1\n collects:\n - { name: email, type: email }\n";
#[test]
fn a_contact_that_is_a_group_answers_for_every_page_as_that_group() {
let plan: Plan = serde_yaml::from_str(&BOARD.replace("contact: { name: S, email: s@v.example }", "contact: { name: S, email: s@v.example, group: redaktor }")).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
assert_eq!(front["responsible"]["group"], "redaktor");
assert_eq!(front["responsible"]["contact"], "s@v.example");
let nobody: Plan = serde_yaml::from_str(&BOARD.replace("contact: { name: S, email: s@v.example }", "contact: { name: S, email: s@v.example, group: nobody }")).unwrap();
assert!(nobody.validate().unwrap_err().to_string().contains("no desk"));
}
#[test]
fn two_tones_become_two_storylines_that_render_lift_and_render_again() {
let plan: Plan = serde_yaml::from_str(&PLAN.replace("business: Vel", "business: Vel\ntones:\n - plain, like a notice on the door\n - warm, as a neighbour would put it")).unwrap();
plan.validate().unwrap();
// The words as two tones told them, folded: agreed slots stay
// one string, others become the list.
let mut words = fallback_words(&plan);
let page = words["questions/index.yaml"].clone();
let mut warm = page.clone();
warm["needs"]["report"]["heading"] = json!("Say where it is dangerous");
warm["needs"]["report"]["button"] = json!("Tell us");
let folded = fold_tones(&[page.clone(), warm]);
assert_eq!(folded["needs"]["report"]["heading"], json!([page["needs"]["report"]["heading"], "Say where it is dangerous"]));
assert_eq!(folded["question"], page["question"], "a slot the tones agree on stays one string");
words.insert("questions/index.yaml".into(), folded);
// Rendered, a list is a list, and a button list a list of lists.
let files = render(&plan, &words).unwrap();
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let card = front["alternatives"].as_array().unwrap().iter().find(|a| a["record_as"] == "hazards").unwrap();
assert_eq!(card["name"], json!([page["needs"]["report"]["heading"], "Say where it is dangerous"]));
assert_eq!(card["consequence"], json!([[page["needs"]["report"]["button"]], ["Tell us"]]));
assert!(files["needs.yaml"].contains("tones:"));
// Lifted back out of the rendered site, the storylines survive.
let repo = std::env::temp_dir().join(format!("trainer-tones-{}", std::process::id()));
for (path, text) in &files {
let full = repo.join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, text).unwrap();
}
let lifted = lift_words(&plan, &repo).unwrap();
assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["heading"], json!([page["needs"]["report"]["heading"], "Say where it is dangerous"]));
assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["button"], json!([page["needs"]["report"]["button"], "Tell us"]));
assert_eq!(render(&plan, &lifted).unwrap()["questions/index.yaml"], files["questions/index.yaml"]);
let _ = std::fs::remove_dir_all(&repo);
// An empty tone says nothing.
let blank: Plan = serde_yaml::from_str(&PLAN.replace("business: Vel", "business: Vel\ntones: [\"\"]")).unwrap();
assert!(blank.validate().unwrap_err().to_string().contains("tones"));
}
#[test]
fn a_document_is_a_page_with_one_card_per_section_word_for_word() {
let text = "# Vedtekter\n\n### av 23.04.2018\n\n##### § 1\n\nTomter vel er upolitisk. Foreningen har som\nformål å virke for stedets velferd.\n\n##### § 4.\n\nEkstraordinært årsmøte holdes når styret krever det.\n\nGjennomføringen følger samme prosedyre.\n";
let sections = sections_of(text);
// Titles with nothing under them are not sections; a wrapped
// paragraph is one paragraph; the words are untouched.
assert_eq!(sections.len(), 2);
assert_eq!(sections[0], Section { heading: "§ 1".into(), text: vec!["Tomter vel er upolitisk. Foreningen har som formål å virke for stedets velferd.".into()] });
assert_eq!(sections[1].heading, "§ 4");
assert_eq!(sections[1].text.len(), 2);
let mut plan: Plan = serde_yaml::from_str(&format!("{PLAN}documents:\n - id: vedtekter\n question: Hva sier vedtektene?\n description: Vedtatt 23.04.2018.\n")).unwrap();
assert!(plan.validate().unwrap_err().to_string().contains("no sections"));
plan.documents[0].sections = sections;
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let page: Value = serde_yaml::from_str(&files["questions/vedtekter.yaml"]).unwrap();
assert_eq!(page["name"], "Hva sier vedtektene?");
let alts = page["alternatives"].as_array().unwrap();
assert_eq!(alts.len(), 2);
assert_eq!(alts[0]["name"], "§ 1");
assert_eq!(alts[0]["description"], "Tomter vel er upolitisk. Foreningen har som formål å virke for stedets velferd.");
assert!(alts[0].get("action").is_none() && alts[0].get("features").is_none());
assert_eq!(alts[1]["features"][0]["description"], "Gjennomføringen følger samme prosedyre.");
// A path something else has is refused.
plan.documents[0].id = "status".into();
assert!(plan.validate().unwrap_err().to_string().contains("document"));
}
#[test]
fn with_memberships_a_desk_invites_by_phone_and_lists_who_is_in() {
let plan: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]\n", "invites_by: [board]\npeople: memberships\n")).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let board: Value = serde_yaml::from_str(&files["questions/board/index.yaml"]).unwrap();
let alts = board["alternatives"].as_array().unwrap();
let invite = alts.iter().find(|a| a["invite"]["group"] == "traffic").unwrap();
let fields: Vec<(&str, &str)> = invite["features"][0]["requirements"].as_array().unwrap().iter().map(|r| (r["name"].as_str().unwrap(), r["type"].as_str().unwrap_or("text"))).collect();
assert_eq!(fields, [("name", "text"), ("phone", "tel"), ("email", "email")]);
assert_eq!(invite["features"][0]["requirements"][2]["optional"], true);
let list = alts.iter().find(|a| a["features"][0]["resource"]["source"]["bucket"] == "portal_memberships").unwrap();
let moves: Vec<(&str, &str)> = list["features"][0]["resource"]["transitions"].as_array().unwrap().iter().map(|t| (t["from"].as_str().unwrap(), t["to"].as_str().unwrap())).collect();
assert_eq!(moves, [("invited", "removed"), ("active", "removed"), ("removed", "invited")]);
assert!(!files["questions/board/index.yaml"].contains("portal_invites"));
// A grant may know the person by phone number instead of address.
let by_phone: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]\n", "invites_by: [board]\npeople: memberships\nmail: { from: v@v.example }\n").replace(" stages: [open, betalt, avvist]\n", " grants: { betalt: traffic }\n stages: [open, betalt, avvist]\n").replace(" - { name: amount }\n", " - { name: amount }\n - { name: name }\n - { name: phone, type: tel }\n")).unwrap();
by_phone.validate().unwrap();
let nobody: Plan = serde_yaml::from_str(&PLAN.replace("business: Vel", "business: Vel\npeople: ldap")).unwrap();
assert!(nobody.validate().unwrap_err().to_string().contains("people must be"));
}
#[test]
fn a_case_left_too_long_goes_up_to_another_desk_with_the_same_buttons() {
let plan: Plan = serde_yaml::from_str(&PLAN.replace(" handled_by: traffic\n stages: [open, fixed]\n", " handled_by: traffic\n escalate: { open: { days: 7, to: board } }\n stages: [open, fixed]\n")).unwrap();
plan.validate().unwrap();
// The machine: the clock's move in, the same moves out.
let need = plan.needs.iter().find(|n| n.id == "report").unwrap();
let m = machine(need);
assert_eq!(m["open"], ["fixed", "open_escalated"]);
assert_eq!(m["open_escalated"], ["fixed"]);
assert_eq!(all_stages(need), ["open", "open_escalated", "fixed"]);
let files = render(&plan, &fallback_words(&plan)).unwrap();
// The clock moves it up after seven days.
let agg: Value = serde_yaml::from_str(&files["aggregates.yaml"]).unwrap();
let hazards = agg["aggregates"].as_array().unwrap().iter().find(|a| a["bucket"] == "hazards").unwrap();
assert_eq!(hazards["states"]["open"]["after"], json!({ "days": 7, "to": "open_escalated" }));
assert!(hazards["states"]["open_escalated"].is_object());
// The board's desk lists the escalated cases only, and may finish them.
let board: Value = serde_yaml::from_str(&files["questions/board/index.yaml"]).unwrap();
let card = board["alternatives"].as_array().unwrap().iter().find(|a| a["features"][0]["resource"]["source"]["bucket"] == "hazards").unwrap();
let resource = &card["features"][0]["resource"];
assert_eq!(resource["states"], json!(["open_escalated"]));
assert_eq!(resource["transitions"][0]["from"], "open_escalated");
assert_eq!(resource["transitions"][0]["to"], "fixed");
// Traffic's own desk still has the case, with everything, and may
// send it up itself.
let traffic: Value = serde_yaml::from_str(&files["questions/traffic/index.yaml"]).unwrap();
let card = traffic["alternatives"].as_array().unwrap().iter().find(|a| a["features"][0]["resource"]["source"]["bucket"] == "hazards").unwrap();
let tos: Vec<&str> = card["features"][0]["resource"]["transitions"].as_array().unwrap().iter().map(|t| t["to"].as_str().unwrap()).collect();
assert!(tos.contains(&"open_escalated") && tos.contains(&"fixed"), "{tos:?}");
assert!(card["features"][0]["resource"].get("states").is_none());
// The spec knows the stage, in words.
assert!(files["needs.yaml"].contains("open_escalated: escalated to board"));
// A stage cannot both end and go up on the clock.
let both: Plan = serde_yaml::from_str(&PLAN.replace(" handled_by: traffic\n stages: [open, fixed]\n", " handled_by: traffic\n escalate: { open: { days: 7, to: board } }\n deadlines: { open: { days: 30, to: fixed } }\n stages: [open, fixed]\n")).unwrap();
assert!(both.validate().unwrap_err().to_string().contains("not both"));
}
#[test]
fn a_need_about_another_is_answered_from_the_boards_own_rows() {
let plan: Plan = serde_yaml::from_str(BOARD).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
// The board links every row to the record's own page, on the
// front door and on the full list alike.
let board: Value = serde_yaml::from_str(&files["questions/status/oppslag/index.yaml"]).unwrap();
let link = &board["alternatives"][0]["features"][0]["resource"]["link"];
assert_eq!(link["to"], "/status/oppslag/{id}");
assert_eq!(link["label"], "At den som la ut oppslaget får vite det");
assert!(files["questions/index.yaml"].contains("to: /status/oppslag/{id}"));
// The page per record: a dynamic page holding the form, which
// carries the record's id and takes a signed-in person's address.
let page: Value = serde_yaml::from_str(&files["questions/status/oppslag/[key].yaml"]).unwrap();
let alt = &page["alternatives"][0];
assert_eq!(alt["record_as"], "svar");
assert_eq!(alt["action"], "/received/answer");
let reqs = alt["features"].as_array().unwrap().last().unwrap()["requirements"].as_array().unwrap();
assert_eq!(reqs[0]["value"], "{user.email}");
// Two needs about the same record share its page, one form each.
let two: Plan = serde_yaml::from_str(&format!("{BOARD} - id: borrow\n who: en nabo som vil låne det\n wants: å låne det\n about: notice\n lands_in: laan\n handled_by: redaktor\n stages: [open, lest]\n done_when: [lest]\n max_fields: 1\n collects:\n - {{ name: email, type: email }}\n")).unwrap();
two.validate().unwrap();
let files = render(&two, &fallback_words(&two)).unwrap();
let page: Value = serde_yaml::from_str(&files["questions/status/oppslag/[key].yaml"]).unwrap();
let buckets: Vec<&str> = page["alternatives"].as_array().unwrap().iter().map(|a| a["record_as"].as_str().unwrap()).collect();
assert_eq!(buckets, ["svar", "laan"]);
assert_eq!(reqs[1]["type"], "record");
assert_eq!(reqs[1]["of"], "oppslag");
assert_eq!(reqs[1]["value"], "{key}");
// The answer is not on the front door, and its mail reaches
// the poster through the record field.
assert!(!files["questions/index.yaml"].contains("record_as: svar"));
assert!(files["aggregates.yaml"].contains("to: '{oppslag.email}'"), "{}", files["aggregates.yaml"]);
// The verifier is told where an answer enters.
let needs: Value = serde_yaml::from_str(&files["needs.yaml"]).unwrap();
assert_eq!(needs["needs"][1]["enters"], "/status/oppslag/[key]");
// A need that answers one nobody can see is refused, and so is
// owner mail with nobody to send it to.
let hidden: Plan = serde_yaml::from_str(&BOARD.replace(" watched_by: [public]\n", "")).unwrap();
assert!(hidden.validate().unwrap_err().to_string().contains("watched_by"));
let orphan: Plan = serde_yaml::from_str(&BOARD.replace(" about: notice\n", "")).unwrap();
assert!(orphan.validate().unwrap_err().to_string().contains("owner"));
}
#[test]
fn a_card_that_left_a_desk_does_not_leave_its_words_on_the_next_card() {
// Live: the board handles expenses, and its card has words of
// its own. Then the plan moves expenses to traffic's desk: the
// invite that now sits where the card sat must not take them.
let live: Plan = serde_yaml::from_str(PLAN).unwrap();
let mut words = fallback_words(&live);
words.get_mut("questions/board/index.yaml").unwrap()["buckets"]["expenses"]["heading"] = json!("Money people want back");
let repo = std::env::temp_dir().join(format!("trainer-moved-{}", std::process::id()));
for (path, text) in render(&live, &words).unwrap() {
let full = repo.join(&path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, text).unwrap();
}
// Expenses go to traffic's desk; the board watches hazards
// instead, so its desk stays, with another bucket's card where
// the expenses card was.
let moved: Plan = serde_yaml::from_str(&PLAN.replace(" handled_by: board\n", " handled_by: traffic\n").replace(" handled_by: traffic\n stages: [open, fixed]", " handled_by: traffic\n watched_by: [board]\n stages: [open, fixed]")).unwrap();
moved.validate().unwrap();
let lifted = lift_words(&moved, &repo).unwrap();
let board = &lifted["questions/board/index.yaml"];
assert!(board["buckets"].get("expenses").is_none());
assert_ne!(board["buckets"]["hazards"]["heading"], "Money people want back", "the hazards card took the moved card's heading");
for (group, invite) in board["invites"].as_object().unwrap() {
assert_ne!(invite["heading"], "Money people want back", "the invite into {group} took the moved card's heading");
}
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"));
// An invite whose link ran out can be sent again, from any desk
// that lists them.
assert!(board.contains("from: expired") && board.contains("label: Send again"), "{board}");
let traffic = &files["questions/traffic/index.yaml"];
assert_eq!(traffic.matches("invite:").count(), 1);
assert!(traffic.contains("label: Send again"));
// Suggested changes are settled at one desk: the one that
// invites into the most groups.
assert!(board.contains("bucket: portal_edit_suggestions") && board.contains("to: discussed"), "{board}");
assert!(!traffic.contains("portal_edit_suggestions"));
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);
}
}