2026-09-21 21:01:35 +02:00
//! Structure from the plan, words from the model.
//!
//! A `plan.yaml` is a needs spec that also says what each form collects.
//! From it, with no model involved, come `needs.yaml`,
//! `aggregates.yaml`, `site.yaml`, the front door, a followup page per
//! need, a directory per group (gated by `_section.yaml`) holding its
//! desk and the pages only that group may open, and every desk button
//! the state machine allows. What the business said determines all of
//! that; a model has nothing to decide there and much to get wrong
//! (measured: a dropped stage from a strong model, no loadable site at
//! all from a small one).
//!
//! The model writes the words: one small JSON object per page - the
//! question, headings, descriptions, field and button labels - in the
//! plan's language. Words are merged over built-in fallbacks key by
//! key, so a reply that is missing, malformed or partial costs wording,
//! never a working site.
use std ::collections ::{ BTreeMap , BTreeSet };
use std ::path ::Path ;
use anyhow ::{ anyhow , bail , Context };
use serde ::Deserialize ;
use serde_json ::{ json , Map , Value };
use crate ::simulate ::first_object ;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plan {
pub business : String ,
#[serde(default = "english" )]
pub language : String ,
pub contact : Contact ,
/// What the business does, in its own words - context for the
/// model's wording, never structure.
#[serde(default)]
pub about : String ,
pub needs : Vec < PlanNeed > ,
#[serde(default)]
pub personas : Vec < Value > ,
}
fn english () -> String {
"en" . to_string ()
}
#[derive(Debug, Deserialize)]
pub struct Contact {
pub name : String ,
pub email : 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 > ,
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 > ,
pub collects : Vec < Field > ,
#[serde(default)]
pub max_fields : Option < usize > ,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Field {
pub name : String ,
#[serde(default = "text" , rename = "type" )]
pub kind : String ,
#[serde(default)]
pub optional : bool ,
#[serde(default)]
pub options : Vec < String > ,
}
fn text () -> String {
"text" . to_string ()
}
const FIELD_TYPES : & [ & str ] = & [ "text" , "email" , "tel" , "textarea" , "date" , "select" ];
fn is_id ( s : & str ) -> bool {
! s . is_empty () && s . chars (). all ( | c | c . is_ascii_lowercase () || c . is_ascii_digit () || c == '_' || c == '-' )
}
impl Plan {
pub fn validate ( & self ) -> anyhow ::Result < () > {
let mut 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" ));
}
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 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" ));
}
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 )));
}
if field . kind == "select" && field . options . is_empty () {
bail! ( at ( & format! ( "field {:?} is a select and lists no options" , 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 {
bail! ( "needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when and skippable" , first . id , other . id );
}
}
}
Ok (())
}
fn by_bucket ( & self ) -> BTreeMap <& str , Vec <& PlanNeed >> {
let mut map : BTreeMap <& str , Vec <& PlanNeed >> = BTreeMap ::new ();
for need in & self . needs {
map . entry ( & need . lands_in ). or_default (). push ( need );
}
map
}
}
/// The moves out of every unfinished stage. The unfinished stages form
/// the line a case walks; from each, the next stage, plus whatever lies
/// beyond a run of skippable ones, plus every finished state that is
/// not a success (a case can be turned down at any point). Success is
/// only reachable from the end of the line.
pub fn machine ( need : & PlanNeed ) -> BTreeMap < String , Vec < String >> {
let line : Vec <& String > = need . stages . iter (). filter ( | s | ! need . done_when . contains ( s )). collect ();
let ends_well : Vec <& String > = if need . success . is_empty () { need . done_when . iter (). collect () } else { need . success . iter (). collect () };
let ends_badly : Vec <& String > = need . done_when . iter (). filter ( | s | ! ends_well . contains ( s )). collect ();
let mut moves = BTreeMap ::new ();
for ( i , stage ) in line . iter (). enumerate () {
let mut to : Vec < String > = Vec ::new ();
let mut j = i + 1 ;
loop {
match line . get ( j ) {
Some ( next ) => {
to . push (( * next ). clone ());
if ! need . skippable . contains ( next ) {
break ;
}
j += 1 ;
}
None => {
to . extend ( ends_well . iter (). map ( | s | ( * s ). clone ()));
break ;
}
}
}
to . extend ( ends_badly . iter (). map ( | s | ( * s ). clone ()));
moves . insert (( * stage ). clone (), to );
}
moves
}
fn humanize ( id : & str ) -> String {
let mut words = id . replace ([ '_' , '-' ], " " );
if let Some ( first ) = words . get_mut ( 0 .. 1 ) {
first . make_ascii_uppercase ();
}
words
}
fn public_followup ( need : & PlanNeed ) -> String {
format! ( "/received/ {} " , need . id )
}
fn internal_page ( need : & PlanNeed , group : & str ) -> String {
format! ( "/ {group} / {} " , need . id )
}
/// The words every page needs, as built-in fallbacks: plain, English,
/// made from the plan's own text. Keyed by file path, then the same
/// JSON shape the model is asked for.
pub fn fallback_words ( plan : & Plan ) -> BTreeMap < String , Value > {
let form = | need : & PlanNeed | {
json! ({
"heading" : need . wants ,
"description" : format ! ( "For {}." , need . who ),
"button" : "Send" ,
"form_title" : "Details" ,
"labels" : need . collects . iter (). map ( | f | ( f . name . clone (), Value ::String ( humanize ( & f . name )))). collect ::< Map < _ , _ >> (),
})
};
let mut words = BTreeMap ::new ();
let public : Vec <& PlanNeed > = plan . needs . iter (). filter ( | n | n . enters_as . is_none ()). collect ();
words . insert (
"questions/index.yaml" . to_string (),
json! ({
"question" : "What brings you here?" ,
"description" : plan . about ,
"needs" : public . iter (). map ( | n | ( n . id . clone (), form ( n ))). collect ::< Map < _ , _ >> (),
}),
);
for need in & plan . needs {
let ( path , back ) = match & need . enters_as {
None => ( format! ( "questions/received/ {} .yaml" , need . id ), "/" . to_string ()),
Some ( group ) => ( format! ( "questions/ {group} / {} -sent.yaml" , need . id ), format! ( "/ {group} " )),
};
let _ = back ;
words . insert (
path ,
json! ({
"question" : "What happens now?" ,
"description" : format ! ( "It has reached our {}." , humanize ( & need . handled_by ). to_lowercase ()),
"next_heading" : "We read it and come back to you" ,
"next_description" : format ! ( "Questions in the meantime go to {} at {}." , plan . contact . name , plan . contact . email ),
"back" : "Back" ,
}),
);
if 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 ) | needs . iter (). any ( | n | n . handled_by == group ))
. map ( | ( bucket , needs ) | {
let moves : Map < String , Value > = machine ( needs [ 0 ])
. into_iter ()
. flat_map ( | ( from , tos ) | tos . into_iter (). map ( move | to | ( format! ( " {from} -> {to} " ), Value ::String ( humanize ( & to )))))
. collect ();
( bucket . to_string (), json! ({ "heading" : humanize ( bucket ), "description" : "" , "moves" : moves }))
})
. collect ();
let links : Map < String , Value > = plan
. needs
. iter ()
. filter ( | n | n . enters_as . as_deref () == Some ( group . as_str ()))
. map ( | n | ( n . id . clone (), json! ({ "heading" : n . wants , "description" : "" , "button" : "Open" })))
. collect ();
words . insert (
format! ( "questions/ {group} /index.yaml" ),
json! ({ "question" : "What needs doing?" , "description" : "" , "confirm" : "Confirm" , "buckets" : buckets , "links" : links }),
);
}
words
}
fn groups ( plan : & Plan ) -> BTreeSet < String > {
plan . needs . iter (). flat_map ( | n | [ Some ( n . handled_by . clone ()), n . enters_as . clone ()]). flatten (). collect ()
}
/// Takes the model's strings where it gave them, key by key, and keeps
/// the fallback everywhere else. The shape always comes from the
/// fallback, so a reply can never add, drop or restructure anything.
pub fn merge_words ( fallback : & Value , given : & Value ) -> Value {
match ( fallback , given ) {
( Value ::Object ( f ), Value ::Object ( g )) => {
Value ::Object ( f . iter (). map ( | ( k , v ) | ( k . clone (), g . get ( k ). map_or_else ( || v . clone (), | gv | merge_words ( v , gv )))). collect ())
}
2026-09-21 21:08:08 +02:00
// An unfilled slot echoed back is no answer.
( Value ::String ( _ ), Value ::String ( s )) if ! s . trim (). is_empty () && ! s . contains ( SLOT ) => {
Value ::String ( s . trim (). to_string ())
}
2026-09-21 21:01:35 +02:00
_ => fallback . clone (),
}
}
2026-09-21 21:08:08 +02:00
const SLOT : & str = "<WRITE" ;
/// 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 {
match value {
Value ::Object ( map ) => Value ::Object ( map . iter (). map ( | ( k , v ) | ( k . clone (), slots ( v , k ))). collect ()),
Value ::String ( _ ) => Value ::String ( format! (
" {SLOT} : {} >" ,
match 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" | "confirm" => "a button, one to three words" . to_string (),
"form_title" => "a title for the form, two to four words" . to_string (),
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 (),
}
}
2026-09-21 21:01:35 +02:00
fn s ( v : & Value ) -> & str {
v . as_str (). unwrap_or_default ()
}
fn form_alternative ( need : & PlanNeed , w : & Value , action : & str ) -> Value {
json! ({
"name" : s ( & w [ "heading" ]),
"description" : s ( & w [ "description" ]),
"action" : action ,
"consequence" : [ s ( & w [ "button" ])],
"record_as" : need . lands_in ,
"features" : [{
"name" : s ( & w [ "form_title" ]),
"requirements" : need . collects . iter (). map ( | f | {
let mut r = json! ({ "name" : f . name , "label" : s ( & w [ "labels" ][ & f . name ]), "type" : f . kind });
if f . optional { r [ "optional" ] = json! ( true ); }
if ! f . options . is_empty () { r [ "options" ] = json! ( f . options ); }
r
}). collect ::< Vec < _ >> (),
}],
})
}
/// Every file of the content repo, from the plan and a set of words.
pub fn render ( plan : & Plan , words : & BTreeMap < String , Value > ) -> anyhow ::Result < BTreeMap < String , String >> {
let mut files : BTreeMap < String , Value > = BTreeMap ::new ();
let responsible = json! ({ "name" : plan . contact . name , "contact" : plan . contact . email });
files . insert ( "site.yaml" . into (), json! ({ "title" : plan . business , "lang" : plan . language }));
files . insert (
"aggregates.yaml" . into (),
json! ({ "aggregates" : plan . by_bucket (). into_iter (). map ( | ( bucket , needs ) | {
let need = needs [ 0 ];
let initial = & need . stages [ 0 ];
let first_event = if need . stages . iter (). any ( | st | st == "received" ) { "submitted" } else { "received" };
json! ({
"bucket" : bucket ,
"initial" : initial ,
"states" : need . stages . iter (). map ( | st | ( st . clone (), json! ({ "event" : if st == initial { first_event } else { st . as_str () } }))). collect ::< Map < _ , _ >> (),
"transitions" : machine ( need ),
})
}). collect ::< Vec < _ >> () }),
);
// The front door: one alternative per need the public may bring.
let front = & words [ "questions/index.yaml" ];
let public : Vec <& PlanNeed > = plan . needs . iter (). filter ( | n | n . enters_as . is_none ()). collect ();
files . insert (
"questions/index.yaml" . into (),
json! ({
"name" : s ( & front [ "question" ]),
"description" : s ( & front [ "description" ]),
"responsible" : responsible ,
"alternatives" : public . iter (). map ( | n | form_alternative ( n , & front [ "needs" ][ & n . id ], & public_followup ( n ))). collect ::< Vec < _ >> (),
}),
);
for need in & plan . needs {
let ( path , back ) = match & need . enters_as {
None => ( format! ( "questions/received/ {} .yaml" , need . id ), "/" . to_string ()),
Some ( group ) => ( format! ( "questions/ {group} / {} -sent.yaml" , need . id ), format! ( "/ {group} " )),
};
let w = & words [ & path ];
// Nested and not an index: portal infers a followup page.
files . insert (
path ,
json! ({
"name" : s ( & w [ "question" ]),
"description" : s ( & w [ "description" ]),
"alternatives" : [
{ "name" : s ( & w [ "next_heading" ]), "description" : s ( & w [ "next_description" ]) },
{ "name" : s ( & w [ "back" ]), "description" : "" , "action" : back , "consequence" : [ s ( & w [ "back" ])] },
],
}),
);
if let Some ( group ) = & need . enters_as {
let path = format! ( "questions/ {group} / {} .yaml" , need . id );
let w = & words [ & path ];
files . insert (
path ,
json! ({
// A way in, not an outcome, though it sits in a directory.
"followup" : false ,
"name" : s ( & w [ "question" ]),
"description" : s ( & w [ "page_description" ]),
"responsible" : responsible ,
"alternatives" : [ form_alternative ( 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 ! needs . iter (). any ( | n | n . handled_by == group ) {
continue ;
}
let bw = & w [ "buckets" ][ bucket ];
alternatives . push ( json! ({
"name" : s ( & bw [ "heading" ]),
"description" : s ( & bw [ "description" ]),
"action" : format ! ( "/{group}" ),
"consequence" : [ s ( & w [ "confirm" ])],
"features" : [{ "name" : "" , "description" : "" , "resource" : {
"source" : { "kind" : "kv" , "bucket" : bucket },
"requires_group" : group ,
"transitions" : machine ( needs [ 0 ]). into_iter (). flat_map ( | ( from , tos ) | {
tos . into_iter (). map ( | to | json! ({ "from" : from , "to" : to , "label" : s ( & bw [ "moves" ][ & format! ( " {from} -> {to} " )]) })). collect ::< Vec < _ >> ()
}). collect ::< Vec < _ >> (),
}}],
}));
}
for need in plan . needs . iter (). filter ( | n | n . enters_as . as_deref () == Some ( group . as_str ())) {
let lw = & w [ "links" ][ & need . id ];
alternatives . push ( json! ({
"name" : s ( & lw [ "heading" ]),
"description" : s ( & lw [ "description" ]),
"action" : internal_page ( need , & group ),
"consequence" : [ s ( & lw [ "button" ])],
}));
}
files . insert ( format! ( "questions/ {group} /_section.yaml" ), json! ({ "qualifies" : group }));
files . insert (
format! ( "questions/ {group} /index.yaml" ),
json! ({ "name" : s ( & w [ "question" ]), "description" : s ( & w [ "description" ]), "responsible" : responsible , "alternatives" : alternatives }),
);
}
// 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 | {
let ( enters , heading ) = match & n . enters_as {
None => ( "/" . to_string (), s ( & front [ "needs" ][ & n . id ][ "heading" ]). to_string ()),
Some ( group ) => ( internal_page ( n , group ), s ( & words [ & format! ( "questions/ {group} / {} .yaml" , n . id )][ "heading" ]). to_string ()),
};
let required = n . collects . iter (). filter ( | f | ! f . optional ). count ();
let mut need = json! ({
"id" : n . id , "who" : n . who , "wants" : n . wants , "enters" : enters , "via" : heading ,
"lands_in" : n . lands_in , "handled_by" : n . handled_by ,
"stages" : n . stages , "done_when" : n . done_when , "success" : n . success ,
"max_steps" : 1 , "max_fields" : n . max_fields . unwrap_or ( required ),
});
if let Some ( group ) = & n . enters_as { need [ "enters_as" ] = json! ( group ); }
need
}). collect ::< Vec < _ >> (),
"personas" : plan . personas ,
}),
);
files . into_iter (). map ( | ( path , value ) | Ok (( path , serde_yaml ::to_string ( & value ) ? ))). collect ()
}
fn words_prompt ( plan : & Plan , path : & str , fallback : & Value ) -> String {
let purpose = if path == "questions/index.yaml" {
"the front door: the first page any visitor sees. `question` is the page's one question; under `needs`, each entry is one alternative the visitor can pick, with the form behind it."
} else if path . ends_with ( "/index.yaml" ) {
"a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open."
} 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 context : Vec < String > = plan
. needs
. iter ()
. map ( | n | format! ( "- {} : {} - wants: {} . Handled by {} . Stages: {} ." , n . id , n . who , n . wants , n . handled_by , n . stages . join ( " -> " )))
. collect ();
format! (
2026-09-21 21:08:08 +02:00
"You write the words for one page of a website for this business. \n\n Business: {} \n About: {} \n Contact: {} < {} > \n Language to write in: {} \n\n Everyone the site serves: \n {} \n\n The page: {path} \n It is {purpose} \n\n Below is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer. \n\n Rules for the words: \n - `question` is a question the reader could ask themselves. \n - A heading says what the reader gets, in the reader's own words, and must work without its description. \n - Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use. \n - Descriptions are one or two short sentences. Labels and buttons are a few words. \n - Write the way people in this trade and country actually speak. \n\n {} \n\n Reply with the JSON object only." ,
2026-09-21 21:01:35 +02:00
plan . business ,
plan . about . trim (),
plan . contact . name ,
plan . contact . email ,
plan . language ,
context . join ( " \n " ),
2026-09-21 21:08:08 +02:00
serde_json ::to_string_pretty ( & slots ( fallback , "" )). unwrap_or_default (),
2026-09-21 21:01:35 +02:00
)
}
pub fn run ( argv : & [ String ]) -> anyhow ::Result < () > {
let value = | flag : & str | argv . iter (). position ( | a | a == flag ). and_then ( | i | argv . get ( i + 1 )). cloned ();
let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--llm 'cmd'] [--lint question_lint]" ;
let plan_path = value ( "--plan" ). ok_or_else ( || anyhow! ( usage )) ? ;
let out = std ::path ::PathBuf ::from ( value ( "--out" ). ok_or_else ( || anyhow! ( usage )) ? );
let plan : Plan = serde_yaml ::from_str ( & std ::fs ::read_to_string ( & plan_path ). with_context ( || format! ( "reading {plan_path} " )) ? )
. with_context ( || format! ( "parsing {plan_path} " )) ? ;
plan . validate () ? ;
let mut words = fallback_words ( & plan );
if let Some ( cmd ) = value ( "--llm" ) {
std ::fs ::create_dir_all ( out . join ( "llm" )) ? ;
for ( n , ( path , fallback )) in words . clone (). iter (). enumerate () {
let prompt = words_prompt ( & plan , path , fallback );
let reply = crate ::llm_with ( & cmd , & prompt , & [( "FORMAT" , "json" ), ( "TEMPERATURE" , "0.4" )]). unwrap_or_default ();
std ::fs ::write ( out . join ( format! ( "llm/ {:02} - {} .md" , n + 1 , path . replace ( '/' , "_" ))), format! ( " {prompt} \n\n === REPLY === \n {reply} " )) ? ;
match first_object ( & reply ) {
Some ( given ) => {
words . insert ( path . clone (), merge_words ( fallback , & given ));
eprintln! ( "words: {path} " );
}
None => eprintln! ( "words: {path} - no usable reply, keeping the fallback" ),
}
}
}
write_repo ( & out , render ( & plan , & 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} " )) ? ;
print! ( " {}{} " , String ::from_utf8_lossy ( & output . stdout ), String ::from_utf8_lossy ( & output . stderr ));
if ! output . status . success () {
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 ,
lands_in : "b" . into (), handled_by : "g" . into (),
stages : v ( stages ), done_when : v ( done ), success : v ( success ), skippable : v ( skippable ),
collects : vec ! [], max_fields : None ,
}
}
#[test]
fn the_machine_walks_the_line_and_can_be_turned_down_anywhere () {
let m = machine ( & need ( & [ "open" , "surveyed" , "quoted" , "won" , "lost" ], & [ "won" , "lost" ], & [ "won" ], & []));
assert_eq! ( m [ "open" ], [ "surveyed" , "lost" ]);
assert_eq! ( m [ "surveyed" ], [ "quoted" , "lost" ]);
assert_eq! ( m [ "quoted" ], [ "won" , "lost" ]);
assert! ( ! m . contains_key ( "won" ));
}
#[test]
fn a_skippable_stage_can_be_passed_over () {
let m = machine ( & need ( & [ "open" , "awaiting_papers" , "approved" , "declined" ], & [ "approved" , "declined" ], & [ "approved" ], & [ "awaiting_papers" ]));
assert_eq! ( m [ "open" ], [ "awaiting_papers" , "approved" , "declined" ]);
assert_eq! ( m [ "awaiting_papers" ], [ "approved" , "declined" ]);
}
#[test]
fn a_line_with_one_ending_has_no_way_to_be_turned_down () {
let m = machine ( & need ( & [ "open" , "in_hand" , "closed" ], & [ "closed" ], & [ "closed" ], & []));
assert_eq! ( m [ "open" ], [ "in_hand" ]);
assert_eq! ( m [ "in_hand" ], [ "closed" ]);
}
#[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" : " Hva trenger du? " , "needs" : { "a" : { "heading" : "" , "labels" : { "email" : 7 , "extra" : "x" }}, "b" : { "heading" : "B" }}, "injected" : "y" });
let merged = merge_words ( & fallback , & given );
assert_eq! ( merged , json! ({ "question" : "Hva trenger du?" , "needs" : { "a" : { "heading" : "A" , "labels" : { "email" : "Email" }}}}));
}
const PLAN : & str = "business: Nordvik Bygg \n contact: { name: Ingrid, email: i@n.example } \n about: We fix old buildings. \n needs: \n - id: survey \n who: owners \n wants: Someone to look at the building \n lands_in: enquiries \n handled_by: project_managers \n stages: [open, surveyed, quoted, won, lost] \n done_when: [won, lost] \n success: [won] \n 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 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: 2" ));
assert! ( files [ "needs.yaml" ]. contains ( "enters: /crew/report" ));
}
#[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 collects: [] \n " );
let plan : Plan = serde_yaml ::from_str ( & clash ). unwrap ();
assert! ( plan . validate (). unwrap_err (). to_string (). contains ( "must agree" ));
}
}