diff --git a/plans/contractor.yaml b/plans/contractor.yaml new file mode 100644 index 0000000..e89f76c --- /dev/null +++ b/plans/contractor.yaml @@ -0,0 +1,116 @@ +# The contractor from intakes/contractor.yaml as a plan: the needs spec +# plus what each form collects. Everything structural in the generated +# site comes from this file; a model only writes the words. +business: Nordvik Bygg +language: en +contact: { name: Ingrid Nordvik, email: ingrid@nordvikbygg.example } +about: > + Twelve people who survey old buildings, draw up rehabilitation plans, + handle the permit application, and do the building work with their + own carpenters plus subcontractors for electrical, plumbing and + scaffolding. + +needs: + - id: survey-enquiry + who: a housing board or private owner with a problem in a building + wants: someone qualified to come and look at it and say what it takes + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + collects: + - { name: name } + - { name: phone, type: tel } + - { name: address } + - { name: problem, type: textarea } + - { name: access, optional: true } + + - id: build-enquiry + who: an owner who already knows what they want built or changed + wants: drawings, a permit application and a price + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + collects: + - { name: name } + - { name: phone, type: tel } + - { name: address } + - { name: project, type: textarea } + - { name: timeframe, type: select, options: [This year, Next year, No fixed date] } + + - id: log-phone-enquiry + who: a project manager who just took a call or a text about a building + wants: the enquiry written down before it gets lost + enters_as: project_managers + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + collects: + - { name: caller } + - { name: phone, type: tel } + - { name: address } + - { name: problem, type: textarea } + + - id: subcontractor-application + who: a trade firm doing electrical, plumbing or scaffolding work + wants: to be approved once, and then be called when a job needs them + lands_in: subcontractors + handled_by: project_managers + stages: [open, awaiting_papers, approved, declined] + done_when: [approved, declined] + success: [approved] + skippable: [awaiting_papers] + collects: + - { name: company } + - { name: registration_number } + - { name: trade, type: select, options: [Electrical, Plumbing, Scaffolding, Sheet metal, Other] } + - { name: contact_email, type: email } + - { name: insurance_confirmed, type: select, options: ["Yes", Not yet] } + - { name: trade_approval, optional: true } + + - id: job-application + who: a carpenter, bricklayer or apprentice looking for work + wants: to be considered, without writing a letter + lands_in: applicants + handled_by: project_managers + stages: [open, interviewing, hired, declined] + done_when: [hired, declined] + success: [hired] + collects: + - { name: name } + - { name: phone, type: tel } + - { name: trade, type: select, options: [Carpenter, Bricklayer, Apprentice, Other] } + - { name: experience, type: textarea, optional: true } + + - id: report-site-issue + who: anyone on site, ours or a subcontractor's, on a phone with gloves on + wants: to report a hazard, a near miss or wrong materials before they forget + enters_as: crew + lands_in: site_issues + handled_by: safety_officer + stages: [open, in_hand, closed] + done_when: [closed] + success: [closed] + collects: + - { name: kind, type: select, options: [Hazard, Near miss, Wrong materials, Other] } + - { name: where } + - { name: what_happened, type: textarea } + +# Held out from every model that writes words: these are the personas +# from sectors/construction, which no generator has seen. +personas: + - { id: housing-board-damp, need: survey-enquiry, text: "I chair the board of a housing cooperative from the seventies. Two ground-floor flats have damp in the walls and we don't know if it's drainage or the facade. We need someone to tell us." } + - { id: buyer-before-purchase, need: survey-enquiry, text: "We are about to buy an old timber house. Before we sign we want a professional to go through it and tell us what is wrong with it." } + - { id: attic-conversion, need: build-enquiry, text: "We want to turn our attic into two bedrooms and a bathroom. We need drawings and someone who handles the application to the municipality." } + - { id: shop-to-flat, need: build-enquiry, text: "I own a former shop on the ground floor and want to make it a flat. I have no idea what is allowed. Can you sort that out and give me a price?" } + - { id: electrician-firm, need: subcontractor-application, text: "We are a four-person electrical firm, fully certified. We would like to get on your list so you call us when you need electricians." } + - { id: scaffolder, need: subcontractor-application, text: "I run a scaffolding company. How do I get to do jobs for you?" } + - { id: carpenter-jobseeker, need: job-application, text: "I'm a certified carpenter with eight years on rehabilitation jobs. Are you hiring?" } + - { id: apprentice, need: job-application, text: "I finish vocational school this spring and need an apprenticeship as a bricklayer." } + - { id: loose-railing, need: report-site-issue, text: "The railing on the third level of the scaffold on the north side is loose. Nobody got hurt but someone will." } + - { id: wrong-membrane, need: report-site-issue, text: "The bathroom membrane delivered today is not the one in the specification. We stopped work on it." } diff --git a/src/build.rs b/src/build.rs new file mode 100644 index 0000000..77ee04c --- /dev/null +++ b/src/build.rs @@ -0,0 +1,616 @@ +//! 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, + #[serde(default)] + pub personas: Vec, +} + +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, + pub lands_in: String, + pub handled_by: String, + /// Every stage, first to last, finished states included. + pub stages: Vec, + pub done_when: Vec, + #[serde(default)] + pub success: Vec, + /// Stages a case may pass over ("awaiting papers" when the papers + /// were complete). + #[serde(default)] + pub skippable: Vec, + pub collects: Vec, + #[serde(default)] + pub max_fields: Option, +} + +#[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, +} + +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> { + let line: Vec<&String> = need.stages.iter().filter(|s| !need.done_when.contains(s)).collect(); + let ends_well: Vec<&String> = if need.success.is_empty() { need.done_when.iter().collect() } else { need.success.iter().collect() }; + let ends_badly: Vec<&String> = need.done_when.iter().filter(|s| !ends_well.contains(s)).collect(); + let mut moves = BTreeMap::new(); + for (i, stage) in line.iter().enumerate() { + let mut to: Vec = Vec::new(); + let mut j = i + 1; + loop { + match line.get(j) { + Some(next) => { + to.push((*next).clone()); + if !need.skippable.contains(next) { + break; + } + j += 1; + } + None => { + to.extend(ends_well.iter().map(|s| (*s).clone())); + break; + } + } + } + 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 { + 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::>(), + }) + }; + 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::>(), + }), + ); + 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 = plan + .by_bucket() + .into_iter() + .filter(|(_, needs)| needs.iter().any(|n| n.handled_by == group)) + .map(|(bucket, needs)| { + let moves: Map = 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 = 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 { + 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()) + } + (Value::String(_), Value::String(s)) if !s.trim().is_empty() => Value::String(s.trim().to_string()), + _ => fallback.clone(), + } +} + +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::>(), + }], + }) +} + +/// Every file of the content repo, from the plan and a set of words. +pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result> { + let mut files: BTreeMap = BTreeMap::new(); + let responsible = json!({ "name": plan.contact.name, "contact": plan.contact.email }); + + 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::>(), + "transitions": machine(need), + }) + }).collect::>() }), + ); + + // 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::>(), + }), + ); + + 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::>() + }).collect::>(), + }}], + })); + } + for need in plan.needs.iter().filter(|n| n.enters_as.as_deref() == Some(group.as_str())) { + let lw = &w["links"][&need.id]; + alternatives.push(json!({ + "name": s(&lw["heading"]), + "description": s(&lw["description"]), + "action": internal_page(need, &group), + "consequence": [s(&lw["button"])], + })); + } + 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::>(), + "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 = plan + .needs + .iter() + .map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "))) + .collect(); + 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's wording as JSON, filled with placeholder text. Return the same JSON with every string rewritten. Keep every key exactly as it is; change only the string values.\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- Write the way people in this trade and country actually speak.\n\n{}\n\nReply with the JSON object only.", + plan.business, + plan.about.trim(), + plan.contact.name, + plan.contact.email, + plan.language, + context.join("\n"), + serde_json::to_string_pretty(fallback).unwrap_or_default(), + ) +} + +pub fn run(argv: &[String]) -> anyhow::Result<()> { + let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned(); + let usage = "usage: portal-trainer build --plan --out [--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) -> anyhow::Result<()> { + // The questions tree is rebuilt whole, so a page from an earlier + // plan never lingers. + let _ = std::fs::remove_dir_all(out.join("questions")); + for (path, body) in files { + let target = out.join(&path); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(target, body)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn need(stages: &[&str], done: &[&str], success: &[&str], skippable: &[&str]) -> PlanNeed { + let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::>(); + PlanNeed { + id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None, + 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\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 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")); + } +} diff --git a/src/main.rs b/src/main.rs index 53339c1..814d0da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ use std::process::{Command, Stdio}; use anyhow::{anyhow, bail, Context}; +mod build; mod rate; mod simulate; @@ -47,6 +48,7 @@ fn main() -> anyhow::Result<()> { match argv.first().map(String::as_str) { Some("simulate") => return simulate::run(&argv), Some("compare") => return simulate::compare(&argv), + Some("build") => return build::run(&argv), Some("rate") => return rate::rate(&argv), Some("prefer") => return rate::prefer(&argv), _ => {}