diff --git a/CHANGELOG.md b/CHANGELOG.md index 75c9cca..dbd2516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## 0.6.22 (2026-10-07) + +- `/profile.mobileconfig` renders an Apple configuration profile from the mail server's own PACC document (`profile.pacc` in `site.yaml`), so iOS can be set up in one install instead of probing for ports it cannot reach. Mail, CalDAV and CardDAV in one profile; every host, port and path from PACC, never written down twice; no address, username or password in the document, so one profile serves everyone and iOS asks at install; identifiers derived from the site, so reinstalling replaces the accounts rather than duplicating them (docs: content-reference, "A configuration profile"). + ## 0.6.21 (2026-10-07) - A content repo's `.well-known/` is served at the site root (`/.well-known/security.txt`, `/.well-known/assetlinks.json`, `/.well-known/matrix/server`), and a miss there is a 404 instead of the app shell answering 200 with an HTML page. RFC 8615 reserves the prefix and a question's id cannot begin with a dot, so nothing a repo ships can shadow a page. An extensionless file under it is typed as JSON, by the convention those files follow; `.txt`, `.xml`, `.webmanifest` and `.mobileconfig` are typed too, the last so an Apple configuration profile offers to install rather than download. diff --git a/Cargo.lock b/Cargo.lock index 2f174f2..9dbdad8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "portal" -version = "0.6.21" +version = "0.6.22" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 14c891d..84b56b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "portal" -version = "0.6.21" +version = "0.6.22" edition = "2021" [lib] diff --git a/docs/content-reference.md b/docs/content-reference.md index 290bef2..158d0ac 100644 --- a/docs/content-reference.md +++ b/docs/content-reference.md @@ -126,6 +126,50 @@ mail autoconfiguration (`/.well-known/autoconfig/`) and the CalDAV and CardDAV redirects are usually handed to a mail server that generates them. +## A configuration profile + +iOS has no autoconfiguration for IMAP accounts - Apple Mail reads neither +Mozilla autoconfig nor Microsoft Autodiscover, which are for ActiveSync - so +adding an account by hand leaves the device probing for ports. A probe that +meets a *filtered* port, a firewall that drops rather than refuses, waits out +a TCP timeout, and the account comes back "could not be verified", which reads +to the person as a wrong password. An Apple configuration profile ends the +guessing by stating the hosts and the ports, and it carries mail, calendars +and contacts in a single install. + +Portal renders one at `/profile.mobileconfig` when `site.yaml` says where to +get the values: + +```yaml +profile: + pacc: https://mail.example.no/.well-known/user-agent-configuration.json + name: Example # what the accounts are called on the device + organization: Example AS # the profile's publisher, defaults to name +``` + +`pacc` is the mail server's **PACC** document - the standard that replaces +guessing at hostnames, defined to be fetched from the mail domain itself, and +served by Stalwart among others. Every host, port and collection path in the +profile comes from it, so nothing about the server is written down a second +place to drift from the first: move a collection on the server and the profile +moves with it. Where PACC states no port, the registered implicit-TLS default +is used (993 for IMAP, 465 for submission, 443 for DAV). + +What the profile contains is only what PACC already says in public. It carries +**no address and no username** - iOS asks for those and the password at +install - so one document serves everyone with an account and may be fetched +by anyone. Payload identifiers are derived from the site and the payload +rather than drawn at random, so reinstalling replaces the accounts instead of +stacking duplicates beside them. + +Two things it cannot do. A profile Portal renders is unsigned, so iOS shows it +as *Unverified* while still offering to install it; signing needs a +certificate the device already trusts. And a PACC document describing no +protocol iOS understands - JMAP alone, or an incoming server with no outgoing +one - is an error rather than a half-empty profile that looks like it worked. + +No `profile:` means `/profile.mobileconfig` is a 404. + ## An alternative One choice. Never two actions behind one heading: if two things can be diff --git a/src/access.rs b/src/access.rs index 0f912fb..49fa82b 100644 --- a/src/access.rs +++ b/src/access.rs @@ -70,12 +70,22 @@ pub enum Principal { impl Principal { pub fn anonymous() -> Self { - Principal::User { sub: "anonymous".into(), groups: Vec::new(), phone: None, email: None } + Principal::User { + sub: "anonymous".into(), + groups: Vec::new(), + phone: None, + email: None, + } } pub fn from_user(user: Option<&crate::auth::User>) -> Self { match user { - Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone(), phone: u.phone.clone(), email: u.email.clone() }, + Some(u) => Principal::User { + sub: u.sub.clone(), + groups: u.groups.clone(), + phone: u.phone.clone(), + email: u.email.clone(), + }, None => Principal::anonymous(), } } @@ -110,13 +120,23 @@ impl Action { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Resource { - Page { id: String }, - Bucket { name: String }, + Page { + id: String, + }, + Bucket { + name: String, + }, /// One resource block on one alternative of one page. `feature` is /// the feature name, or `feature|requirement` (`|bind` appended) /// for a requirement's own resource. - Feature { page: String, alternative: String, feature: String }, - Group { name: String }, + Feature { + page: String, + alternative: String, + feature: String, + }, + Group { + name: String, + }, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -164,12 +184,29 @@ impl Policy { // Each rule is parsed on its own under its own id, so a // decision's reasons name rules, not `policy17`. let mut sources: Vec<(String, String)> = Vec::new(); - let mut emit = |id_base: String, who: String, action: &str, resource: String, when: String, policy: String, text: &mut String, rules: &mut Vec| { + let mut emit = |id_base: String, + who: String, + action: &str, + resource: String, + when: String, + policy: String, + text: &mut String, + rules: &mut Vec| { let n = ids.entry(id_base.clone()).or_insert(0); *n += 1; - let id = if *n == 1 { id_base } else { format!("{id_base}#{n}") }; + let id = if *n == 1 { + id_base + } else { + format!("{id_base}#{n}") + }; text.push_str(&format!("@id({})\n{policy}\n\n", quote(&id))); - rules.push(Rule { id: id.clone(), who, action: action.to_string(), resource, when }); + rules.push(Rule { + id: id.clone(), + who, + action: action.to_string(), + resource, + when, + }); sources.push((id, policy)); }; @@ -179,7 +216,10 @@ impl Policy { let page = quote(&q.id); let (who, principal) = match &q.qualifies { None => ("anyone".to_string(), "principal".to_string()), - Some(g) => (format!("group {g}"), format!("principal in Group::{}", quote(g))), + Some(g) => ( + format!("group {g}"), + format!("principal in Group::{}", quote(g)), + ), }; emit( format!("open:{}", q.id), who, "open, submit", format!("page {}", q.id), String::new(), @@ -197,16 +237,25 @@ impl Policy { } for req in &feature.requirements { if let Some(spec) = &req.resource { - specs.push((format!("{}|{}|{}|{}", q.id, alt.name, feature.name, req.name), spec)); + specs.push(( + format!("{}|{}|{}|{}", q.id, alt.name, feature.name, req.name), + spec, + )); } if let Some(bind) = &req.bind { - specs.push((format!("{}|{}|{}|{}|bind", q.id, alt.name, feature.name, req.name), &bind.resource)); + specs.push(( + format!("{}|{}|{}|{}|bind", q.id, alt.name, feature.name, req.name), + &bind.resource, + )); } } } for (fid, spec) in specs { let (who, principal) = match (&spec.requires_group, spec.public) { - (Some(g), _) => (format!("group {g}"), format!("principal in Group::{}", quote(g))), + (Some(g), _) => ( + format!("group {g}"), + format!("principal in Group::{}", quote(g)), + ), (None, true) => ("anyone".to_string(), "principal".to_string()), // Neither: unreachable by design (fail closed). (None, false) => continue, @@ -279,20 +328,37 @@ impl Policy { .expect("generated policy text always parses"); policies.add(policy).expect("rule ids are unique"); } - let schema = Schema::from_cedarschema_str(SCHEMA).map(|(s, _)| s).expect("the schema is a constant"); - let patterns = questions.values().filter(|q| q.is_dynamic()).map(|q| q.id.clone()).collect(); - Policy { policies, schema, rules, text, patterns } + let schema = Schema::from_cedarschema_str(SCHEMA) + .map(|(s, _)| s) + .expect("the schema is a constant"); + let patterns = questions + .values() + .filter(|q| q.is_dynamic()) + .map(|q| q.id.clone()) + .collect(); + Policy { + policies, + schema, + rules, + text, + patterns, + } } /// Every generated rule type-checks against the schema. A rule the /// schema cannot express is a compiler bug, and this is where it /// shows. pub fn validate(&self) -> Result<(), String> { - let result = Validator::new(self.schema.clone()).validate(&self.policies, ValidationMode::Strict); + let result = + Validator::new(self.schema.clone()).validate(&self.policies, ValidationMode::Strict); if result.validation_passed() { Ok(()) } else { - Err(result.validation_errors().map(|e| e.to_string()).collect::>().join("; ")) + Err(result + .validation_errors() + .map(|e| e.to_string()) + .collect::>() + .join("; ")) } } @@ -317,7 +383,10 @@ impl Policy { context: serde_json::Value, ) -> Decision { let uid = |ty: &str, id: &str| { - EntityUid::from_type_name_and_id(EntityTypeName::from_str(ty).expect("static type"), EntityId::new(id)) + EntityUid::from_type_name_and_id( + EntityTypeName::from_str(ty).expect("static type"), + EntityId::new(id), + ) }; let mut entities: Vec = Vec::new(); let principal_uid = match principal { @@ -332,8 +401,14 @@ impl Policy { } Principal::Submitter { record, email } => { let me = uid("Submitter", record); - let attrs = HashMap::from([("email".to_string(), RestrictedExpression::new_string(email.clone()))]); - entities.push(Entity::new(me.clone(), attrs, HashSet::new()).expect("a string attribute always evaluates")); + let attrs = HashMap::from([( + "email".to_string(), + RestrictedExpression::new_string(email.clone()), + )]); + entities.push( + Entity::new(me.clone(), attrs, HashSet::new()) + .expect("a string attribute always evaluates"), + ); me } Principal::Service { name } => { @@ -345,9 +420,14 @@ impl Policy { let resource_uid = match resource { Resource::Page { id } => uid("Page", &self.page_entity(id)), Resource::Bucket { name } => uid("Bucket", name), - Resource::Feature { page, alternative, feature } => { - uid("Feature", &format!("{}|{alternative}|{feature}", self.page_entity(page))) - } + Resource::Feature { + page, + alternative, + feature, + } => uid( + "Feature", + &format!("{}|{alternative}|{feature}", self.page_entity(page)), + ), Resource::Group { name } => uid("Group", name), }; entities.push(Entity::new_no_attrs(resource_uid.clone(), HashSet::new())); @@ -357,22 +437,32 @@ impl Policy { return Decision::default(); }; // An absent context is an empty one, not a malformed one. - let context = if context.is_object() { context } else { serde_json::json!({}) }; + let context = if context.is_object() { + context + } else { + serde_json::json!({}) + }; let Ok(context) = Context::from_json_value(context, None) else { return Decision::default(); }; - let Ok(request) = Request::new(principal_uid, action_uid, resource_uid, context, None) else { + let Ok(request) = Request::new(principal_uid, action_uid, resource_uid, context, None) + else { return Decision::default(); }; let response = Authorizer::new().is_authorized(&request, &self.policies, &entities); Decision { allow: response.decision() == cedar_policy::Decision::Allow, - reasons: response.diagnostics().reason().map(|id| id.to_string()).collect(), + reasons: response + .diagnostics() + .reason() + .map(|id| id.to_string()) + .collect(), } } pub fn allows(&self, principal: &Principal, action: Action, resource: &Resource) -> bool { - self.decide(principal, action, resource, serde_json::json!({})).allow + self.decide(principal, action, resource, serde_json::json!({})) + .allow } } @@ -473,21 +563,50 @@ pub async fn serve( // A question that names a person and lists no groups is // asking portal what they belong to: their memberships, // matched on subject, phone number or address. - if let (Principal::User { sub, groups, phone, email }, Some(state)) = (&mut q.principal, &state) { + if let ( + Principal::User { + sub, + groups, + phone, + email, + }, + Some(state), + ) = (&mut q.principal, &state) + { if groups.is_empty() && sub != "anonymous" { let who = crate::memberships::Identity { sub: Some(sub.clone()), - phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))), + phone: phone.as_deref().and_then(|p| { + crate::memberships::normalize_phone( + p, + &std::env::var("PHONE_COUNTRY") + .unwrap_or_else(|_| "+47".into()), + ) + }), email: email.as_deref().map(|e| e.trim().to_lowercase()), }; - *groups = crate::memberships::groups_of(&crate::memberships::all(state).await, &who); + *groups = crate::memberships::groups_of( + &crate::memberships::all(state).await, + &who, + ); } } - decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context) + decide_and_publish( + &nats, + &access.load(), + &q.principal, + q.action, + &q.resource, + q.context, + ) } - Err(e) => Decision { allow: false, reasons: vec![format!("malformed question: {e}")] }, + Err(e) => Decision { + allow: false, + reasons: vec![format!("malformed question: {e}")], + }, }; - let bytes = serde_json::to_vec(&decision).unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec()); + let bytes = serde_json::to_vec(&decision) + .unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec()); let _ = nats.publish(reply, bytes.into()).await; } } @@ -513,14 +632,22 @@ mod tests { } fn user(groups: &[&str]) -> Principal { - Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: None } + Principal::User { + sub: "u".into(), + groups: groups.iter().map(|g| g.to_string()).collect(), + phone: None, + email: None, + } } #[test] fn the_generated_policy_validates_against_the_schema() { let p = site(); assert_eq!(p.validate(), Ok(()), "{}", p.text); - assert!(p.rules.iter().any(|r| r.id == "move:applicants:open>invited")); + assert!(p + .rules + .iter() + .any(|r| r.id == "move:applicants:open>invited")); } #[test] @@ -530,7 +657,12 @@ mod tests { assert!(p.allows(&Principal::anonymous(), Action::Open, &page("/"))); assert!(!p.allows(&Principal::anonymous(), Action::Open, &page("/review"))); assert!(!p.allows(&user(&["staff"]), Action::Submit, &page("/review"))); - let d = p.decide(&user(&["owners"]), Action::Submit, &page("/review"), json!({})); + let d = p.decide( + &user(&["owners"]), + Action::Submit, + &page("/review"), + json!({}), + ); assert!(d.allow); assert_eq!(d.reasons, vec!["open:/review"]); // A concrete dynamic path resolves to its pattern's rule. @@ -541,8 +673,16 @@ mod tests { #[test] fn reads_follow_requires_group_or_public() { let p = site(); - let desk = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "".into() }; - let releases = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "Releases".into() }; + let desk = Resource::Feature { + page: "/review".into(), + alternative: "Applicants".into(), + feature: "".into(), + }; + let releases = Resource::Feature { + page: "/review".into(), + alternative: "Applicants".into(), + feature: "Releases".into(), + }; assert!(p.allows(&user(&["owners"]), Action::Read, &desk)); assert!(!p.allows(&user(&["staff"]), Action::Read, &desk)); assert!(p.allows(&Principal::anonymous(), Action::Read, &releases)); @@ -551,26 +691,88 @@ mod tests { #[test] fn transitions_are_exactly_the_desks_buttons() { let p = site(); - let bucket = Resource::Bucket { name: "applicants".into() }; + let bucket = Resource::Bucket { + name: "applicants".into(), + }; let ctx = |from: &str, to: &str| json!({ "from": from, "to": to }); - assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "invited")).allow); - assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("invited", "active")).allow); + assert!( + p.decide( + &user(&["owners"]), + Action::Transition, + &bucket, + ctx("open", "invited") + ) + .allow + ); + assert!( + p.decide( + &user(&["owners"]), + Action::Transition, + &bucket, + ctx("invited", "active") + ) + .allow + ); // Right target, wrong starting state: no rule. - assert!(!p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "active")).allow); - assert!(!p.decide(&user(&["staff"]), Action::Transition, &bucket, ctx("open", "invited")).allow); + assert!( + !p.decide( + &user(&["owners"]), + Action::Transition, + &bucket, + ctx("open", "active") + ) + .allow + ); + assert!( + !p.decide( + &user(&["staff"]), + Action::Transition, + &bucket, + ctx("open", "invited") + ) + .allow + ); } #[test] fn a_submitter_moves_their_own_record_only_with_the_matching_email() { let p = site(); - let bucket = Resource::Bucket { name: "subscribers".into() }; - let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() }; - let ctx = |stored: &str| json!({ "from": "open", "to": "unsubscribed", "stored_email": stored }); - assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("a@b.c")).allow); - assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("x@y.z")).allow); - assert!(!p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "open", "to": "somewhere", "stored_email": "a@b.c" })).allow); + let bucket = Resource::Bucket { + name: "subscribers".into(), + }; + let me = Principal::Submitter { + record: "r1".into(), + email: "a@b.c".into(), + }; + let ctx = + |stored: &str| json!({ "from": "open", "to": "unsubscribed", "stored_email": stored }); + assert!( + p.decide(&me, Action::SelfTransition, &bucket, ctx("a@b.c")) + .allow + ); + assert!( + !p.decide(&me, Action::SelfTransition, &bucket, ctx("x@y.z")) + .allow + ); + assert!( + !p.decide( + &me, + Action::SelfTransition, + &bucket, + json!({ "from": "open", "to": "somewhere", "stored_email": "a@b.c" }) + ) + .allow + ); // A signed-in owner has no such rule: the link is the credential. - assert!(!p.decide(&user(&["owners"]), Action::SelfTransition, &bucket, ctx("a@b.c")).allow); + assert!( + !p.decide( + &user(&["owners"]), + Action::SelfTransition, + &bucket, + ctx("a@b.c") + ) + .allow + ); } // The applicant written to for a missing drawing sends it from @@ -590,15 +792,52 @@ mod tests { .unwrap(); let p = Policy::from_content(&questions, &aggregates); assert_eq!(p.validate(), Ok(()), "{}", p.text); - let bucket = Resource::Bucket { name: "cases".into() }; - let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() }; + let bucket = Resource::Bucket { + name: "cases".into(), + }; + let me = Principal::Submitter { + record: "r1".into(), + email: "a@b.c".into(), + }; let ctx = |from: &str, to: &str| json!({ "from": from, "to": to, "stored_email": "a@b.c" }); - assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("incomplete", "complete")).allow); - assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "withdrawn")).allow); + assert!( + p.decide( + &me, + Action::SelfTransition, + &bucket, + ctx("incomplete", "complete") + ) + .allow + ); + assert!( + p.decide( + &me, + Action::SelfTransition, + &bucket, + ctx("complete", "withdrawn") + ) + .allow + ); // And only where it was said: the paperwork cannot be sent // after the case is already withdrawn. - assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("withdrawn", "complete")).allow); - assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("complete", "complete")).allow); + assert!( + !p.decide( + &me, + Action::SelfTransition, + &bucket, + ctx("withdrawn", "complete") + ) + .allow + ); + assert!( + !p.decide( + &me, + Action::SelfTransition, + &bucket, + ctx("complete", "complete") + ) + .allow + ); } // Omitted, `from` is the initial state alone - what every @@ -606,8 +845,17 @@ mod tests { #[test] fn a_move_that_does_not_say_where_from_is_a_one_shot_on_arrival() { let p = site(); - assert!(p.rules.iter().any(|r| r.id == "self:subscribers:open>unsubscribed")); - assert_eq!(p.rules.iter().filter(|r| r.id.starts_with("self:subscribers:")).count(), 1); + assert!(p + .rules + .iter() + .any(|r| r.id == "self:subscribers:open>unsubscribed")); + assert_eq!( + p.rules + .iter() + .filter(|r| r.id.starts_with("self:subscribers:")) + .count(), + 1 + ); } // "On arrival" is the bucket's own first state, not the word @@ -633,20 +881,49 @@ mod tests { "the rule must start where the records do: {:?}", p.rules.iter().map(|r| &r.id).collect::>() ); - let me = Principal::Submitter { record: "r".into(), email: "a@b.c".into() }; - let bucket = Resource::Bucket { name: "watchers".into() }; - assert!(p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "pending", "to": "open", "stored_email": "a@b.c" })).allow); + let me = Principal::Submitter { + record: "r".into(), + email: "a@b.c".into(), + }; + let bucket = Resource::Bucket { + name: "watchers".into(), + }; + assert!( + p.decide( + &me, + Action::SelfTransition, + &bucket, + json!({ "from": "pending", "to": "open", "stored_email": "a@b.c" }) + ) + .allow + ); } #[test] fn invites_and_automation() { let p = site(); - let owners = Resource::Group { name: "owners".into() }; + let owners = Resource::Group { + name: "owners".into(), + }; assert!(p.allows(&user(&["owners"]), Action::Invite, &owners)); assert!(!p.allows(&user(&["staff"]), Action::Invite, &owners)); - let bucket = Resource::Bucket { name: "applicants".into() }; - assert!(p.allows(&Principal::Service { name: "automation".into() }, Action::AutomationRead, &bucket)); - assert!(!p.allows(&Principal::Service { name: "someone".into() }, Action::AutomationRead, &bucket)); + let bucket = Resource::Bucket { + name: "applicants".into(), + }; + assert!(p.allows( + &Principal::Service { + name: "automation".into() + }, + Action::AutomationRead, + &bucket + )); + assert!(!p.allows( + &Principal::Service { + name: "someone".into() + }, + Action::AutomationRead, + &bucket + )); assert!(!p.allows(&user(&["owners"]), Action::AutomationRead, &bucket)); } @@ -657,12 +934,18 @@ mod tests { r#"{"principal":{"kind":"user","sub":"n8n-as-owner","groups":["owners"]},"action":"transition","resource":{"kind":"bucket","name":"applicants"},"context":{"from":"open","to":"invited"}}"#, ) .unwrap(); - assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow); + assert!( + p.decide(&q.principal, q.action, &q.resource, q.context) + .allow + ); let q: Query = serde_json::from_str( r#"{"principal":{"kind":"service","name":"automation"},"action":"automation_read","resource":{"kind":"bucket","name":"applicants"}}"#, ) .unwrap(); - assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow); + assert!( + p.decide(&q.principal, q.action, &q.resource, q.context) + .allow + ); assert_eq!(subject_for("uhhm"), "portal.access.uhhm.may"); } diff --git a/src/aggregates/mod.rs b/src/aggregates/mod.rs index 1fda973..6828b1a 100644 --- a/src/aggregates/mod.rs +++ b/src/aggregates/mod.rs @@ -78,7 +78,9 @@ fn is_zero(n: &u32) -> bool { impl LeavesSpec { pub fn millis(&self) -> i64 { - ((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes)) * 60 * 1000 + ((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes)) + * 60 + * 1000 } } @@ -200,8 +202,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result Option anyhow::Result<()> { let now_ms = chrono::Utc::now().timestamp_millis(); let questions = state.questions.load(); - let events: Vec<_> = questions.values().filter(|q| q.event.is_some()).cloned().collect(); + let events: Vec<_> = questions + .values() + .filter(|q| q.event.is_some()) + .cloned() + .collect(); if events.is_empty() { return Ok(()); } @@ -100,7 +104,9 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> { let mut refreshed = answer.clone(); refreshed.responses = responses.clone(); if let Some(store) = &store { - store.put(&id, serde_json::to_vec(&refreshed)?.into()).await?; + store + .put(&id, serde_json::to_vec(&refreshed)?.into()) + .await?; tracing::info!(question = %q.id, "event record refreshed from content"); } } @@ -108,15 +114,31 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> { if answer.state == schema.initial && now_ms >= ends { let target = "awaiting_summary"; let payload = serde_json::json!({ "to": target, "item": id, "by": "portal" }); - match crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms).await { + match crate::aggregates::transition( + &state.jetstream, + schema, + &id, + target, + payload.clone(), + now_ms, + ) + .await + { Ok(_) => {} Err(crate::aggregates::TransitionError::UnknownAggregate) => { crate::aggregates::reseed(&state.jetstream, schema, &id, &answer.state, now_ms) .await .map_err(|e| anyhow::anyhow!("{e}"))?; - crate::aggregates::transition(&state.jetstream, schema, &id, target, payload.clone(), now_ms) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + crate::aggregates::transition( + &state.jetstream, + schema, + &id, + target, + payload.clone(), + now_ms, + ) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; } Err(e) => return Err(anyhow::anyhow!("{e}")), } diff --git a/src/answers.rs b/src/answers.rs index 04751b8..b0785f3 100644 --- a/src/answers.rs +++ b/src/answers.rs @@ -113,7 +113,9 @@ pub async fn store_answer( // submission the NATS notification event has already recorded" // policy as the KV write above. if let Some(schema) = aggregates.get(bucket) { - if let Err(e) = crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await { + if let Err(e) = + crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await + { tracing::error!(bucket, id, error = %e, "failed to seed event log for submission"); } } @@ -127,17 +129,27 @@ pub const OWNER_FIELD: &str = "by"; /// Every record in `bucket` that belongs to `username`, newest first. #[cfg(feature = "ssr")] -pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, username: &str) -> Vec { +pub async fn own_records( + js: &async_nats::jetstream::Context, + bucket: &str, + username: &str, +) -> Vec { use futures::TryStreamExt; - let Ok(store) = js.get_key_value(bucket).await else { return Vec::new() }; + let Ok(store) = js.get_key_value(bucket).await else { + return Vec::new(); + }; let keys: Vec = match store.keys().await { Ok(keys) => keys.try_collect().await.unwrap_or_default(), Err(_) => return Vec::new(), }; let mut own = Vec::new(); for key in keys { - let Ok(Some(bytes)) = store.get(&key).await else { continue }; - let Ok(answer) = serde_json::from_slice::(&bytes) else { continue }; + let Ok(Some(bytes)) = store.get(&key).await else { + continue; + }; + let Ok(answer) = serde_json::from_slice::(&bytes) else { + continue; + }; if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) { own.push(answer); } @@ -151,9 +163,16 @@ pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, user /// it (the event log keeps them). Run after every store, so two sends /// racing each other - a double click - both settle on the same one. #[cfg(feature = "ssr")] -pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str, initial: &str, username: &str) { +pub async fn keep_newest_own( + js: &async_nats::jetstream::Context, + bucket: &str, + initial: &str, + username: &str, +) { let own = own_records(js, bucket, username).await; - let Ok(store) = js.get_key_value(bucket).await else { return }; + let Ok(store) = js.get_key_value(bucket).await else { + return; + }; for older in own.iter().filter(|a| a.state == initial).skip(1) { if let Err(e) = store.delete(&older.id).await { tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record"); @@ -167,16 +186,39 @@ pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str, /// the address a list would open it from, and may be left empty to /// keep it. #[cfg(feature = "ssr")] -pub async fn fill_own(js: &async_nats::jetstream::Context, question: &mut crate::content::Question, user: Option<&crate::auth::User>) { +pub async fn fill_own( + js: &async_nats::jetstream::Context, + question: &mut crate::content::Question, + user: Option<&crate::auth::User>, +) { let Some(user) = user else { return }; for alt in &mut question.alternatives { - let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { continue }; - let Some(mine) = own_records(js, &bucket, &user.username).await.into_iter().next() else { continue }; - for req in alt.features.iter_mut().flat_map(|f| f.requirements.iter_mut()) { + let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { + continue; + }; + let Some(mine) = own_records(js, &bucket, &user.username) + .await + .into_iter() + .next() + else { + continue; + }; + for req in alt + .features + .iter_mut() + .flat_map(|f| f.requirements.iter_mut()) + { if req.kind == "hidden" { continue; } - let Some(value) = mine.responses.get(&req.name).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty()) else { continue }; + let Some(value) = mine + .responses + .get(&req.name) + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()) + else { + continue; + }; if req.kind == "file" { req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name)); req.optional = true; @@ -217,7 +259,12 @@ pub async fn transition_answers( .load() .at(storyline) .get(&question_id) - .and_then(|q| q.alternatives.iter().find(|a| a.name == alternative).cloned()) + .and_then(|q| { + q.alternatives + .iter() + .find(|a| a.name == alternative) + .cloned() + }) .ok_or_else(|| ServerFnError::new("unknown question or alternative"))?; let session: tower_sessions::Session = leptos_axum::extract().await?; @@ -333,7 +380,9 @@ async fn apply_transition( &state.access.load(), &crate::access::Principal::from_user(Some(user)), crate::access::Action::Transition, - &crate::access::Resource::Bucket { name: bucket.to_string() }, + &crate::access::Resource::Bucket { + name: bucket.to_string(), + }, serde_json::json!({ "from": answer.state, "to": item.to }), ); if !decision.allow { @@ -370,7 +419,15 @@ async fn apply_transition( .put(&item.item_id, data.into()) .await .map_err(|e| ServerFnError::new(e.to_string()))?; - crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses, answer.storyline).await; + crate::mail::on_state( + state, + bucket, + &item.item_id, + &item.to, + &answer.responses, + answer.storyline, + ) + .await; crate::people::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await; // An expired invite sent again: the move back is the asking. if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE { @@ -380,7 +437,12 @@ async fn apply_transition( // The decision extends the DAG as a child of the answer's own // submission hash, published like any other answer. let parent_hashes = vec![item.item_id.clone()]; - let chain_hash = hash_node(question_id, &parent_hashes, &decision_payload_for_event, decided_ms); + let chain_hash = hash_node( + question_id, + &parent_hashes, + &decision_payload_for_event, + decided_ms, + ); let event = AnswerSubmitted { chain_hash, parent_hashes, @@ -447,13 +509,22 @@ pub async fn self_transition_answer( // policy compares them and knows the one move a submitter may make. // Refusal reads "not found" so the endpoint cannot be used to probe // which records or emails exist. - let stored_email = answer.responses.get("email").and_then(|v| v.as_str()).unwrap_or_default(); + let stored_email = answer + .responses + .get("email") + .and_then(|v| v.as_str()) + .unwrap_or_default(); let decision = crate::access::decide_and_publish( &state.nats, &state.access.load(), - &crate::access::Principal::Submitter { record: item_id.clone(), email: email.clone() }, + &crate::access::Principal::Submitter { + record: item_id.clone(), + email: email.clone(), + }, crate::access::Action::SelfTransition, - &crate::access::Resource::Bucket { name: self_transition.bucket.clone() }, + &crate::access::Resource::Bucket { + name: self_transition.bucket.clone(), + }, serde_json::json!({ "from": answer.state, "to": self_transition.to, "stored_email": stored_email }), ); if !decision.allow { @@ -468,8 +539,15 @@ pub async fn self_transition_answer( .get(&self_transition.bucket) .map(|s| s.initial.clone()) .unwrap_or_else(|| OPEN_STATE.to_string()); - let in_reach = self_transition.from_states(&initial).iter().any(|s| s == &answer.state); - return Err(ServerFnError::new(if in_reach { "not found" } else { "already processed" })); + let in_reach = self_transition + .from_states(&initial) + .iter() + .any(|s| s == &answer.state); + return Err(ServerFnError::new(if in_reach { + "not found" + } else { + "already processed" + })); } let decided_ms = chrono::Utc::now().timestamp_millis(); @@ -499,11 +577,31 @@ pub async fn self_transition_answer( .put(&item_id, data.into()) .await .map_err(|e| ServerFnError::new(e.to_string()))?; - crate::mail::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses, answer.storyline).await; - crate::people::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await; + crate::mail::on_state( + &state, + &self_transition.bucket, + &item_id, + &self_transition.to, + &answer.responses, + answer.storyline, + ) + .await; + crate::people::on_state( + &state, + &self_transition.bucket, + &item_id, + &self_transition.to, + &answer.responses, + ) + .await; let parent_hashes = vec![item_id.clone()]; - let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms); + let chain_hash = hash_node( + &question_id, + &parent_hashes, + &decision_payload_for_event, + decided_ms, + ); let event = AnswerSubmitted { chain_hash, parent_hashes, @@ -560,7 +658,10 @@ mod tests { }, ]; let value = serde_json::Value::Array( - answers.iter().map(|a| serde_json::to_value(a).unwrap()).collect(), + answers + .iter() + .map(|a| serde_json::to_value(a).unwrap()) + .collect(), ); let round_tripped: Vec = serde_json::from_value(value).expect("Answer list round-trips"); diff --git a/src/app.rs b/src/app.rs index f8fff95..449f71e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,7 @@ use leptos::prelude::*; -use leptos_meta::{provide_meta_context, HashedStylesheet, Html, Link, MetaTags, Stylesheet, Title}; +use leptos_meta::{ + provide_meta_context, HashedStylesheet, Html, Link, MetaTags, Stylesheet, Title, +}; use leptos_router::{ components::{Route, Router, Routes}, hooks::{use_location, use_navigate, use_query_map}, @@ -9,7 +11,10 @@ use serde::{Deserialize, Serialize}; use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem}; use crate::auth::{current_user, User}; -use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, RowLink, SiteConfig, Transition}; +use crate::content::{ + is_qualified, render_inline_markdown, Alternative, Question, Responsible, RowLink, SiteConfig, + Transition, +}; use crate::i18n::t; use crate::resource::{get_requirement_binding, get_requirement_options, get_resource}; @@ -109,9 +114,8 @@ pub fn App() -> impl IntoView { // the head starts the fetch during hydration instead of after. On // redoal.com it lives on the content host, so a preconnect opens // that TLS session while the preload is still queued. - let wordmark_href = Memo::new(move |_| { - site.get().and_then(|r| r.ok()).and_then(|s| s.wordmark) - }); + let wordmark_href = + Memo::new(move |_| site.get().and_then(|r| r.ok()).and_then(|s| s.wordmark)); let wordmark_origin = Memo::new(move |_| { wordmark_href.get().and_then(|w| { let rest = w.strip_prefix("https://")?; @@ -371,7 +375,8 @@ fn QuestionView( // Swiper Element only registers its custom elements when its // (vendored, ~180KB) script runs - loaded once per page, and only // on pages where some alternative actually declares a deck. - let needs_swiper = question.slideshow.is_some() || question.alternatives.iter().any(|a| a.images.len() > 1); + let needs_swiper = + question.slideshow.is_some() || question.alternatives.iter().any(|a| a.images.len() > 1); // MapLibre is vendored and large, so it loads only where a field // actually asks for a place, and only where this instance can // serve tiles at all. @@ -379,10 +384,11 @@ fn QuestionView( let place_value = question.place.as_ref().map(crate::content::Place::as_value); let needs_map = site.maps && question.alternatives.iter().any(|a| { - a.features.iter().any(|f| f.requirements.iter().any(|r| r.kind == "location")) + a.features + .iter() + .any(|f| f.requirements.iter().any(|r| r.kind == "location")) }); - view! { {needs_swiper.then(|| view! { })} {needs_map @@ -489,7 +495,11 @@ fn QuestionView( /// valid way on, so a visitor who went up a branch whose context turned /// out not to apply is never left stranded. pub fn down_the_tree(path: &str, pages: &[(String, String)]) -> Option<(String, String)> { - let path = path.split(['?', '#']).next().unwrap_or("/").trim_end_matches('/'); + let path = path + .split(['?', '#']) + .next() + .unwrap_or("/") + .trim_end_matches('/'); if path.is_empty() { return None; } @@ -620,11 +630,22 @@ fn Announcements(current_id: String) -> impl IntoView { /// case ignored), or they are in the group that holds it. pub fn is_responsible(user: Option<&User>, responsible: &Responsible) -> bool { let Some(user) = user else { return false }; - if responsible.group.as_deref().is_some_and(|g| !g.is_empty() && user.groups.iter().any(|mine| mine == g)) { + if responsible + .group + .as_deref() + .is_some_and(|g| !g.is_empty() && user.groups.iter().any(|mine| mine == g)) + { return true; } - let contact = responsible.contact.trim().trim_start_matches("mailto:").trim().to_lowercase(); - user.email.as_deref().is_some_and(|e| !contact.is_empty() && e.trim().to_lowercase() == contact) + let contact = responsible + .contact + .trim() + .trim_start_matches("mailto:") + .trim() + .to_lowercase(); + user.email + .as_deref() + .is_some_and(|e| !contact.is_empty() && e.trim().to_lowercase() == contact) } /// One suggestion as the person who made it sees it: what they wrote @@ -660,13 +681,19 @@ fn SuggestEdit(question_id: String) -> impl IntoView { let sent = move || send.value().get().is_some_and(|r| r.is_ok()); let mine = { let question_id = question_id.clone(); - Resource::new(move || send.version().get(), move |_| my_suggestions(question_id.clone())) + Resource::new( + move || send.version().get(), + move |_| my_suggestions(question_id.clone()), + ) }; let on_submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); let value = text.get().map(|t| t.value()).unwrap_or_default(); if !value.trim().is_empty() { - send.dispatch(SuggestPageEdit { question_id: question_id.clone(), text: value }); + send.dispatch(SuggestPageEdit { + question_id: question_id.clone(), + text: value, + }); } }; view! { @@ -727,9 +754,10 @@ fn ResponsibleNote(responsible: Responsible) -> impl IntoView { #[cfg(feature = "hydrate")] { let target = event_target::(&ev); - if let (Some(user), Some(domain)) = - (target.get_attribute("data-user"), target.get_attribute("data-domain")) - { + if let (Some(user), Some(domain)) = ( + target.get_attribute("data-user"), + target.get_attribute("data-domain"), + ) { let _ = target.set_attribute("href", &format!("mailto:{user}@{domain}")); } } @@ -748,7 +776,6 @@ fn ResponsibleNote(responsible: Responsible) -> impl IntoView { } } - // Same idiomatic-typed-module approach as `yes` above, for the // prosekit-backed rich-text requirement kind (`prosekit-editor.js`, // project root - `#[wasm_bindgen(module = "/x.js")]` resolves relative @@ -871,7 +898,10 @@ fn Hero( // only the built-in house mark inverts; a content-shipped logo // keeps its colours unless site.yaml opts in with wordmark_invert. let house_wordmark = site.wordmark_invert.unwrap_or(site.wordmark.is_none()); - let wordmark = site.wordmark.clone().unwrap_or_else(|| "/wordmark.svg".to_string()); + let wordmark = site + .wordmark + .clone() + .unwrap_or_else(|| "/wordmark.svg".to_string()); // Clamped server-side too, but belt and braces for the inline style. let wordmark_style = site .wordmark_height @@ -999,7 +1029,11 @@ fn Hero( }; let top = view! {
{mark()}{session}
}; - let (bar_top, copy_top) = if has_slides { (Some(top), None) } else { (None, Some(top)) }; + let (bar_top, copy_top) = if has_slides { + (Some(top), None) + } else { + (None, Some(top)) + }; view! {
@@ -1128,9 +1162,11 @@ fn AlternativeCard( // confirmation instead of the normal form - the visitor already // holds everything the action needs (see // `answers::self_transition_answer`), there's nothing left to type. - if let (Some(st), Some(item_id), Some(email)) = - (alternative.self_transition.clone(), parent_hash.clone(), query_email) - { + if let (Some(st), Some(item_id), Some(email)) = ( + alternative.self_transition.clone(), + parent_hash.clone(), + query_email, + ) { let self_transition = ServerAction::::new(); let question_id_for_action = question_id.clone(); let alt_name_for_action = alternative.name.clone(); @@ -1175,7 +1211,10 @@ fn AlternativeCard( } let has_action = alternative.action.is_some(); - let has_requirements = alternative.features.iter().any(|f| !f.requirements.is_empty()); + let has_requirements = alternative + .features + .iter() + .any(|f| !f.requirements.is_empty()); // Nothing to submit, nothing to confirm, nothing to record: the // alternative is a gateway to another question, and its button is // just a link (a form submit would be a click that does nothing). @@ -1211,7 +1250,8 @@ fn AlternativeCard( std::collections::HashMap::new(); let mut select_field_map: std::collections::HashMap>> = std::collections::HashMap::new(); - let mut select_multi: std::collections::HashMap = std::collections::HashMap::new(); + let mut select_multi: std::collections::HashMap = + std::collections::HashMap::new(); // Gesture fields share field_map (their hidden input carries a // JSON string), but submit re-parses them so the stored answer // holds a real {points, key} object, not a double-encoded string. @@ -1222,7 +1262,12 @@ fn AlternativeCard( file_refs.entry(req.name.clone()).or_default(); } else if req.kind == "select" { // Preset by a card of one's own (`answers::fill_own`). - let preset: Vec = req.value.clone().filter(|v| !v.is_empty()).into_iter().collect(); + let preset: Vec = req + .value + .clone() + .filter(|v| !v.is_empty()) + .into_iter() + .collect(); select_field_map .entry(req.name.clone()) .or_insert_with(|| RwSignal::new(preset)); @@ -1234,7 +1279,11 @@ fn AlternativeCard( } // A voice field's preset is the key it records at, // not a value to submit. - let preset = if req.kind == "voice" { String::new() } else { req.value.clone().unwrap_or_default() }; + let preset = if req.kind == "voice" { + String::new() + } else { + req.value.clone().unwrap_or_default() + }; field_map .entry(req.name.clone()) .or_insert_with(|| RwSignal::new(preset)); @@ -1275,7 +1324,11 @@ fn AlternativeCard( if !pending.is_empty() { let items = pending .into_iter() - .map(|((feature_name, item_id), to)| TransitionItem { feature_name, item_id, to }) + .map(|((feature_name, item_id), to)| TransitionItem { + feature_name, + item_id, + to, + }) .collect(); transition_batch.dispatch(TransitionAnswers { question_id: question_id_for_transition.clone(), @@ -1315,7 +1368,9 @@ fn AlternativeCard( let ids = sig.get(); let multiple = select_multi_for_submit.get(name).copied().unwrap_or(false); let value = if multiple { - serde_json::Value::Array(ids.into_iter().map(serde_json::Value::String).collect()) + serde_json::Value::Array( + ids.into_iter().map(serde_json::Value::String).collect(), + ) } else { serde_json::Value::String(ids.into_iter().next().unwrap_or_default()) }; @@ -1335,7 +1390,9 @@ fn AlternativeCard( let Some(file) = files.get(0) else { continue }; // Too big is known before a byte is sent. if file.size() > MAX_UPLOAD_BYTES as f64 { - send_error.set(Some(t(&lang().get_untracked(), "upload_too_large").to_string())); + send_error.set(Some( + t(&lang().get_untracked(), "upload_too_large").to_string(), + )); sending.set(false); return; } @@ -1348,8 +1405,12 @@ fn AlternativeCard( // say so. Err(e) => { leptos::logging::error!("upload of {name} failed: {e:?}"); - let status = e.as_string().and_then(|m| m.rsplit(' ').next().and_then(|n| n.parse().ok())); - send_error.set(Some(t(&lang().get_untracked(), upload_refusal(status)).to_string())); + let status = e + .as_string() + .and_then(|m| m.rsplit(' ').next().and_then(|n| n.parse().ok())); + send_error.set(Some( + t(&lang().get_untracked(), upload_refusal(status)).to_string(), + )); sending.set(false); return; } @@ -1944,7 +2005,14 @@ fn ResourceFeature( let question_id = question_id.clone(); let alternative = alternative.clone(); let feature_name = feature_name.clone(); - move || (question_id.clone(), alternative.clone(), feature_name.clone(), finished_shown.get()) + move || { + ( + question_id.clone(), + alternative.clone(), + feature_name.clone(), + finished_shown.get(), + ) + } }, // Forwarding sibling requirement values as query params (for a // resource parameterized by other form fields) is deferred, see @@ -1952,7 +2020,10 @@ fn ResourceFeature( |(q, a, f, shown)| { let mut params = std::collections::HashMap::new(); if shown != crate::resource::FINISHED_SHOWN { - params.insert(crate::resource::FINISHED_PARAM.to_string(), shown.to_string()); + params.insert( + crate::resource::FINISHED_PARAM.to_string(), + shown.to_string(), + ); } get_resource(q, a, f, params) }, @@ -2038,10 +2109,17 @@ fn ResourceValue( let mut answers = answers; // Cards are shown in the order they were put up, the way a // team or a gallery is read; a list of cases, newest first. - let cards = transitions.is_empty() && answers.iter().all(|a| picture_of(a, bucket.as_deref()).is_some()); + let cards = transitions.is_empty() + && answers + .iter() + .all(|a| picture_of(a, bucket.as_deref()).is_some()); answers.sort_by(|a, b| { let open = |s: &str| s != crate::answers::OPEN_STATE; - let when = if cards { a.submitted_ms.cmp(&b.submitted_ms) } else { b.submitted_ms.cmp(&a.submitted_ms) }; + let when = if cards { + a.submitted_ms.cmp(&b.submitted_ms) + } else { + b.submitted_ms.cmp(&a.submitted_ms) + }; open(&a.state).cmp(&open(&b.state)).then(when) }); // The desk sent one finished record more than it shows, when @@ -2139,7 +2217,10 @@ fn ResourceValue( /// release pipeline writes (redoal's does) for a site to offer downloads. pub fn is_releases_manifest(value: &serde_json::Value) -> bool { value.get("latest").and_then(|v| v.as_str()).is_some() - && value.get("versions").and_then(|v| v.as_array()).is_some_and(|v| !v.is_empty()) + && value + .get("versions") + .and_then(|v| v.as_array()) + .is_some_and(|v| !v.is_empty()) } /// The platform a browser says it is, in the manifest's words: @@ -2173,11 +2254,27 @@ pub fn platform_of(user_agent: &str) -> &'static str { /// that are not files - a package repo, TestFlight. #[component] fn ReleasesCard(manifest: serde_json::Value) -> impl IntoView { - let name = manifest.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let latest = manifest.get("latest").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let name = manifest + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let latest = manifest + .get("latest") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); let newest = manifest["versions"][0].clone(); - let date = newest.get("date").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let notes = newest.get("notes").and_then(|v| v.as_str()).filter(|u| u.starts_with("https://")).map(str::to_string); + let date = newest + .get("date") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let notes = newest + .get("notes") + .and_then(|v| v.as_str()) + .filter(|u| u.starts_with("https://")) + .map(str::to_string); let assets: Vec<(String, String, String)> = newest .get("assets") .and_then(|a| a.as_array()) @@ -2186,13 +2283,20 @@ fn ReleasesCard(manifest: serde_json::Value) -> impl IntoView { .filter_map(|x| { let platform = x.get("platform")?.as_str()?.to_string(); let url = x.get("url")?.as_str()?.to_string(); - let sha = x.get("sha256").and_then(|s| s.as_str()).unwrap_or("").to_string(); + let sha = x + .get("sha256") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); Some((platform, url, sha)) }) .collect() }) .unwrap_or_default(); - let channels = manifest.get("channels").cloned().unwrap_or(serde_json::Value::Null); + let channels = manifest + .get("channels") + .cloned() + .unwrap_or(serde_json::Value::Null); let arch = channels.get("arch").cloned(); let ios = channels.get("ios").cloned(); @@ -2203,13 +2307,16 @@ fn ReleasesCard(manifest: serde_json::Value) -> impl IntoView { platform.set(Some(platform_of(&ua))); } }); - let label = |p: &str| match p { - "linux-x86_64" => "Linux (x86_64)", - "linux-aarch64" => "Linux (aarch64)", - "macos" | "macos-arm64" => "macOS", - "windows" | "windows-x86_64" => "Windows", - other => other, - }.to_string(); + let label = |p: &str| { + match p { + "linux-x86_64" => "Linux (x86_64)", + "linux-aarch64" => "Linux (aarch64)", + "macos" | "macos-arm64" => "macOS", + "windows" | "windows-x86_64" => "Windows", + other => other, + } + .to_string() + }; let assets_for_list = assets.clone(); let arch_for_pick = arch.clone(); let ios_for_pick = ios.clone(); @@ -2271,11 +2378,11 @@ fn ReleasesCard(manifest: serde_json::Value) -> impl IntoView { /// and `SelectField`'s option rendering, since both need "guess a /// display label out of an otherwise-arbitrary shaped object" and /// should agree on the same guesses. -fn text_field( - obj: &serde_json::Map, - keys: &[&str], -) -> Option { - keys.iter().find_map(|k| obj.get(*k)).and_then(|v| v.as_str()).map(str::to_string) +fn text_field(obj: &serde_json::Map, keys: &[&str]) -> Option { + keys.iter() + .find_map(|k| obj.get(*k)) + .and_then(|v| v.as_str()) + .map(str::to_string) } /// One card in a generic (non-`Answer`) resource list - see @@ -2298,12 +2405,22 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView { .iter() .filter(|(k, _)| !known.contains(&k.as_str())) .find_map(|(_, v)| crate::maps::read(v)) - .map(|(lat, lon, zoom)| (crate::maps::still_url(lat, lon, zoom, 480, 240), format!("{lat}, {lon}"))); + .map(|(lat, lon, zoom)| { + ( + crate::maps::still_url(lat, lon, zoom, 480, 240), + format!("{lat}, {lon}"), + ) + }); let extra: Vec<(String, String)> = obj .iter() - .filter(|(k, v)| !known.contains(&k.as_str()) && !v.is_null() && crate::maps::read(v).is_none()) + .filter(|(k, v)| { + !known.contains(&k.as_str()) && !v.is_null() && crate::maps::read(v).is_none() + }) .map(|(k, v)| { - let text = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); + let text = v + .as_str() + .map(str::to_string) + .unwrap_or_else(|| v.to_string()); (k.clone(), text) }) .collect(); @@ -2485,8 +2602,15 @@ fn SelectField( /// uuid, a dash, and the name it was sent with (`upload.rs`). pub fn uploaded_filename(value: &str) -> Option<&str> { let (id, rest) = value.split_at_checked(36)?; - let shaped = id.char_indices().all(|(i, c)| if matches!(i, 8 | 13 | 18 | 23) { c == '-' } else { c.is_ascii_hexdigit() }); - rest.strip_prefix('-').filter(|name| shaped && !name.is_empty()) + let shaped = id.char_indices().all(|(i, c)| { + if matches!(i, 8 | 13 | 18 | 23) { + c == '-' + } else { + c.is_ascii_hexdigit() + } + }); + rest.strip_prefix('-') + .filter(|name| shaped && !name.is_empty()) } /// The largest file one upload may carry. Here, not in `upload.rs`, @@ -2511,13 +2635,18 @@ fn upload_refusal(status: Option) -> &'static str { /// has no use for. fn server_message(e: &ServerFnError) -> String { let text = e.to_string(); - text.rsplit_once(": ").map(|(_, m)| m.to_string()).filter(|m| !m.is_empty()).unwrap_or(text) + text.rsplit_once(": ") + .map(|(_, m)| m.to_string()) + .filter(|m| !m.is_empty()) + .unwrap_or(text) } /// Whether an uploaded file is a picture a browser shows as it is. pub fn is_picture(filename: &str) -> bool { let lower = filename.to_ascii_lowercase(); - [".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"].iter().any(|ext| lower.ends_with(ext)) + [".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"] + .iter() + .any(|ext| lower.ends_with(ext)) } /// A record that carries a picture, in a list nobody works from, is @@ -2525,10 +2654,14 @@ pub fn is_picture(filename: &str) -> bool { /// as its heading. A team, a gallery of what has been built. fn picture_of(answer: &Answer, bucket: Option<&str>) -> Option { let bucket = bucket?; - answer.responses.as_object()?.iter().find_map(|(key, value)| { - let name = uploaded_filename(value.as_str()?)?; - is_picture(name).then(|| format!("/attachment/{bucket}/{}/{key}", answer.id)) - }) + answer + .responses + .as_object()? + .iter() + .find_map(|(key, value)| { + let name = uploaded_filename(value.as_str()?)?; + is_picture(name).then(|| format!("/attachment/{bucket}/{}/{key}", answer.id)) + }) } #[component] @@ -2564,11 +2697,7 @@ fn AnswerRow( return AnswerCard(answer, picture, link).into_any(); } } - let responses = answer - .responses - .as_object() - .cloned() - .unwrap_or_default(); + let responses = answer.responses.as_object().cloned().unwrap_or_default(); let submitted_ms = answer.submitted_ms; let notes = answer.notes.clone(); let state = answer.state.clone(); @@ -2769,7 +2898,9 @@ fn AnswerCard(answer: Answer, picture: String, link: Option) -> impl In /// before the value goes into a URL, not a lookup against Iconify's /// real prefix/name list. fn is_safe_icon_name(s: &str) -> bool { - !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':') + !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ':') } fn format_ms(ms: i64) -> String { @@ -2838,7 +2969,10 @@ pub async fn get_question( return Ok(None); }; let session: tower_sessions::Session = leptos_axum::extract().await?; - let user = session.get::(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?; + let user = session + .get::(SESSION_USER_KEY) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; fill_from_user(&mut question, user.as_ref()); crate::answers::fill_own(&state.jetstream, &mut question, user.as_ref()).await; let mut chain_gate = None; @@ -2851,8 +2985,7 @@ pub async fn get_question( Some(hash) => crate::chain::lookup_node(&state.jetstream, hash) .await .is_some_and(|node| { - node.question_id == *target - || path_matches(target, &node.question_id).is_some() + node.question_id == *target || path_matches(target, &node.question_id).is_some() }), None => false, }; @@ -2877,7 +3010,11 @@ pub async fn get_question( pub async fn get_site() -> Result { use crate::server::AppState; let state = expect_context::(); - let mut site = state.site.load().at(crate::server::storyline::current().await).clone(); + let mut site = state + .site + .load() + .at(crate::server::storyline::current().await) + .clone(); // Not content: whether this instance can serve tiles at all is its // own configuration, and the browser has to know before it decides // whether to draw a map or ask for two numbers. @@ -2930,7 +3067,11 @@ pub async fn list_qualifying_questions( let access = state.access.load(); let principal = crate::access::Principal::from_user(user.as_ref()); let may_open = |q: &Question| { - access.allows(&principal, crate::access::Action::Open, &crate::access::Resource::Page { id: q.id.clone() }) + access.allows( + &principal, + crate::access::Action::Open, + &crate::access::Resource::Page { id: q.id.clone() }, + ) }; let mut out: Vec<(String, String)> = questions .values() @@ -2968,8 +3109,11 @@ pub async fn list_announcements() -> Result, ServerFnError> { .load() .values() .filter(|q| { - access.allows(&principal, crate::access::Action::Open, &crate::access::Resource::Page { id: q.id.clone() }) - && q.event_live_at(now_ms) + access.allows( + &principal, + crate::access::Action::Open, + &crate::access::Resource::Page { id: q.id.clone() }, + ) && q.event_live_at(now_ms) }) .filter_map(|q| { let event = q.event.as_ref()?; @@ -3032,15 +3176,30 @@ pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(), let state = expect_context::(); let session: tower_sessions::Session = leptos_axum::extract().await?; - let user = session.get::(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?; + let user = session + .get::(SESSION_USER_KEY) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; let text = text.trim().to_string(); if text.is_empty() || text.len() > 8000 { - return Err(ServerFnError::new("a suggestion is between one and 8000 characters")); + return Err(ServerFnError::new( + "a suggestion is between one and 8000 characters", + )); } - let question = state.questions.load().get(&question_id).cloned().ok_or_else(|| ServerFnError::new("no such page"))?; - let responsible = question.responsible.clone().ok_or_else(|| ServerFnError::new("this page names no one responsible"))?; + let question = state + .questions + .load() + .get(&question_id) + .cloned() + .ok_or_else(|| ServerFnError::new("no such page"))?; + let responsible = question + .responsible + .clone() + .ok_or_else(|| ServerFnError::new("this page names no one responsible"))?; if !is_responsible(user.as_ref(), &responsible) { - return Err(ServerFnError::new("only whoever is responsible for this page may suggest changes to it here")); + return Err(ServerFnError::new( + "only whoever is responsible for this page may suggest changes to it here", + )); } let user = user.expect("is_responsible needs a user"); let email = user.email.clone().unwrap_or_default(); @@ -3075,12 +3234,26 @@ pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(), storyline: 0, }; if let Err(e) = emit_answer_submitted(&state.nats, &event).await { - tracing::warn!(question_id, "the suggestion is on file but was not announced: {e}"); + tracing::warn!( + question_id, + "the suggestion is on file but was not announced: {e}" + ); } if let Ok(bytes) = serde_json::to_vec(&responses) { - let _ = state.nats.publish("portal.edits.suggested", bytes.into()).await; + let _ = state + .nats + .publish("portal.edits.suggested", bytes.into()) + .await; } - crate::mail::on_edit_suggestion(&state, &question_id, &question.name, &user.display_name, &email, &text).await; + crate::mail::on_edit_suggestion( + &state, + &question_id, + &question.name, + &user.display_name, + &email, + &text, + ) + .await; Ok(()) } @@ -3099,12 +3272,23 @@ pub async fn my_suggestions(question_id: String) -> Result, Se let state = expect_context::(); let session: tower_sessions::Session = leptos_axum::extract().await?; - let user = session.get::(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?; - let Some(email) = user.and_then(|u| u.email).map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()) else { + let user = session + .get::(SESSION_USER_KEY) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let Some(email) = user + .and_then(|u| u.email) + .map(|e| e.trim().to_lowercase()) + .filter(|e| !e.is_empty()) + else { return Ok(Vec::new()); }; // Made on first write: nobody has suggested anything yet. - let Ok(store) = state.jetstream.get_key_value(crate::content::EDIT_SUGGESTIONS_BUCKET).await else { + let Ok(store) = state + .jetstream + .get_key_value(crate::content::EDIT_SUGGESTIONS_BUCKET) + .await + else { return Ok(Vec::new()); }; let keys: Vec = { @@ -3116,13 +3300,26 @@ pub async fn my_suggestions(question_id: String) -> Result, Se }; let mut mine = Vec::new(); for key in keys { - let Ok(Some(bytes)) = store.get(&key).await else { continue }; - let Ok(answer) = serde_json::from_slice::(&bytes) else { continue }; - let theirs = answer.responses.get("email").and_then(|v| v.as_str()).is_some_and(|e| e.trim().to_lowercase() == email); + let Ok(Some(bytes)) = store.get(&key).await else { + continue; + }; + let Ok(answer) = serde_json::from_slice::(&bytes) else { + continue; + }; + let theirs = answer + .responses + .get("email") + .and_then(|v| v.as_str()) + .is_some_and(|e| e.trim().to_lowercase() == email); if theirs && answer.question_id == question_id { mine.push(MySuggestion { id: answer.id, - text: answer.responses.get("text").and_then(|v| v.as_str()).unwrap_or_default().to_string(), + text: answer + .responses + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), state: answer.state, submitted_ms: answer.submitted_ms, }); @@ -3160,8 +3357,9 @@ pub async fn submit_answer( // path (what the client holds as its question id) resolves to the // pattern page it came from. let storyline = crate::server::storyline::current().await; - let question = crate::content::resolve_question(state.questions.load().at(storyline), &question_id) - .ok_or_else(|| ServerFnError::new("unknown question"))?; + let question = + crate::content::resolve_question(state.questions.load().at(storyline), &question_id) + .ok_or_else(|| ServerFnError::new("unknown question"))?; let session: tower_sessions::Session = leptos_axum::extract().await?; let user = session @@ -3174,7 +3372,9 @@ pub async fn submit_answer( &state.access.load(), &principal, crate::access::Action::Submit, - &crate::access::Resource::Page { id: question_id.clone() }, + &crate::access::Resource::Page { + id: question_id.clone(), + }, serde_json::json!({ "alternative": alternative }), ); if !submit.allow { @@ -3197,17 +3397,38 @@ pub async fn submit_answer( // One card each: whose it is comes from the session, and a file the // person did not choose again is the one already on their card. let owner = if alt.one_per_person { - let user = user.as_ref().ok_or_else(|| ServerFnError::new("sign in to keep a card of your own"))?; - let bucket = record_as.clone().ok_or_else(|| ServerFnError::new("this alternative keeps nothing"))?; - let previous = crate::answers::own_records(&state.jetstream, &bucket, &user.username).await.into_iter().next(); + let user = user + .as_ref() + .ok_or_else(|| ServerFnError::new("sign in to keep a card of your own"))?; + let bucket = record_as + .clone() + .ok_or_else(|| ServerFnError::new("this alternative keeps nothing"))?; + let previous = crate::answers::own_records(&state.jetstream, &bucket, &user.username) + .await + .into_iter() + .next(); if let Some(map) = responses.as_object_mut() { - for req in alt.features.iter().flat_map(|f| &f.requirements).filter(|r| r.kind == "file") { - let chosen = map.get(&req.name).and_then(|v| v.as_str()).is_some_and(|v| !v.is_empty()); - if let (false, Some(kept)) = (chosen, previous.as_ref().and_then(|p| p.responses.get(&req.name))) { + for req in alt + .features + .iter() + .flat_map(|f| &f.requirements) + .filter(|r| r.kind == "file") + { + let chosen = map + .get(&req.name) + .and_then(|v| v.as_str()) + .is_some_and(|v| !v.is_empty()); + if let (false, Some(kept)) = ( + chosen, + previous.as_ref().and_then(|p| p.responses.get(&req.name)), + ) { map.insert(req.name.clone(), kept.clone()); } } - map.insert(crate::answers::OWNER_FIELD.to_string(), serde_json::Value::String(user.username.clone())); + map.insert( + crate::answers::OWNER_FIELD.to_string(), + serde_json::Value::String(user.username.clone()), + ); } Some((bucket, user.username.clone())) } else { @@ -3222,19 +3443,29 @@ pub async fn submit_answer( let (Some(of), true) = (&requirement.of, requirement.kind == "record") else { continue; }; - let given = responses.get(&requirement.name).and_then(|v| v.as_str()).map(str::trim).unwrap_or_default(); + let given = responses + .get(&requirement.name) + .and_then(|v| v.as_str()) + .map(str::trim) + .unwrap_or_default(); if given.is_empty() { if requirement.optional { continue; } - return Err(ServerFnError::new(format!("this answer is missing the {} it is about", requirement.name))); + return Err(ServerFnError::new(format!( + "this answer is missing the {} it is about", + requirement.name + ))); } let found = match state.jetstream.get_key_value(of).await { Ok(store) => store.get(given).await.ok().flatten().is_some(), Err(_) => false, }; if !found { - return Err(ServerFnError::new(format!("the {} this is about is not on file", requirement.name))); + return Err(ServerFnError::new(format!( + "the {} this is about is not on file", + requirement.name + ))); } } @@ -3248,18 +3479,31 @@ pub async fn submit_answer( &state.access.load(), &principal, crate::access::Action::Invite, - &crate::access::Resource::Group { name: invite.group.clone() }, + &crate::access::Resource::Group { + name: invite.group.clone(), + }, serde_json::json!({}), ); if !may.allow { - return Err(ServerFnError::new("not authorized to invite into this group")); + return Err(ServerFnError::new( + "not authorized to invite into this group", + )); } - let people = crate::people::People::from_env().ok_or_else(|| ServerFnError::new(crate::people::People::NOT_CONFIGURED))?; + let people = crate::people::People::from_env() + .ok_or_else(|| ServerFnError::new(crate::people::People::NOT_CONFIGURED))?; let Some((name, who)) = crate::people::who_for(&responses) else { - return Err(ServerFnError::new("an invite needs a name, and a phone number or an email")); + return Err(ServerFnError::new( + "an invite needs a name, and a phone number or an email", + )); }; let mut invited = people - .invite(&state, &name, &who, &invite.group, &principal_name(&principal)) + .invite( + &state, + &name, + &who, + &invite.group, + &principal_name(&principal), + ) .await .map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?; // This invite's own words, and the form they draw on: kept with @@ -3269,7 +3513,10 @@ pub async fn submit_answer( invited.fields = responses.as_object().cloned().unwrap_or_default(); } if let Some(map) = responses.as_object_mut() { - map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default()); + map.insert( + "invited".to_string(), + serde_json::to_value(&invited).unwrap_or_default(), + ); } // Through Kanidm the invite is its own case, with a link that // runs out; a membership is the case already. @@ -3277,15 +3524,19 @@ pub async fn submit_answer( record_as.get_or_insert_with(|| crate::people::INVITES_BUCKET.to_string()); } if let Ok(bytes) = serde_json::to_vec(&invited) { - let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.into()).await; + let _ = state + .nats + .publish(crate::people::INVITED_SUBJECT, bytes.into()) + .await; } crate::mail::on_invite(&state, &invited).await; } // `/shape/{curve.key}`: the next page may be chosen by the answer. let next = match alt.action.as_deref() { Some(action) => Some( - crate::content::template_action(action, &responses) - .ok_or_else(|| ServerFnError::new("this answer is missing what the next page needs"))?, + crate::content::template_action(action, &responses).ok_or_else(|| { + ServerFnError::new("this answer is missing what the next page needs") + })?, ), None => None, }; @@ -3311,8 +3562,14 @@ pub async fn submit_answer( // Index the node so requires_chain pages can verify lineage. // Best-effort: the NATS event above is the durable record. - if let Err(e) = - crate::chain::record_node(&state.jetstream, &chain_hash, &question_id, &alternative, timestamp_ms).await + if let Err(e) = crate::chain::record_node( + &state.jetstream, + &chain_hash, + &question_id, + &alternative, + timestamp_ms, + ) + .await { tracing::error!("failed to index chain node: {e}"); } @@ -3346,8 +3603,21 @@ pub async fn submit_answer( } // Arrival is a state too: what the schema says the first // state sends, it sends now. - if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) { - crate::mail::on_state(&state, bucket, &chain_hash, &initial, &responses, storyline as u32).await; + if let Some(initial) = state + .aggregates + .load() + .get(bucket) + .map(|s| s.initial.clone()) + { + crate::mail::on_state( + &state, + bucket, + &chain_hash, + &initial, + &responses, + storyline as u32, + ) + .await; crate::people::on_state(&state, bucket, &chain_hash, &initial, &responses).await; } } @@ -3405,7 +3675,9 @@ mod down_the_tree_tests { use super::down_the_tree; fn pages(ids: &[&str]) -> Vec<(String, String)> { - ids.iter().map(|id| (id.to_string(), format!("page {id}"))).collect() + ids.iter() + .map(|id| (id.to_string(), format!("page {id}"))) + .collect() } #[test] @@ -3418,15 +3690,32 @@ mod down_the_tree_tests { assert_eq!(down_the_tree("/status/oppslag", &p).unwrap().0, "/"); assert_eq!(down_the_tree("/a/b/c/d", &p).unwrap().0, "/"); // A trailing slash or a query does not count as a level. - assert_eq!(down_the_tree("/review/abc/?chain=x", &p).unwrap().0, "/review"); + assert_eq!( + down_the_tree("/review/abc/?chain=x", &p).unwrap().0, + "/review" + ); // Named: the start, or the page's question in quotation marks. - assert_eq!(super::down_the_tree_label("no", "/", "Hva gjør vi?"), "Tilbake til starten"); - assert_eq!(super::down_the_tree_label("no", "/review", "Hva kom inn?"), "Tilbake til «Hva kom inn?»"); - assert_eq!(super::down_the_tree_label("en", "/review", "What came in?"), "Back to \u{201c}What came in?\u{201d}"); + assert_eq!( + super::down_the_tree_label("no", "/", "Hva gjør vi?"), + "Tilbake til starten" + ); + assert_eq!( + super::down_the_tree_label("no", "/review", "Hva kom inn?"), + "Tilbake til «Hva kom inn?»" + ); + assert_eq!( + super::down_the_tree_label("en", "/review", "What came in?"), + "Back to \u{201c}What came in?\u{201d}" + ); // The front door has nowhere further down. assert!(down_the_tree("/", &p).is_none()); // A visitor who may not open the front door still gets "/". - assert_eq!(down_the_tree("/melde-fra", &pages(&["/melde-fra"])).unwrap().0, "/"); + assert_eq!( + down_the_tree("/melde-fra", &pages(&["/melde-fra"])) + .unwrap() + .0, + "/" + ); } } @@ -3437,37 +3726,78 @@ mod responsible_tests { use crate::content::Responsible; fn user(email: Option<&str>, groups: &[&str]) -> User { - User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: email.map(String::from) } + User { + sub: "s".into(), + username: "kari".into(), + display_name: "Kari".into(), + groups: groups.iter().map(|g| g.to_string()).collect(), + phone: None, + email: email.map(String::from), + } } fn named(contact: &str, group: Option<&str>) -> Responsible { - Responsible { name: "R".into(), contact: contact.into(), group: group.map(String::from) } + Responsible { + name: "R".into(), + contact: contact.into(), + group: group.map(String::from), + } } #[test] fn an_uploads_key_shows_as_the_name_it_was_sent_with() { - assert_eq!(super::uploaded_filename("3f2b8c1e-9a4d-4e7b-8c21-0a1b2c3d4e5f-rapport 2026.pdf"), Some("rapport 2026.pdf")); + assert_eq!( + super::uploaded_filename("3f2b8c1e-9a4d-4e7b-8c21-0a1b2c3d4e5f-rapport 2026.pdf"), + Some("rapport 2026.pdf") + ); assert_eq!(super::uploaded_filename("rapport.pdf"), None); - assert_eq!(super::uploaded_filename("3f2b8c1e-9a4d-4e7b-8c21-0a1b2c3d4e5f-"), None); - assert_eq!(super::uploaded_filename("Storgata 1, 1850 Mysen - se vedlegg i e-post fra oss"), None); + assert_eq!( + super::uploaded_filename("3f2b8c1e-9a4d-4e7b-8c21-0a1b2c3d4e5f-"), + None + ); + assert_eq!( + super::uploaded_filename("Storgata 1, 1850 Mysen - se vedlegg i e-post fra oss"), + None + ); } #[test] fn only_the_person_a_page_names_may_suggest_a_change_to_it() { - assert!(is_responsible(Some(&user(Some("kari@vel.no"), &[])), &named("mailto:Kari@Vel.no", None))); - assert!(!is_responsible(Some(&user(Some("ola@vel.no"), &[])), &named("kari@vel.no", None))); - assert!(!is_responsible(Some(&user(None, &[])), &named("kari@vel.no", None))); + assert!(is_responsible( + Some(&user(Some("kari@vel.no"), &[])), + &named("mailto:Kari@Vel.no", None) + )); + assert!(!is_responsible( + Some(&user(Some("ola@vel.no"), &[])), + &named("kari@vel.no", None) + )); + assert!(!is_responsible( + Some(&user(None, &[])), + &named("kari@vel.no", None) + )); assert!(!is_responsible(None, &named("kari@vel.no", None))); - assert!(!is_responsible(Some(&user(Some(""), &[])), &named("", None))); + assert!(!is_responsible( + Some(&user(Some(""), &[])), + &named("", None) + )); } #[test] fn a_page_a_group_answers_for_is_every_members() { let board = named("post@vel.no", Some("styret")); - assert!(is_responsible(Some(&user(Some("ola@vel.no"), &["styret"])), &board)); - assert!(!is_responsible(Some(&user(Some("ola@vel.no"), &["trafikk"])), &board)); + assert!(is_responsible( + Some(&user(Some("ola@vel.no"), &["styret"])), + &board + )); + assert!(!is_responsible( + Some(&user(Some("ola@vel.no"), &["trafikk"])), + &board + )); // The inbox itself, signed in, still counts. - assert!(is_responsible(Some(&user(Some("post@vel.no"), &[])), &board)); + assert!(is_responsible( + Some(&user(Some("post@vel.no"), &[])), + &board + )); assert!(!is_responsible(None, &board)); } } @@ -3499,7 +3829,10 @@ mod picture_tests { "responses": { "name": "Kari", "portrett": "0f8fad5b-d9cb-469f-a165-70867728950e-kari.jpg", "cv": "0f8fad5b-d9cb-469f-a165-70867728950e-cv.pdf" } })) .unwrap(); - assert_eq!(picture_of(&answer, Some("ansatte")).as_deref(), Some("/attachment/ansatte/r1/portrett")); + assert_eq!( + picture_of(&answer, Some("ansatte")).as_deref(), + Some("/attachment/ansatte/r1/portrett") + ); // Without a bucket there is nowhere to fetch it from. assert_eq!(picture_of(&answer, None), None); } @@ -3511,11 +3844,26 @@ mod releases_tests { #[test] fn a_browser_names_its_platform_in_the_manifests_words() { - assert_eq!(platform_of("Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X)"), "ios"); - assert_eq!(platform_of("Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0)"), "macos"); - assert_eq!(platform_of("Mozilla/5.0 (X11; Linux x86_64) Firefox"), "linux-x86_64"); - assert_eq!(platform_of("Mozilla/5.0 (X11; Linux aarch64) Firefox"), "linux-aarch64"); - assert_eq!(platform_of("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"), "windows"); + assert_eq!( + platform_of("Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X)"), + "ios" + ); + assert_eq!( + platform_of("Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0)"), + "macos" + ); + assert_eq!( + platform_of("Mozilla/5.0 (X11; Linux x86_64) Firefox"), + "linux-x86_64" + ); + assert_eq!( + platform_of("Mozilla/5.0 (X11; Linux aarch64) Firefox"), + "linux-aarch64" + ); + assert_eq!( + platform_of("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"), + "windows" + ); assert_eq!(platform_of("Mozilla/5.0 (Linux; Android 15)"), "android"); assert_eq!(platform_of("curl/8"), "other"); } @@ -3524,7 +3872,9 @@ mod releases_tests { fn only_a_manifest_shape_is_a_manifest() { let m = serde_json::json!({"name":"redoal","latest":"0.1.17","versions":[{"version":"0.1.17","assets":[]}]}); assert!(is_releases_manifest(&m)); - assert!(!is_releases_manifest(&serde_json::json!({"latest":"1","versions":[]}))); + assert!(!is_releases_manifest( + &serde_json::json!({"latest":"1","versions":[]}) + )); assert!(!is_releases_manifest(&serde_json::json!([{"name":"x"}]))); } } diff --git a/src/chain.rs b/src/chain.rs index 6c4a632..84c787e 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -53,7 +53,9 @@ pub async fn record_node( alternative: Some(alternative.to_string()), timestamp_ms, }; - store.put(chain_hash, serde_json::to_vec(&node)?.into()).await?; + store + .put(chain_hash, serde_json::to_vec(&node)?.into()) + .await?; Ok(()) } diff --git a/src/content/gitea.rs b/src/content/gitea.rs index bf74acc..820fca2 100644 --- a/src/content/gitea.rs +++ b/src/content/gitea.rs @@ -9,7 +9,10 @@ use super::*; /// config; a file that exists but doesn't parse or validate is a real /// error, surfaced at boot rather than papered over. #[cfg(feature = "ssr")] -pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result> { +pub async fn load_site_from_gitea( + repo_url: &str, + branch: &str, +) -> anyhow::Result> { let (owner, repo) = parse_owner_repo(repo_url)?; let api_base = gitea_api_base(repo_url)?; let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await { @@ -24,8 +27,8 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul let arity = super::variants::arity(&files)?; let mut each = Vec::with_capacity(arity); for n in 0..arity { - let site: SiteConfig = - serde_yaml::from_str(&super::variants::storyline(&files, n)[0].1).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?; + let site: SiteConfig = serde_yaml::from_str(&super::variants::storyline(&files, n)[0].1) + .map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?; site.validate()?; each.push(site); } @@ -40,7 +43,10 @@ pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Resul #[cfg(feature = "ssr")] pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result { let (owner, repo) = parse_owner_repo(repo_url)?; - Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?)) + Ok(format!( + "{}/api/v1/repos/{owner}/{repo}/raw", + gitea_api_base(repo_url)? + )) } #[cfg(feature = "ssr")] @@ -114,7 +120,8 @@ pub(crate) async fn fetch_gitea_file( pub async fn load_aggregates_from_gitea( repo_url: &str, branch: &str, -) -> anyhow::Result>> { +) -> anyhow::Result>> +{ let (owner, repo) = parse_owner_repo(repo_url)?; let api_base = gitea_api_base(repo_url)?; let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?; @@ -123,7 +130,10 @@ pub async fn load_aggregates_from_gitea( let files = vec![("aggregates.yaml".to_string(), raw)]; let arity = super::variants::arity(&files)?; let each = (0..arity) - .map(|n| crate::aggregates::parse_aggregates_yaml(&super::variants::storyline(&files, n)[0].1).map(with_builtin_aggregates)) + .map(|n| { + crate::aggregates::parse_aggregates_yaml(&super::variants::storyline(&files, n)[0].1) + .map(with_builtin_aggregates) + }) .collect::>>()?; Ok(Storylines { each }) } @@ -183,7 +193,11 @@ pub async fn load_questions_from_gitea( #[cfg(feature = "ssr")] pub fn same_arity(questions: usize, aggregates: usize, site: usize) -> anyhow::Result { let arity = questions.max(aggregates).max(site); - for (what, n) in [("questions", questions), ("aggregates.yaml", aggregates), ("site.yaml", site)] { + for (what, n) in [ + ("questions", questions), + ("aggregates.yaml", aggregates), + ("site.yaml", site), + ] { if n != 1 && n != arity { anyhow::bail!("{what} tells {n} storyline(s), but the site tells {arity}; every list of texts has the same length"); } @@ -219,9 +233,13 @@ pub async fn watch_for_reload( repo_url: String, branch: String, subdir: String, - questions: std::sync::Arc>>>, + questions: std::sync::Arc< + arc_swap::ArcSwap>>, + >, aggregates: std::sync::Arc< - arc_swap::ArcSwap>>, + arc_swap::ArcSwap< + Storylines>, + >, >, site: std::sync::Arc>>, access: std::sync::Arc>, @@ -255,14 +273,23 @@ pub async fn watch_for_reload( }; match load_questions_from_gitea(&repo_url, &branch, &subdir).await { Ok(loaded) => { - if let Err(e) = validate_questions(&loaded, &loaded_aggregates).and_then(|_| same_arity(loaded.arity(), loaded_aggregates.arity(), loaded_site.arity())) { + if let Err(e) = validate_questions(&loaded, &loaded_aggregates).and_then(|_| { + same_arity( + loaded.arity(), + loaded_aggregates.arity(), + loaded_site.arity(), + ) + }) { tracing::error!(error = %e, "content reload failed validation, keeping last-good content"); continue; } let count = loaded.len(); // The policy is compiled from the content it gates, so // it swaps in the same breath. - access.store(std::sync::Arc::new(crate::access::Policy::from_content(&loaded, &loaded_aggregates))); + access.store(std::sync::Arc::new(crate::access::Policy::from_content( + &loaded, + &loaded_aggregates, + ))); questions.store(std::sync::Arc::new(loaded)); aggregates.store(std::sync::Arc::new(loaded_aggregates)); site.store(std::sync::Arc::new(loaded_site)); @@ -275,4 +302,3 @@ pub async fn watch_for_reload( } } } - diff --git a/src/content/markdown.rs b/src/content/markdown.rs index 07b0b4e..200edaf 100644 --- a/src/content/markdown.rs +++ b/src/content/markdown.rs @@ -118,7 +118,6 @@ pub(crate) fn rewrite_img_tag(tag: &str) -> String { cleaned.replacen(" String { // What someone typed on a new line is on a new line. Event::SoftBreak => Some(Event::HardBreak), Event::Start(Tag::Link { dest_url, .. }) - if !(dest_url.starts_with("https://") || dest_url.starts_with("mailto:") || dest_url.starts_with("tel:")) => + if !(dest_url.starts_with("https://") + || dest_url.starts_with("mailto:") + || dest_url.starts_with("tel:")) => { in_dropped_link += 1; None @@ -160,7 +161,8 @@ pub fn render_written_markdown(text: &str) -> String { }); let mut out = String::new(); html::push_html(&mut out, filtered); - out.trim().replace("Line one
\nline two

"); assert!(md("- NS 3424\n- NS 3423").contains("
    \n
  • NS 3424
  • ")); - assert!(md("**Sivilingeniør**, *NTNU*").contains("Sivilingeniør, NTNU")); - assert_eq!(md("[Westra](https://westra.no)"), "

    Westra

    "); + assert!(md("**Sivilingeniør**, *NTNU*") + .contains("Sivilingeniør, NTNU")); + assert_eq!( + md("[Westra](https://westra.no)"), + "

    Westra

    " + ); // Everything that would put something else in the page is gone. assert_eq!(md(""), ""); assert_eq!(md("hi "), "

    hi

    "); assert_eq!(md("[click](javascript:alert(1))"), "

    click

    "); - assert_eq!(md("[home](/kontor)"), "

    home

    ", "a site path is the site's to link, not a bio's"); - assert_eq!(md("before ![pixel](https://tracker.example/p.gif) after"), "

    before after

    "); + assert_eq!( + md("[home](/kontor)"), + "

    home

    ", + "a site path is the site's to link, not a bio's" + ); + assert_eq!( + md("before ![pixel](https://tracker.example/p.gif) after"), + "

    before after

    " + ); assert_eq!(md("# Shouting"), "

    Shouting

    "); } } diff --git a/src/content/mod.rs b/src/content/mod.rs index 2c72386..c7b66e6 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -13,19 +13,19 @@ use serde::{Deserialize, Serialize}; // Loading is server-only; the rest is shared with the browser build. #[cfg(feature = "ssr")] mod gitea; -#[cfg(feature = "ssr")] -mod variants; mod handlers; mod markdown; mod validate; +#[cfg(feature = "ssr")] +mod variants; #[cfg(feature = "ssr")] pub use gitea::*; -#[cfg(feature = "ssr")] -pub use variants::*; pub use handlers::*; pub use markdown::*; pub use validate::*; +#[cfg(feature = "ssr")] +pub use variants::*; /// One page: a prompt plus the paths on from it. The `questions/` /// directory tree IS the URL tree (`index.yaml` names its directory, @@ -129,7 +129,10 @@ impl Place { pub fn validate(&self) -> Result<(), String> { if !(-90.0..=90.0).contains(&self.lat) || !(-180.0..=180.0).contains(&self.lon) { - return Err(format!("lat {} / lon {} is not a point on Earth", self.lat, self.lon)); + return Err(format!( + "lat {} / lon {} is not a point on Earth", + self.lat, self.lon + )); } if !(0.0..=22.0).contains(&self.zoom) { return Err(format!("zoom {} is outside 0 to 22", self.zoom)); @@ -158,13 +161,23 @@ pub fn parse_duration_ms(text: &str) -> Result { let mut total: i64 = 0; let mut seen = false; for token in text.split_whitespace() { - let (num, unit) = token.split_at(token.trim_end_matches(|c: char| c.is_ascii_alphabetic()).len()); - let n: i64 = num.parse().map_err(|_| format!("bad duration {text:?}: {token:?}"))?; + let (num, unit) = token.split_at( + token + .trim_end_matches(|c: char| c.is_ascii_alphabetic()) + .len(), + ); + let n: i64 = num + .parse() + .map_err(|_| format!("bad duration {text:?}: {token:?}"))?; let per = match unit { "m" => 60_000, "h" => 3_600_000, "d" => 86_400_000, - _ => return Err(format!("bad duration {text:?}: unit {unit:?} (use m, h, d)")), + _ => { + return Err(format!( + "bad duration {text:?}: unit {unit:?} (use m, h, d)" + )) + } }; total += n * per; seen = true; @@ -228,7 +241,9 @@ pub fn fill_from_user(question: &mut Question, user: Option<&crate::auth::User>) for alt in &mut question.alternatives { for feature in &mut alt.features { for req in &mut feature.requirements { - let Some(value) = req.value.as_deref() else { continue }; + let Some(value) = req.value.as_deref() else { + continue; + }; let filled = match value { "{user.email}" => user.and_then(|u| u.email.clone()), "{user.name}" => user.map(|u| u.display_name.clone()), @@ -274,7 +289,13 @@ pub fn template_action(action: &str, responses: &serde_json::Value) -> Option, + /// Where `/profile.mobileconfig` gets its values. Absent means the + /// site offers no configuration profile and that route is a 404. + #[serde(default)] + pub profile: Option, +} + +/// An Apple configuration profile rendered from the mail server's own PACC +/// document - see `profile.rs`. Nothing here describes a server: `pacc` says +/// where the description already lives, so hosts and ports are not written +/// down a second time to drift from the first. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ProfileConfig { + /// The PACC document, normally + /// `https:///.well-known/user-agent-configuration.json`. + pub pacc: String, + /// What the accounts are called on the device. Defaults to the site's + /// own host. + #[serde(default)] + pub name: Option, + /// Shown as the profile's publisher. Defaults to `name`. + #[serde(default)] + pub organization: Option, } /// The sender identity, and the one built-in mail's wording. @@ -867,7 +919,10 @@ pub struct HeroConfig { impl Default for HeroConfig { fn default() -> Self { - Self { kind: default_hero_kind(), module: None } + Self { + kind: default_hero_kind(), + module: None, + } } } @@ -907,6 +962,14 @@ impl SiteConfig { ); } } + if let Some(profile) = &self.profile { + if !profile.pacc.starts_with("https://") { + anyhow::bail!( + "site.yaml: profile.pacc {:?} must be an https:// URL (the PACC document the profile is built from)", + profile.pacc + ); + } + } if let Some(icon) = &self.favicon { let ok = [".svg", ".png", ".ico"].iter().any(|e| icon.ends_with(e)); if !is_safe_site_path(icon) || !ok { @@ -1084,7 +1147,12 @@ pub fn earned_followups( queue.push_back(q); } while let Some(q) = queue.pop_front() { - for action in q.alternatives.iter().filter(|a| asks_nothing(a)).filter_map(|a| a.action.as_deref()) { + for action in q + .alternatives + .iter() + .filter(|a| asks_nothing(a)) + .filter_map(|a| a.action.as_deref()) + { let Some(id) = action_target_id(questions, action) else { continue; }; @@ -1177,9 +1245,13 @@ pub fn build_questions( /// Every storyline of the content, each a full map of questions. #[cfg(feature = "ssr")] -pub fn build_storylines(files: &[(String, String)]) -> anyhow::Result>> { +pub fn build_storylines( + files: &[(String, String)], +) -> anyhow::Result>> { let arity = variants::arity(files)?; - let each = (0..arity).map(|n| build_one_storyline(&variants::storyline(files, n))).collect::>>()?; + let each = (0..arity) + .map(|n| build_one_storyline(&variants::storyline(files, n))) + .collect::>>()?; Ok(variants::Storylines { each }) } @@ -1195,8 +1267,8 @@ fn build_one_storyline( if name != "_section.yaml" { continue; } - let mut section: SectionConfig = serde_yaml::from_str(raw) - .map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?; + let mut section: SectionConfig = + serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?; let dir = dir_from_path(rel); if let Some(rc) = §ion.requires_chain { section.requires_chain = Some(resolve_ref(&dir, rc)); @@ -1301,7 +1373,9 @@ aggregates: .unwrap() } - fn question_with_transitions(transitions_yaml: &str) -> std::collections::HashMap { + fn question_with_transitions( + transitions_yaml: &str, + ) -> std::collections::HashMap { let question: Question = serde_yaml::from_str(&format!( r#" id: /t @@ -1321,7 +1395,6 @@ alternatives: std::collections::HashMap::from([(question.id.clone(), question)]) } - fn question_with_record(field_yaml: &str) -> std::collections::HashMap { let question: Question = serde_yaml::from_str(&format!( r#" @@ -1356,9 +1429,13 @@ alternatives: let good = question_with_record(" - { name: case, type: record, of: things }"); validate_questions(&good, &schema_fixture()).unwrap(); - let nowhere = question_with_record(" - { name: case, type: record, of: nothing_declared }"); + let nowhere = + question_with_record(" - { name: case, type: record, of: nothing_declared }"); let err = validate_questions(&nowhere, &schema_fixture()).unwrap_err(); - assert!(err.to_string().contains("has no aggregate declared"), "{err}"); + assert!( + err.to_string().contains("has no aggregate declared"), + "{err}" + ); let unsaid = question_with_record(" - { name: case, type: record }"); let err = validate_questions(&unsaid, &schema_fixture()).unwrap_err(); @@ -1367,12 +1444,19 @@ alternatives: // `of` means one thing, so it belongs to one type. let stray = question_with_record(" - { name: case, of: things }"); let err = validate_questions(&stray, &schema_fixture()).unwrap_err(); - assert!(err.to_string().contains("only a type: record field has"), "{err}"); + assert!( + err.to_string().contains("only a type: record field has"), + "{err}" + ); // The id arrives on the link; it is not looked up. let bound = question_with_record(" - name: case\n type: record\n of: things\n bind:\n field: what\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }"); let err = validate_questions(&bound, &schema_fixture()).unwrap_err(); - assert!(err.to_string().contains("the link it came in on carries the id"), "{err}"); + assert!( + err.to_string() + .contains("the link it came in on carries the id"), + "{err}" + ); } // A state that grants a group makes an account for someone, so the @@ -1384,7 +1468,9 @@ alternatives: ) .unwrap(); - let asks = question_with_record(" - { name: name }\n - { name: email, type: email }"); + let asks = question_with_record( + " - { name: name }\n - { name: email, type: email }", + ); validate_questions(&asks, &granting).unwrap(); let no_name = question_with_record(" - { name: email, type: email }"); @@ -1407,7 +1493,10 @@ alternatives: let undeclared = crate::aggregates::parse_aggregates_yaml( "aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions: {}\n", ); - assert!(undeclared.unwrap_err().to_string().contains("not a declared transition")); + assert!(undeclared + .unwrap_err() + .to_string() + .contains("not a declared transition")); } #[test] @@ -1458,7 +1547,10 @@ alternatives: " bind:\n field: other\n param: p\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }", ); let err = validate_questions(&bound, &schema_fixture()).unwrap_err(); - assert!(err.to_string().contains("the visitor puts the pin"), "{err}"); + assert!( + err.to_string().contains("the visitor puts the pin"), + "{err}" + ); } #[test] @@ -1468,7 +1560,13 @@ alternatives: let no_zoom = question_with_location(" value: \"59.8938,11.0332\""); validate_questions(&no_zoom, &schema_fixture()).unwrap(); - for bad in ["\"59.8938\"", "\"91,11\"", "\"59,200\"", "\"here\"", "\"59,11,15,3\""] { + for bad in [ + "\"59.8938\"", + "\"91,11\"", + "\"59,200\"", + "\"here\"", + "\"59,11,15,3\"", + ] { let questions = question_with_location(&format!(" value: {bad}")); let err = validate_questions(&questions, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("opens the map"), "{bad}: {err}"); @@ -1477,7 +1575,12 @@ alternatives: #[test] fn a_page_about_a_place_says_where_and_how_close() { - let place = Place { lat: 59.8938, lon: 11.0332, zoom: 15.0, name: Some("Tomter".into()) }; + let place = Place { + lat: 59.8938, + lon: 11.0332, + zoom: 15.0, + name: Some("Tomter".into()), + }; place.validate().unwrap(); assert_eq!(place.as_value(), "59.8938,11.0332,15"); // The default is a street and its neighbours, not the globe. @@ -1486,10 +1589,30 @@ alternatives: assert!(bare.name.is_none()); for bad in [ - Place { lat: 91.0, lon: 11.0, zoom: 15.0, name: None }, - Place { lat: 59.0, lon: 181.0, zoom: 15.0, name: None }, - Place { lat: 59.0, lon: 11.0, zoom: 30.0, name: None }, - Place { lat: 59.0, lon: 11.0, zoom: 15.0, name: Some(" ".into()) }, + Place { + lat: 91.0, + lon: 11.0, + zoom: 15.0, + name: None, + }, + Place { + lat: 59.0, + lon: 181.0, + zoom: 15.0, + name: None, + }, + Place { + lat: 59.0, + lon: 11.0, + zoom: 30.0, + name: None, + }, + Place { + lat: 59.0, + lon: 11.0, + zoom: 15.0, + name: Some(" ".into()), + }, ] { assert!(bad.validate().is_err(), "{bad:?}"); } @@ -1497,7 +1620,8 @@ alternatives: #[test] fn undeclared_from_state_is_rejected() { - let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }"); + let questions = + question_with_transitions(" - { from: bogus, to: middle, label: X }"); let err = validate_questions(&questions, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("transition.from")); } @@ -1506,19 +1630,23 @@ alternatives: fn undeclared_edge_is_rejected() { // Both states exist, but open -> done skips a step the graph // never declared. - let questions = question_with_transitions(" - { from: open, to: done, label: Skip }"); + let questions = + question_with_transitions(" - { from: open, to: done, label: Skip }"); let err = validate_questions(&questions, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("not a declared edge")); } #[test] fn dangling_action_is_rejected() { - let question: Question = - serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /nowhere\n") - .unwrap(); + let question: Question = serde_yaml::from_str( + "id: /a\nname: A\nalternatives:\n - name: Go\n action: /nowhere\n", + ) + .unwrap(); let questions = std::collections::HashMap::from([(question.id.clone(), question)]); let err = validate_questions(&questions, &Default::default()).unwrap_err(); - assert!(err.to_string().contains("does not match any declared question id")); + assert!(err + .to_string() + .contains("does not match any declared question id")); } #[test] @@ -1606,7 +1734,9 @@ alternatives: " - { name: email, type: email, relay: \"wss://relay.redoal.com\" }", ); let err = validate_questions(&questions, &Default::default()).unwrap_err(); - assert!(err.to_string().contains("relay only makes sense on type: gesture")); + assert!(err + .to_string() + .contains("relay only makes sense on type: gesture")); } #[test] @@ -1645,7 +1775,8 @@ alternatives: let absolute: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: https://x/y.js }").unwrap(); assert!(absolute.validate().is_err()); - let ok: SiteConfig = serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap(); + let ok: SiteConfig = + serde_yaml::from_str("hero: { kind: module, module: hero.js }").unwrap(); assert!(ok.validate().is_ok()); } @@ -1665,7 +1796,10 @@ alternatives: assert_eq!(route_from_path("index.yaml"), "/"); assert_eq!(route_from_path("applied.yaml"), "/applied"); assert_eq!(route_from_path("develop/index.yaml"), "/develop"); - assert_eq!(route_from_path("develop/proposal.yaml"), "/develop/proposal"); + assert_eq!( + route_from_path("develop/proposal.yaml"), + "/develop/proposal" + ); assert_eq!(route_from_path("review/[record].yaml"), "/review/[record]"); } @@ -1680,8 +1814,10 @@ alternatives: #[test] fn dynamic_patterns_capture_segments() { - assert_eq!(path_matches("/review/[record]", "/review/abc"), - Some(vec![("record".into(), "abc".into())])); + assert_eq!( + path_matches("/review/[record]", "/review/abc"), + Some(vec![("record".into(), "abc".into())]) + ); assert_eq!(path_matches("/review/[record]", "/review"), None); assert_eq!(path_matches("/review/[record]", "/other/abc"), None); assert_eq!(path_matches("/review", "/review"), Some(vec![])); @@ -1690,25 +1826,55 @@ alternatives: #[test] fn tree_becomes_router_with_inference_and_sections() { let files = vec![ - ("index.yaml".to_string(), "name: Home\nalternatives:\n - name: go\n action: applied\n".to_string()), - ("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()), - ("develop/index.yaml".to_string(), "name: Develop\nalternatives:\n - name: propose\n action: proposal\n".to_string()), - ("develop/proposal.yaml".to_string(), "name: Proposal\nalternatives:\n - name: send\n action: proposed\n".to_string()), - ("develop/proposed.yaml".to_string(), "name: Proposed\n".to_string()), - ("review/_section.yaml".to_string(), "qualifies: portal_owners\n".to_string()), - ("review/index.yaml".to_string(), "name: Review\n".to_string()), - ("review/_draft.yaml".to_string(), "not even valid yaml: [\n".to_string()), + ( + "index.yaml".to_string(), + "name: Home\nalternatives:\n - name: go\n action: applied\n".to_string(), + ), + ( + "applied.yaml".to_string(), + "name: Applied\nfollowup: true\n".to_string(), + ), + ( + "develop/index.yaml".to_string(), + "name: Develop\nalternatives:\n - name: propose\n action: proposal\n" + .to_string(), + ), + ( + "develop/proposal.yaml".to_string(), + "name: Proposal\nalternatives:\n - name: send\n action: proposed\n".to_string(), + ), + ( + "develop/proposed.yaml".to_string(), + "name: Proposed\n".to_string(), + ), + ( + "review/_section.yaml".to_string(), + "qualifies: portal_owners\n".to_string(), + ), + ( + "review/index.yaml".to_string(), + "name: Review\n".to_string(), + ), + ( + "review/_draft.yaml".to_string(), + "not even valid yaml: [\n".to_string(), + ), ]; let questions = build_questions(&files).unwrap(); // Derived ids and resolved relative actions. - assert_eq!(questions["/"].alternatives[0].action.as_deref(), Some("/applied")); + assert_eq!( + questions["/"].alternatives[0].action.as_deref(), + Some("/applied") + ); assert_eq!( questions["/develop"].alternatives[0].action.as_deref(), Some("/develop/proposal") ); assert_eq!( - questions["/develop/proposal"].alternatives[0].action.as_deref(), + questions["/develop/proposal"].alternatives[0] + .action + .as_deref(), Some("/develop/proposed") ); @@ -1720,7 +1886,10 @@ alternatives: assert!(questions["/develop/proposal"].is_followup()); // Section inheritance gates the directory; drafts are skipped. - assert_eq!(questions["/review"].qualifies.as_deref(), Some("portal_owners")); + assert_eq!( + questions["/review"].qualifies.as_deref(), + Some("portal_owners") + ); assert_eq!(questions["/"].qualifies, None); assert!(!questions.contains_key("/review/_draft")); } @@ -1751,7 +1920,10 @@ alternatives: }; // Templated actions resolve to the dynamic page's pattern. - assert_eq!(action_target_id(&questions, "/shape/{curve.key}").as_deref(), Some("/shape/[key]")); + assert_eq!( + action_target_id(&questions, "/shape/{curve.key}").as_deref(), + Some("/shape/[key]") + ); assert_eq!(action_target_id(&questions, "/nowhere"), None); // No chain, no followups: standing on the front door earns none @@ -1762,9 +1934,18 @@ alternatives: assert_eq!(earned("/applied", &[]), set(&[])); // A verified answer earns exactly where it sent the visitor. - assert_eq!(unlocked_by(&questions, "/", Some("apply")), vec!["/applied"]); - assert_eq!(unlocked_by(&questions, "/", Some("draw")), vec!["/shape/[key]"]); - assert_eq!(unlocked_by(&questions, "/kontakt", Some("send")), vec!["/takk"]); + assert_eq!( + unlocked_by(&questions, "/", Some("apply")), + vec!["/applied"] + ); + assert_eq!( + unlocked_by(&questions, "/", Some("draw")), + vec!["/shape/[key]"] + ); + assert_eq!( + unlocked_by(&questions, "/kontakt", Some("send")), + vec!["/takk"] + ); assert_eq!(earned("/applied", &["/applied"]), set(&["/applied"])); // An old node without the alternative: the answered question's // recording alternatives, and nothing beyond that question. @@ -1776,7 +1957,10 @@ alternatives: // "watch" records an answer, so /subscribed is not earned by // having drawn a shape; "back" leads to the front door, which // is never a followup and ends the walk. - assert_eq!(earned("/shape/abc", &["/shape/[key]"]), set(&["/shape/[key]"])); + assert_eq!( + earned("/shape/abc", &["/shape/[key]"]), + set(&["/shape/[key]"]) + ); assert_eq!(earned("/takk", &["/takk"]), set(&["/takk"])); // The email-link page is earned by nothing on the site. assert!(!earned("/", &["/applied"]).contains("/watching")); @@ -1817,7 +2001,12 @@ alternatives: let page = resolve_question(&questions, "/review/abc123").unwrap(); assert_eq!(page.id, "/review/abc123"); assert_eq!( - page.alternatives[0].features[0].resource.as_ref().unwrap().key.as_deref(), + page.alternatives[0].features[0] + .resource + .as_ref() + .unwrap() + .key + .as_deref(), Some("abc123") ); assert!(resolve_question(&questions, "/review").is_none()); @@ -1835,7 +2024,10 @@ alternatives: ), ]; let questions = build_questions(&files).unwrap(); - assert_eq!(questions["/shaped"].requires_chain.as_deref(), Some("/shape")); + assert_eq!( + questions["/shaped"].requires_chain.as_deref(), + Some("/shape") + ); assert!(validate_questions(&questions, &Default::default()).is_ok()); let dangling = vec![( @@ -1850,7 +2042,8 @@ alternatives: fn recorded_buckets_must_be_attended() { let write_only = vec![( "index.yaml".to_string(), - "name: Home\nalternatives:\n - name: send\n action: /\n record_as: black_hole\n".to_string(), + "name: Home\nalternatives:\n - name: send\n action: /\n record_as: black_hole\n" + .to_string(), )]; let questions = build_questions(&write_only).unwrap(); let err = validate_questions(&questions, &Default::default()).unwrap_err(); @@ -1885,9 +2078,13 @@ alternatives: let files = vec![ ( "index.yaml".to_string(), - "name: Home\nalternatives:\n - name: go\n action: /review/[record]\n".to_string(), + "name: Home\nalternatives:\n - name: go\n action: /review/[record]\n" + .to_string(), + ), + ( + "review/[record].yaml".to_string(), + "name: Record\n".to_string(), ), - ("review/[record].yaml".to_string(), "name: Record\n".to_string()), ]; let questions = build_questions(&files).unwrap(); assert!(validate_questions(&questions, &Default::default()).is_err()); @@ -1930,7 +2127,9 @@ alternatives: .unwrap(); let mut qs = std::collections::HashMap::new(); qs.insert(bad.id.clone(), bad); - let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string(); + let err = validate_questions(&qs, &Default::default()) + .unwrap_err() + .to_string(); assert!(err.contains("bad starts"), "{err}"); let dynamic: Question = serde_yaml::from_str( @@ -1939,7 +2138,9 @@ alternatives: .unwrap(); let mut qs = std::collections::HashMap::new(); qs.insert(dynamic.id.clone(), dynamic); - let err = validate_questions(&qs, &Default::default()).unwrap_err().to_string(); + let err = validate_questions(&qs, &Default::default()) + .unwrap_err() + .to_string(); assert!(err.contains("dynamic page can't carry an event"), "{err}"); } @@ -1948,14 +2149,20 @@ alternatives: let merged = with_builtin_aggregates(Default::default()); let schema = &merged[EVENTS_BUCKET]; assert_eq!(schema.initial, "announced"); - assert_eq!(schema.allowed("announced"), ["awaiting_summary", "summarized"]); + assert_eq!( + schema.allowed("announced"), + ["awaiting_summary", "summarized"] + ); assert_eq!(schema.allowed("awaiting_summary"), ["summarized"]); let content = crate::aggregates::parse_aggregates_yaml( "aggregates:\n - bucket: portal_events\n initial: mine\n states:\n mine: { event: mine }\n", ) .unwrap(); - assert_eq!(with_builtin_aggregates(content)[EVENTS_BUCKET].initial, "mine"); + assert_eq!( + with_builtin_aggregates(content)[EVENTS_BUCKET].initial, + "mine" + ); } #[test] @@ -1981,20 +2188,39 @@ alternatives: fn a_signed_in_person_is_not_asked_who_they_are() { let yaml = "id: /oppslag/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: email\n type: email\n value: \"{user.email}\"\n - name: navn\n value: \"{user.name}\"\n - name: hva\n type: textarea\n value: \"{user.email} says\"\n"; let q: Question = serde_yaml::from_str(yaml).unwrap(); - let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) }; + let user = crate::auth::User { + sub: "s".into(), + username: "kari".into(), + display_name: "Kari N".into(), + groups: vec![], + phone: None, + email: Some("kari@vel.no".into()), + }; let mut signed_in = q.clone(); fill_from_user(&mut signed_in, Some(&user)); let reqs = &signed_in.alternatives[0].features[0].requirements; - assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("hidden", Some("kari@vel.no"))); - assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("hidden", Some("Kari N"))); + assert_eq!( + (reqs[0].kind.as_str(), reqs[0].value.as_deref()), + ("hidden", Some("kari@vel.no")) + ); + assert_eq!( + (reqs[1].kind.as_str(), reqs[1].value.as_deref()), + ("hidden", Some("Kari N")) + ); // Mixed text is not a user field, and is left as written. assert_eq!(reqs[2].value.as_deref(), Some("{user.email} says")); // A visitor is asked: the placeholder goes, the field stays. let mut visitor = q.clone(); fill_from_user(&mut visitor, None); let reqs = &visitor.alternatives[0].features[0].requirements; - assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("email", None)); - assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None)); + assert_eq!( + (reqs[0].kind.as_str(), reqs[0].value.as_deref()), + ("email", None) + ); + assert_eq!( + (reqs[1].kind.as_str(), reqs[1].value.as_deref()), + ("text", None) + ); } #[test] @@ -2040,8 +2266,14 @@ alternatives: render_inline_markdown("![J](https://x.no/j.jpg \"wat\")"), "\"J\"" ); - assert_eq!(render_inline_markdown("[ok](/shape)"), "ok"); - assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "mail"); + assert_eq!( + render_inline_markdown("[ok](/shape)"), + "ok" + ); + assert_eq!( + render_inline_markdown("[mail](mailto:bl@uhhm.no)"), + "mail" + ); } #[test] @@ -2063,7 +2295,10 @@ alternatives: #[test] fn action_templates_from_responses() { let r = serde_json::json!({"curve": {"key": "20aa98", "points": [[0,0]]}, "n": 3}); - assert_eq!(template_action("/shape/{curve.key}", &r).as_deref(), Some("/shape/20aa98")); + assert_eq!( + template_action("/shape/{curve.key}", &r).as_deref(), + Some("/shape/20aa98") + ); assert_eq!(template_action("/x/{n}/y", &r).as_deref(), Some("/x/3/y")); assert_eq!(template_action("/shape", &r).as_deref(), Some("/shape")); assert!(template_action("/shape/{curve.missing}", &r).is_none()); @@ -2081,7 +2316,12 @@ alternatives: let mut qs = std::collections::HashMap::new(); qs.insert(q.id.clone(), q); let page = resolve_question(&qs, "/shape/20aa98").unwrap(); - assert_eq!(page.alternatives[0].features[0].requirements[0].value.as_deref(), Some("20aa98")); + assert_eq!( + page.alternatives[0].features[0].requirements[0] + .value + .as_deref(), + Some("20aa98") + ); let q: Question = serde_yaml::from_str( "id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n resource:\n source: { kind: url, url: \"https://relay.example/place/{key}\" }\n public: true\n", @@ -2090,7 +2330,12 @@ alternatives: let mut qs = std::collections::HashMap::new(); qs.insert(q.id.clone(), q); let page = resolve_question(&qs, "/shape/20aa98").unwrap(); - match &page.alternatives[0].features[0].resource.as_ref().unwrap().source { + match &page.alternatives[0].features[0] + .resource + .as_ref() + .unwrap() + .source + { ResourceSource::Url { url } => assert_eq!(url, "https://relay.example/place/20aa98"), _ => panic!(), } diff --git a/src/content/validate.rs b/src/content/validate.rs index 031cd80..51ff833 100644 --- a/src/content/validate.rs +++ b/src/content/validate.rs @@ -107,12 +107,18 @@ pub fn with_builtin_aggregates( /// runtime's own `portal_events` (a warning, not a failure: a repo /// with no event pages has nothing to attend). #[cfg(feature = "ssr")] -pub fn bucket_is_read(questions: &std::collections::HashMap, bucket: &str) -> bool { +pub fn bucket_is_read( + questions: &std::collections::HashMap, + bucket: &str, +) -> bool { questions.values().any(|q| { q.alternatives.iter().any(|a| { - a.features - .iter() - .any(|f| f.resource.as_ref().and_then(|r| r.bucket()).is_some_and(|b| b == bucket)) + a.features.iter().any(|f| { + f.resource + .as_ref() + .and_then(|r| r.bucket()) + .is_some_and(|b| b == bucket) + }) }) }) } @@ -125,12 +131,21 @@ pub fn validate_questions( // A mail to the submitter needs an address to go to: every form // that records into that bucket has to collect a required email. for (bucket, schema) in aggregates { - let submitter_states: Vec<&String> = schema.mail_for_state.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| s).collect(); + let submitter_states: Vec<&String> = schema + .mail_for_state + .iter() + .filter(|(_, m)| m.to_submitter()) + .map(|(s, _)| s) + .collect(); if submitter_states.is_empty() { continue; } for question in questions.values() { - for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) { + for alternative in question + .alternatives + .iter() + .filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) + { let collects_email = alternative .features .iter() @@ -154,10 +169,20 @@ pub fn validate_questions( anyhow::bail!("question {:?}: slideshow has no slides", question.id); } if !(20..=100).contains(&show.max_height) { - anyhow::bail!("question {:?}: slideshow max_height is {} svh; between 20 and 100", question.id, show.max_height); + anyhow::bail!( + "question {:?}: slideshow max_height is {} svh; between 20 and 100", + question.id, + show.max_height + ); } for slide in &show.slides { - let ok = slide.image.starts_with("https://") || crate::content::is_safe_site_path(slide.image.trim_start_matches("/site/").trim_start_matches('/')); + let ok = slide.image.starts_with("https://") + || crate::content::is_safe_site_path( + slide + .image + .trim_start_matches("/site/") + .trim_start_matches('/'), + ); if !ok { anyhow::bail!("question {:?}: slideshow image {:?} is neither an https address nor a path in the content repo", question.id, slide.image); } @@ -171,10 +196,20 @@ pub fn validate_questions( // has nowhere to go. for (bucket, schema) in aggregates { for (state, spec) in &schema.mail_for_state { - let Some(parent) = crate::mail::parent_of(&spec.to) else { continue }; + let Some(parent) = crate::mail::parent_of(&spec.to) else { + continue; + }; for question in questions.values() { - for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) { - let has = alternative.features.iter().flat_map(|f| &f.requirements).any(|r| r.name == parent && r.kind == "record" && r.of.is_some()); + for alternative in question + .alternatives + .iter() + .filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) + { + let has = alternative + .features + .iter() + .flat_map(|f| &f.requirements) + .any(|r| r.name == parent && r.kind == "record" && r.of.is_some()); if !has { anyhow::bail!( "question {:?} alternative {:?}: records into {bucket:?}, whose state {state:?} mails {:?}, so it must carry a `type: record` field called {parent:?} with `of`", @@ -199,7 +234,11 @@ pub fn validate_questions( } granting.sort(); for question in questions.values() { - for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) { + for alternative in question + .alternatives + .iter() + .filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) + { let required = |name: &str, kind: &str| { alternative .features @@ -220,7 +259,10 @@ pub fn validate_questions( for question in questions.values() { if let Some(event) = &question.event { if question.is_dynamic() { - anyhow::bail!("question {:?}: a dynamic page can't carry an event", question.id); + anyhow::bail!( + "question {:?}: a dynamic page can't carry an event", + question.id + ); } if let Err(e) = event.window_ms() { anyhow::bail!("question {:?}: event: {e}", question.id); @@ -361,10 +403,12 @@ pub fn validate_questions( question.id, alternative.name, feature.name, requirement.name, requirement.kind ); } - validate_relay_url(relay).map_err(|e| anyhow::anyhow!( + validate_relay_url(relay).map_err(|e| { + anyhow::anyhow!( "question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}", question.id, alternative.name, feature.name, requirement.name - ))?; + ) + })?; } if requirement.kind == "gesture" && (requirement.resource.is_some() || requirement.bind.is_some()) @@ -388,8 +432,12 @@ pub fn validate_questions( let parts: Vec<&str> = value.split(',').map(str::trim).collect(); let ok = (2..=3).contains(&parts.len()) && parts.iter().all(|p| p.parse::().is_ok()) - && parts[0].parse::().is_ok_and(|lat| (-90.0..=90.0).contains(&lat)) - && parts[1].parse::().is_ok_and(|lon| (-180.0..=180.0).contains(&lon)); + && parts[0] + .parse::() + .is_ok_and(|lat| (-90.0..=90.0).contains(&lat)) + && parts[1] + .parse::() + .is_ok_and(|lon| (-180.0..=180.0).contains(&lon)); if !ok { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - its `value` opens the map and must be `lat,lon` or `lat,lon,zoom`, not {value:?}", @@ -468,7 +516,11 @@ pub fn validate_questions( question.id, alternative.name, feature.name, transition.to, bucket ); } - if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) { + if !schema + .allowed(&transition.from) + .iter() + .any(|s| s == &transition.to) + { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}", question.id, alternative.name, feature.name, transition.from, transition.to, bucket @@ -515,7 +567,10 @@ pub fn validate_questions( // A form is sent with its button, and the button is only // drawn for an alternative that says where to go afterwards: // without `action` the person fills it in and cannot send it. - let asks = alternative.features.iter().any(|f| f.requirements.iter().any(|r| r.kind != "hidden")); + let asks = alternative + .features + .iter() + .any(|f| f.requirements.iter().any(|r| r.kind != "hidden")); if (asks || alternative.invite.is_some() || alternative.record_as.is_some()) && alternative.action.is_none() && alternative.self_transition.is_none() @@ -549,4 +604,3 @@ pub fn validate_questions( } Ok(()) } - diff --git a/src/content/variants.rs b/src/content/variants.rs index faf2a08..65b495f 100644 --- a/src/content/variants.rs +++ b/src/content/variants.rs @@ -31,7 +31,16 @@ use serde_yaml::Value; /// Keys whose value is one string to a person. -const TEXT: &[&str] = &["name", "description", "label", "placeholder", "subject", "body", "empty", "title"]; +const TEXT: &[&str] = &[ + "name", + "description", + "label", + "placeholder", + "subject", + "body", + "empty", + "title", +]; /// Keys whose value is a list of strings to a person; their storylines /// are a list of lists. const TEXT_LIST: &[&str] = &["consequence", "encouragements"]; @@ -41,7 +50,12 @@ const TEXT_LIST: &[&str] = &["consequence", "encouragements"]; fn is_identifier(path: &[String], key: &str) -> bool { // `path` ends with the key itself, then the item's index, then the // list it is in. - key == "name" && path.iter().rev().nth(2).is_some_and(|p| p == "requirements") + key == "name" + && path + .iter() + .rev() + .nth(2) + .is_some_and(|p| p == "requirements") } fn is_strings(seq: &[Value]) -> bool { @@ -49,7 +63,11 @@ fn is_strings(seq: &[Value]) -> bool { } fn is_lists_of_strings(seq: &[Value]) -> bool { - !seq.is_empty() && seq.iter().all(|v| v.as_sequence().is_some_and(|s| s.iter().all(|x| x.is_string()))) + !seq.is_empty() + && seq.iter().all(|v| { + v.as_sequence() + .is_some_and(|s| s.iter().all(|x| x.is_string())) + }) } /// The storyline counts found in a document, with where each was. @@ -60,8 +78,18 @@ fn counts(value: &Value, path: &mut Vec, out: &mut Vec<(String, usize)>) let key = k.as_str().unwrap_or_default().to_string(); path.push(key.clone()); let variants = match v { - Value::Sequence(seq) if TEXT.contains(&key.as_str()) && !is_identifier(path, &key) && is_strings(seq) => Some(seq.len()), - Value::Sequence(seq) if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => Some(seq.len()), + Value::Sequence(seq) + if TEXT.contains(&key.as_str()) + && !is_identifier(path, &key) + && is_strings(seq) => + { + Some(seq.len()) + } + Value::Sequence(seq) + if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => + { + Some(seq.len()) + } _ => None, }; match variants { @@ -90,8 +118,18 @@ fn pick(value: &mut Value, path: &mut Vec, n: usize) { let key = k.as_str().unwrap_or_default().to_string(); path.push(key.clone()); let chosen = match v { - Value::Sequence(seq) if TEXT.contains(&key.as_str()) && !is_identifier(path, &key) && is_strings(seq) => Some(seq[n.min(seq.len() - 1)].clone()), - Value::Sequence(seq) if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => Some(seq[n.min(seq.len() - 1)].clone()), + Value::Sequence(seq) + if TEXT.contains(&key.as_str()) + && !is_identifier(path, &key) + && is_strings(seq) => + { + Some(seq[n.min(seq.len() - 1)].clone()) + } + Value::Sequence(seq) + if TEXT_LIST.contains(&key.as_str()) && is_lists_of_strings(seq) => + { + Some(seq[n.min(seq.len() - 1)].clone()) + } _ => None, }; match chosen { @@ -118,7 +156,9 @@ fn pick(value: &mut Value, path: &mut Vec, n: usize) { pub fn arity(files: &[(String, String)]) -> anyhow::Result { let mut found: Vec<(String, String, usize)> = Vec::new(); for (rel, raw) in files { - let Ok(value) = serde_yaml::from_str::(raw) else { continue }; + let Ok(value) = serde_yaml::from_str::(raw) else { + continue; + }; let mut out = Vec::new(); counts(&value, &mut Vec::new(), &mut out); found.extend(out.into_iter().map(|(p, n)| (rel.clone(), p, n))); @@ -135,14 +175,19 @@ pub fn storyline(files: &[(String, String)], n: usize) -> Vec<(String, String)> files .iter() .map(|(rel, raw)| { - let Ok(mut value) = serde_yaml::from_str::(raw) else { return (rel.clone(), raw.clone()) }; + let Ok(mut value) = serde_yaml::from_str::(raw) else { + return (rel.clone(), raw.clone()); + }; let mut out = Vec::new(); counts(&value, &mut Vec::new(), &mut out); if out.is_empty() { return (rel.clone(), raw.clone()); } pick(&mut value, &mut Vec::new(), n); - (rel.clone(), serde_yaml::to_string(&value).unwrap_or_else(|_| raw.clone())) + ( + rel.clone(), + serde_yaml::to_string(&value).unwrap_or_else(|_| raw.clone()), + ) }) .collect() } @@ -192,17 +237,32 @@ mod tests { assert_eq!(v["name"], "Trenger naboene deg?"); assert_eq!(v["description"], "Alle trenger flere hender."); assert_eq!(v["alternatives"][0]["name"], "Meld deg"); - assert_eq!(v["alternatives"][0]["consequence"], serde_yaml::to_value(vec!["Meld meg"]).unwrap()); + assert_eq!( + v["alternatives"][0]["consequence"], + serde_yaml::to_value(vec!["Meld meg"]).unwrap() + ); // A requirement's name is the stored field: left as written. - assert_eq!(v["alternatives"][0]["features"][0]["requirements"][0]["name"], serde_yaml::to_value(vec!["navn"]).unwrap()); - assert_eq!(v["alternatives"][0]["features"][0]["requirements"][0]["label"], "Hva heter du?"); + assert_eq!( + v["alternatives"][0]["features"][0]["requirements"][0]["name"], + serde_yaml::to_value(vec!["navn"]).unwrap() + ); + assert_eq!( + v["alternatives"][0]["features"][0]["requirements"][0]["label"], + "Hva heter du?" + ); // Options are values people choose from and are stored: untouched. - assert_eq!(v["alternatives"][0]["features"][0]["requirements"][1]["options"], serde_yaml::to_value(vec!["Dugnad", "Komité"]).unwrap()); + assert_eq!( + v["alternatives"][0]["features"][0]["requirements"][1]["options"], + serde_yaml::to_value(vec!["Dugnad", "Komité"]).unwrap() + ); } #[test] fn a_site_with_no_lists_tells_one_storyline_and_a_short_list_is_refused() { - let plain = vec![("index.yaml".to_string(), "name: Hva?\nalternatives:\n - name: A\n consequence: [Send]\n".to_string())]; + let plain = vec![( + "index.yaml".to_string(), + "name: Hva?\nalternatives:\n - name: A\n consequence: [Send]\n".to_string(), + )]; assert_eq!(arity(&plain).unwrap(), 1); assert_eq!(storyline(&plain, 0)[0].1, plain[0].1); let uneven = vec![ @@ -210,12 +270,18 @@ mod tests { ("b.yaml".to_string(), "name: [En, To, Tre]\n".to_string()), ]; let err = arity(&uneven).unwrap_err().to_string(); - assert!(err.contains("b.yaml: name tells 3") && err.contains("tells 2") || err.contains("tells 3"), "{err}"); + assert!( + err.contains("b.yaml: name tells 3") && err.contains("tells 2") + || err.contains("tells 3"), + "{err}" + ); } #[test] fn the_first_storyline_is_what_the_machinery_reads() { - let s = Storylines { each: vec!["a", "b"] }; + let s = Storylines { + each: vec!["a", "b"], + }; assert_eq!(*s, "a"); assert_eq!(*s.at(1), "b"); assert_eq!(*s.at(7), "b"); diff --git a/src/deadlines.rs b/src/deadlines.rs index 475aec2..50de493 100644 --- a/src/deadlines.rs +++ b/src/deadlines.rs @@ -63,8 +63,13 @@ pub fn has_left(schema: &crate::aggregates::AggregateSchema, answer: &Answer, no pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> { let now_ms = chrono::Utc::now().timestamp_millis(); let aggregates = state.aggregates.load(); - for schema in aggregates.values().filter(|s| !s.leaves_for_state.is_empty()) { - let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else { continue }; + for schema in aggregates + .values() + .filter(|s| !s.leaves_for_state.is_empty()) + { + let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else { + continue; + }; let keys: Vec = { use futures::TryStreamExt; match store.keys().await { @@ -73,12 +78,20 @@ pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> { } }; for id in keys { - let Ok(Some(bytes)) = store.get(&id).await else { continue }; - let Ok(answer) = serde_json::from_slice::(&bytes) else { continue }; + let Ok(Some(bytes)) = store.get(&id).await else { + continue; + }; + let Ok(answer) = serde_json::from_slice::(&bytes) else { + continue; + }; if has_left(schema, &answer, now_ms) { match store.delete(&id).await { - Ok(()) => tracing::info!(bucket = %schema.bucket, id, state = %answer.state, "a record left its list"), - Err(e) => tracing::warn!(bucket = %schema.bucket, id, "a record could not leave its list: {e}"), + Ok(()) => { + tracing::info!(bucket = %schema.bucket, id, state = %answer.state, "a record left its list") + } + Err(e) => { + tracing::warn!(bucket = %schema.bucket, id, "a record could not leave its list: {e}") + } } } } @@ -93,7 +106,11 @@ fn waiting_since(answer: &Answer) -> i64 { } /// Whether a record in `state` has waited out its deadline by `now_ms`. -pub fn due(schema: &crate::aggregates::AggregateSchema, answer: &Answer, now_ms: i64) -> Option { +pub fn due( + schema: &crate::aggregates::AggregateSchema, + answer: &Answer, + now_ms: i64, +) -> Option { let after = schema.after_for(&answer.state)?; let deadline = waiting_since(answer) + i64::from(after.days) * DAY_MS; (now_ms >= deadline).then(|| after.to.clone()) @@ -120,13 +137,24 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> { } }; for id in keys { - let Ok(Some(bytes)) = store.get(&id).await else { continue }; - let Ok(mut answer) = serde_json::from_slice::(&bytes) else { continue }; - let Some(target) = due(schema, &answer, now_ms) else { continue }; + let Ok(Some(bytes)) = store.get(&id).await else { + continue; + }; + let Ok(mut answer) = serde_json::from_slice::(&bytes) else { + continue; + }; + let Some(target) = due(schema, &answer, now_ms) else { + continue; + }; let payload = serde_json::json!({ "to": target, "item": id, "by": BY }); if let Err(e) = crate::aggregates::transition( - &state.jetstream, schema, &id, &target, payload.clone(), now_ms, + &state.jetstream, + schema, + &id, + &target, + payload.clone(), + now_ms, ) .await { @@ -143,11 +171,20 @@ pub async fn sweep(state: &AppState) -> anyhow::Result<()> { if let Ok(data) = serde_json::to_vec(&answer) { let _ = store.put(&id, data.into()).await; } - crate::mail::on_state(state, &schema.bucket, &id, &target, &answer.responses, answer.storyline).await; + crate::mail::on_state( + state, + &schema.bucket, + &id, + &target, + &answer.responses, + answer.storyline, + ) + .await; crate::people::on_state(state, &schema.bucket, &id, &target, &answer.responses).await; let parent_hashes = vec![id.clone()]; - let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms); + let chain_hash = + crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms); if let Err(e) = emit_answer_submitted( &state.nats, &AnswerSubmitted { @@ -175,7 +212,11 @@ mod tests { use super::*; fn schema(yaml: &str) -> crate::aggregates::AggregateSchema { - crate::aggregates::parse_aggregates_yaml(yaml).unwrap().into_values().next().unwrap() + crate::aggregates::parse_aggregates_yaml(yaml) + .unwrap() + .into_values() + .next() + .unwrap() } const HEARINGS: &str = r#" @@ -192,9 +233,16 @@ aggregates: fn waiting(state: &str, since_ms: i64) -> Answer { Answer { - id: "r".into(), question_id: "/q".into(), alternative: "a".into(), - responses: serde_json::json!({}), state: state.into(), - submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![], finished: false, + id: "r".into(), + question_id: "/q".into(), + alternative: "a".into(), + responses: serde_json::json!({}), + state: state.into(), + submitted_ms: since_ms, + decided_ms: None, + decided_by: None, + notes: vec![], + finished: false, storyline: 0, } } @@ -207,7 +255,10 @@ aggregates: let t = 1_000_000_000_000; let mut answer = waiting("gone", t - 60 * 60 * 1000); answer.decided_ms = Some(t); - assert!(!has_left(&s, &answer, t + 9 * 60 * 1000), "measured from entering the state, not from sending"); + assert!( + !has_left(&s, &answer, t + 9 * 60 * 1000), + "measured from entering the state, not from sending" + ); assert!(has_left(&s, &answer, t + 10 * 60 * 1000)); // A state without it keeps its records. assert!(!has_left(&s, &waiting("open", 0), t)); @@ -221,7 +272,10 @@ aggregates: let answer = waiting("open", sent); assert_eq!(due(&s, &answer, sent), None); assert_eq!(due(&s, &answer, sent + 41 * DAY_MS), None); - assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), Some("no_answer".into())); + assert_eq!( + due(&s, &answer, sent + 42 * DAY_MS), + Some("no_answer".into()) + ); } // The six weeks run from the last move, not from the day the case @@ -233,7 +287,10 @@ aggregates: let mut answer = waiting("open", sent); answer.decided_ms = Some(sent + 40 * DAY_MS); assert_eq!(due(&s, &answer, sent + 42 * DAY_MS), None); - assert_eq!(due(&s, &answer, sent + 82 * DAY_MS), Some("no_answer".into())); + assert_eq!( + due(&s, &answer, sent + 82 * DAY_MS), + Some("no_answer".into()) + ); } #[test] @@ -248,15 +305,21 @@ aggregates: // not when the hour comes round. #[test] fn a_deadline_onto_an_undeclared_edge_is_refused_on_load() { - let bad = HEARINGS.replace("to: no_answer }", "to: answered }").replace("open: [answered, no_answer]", "open: [no_answer]"); - let err = crate::aggregates::parse_aggregates_yaml(&bad).unwrap_err().to_string(); + let bad = HEARINGS + .replace("to: no_answer }", "to: answered }") + .replace("open: [answered, no_answer]", "open: [no_answer]"); + let err = crate::aggregates::parse_aggregates_yaml(&bad) + .unwrap_err() + .to_string(); assert!(err.contains("not a declared transition"), "{err}"); } #[test] fn a_deadline_of_no_days_is_refused() { let bad = HEARINGS.replace("days: 42", "days: 0"); - let err = crate::aggregates::parse_aggregates_yaml(&bad).unwrap_err().to_string(); + let err = crate::aggregates::parse_aggregates_yaml(&bad) + .unwrap_err() + .to_string(); assert!(err.contains("at least one"), "{err}"); } } diff --git a/src/events/store.rs b/src/events/store.rs index 9b1ba53..4a4e80a 100644 --- a/src/events/store.rs +++ b/src/events/store.rs @@ -109,11 +109,16 @@ pub async fn load_events( .await .map_err(|e| anyhow::anyhow!("fetching messages: {e}"))? { - let info = message.info().map_err(|e| anyhow::anyhow!("reading message info: {e}"))?; + let info = message + .info() + .map_err(|e| anyhow::anyhow!("reading message info: {e}"))?; let mut event: Event = serde_json::from_slice(&message.payload)?; event.sequence = info.stream_sequence; events.push(event); - message.ack().await.map_err(|e| anyhow::anyhow!("acking: {e}"))?; + message + .ack() + .await + .map_err(|e| anyhow::anyhow!("acking: {e}"))?; } events.sort_by_key(|e| e.sequence); diff --git a/src/lib.rs b/src/lib.rs index eefabfa..d1e5aa6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,15 +31,18 @@ pub mod people; #[cfg(feature = "ssr")] pub mod mail; -#[cfg(feature = "ssr")] -pub mod seed; -#[cfg(feature = "ssr")] -pub mod memberships; -#[cfg(feature = "ssr")] -pub mod report; /// The lint: `portal lint`, what a content repo's CI runs (once uhhm/iris). #[cfg(feature = "ssr")] pub mod lint; +#[cfg(feature = "ssr")] +pub mod memberships; +/// The Apple configuration profile at `/profile.mobileconfig`. +#[cfg(feature = "ssr")] +pub mod profile; +#[cfg(feature = "ssr")] +pub mod report; +#[cfg(feature = "ssr")] +pub mod seed; /// The trainer: `portal plan`, `portal build`, `portal report` and the /// rest (once uhhm/portal-trainer). #[cfg(feature = "ssr")] diff --git a/src/lint/check.rs b/src/lint/check.rs index 1c70723..c83bdf0 100644 --- a/src/lint/check.rs +++ b/src/lint/check.rs @@ -8,7 +8,9 @@ //! than compiled there - keeps that repo's CI coupling to "run a //! static binary," not "build a Rust workspace." -use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir, load_site_from_dir}; +use crate::lint::local::{ + load_aggregates_from_dir, load_from_dir, load_needs_from_dir, load_site_from_dir, +}; use crate::lint::needs; use crate::{aggregates, content}; @@ -39,7 +41,10 @@ pub async fn run(argv: Vec) -> anyhow::Result<()> { "--findings" => findings_out = args.next(), "--needs-score" => answers_in = args.next(), "--min-accuracy" => { - min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy) + min_accuracy = args + .next() + .and_then(|v| v.parse().ok()) + .unwrap_or(min_accuracy) } other => { eprintln!("unknown argument: {other}"); @@ -58,13 +63,30 @@ pub async fn run(argv: Vec) -> anyhow::Result<()> { } else if let Some(repo_url) = repo { // The first storyline of each, as the checks read it; the // loaders have held every list of texts to the site's arity. - let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?.at(0).clone(); - let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?.at(0).clone(); + let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir) + .await? + .at(0) + .clone(); + let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch) + .await? + .at(0) + .clone(); // load_site_from_gitea already validates; a missing file is // the default config, same as at portal boot. - let site = content::load_site_from_gitea(&repo_url, &branch).await?.at(0).clone(); + let site = content::load_site_from_gitea(&repo_url, &branch) + .await? + .at(0) + .clone(); // Optional: a fetch failure means the repo declares no needs. - let needs_raw = crate::http::get_text(&format!("{}/needs.yaml?ref={branch}", content::gitea_raw_base(&repo_url)?), crate::http::GITEA_CAP).await.ok(); + let needs_raw = crate::http::get_text( + &format!( + "{}/needs.yaml?ref={branch}", + content::gitea_raw_base(&repo_url)? + ), + crate::http::GITEA_CAP, + ) + .await + .ok(); (questions, aggregates_map, site, needs_raw) } else { eprintln!( @@ -106,13 +128,24 @@ pub async fn run(argv: Vec) -> anyhow::Result<()> { eprintln!("FAIL: access policy does not validate: {e}"); std::process::exit(1); } - println!("OK: access policy, {} rule(s), validates", policy.rules.len()); + println!( + "OK: access policy, {} rule(s), validates", + policy.rules.len() + ); if show_access { - println!("\n{:<16} {:<44} {:<40} {}", "ACTION", "RESOURCE", "WHO", "WHEN"); + println!( + "\n{:<16} {:<44} {:<40} {}", + "ACTION", "RESOURCE", "WHO", "WHEN" + ); let mut rows = policy.rules.clone(); - rows.sort_by(|a, b| (&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who))); + rows.sort_by(|a, b| { + (&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who)) + }); for r in rows { - println!("{:<16} {:<44} {:<40} {}", r.action, r.resource, r.who, r.when); + println!( + "{:<16} {:<44} {:<40} {}", + r.action, r.resource, r.who, r.when + ); } println!(); } @@ -164,7 +197,10 @@ fn schema_finding(message: &str) -> needs::Finding { /// "clean" from "not run". fn write_findings(out: Option<&str>, findings: &[needs::Finding]) { let Some(out) = out else { return }; - let lines: String = findings.iter().map(|f| format!("{}\n", f.to_json())).collect(); + let lines: String = findings + .iter() + .map(|f| format!("{}\n", f.to_json())) + .collect(); if let Err(e) = std::fs::write(out, lines) { eprintln!("WARN: could not write the findings to {out}: {e}"); } @@ -181,7 +217,10 @@ fn print_findings(findings: &[needs::Finding]) -> usize { } needs::Level::Warn => "WARN", }; - eprintln!("{level}: {} {:?}: {}", finding.scope, finding.need, finding.message); + eprintln!( + "{level}: {} {:?}: {}", + finding.scope, finding.need, finding.message + ); } failed } @@ -225,7 +264,12 @@ fn check_needs( .iter() .map(|s| format!("{} [{}]", s.page, s.alternative)) .collect(); - println!("need {:?}: {} -> {:?}", need.id, steps.join(" -> "), need.lands_in); + println!( + "need {:?}: {} -> {:?}", + need.id, + steps.join(" -> "), + need.lands_in + ); } } if failed == 0 { @@ -259,9 +303,14 @@ fn check_needs( println!("wrote {} wording task(s) to {out}", tasks.len()); } if let Some(path) = answers_in { - let raw = std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?; + let raw = + std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?; let mut answers = Vec::new(); - for (n, line) in raw.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) { + for (n, line) in raw + .lines() + .enumerate() + .filter(|(_, l)| !l.trim().is_empty()) + { answers.push( serde_json::from_str::(line) .map_err(|e| anyhow::anyhow!("{path}:{}: {e}", n + 1))?, diff --git a/src/lint/local.rs b/src/lint/local.rs index f8f0647..e0fb1d7 100644 --- a/src/lint/local.rs +++ b/src/lint/local.rs @@ -22,7 +22,8 @@ pub fn load_site_from_dir(dir: &str) -> anyhow::Result { // The first storyline, held to the arity (content::variants). let files = vec![("site.yaml".to_string(), raw)]; content::arity(&files)?; - serde_yaml::from_str(&content::storyline(&files, 0)[0].1).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display())) + serde_yaml::from_str(&content::storyline(&files, 0)[0].1) + .map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display())) } /// The offline counterpart to `content::load_aggregates_from_gitea` - @@ -102,4 +103,3 @@ pub fn load_needs_from_dir(dir: &str) -> anyhow::Result> { .map(Some) .map_err(|e| anyhow::anyhow!("reading {}: {e}", needs_path.display())) } - diff --git a/src/lint/mod.rs b/src/lint/mod.rs index 03a835f..b266a04 100644 --- a/src/lint/mod.rs +++ b/src/lint/mod.rs @@ -50,4 +50,3 @@ pub async fn run(mut argv: Vec) -> anyhow::Result<()> { } } } - diff --git a/src/lint/needs.rs b/src/lint/needs.rs index c538815..ad17da0 100644 --- a/src/lint/needs.rs +++ b/src/lint/needs.rs @@ -238,7 +238,11 @@ fn viable( if remaining == 0 { return out; } - for alt in page.alternatives.iter().filter(|a| include_disabled || !a.disabled) { + for alt in page + .alternatives + .iter() + .filter(|a| include_disabled || !a.disabled) + { if entry && need.via.as_deref().is_some_and(|via| via != alt.name) { continue; } @@ -268,7 +272,15 @@ fn viable( continue; } trail.push(target.id.clone()); - let onward = viable(questions, need, target, remaining - 1, false, include_disabled, trail); + let onward = viable( + questions, + need, + target, + remaining - 1, + false, + include_disabled, + trail, + ); trail.pop(); if let Some(best) = onward.into_iter().min_by_key(|v| (v.path.len(), v.fields)) { let mut path = vec![step]; @@ -287,7 +299,15 @@ fn viable( fn entry_viable(questions: &Questions, need: &Need, include_disabled: bool) -> Option> { let page = questions.get(&need.enters)?; let mut trail = vec![page.id.clone()]; - Some(viable(questions, need, page, need.max_steps, true, include_disabled, &mut trail)) + Some(viable( + questions, + need, + page, + need.max_steps, + true, + include_disabled, + &mut trail, + )) } /// The cheapest path that meets the need, if any. @@ -321,7 +341,10 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec { { desks.push(Desk { page: q.id.clone(), - group: resource.requires_group.clone().or_else(|| q.qualifies.clone()), + group: resource + .requires_group + .clone() + .or_else(|| q.qualifies.clone()), page_group: q.qualifies.clone(), moves: resource .transitions @@ -344,15 +367,22 @@ fn self_moves(questions: &Questions, bucket: &str, initial: &str) -> Vec<(String .flat_map(|q| &q.alternatives) .filter_map(|a| a.self_transition.as_ref()) .filter(|s| s.bucket == bucket) - .flat_map(|s| s.from_states(initial).into_iter().map(|from| (from, s.to.clone()))) + .flat_map(|s| { + s.from_states(initial) + .into_iter() + .map(|from| (from, s.to.clone())) + }) .collect() } /// The moves a clock makes in `schema` (`after:` on a state), as /// (from, to). Nobody presses them, and they still move records. fn clock_moves(schema: &AggregateSchema) -> Vec<(String, String)> { - let mut moves: Vec<(String, String)> = - schema.after_for_state.iter().map(|(from, after)| (from.clone(), after.to.clone())).collect(); + let mut moves: Vec<(String, String)> = schema + .after_for_state + .iter() + .map(|(from, after)| (from.clone(), after.to.clone())) + .collect(); moves.sort(); moves } @@ -402,7 +432,11 @@ pub fn check( scope: "need", code, subject, - level: if need.planned || need.paused { Level::Warn } else { level }, + level: if need.planned || need.paused { + Level::Warn + } else { + level + }, message: match (need.paused, need.planned, level == Level::Fail) { (true, _, true) => format!("paused, not open now: {message}"), (false, true, true) => format!("planned, not met yet: {message}"), @@ -411,13 +445,23 @@ pub fn check( }) }; if !seen.insert(need.id.as_str()) { - report(Level::Fail, "need-duplicate", String::new(), "declared more than once".to_string()); + report( + Level::Fail, + "need-duplicate", + String::new(), + "declared more than once".to_string(), + ); continue; } // The visitor's side: can they get their answer recorded? match questions.get(&need.enters) { - None => report(Level::Fail, "entry-missing", need.enters.clone(), format!("entry page {:?} does not exist", need.enters)), + None => report( + Level::Fail, + "entry-missing", + need.enters.clone(), + format!("entry page {:?} does not exist", need.enters), + ), Some(page) if !visible(page, need.enters_as.as_deref()) => report( Level::Fail, "entry-gated", @@ -433,7 +477,10 @@ pub fn check( Level::Warn, "entry-no-responsible", need.enters.clone(), - format!("entry page {:?} names no one responsible to turn to", need.enters), + format!( + "entry page {:?} names no one responsible to turn to", + need.enters + ), ); } let best = entry_viable(questions, need, false) @@ -515,7 +562,10 @@ pub fn check( Level::Fail, "machine-missing", need.lands_in.clone(), - format!("aggregates.yaml declares no state machine for {:?}", need.lands_in), + format!( + "aggregates.yaml declares no state machine for {:?}", + need.lands_in + ), ); continue; }; @@ -523,10 +573,15 @@ pub fn check( let own: Vec<&Desk> = desks .iter() .filter(|d| { - d.group.as_deref().is_some_and(|g| g == need.handled_by || need.also_moved_by.iter().any(|m| m == g)) + d.group.as_deref().is_some_and(|g| { + g == need.handled_by || need.also_moved_by.iter().any(|m| m == g) + }) }) .collect(); - if !desks.iter().any(|d| d.group.as_deref() == Some(need.handled_by.as_str())) { + if !desks + .iter() + .any(|d| d.group.as_deref() == Some(need.handled_by.as_str())) + { let others: Vec = desks .iter() .map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated"))) @@ -560,7 +615,11 @@ pub fn check( moves.extend(self_moves(questions, &need.lands_in, &schema.initial)); moves.extend(clock_moves(schema)); let exits = |state: &str| -> Vec { - moves.iter().filter(|(from, _)| from == state).map(|(_, to)| to.clone()).collect() + moves + .iter() + .filter(|(from, _)| from == state) + .map(|(_, to)| to.clone()) + .collect() }; let movers = std::iter::once(need.handled_by.as_str()) .chain(need.also_moved_by.iter().map(String::as_str)) @@ -573,7 +632,9 @@ pub fn check( let elsewhere = offered_elsewhere(&desks, from, to); let desk = own.first().map(|d| d.page.as_str()).unwrap_or("their desk"); if elsewhere.is_empty() { - format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {desk}") + format!( + "add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {desk}" + ) } else { format!( "{} already offers it - add that group to also_moved_by, or add `{{ from: {from}, to: {to}, label: ... }}` on {desk}", @@ -627,7 +688,10 @@ pub fn check( Level::Fail, "done-unknown", state.clone(), - format!("done_when names {state:?}, which {:?} has no such state", need.lands_in), + format!( + "done_when names {state:?}, which {:?} has no such state", + need.lands_in + ), ); } else if !reached.contains(state) { unreached_reported.insert((need.lands_in.clone(), state.clone())); @@ -678,7 +742,10 @@ pub fn check( if done.contains(before.as_str()) || done.contains(after.as_str()) { continue; } - if reached.contains(before) && reached.contains(after) && !reachable(before, &moves).contains(after) { + if reached.contains(before) + && reached.contains(after) + && !reachable(before, &moves).contains(after) + { report( Level::Fail, "stage-order", @@ -724,7 +791,13 @@ pub fn check( } } - findings.extend(operations(file, questions, aggregates, &strands_reported, &unreached_reported)); + findings.extend(operations( + file, + questions, + aggregates, + &strands_reported, + &unreached_reported, + )); let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect(); for persona in &file.personas { @@ -735,7 +808,10 @@ pub fn check( code: "persona-unknown-need", subject: persona.id.clone(), level: Level::Fail, - message: format!("persona {:?} points at a need that is not declared", persona.id), + message: format!( + "persona {:?} points at a need that is not declared", + persona.id + ), }); } } @@ -761,7 +837,9 @@ fn operations( buckets.sort(); buckets.dedup(); for bucket in buckets { - let Some(schema) = aggregates.get(bucket) else { continue }; + let Some(schema) = aggregates.get(bucket) else { + continue; + }; let needs: Vec<&Need> = file.needs.iter().filter(|n| n.lands_in == bucket).collect(); // Only open needs make a gap fail; a bucket that only planned // or paused needs use reports it as a warning. @@ -791,18 +869,29 @@ fn operations( // for the bucket's first need. let home = needs .iter() - .find_map(|n| desks.iter().find(|d| d.group.as_deref() == Some(n.handled_by.as_str()))) + .find_map(|n| { + desks + .iter() + .find(|d| d.group.as_deref() == Some(n.handled_by.as_str())) + }) .map(|d| d.page.clone()) .unwrap_or_else(|| "the handling group's desk".to_string()); - let button = |from: &str, to: &str| format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {home}"); + let button = |from: &str, to: &str| { + format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {home}") + }; let mut states: Vec<&String> = schema.event_for_state.keys().collect(); states.sort(); for state in &states { - if reached.contains(*state) || unreached_reported.contains(&(bucket.to_string(), (*state).clone())) { + if reached.contains(*state) + || unreached_reported.contains(&(bucket.to_string(), (*state).clone())) + { continue; } - let mut ways: Vec<&String> = reached.iter().filter(|from| schema.allowed(from).contains(state)).collect(); + let mut ways: Vec<&String> = reached + .iter() + .filter(|from| schema.allowed(from).contains(state)) + .collect(); ways.sort(); report( Level::Fail, @@ -875,17 +964,26 @@ fn operations( .flat_map(|f| &f.requirements) .map(|r| (r.name.as_str(), r.kind.as_str())) .collect(); - let mut mail_states: Vec<(&String, &crate::aggregates::MailSpec)> = schema.mail_for_state.iter().collect(); + let mut mail_states: Vec<(&String, &crate::aggregates::MailSpec)> = + schema.mail_for_state.iter().collect(); mail_states.sort_by(|a, b| a.0.cmp(b.0)); for (state, mail) in mail_states { if !reached.contains(state) { report(Level::Warn, "mail-unreachable", state.clone(), format!("the mail on entering {state:?} never goes out: no record reaches {state:?} - fix: make {state:?} reachable, or drop its mail")); } let mut unknown: Vec = Vec::new(); - for key in placeholders(&mail.subject).into_iter().chain(placeholders(&mail.body)) { + for key in placeholders(&mail.subject) + .into_iter() + .chain(placeholders(&mail.body)) + { let known = match key.split_once('.') { - Some((parent, _)) => fields.iter().any(|(name, kind)| *name == parent && *kind == "record"), - None => MAIL_BUILTINS.contains(&key.as_str()) || fields.iter().any(|(name, _)| *name == key), + Some((parent, _)) => fields + .iter() + .any(|(name, kind)| *name == parent && *kind == "record"), + None => { + MAIL_BUILTINS.contains(&key.as_str()) + || fields.iter().any(|(name, _)| *name == key) + } }; if !known && !unknown.contains(&key) { unknown.push(key); @@ -913,7 +1011,17 @@ fn operations( /// What the mailer fills in on every state mail, whatever the record /// holds (`mail::vars_for`, `mail::site_vars`, `mail::record_links`). -const MAIL_BUILTINS: &[&str] = &["site", "site_name", "chain", "email", "bucket", "state", "link", "self_link", "desk_link"]; +const MAIL_BUILTINS: &[&str] = &[ + "site", + "site_name", + "chain", + "email", + "bucket", + "state", + "link", + "self_link", + "desk_link", +]; /// The `{key}`s in a mail template, read the way `mail::render` reads /// them: letters, digits, `_`, `-` and `.`, closed by `}`. @@ -1011,7 +1119,10 @@ pub fn sim_model( // The simulator's shape: the states a visitor's own link // moves to (it does not model where from). let mut self_move_targets: Vec = - self_moves(questions, &need.lands_in, &schema.initial).into_iter().map(|(_, to)| to).collect(); + self_moves(questions, &need.lands_in, &schema.initial) + .into_iter() + .map(|(_, to)| to) + .collect(); self_move_targets.dedup(); buckets.entry(need.lands_in.clone()).or_insert_with(|| { json!({ @@ -1116,23 +1227,39 @@ pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec { }; for (i, step) in path.iter().enumerate() { let page = &questions[&step.page]; - let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).collect(); + let open: Vec<&Alternative> = page + .alternatives + .iter() + .filter(|a| !a.disabled && leads_somewhere(a)) + .collect(); if open.len() < 2 { continue; } let context = page_context(page); let mut trail: Vec = path[..=i].iter().map(|s| s.page.clone()).collect(); - let expect: Vec = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail) - .into_iter() - .map(|v| v.alternative) - .collect(); + let expect: Vec = viable( + questions, + need, + page, + need.max_steps - i, + i == 0, + false, + &mut trail, + ) + .into_iter() + .map(|v| v.alternative) + .collect(); let criteria = open .iter() .map(|a| { let description = plain(&a.description); ( a.name.clone(), - if skim || description.is_empty() { a.name.clone() } else { description }, + if skim || description.is_empty() { + a.name.clone() + } else { + description + }, ) }) .collect(); @@ -1273,7 +1400,10 @@ mod tests { fn effort_budget_counts_required_fields_only() { let (questions, aggregates) = site(FULL_DESK); let findings = check(&needs(" max_fields: 1\n"), &questions, &aggregates); - assert_eq!(fails(&findings), vec!["the path asks for 2 required field(s), over the budget of 1"]); + assert_eq!( + fails(&findings), + vec!["the path asks for 2 required field(s), over the budget of 1"] + ); // A field that carries rather than asks is not effort: the // record an answer is about arrives on the link. let (mut questions, aggregates) = site(FULL_DESK); @@ -1292,7 +1422,9 @@ mod tests { #[test] fn a_desk_missing_a_move_strands_records_and_cannot_finish() { // The desk can quote, but never close a quoted job. - let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n"); + let (questions, aggregates) = site( + " - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n", + ); let findings = check(&needs(""), &questions, &aggregates); assert_eq!( fails(&findings), @@ -1344,8 +1476,12 @@ mod tests { assert_eq!(fields.len(), 3); assert_eq!(fields[2]["optional"], true); // The desk's buttons, labelled, per state. - let moves = model["buckets"]["projects"]["desks"][0]["moves"].as_array().unwrap(); - assert!(moves.iter().any(|m| m["from"] == "quoted" && m["to"] == "won" && m["label"] == "Win")); + let moves = model["buckets"]["projects"]["desks"][0]["moves"] + .as_array() + .unwrap(); + assert!(moves + .iter() + .any(|m| m["from"] == "quoted" && m["to"] == "won" && m["label"] == "Win")); assert_eq!(model["buckets"]["projects"]["initial"], "open"); assert_eq!(model["personas"][0]["id"], "founder"); @@ -1369,7 +1505,9 @@ mod tests { fn a_need_can_be_carried_by_more_than_one_groups_desk() { // owners may only quote or lose; closing a quoted job is the // site managers' button, on a desk of their own. - let (mut questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n"); + let (mut questions, aggregates) = site( + " - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n", + ); let more = build_questions(&[( "site/index.yaml".to_string(), "name: Site desk\nqualifies: site_managers\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: projects }\n requires_group: site_managers\n transitions:\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n".to_string(), @@ -1385,7 +1523,8 @@ mod tests { assert!(check(&file, &questions, &aggregates).is_empty()); // But someone has to be answerable: handled_by needs its own desk. file.needs[0].handled_by = "nobody".to_string(); - assert!(fails(&check(&file, &questions, &aggregates))[0].starts_with("no desk gated on \"nobody\"")); + assert!(fails(&check(&file, &questions, &aggregates))[0] + .starts_with("no desk gated on \"nobody\"")); } #[test] @@ -1418,7 +1557,10 @@ mod tests { let file = needs(""); let tasks = tasks(&file, &questions, false); let skimmed = super::tasks(&file, &questions, true); - assert_eq!(skimmed[0].questions["pick"].criteria["Follow along"], "Follow along"); + assert_eq!( + skimmed[0].questions["pick"].criteria["Follow along"], + "Follow along" + ); assert_eq!(tasks.len(), 1); let task = &tasks[0]; assert_eq!(task.id, "founder@/"); @@ -1428,11 +1570,19 @@ mod tests { assert_eq!(q.criteria["Follow along"], "Notes by mail."); assert!(q.instructions.contains("What are you here for?")); - let right = vec![Answer { id: "founder@/".into(), choice: "Hire us".into(), confidence: Some(0.55) }]; + let right = vec![Answer { + id: "founder@/".into(), + choice: "Hire us".into(), + confidence: Some(0.55), + }]; let s = score(&tasks, &right, 0.6); assert_eq!((s.correct, s.misroutes.len(), s.hesitant.len()), (1, 0, 1)); - let wrong = vec![Answer { id: "founder@/".into(), choice: "Follow along".into(), confidence: Some(0.9) }]; + let wrong = vec![Answer { + id: "founder@/".into(), + choice: "Follow along".into(), + confidence: Some(0.9), + }]; let s = score(&tasks, &wrong, 0.6); assert_eq!((s.correct, s.misroutes.len()), (0, 1)); assert_eq!(s.accuracy(), 0.0); @@ -1442,7 +1592,10 @@ mod tests { /// The fixture site with its aggregates replaced, for the checks /// that are about the state machine rather than the pages. - fn site_with(desk_moves: &str, projects: &str) -> (Questions, HashMap) { + fn site_with( + desk_moves: &str, + projects: &str, + ) -> (Questions, HashMap) { let (questions, _) = site(desk_moves); let aggregates = parse_aggregates_yaml(&format!( "aggregates:\n - bucket: projects\n{projects} - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: {{ event: subscribed }}\n transitions: {{}}\n" @@ -1452,7 +1605,11 @@ mod tests { } fn warns(findings: &[Finding]) -> Vec<&str> { - findings.iter().filter(|f| f.level == Level::Warn).map(|f| f.message.as_str()).collect() + findings + .iter() + .filter(|f| f.level == Level::Warn) + .map(|f| f.message.as_str()) + .collect() } #[test] @@ -1507,7 +1664,11 @@ mod tests { " - { to: quoted, label: Quote }\n - { to: surveyed, label: Survey }\n - { from: surveyed, to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n", " initial: open\n states:\n open: { event: submitted }\n surveyed: { event: surveyed }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [surveyed, quoted]\n surveyed: [lost]\n quoted: [won, lost]\n", ); - let findings = check(&needs(" stages: [open, surveyed, quoted, won, lost]\n"), &questions, &aggregates); + let findings = check( + &needs(" stages: [open, surveyed, quoted, won, lost]\n"), + &questions, + &aggregates, + ); assert_eq!( fails(&findings), vec!["stage \"quoted\" comes after \"surveyed\" for the business, but no move leads on from \"surveyed\" to it - fix: add an edge from \"surveyed\" toward \"quoted\" in aggregates.yaml under \"projects\""] @@ -1540,14 +1701,25 @@ mod tests { #[test] fn a_finding_keeps_its_id_when_reworded_and_says_its_fix() { - let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n"); + let (questions, aggregates) = site( + " - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n", + ); let findings = check(&needs(""), &questions, &aggregates); let strand = findings.iter().find(|f| f.code == "strand").unwrap(); assert_eq!(strand.subject, "quoted"); - assert_eq!(strand.fix(), Some("add `{ from: quoted, to: won, label: ... }` to the transitions on /desk")); - let reworded = Finding { message: "something else entirely".into(), ..clone_of(strand) }; + assert_eq!( + strand.fix(), + Some("add `{ from: quoted, to: won, label: ... }` to the transitions on /desk") + ); + let reworded = Finding { + message: "something else entirely".into(), + ..clone_of(strand) + }; assert_eq!(reworded.id(), strand.id()); - let elsewhere = Finding { subject: "open".into(), ..clone_of(strand) }; + let elsewhere = Finding { + subject: "open".into(), + ..clone_of(strand) + }; assert_ne!(elsewhere.id(), strand.id()); let json = strand.to_json(); assert_eq!(json["code"], "strand"); @@ -1556,6 +1728,13 @@ mod tests { } fn clone_of(f: &Finding) -> Finding { - Finding { need: f.need.clone(), level: f.level, message: f.message.clone(), scope: f.scope, code: f.code, subject: f.subject.clone() } + Finding { + need: f.need.clone(), + level: f.level, + message: f.message.clone(), + scope: f.scope, + code: f.code, + subject: f.subject.clone(), + } } } diff --git a/src/lint/replay.rs b/src/lint/replay.rs index dc6f09d..faf2218 100644 --- a/src/lint/replay.rs +++ b/src/lint/replay.rs @@ -14,14 +14,19 @@ use std::collections::{BTreeMap, HashMap}; -use futures::StreamExt; use crate::events::store::{ensure_stream, load_events}; use crate::lint::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir}; use crate::lint::needs; use crate::{aggregates, answers, content}; +use futures::StreamExt; pub async fn run(argv: Vec) -> anyhow::Result<()> { - let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned(); + let value = |flag: &str| { + argv.iter() + .position(|a| a == flag) + .and_then(|i| argv.get(i + 1)) + .cloned() + }; let usage = "usage: iris replay --path --nats (--cases | --report-only)"; let dir = value("--path").ok_or_else(|| anyhow::anyhow!(usage))?; let nats_url = value("--nats").ok_or_else(|| anyhow::anyhow!(usage))?; @@ -32,31 +37,71 @@ pub async fn run(argv: Vec) -> anyhow::Result<()> { let questions = load_from_dir(&dir)?; let schemas = content::with_builtin_aggregates(load_aggregates_from_dir(&dir)?); - let file = needs::parse(&load_needs_from_dir(&dir)?.ok_or_else(|| anyhow::anyhow!("the repo has no needs.yaml"))?)?; + let file = needs::parse( + &load_needs_from_dir(&dir)?.ok_or_else(|| anyhow::anyhow!("the repo has no needs.yaml"))?, + )?; let js = async_nats::jetstream::new(async_nats::connect(&nats_url).await?); // The same call the site makes at boot. ensure_stream(&js).await?; if let Some(path) = cases_path { - let raw = std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?; + let raw = + std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?; let (mut created, mut moved, mut refused) = (0, 0, 0); for (n, line) in raw.lines().filter(|l| !l.trim().is_empty()).enumerate() { let case: serde_json::Value = serde_json::from_str(line)?; - let Some(need) = file.needs.iter().find(|need| case["need"] == need.id.as_str()) else { continue }; - let Some(schema) = schemas.get(&need.lands_in) else { continue }; + let Some(need) = file + .needs + .iter() + .find(|need| case["need"] == need.id.as_str()) + else { + continue; + }; + let Some(schema) = schemas.get(&need.lands_in) else { + continue; + }; // Only a case that got as far as submitting leaves a record. - let states: Vec<&str> = case["states"].as_array().into_iter().flatten().filter_map(|s| s.as_str()).collect(); + let states: Vec<&str> = case["states"] + .as_array() + .into_iter() + .flatten() + .filter_map(|s| s.as_str()) + .collect(); if states.is_empty() { continue; } let step = needs::path_for(&questions, need).and_then(|p| p.last().cloned()); let (page, alternative) = step.map(|s| (s.page, s.alternative)).unwrap_or_default(); - let id = format!("sim-{}-{}-{n}", case["persona"].as_str().unwrap_or("x"), case["run"]); + let id = format!( + "sim-{}-{}-{n}", + case["persona"].as_str().unwrap_or("x"), + case["run"] + ); let now = chrono::Utc::now().timestamp_millis(); - answers::store_answer(&js, &schemas, &need.lands_in, id.clone(), &page, &alternative, &case["record"], now, 0).await?; + answers::store_answer( + &js, + &schemas, + &need.lands_in, + id.clone(), + &page, + &alternative, + &case["record"], + now, + 0, + ) + .await?; created += 1; for target in &states[1..] { - match aggregates::transition(&js, schema, &id, target, serde_json::json!({"by": "simulation"}), now).await { + match aggregates::transition( + &js, + schema, + &id, + target, + serde_json::json!({"by": "simulation"}), + now, + ) + .await + { Ok(_) => moved += 1, Err(e) => { refused += 1; @@ -72,33 +117,72 @@ pub async fn run(argv: Vec) -> anyhow::Result<()> { // Read every bucket back the way the engine knows it: by replaying // each record's own event log. let mut by_bucket: HashMap> = HashMap::new(); - for bucket in file.needs.iter().map(|n| n.lands_in.clone()).collect::>() { - let (Some(schema), Ok(store)) = (schemas.get(&bucket), js.get_key_value(&bucket).await) else { continue }; + for bucket in file + .needs + .iter() + .map(|n| n.lands_in.clone()) + .collect::>() + { + let (Some(schema), Ok(store)) = (schemas.get(&bucket), js.get_key_value(&bucket).await) + else { + continue; + }; let mut keys = store.keys().await?; while let Some(Ok(id)) = keys.next().await { let events = load_events(&js, &bucket, &id).await?; if let Some(record) = aggregates::replay(schema, &id, &events) { - *by_bucket.entry(bucket.clone()).or_default().entry(record.state).or_default() += 1; + *by_bucket + .entry(bucket.clone()) + .or_default() + .entry(record.state) + .or_default() += 1; } } } - println!("{:<18} {:>8} {:>9} {:>9} {}", "bucket", "records", "finished", "success", "by state"); + println!( + "{:<18} {:>8} {:>9} {:>9} {}", + "bucket", "records", "finished", "success", "by state" + ); let mut buckets: Vec<_> = by_bucket.iter().collect(); buckets.sort(); for (bucket, states) in buckets { - let needs_here: Vec<&needs::Need> = file.needs.iter().filter(|n| &n.lands_in == bucket).collect(); - let is = |pick: fn(&needs::Need) -> &Vec, state: &String| needs_here.iter().any(|n| pick(n).contains(state)); + let needs_here: Vec<&needs::Need> = file + .needs + .iter() + .filter(|n| &n.lands_in == bucket) + .collect(); + let is = |pick: fn(&needs::Need) -> &Vec, state: &String| { + needs_here.iter().any(|n| pick(n).contains(state)) + }; let total: usize = states.values().sum(); - let finished: usize = states.iter().filter(|(s, _)| is(|n| &n.done_when, s)).map(|(_, c)| c).sum(); - let success: usize = states.iter().filter(|(s, _)| is(|n| &n.success, s)).map(|(_, c)| c).sum(); - let pct = |n: usize| if total == 0 { "-".to_string() } else { format!("{:.0}%", n as f64 * 100.0 / total as f64) }; + let finished: usize = states + .iter() + .filter(|(s, _)| is(|n| &n.done_when, s)) + .map(|(_, c)| c) + .sum(); + let success: usize = states + .iter() + .filter(|(s, _)| is(|n| &n.success, s)) + .map(|(_, c)| c) + .sum(); + let pct = |n: usize| { + if total == 0 { + "-".to_string() + } else { + format!("{:.0}%", n as f64 * 100.0 / total as f64) + } + }; println!( "{:<18} {:>8} {:>9} {:>9} {}", bucket, total, pct(finished), pct(success), - states.iter().map(|(s, c)| format!("{s} {c}")).collect::>().join(", ") + states + .iter() + .map(|(s, c)| format!("{s} {c}")) + .collect::>() + .join(", ") ); } Ok(()) diff --git a/src/mail.rs b/src/mail.rs index 3476cba..3ad1438 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -74,7 +74,10 @@ pub struct Incoming { /// address can be forged. The sender must therefore also match the /// address the record already holds, which is the one portal wrote to /// in the first place. -pub fn note_for(incoming: &Incoming, answer: &crate::answers::Answer) -> Result { +pub fn note_for( + incoming: &Incoming, + answer: &crate::answers::Answer, +) -> Result { if incoming.v != CONTRACT { return Err(format!("reply contract v{} is not v{CONTRACT}", incoming.v)); } @@ -90,7 +93,10 @@ pub fn note_for(incoming: &Incoming, answer: &crate::answers::Answer) -> Result< .filter(|e| !e.is_empty()) .ok_or_else(|| "the record holds no address to compare the sender with".to_string())?; if address_of(&incoming.from) != held { - return Err(format!("{} is not the address this record was written to", incoming.from)); + return Err(format!( + "{} is not the address this record was written to", + incoming.from + )); } if answer.notes.iter().any(|n| n.id == incoming.id) { return Err("this reply is already on the record".to_string()); @@ -128,7 +134,10 @@ pub async fn receive(state: crate::server::AppState) { return; } }; - tracing::info!(subject = RECEIVED_SUBJECT, "listening for replies to case mail"); + tracing::info!( + subject = RECEIVED_SUBJECT, + "listening for replies to case mail" + ); while let Some(msg) = sub.next().await { let incoming: Incoming = match serde_json::from_slice(&msg.payload) { Ok(i) => i, @@ -138,8 +147,12 @@ pub async fn receive(state: crate::server::AppState) { } }; match note_onto(&state, &incoming).await { - Ok(()) => tracing::info!(bucket = %incoming.bucket, record = %incoming.record, "a reply was added to the record"), - Err(e) => tracing::warn!(bucket = %incoming.bucket, record = %incoming.record, "reply refused: {e}"), + Ok(()) => { + tracing::info!(bucket = %incoming.bucket, record = %incoming.record, "a reply was added to the record") + } + Err(e) => { + tracing::warn!(bucket = %incoming.bucket, record = %incoming.record, "reply refused: {e}") + } } } } @@ -147,7 +160,9 @@ pub async fn receive(state: crate::server::AppState) { /// The record's own bucket is the only one a reply may touch, and the /// note is appended under the same read-modify-write the desks use. async fn note_onto(state: &crate::server::AppState, incoming: &Incoming) -> anyhow::Result<()> { - if !state.aggregates.load().contains_key(&incoming.bucket) && !crate::content::bucket_is_read(&state.questions.load(), &incoming.bucket) { + if !state.aggregates.load().contains_key(&incoming.bucket) + && !crate::content::bucket_is_read(&state.questions.load(), &incoming.bucket) + { anyhow::bail!("{} is not a bucket of this site", incoming.bucket); } let store = state.jetstream.get_key_value(&incoming.bucket).await?; @@ -158,14 +173,18 @@ async fn note_onto(state: &crate::server::AppState, incoming: &Incoming) -> anyh let mut answer: crate::answers::Answer = serde_json::from_slice(&bytes)?; let note = note_for(incoming, &answer).map_err(|e| anyhow::anyhow!(e))?; answer.notes.push(note); - store.put(&incoming.record, serde_json::to_vec(&answer)?.into()).await?; + store + .put(&incoming.record, serde_json::to_vec(&answer)?.into()) + .await?; Ok(()) } /// Idempotent, called by portal at boot and by gdo. A week of /// retention: a mail nobody delivered in a week is stale, not owed. /// The duplicate window has to fit inside it. -pub async fn ensure_stream(js: &async_nats::jetstream::Context) -> anyhow::Result { +pub async fn ensure_stream( + js: &async_nats::jetstream::Context, +) -> anyhow::Result { Ok(js .get_or_create_stream(async_nats::jetstream::stream::Config { name: STREAM.to_string(), @@ -213,7 +232,8 @@ pub fn render(template: &str, vars: &serde_json::Map) /// in the body is dropped, the same rule as page descriptions. pub fn to_html(markdown: &str) -> String { use pulldown_cmark::{html, Event, Options, Parser}; - let parser = Parser::new_ext(markdown, Options::empty()).filter(|e| !matches!(e, Event::Html(_) | Event::InlineHtml(_))); + let parser = Parser::new_ext(markdown, Options::empty()) + .filter(|e| !matches!(e, Event::Html(_) | Event::InlineHtml(_))); let mut body = String::new(); html::push_html(&mut body, parser); format!( @@ -221,28 +241,46 @@ pub fn to_html(markdown: &str) -> String { ) } -fn public_url() -> String { - std::env::var("PUBLIC_URL").unwrap_or_default().trim_end_matches('/').to_string() +pub(crate) fn public_url() -> String { + std::env::var("PUBLIC_URL") + .unwrap_or_default() + .trim_end_matches('/') + .to_string() } fn site_name(site: &crate::content::SiteConfig) -> String { - site.title.clone().unwrap_or_else(|| option_env!("SITE_NAME").unwrap_or("portal").to_string()) + site.title + .clone() + .unwrap_or_else(|| option_env!("SITE_NAME").unwrap_or("portal").to_string()) } /// The variables a state mail may use: every field of the record, /// then the runtime's own. -pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Value) -> serde_json::Map { +pub fn vars_for( + bucket: &str, + id: &str, + state: &str, + responses: &serde_json::Value, +) -> serde_json::Map { let mut vars = serde_json::Map::new(); if let Some(fields) = responses.as_object() { for (k, v) in fields { - vars.insert(k.clone(), match v { - serde_json::Value::String(s) => serde_json::Value::String(s.clone()), - serde_json::Value::Null => serde_json::Value::String(String::new()), - other => serde_json::Value::String(other.to_string()), - }); + vars.insert( + k.clone(), + match v { + serde_json::Value::String(s) => serde_json::Value::String(s.clone()), + serde_json::Value::Null => serde_json::Value::String(String::new()), + other => serde_json::Value::String(other.to_string()), + }, + ); } } - let email = responses.get("email").and_then(|v| v.as_str()).unwrap_or_default().trim().to_string(); + let email = responses + .get("email") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .trim() + .to_string(); vars.insert("email".into(), email.into()); vars.insert("site".into(), public_url().into()); vars.insert("chain".into(), id.into()); @@ -303,7 +341,10 @@ pub fn record_links( .filter_map(|f| f.resource.as_ref()) .any(|r| r.bucket() == Some(bucket) && (!moving || !r.transitions.is_empty())) }; - let desk_page = pages.iter().find(|q| reads(q, true)).or_else(|| pages.iter().find(|q| reads(q, false))); + let desk_page = pages + .iter() + .find(|q| reads(q, true)) + .or_else(|| pages.iter().find(|q| reads(q, false))); let site = public_url(); let self_link = match self_page { Some(q) if email.contains('@') => { @@ -312,7 +353,9 @@ pub fn record_links( } _ => String::new(), }; - let desk_link = desk_page.map(|q| format!("{site}{}", q.id)).unwrap_or_default(); + let desk_link = desk_page + .map(|q| format!("{site}{}", q.id)) + .unwrap_or_default(); let link = match (to_submitter, self_link.is_empty(), desk_link.is_empty()) { (true, false, _) => self_link.clone(), (false, _, false) => desk_link.clone(), @@ -338,7 +381,12 @@ pub fn parent_of(to: &str) -> Option<&str> { /// `bucket`, the record it names, read from its own bucket. A /// reference that does not resolve adds nothing, and a template that /// needed it renders empty there. -async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &serde_json::Value, vars: &mut serde_json::Map) { +async fn parent_vars( + state: &crate::server::AppState, + bucket: &str, + responses: &serde_json::Value, + vars: &mut serde_json::Map, +) { let questions = state.questions.load(); let refs: Vec<(String, String)> = questions .values() @@ -349,10 +397,22 @@ async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: & .filter_map(|r| Some((r.name.clone(), r.of.clone()?))) .collect(); for (field, of) in refs { - let Some(id) = responses.get(&field).and_then(|v| v.as_str()).filter(|s| !s.is_empty()) else { continue }; - let Ok(store) = state.jetstream.get_key_value(&of).await else { continue }; - let Ok(Some(bytes)) = store.get(id).await else { continue }; - let Ok(parent) = serde_json::from_slice::(&bytes) else { continue }; + let Some(id) = responses + .get(&field) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + else { + continue; + }; + let Ok(store) = state.jetstream.get_key_value(&of).await else { + continue; + }; + let Ok(Some(bytes)) = store.get(id).await else { + continue; + }; + let Ok(parent) = serde_json::from_slice::(&bytes) else { + continue; + }; if let Some(fields) = parent.responses.as_object() { for (k, v) in fields { let text = match v { @@ -372,7 +432,9 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow let mut headers = async_nats::HeaderMap::new(); headers.insert("Nats-Msg-Id", mail.id.as_str()); let bytes = serde_json::to_vec(mail)?; - js.publish_with_headers(SUBJECT, headers, bytes.into()).await?.await?; + js.publish_with_headers(SUBJECT, headers, bytes.into()) + .await? + .await?; Ok(()) } @@ -380,10 +442,24 @@ async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow /// if anything. Best-effort by design - the transition is already /// committed, and a mail that cannot be rendered is logged, never a /// failed request. -pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value, storyline: u32) { +pub async fn on_state( + state: &crate::server::AppState, + bucket: &str, + id: &str, + to_state: &str, + responses: &serde_json::Value, + storyline: u32, +) { // The mail tells the storyline the person read. let storyline = storyline as usize; - let Some(spec) = state.aggregates.load().at(storyline).get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else { + let Some(spec) = state + .aggregates + .load() + .at(storyline) + .get(bucket) + .and_then(|s| s.mail_for(to_state)) + .cloned() + else { // A suggestion has its own words for this unless the content // gave the state a mail, which then is the one sent. if bucket == crate::content::EDIT_SUGGESTIONS_BUCKET { @@ -394,23 +470,54 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t let site_storylines = state.site.load(); let site = site_storylines.at(storyline); let Some(cfg) = &site.mail else { - tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent"); + tracing::warn!( + bucket, + to_state, + "a mail is declared for this state, but site.yaml has no `mail.from` - not sent" + ); return; }; let mut vars = vars_for(bucket, id, to_state, responses); parent_vars(state, bucket, responses, &mut vars).await; vars.extend(site_vars(site)); { - let email = vars.get("email").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let email = vars + .get("email") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); let questions = state.questions.load(); - let initial = state.aggregates.load().at(storyline).get(bucket).map(|s| s.initial.clone()).unwrap_or_default(); - vars.extend(record_links(questions.at(storyline), &initial, bucket, id, to_state, &email, spec.to_submitter())); + let initial = state + .aggregates + .load() + .at(storyline) + .get(bucket) + .map(|s| s.initial.clone()) + .unwrap_or_default(); + vars.extend(record_links( + questions.at(storyline), + &initial, + bucket, + id, + to_state, + &email, + spec.to_submitter(), + )); } let to = if spec.to_submitter() { - match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) { + match vars + .get("email") + .and_then(|v| v.as_str()) + .filter(|e| e.contains('@')) + { Some(e) => e.to_string(), None => { - tracing::warn!(bucket, id, to_state, "mail to the submitter, but the record carries no email - not sent"); + tracing::warn!( + bucket, + id, + to_state, + "mail to the submitter, but the record carries no email - not sent" + ); return; } } @@ -444,14 +551,24 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t /// quotes, and past the first three only how many more - someone who /// answers for a whole site is not read its table of contents. pub fn questions_listed(questions: &[String], norwegian: bool) -> String { - let quote = |q: &String| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") }; + let quote = |q: &String| { + if norwegian { + format!("«{q}»") + } else { + format!("\u{201c}{q}\u{201d}") + } + }; let and = if norwegian { "og" } else { "and" }; match questions { [] => String::new(), [one] => quote(one), few if few.len() <= 3 => { let quoted: Vec = few.iter().map(quote).collect(); - format!("{} {and} {}", quoted[..quoted.len() - 1].join(", "), quoted[quoted.len() - 1]) + format!( + "{} {and} {}", + quoted[..quoted.len() - 1].join(", "), + quoted[quoted.len() - 1] + ) } many => { let quoted: Vec = many[..3].iter().map(quote).collect(); @@ -471,13 +588,20 @@ pub fn questions_listed(questions: &[String], norwegian: bool) -> String { /// a question and they had no account: who they are to the site (the /// person listed as asking that question), and the link that lets them /// sign in and suggest changes to it. Built in, in the site's language. -pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::people::Invited, questions: &[String]) { +pub async fn on_responsible( + state: &crate::server::AppState, + invited: &crate::people::Invited, + questions: &[String], +) { let site = state.site.load(); let Some(cfg) = &site.mail else { tracing::warn!(user = %invited.username, "an account was made for a responsible person, but site.yaml has no `mail.from` - they were not told"); return; }; - let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); + let norwegian = site + .lang + .as_deref() + .is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); let list = questions_listed(questions, norwegian); let (subject, body) = if norwegian { ( @@ -500,7 +624,9 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe // The form's own fields first, so the invite's fixed placeholders // win a clash; a date and time reads as one. for (key, value) in &invited.fields { - let text = value.as_str().map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string())); + let text = value + .as_str() + .map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string())); if let Some(text) = text { vars.insert(key.clone(), text.into()); } @@ -509,7 +635,10 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe vars.insert("username".into(), invited.username.clone().into()); vars.insert("questions".into(), list.into()); vars.extend(site_vars(&site)); - vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into()); + vars.insert( + "link".into(), + invited.reset_link.clone().unwrap_or_default().into(), + ); vars.insert("expires".into(), expires.into()); let text = render(&body, &vars); let mail = Outgoing { @@ -564,32 +693,65 @@ pub fn suggestion_words(to_state: &str, norwegian: bool) -> Option<(&'static str /// A suggestion moved: tell whoever made it. Replies go to the site's /// inbox, which is who decided. -async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state: &str, responses: &serde_json::Value) { +async fn on_suggestion_state( + state: &crate::server::AppState, + id: &str, + to_state: &str, + responses: &serde_json::Value, +) { let site = state.site.load(); - let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); + let norwegian = site + .lang + .as_deref() + .is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); let Some((subject, body)) = suggestion_words(to_state, norwegian) else { return; }; let Some(cfg) = &site.mail else { - tracing::warn!(id, to_state, "a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told"); + tracing::warn!( + id, + to_state, + "a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told" + ); return; }; - let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).unwrap_or_default().trim().to_string(); + let field = |name: &str| { + responses + .get(name) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .trim() + .to_string() + }; let to = field("email"); if !to.contains('@') { - tracing::warn!(id, to_state, "a suggestion moved, but the record carries no email - not sent"); + tracing::warn!( + id, + to_state, + "a suggestion moved, but the record carries no email - not sent" + ); return; } - let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::>().join("\n"); + let quoted: String = field("text") + .lines() + .map(|l| format!("> {l}")) + .collect::>() + .join("\n"); let mut vars = site_vars(&site); vars.insert("name".into(), field("name").into()); vars.insert("question".into(), field("question").into()); - vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into()); + vars.insert( + "page".into(), + format!("{}{}", public_url(), field("page")).into(), + ); vars.insert("quoted".into(), quoted.into()); let text = render(body, &vars); let mail = Outgoing { v: CONTRACT, - id: format!("{}:{id}:{to_state}", crate::content::EDIT_SUGGESTIONS_BUCKET), + id: format!( + "{}:{id}:{to_state}", + crate::content::EDIT_SUGGESTIONS_BUCKET + ), site: site_name(&site), from: cfg.from.clone(), reply_to: cfg.reply_to.clone(), @@ -606,14 +768,24 @@ async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state /// A change suggested by the person responsible for a page, to the /// site's own inbox (`mail.reply_to`, else `mail.from`), with the /// suggester as reply-to so answering the mail answers them. -pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &str, question_name: &str, by_name: &str, by_email: &str, text: &str) { +pub async fn on_edit_suggestion( + state: &crate::server::AppState, + question_id: &str, + question_name: &str, + by_name: &str, + by_email: &str, + text: &str, +) { let site = state.site.load(); let Some(cfg) = &site.mail else { tracing::warn!(question_id, "an edit was suggested, but site.yaml has no `mail.from` - it is in portal_edit_suggestions only"); return; }; let to = cfg.reply_to.clone().unwrap_or_else(|| cfg.from.clone()); - let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); + let norwegian = site + .lang + .as_deref() + .is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); let (subject, lead) = if norwegian { (format!("Forslag til endring: «{question_name}»"), format!("{by_name} ({by_email}), som står som ansvarlig for spørsmålet, foreslår en endring på {}{question_id}:", public_url())) } else { @@ -650,13 +822,63 @@ pub fn spoken_datetime(value: &str, norwegian: bool) -> Option { let day = t.weekday().num_days_from_monday() as usize; let month = t.month0() as usize; Some(if norwegian { - const DAYS: [&str; 7] = ["mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag"]; - const MONTHS: [&str; 12] = ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"]; - format!("{} {}. {} kl. {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute()) + const DAYS: [&str; 7] = [ + "mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag", + ]; + const MONTHS: [&str; 12] = [ + "januar", + "februar", + "mars", + "april", + "mai", + "juni", + "juli", + "august", + "september", + "oktober", + "november", + "desember", + ]; + format!( + "{} {}. {} kl. {:02}:{:02}", + DAYS[day], + t.day(), + MONTHS[month], + t.hour(), + t.minute() + ) } else { - const DAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; - const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; - format!("{} {} {} at {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute()) + const DAYS: [&str; 7] = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ]; + const MONTHS: [&str; 12] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + format!( + "{} {} {} at {:02}:{:02}", + DAYS[day], + t.day(), + MONTHS[month], + t.hour(), + t.minute() + ) }) } @@ -666,7 +888,10 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people: tracing::warn!(user = %invited.username, "invite made, but site.yaml has no `mail.from` - the link is on the desk only"); return; }; - let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); + let norwegian = site + .lang + .as_deref() + .is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn")); let (subject, body) = match (invited.mail.as_ref().or(cfg.invite.as_ref()), invited.reset_link.is_some(), norwegian) { (Some(t), _, _) => (t.subject.clone(), t.body.clone()), (None, true, true) => ( @@ -698,7 +923,10 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people: vars.insert("email".into(), invited.email.clone().into()); vars.insert("group".into(), invited.group.clone().into()); vars.extend(site_vars(&site)); - vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into()); + vars.insert( + "link".into(), + invited.reset_link.clone().unwrap_or_default().into(), + ); vars.insert("expires".into(), expires.into()); let text = render(&body, &vars); let mail = Outgoing { @@ -730,15 +958,42 @@ mod tests { ]; let questions = crate::content::build_questions(&files).unwrap(); let site = public_url(); - let to_tenant = record_links(&questions, "open", "feil", "abc", "befaring", "kari@x.no", true); - assert_eq!(to_tenant["self_link"], format!("{site}/trekk?chain=abc&email=kari%40x.no")); + let to_tenant = record_links( + &questions, + "open", + "feil", + "abc", + "befaring", + "kari@x.no", + true, + ); + assert_eq!( + to_tenant["self_link"], + format!("{site}/trekk?chain=abc&email=kari%40x.no") + ); assert_eq!(to_tenant["desk_link"], format!("{site}/styret")); assert_eq!(to_tenant["link"], to_tenant["self_link"]); - let to_board = record_links(&questions, "open", "feil", "abc", "befaring", "kari@x.no", false); + let to_board = record_links( + &questions, + "open", + "feil", + "abc", + "befaring", + "kari@x.no", + false, + ); assert_eq!(to_board["link"], to_board["desk_link"]); // Past where the self-service move can be made: the person has // no page of their own, so `{link}` is the site. - let after = record_links(&questions, "open", "feil", "abc", "utbedret", "kari@x.no", true); + let after = record_links( + &questions, + "open", + "feil", + "abc", + "utbedret", + "kari@x.no", + true, + ); assert_eq!(after["self_link"], ""); assert_eq!(after["link"], site); } @@ -751,10 +1006,22 @@ mod tests { assert_eq!(questions_listed(&q(0), true), ""); assert_eq!(questions_listed(&q(1), true), "«Q1?»"); assert_eq!(questions_listed(&q(3), true), "«Q1?», «Q2?» og «Q3?»"); - assert_eq!(questions_listed(&q(4), true), "«Q1?», «Q2?», «Q3?» og ett spørsmål til"); - assert_eq!(questions_listed(&q(17), true), "«Q1?», «Q2?», «Q3?» og 14 spørsmål til"); - assert_eq!(questions_listed(&q(2), false), "\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}"); - assert_eq!(questions_listed(&q(5), false), "\u{201c}Q1?\u{201d}, \u{201c}Q2?\u{201d}, \u{201c}Q3?\u{201d} and 2 more questions"); + assert_eq!( + questions_listed(&q(4), true), + "«Q1?», «Q2?», «Q3?» og ett spørsmål til" + ); + assert_eq!( + questions_listed(&q(17), true), + "«Q1?», «Q2?», «Q3?» og 14 spørsmål til" + ); + assert_eq!( + questions_listed(&q(2), false), + "\u{201c}Q1?\u{201d} and \u{201c}Q2?\u{201d}" + ); + assert_eq!( + questions_listed(&q(5), false), + "\u{201c}Q1?\u{201d}, \u{201c}Q2?\u{201d}, \u{201c}Q3?\u{201d} and 2 more questions" + ); } #[test] @@ -762,8 +1029,14 @@ mod tests { for norwegian in [true, false] { for state in ["discussed", "done", "declined"] { let (subject, body) = suggestion_words(state, norwegian).expect(state); - assert!(subject.contains("{question}"), "{state}: the subject names the question"); - assert!(body.contains("{page}") && body.contains("{quoted}"), "{state}: the body says where and what"); + assert!( + subject.contains("{question}"), + "{state}: the subject names the question" + ); + assert!( + body.contains("{page}") && body.contains("{quoted}"), + "{state}: the body says where and what" + ); } // Arriving is told to the inbox, not back to who sent it. assert!(suggestion_words("open", norwegian).is_none()); @@ -784,18 +1057,34 @@ mod tests { #[test] fn templates_take_fields_and_leave_stray_braces_alone() { - let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null }); + let responses = + serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null }); let vars = vars_for("reports", "abc", "fixed", &responses); - assert_eq!(render("Hi {name}, {count} at {site}/decide?chain={chain}&email={email}", &vars), "Hi Kari, 3 at /decide?chain=abc&email=kari@x.no"); - assert_eq!(render("{unknown} {hva} {not a key} {", &vars), " {not a key} {"); + assert_eq!( + render( + "Hi {name}, {count} at {site}/decide?chain={chain}&email={email}", + &vars + ), + "Hi Kari, 3 at /decide?chain=abc&email=kari@x.no" + ); + assert_eq!( + render("{unknown} {hva} {not a key} {", &vars), + " {not a key} {" + ); assert_eq!(vars["bucket"], "reports"); assert_eq!(vars["state"], "fixed"); } #[test] fn a_date_and_time_from_a_form_reads_as_a_person_says_it() { - assert_eq!(spoken_datetime("2026-10-06T15:00", true).as_deref(), Some("tirsdag 6. oktober kl. 15:00")); - assert_eq!(spoken_datetime("2026-10-06T15:00", false).as_deref(), Some("Tuesday 6 October at 15:00")); + assert_eq!( + spoken_datetime("2026-10-06T15:00", true).as_deref(), + Some("tirsdag 6. oktober kl. 15:00") + ); + assert_eq!( + spoken_datetime("2026-10-06T15:00", false).as_deref(), + Some("Tuesday 6 October at 15:00") + ); assert_eq!(spoken_datetime("Anders Kluge", true), None); } @@ -831,7 +1120,9 @@ mod tests { ) .unwrap(); assert_eq!(older.subject, "s"); - assert!(serde_json::to_string(&older).unwrap().contains("\"html\":\"h\"")); + assert!(serde_json::to_string(&older) + .unwrap() + .contains("\"html\":\"h\"")); } fn record(email: &str) -> crate::answers::Answer { @@ -865,7 +1156,11 @@ mod tests { #[test] fn a_reply_from_the_address_on_the_record_becomes_a_note() { - let note = note_for(&reply("Kari ", " It is fixed now. "), &record("kari@x.no")).unwrap(); + let note = note_for( + &reply("Kari ", " It is fixed now. "), + &record("kari@x.no"), + ) + .unwrap(); assert_eq!(note.from, "kari@x.no"); assert_eq!(note.text, "It is fixed now."); assert_eq!(note.id, "mail-1"); @@ -876,21 +1171,33 @@ mod tests { fn a_reply_from_anyone_else_is_refused() { // The address a reply arrives on is unguessable, but a From is // a claim, and a mailbox is a door anyone may knock on. - let err = note_for(&reply("someone@else.no", "Let me in"), &record("kari@x.no")).unwrap_err(); - assert!(err.contains("not the address this record was written to"), "{err}"); + let err = + note_for(&reply("someone@else.no", "Let me in"), &record("kari@x.no")).unwrap_err(); + assert!( + err.contains("not the address this record was written to"), + "{err}" + ); // Nothing to compare against is also a refusal, not a pass. let mut anonymous = record("kari@x.no"); anonymous.responses = serde_json::json!({ "where": "The bend" }); - assert!(note_for(&reply("kari@x.no", "hi"), &anonymous).unwrap_err().contains("no address")); + assert!(note_for(&reply("kari@x.no", "hi"), &anonymous) + .unwrap_err() + .contains("no address")); } #[test] fn an_empty_reply_and_a_repeated_one_add_nothing() { - assert!(note_for(&reply("kari@x.no", " \n "), &record("kari@x.no")).unwrap_err().contains("no text")); + assert!( + note_for(&reply("kari@x.no", " \n "), &record("kari@x.no")) + .unwrap_err() + .contains("no text") + ); let mut already = record("kari@x.no"); - already.notes.push(note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap()); + already + .notes + .push(note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap()); let err = note_for(&reply("kari@x.no", "It is fixed."), &already).unwrap_err(); assert!(err.contains("already on the record"), "{err}"); } @@ -899,7 +1206,9 @@ mod tests { fn a_reply_from_a_contract_this_portal_does_not_know_is_refused() { let mut future = reply("kari@x.no", "hello"); future.v = CONTRACT + 1; - assert!(note_for(&future, &record("kari@x.no")).unwrap_err().contains("contract")); + assert!(note_for(&future, &record("kari@x.no")) + .unwrap_err() + .contains("contract")); } #[test] diff --git a/src/main.rs b/src/main.rs index 39fa89a..db28158 100644 --- a/src/main.rs +++ b/src/main.rs @@ -316,6 +316,7 @@ async fn serve() -> anyhow::Result<()> { // Content-shipped assets (hero module etc.), same-origin. .route("/site/{*path}", get(content::site_asset_handler)) .route("/.well-known/{*path}", get(content::well_known_handler)) + .route("/profile.mobileconfig", get(portal::profile::handler)) .route( "/automation/kv/{bucket}", get(content::automation_kv_handler), diff --git a/src/maps.rs b/src/maps.rs index 49fc414..3ba5005 100644 --- a/src/maps.rs +++ b/src/maps.rs @@ -42,7 +42,11 @@ fn style_id() -> String { let (Some(owner), Some(id), None) = (parts.next(), parts.next(), parts.next()) else { return false; }; - [owner, id].iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')) + [owner, id].iter().all(|p| { + !p.is_empty() + && p.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + }) }) .unwrap_or_else(|| "mapbox/streets-v12".to_string()) } @@ -50,7 +54,9 @@ fn style_id() -> String { /// The instance's Mapbox token, or `None` when maps are off. #[cfg(feature = "ssr")] pub fn token() -> Option { - std::env::var("MAPBOX_TOKEN").ok().filter(|t| !t.trim().is_empty()) + std::env::var("MAPBOX_TOKEN") + .ok() + .filter(|t| !t.trim().is_empty()) } #[cfg(feature = "ssr")] @@ -64,7 +70,11 @@ pub fn enabled() -> bool { #[cfg(feature = "ssr")] pub async fn style() -> Response { if !enabled() { - return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response(); + return ( + StatusCode::NOT_FOUND, + "maps are not configured on this instance", + ) + .into_response(); } let style = serde_json::json!({ "version": 8, @@ -79,7 +89,10 @@ pub async fn style() -> Response { "layers": [{ "id": "base", "type": "raster", "source": "base" }], }); ( - [(header::CONTENT_TYPE, "application/json"), (header::CACHE_CONTROL, "public, max-age=3600")], + [ + (header::CONTENT_TYPE, "application/json"), + (header::CACHE_CONTROL, "public, max-age=3600"), + ], style.to_string(), ) .into_response() @@ -90,7 +103,11 @@ pub async fn style() -> Response { #[cfg(feature = "ssr")] pub async fn tile(Path((z, x, y)): Path<(u32, u32, u32)>) -> Response { let Some(token) = token() else { - return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response(); + return ( + StatusCode::NOT_FOUND, + "maps are not configured on this instance", + ) + .into_response(); }; if z > 22 || x >= 1 << z.min(22) || y >= 1 << z.min(22) { return (StatusCode::BAD_REQUEST, "no such tile").into_response(); @@ -108,13 +125,21 @@ pub async fn tile(Path((z, x, y)): Path<(u32, u32, u32)>) -> Response { #[cfg(feature = "ssr")] pub async fn still(Path((place, size)): Path<(String, String)>) -> Response { let Some(token) = token() else { - return (StatusCode::NOT_FOUND, "maps are not configured on this instance").into_response(); + return ( + StatusCode::NOT_FOUND, + "maps are not configured on this instance", + ) + .into_response(); }; let Some((lon, lat, zoom)) = parse_place(&place) else { return (StatusCode::BAD_REQUEST, "expected ,,").into_response(); }; let Some((w, h)) = parse_size(&size) else { - return (StatusCode::BAD_REQUEST, "expected x, at most 1280").into_response(); + return ( + StatusCode::BAD_REQUEST, + "expected x, at most 1280", + ) + .into_response(); }; // The pin is drawn by Mapbox so the image is self-contained; the // marker colour is portal's ink, not a brand colour. @@ -127,7 +152,12 @@ pub async fn still(Path((place, size)): Path<(String, String)>) -> Response { #[cfg(feature = "ssr")] async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response { - let resp = match crate::http::client().get(url).send().await.and_then(|r| r.error_for_status()) { + let resp = match crate::http::client() + .get(url) + .send() + .await + .and_then(|r| r.error_for_status()) + { Ok(r) => r, Err(e) => { tracing::warn!("map upstream: {e}"); @@ -143,7 +173,10 @@ async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response { match crate::http::bytes(resp, crate::http::ASSET_CAP).await { Ok(bytes) => ( [ - (header::CONTENT_TYPE, upstream.unwrap_or_else(|| mime.to_string())), + ( + header::CONTENT_TYPE, + upstream.unwrap_or_else(|| mime.to_string()), + ), (header::CACHE_CONTROL, cache.to_string()), ], axum::body::Bytes::from(bytes), @@ -159,16 +192,25 @@ async fn fetch(url: &str, mime: &'static str, cache: &'static str) -> Response { #[cfg(feature = "ssr")] fn parse_place(place: &str) -> Option<(String, String, String)> { let mut parts = place.split(','); - let (Some(lon), Some(lat), Some(zoom), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else { + let (Some(lon), Some(lat), Some(zoom), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { return None; }; let lon: f64 = lon.parse().ok()?; let lat: f64 = lat.parse().ok()?; let zoom: f64 = zoom.parse().ok()?; - if !(-180.0..=180.0).contains(&lon) || !(-85.0511..=85.0511).contains(&lat) || !(0.0..=22.0).contains(&zoom) { + if !(-180.0..=180.0).contains(&lon) + || !(-85.0511..=85.0511).contains(&lat) + || !(0.0..=22.0).contains(&zoom) + { return None; } - Some((format!("{lon:.5}"), format!("{lat:.5}"), format!("{zoom:.2}"))) + Some(( + format!("{lon:.5}"), + format!("{lat:.5}"), + format!("{zoom:.2}"), + )) } #[cfg(feature = "ssr")] @@ -203,7 +245,15 @@ mod tests { parse_place("11.123456,59.987654,13.5"), Some(("11.12346".into(), "59.98765".into(), "13.50".into())) ); - for bad in ["11,59", "11,59,13,0", "200,59,13", "11,95,13", "11,59,30", "11,59,x", "11,59,13?a=b"] { + for bad in [ + "11,59", + "11,59,13,0", + "200,59,13", + "11,95,13", + "11,59,30", + "11,59,x", + "11,59,13?a=b", + ] { assert_eq!(parse_place(bad), None, "{bad}"); } } @@ -212,7 +262,15 @@ mod tests { #[cfg(feature = "ssr")] fn a_size_is_two_numbers_within_what_the_service_draws() { assert_eq!(parse_size("600x300"), Some((600, 300))); - for bad in ["600", "0x300", "600x0", "2000x300", "600x2000", "600x300x2", "sixx300"] { + for bad in [ + "600", + "0x300", + "600x0", + "2000x300", + "600x2000", + "600x300x2", + "sixx300", + ] { assert_eq!(parse_size(bad), None, "{bad}"); } } @@ -221,7 +279,10 @@ mod tests { fn a_location_is_a_lat_and_a_lon_and_a_zoom_it_may_leave_out() { let v = serde_json::json!({ "lat": 59.5, "lon": 11.0, "zoom": 16 }); assert_eq!(read(&v), Some((59.5, 11.0, 16.0))); - assert_eq!(read(&serde_json::json!({ "lat": 59.5, "lon": 11.0 })), Some((59.5, 11.0, 13.0))); + assert_eq!( + read(&serde_json::json!({ "lat": 59.5, "lon": 11.0 })), + Some((59.5, 11.0, 13.0)) + ); for not in [ serde_json::json!({ "lat": 91.0, "lon": 11.0 }), serde_json::json!({ "lat": 59.5 }), diff --git a/src/memberships.rs b/src/memberships.rs index facef88..b099f41 100644 --- a/src/memberships.rs +++ b/src/memberships.rs @@ -32,7 +32,10 @@ pub const BY: &str = "portal"; /// The country a phone number without one is in, as a calling code. fn default_country() -> String { - std::env::var("PHONE_COUNTRY").ok().filter(|c| !c.trim().is_empty()).unwrap_or_else(|| "+47".to_string()) + std::env::var("PHONE_COUNTRY") + .ok() + .filter(|c| !c.trim().is_empty()) + .unwrap_or_else(|| "+47".to_string()) } /// Who a person is, as far as a record or a session knows. @@ -69,7 +72,11 @@ pub fn normalize_phone(raw: &str, country: &str) -> Option { } fn normalize_email(raw: &str) -> Option { - let e = raw.trim().trim_start_matches("mailto:").trim().to_lowercase(); + let e = raw + .trim() + .trim_start_matches("mailto:") + .trim() + .to_lowercase(); (e.contains('@') && !e.contains(char::is_whitespace)).then_some(e) } @@ -88,7 +95,8 @@ impl Identity { let country = default_country(); Identity { sub: None, - phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"]).and_then(|p| normalize_phone(&p, &country)), + phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"]) + .and_then(|p| normalize_phone(&p, &country)), email: field(&["email", "epost", "e-post", "mail"]).and_then(|e| normalize_email(&e)), } } @@ -97,7 +105,10 @@ impl Identity { let country = default_country(); Identity { sub: Some(user.sub.clone()), - phone: user.phone.as_deref().and_then(|p| normalize_phone(p, &country)), + phone: user + .phone + .as_deref() + .and_then(|p| normalize_phone(p, &country)), email: user.email.as_deref().and_then(normalize_email), } } @@ -107,8 +118,16 @@ impl Identity { pub fn from_contact(contact: &str) -> Self { let country = default_country(); match normalize_email(contact) { - Some(email) => Identity { sub: None, phone: None, email: Some(email) }, - None => Identity { sub: None, phone: normalize_phone(contact, &country), email: None }, + Some(email) => Identity { + sub: None, + phone: None, + email: Some(email), + }, + None => Identity { + sub: None, + phone: normalize_phone(contact, &country), + email: None, + }, } } @@ -120,14 +139,26 @@ impl Identity { .as_deref() .or(self.email.as_deref()) .or(self.sub.as_deref()) - .map(|k| k.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect()) + .map(|k| { + k.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() + }) } /// Whether a record about `other` is about this person: the same /// subject, phone number or address, any one of them. pub fn matches(&self, other: &Identity) -> bool { let same = |a: &Option, b: &Option| a.is_some() && a == b; - same(&self.sub, &other.sub) || same(&self.phone, &other.phone) || same(&self.email, &other.email) + same(&self.sub, &other.sub) + || same(&self.phone, &other.phone) + || same(&self.email, &other.email) } /// What is worth writing on a record from a login: everything the @@ -149,14 +180,34 @@ pub fn record_id(group: &str, key: &str) -> String { /// The identity a record holds. pub fn identity_of(answer: &Answer) -> Identity { Identity { - sub: answer.responses.get("sub").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), - phone: answer.responses.get("phone").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), - email: answer.responses.get("email").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), + sub: answer + .responses + .get("sub") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), + phone: answer + .responses + .get("phone") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), + email: answer + .responses + .get("email") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), } } pub fn group_of(answer: &Answer) -> String { - answer.responses.get("group").and_then(|v| v.as_str()).unwrap_or_default().to_string() + answer + .responses + .get("group") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string() } /// The groups `who` is an active member of, from every record. @@ -172,19 +223,26 @@ pub fn groups_of(records: &[Answer], who: &Identity) -> Vec { groups } -async fn store(state: &crate::server::AppState) -> anyhow::Result { +async fn store( + state: &crate::server::AppState, +) -> anyhow::Result { match state.jetstream.get_key_value(BUCKET).await { Ok(store) => Ok(store), Err(_) => Ok(state .jetstream - .create_key_value(async_nats::jetstream::kv::Config { bucket: BUCKET.to_string(), ..Default::default() }) + .create_key_value(async_nats::jetstream::kv::Config { + bucket: BUCKET.to_string(), + ..Default::default() + }) .await?), } } /// Every membership record. pub async fn all(state: &crate::server::AppState) -> Vec { - let Ok(store) = state.jetstream.get_key_value(BUCKET).await else { return Vec::new() }; + let Ok(store) = state.jetstream.get_key_value(BUCKET).await else { + return Vec::new(); + }; let keys: Vec = { use futures::TryStreamExt; match store.keys().await { @@ -205,18 +263,36 @@ pub async fn all(state: &crate::server::AppState) -> Vec { /// Moves one record the way a desk button would: through the event /// log, onto the projection, out on the answers subject. -async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: Answer, target: &str, by: &str) -> anyhow::Result { +async fn move_to( + state: &crate::server::AppState, + store: &async_nats::jetstream::kv::Store, + mut answer: Answer, + target: &str, + by: &str, +) -> anyhow::Result { let aggregates = state.aggregates.load(); - let schema = aggregates.get(BUCKET).ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?; + let schema = aggregates + .get(BUCKET) + .ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?; let now_ms = chrono::Utc::now().timestamp_millis(); let payload = serde_json::json!({ "to": target, "item": answer.id, "by": by }); - crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; + crate::answers::transition_or_reseed( + &state.jetstream, + schema, + &answer.id, + target, + &answer.state, + payload.clone(), + now_ms, + ) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; answer.state = target.to_string(); answer.decided_ms = Some(now_ms); answer.decided_by = Some(by.to_string()); - store.put(&answer.id, serde_json::to_vec(&answer)?.into()).await?; + store + .put(&answer.id, serde_json::to_vec(&answer)?.into()) + .await?; let parent_hashes = vec![answer.id.clone()]; let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms); let event = crate::events::AnswerSubmitted { @@ -236,15 +312,29 @@ async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream: /// `invited`; a removed one is invited again; an invited or active one /// is left as it is, so a second grant costs nothing. `created` in /// what comes back says whether the person had a place already. -pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who: &Identity, by: &str) -> anyhow::Result { - let key = who.key().ok_or_else(|| anyhow::anyhow!("a membership needs a phone number or an address to know the person by"))?; +pub async fn grant( + state: &crate::server::AppState, + group: &str, + name: &str, + who: &Identity, + by: &str, +) -> anyhow::Result { + let key = who.key().ok_or_else(|| { + anyhow::anyhow!("a membership needs a phone number or an address to know the person by") + })?; let store = store(state).await?; // The person's record in this group, however it was keyed: a desk // that invited by address and a decision that granted by phone // number are talking about one person once a sign-in has joined // the two, and must not make two. - let existing: Option = all(state).await.into_iter().find(|a| group_of(a) == group.trim() && identity_of(a).matches(who)); - let id = existing.as_ref().map(|a| a.id.clone()).unwrap_or_else(|| record_id(group, &key)); + let existing: Option = all(state) + .await + .into_iter() + .find(|a| group_of(a) == group.trim() && identity_of(a).matches(who)); + let id = existing + .as_ref() + .map(|a| a.id.clone()) + .unwrap_or_else(|| record_id(group, &key)); let now_ms = chrono::Utc::now().timestamp_millis(); let invited = |created: bool| crate::people::Invited { username: key.clone(), @@ -269,7 +359,18 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who "name": name, "group": group, "by": by, "phone": who.phone, "email": who.email, "sub": who.sub, }); - crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), BUCKET, id, "/", "Membership", &responses, now_ms, 0).await?; + crate::answers::store_answer( + &state.jetstream, + &state.aggregates.load(), + BUCKET, + id, + "/", + "Membership", + &responses, + now_ms, + 0, + ) + .await?; Ok(invited(true)) } } @@ -277,11 +378,22 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who /// Takes a person out of a group: the record moves to `removed` and /// stays, so the desk can see who was in and put them back. -pub async fn revoke(state: &crate::server::AppState, group: &str, who: &Identity, by: &str) -> anyhow::Result { - let Some(key) = who.key() else { return Ok(false) }; +pub async fn revoke( + state: &crate::server::AppState, + group: &str, + who: &Identity, + by: &str, +) -> anyhow::Result { + let Some(key) = who.key() else { + return Ok(false); + }; let store = store(state).await?; let id = record_id(group, &key); - let Some(answer) = store.get(&id).await?.and_then(|b| serde_json::from_slice::(&b).ok()) else { + let Some(answer) = store + .get(&id) + .await? + .and_then(|b| serde_json::from_slice::(&b).ok()) + else { return Ok(false); }; if answer.state == REMOVED { @@ -296,10 +408,15 @@ pub async fn revoke(state: &crate::server::AppState, group: &str, who: &Identity /// and their active groups come back to be their groups for the /// session. Their `sub` is remembered, so the next login matches on /// it before anything else. -pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::User) -> Vec { +pub async fn on_signed_in( + state: &crate::server::AppState, + user: &crate::auth::User, +) -> Vec { let who = Identity::from_user(user); let records = all(state).await; - let Ok(store) = store(state).await else { return groups_of(&records, &who) }; + let Ok(store) = store(state).await else { + return groups_of(&records, &who); + }; let mut settled = Vec::with_capacity(records.len()); for answer in records { let held = identity_of(&answer); @@ -341,7 +458,13 @@ pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::U mod tests { use super::*; - fn record(group: &str, state: &str, phone: Option<&str>, email: Option<&str>, sub: Option<&str>) -> Answer { + fn record( + group: &str, + state: &str, + phone: Option<&str>, + email: Option<&str>, + sub: Option<&str>, + ) -> Answer { Answer { id: record_id(group, "k"), question_id: "/".into(), @@ -359,39 +482,89 @@ mod tests { #[test] fn a_phone_number_is_one_string_for_everyone() { - assert_eq!(normalize_phone("912 34 567", "+47"), Some("+4791234567".into())); - assert_eq!(normalize_phone("+47 912-34-567", "+47"), Some("+4791234567".into())); - assert_eq!(normalize_phone("004791234567", "+47"), Some("+4791234567".into())); - assert_eq!(normalize_phone("91234567", "+46"), Some("+4691234567".into())); + assert_eq!( + normalize_phone("912 34 567", "+47"), + Some("+4791234567".into()) + ); + assert_eq!( + normalize_phone("+47 912-34-567", "+47"), + Some("+4791234567".into()) + ); + assert_eq!( + normalize_phone("004791234567", "+47"), + Some("+4791234567".into()) + ); + assert_eq!( + normalize_phone("91234567", "+46"), + Some("+4691234567".into()) + ); assert_eq!(normalize_phone("kari@vel.no", "+47"), None); assert_eq!(normalize_phone("123", "+47"), None); } #[test] fn a_person_is_their_phone_first_and_their_address_second() { - let both = Identity::from_responses(&serde_json::json!({ "name": "Kari", "telefon": "912 34 567", "email": "Kari@Vel.no" })); + let both = Identity::from_responses( + &serde_json::json!({ "name": "Kari", "telefon": "912 34 567", "email": "Kari@Vel.no" }), + ); assert_eq!(both.key().as_deref(), Some("_4791234567")); assert_eq!(both.email.as_deref(), Some("kari@vel.no")); let mail_only = Identity::from_responses(&serde_json::json!({ "epost": "Kari@Vel.no" })); assert_eq!(mail_only.key().as_deref(), Some("kari_vel_no")); - assert_eq!(Identity::from_contact("mailto:Post@Vel.no").email.as_deref(), Some("post@vel.no")); - assert_eq!(Identity::from_contact("912 34 567").phone.as_deref(), Some("+4791234567")); + assert_eq!( + Identity::from_contact("mailto:Post@Vel.no") + .email + .as_deref(), + Some("post@vel.no") + ); + assert_eq!( + Identity::from_contact("912 34 567").phone.as_deref(), + Some("+4791234567") + ); assert!(Identity::default().key().is_none()); } #[test] fn a_record_is_about_a_person_by_any_one_of_sub_phone_or_email() { - let kari = Identity { sub: Some("abc".into()), phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) }; - assert!(Identity { sub: None, phone: Some("+4791234567".into()), email: None }.matches(&kari)); - assert!(Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) }.matches(&kari)); - assert!(Identity { sub: Some("abc".into()), phone: None, email: Some("old@vel.no".into()) }.matches(&kari)); - assert!(!Identity { sub: None, phone: Some("+4799999999".into()), email: Some("ola@vel.no".into()) }.matches(&kari)); + let kari = Identity { + sub: Some("abc".into()), + phone: Some("+4791234567".into()), + email: Some("kari@vel.no".into()), + }; + assert!(Identity { + sub: None, + phone: Some("+4791234567".into()), + email: None + } + .matches(&kari)); + assert!(Identity { + sub: None, + phone: None, + email: Some("kari@vel.no".into()) + } + .matches(&kari)); + assert!(Identity { + sub: Some("abc".into()), + phone: None, + email: Some("old@vel.no".into()) + } + .matches(&kari)); + assert!(!Identity { + sub: None, + phone: Some("+4799999999".into()), + email: Some("ola@vel.no".into()) + } + .matches(&kari)); assert!(!Identity::default().matches(&kari)); } #[test] fn only_active_memberships_are_groups() { - let kari = Identity { sub: None, phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) }; + let kari = Identity { + sub: None, + phone: Some("+4791234567".into()), + email: Some("kari@vel.no".into()), + }; let records = vec![ record("styret", ACTIVE, Some("+4791234567"), None, None), record("kasserer", INVITED, None, Some("kari@vel.no"), None), diff --git a/src/people.rs b/src/people.rs index 98f7198..0bbce6a 100644 --- a/src/people.rs +++ b/src/people.rs @@ -37,14 +37,25 @@ pub enum People { impl People { /// `None` only when Kanidm is asked for and not configured. pub fn from_env() -> Option { - match std::env::var("PEOPLE_BACKEND").ok().map(|b| b.trim().to_lowercase()).as_deref() { + match std::env::var("PEOPLE_BACKEND") + .ok() + .map(|b| b.trim().to_lowercase()) + .as_deref() + { Some("memberships") => Some(People::Memberships), Some("kanidm") => Kanidm::from_env().map(People::Kanidm), Some(other) => { - tracing::warn!(backend = other, "PEOPLE_BACKEND is neither kanidm nor memberships; using memberships"); + tracing::warn!( + backend = other, + "PEOPLE_BACKEND is neither kanidm nor memberships; using memberships" + ); Some(People::Memberships) } - None => Some(Kanidm::from_env().map(People::Kanidm).unwrap_or(People::Memberships)), + None => Some( + Kanidm::from_env() + .map(People::Kanidm) + .unwrap_or(People::Memberships), + ), } } @@ -55,10 +66,19 @@ impl People { /// through Kanidm an account and a one-time link, through /// memberships a record that becomes theirs when they sign in. /// `by` is who asked, for the record. - pub async fn invite(&self, state: &crate::server::AppState, name: &str, who: &crate::memberships::Identity, group: &str, by: &str) -> anyhow::Result { + pub async fn invite( + &self, + state: &crate::server::AppState, + name: &str, + who: &crate::memberships::Identity, + group: &str, + by: &str, + ) -> anyhow::Result { match self { People::Kanidm(kanidm) => { - let email = who.email.as_deref().ok_or_else(|| anyhow::anyhow!("Kanidm knows people by their address, and this one has none"))?; + let email = who.email.as_deref().ok_or_else(|| { + anyhow::anyhow!("Kanidm knows people by their address, and this one has none") + })?; kanidm.invite(name, email, group).await } People::Memberships => { @@ -75,7 +95,9 @@ impl People { invited.link_expires_ms = account.link_expires_ms; invited.created = account.created; } - Err(e) => tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}"), + Err(e) => { + tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}") + } } } Ok(invited) @@ -85,10 +107,19 @@ impl People { /// Takes a person out of a group. Through Kanidm only what portal /// put there (see `Invited.already_member`); memberships remember. - pub async fn remove(&self, state: &crate::server::AppState, who: &crate::memberships::Identity, username: &str, group: &str, by: &str) -> anyhow::Result<()> { + pub async fn remove( + &self, + state: &crate::server::AppState, + who: &crate::memberships::Identity, + username: &str, + group: &str, + by: &str, + ) -> anyhow::Result<()> { match self { People::Kanidm(kanidm) => kanidm.remove_member(group, username).await, - People::Memberships => crate::memberships::revoke(state, group, who, by).await.map(|_| ()), + People::Memberships => crate::memberships::revoke(state, group, who, by) + .await + .map(|_| ()), } } @@ -140,7 +171,9 @@ pub struct Invited { fn expiry_ms(value: &serde_json::Value) -> Option { match value { serde_json::Value::Number(n) => n.as_i64().map(|s| s * 1000), - serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s).ok().map(|t| t.timestamp_millis()), + serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s) + .ok() + .map(|t| t.timestamp_millis()), _ => None, } } @@ -163,7 +196,11 @@ fn members_from(body: &str) -> Vec { fn has_credentials_from(body: &str) -> bool { serde_json::from_str::(body) .ok() - .and_then(|v| v.get("creds").and_then(|c| c.as_array()).map(|c| !c.is_empty())) + .and_then(|v| { + v.get("creds") + .and_then(|c| c.as_array()) + .map(|c| !c.is_empty()) + }) .unwrap_or(false) } @@ -184,7 +221,15 @@ fn person_lookup_found(status: u16, body: &str) -> Option { /// `{"entries": [{"attrs": {"name": ["…"], …}}]}`. fn name_from_search(body: &str) -> Option { let v: serde_json::Value = serde_json::from_str(body).ok()?; - v.get("entries")?.as_array()?.first()?.get("attrs")?.get("name")?.as_array()?.first()?.as_str().map(str::to_string) + v.get("entries")? + .as_array()? + .first()? + .get("attrs")? + .get("name")? + .as_array()? + .first()? + .as_str() + .map(str::to_string) } pub struct Kanidm { @@ -202,13 +247,30 @@ impl Kanidm { /// `None` when `KANIDM_API_TOKEN` is unset: invites fail closed with /// a clear message instead of a half-made account. pub fn from_env() -> Option { - let base = std::env::var("KANIDM_URL").ok()?.trim_end_matches('/').to_string(); - let token = std::env::var("KANIDM_API_TOKEN").ok().filter(|t| !t.is_empty())?; - let group_prefix = std::env::var("KANIDM_GROUP_PREFIX").unwrap_or_default().trim().to_string(); - Some(Kanidm { base, token, group_prefix }) + let base = std::env::var("KANIDM_URL") + .ok()? + .trim_end_matches('/') + .to_string(); + let token = std::env::var("KANIDM_API_TOKEN") + .ok() + .filter(|t| !t.is_empty())?; + let group_prefix = std::env::var("KANIDM_GROUP_PREFIX") + .unwrap_or_default() + .trim() + .to_string(); + Some(Kanidm { + base, + token, + group_prefix, + }) } - async fn call(&self, method: openidconnect::reqwest::Method, path: &str, body: Option) -> anyhow::Result<(u16, String)> { + async fn call( + &self, + method: openidconnect::reqwest::Method, + path: &str, + body: Option, + ) -> anyhow::Result<(u16, String)> { let mut req = crate::http::client() .request(method, format!("{}{path}", self.base)) .bearer_auth(&self.token) @@ -223,8 +285,15 @@ impl Kanidm { } async fn person_exists(&self, username: &str) -> anyhow::Result { - let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}"), None).await?; - person_lookup_found(status, &text).ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}")) + let (status, text) = self + .call( + openidconnect::reqwest::Method::GET, + &format!("/v1/person/{username}"), + None, + ) + .await?; + person_lookup_found(status, &text) + .ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}")) } /// The account that already carries this email, if the token may @@ -275,7 +344,13 @@ impl Kanidm { /// Who is in a group, by account name. pub async fn members(&self, group: &str) -> anyhow::Result> { - let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), None).await?; + let (status, text) = self + .call( + openidconnect::reqwest::Method::GET, + &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), + None, + ) + .await?; if !(200..300).contains(&status) { anyhow::bail!("kanidm: reading the members of {group}: http {status}: {text}"); } @@ -286,7 +361,11 @@ impl Kanidm { /// service account manages, like adding them was. pub async fn remove_member(&self, group: &str, username: &str) -> anyhow::Result<()> { let (status, text) = self - .call(openidconnect::reqwest::Method::DELETE, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), Some(serde_json::json!([username]))) + .call( + openidconnect::reqwest::Method::DELETE, + &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), + Some(serde_json::json!([username])), + ) .await?; if !(200..300).contains(&status) { anyhow::bail!("kanidm: removing {username} from {group}: http {status}: {text}"); @@ -297,7 +376,13 @@ impl Kanidm { /// Whether Kanidm shows a credential for this person. `false` is /// "not that it can see", never "none": see `has_credentials_from`. pub async fn has_credentials(&self, username: &str) -> anyhow::Result { - let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_status"), None).await?; + let (status, text) = self + .call( + openidconnect::reqwest::Method::GET, + &format!("/v1/person/{username}/_credential/_status"), + None, + ) + .await?; if !(200..300).contains(&status) { anyhow::bail!("kanidm: credential status of {username}: http {status}"); } @@ -308,7 +393,11 @@ impl Kanidm { /// passkey, and when it runs out. pub async fn reset_link(&self, username: &str) -> anyhow::Result<(String, Option)> { let (status, text) = self - .call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/{LINK_SECONDS}"), None) + .call( + openidconnect::reqwest::Method::GET, + &format!("/v1/person/{username}/_credential/_update_intent/{LINK_SECONDS}"), + None, + ) .await?; if !(200..300).contains(&status) { anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}"); @@ -320,10 +409,18 @@ impl Kanidm { expiry_time: Option, } let intent: Intent = serde_json::from_str(&text)?; - Ok((format!("{}/ui/reset?token={}", self.base, intent.token), intent.expiry_time.as_ref().and_then(expiry_ms))) + Ok(( + format!("{}/ui/reset?token={}", self.base, intent.token), + intent.expiry_time.as_ref().and_then(expiry_ms), + )) } - async fn onboard(&self, name: &str, email: &str, group: Option<&str>) -> anyhow::Result { + async fn onboard( + &self, + name: &str, + email: &str, + group: Option<&str>, + ) -> anyhow::Result { let existing = self.find_by_mail(email).await?; let username = existing.clone().unwrap_or_else(|| username_for(email)); let created = if existing.is_some() || self.person_exists(&username).await? { @@ -351,9 +448,17 @@ impl Kanidm { // were here before is what says whose the membership is; a // failed read says "not before", which errs toward portal // tidying up after itself. - already_member = !created && self.members(group).await.is_ok_and(|m| m.contains(&username)); + already_member = !created + && self + .members(group) + .await + .is_ok_and(|m| m.contains(&username)); let (status, text) = self - .call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), Some(serde_json::json!([username]))) + .call( + openidconnect::reqwest::Method::POST, + &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), + Some(serde_json::json!([username])), + ) .await?; if !(200..300).contains(&status) { anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}"); @@ -393,7 +498,10 @@ fn link_runs_out(answer: &crate::answers::Answer) -> i64 { /// The account an invite record is about. fn invited_of(answer: &crate::answers::Answer) -> Option { - answer.responses.get("invited").and_then(|v| serde_json::from_value(v.clone()).ok()) + answer + .responses + .get("invited") + .and_then(|v| serde_json::from_value(v.clone()).ok()) } /// Where a waiting invite goes now, if anywhere. Pure: what is known @@ -401,7 +509,11 @@ fn invited_of(answer: &crate::answers::Answer) -> Option { /// /// An invite that made no account has no link to wait for: the person /// had their way in already, and was only given a place. -pub fn invite_due(answer: &crate::answers::Answer, is_in: bool, now_ms: i64) -> Option<&'static str> { +pub fn invite_due( + answer: &crate::answers::Answer, + is_in: bool, + now_ms: i64, +) -> Option<&'static str> { if answer.state != crate::answers::OPEN_STATE { return None; } @@ -418,7 +530,11 @@ pub fn invite_due(answer: &crate::answers::Answer, is_in: bool, now_ms: i64) -> /// An invite nobody at a desk made - the first person, someone the /// content names as responsible - on file like any other, so the desk /// that reads `portal_invites` sees it, and can send it again. -pub async fn record(state: &crate::server::AppState, invited: &Invited, responsible_for: &[String]) { +pub async fn record( + state: &crate::server::AppState, + invited: &Invited, + responsible_for: &[String], +) { let now_ms = chrono::Utc::now().timestamp_millis(); let mut responses = serde_json::json!({ "name": invited.display_name, @@ -429,23 +545,53 @@ pub async fn record(state: &crate::server::AppState, invited: &Invited, responsi responses["responsible_for"] = serde_json::json!(responsible_for); } let id = crate::chain::hash_node("/", &[], &responses, now_ms); - if let Err(e) = crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), INVITES_BUCKET, id, "/", SEED_ALTERNATIVE, &responses, now_ms, 0).await { + if let Err(e) = crate::answers::store_answer( + &state.jetstream, + &state.aggregates.load(), + INVITES_BUCKET, + id, + "/", + SEED_ALTERNATIVE, + &responses, + now_ms, + 0, + ) + .await + { tracing::warn!(user = %invited.username, "the invite is sent, but could not be put on file for a desk: {e}"); } } /// Moves an invite record, the way a desk button would: through the /// event log, onto the projection, and out on the answers subject. -async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: crate::answers::Answer, target: &str) { +async fn move_invite( + state: &crate::server::AppState, + store: &async_nats::jetstream::kv::Store, + mut answer: crate::answers::Answer, + target: &str, +) { let aggregates = state.aggregates.load(); - let Some(schema) = aggregates.get(INVITES_BUCKET).filter(|s| s.allowed(&answer.state).iter().any(|t| t == target)) else { + let Some(schema) = aggregates + .get(INVITES_BUCKET) + .filter(|s| s.allowed(&answer.state).iter().any(|t| t == target)) + else { // A content repo may declare the bucket itself, without this // state. Then the invite stays where its own graph has it. return; }; let now_ms = chrono::Utc::now().timestamp_millis(); let payload = serde_json::json!({ "to": target, "item": answer.id, "by": BY }); - if let Err(e) = crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms).await { + if let Err(e) = crate::answers::transition_or_reseed( + &state.jetstream, + schema, + &answer.id, + target, + &answer.state, + payload.clone(), + now_ms, + ) + .await + { tracing::warn!(id = %answer.id, target, "an invite could not be moved: {e}"); return; } @@ -454,7 +600,11 @@ async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstr answer.decided_by = Some(BY.to_string()); // The link is a way in for as long as it is on file; one that is // used or run out is only something to leak. - if let Some(invited) = answer.responses.get_mut("invited").and_then(|i| i.as_object_mut()) { + if let Some(invited) = answer + .responses + .get_mut("invited") + .and_then(|i| i.as_object_mut()) + { invited.insert("reset_link".into(), serde_json::Value::Null); } if let Ok(data) = serde_json::to_vec(&answer) { @@ -478,17 +628,33 @@ async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstr } /// Every invite on file that is still waiting in one of `states`. -async fn invites_in(state: &crate::server::AppState, states: &[&str]) -> Option<(async_nats::jetstream::kv::Store, Vec)> { +async fn invites_in( + state: &crate::server::AppState, + states: &[&str], +) -> Option<( + async_nats::jetstream::kv::Store, + Vec, +)> { // Made on first write: a site that has invited nobody has no bucket. let store = state.jetstream.get_key_value(INVITES_BUCKET).await.ok()?; let keys: Vec = { use futures::TryStreamExt; - store.keys().await.ok()?.try_collect().await.unwrap_or_default() + store + .keys() + .await + .ok()? + .try_collect() + .await + .unwrap_or_default() }; let mut found = Vec::new(); for id in keys { - let Ok(Some(bytes)) = store.get(&id).await else { continue }; - let Ok(answer) = serde_json::from_slice::(&bytes) else { continue }; + let Ok(Some(bytes)) = store.get(&id).await else { + continue; + }; + let Ok(answer) = serde_json::from_slice::(&bytes) else { + continue; + }; if states.contains(&answer.state.as_str()) { found.push(answer); } @@ -509,12 +675,17 @@ pub async fn run(state: crate::server::AppState) { /// One pass over the waiting invites: done for whoever Kanidm shows a /// credential for, expired for a link that ran out. pub async fn sweep_invites(state: &crate::server::AppState) { - let Some((store, waiting)) = invites_in(state, &[crate::answers::OPEN_STATE]).await else { return }; + let Some((store, waiting)) = invites_in(state, &[crate::answers::OPEN_STATE]).await else { + return; + }; let kanidm = Kanidm::from_env(); let now_ms = chrono::Utc::now().timestamp_millis(); for answer in waiting { let is_in = match (&kanidm, invited_of(&answer)) { - (Some(kanidm), Some(invited)) if invited.created => kanidm.has_credentials(&invited.username).await.unwrap_or(false), + (Some(kanidm), Some(invited)) if invited.created => kanidm + .has_credentials(&invited.username) + .await + .unwrap_or(false), _ => false, }; if let Some(target) = invite_due(&answer, is_in, now_ms) { @@ -527,14 +698,23 @@ pub async fn sweep_invites(state: &crate::server::AppState) { /// address it went to for someone whose account goes by another name. pub fn is_for(invited: &Invited, user: &crate::auth::User) -> bool { let address = invited.email.trim().to_lowercase(); - invited.username == user.username || (!address.is_empty() && user.email.as_deref().is_some_and(|e| e.trim().to_lowercase() == address)) + invited.username == user.username + || (!address.is_empty() + && user + .email + .as_deref() + .is_some_and(|e| e.trim().to_lowercase() == address)) } /// Someone signed in: whatever invites were waiting for them, or had /// run out, are done. This is the one sure sign a link was used - /// they are here. pub async fn on_signed_in(state: crate::server::AppState, user: crate::auth::User) { - let Some((store, waiting)) = invites_in(&state, &[crate::answers::OPEN_STATE, INVITE_EXPIRED]).await else { return }; + let Some((store, waiting)) = + invites_in(&state, &[crate::answers::OPEN_STATE, INVITE_EXPIRED]).await + else { + return; + }; for answer in waiting { if invited_of(&answer).is_some_and(|invited| is_for(&invited, &user)) { move_invite(&state, &store, answer, INVITE_DONE).await; @@ -547,11 +727,20 @@ pub async fn on_signed_in(state: crate::server::AppState, user: crate::auth::Use /// time, which for someone the content names as responsible is the one /// that says so. pub async fn on_reopened(state: &crate::server::AppState, id: &str) { - let Ok(store) = state.jetstream.get_key_value(INVITES_BUCKET).await else { return }; - let Ok(Some(bytes)) = store.get(id).await else { return }; - let Ok(mut answer) = serde_json::from_slice::(&bytes) else { return }; + let Ok(store) = state.jetstream.get_key_value(INVITES_BUCKET).await else { + return; + }; + let Ok(Some(bytes)) = store.get(id).await else { + return; + }; + let Ok(mut answer) = serde_json::from_slice::(&bytes) else { + return; + }; let Some(mut invited) = invited_of(&answer) else { - tracing::warn!(id, "an invite was sent again, but the record names no account - nothing sent"); + tracing::warn!( + id, + "an invite was sent again, but the record names no account - nothing sent" + ); return; }; let Some(kanidm) = Kanidm::from_env() else { @@ -568,7 +757,10 @@ pub async fn on_reopened(state: &crate::server::AppState, id: &str) { invited.reset_link = Some(link); invited.link_expires_ms = expires; if let Some(map) = answer.responses.as_object_mut() { - map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default()); + map.insert( + "invited".to_string(), + serde_json::to_value(&invited).unwrap_or_default(), + ); } if let Ok(data) = serde_json::to_vec(&answer) { let _ = store.put(id, data.into()).await; @@ -592,7 +784,13 @@ pub fn username_for(email: &str) -> String { let local = email.split('@').next().unwrap_or(email).to_lowercase(); let mut out: String = local .chars() - .map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') { c } else { '-' }) + .map(|c| { + if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') { + c + } else { + '-' + } + }) .collect(); while out.starts_with(|c: char| !c.is_ascii_lowercase()) && !out.is_empty() { out.remove(0); @@ -645,17 +843,33 @@ pub async fn on_state( return; }; let Some(people) = People::from_env() else { - tracing::warn!(bucket, id, to_state, group, "this state grants a group, but {} - nobody was added", People::NOT_CONFIGURED); + tracing::warn!( + bucket, + id, + to_state, + group, + "this state grants a group, but {} - nobody was added", + People::NOT_CONFIGURED + ); return; }; let Some((name, who)) = who_for(responses) else { tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and no phone number or email - nobody was added"); return; }; - let invited = match people.invite(state, &name, &who, &group, &format!("{bucket}:{id}")).await { + let invited = match people + .invite(state, &name, &who, &group, &format!("{bucket}:{id}")) + .await + { Ok(invited) => invited, Err(e) => { - tracing::error!(bucket, id, to_state, group, "granting the group failed: {e}"); + tracing::error!( + bucket, + id, + to_state, + group, + "granting the group failed: {e}" + ); return; } }; @@ -673,14 +887,20 @@ mod tests { #[test] fn usernames_are_the_email_local_part_made_safe() { assert_eq!(username_for("jorn@westra-engineering.no"), "jorn"); - assert_eq!(username_for("Ole.Nordmann+site@Example.com"), "ole.nordmann-site"); + assert_eq!( + username_for("Ole.Nordmann+site@Example.com"), + "ole.nordmann-site" + ); assert_eq!(username_for("42@x.no"), "person"); assert_eq!(username_for("Ærlig@x.no"), "rlig"); } #[test] fn a_search_by_mail_yields_the_accounts_own_name() { - assert_eq!(name_from_search(r#"{"entries":[{"attrs":{"name":["ole.n"],"mail":["ole@x.no"]}}]}"#), Some("ole.n".into())); + assert_eq!( + name_from_search(r#"{"entries":[{"attrs":{"name":["ole.n"],"mail":["ole@x.no"]}}]}"#), + Some("ole.n".into()) + ); assert_eq!(name_from_search(r#"{"entries":[]}"#), None); assert_eq!(name_from_search("null"), None); assert_eq!(name_from_search("not json"), None); @@ -688,21 +908,32 @@ mod tests { #[test] fn a_link_runs_out_when_kanidm_says_in_either_spelling() { - assert_eq!(expiry_ms(&serde_json::json!(1790793550)), Some(1790793550000)); - assert_eq!(expiry_ms(&serde_json::json!("2026-09-30T12:00:00Z")), Some(1790769600000)); + assert_eq!( + expiry_ms(&serde_json::json!(1790793550)), + Some(1790793550000) + ); + assert_eq!( + expiry_ms(&serde_json::json!("2026-09-30T12:00:00Z")), + Some(1790769600000) + ); assert_eq!(expiry_ms(&serde_json::json!(null)), None); } #[test] fn members_are_account_names_and_an_empty_group_is_null() { - assert_eq!(members_from(r#"["kari@id.vel.no","ola@id.vel.no"]"#), vec!["kari", "ola"]); + assert_eq!( + members_from(r#"["kari@id.vel.no","ola@id.vel.no"]"#), + vec!["kari", "ola"] + ); assert!(members_from("null").is_empty()); assert!(members_from("nonsense").is_empty()); } #[test] fn only_a_listed_credential_counts() { - assert!(has_credentials_from(r#"{"creds":[{"type_":"PasswordMfa"}]}"#)); + assert!(has_credentials_from( + r#"{"creds":[{"type_":"PasswordMfa"}]}"# + )); assert!(!has_credentials_from(r#"{"creds":[]}"#)); assert!(!has_credentials_from("null")); } @@ -730,24 +961,53 @@ mod tests { fn a_waiting_invite_is_done_when_they_are_in_and_expired_when_the_link_ran_out() { let day = LINK_SECONDS * 1000; // Still within the link's life, and nobody has seen them: wait. - assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 4_999), None); - assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 5_000), Some(INVITE_EXPIRED)); + assert_eq!( + invite_due(&invite("open", true, Some(5_000)), false, 4_999), + None + ); + assert_eq!( + invite_due(&invite("open", true, Some(5_000)), false, 5_000), + Some(INVITE_EXPIRED) + ); // In is in, however long the link had left. - assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 10), Some(INVITE_DONE)); - assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 9_000), Some(INVITE_DONE)); + assert_eq!( + invite_due(&invite("open", true, Some(5_000)), true, 10), + Some(INVITE_DONE) + ); + assert_eq!( + invite_due(&invite("open", true, Some(5_000)), true, 9_000), + Some(INVITE_DONE) + ); // A record from before links carried their end: a day from when it was sent. - assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day - 1), None); - assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day), Some(INVITE_EXPIRED)); + assert_eq!( + invite_due(&invite("open", true, None), false, 1_000 + day - 1), + None + ); + assert_eq!( + invite_due(&invite("open", true, None), false, 1_000 + day), + Some(INVITE_EXPIRED) + ); // Someone who had an account was only given a place: nothing to wait for. - assert_eq!(invite_due(&invite("open", false, None), false, 10), Some(INVITE_DONE)); + assert_eq!( + invite_due(&invite("open", false, None), false, 10), + Some(INVITE_DONE) + ); // Only a waiting invite moves. - assert_eq!(invite_due(&invite("expired", true, Some(5_000)), false, 9_000), None); - assert_eq!(invite_due(&invite("done", true, Some(5_000)), true, 9_000), None); + assert_eq!( + invite_due(&invite("expired", true, Some(5_000)), false, 9_000), + None + ); + assert_eq!( + invite_due(&invite("done", true, Some(5_000)), true, 9_000), + None + ); } #[test] fn an_invite_is_theirs_by_account_or_by_address() { - let invited: Invited = serde_json::from_value(invite("open", true, None).responses["invited"].clone()).unwrap(); + let invited: Invited = + serde_json::from_value(invite("open", true, None).responses["invited"].clone()) + .unwrap(); let user = |username: &str, email: Option<&str>| crate::auth::User { sub: "s".into(), username: username.into(), @@ -766,7 +1026,10 @@ mod tests { fn a_null_person_answer_means_nobody_there() { assert_eq!(person_lookup_found(200, "null"), Some(false)); assert_eq!(person_lookup_found(200, "null\n"), Some(false)); - assert_eq!(person_lookup_found(200, r#"{"attrs":{"name":["bl"]}}"#), Some(true)); + assert_eq!( + person_lookup_found(200, r#"{"attrs":{"name":["bl"]}}"#), + Some(true) + ); assert_eq!(person_lookup_found(404, ""), Some(false)); assert_eq!(person_lookup_found(401, "unauthorized"), None); } diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..33c22ba --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,435 @@ +//! An Apple configuration profile, rendered from the mail server's own +//! PACC document. +//! +//! iOS has no autoconfiguration for IMAP accounts: Apple Mail reads neither +//! Mozilla autoconfig nor Microsoft Autodiscover (those are for ActiveSync), +//! so adding an account by hand means the device probing for ports. A probe +//! that meets a *filtered* port - a firewall that drops rather than refuses - +//! waits out a TCP timeout and the account is reported as unverifiable, which +//! reads to the person as "wrong password". A profile ends the guessing: it +//! states the hosts and the ports, and carries mail, calendars and contacts in +//! one install. +//! +//! What it does not do is invent any of those values. `site.yaml` names a +//! **PACC** document (`/.well-known/user-agent-configuration.json`, which +//! Stalwart generates and which is defined to live on the mail domain), and +//! every host, port and collection path in the profile comes from there. So +//! the ports are never written down in two places, and a server that moves a +//! collection moves it in the profile too. +//! +//! The profile asks for the address and the password at install time rather +//! than embedding them: it holds nothing personal, so one document serves +//! everyone with an account and it can be fetched by anyone - it says no more +//! than the PACC document it is built from already says in public. + +#[cfg(feature = "ssr")] +use crate::content::ProfileConfig; + +/// The parts of a PACC document this renders from. Unknown protocols and +/// members are ignored: PACC grows, and a profile only needs what iOS can +/// be told about. +#[cfg(feature = "ssr")] +#[derive(serde::Deserialize, Default)] +struct Pacc { + #[serde(default)] + protocols: Protocols, +} + +#[cfg(feature = "ssr")] +#[derive(serde::Deserialize, Default)] +struct Protocols { + #[serde(default)] + imap: Option, + #[serde(default)] + smtp: Option, + #[serde(default)] + caldav: Option, + #[serde(default)] + carddav: Option, +} + +/// A protocol spoken on a host. PACC may state the port; when it does not, +/// the caller supplies the registered implicit-TLS default. +#[cfg(feature = "ssr")] +#[derive(serde::Deserialize)] +struct HostPort { + host: String, + #[serde(default)] + port: Option, +} + +#[cfg(feature = "ssr")] +#[derive(serde::Deserialize)] +struct Endpoint { + url: String, +} + +/// Host, port and path of a DAV collection URL. A collection whose URL +/// cannot be parsed is left out of the profile rather than guessed at. +#[cfg(feature = "ssr")] +fn split_url(url: &str) -> Option<(String, u16, String)> { + let rest = url.strip_prefix("https://")?; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + let (host, port) = match authority.rsplit_once(':') { + Some((h, p)) => (h.to_string(), p.parse().ok()?), + None => (authority.to_string(), 443), + }; + if host.is_empty() { + return None; + } + Some((host, port, path.to_string())) +} + +/// A UUID derived from what the payload is for, not drawn at random: iOS +/// replaces an installed profile whose identifier and UUID it already has, and +/// stacks a duplicate when they differ. Rendering the document twice has to +/// produce the same ids, so reinstalling updates the account instead of adding +/// a second one beside it. +#[cfg(feature = "ssr")] +fn stable_uuid(seed: &str) -> String { + use sha2::{Digest, Sha256}; + let h = Sha256::digest(seed.as_bytes()); + let b: Vec = h.iter().take(16).map(|x| format!("{x:02X}")).collect(); + let s = b.join(""); + format!( + "{}-{}-{}-{}-{}", + &s[0..8], + &s[8..12], + &s[12..16], + &s[16..20], + &s[20..32] + ) +} + +#[cfg(feature = "ssr")] +fn esc(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +#[cfg(feature = "ssr")] +fn string(key: &str, value: &str) -> String { + format!( + "\t\t\t{key}\n\t\t\t{}\n", + esc(value) + ) +} + +#[cfg(feature = "ssr")] +fn integer(key: &str, value: u16) -> String { + format!("\t\t\t{key}\n\t\t\t{value}\n") +} + +#[cfg(feature = "ssr")] +fn boolean(key: &str, value: bool) -> String { + format!( + "\t\t\t{key}\n\t\t\t<{}/>\n", + if value { "true" } else { "false" } + ) +} + +/// Renders the profile. `domain` is the site's own host, which seeds the +/// identifiers and names the accounts on the device. +#[cfg(feature = "ssr")] +pub fn render(pacc: &str, domain: &str, cfg: &ProfileConfig) -> anyhow::Result { + let pacc: Pacc = serde_json::from_str(pacc)?; + let name = cfg.name.clone().unwrap_or_else(|| domain.to_string()); + let base = format!("{domain}.profile"); + let mut payloads = String::new(); + let mut count = 0; + + // Mail. Both halves have to be there: a profile with an incoming server + // and no outgoing one installs an account that cannot send. + if let (Some(imap), Some(smtp)) = (&pacc.protocols.imap, &pacc.protocols.smtp) { + count += 1; + payloads.push_str("\t\t\n"); + payloads.push_str(&string("PayloadType", "com.apple.mail.managed")); + payloads.push_str(&string("PayloadVersion", "1")); + payloads.push_str(&string("PayloadIdentifier", &format!("{base}.mail"))); + payloads.push_str(&string( + "PayloadUUID", + &stable_uuid(&format!("{base}.mail")), + )); + payloads.push_str(&string("PayloadDisplayName", &format!("{name} mail"))); + payloads.push_str(&string("EmailAccountType", "EmailTypeIMAP")); + payloads.push_str(&string("EmailAccountDescription", &name)); + payloads.push_str(&string("IncomingMailServerHostName", &imap.host)); + payloads.push_str(&integer( + "IncomingMailServerPortNumber", + imap.port.unwrap_or(993), + )); + payloads.push_str(&boolean("IncomingMailServerUseSSL", true)); + payloads.push_str(&string( + "IncomingMailServerAuthentication", + "EmailAuthPassword", + )); + payloads.push_str(&string("OutgoingMailServerHostName", &smtp.host)); + payloads.push_str(&integer( + "OutgoingMailServerPortNumber", + smtp.port.unwrap_or(465), + )); + payloads.push_str(&boolean("OutgoingMailServerUseSSL", true)); + payloads.push_str(&string( + "OutgoingMailServerAuthentication", + "EmailAuthPassword", + )); + // The one password is the same both ways, so the person types it once. + payloads.push_str(&boolean("OutgoingPasswordSameAsIncomingPassword", true)); + payloads.push_str("\t\t\n"); + } + + for (proto, endpoint, kind, label) in [ + ( + "caldav", + pacc.protocols.caldav.as_ref(), + "com.apple.caldav.account", + "calendars", + ), + ( + "carddav", + pacc.protocols.carddav.as_ref(), + "com.apple.carddav.account", + "contacts", + ), + ] { + let Some((host, port, path)) = endpoint.and_then(|e| split_url(&e.url)) else { + continue; + }; + count += 1; + let prefix = if proto == "caldav" { + "CalDAV" + } else { + "CardDAV" + }; + payloads.push_str("\t\t\n"); + payloads.push_str(&string("PayloadType", kind)); + payloads.push_str(&string("PayloadVersion", "1")); + payloads.push_str(&string("PayloadIdentifier", &format!("{base}.{proto}"))); + payloads.push_str(&string( + "PayloadUUID", + &stable_uuid(&format!("{base}.{proto}")), + )); + payloads.push_str(&string("PayloadDisplayName", &format!("{name} {label}"))); + payloads.push_str(&string(&format!("{prefix}AccountDescription"), &name)); + payloads.push_str(&string(&format!("{prefix}HostName"), &host)); + payloads.push_str(&integer(&format!("{prefix}Port"), port)); + payloads.push_str(&boolean(&format!("{prefix}UseSSL"), true)); + payloads.push_str(&string(&format!("{prefix}PrincipalURL"), &path)); + payloads.push_str("\t\t\n"); + } + + if count == 0 { + anyhow::bail!("PACC document describes no protocol iOS can be configured with"); + } + + let organization = cfg.organization.clone().unwrap_or_else(|| name.clone()); + Ok(format!( + "\n\ + \n\ + \n\ + \n\ + \tPayloadType\n\tConfiguration\n\ + \tPayloadVersion\n\t1\n\ + \tPayloadIdentifier\n\t{base}\n\ + \tPayloadUUID\n\t{uuid}\n\ + \tPayloadDisplayName\n\t{display}\n\ + \tPayloadOrganization\n\t{org}\n\ + \tPayloadDescription\n\t{desc}\n\ + \tPayloadRemovalDisallowed\n\t\n\ + \tPayloadContent\n\t\n{payloads}\t\n\ + \n\n", + base = esc(&base), + uuid = stable_uuid(&base), + display = esc(&name), + org = esc(&organization), + desc = esc(&format!( + "Sets up {name} on this device. You will be asked for your address and password." + )), + payloads = payloads, + )) +} + +/// `GET /profile.mobileconfig` - the profile for this site, or 404 when +/// `site.yaml` declares no `profile:`. Served as +/// `application/x-apple-aspen-config`, without which iOS downloads the file +/// instead of offering to install it. +#[cfg(feature = "ssr")] +pub async fn handler( + axum::extract::State(state): axum::extract::State, +) -> axum::response::Response { + use axum::http::{header, StatusCode}; + use axum::response::IntoResponse; + + let site = state.site.load(); + let Some(cfg) = site.profile.clone() else { + return ( + StatusCode::NOT_FOUND, + "this site offers no configuration profile", + ) + .into_response(); + }; + let doc = match crate::http::client() + .get(&cfg.pacc) + .send() + .await + .and_then(|r| r.error_for_status()) + { + Ok(r) => r, + Err(e) => { + tracing::warn!("profile: fetching {}: {e}", cfg.pacc); + return (StatusCode::BAD_GATEWAY, "cannot reach the mail server").into_response(); + } + }; + let body = match crate::http::bytes(doc, crate::http::ASSET_CAP).await { + Ok(b) => b, + Err(e) => return (StatusCode::BAD_GATEWAY, e.to_string()).into_response(), + }; + let text = match String::from_utf8(body) { + Ok(t) => t, + Err(_) => return (StatusCode::BAD_GATEWAY, "PACC document is not UTF-8").into_response(), + }; + let domain = crate::mail::public_url(); + let domain = domain.trim_start_matches("https://").trim_end_matches('/'); + match render(&text, domain, &cfg) { + Ok(plist) => ( + [ + (header::CONTENT_TYPE, "application/x-apple-aspen-config"), + (header::CACHE_CONTROL, "no-cache"), + ], + plist, + ) + .into_response(), + Err(e) => { + tracing::warn!("profile: {e}"); + (StatusCode::BAD_GATEWAY, e.to_string()).into_response() + } + } +} + +#[cfg(all(test, feature = "ssr"))] +mod tests { + use super::*; + + /// Stalwart 0.16's own PACC document, as served. Note what it does *not* + /// say: imap and smtp carry a host and no port, which is why the renderer + /// has to supply the registered implicit-TLS defaults. + const PACC: &str = r#"{"protocols":{"jmap":{"url":"https://mail.example.no/jmap/session"}, + "imap":{"host":"mail.example.no"},"pop3":{"host":"mail.example.no"}, + "smtp":{"host":"mail.example.no"},"caldav":{"url":"https://mail.example.no/dav/cal/"}, + "carddav":{"url":"https://mail.example.no/dav/card/"}, + "webdav":{"url":"https://mail.example.no/dav/file/"}, + "managesieve":{"host":"mail.example.no"}}, + "authentication":{"oauth-public":{"issuer":"https://mail.example.no"},"password":true}, + "info":{"provider":{"name":"Stalwart"}}}"#; + + fn cfg() -> ProfileConfig { + ProfileConfig { + pacc: "https://mail.example.no/.well-known/user-agent-configuration.json".into(), + name: Some("Example".into()), + organization: None, + } + } + + #[test] + fn every_value_comes_from_the_pacc_document() { + let p = render(PACC, "example.no", &cfg()).unwrap(); + assert!(p.contains("com.apple.mail.managed")); + assert!(p.contains("com.apple.caldav.account")); + assert!(p.contains("com.apple.carddav.account")); + assert!(p.contains( + "IncomingMailServerHostName\n\t\t\tmail.example.no" + )); + // The ports PACC left out, filled in from the registry, not invented. + assert!(p.contains("IncomingMailServerPortNumber\n\t\t\t993")); + assert!(p.contains("OutgoingMailServerPortNumber\n\t\t\t465")); + // A DAV URL becomes a host, a port and the collection's own path. + assert!(p.contains("CalDAVHostName\n\t\t\tmail.example.no")); + assert!(p.contains("CalDAVPrincipalURL\n\t\t\t/dav/cal/")); + assert!(p.contains("CardDAVPrincipalURL\n\t\t\t/dav/card/")); + assert!(p.contains("CalDAVPort\n\t\t\t443")); + } + + /// Nothing personal in the document: no address and no username, so iOS + /// asks at install and one profile serves everyone with an account. + #[test] + fn the_profile_holds_no_credentials() { + let p = render(PACC, "example.no", &cfg()).unwrap(); + for key in [ + "EmailAddress", + "IncomingMailServerUsername", + "OutgoingMailServerUsername", + "CalDAVUsername", + "CardDAVUsername", + "CalDAVPassword", + "Password", + ] { + assert!( + !p.contains(&format!("{key}")), + "{key} must not be in the profile" + ); + } + } + + /// Rendering twice has to give the same ids, or a reinstall adds a second + /// account beside the first instead of replacing it. + #[test] + fn identifiers_are_stable_across_renders() { + let a = render(PACC, "example.no", &cfg()).unwrap(); + let b = render(PACC, "example.no", &cfg()).unwrap(); + assert_eq!(a, b); + // ...and belong to this site alone. + let other = render(PACC, "other.no", &cfg()).unwrap(); + assert_ne!(a, other); + } + + /// A PACC document that describes a port explicitly wins over the default. + #[test] + fn a_stated_port_is_used() { + let pacc = r#"{"protocols":{"imap":{"host":"m.example.no","port":1993}, + "smtp":{"host":"m.example.no","port":1465}}}"#; + let p = render(pacc, "example.no", &cfg()).unwrap(); + assert!(p.contains("1993")); + assert!(p.contains("1465")); + } + + /// Half a mail account is worse than none: an account that cannot send is + /// not installed, and a document with nothing iOS understands is an error + /// rather than an empty profile that looks like it worked. + #[test] + fn an_incomplete_document_is_refused() { + let incoming_only = r#"{"protocols":{"imap":{"host":"m.example.no"}}}"#; + assert!(render(incoming_only, "example.no", &cfg()).is_err()); + let jmap_only = r#"{"protocols":{"jmap":{"url":"https://m.example.no/jmap/session"}}}"#; + assert!(render(jmap_only, "example.no", &cfg()).is_err()); + } + + /// A name with an ampersand in it must not break the plist. + #[test] + fn names_are_escaped() { + let c = ProfileConfig { + name: Some("Smith & Co ".into()), + ..cfg() + }; + let p = render(PACC, "example.no", &c).unwrap(); + assert!(p.contains("Smith & Co <mail>")); + assert!(!p.contains("Smith & Co")); + } + + #[test] + fn a_dav_url_that_cannot_be_parsed_is_left_out() { + assert_eq!( + split_url("https://m.example.no:8443/dav/cal/"), + Some(("m.example.no".into(), 8443, "/dav/cal/".into())) + ); + assert_eq!( + split_url("https://m.example.no"), + Some(("m.example.no".into(), 443, "/".into())) + ); + assert_eq!(split_url("http://m.example.no/dav/cal/"), None); + assert_eq!(split_url("https://"), None); + } +} diff --git a/src/report.rs b/src/report.rs index 529d1c5..335c051 100644 --- a/src/report.rs +++ b/src/report.rs @@ -23,14 +23,19 @@ pub struct Report { } pub async fn gather(state: &crate::server::AppState) -> Report { - let mut report = Report { storylines: state.questions.load().arity(), buckets: BTreeMap::new() }; + let mut report = Report { + storylines: state.questions.load().arity(), + buckets: BTreeMap::new(), + }; let aggregates = state.aggregates.load(); for bucket in aggregates.keys() { // The runtime's own buckets are not the site's offers. if bucket.starts_with("portal_") { continue; } - let Ok(store) = state.jetstream.get_key_value(bucket).await else { continue }; + let Ok(store) = state.jetstream.get_key_value(bucket).await else { + continue; + }; let keys: Vec = { use futures::TryStreamExt; match store.keys().await { @@ -40,14 +45,24 @@ pub async fn gather(state: &crate::server::AppState) -> Report { }; let counts = report.buckets.entry(bucket.clone()).or_default(); for key in keys { - let Ok(Some(bytes)) = store.get(&key).await else { continue }; - let Ok(answer) = serde_json::from_slice::(&bytes) else { continue }; - *counts.entry(answer.storyline.to_string()).or_default().entry(answer.state).or_default() += 1; + let Ok(Some(bytes)) = store.get(&key).await else { + continue; + }; + let Ok(answer) = serde_json::from_slice::(&bytes) else { + continue; + }; + *counts + .entry(answer.storyline.to_string()) + .or_default() + .entry(answer.state) + .or_default() += 1; } } report } -pub async fn handler(axum::extract::State(state): axum::extract::State) -> axum::Json { +pub async fn handler( + axum::extract::State(state): axum::extract::State, +) -> axum::Json { axum::Json(gather(&state).await) } diff --git a/src/resource.rs b/src/resource.rs index 32b8ae4..8049f4c 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -27,13 +27,18 @@ pub async fn get_resource( // `Resource::refetch()` after a fully-empty-params call failed with // "missing field `params`", even though the very first load (always // server-resolved) never did. - #[server(default)] - params: std::collections::HashMap, + #[server(default)] params: std::collections::HashMap, ) -> Result { use crate::server::AppState; let state = expect_context::(); - let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?; + let feature = find_feature( + &state, + crate::server::storyline::current().await, + &question_id, + &alternative, + &feature_name, + )?; let resource = feature .resource .as_ref() @@ -56,13 +61,18 @@ pub async fn get_requirement_options( feature_name: String, requirement_name: String, // See get_resource's `params` for why this is needed. - #[server(default)] - params: std::collections::HashMap, + #[server(default)] params: std::collections::HashMap, ) -> Result { use crate::server::AppState; let state = expect_context::(); - let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?; + let feature = find_feature( + &state, + crate::server::storyline::current().await, + &question_id, + &alternative, + &feature_name, + )?; let requirement = feature .requirements .iter() @@ -82,7 +92,13 @@ pub async fn get_requirement_options( .resource .as_ref() .ok_or_else(|| ServerFnError::new("requirement has no resource"))?; - authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?; + authorize_resource( + &state, + &question_id, + &alternative, + &format!("{feature_name}|{requirement_name}"), + ) + .await?; fetch_resource_value(&state, resource, &as_the_person_asking(params).await).await } @@ -97,13 +113,18 @@ pub async fn get_requirement_binding( feature_name: String, requirement_name: String, // See get_resource's `params` for why this is needed. - #[server(default)] - params: std::collections::HashMap, + #[server(default)] params: std::collections::HashMap, ) -> Result { use crate::server::AppState; let state = expect_context::(); - let feature = find_feature(&state, crate::server::storyline::current().await, &question_id, &alternative, &feature_name)?; + let feature = find_feature( + &state, + crate::server::storyline::current().await, + &question_id, + &alternative, + &feature_name, + )?; let requirement = feature .requirements .iter() @@ -113,7 +134,13 @@ pub async fn get_requirement_binding( .bind .as_ref() .ok_or_else(|| ServerFnError::new("requirement has no binding"))?; - authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?; + authorize_resource( + &state, + &question_id, + &alternative, + &format!("{feature_name}|{requirement_name}|bind"), + ) + .await?; fetch_resource_value(&state, &bind.resource, &as_the_person_asking(params).await).await } @@ -138,8 +165,9 @@ fn find_feature( // also what substitutes the URL segment into the page's resource // keys - so this lookup is where a `/review/` page's // feature acquires its record-specific key. - let question = crate::content::resolve_question(state.questions.load().at(storyline), question_id) - .ok_or_else(|| ServerFnError::new("unknown question"))?; + let question = + crate::content::resolve_question(state.questions.load().at(storyline), question_id) + .ok_or_else(|| ServerFnError::new("unknown question"))?; question .alternatives .iter() @@ -232,9 +260,16 @@ pub(crate) fn matches_filter( } else { filter.equals.clone() }; - let wanted = crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals); + let wanted = + crate::content::template_action(&equals, &serde_json::json!(params)).unwrap_or(equals); // Addresses compare without case; everything else as written. - let same = |found: &str| if filter.equals.contains("{user.email}") { found.trim().eq_ignore_ascii_case(&wanted) } else { found == wanted }; + let same = |found: &str| { + if filter.equals.contains("{user.email}") { + found.trim().eq_ignore_ascii_case(&wanted) + } else { + found == wanted + } + }; let found = value .get("responses") .and_then(|r| r.get(&filter.field)) @@ -254,16 +289,24 @@ pub(crate) const USER_EMAIL: &str = "__user_email"; /// The caller's parameters with the session's person put in and /// anything pretending to be them taken out. #[cfg(feature = "ssr")] -pub(crate) fn with_person(mut params: std::collections::HashMap, user: Option<&crate::auth::User>) -> std::collections::HashMap { +pub(crate) fn with_person( + mut params: std::collections::HashMap, + user: Option<&crate::auth::User>, +) -> std::collections::HashMap { params.retain(|k, _| !k.starts_with("__")); - if let Some(email) = user.and_then(|u| u.email.as_deref()).filter(|e| !e.trim().is_empty()) { + if let Some(email) = user + .and_then(|u| u.email.as_deref()) + .filter(|e| !e.trim().is_empty()) + { params.insert(USER_EMAIL.to_string(), email.trim().to_lowercase()); } params } #[cfg(feature = "ssr")] -async fn as_the_person_asking(params: std::collections::HashMap) -> std::collections::HashMap { +async fn as_the_person_asking( + params: std::collections::HashMap, +) -> std::collections::HashMap { use crate::auth::{User, SESSION_USER_KEY}; let user = match leptos_axum::extract::().await { Ok(session) => session.get::(SESSION_USER_KEY).await.ok().flatten(), @@ -300,8 +343,8 @@ async fn fetch_resource_value( .await .map_err(|e| ServerFnError::new(e.to_string()))? .ok_or_else(|| ServerFnError::new("resource key not found"))?; - let value: serde_json::Value = - serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?; + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|e| ServerFnError::new(e.to_string()))?; // A state filter holds for one record as well as a // listing, or naming the key would walk around it. if !shows_state(resource, &value) || !matches_filter(resource, &value, params) { @@ -322,7 +365,9 @@ async fn fetch_resource_value( for key in keys { if let Ok(Some(bytes)) = store.get(&key).await { if let Ok(value) = serde_json::from_slice::(&bytes) { - if shows_state(resource, &value) && matches_filter(resource, &value, params) { + if shows_state(resource, &value) + && matches_filter(resource, &value, params) + { items.push(value); } } @@ -341,8 +386,12 @@ async fn fetch_resource_value( fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), params).await? } ResourceSource::GiteaReleases { owner, repo } => { - fetch_gitea_json(state, &format!("/api/v1/repos/{owner}/{repo}/releases"), params) - .await? + fetch_gitea_json( + state, + &format!("/api/v1/repos/{owner}/{repo}/releases"), + params, + ) + .await? } ResourceSource::Url { url } => fetch_url_json(url, params).await?, }; @@ -380,7 +429,10 @@ pub const FINISHED_SHOWN: usize = 3; #[cfg(feature = "ssr")] fn finished_wanted(params: &std::collections::HashMap) -> usize { - params.get(FINISHED_PARAM).and_then(|n| n.parse().ok()).unwrap_or(FINISHED_SHOWN) + params + .get(FINISHED_PARAM) + .and_then(|n| n.parse().ok()) + .unwrap_or(FINISHED_SHOWN) } /// A desk is for the cases still moving. Every record still open stays; @@ -389,12 +441,21 @@ fn finished_wanted(params: &std::collections::HashMap) -> usize /// ones to offer. Each finished record is marked, so the page can tell /// them apart without knowing the state machine. #[cfg(feature = "ssr")] -pub(crate) fn cap_finished(items: Vec, schema: &crate::aggregates::AggregateSchema, shown: usize) -> Vec { +pub(crate) fn cap_finished( + items: Vec, + schema: &crate::aggregates::AggregateSchema, + shown: usize, +) -> Vec { let is_final = |v: &serde_json::Value| { - v.get("state").and_then(|s| s.as_str()).is_some_and(|s| schema.has_state(s) && schema.allowed(s).is_empty()) + v.get("state") + .and_then(|s| s.as_str()) + .is_some_and(|s| schema.has_state(s) && schema.allowed(s).is_empty()) }; let when = |v: &serde_json::Value| { - v.get("decided_ms").and_then(|d| d.as_i64()).or_else(|| v.get("submitted_ms").and_then(|d| d.as_i64())).unwrap_or(0) + v.get("decided_ms") + .and_then(|d| d.as_i64()) + .or_else(|| v.get("submitted_ms").and_then(|d| d.as_i64())) + .unwrap_or(0) }; let (mut finished, mut open): (Vec<_>, Vec<_>) = items.into_iter().partition(is_final); finished.sort_by_key(|v| std::cmp::Reverse(when(v))); @@ -591,9 +652,16 @@ pub fn apply_jq( let input_val = read::parse_single(input_text.as_bytes()) .map_err(|e| ServerFnError::new(format!("internal: re-parsing json for jq: {e:?}")))?; - let program = File { code: filter_src, path: () }; - let defs = jaq_core::defs().chain(jaq_std::defs()).chain(jaq_json::defs()); - let funs = jaq_core::funs().chain(jaq_std::funs()).chain(jaq_json::funs()); + let program = File { + code: filter_src, + path: (), + }; + let defs = jaq_core::defs() + .chain(jaq_std::defs()) + .chain(jaq_json::defs()); + let funs = jaq_core::funs() + .chain(jaq_std::funs()) + .chain(jaq_json::funs()); let loader = Loader::new(defs); let arena = Arena::default(); let modules = loader @@ -607,7 +675,8 @@ pub fn apply_jq( let mut outputs = Vec::new(); for result in filter.id.run((ctx, input_val)).map(unwrap_valr) { - let val = result.map_err(|e| ServerFnError::new(format!("jq filter runtime error: {e:?}")))?; + let val = + result.map_err(|e| ServerFnError::new(format!("jq filter runtime error: {e:?}")))?; let text = val.to_string(); let json: serde_json::Value = serde_json::from_str(&text) .map_err(|e| ServerFnError::new(format!("internal: parsing jq output: {e}")))?; @@ -633,16 +702,30 @@ mod tests { #[test] fn a_listing_can_be_narrowed_to_the_states_worth_showing() { let all = spec("source: { kind: kv, bucket: cases }\npublic: true\n"); - let decided = spec("source: { kind: kv, bucket: cases }\npublic: true\nstates: [approved, refused]\n"); + let decided = spec( + "source: { kind: kv, bucket: cases }\npublic: true\nstates: [approved, refused]\n", + ); for state in ["approved", "refused", "withdrawn", "open"] { assert!(shows_state(&all, &record(state, serde_json::json!({})))); } - assert!(shows_state(&decided, &record("approved", serde_json::json!({})))); - assert!(shows_state(&decided, &record("refused", serde_json::json!({})))); - assert!(!shows_state(&decided, &record("withdrawn", serde_json::json!({})))); + assert!(shows_state( + &decided, + &record("approved", serde_json::json!({})) + )); + assert!(shows_state( + &decided, + &record("refused", serde_json::json!({})) + )); + assert!(!shows_state( + &decided, + &record("withdrawn", serde_json::json!({})) + )); // Something that is not an aggregate has no state to filter on // and is left alone. - assert!(shows_state(&decided, &serde_json::json!({ "name": "a file" }))); + assert!(shows_state( + &decided, + &serde_json::json!({ "name": "a file" }) + )); } // The other half of a record belonging to a record: the @@ -651,9 +734,15 @@ mod tests { fn a_listing_can_be_narrowed_to_the_children_of_one_record() { let children = spec("source: { kind: kv, bucket: objections }\nrequires_group: case_officers\nwhere: { field: case, equals: \"{key}\" }\n"); let params = std::collections::HashMap::from([("key".to_string(), "abc123".to_string())]); - let mine = record("open", serde_json::json!({ "case": "abc123", "your_objection": "the sun" })); + let mine = record( + "open", + serde_json::json!({ "case": "abc123", "your_objection": "the sun" }), + ); let theirs = record("open", serde_json::json!({ "case": "def456" })); - let neither = record("open", serde_json::json!({ "your_objection": "no case named" })); + let neither = record( + "open", + serde_json::json!({ "your_objection": "no case named" }), + ); assert!(matches_filter(&children, &mine, ¶ms)); assert!(!matches_filter(&children, &theirs, ¶ms)); // A record that did not say is not everyone's child. @@ -663,13 +752,25 @@ mod tests { let own = spec("source: { kind: kv, bucket: cases }\nrequires_group: customers\nwhere: { field: email, equals: \"{user.email}\" }\n"); let hers = serde_json::json!({ "responses": { "email": "Kari@Vel.no" } }); let his = serde_json::json!({ "responses": { "email": "ola@vel.no" } }); - let kari = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) }; - let forged: std::collections::HashMap = [(USER_EMAIL.to_string(), "ola@vel.no".to_string())].into_iter().collect(); + let kari = crate::auth::User { + sub: "s".into(), + username: "kari".into(), + display_name: "Kari".into(), + groups: vec![], + phone: None, + email: Some("kari@vel.no".into()), + }; + let forged: std::collections::HashMap = + [(USER_EMAIL.to_string(), "ola@vel.no".to_string())] + .into_iter() + .collect(); let asked = with_person(forged.clone(), Some(&kari)); assert!(matches_filter(&own, &hers, &asked) && !matches_filter(&own, &his, &asked)); // Nobody signed in: the forged parameter is dropped, and nothing matches. let anonymous = with_person(forged, None); - assert!(!matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous)); + assert!( + !matches_filter(&own, &hers, &anonymous) && !matches_filter(&own, &his, &anonymous) + ); // No filter, no narrowing: every resource written before this // existed still lists its whole bucket. let plain = spec("source: { kind: kv, bucket: objections }\npublic: true\n"); @@ -680,8 +781,22 @@ mod tests { fn a_filter_can_name_a_value_outright() { let fixed = spec("source: { kind: kv, bucket: cases }\npublic: true\nwhere: { field: which_authority, equals: The county }\n"); let params = std::collections::HashMap::new(); - assert!(matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "The county" })), ¶ms)); - assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), ¶ms)); + assert!(matches_filter( + &fixed, + &record( + "open", + serde_json::json!({ "which_authority": "The county" }) + ), + ¶ms + )); + assert!(!matches_filter( + &fixed, + &record( + "open", + serde_json::json!({ "which_authority": "Fire service" }) + ), + ¶ms + )); } /// `limit` caps what the page renders, and does it after `jq` so @@ -702,12 +817,19 @@ mod tests { let shaped = apply_jq(filter, &releases).expect("filter runs"); let all = apply_limit(None, shaped.clone()); - assert_eq!(all.as_array().map(Vec::len), Some(3), "no limit shows everything"); + assert_eq!( + all.as_array().map(Vec::len), + Some(3), + "no limit shows everything" + ); let latest = apply_limit(Some(1), shaped.clone()); let rows = latest.as_array().expect("still a list"); assert_eq!(rows.len(), 1); - assert_eq!(rows[0]["tag"], "v0.1.17", "and it is the newest, which Gitea returns first"); + assert_eq!( + rows[0]["tag"], "v0.1.17", + "and it is the newest, which Gitea returns first" + ); let two = apply_limit(Some(2), shaped.clone()); assert_eq!(two.as_array().map(Vec::len), Some(2)); @@ -823,7 +945,11 @@ mod tests { assert_eq!(items[0]["name"], "varde bring-up"); assert_eq!(items[0]["tag"], "v0.3.0"); assert_eq!(items[0]["description"], "Embedded varde-core on iOS."); - assert_eq!(items[0]["url"], serde_json::Value::Null, "no public link while private"); + assert_eq!( + items[0]["url"], + serde_json::Value::Null, + "no public link while private" + ); assert!(items[0].get("html_url").is_none()); } @@ -845,8 +971,7 @@ mod tests { ("path".to_string(), "questions/a b.yaml".to_string()), ("ref".to_string(), "main".to_string()), ]); - let (url, leftover) = - template_url("https://x.example/api/contents/{path}", ¶ms); + let (url, leftover) = template_url("https://x.example/api/contents/{path}", ¶ms); // `/` survives, the space percent-encodes, and the param that // matched a placeholder is consumed. assert_eq!(url, "https://x.example/api/contents/questions/a%20b.yaml"); @@ -878,8 +1003,14 @@ mod finished_tests { .unwrap(); let rec = |id: &str, state: &str, decided: i64| serde_json::json!({ "id": id, "state": state, "submitted_ms": 0, "decided_ms": decided }); let mut items = vec![rec("o1", "open", 0), rec("o2", "open", 0)]; - items.extend((1..=6).map(|n| rec(&format!("f{n}"), if n % 2 == 0 { "won" } else { "lost" }, n))); - let ids = |v: &[serde_json::Value]| v.iter().map(|x| x["id"].as_str().unwrap().to_string()).collect::>(); + items.extend( + (1..=6).map(|n| rec(&format!("f{n}"), if n % 2 == 0 { "won" } else { "lost" }, n)), + ); + let ids = |v: &[serde_json::Value]| { + v.iter() + .map(|x| x["id"].as_str().unwrap().to_string()) + .collect::>() + }; let shown = cap_finished(items.clone(), &schema, 3); // Both open cases, then the three newest finished and one more diff --git a/src/seed.rs b/src/seed.rs index 3740773..2332650 100644 --- a/src/seed.rs +++ b/src/seed.rs @@ -58,10 +58,15 @@ pub const SEED_BUCKET: &str = "portal_seed"; pub fn most_privileged_group(questions: &HashMap) -> Option { let mut reach: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); for q in questions.values() { - let Some(group) = q.qualifies.as_deref() else { continue }; + let Some(group) = q.qualifies.as_deref() else { + continue; + }; for alt in &q.alternatives { if let Some(invite) = &alt.invite { - reach.entry(group).or_default().insert(invite.group.as_str()); + reach + .entry(group) + .or_default() + .insert(invite.group.as_str()); } } } @@ -73,7 +78,19 @@ pub fn most_privileged_group(questions: &HashMap) -> Option String { - let clean = |s: &str| s.trim().to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect::(); + let clean = |s: &str| { + s.trim() + .to_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect::() + }; format!("{}.{}", clean(group), clean(email)) } @@ -82,7 +99,9 @@ pub fn seed_key(email: &str, group: &str) -> String { /// questions they answer for, in page order. A page a group answers /// for (`responsible.group`) names nobody: the board's inbox is not a /// person, and its members have their accounts through the desk. -pub fn responsible_people(questions: &HashMap) -> BTreeMap)> { +pub fn responsible_people( + questions: &HashMap, +) -> BTreeMap)> { let mut ids: Vec<&String> = questions.keys().collect(); ids.sort(); let mut out: BTreeMap)> = BTreeMap::new(); @@ -94,7 +113,9 @@ pub fn responsible_people(questions: &HashMap) -> BTreeMap) -> BTreeMap Option { - let c = contact.trim().trim_start_matches("mailto:").trim().to_lowercase(); + let c = contact + .trim() + .trim_start_matches("mailto:") + .trim() + .to_lowercase(); (c.contains('@') && !c.contains(char::is_whitespace)).then_some(c) } @@ -126,7 +151,10 @@ static PASS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); pub async fn run(state: crate::server::AppState) { // Subscribed before the first pass, so content that changes while // it runs is not missed. - let reloads = state.nats.subscribe(crate::content::CONTENT_RELOADED_SUBJECT).await; + let reloads = state + .nats + .subscribe(crate::content::CONTENT_RELOADED_SUBJECT) + .await; tokio::join!(seed_admin(&state), responsible_until_done(&state), async { let mut reloads = match reloads { Ok(reloads) => reloads, @@ -158,7 +186,11 @@ async fn responsible_until_done(state: &crate::server::AppState) { tracing::error!(left, "some responsible people could not be set up, for good - until the content changes or portal starts again"); return; } - tracing::warn!(left, attempt, "some responsible people could not be set up, trying again in a minute"); + tracing::warn!( + left, + attempt, + "some responsible people could not be set up, trying again in a minute" + ); tokio::time::sleep(APART).await; } } @@ -166,7 +198,14 @@ async fn responsible_until_done(state: &crate::server::AppState) { async fn seed_bucket(state: &crate::server::AppState) -> Option { match state.jetstream.get_key_value(SEED_BUCKET).await { Ok(store) => Some(store), - Err(_) => match state.jetstream.create_key_value(async_nats::jetstream::kv::Config { bucket: SEED_BUCKET.to_string(), ..Default::default() }).await { + Err(_) => match state + .jetstream + .create_key_value(async_nats::jetstream::kv::Config { + bucket: SEED_BUCKET.to_string(), + ..Default::default() + }) + .await + { Ok(store) => Some(store), Err(e) => { tracing::error!("the seed bucket could not be made: {e} - making no accounts, so as not to make them twice"); @@ -177,7 +216,10 @@ async fn seed_bucket(state: &crate::server::AppState) -> Option Option { - std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@')) + std::env::var("SEED_ADMIN_EMAIL") + .ok() + .map(|e| e.trim().to_lowercase()) + .filter(|e| e.contains('@')) } /// Who portal put in the responsible group and the content no longer @@ -204,24 +246,37 @@ pub fn no_longer_responsible( /// it could not finish with. async fn responsible(state: &crate::server::AppState) -> usize { let people = responsible_people(&state.questions.load()); - let group = std::env::var("RESPONSIBLE_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty()); + let group = std::env::var("RESPONSIBLE_GROUP") + .ok() + .map(|g| g.trim().to_string()) + .filter(|g| !g.is_empty()); if people.is_empty() && group.is_none() { return 0; } let Some(people_backend) = crate::people::People::from_env() else { if !people.is_empty() { - tracing::info!(people = people.len(), "the content names responsible people, but {} - no accounts made", crate::people::People::NOT_CONFIGURED); + tracing::info!( + people = people.len(), + "the content names responsible people, but {} - no accounts made", + crate::people::People::NOT_CONFIGURED + ); } return 0; }; - let Some(store) = seed_bucket(state).await else { return 0 }; + let Some(store) = seed_bucket(state).await else { + return 0; + }; let mut left = 0; for (email, (name, questions)) in &people { let key = seed_key(email, group.as_deref().unwrap_or("responsible")); if let Ok(Some(_)) = store.get(&key).await { continue; } - let name = if name.is_empty() { email.split('@').next().unwrap_or("").to_string() } else { name.clone() }; + let name = if name.is_empty() { + email.split('@').next().unwrap_or("").to_string() + } else { + name.clone() + }; let who = crate::memberships::Identity::from_contact(email); // With a group, one call makes the account if there is none and // adds them either way; without one, only a missing account is @@ -229,21 +284,30 @@ async fn responsible(state: &crate::server::AppState) -> usize { // way a person is mailed only when an account was made for // them: someone who had one already has their way in. let made = match (&group, &people_backend) { - (Some(group), people_backend) => people_backend.invite(state, &name, &who, group, "responsible").await, - (None, crate::people::People::Kanidm(kanidm)) => match kanidm.has_person_with_mail(email).await { - Ok(true) => { - let _ = store.put(&key, "existing".into()).await; - continue; + (Some(group), people_backend) => { + people_backend + .invite(state, &name, &who, group, "responsible") + .await + } + (None, crate::people::People::Kanidm(kanidm)) => { + match kanidm.has_person_with_mail(email).await { + Ok(true) => { + let _ = store.put(&key, "existing".into()).await; + continue; + } + Ok(false) => kanidm.account(&name, email).await, + Err(e) => Err(e), } - Ok(false) => kanidm.account(&name, email).await, - Err(e) => Err(e), - }, + } (None, crate::people::People::Memberships) => continue, }; match made { Ok(invited) => { if let Ok(bytes) = serde_json::to_vec(&invited) { - let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await; + let _ = state + .nats + .publish(crate::people::INVITED_SUBJECT, bytes.clone().into()) + .await; let _ = store.put(&key, bytes.into()).await; } if invited.created { @@ -274,14 +338,21 @@ async fn responsible(state: &crate::server::AppState) -> usize { }; let mut on_file = Vec::new(); for key in keys.into_iter().filter(|k| k.starts_with(&prefix)) { - let Ok(Some(bytes)) = store.get(&key).await else { continue }; - let Ok(invited) = serde_json::from_slice::(&bytes) else { continue }; + let Ok(Some(bytes)) = store.get(&key).await else { + continue; + }; + let Ok(invited) = serde_json::from_slice::(&bytes) else { + continue; + }; on_file.push((key, invited)); } for (key, invited) in no_longer_responsible(&on_file, &people, seed_admin_email().as_deref()) { if !invited.already_member { let who = crate::memberships::Identity::from_contact(&invited.email); - if let Err(e) = people_backend.remove(state, &who, &invited.username, &group, "responsible").await { + if let Err(e) = people_backend + .remove(state, &who, &invited.username, &group, "responsible") + .await + { left += 1; tracing::warn!(user = %invited.username, group, "could not take someone no longer responsible out of the group: {e}"); continue; @@ -298,13 +369,24 @@ async fn responsible(state: &crate::server::AppState) -> usize { /// or `SEED_ADMIN_PHONE`, for a site whose provider knows people by /// their number. async fn seed_admin(state: &crate::server::AppState) { - let phone = std::env::var("SEED_ADMIN_PHONE").ok().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()); - let email = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@')); + let phone = std::env::var("SEED_ADMIN_PHONE") + .ok() + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()); + let email = std::env::var("SEED_ADMIN_EMAIL") + .ok() + .map(|e| e.trim().to_string()) + .filter(|e| e.contains('@')); let (Some(contact), who) = ( phone.clone().or_else(|| email.clone()), crate::memberships::Identity { sub: None, - phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))), + phone: phone.as_deref().and_then(|p| { + crate::memberships::normalize_phone( + p, + &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()), + ) + }), email: email.as_deref().map(|e| e.to_lowercase()), }, ) else { @@ -316,7 +398,11 @@ async fn seed_admin(state: &crate::server::AppState) { .map(|n| n.trim().to_string()) .filter(|n| !n.is_empty()) .unwrap_or_else(|| email.split('@').next().unwrap_or("admin").to_string()); - let group = match std::env::var("SEED_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty()) { + let group = match std::env::var("SEED_GROUP") + .ok() + .map(|g| g.trim().to_string()) + .filter(|g| !g.is_empty()) + { Some(g) => g, None => match most_privileged_group(&state.questions.load()) { Some(g) => g, @@ -327,13 +413,22 @@ async fn seed_admin(state: &crate::server::AppState) { }, }; let key = seed_key(&email, &group); - let Some(store) = seed_bucket(state).await else { return }; + let Some(store) = seed_bucket(state).await else { + return; + }; if let Ok(Some(_)) = store.get(&key).await { - tracing::info!(group, "the first person was already invited into this group; not again"); + tracing::info!( + group, + "the first person was already invited into this group; not again" + ); return; } let Some(people) = crate::people::People::from_env() else { - tracing::warn!(group, "SEED_ADMIN_EMAIL is set, but {} - nobody was seeded", crate::people::People::NOT_CONFIGURED); + tracing::warn!( + group, + "SEED_ADMIN_EMAIL is set, but {} - nobody was seeded", + crate::people::People::NOT_CONFIGURED + ); return; }; @@ -343,7 +438,11 @@ async fn seed_admin(state: &crate::server::AppState) { match people.invite(state, &name, &who, &group, "seed").await { Ok(invited) => break invited, Err(e) if attempt < ATTEMPTS => { - tracing::warn!(group, attempt, "seeding the first person failed, trying again in a minute: {e}"); + tracing::warn!( + group, + attempt, + "seeding the first person failed, trying again in a minute: {e}" + ); tokio::time::sleep(APART).await; } Err(e) => { @@ -353,7 +452,10 @@ async fn seed_admin(state: &crate::server::AppState) { } }; if let Ok(bytes) = serde_json::to_vec(&invited) { - let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await; + let _ = state + .nats + .publish(crate::people::INVITED_SUBJECT, bytes.clone().into()) + .await; if let Err(e) = store.put(&key, bytes.into()).await { tracing::error!(group, "the first person is invited, but it could not be remembered: {e} - a restart would invite them again"); } @@ -374,7 +476,17 @@ mod tests { .iter() .map(|g| format!(" - name: Invite into {g}\n invite: {{ group: {g} }}\n")) .collect(); - let yaml = format!("name: Q?\n{}alternatives:\n{}", qualifies.map(|q| format!("qualifies: {q}\n")).unwrap_or_default(), if alternatives.is_empty() { " - name: A\n".to_string() } else { alternatives }); + let yaml = format!( + "name: Q?\n{}alternatives:\n{}", + qualifies + .map(|q| format!("qualifies: {q}\n")) + .unwrap_or_default(), + if alternatives.is_empty() { + " - name: A\n".to_string() + } else { + alternatives + } + ); let mut q: Question = serde_yaml::from_str(&yaml).unwrap(); q.id = id.to_string(); (id.to_string(), q) @@ -384,7 +496,11 @@ mod tests { fn the_first_person_goes_where_the_most_invitations_can_be_made() { let qs: HashMap = [ page("/", None, &[]), - page("/styret", Some("styret"), &["styret", "kasserer", "trafikk", "komiteer"]), + page( + "/styret", + Some("styret"), + &["styret", "kasserer", "trafikk", "komiteer"], + ), page("/kasserer", Some("kasserer"), &["kasserer"]), page("/trafikk", Some("trafikk"), &["trafikk"]), ] @@ -392,7 +508,10 @@ mod tests { .collect(); assert_eq!(most_privileged_group(&qs).as_deref(), Some("styret")); // A tie goes to the name that sorts first, every time. - let tie: HashMap = [page("/b", Some("b"), &["x"]), page("/a", Some("a"), &["y"])].into_iter().collect(); + let tie: HashMap = + [page("/b", Some("b"), &["x"]), page("/a", Some("a"), &["y"])] + .into_iter() + .collect(); assert_eq!(most_privileged_group(&tie).as_deref(), Some("a")); // No invitations anywhere: nobody to seed into without SEED_GROUP. let none: HashMap = [page("/", None, &[])].into_iter().collect(); @@ -419,26 +538,54 @@ mod tests { #[test] fn whoever_the_content_stopped_naming_leaves_but_never_the_first_person() { - let people: BTreeMap)> = [("kari@vel.no".to_string(), ("Kari".to_string(), vec!["Hva?".to_string()]))].into_iter().collect(); - let filed = vec![on_file("kari@vel.no", false), on_file("Ola@vel.no", false), on_file("leder@vel.no", false)]; + let people: BTreeMap)> = [( + "kari@vel.no".to_string(), + ("Kari".to_string(), vec!["Hva?".to_string()]), + )] + .into_iter() + .collect(); + let filed = vec![ + on_file("kari@vel.no", false), + on_file("Ola@vel.no", false), + on_file("leder@vel.no", false), + ]; let gone = no_longer_responsible(&filed, &people, Some("leder@vel.no")); - assert_eq!(gone.iter().map(|(_, i)| i.username.as_str()).collect::>(), vec!["Ola"]); + assert_eq!( + gone.iter() + .map(|(_, i)| i.username.as_str()) + .collect::>(), + vec!["Ola"] + ); assert_eq!(gone[0].0, "redaktor.ola_vel_no"); // Nobody named at all: everyone portal put there leaves. - assert_eq!(no_longer_responsible(&filed, &BTreeMap::new(), None).len(), 3); + assert_eq!( + no_longer_responsible(&filed, &BTreeMap::new(), None).len(), + 3 + ); } #[test] fn everyone_named_responsible_is_found_once_by_their_address() { let mut qs: HashMap = HashMap::new(); - for (id, name, contact) in [("/", "Hva gjør vi?", "mailto:Styret@Vel.no"), ("/melde-fra", "Hva vil du melde?", "styret@vel.no"), ("/b", "Ringe?", "+47 900 00 000"), ("/c", "Trafikk?", "trafikk@vel.no")] { + for (id, name, contact) in [ + ("/", "Hva gjør vi?", "mailto:Styret@Vel.no"), + ("/melde-fra", "Hva vil du melde?", "styret@vel.no"), + ("/b", "Ringe?", "+47 900 00 000"), + ("/c", "Trafikk?", "trafikk@vel.no"), + ] { let mut q: Question = serde_yaml::from_str(&format!("name: \"{name}\"\nresponsible: {{ name: Styret, contact: \"{contact}\" }}\nalternatives:\n - name: A\n")).unwrap(); q.id = id.to_string(); qs.insert(id.to_string(), q); } let people = responsible_people(&qs); - assert_eq!(people.keys().collect::>(), ["styret@vel.no", "trafikk@vel.no"]); - assert_eq!(people["styret@vel.no"].1, ["Hva gjør vi?", "Hva vil du melde?"]); + assert_eq!( + people.keys().collect::>(), + ["styret@vel.no", "trafikk@vel.no"] + ); + assert_eq!( + people["styret@vel.no"].1, + ["Hva gjør vi?", "Hva vil du melde?"] + ); assert_eq!(contact_email("+47 900 00 000"), None); // A group's inbox is nobody: its members have the desk. let mut held: Question = serde_yaml::from_str("name: \"Hva?\"\nresponsible: { name: Styret, contact: post@vel.no, group: styret }\nalternatives:\n - name: A\n").unwrap(); @@ -449,7 +596,13 @@ mod tests { #[test] fn a_seed_is_remembered_by_address_and_group() { - assert_eq!(seed_key(" Kari.Lien@Tomtervel.no ", "styret"), "styret.kari_lien_tomtervel_no"); - assert_ne!(seed_key("kari@x.no", "styret"), seed_key("kari@x.no", "kasserer")); + assert_eq!( + seed_key(" Kari.Lien@Tomtervel.no ", "styret"), + "styret.kari_lien_tomtervel_no" + ); + assert_ne!( + seed_key("kari@x.no", "styret"), + seed_key("kari@x.no", "kasserer") + ); } } diff --git a/src/server/mod.rs b/src/server/mod.rs index aac70f8..6b99b9b 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -28,7 +28,9 @@ pub struct AppState { /// hot-swapped alongside `questions` on the same reload. A bucket /// with no entry here isn't event-sourced - plain KV mutate-in-place /// still works (see `answers.rs`). - pub aggregates: Arc>>>, + pub aggregates: Arc< + ArcSwap>>, + >, /// Site branding from the content repo's optional `site.yaml` /// (title, wordmark, hero kind) - hot-swapped with `questions`/ /// `aggregates` on the same reload. Default = the historical uhhm diff --git a/src/server/oidc.rs b/src/server/oidc.rs index 9b783e2..74e0f65 100644 --- a/src/server/oidc.rs +++ b/src/server/oidc.rs @@ -13,8 +13,8 @@ use axum::{ use openidconnect::{ core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims}, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet, - EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, - OAuth2TokenResponse, TokenResponse, + EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, + RedirectUrl, Scope, TokenResponse, }; use serde::Deserialize; use tower_sessions::Session; @@ -148,7 +148,10 @@ pub async fn login( Query(params): Query, ) -> Result { if let Some(redirect) = params.redirect.filter(|r| is_safe_redirect(r)) { - session.insert(REDIRECT_KEY, redirect).await.map_err(internal)?; + session + .insert(REDIRECT_KEY, redirect) + .await + .map_err(internal)?; } let oidc = state.oidc.configured()?; @@ -168,7 +171,10 @@ pub async fn login( // What else to ask the provider for: `phoneNumber` for one that // verifies numbers (Vipps). Only when asked, because Kanidm denies // a sign-in that requests a scope the person does not hold. - for scope in std::env::var("OIDC_EXTRA_SCOPES").unwrap_or_default().split_whitespace() { + for scope in std::env::var("OIDC_EXTRA_SCOPES") + .unwrap_or_default() + .split_whitespace() + { request = request.add_scope(Scope::new(scope.to_string())); } let (auth_url, csrf_state, nonce) = request.url(); @@ -206,8 +212,7 @@ pub async fn callback( let pkce_verifier: Option = session.remove(PKCE_KEY).await.map_err(internal)?; let nonce: Option = session.remove(NONCE_KEY).await.map_err(internal)?; - let (Some(stored_csrf), Some(pkce_verifier), Some(nonce)) = - (stored_csrf, pkce_verifier, nonce) + let (Some(stored_csrf), Some(pkce_verifier), Some(nonce)) = (stored_csrf, pkce_verifier, nonce) else { return Err(( StatusCode::BAD_REQUEST, @@ -262,12 +267,19 @@ pub async fn callback( // The verified phone number lives at userinfo, not in the ID token, // for providers that give one (Vipps). A provider that has none, or // no userinfo endpoint, leaves it empty; nothing depends on it. - let phone = match oidc.client.user_info(token_response.access_token().clone(), None) { - Ok(request) => request - .request_async(&oidc.http) - .await - .ok() - .and_then(|info: CoreUserInfoClaims| info.phone_number().map(|p| p.as_str().to_string())), + let phone = match oidc + .client + .user_info(token_response.access_token().clone(), None) + { + Ok(request) => { + request + .request_async(&oidc.http) + .await + .ok() + .and_then(|info: CoreUserInfoClaims| { + info.phone_number().map(|p| p.as_str().to_string()) + }) + } Err(_) => None, }; let mut user = User { @@ -295,7 +307,9 @@ pub async fn callback( .map_err(internal)?; let redirect: Option = session.remove(REDIRECT_KEY).await.map_err(internal)?; - let target = redirect.filter(|r| is_safe_redirect(r)).unwrap_or_else(|| "/".to_string()); + let target = redirect + .filter(|r| is_safe_redirect(r)) + .unwrap_or_else(|| "/".to_string()); // Being here is what an invite was for (people.rs): off the // request, since nobody signing in should wait on a desk's list. @@ -338,6 +352,10 @@ fn extract_groups_claim(id_token: &T) -> Vec { payload .get("groups") .and_then(|g| g.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) .unwrap_or_default() } diff --git a/src/server/storyline.rs b/src/server/storyline.rs index a1235bb..e9c9e90 100644 --- a/src/server/storyline.rs +++ b/src/server/storyline.rs @@ -21,7 +21,10 @@ fn from_cookies(header: Option<&str>, arity: usize) -> Option { header? .split(';') .map(str::trim) - .find_map(|kv| kv.strip_prefix(COOKIE_NAME).and_then(|rest| rest.strip_prefix('='))) + .find_map(|kv| { + kv.strip_prefix(COOKIE_NAME) + .and_then(|rest| rest.strip_prefix('=')) + }) .and_then(|v| v.trim().parse::().ok()) .filter(|n| *n < arity) } @@ -31,14 +34,23 @@ fn pick(arity: usize) -> usize { (uuid::Uuid::new_v4().as_u128() % arity.max(1) as u128) as usize } -pub async fn assign(State(state): State, mut req: Request, next: Next) -> Response { +pub async fn assign( + State(state): State, + mut req: Request, + next: Next, +) -> Response { let arity = state.questions.load().arity(); - let held = from_cookies(req.headers().get(COOKIE).and_then(|v| v.to_str().ok()), arity); + let held = from_cookies( + req.headers().get(COOKIE).and_then(|v| v.to_str().ok()), + arity, + ); let n = held.unwrap_or_else(|| pick(arity)); req.extensions_mut().insert(Storyline(n)); let mut res = next.run(req).await; if held.is_none() && arity > 1 { - if let Ok(value) = format!("{COOKIE_NAME}={n}; Path=/; Max-Age=31536000; SameSite=Lax").parse() { + if let Ok(value) = + format!("{COOKIE_NAME}={n}; Path=/; Max-Age=31536000; SameSite=Lax").parse() + { res.headers_mut().append(SET_COOKIE, value); } } @@ -60,7 +72,10 @@ mod tests { #[test] fn a_cookie_names_a_storyline_the_site_tells() { - assert_eq!(from_cookies(Some("a=b; portal_storyline=1; c=d"), 3), Some(1)); + assert_eq!( + from_cookies(Some("a=b; portal_storyline=1; c=d"), 3), + Some(1) + ); assert_eq!(from_cookies(Some("portal_storyline=7"), 3), None); assert_eq!(from_cookies(Some("portal_storylines=1"), 3), None); assert_eq!(from_cookies(None, 3), None); diff --git a/src/sessions.rs b/src/sessions.rs index 86493be..a462446 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -37,7 +37,9 @@ pub struct NatsStore { impl std::fmt::Debug for NatsStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("NatsStore").field("bucket", &BUCKET).finish() + f.debug_struct("NatsStore") + .field("bucket", &BUCKET) + .finish() } } @@ -73,7 +75,11 @@ impl tower_sessions::SessionStore for NatsStore { async fn create(&self, record: &mut Record) -> Result<()> { for _ in 0..ID_ATTEMPTS { let bytes = Self::encode(record)?; - match self.store.create(&record.id.to_string(), bytes.into()).await { + match self + .store + .create(&record.id.to_string(), bytes.into()) + .await + { Ok(_) => return Ok(()), Err(_) => record.id = Id::default(), } diff --git a/src/trainer/build.rs b/src/trainer/build.rs index 8b6f2ab..99ecc48 100644 --- a/src/trainer/build.rs +++ b/src/trainer/build.rs @@ -149,7 +149,9 @@ fn english() -> String { /// `moves_by: { checking: submitter }` is what a model writes for a /// single mover, and means the same as `[submitter]`; a bare word or /// a list are both read. -fn one_or_many_by_key<'de, D: serde::Deserializer<'de>>(d: D) -> Result>, D::Error> { +fn one_or_many_by_key<'de, D: serde::Deserializer<'de>>( + d: D, +) -> Result>, D::Error> { #[derive(Deserialize)] #[serde(untagged)] enum OneOrMany { @@ -159,7 +161,15 @@ fn one_or_many_by_key<'de, D: serde::Deserializer<'de>>(d: D) -> Result = BTreeMap::deserialize(d)?; Ok(raw .into_iter() - .map(|(k, v)| (k, match v { OneOrMany::One(s) => vec![s], OneOrMany::Many(l) => l })) + .map(|(k, v)| { + ( + k, + match v { + OneOrMany::One(s) => vec![s], + OneOrMany::Many(l) => l, + }, + ) + }) .collect()) } @@ -213,17 +223,22 @@ pub fn sections_of(markdown: &str) -> Vec
    { let mut heading: Option = None; let mut paragraphs: Vec = Vec::new(); let mut current: Vec<&str> = Vec::new(); - let close = |heading: &mut Option, paragraphs: &mut Vec, out: &mut Vec
    | { - if let Some(h) = heading.take() { - if !paragraphs.is_empty() { - out.push(Section { heading: h, text: std::mem::take(paragraphs) }); + let close = + |heading: &mut Option, paragraphs: &mut Vec, out: &mut Vec
    | { + if let Some(h) = heading.take() { + if !paragraphs.is_empty() { + out.push(Section { + heading: h, + text: std::mem::take(paragraphs), + }); + } } - } - paragraphs.clear(); - }; + 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(' '); + 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(" ")); @@ -232,7 +247,13 @@ pub fn sections_of(markdown: &str) -> Vec
    { } if is_heading { close(&mut heading, &mut paragraphs, &mut out); - heading = Some(trimmed.trim_start_matches('#').trim().trim_end_matches('.').to_string()); + heading = Some( + trimmed + .trim_start_matches('#') + .trim() + .trim_end_matches('.') + .to_string(), + ); } else if !trimmed.is_empty() { current.push(trimmed); } @@ -248,7 +269,9 @@ impl Plan { 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()))?; + let raw = std::fs::read_to_string(&path).with_context(|| { + format!("document {:?}: reading {}", doc.id, path.display()) + })?; doc.sections = sections_of(&raw); } } @@ -523,16 +546,23 @@ impl Plan { } } -const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file", "picture"]; +const FIELD_TYPES: &[&str] = &[ + "text", "email", "tel", "textarea", "date", "select", "location", "file", "picture", +]; fn is_id(s: &str) -> bool { - !s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + !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); + 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"); @@ -541,63 +571,105 @@ impl Plan { // 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"); + 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) { + 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); + 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); + 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); + 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); + 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); + 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)) { + 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); + 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()); + 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); + 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); + 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())) { + 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"); } @@ -608,13 +680,21 @@ impl Plan { 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) { + 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 _"))); + 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")); + bail!(at( + "stages needs a first stage and at least one finished one" + )); } if let Some(door) = &need.door { if !doors.contains(door) { @@ -625,7 +705,9 @@ impl Plan { } 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"))); + 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")); @@ -636,7 +718,12 @@ impl Plan { 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) { + 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") { @@ -644,13 +731,20 @@ impl Plan { } 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"))); + 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`"))); + 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) + && !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")); } @@ -707,10 +801,16 @@ impl Plan { } for (state, to) in &need.mails { if !need.stages.contains(state) { - bail!(at(&format!("mails names {state:?}, which is not one of its stages"))); + 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) { + 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" { @@ -728,14 +828,24 @@ impl Plan { 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"))); + 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:?}"))); + 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); + 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")) { + 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") { @@ -746,19 +856,30 @@ impl Plan { if !is_id(group) || group == PUBLIC || group == SUBMITTER { bail!(at(&format!("followed_by must name groups, not {group:?}"))); } - if !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional) { + if !need + .collects + .iter() + .any(|f| f.name == "email" && f.kind == "email" && !f.optional) + { bail!(at("followed_by shows a person the cases that carry their address, so the form must collect a required `email` of type email")); } } for (stage, e) in &need.escalate { if e.days == 0 { - bail!(at(&format!("escalate from {stage:?} must wait at least one day"))); + 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"))); + 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))); + 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"))); @@ -766,14 +887,25 @@ impl Plan { } for (state, d) in &need.deadlines { if d.days == 0 { - bail!(at(&format!("deadline on {state:?} must wait at least one day"))); + 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)) { + 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 let Some(bad) = need.leaves_after.keys().find(|st| !need.stages.contains(st)) { - bail!(at(&format!("leaves_after names {bad:?}, which is not one of its stages"))); + if let Some(bad) = need + .leaves_after + .keys() + .find(|st| !need.stages.contains(st)) + { + bail!(at(&format!( + "leaves_after names {bad:?}, which is not one of its stages" + ))); } if need.leaves_after.values().any(|l| l.millis() == 0) { bail!(at("leaves_after needs some days, hours or minutes")); @@ -789,12 +921,16 @@ impl Plan { 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"))); + bail!(at(&format!( + "public_states names {bad:?}, which is not one of its stages" + ))); } } for (state, text) in &need.stages_text { if !all_stages(need).contains(state) { - bail!(at(&format!("stages_text names {state:?}, which is not one of its stages"))); + 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"))); @@ -802,10 +938,14 @@ impl Plan { } for (state, event) in &need.events { if !need.stages.contains(state) { - bail!(at(&format!("events names {state:?}, which is not one of its stages"))); + 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 _"))); + bail!(at(&format!( + "event {event:?} for {state:?} must be ASCII lowercase with - or _" + ))); } } for state in &need.success { @@ -814,7 +954,9 @@ impl Plan { } } 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")); + 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 { @@ -825,7 +967,10 @@ impl Plan { } 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))); + 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")) => { @@ -833,16 +978,28 @@ impl Plan { 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))); + 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))); + 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))), + (_, 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))) + bail!(at(&format!( + "field {:?}: multiple is for select fields", + field.name + ))) } _ => {} } @@ -909,11 +1066,17 @@ impl Plan { /// 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() + 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() + self.needs + .iter() + .filter(|n| n.door.as_deref() == Some(door)) + .collect() } fn by_bucket(&self) -> BTreeMap<&str, Vec<&PlanNeed>> { @@ -939,9 +1102,21 @@ impl Plan { /// 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> { - 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 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(); @@ -986,7 +1161,9 @@ pub fn machine(need: &PlanNeed) -> BTreeMap> { // 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 }; + let Some(tos) = moves.get(stage).cloned() else { + continue; + }; moves.entry(stage.clone()).or_default().push(up.clone()); moves.insert(up, tos); } @@ -1007,7 +1184,10 @@ pub fn all_stages(need: &PlanNeed) -> Vec { /// 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()) + 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 @@ -1018,7 +1198,12 @@ pub fn event_for(need: &PlanNeed, stage: &str) -> String { return event.clone(); } if stage == need.stages[0] { - return if need.stages.iter().any(|st| st == "received") { "submitted" } else { "received" }.to_string(); + return if need.stages.iter().any(|st| st == "received") { + "submitted" + } else { + "received" + } + .to_string(); } stage.to_string() } @@ -1037,14 +1222,23 @@ pub fn stage_text(need: &PlanNeed, stage: &str) -> String { } fn stages_sentence(need: &PlanNeed) -> String { - let line: Vec = need.stages.iter().filter(|s| !need.done_when.contains(s)).map(|s| stage_text(need, s)).collect(); + let line: Vec = need + .stages + .iter() + .filter(|s| !need.done_when.contains(s)) + .map(|s| stage_text(need, s)) + .collect(); let ends: Vec = need.done_when.iter().map(|s| stage_text(need, s)).collect(); let mut out = String::from("It is "); out.push_str(&line.join(", then ")); if ends.len() == 1 { out.push_str(&format!(", and ends {}.", ends[0])); } else { - out.push_str(&format!(", and ends {} or {}.", ends[..ends.len() - 1].join(", "), ends[ends.len() - 1])); + out.push_str(&format!( + ", and ends {} or {}.", + ends[..ends.len() - 1].join(", "), + ends[ends.len() - 1] + )); } out } @@ -1099,7 +1293,12 @@ fn internal_page(need: &PlanNeed, group: &str) -> String { /// 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() + 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 { @@ -1162,13 +1361,27 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { .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": "" })) + ( + d.id.clone(), + json!({ "heading": heading, "description": d.text, "icon": "" }), + ) }) .collect() }; - let mut shopping: Map = - plan.shows.iter().map(|s| (s.id.clone(), json!({ "heading": humanize(&s.id), "description": s.text, "icon": "" }))).collect(); - for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| !n.iter().any(|n| n.presents)) { + let mut shopping: Map = plan + .shows + .iter() + .map(|s| { + ( + s.id.clone(), + json!({ "heading": humanize(&s.id), "description": s.text, "icon": "" }), + ) + }) + .collect(); + for (bucket, needs) in public_buckets(plan) + .into_iter() + .filter(|(_, n)| !n.iter().any(|n| n.presents)) + { shopping.insert( format!("status-{bucket}"), json!({ @@ -1234,7 +1447,9 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { // 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 }; + let Some(first) = answers.first() else { + continue; + }; words.insert( answer_page_path(&parent.lands_in), json!({ @@ -1387,7 +1602,10 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { // 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) })); + words.insert( + "site.yaml".to_string(), + json!({ "invite": invite_fallback(&plan.language) }), + ); } // The mails: one subject and body per stage that sends one, keyed @@ -1414,17 +1632,35 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { 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) + 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) + 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)); + 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)); + 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)); @@ -1458,7 +1694,9 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { /// 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()) + !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 @@ -1482,13 +1720,19 @@ fn invite_fallback(lang: &str) -> Value { /// 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(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::>().join("\n"); + let link_line = s(&fallback["body"]) + .lines() + .filter(|l| l.contains("{link}") || l.contains("{expires}")) + .collect::>() + .join("\n"); *body = Value::String(format!("{}\n\n{link_line}", text.trim_end())); } @@ -1558,7 +1802,9 @@ 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)) + .filter(|(from, to)| { + movers(need, to).contains(&who) || escalated_to(need, from) == Some(who) + }) .collect() } @@ -1572,13 +1818,21 @@ fn escalated_states_for(need: &PlanNeed, group: &str) -> Vec { if own { return Vec::new(); } - need.escalate.iter().filter(|(_, e)| e.to == group).map(|(stage, _)| escalated_stage(stage)).collect() + 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) || need.followed_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty()) + group != PUBLIC + && (need.handled_by == group + || need.watched_by.iter().any(|g| g == group) + || need.followed_by.iter().any(|g| g == group) + || !moves_of(need, group).is_empty()) } /// A group that has the bucket only to follow its own cases: its list @@ -1598,7 +1852,13 @@ fn groups(plan: &Plan) -> BTreeSet { .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.moves_by + .values() + .flatten() + .filter(|m| *m != SUBMITTER) + .cloned(), + ) .chain(n.escalate.values().map(|e| e.to.clone())) .chain(n.followed_by.iter().cloned()) }) @@ -1612,13 +1872,25 @@ fn inviters(plan: &Plan, group: &str) -> BTreeSet { 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() + 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 @@ -1629,25 +1901,49 @@ fn suggestions_desk(plan: &Plan) -> Option { let desks = groups(plan); desks .iter() - .map(|desk| (desks.iter().filter(|g| inviters(plan, g).contains(desk)).count(), desk)) + .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)) + 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() + 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 = need.public_states.iter().map(|st| stage_text(need, st)).collect(); + let words: Vec = need + .public_states + .iter() + .map(|st| stage_text(need, st)) + .collect(); match words.len() { 0 => String::new(), 1 => words[0].clone(), @@ -1662,11 +1958,21 @@ const FRONT_DOOR_LATEST: usize = 3; /// 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, answer: &str) -> Value { +fn public_resource( + bucket: &str, + needs: &[&PlanNeed], + empty: &str, + limit: Option, + answer: &str, +) -> Value { // In the order the form asks for them: the first is what a row, or // a card, is headed by. let mut shown: Vec<&str> = Vec::new(); - for f in needs.iter().flat_map(|n| n.collects.iter()).filter(|f| f.is_public()) { + for f in needs + .iter() + .flat_map(|n| n.collects.iter()) + .filter(|f| f.is_public()) + { if !shown.contains(&f.name.as_str()) { shown.push(f.name.as_str()); } @@ -1677,7 +1983,11 @@ fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option // (measured: an empty bucket rendered as `[[]]`). let jq = format!( "sort_by(.submitted_ms) | reverse{take} | .[] | .responses |= {{{}}}", - shown.iter().map(|f| format!("{f}: .{f}")).collect::>().join(", ") + shown + .iter() + .map(|f| format!("{f}: .{f}")) + .collect::>() + .join(", ") ); let mut resource = json!({ "source": { "kind": "kv", "bucket": bucket }, @@ -1689,7 +1999,8 @@ fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option // 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["link"] = + json!({ "to": answer_page(bucket).replace("[key]", "{id}"), "label": answer.trim() }); } resource } @@ -1697,8 +2008,22 @@ fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option /// 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::>().join(" or ")), - only => format!("What {} sent in that is now {only}.", needs.iter().map(|n| n.who.as_str()).collect::>().join(" or ")), + "" => format!( + "Everything sent in by {}, and where each one stands.", + needs + .iter() + .map(|n| n.who.as_str()) + .collect::>() + .join(" or ") + ), + only => format!( + "What {} sent in that is now {only}.", + needs + .iter() + .map(|n| n.who.as_str()) + .collect::>() + .join(" or ") + ), } } @@ -1719,16 +2044,34 @@ fn select_options(plan: &Plan, field: &Field) -> Vec { /// 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::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::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()) @@ -1745,11 +2088,24 @@ pub fn fold_tones(told: &[Value]) -> Value { 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::>()))) + .map(|k| { + ( + k.clone(), + fold_tones( + &told + .iter() + .map(|t| t.get(k).cloned().unwrap_or(Value::Null)) + .collect::>(), + ), + ) + }) .collect(), ), Some(Value::String(_)) => { - let each: Vec<&str> = told.iter().map(|t| t.as_str().unwrap_or_default()).collect(); + 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 { @@ -1770,8 +2126,12 @@ const SLOT: &str = " Option<&'static str> { - const NORWEGIAN: &[&str] = &["og", "er", "du", "det", "ikke", "til", "som", "med", "på", "har", "vi", "deg", "en", "et"]; - const ENGLISH: &[&str] = &["the", "and", "you", "is", "are", "we", "to", "of", "your", "it", "in", "with", "for", "a"]; + 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) => { @@ -1785,13 +2145,24 @@ fn wrong_language(lang: &str, reply: &Value) -> Option<&'static str> { } let mut text = String::new(); collect(reply, &mut text); - let words: Vec = text.split(|c: char| !c.is_alphabetic()).filter(|w| !w.is_empty()).map(|w| w.to_lowercase()).collect(); + let words: Vec = text + .split(|c: char| !c.is_alphabetic()) + .filter(|w| !w.is_empty()) + .map(|w| w.to_lowercase()) + .collect(); if words.len() < 8 { return None; } - let no = words.iter().filter(|w| NORWEGIAN.contains(&w.as_str())).count(); - let en = words.iter().filter(|w| ENGLISH.contains(&w.as_str())).count(); - let plan_is_norwegian = lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn"); + let 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"), @@ -1807,19 +2178,36 @@ fn wrong_language(lang: &str, reply: &Value) -> Option<&'static str> { /// 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 }; + 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 }; + 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 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 = text .lines() - .map(|l| if l.contains("/decide/") { l.to_string() } else { l.replace("{chain}", "").split_whitespace().collect::>().join(" ") }) + .map(|l| { + if l.contains("/decide/") { + l.to_string() + } else { + l.replace("{chain}", "") + .split_whitespace() + .collect::>() + .join(" ") + } + }) .collect(); let mut cleaned = cleaned.join("\n").trim_end().to_string(); if let Some(link) = link { @@ -1883,13 +2271,75 @@ fn slots_in(value: &Value, key: &str, parent: &str) -> Value { /// 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", + "house", + "building-2", + "store", + "school", + "map-pin", + "route", + "footprints", + "trees", + "leaf", + "snowflake", + "sun", + "users", + "user", + "user-plus", + "user-check", + "baby", + "heart-handshake", + "hand-helping", + "handshake", + "smile", + "mail", + "phone", + "message-circle", + "megaphone", + "bell", + "send", + "inbox", + "alert-triangle", + "siren", + "shield", + "construction", + "hammer", + "wrench", + "ruler", + "package", + "truck", + "car", + "bus", + "train-front", + "bike", + "calendar", + "clock", + "file-text", + "clipboard-list", + "receipt", + "wallet", + "coins", + "banknote", + "credit-card", + "key", + "lock", + "search", + "star", + "gift", + "shopping-cart", + "lightbulb", + "flag", + "thumbs-up", + "check-circle", + "briefcase", + "stethoscope", + "activity", + "camera", + "pencil", + "refresh-cw", + "list", + "layers", + "vote", + "scale", ]; /// `lucide:` for a name on the list, otherwise nothing. @@ -1917,7 +2367,12 @@ fn s(v: &Value) -> &str { /// (`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()), + 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()), } } @@ -1925,7 +2380,12 @@ fn text(v: &Value) -> Value { /// 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()), + 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())), } } @@ -1933,34 +2393,56 @@ fn text_map(v: &Value, f: impl Fn(&str) -> String) -> Value { /// 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()), + 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 = need.collects.iter().map(|f| { - let mut r = json!({ "name": f.name, "label": text(&w["labels"][&f.name]), "type": f.kind }); - // A picture is a file the browser can show: a phone offers its - // camera roll and sends a JPEG, whatever it keeps them as. - if f.kind == "picture" { - r["type"] = json!("file"); - r["accept"] = json!("image/jpeg,image/png,image/webp"); - } - 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 any - // form: portal carries their name and address and the fields take - // no room (`content::fill_from_user`). A visitor sees the fields - // as usual, so a public form loses nothing by it. - 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(); + let mut requirements: Vec = need + .collects + .iter() + .map(|f| { + let mut r = + json!({ "name": f.name, "label": text(&w["labels"][&f.name]), "type": f.kind }); + // A picture is a file the browser can show: a phone offers its + // camera roll and sends a JPEG, whatever it keeps them as. + if f.kind == "picture" { + r["type"] = json!("file"); + r["accept"] = json!("image/jpeg,image/png,image/webp"); + } + 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 any + // form: portal carries their name and address and the fields take + // no room (`content::fill_from_user`). A visitor sees the fields + // as usual, so a public form loses nothing by it. + 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 { @@ -1980,7 +2462,9 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va // 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); } + if let Some(icon) = icon_name(icon) { + f["icon"] = json!(icon); + } f }; let mut features = Vec::new(); @@ -1993,13 +2477,25 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va // 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"]))); + 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"]))); + 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(strip( + &w["who_title"], + &w["who_description"], + s(&w["who_icon"]), + )); } // A paused need asks for nothing: a form nobody can send has // already wasted the time it took to fill in. @@ -2007,7 +2503,11 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va features.push(feature); } let description = if need.paused { - text_map(&w["description"], |d| format!("{} {}", d.trim(), s(&w["paused"]).trim()).trim().to_string()) + text_map(&w["description"], |d| { + format!("{} {}", d.trim(), s(&w["paused"]).trim()) + .trim() + .to_string() + }) } else { text(&w["description"]) }; @@ -2034,7 +2534,10 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va } /// Every file of the content repo, from the plan and a set of words. -pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result> { +pub fn render( + plan: &Plan, + words: &BTreeMap, +) -> anyhow::Result> { let mut files: BTreeMap = BTreeMap::new(); let mut responsible = json!({ "name": plan.contact.name, "contact": plan.contact.email }); if let Some(group) = &plan.contact.group { @@ -2047,8 +2550,13 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) -> anyhow::Result = front["opener"]["features"].as_object().into_iter().flatten().map(|(_, w)| card(w)).collect(); + let does: Vec = front["opener"]["features"] + .as_object() + .into_iter() + .flatten() + .map(|(_, w)| card(w)) + .collect(); let mut alternatives: Vec = vec![json!({ "name": text(&front["opener"]["heading"]), "description": text(&front["opener"]["description"]), @@ -2137,12 +2650,28 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result 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"])); + 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() { @@ -2152,29 +2681,28 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result Vec<&String> { - theirs.iter().filter(|(_, t)| t == to).map(|(from, _)| from).collect() + theirs + .iter() + .filter(|(_, t)| t == to) + .map(|(from, _)| from) + .collect() }; files.insert( path, @@ -2523,7 +3068,10 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) -> Value { } fn presenting_buckets(plan: &Plan) -> Vec<&str> { - public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)).map(|(b, _)| b).collect() + public_buckets(plan) + .into_iter() + .filter(|(_, n)| n.iter().any(|n| n.presents)) + .map(|(b, _)| b) + .collect() } fn has_who_page(plan: &Plan) -> bool { @@ -2593,7 +3148,12 @@ pub fn untried_needs(plan: &Plan) -> Vec<&str> { plan.needs .iter() .filter(|n| !n.paused) - .filter(|n| !plan.personas.iter().any(|p| p.get("need").and_then(|v| v.as_str()) == Some(n.id.as_str()))) + .filter(|n| { + !plan + .personas + .iter() + .any(|p| p.get("need").and_then(|v| v.as_str()) == Some(n.id.as_str())) + }) .map(|n| n.id.as_str()) .collect() } @@ -2616,8 +3176,16 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) - // A desk is not always a place of work: a group that only follows // its own cases reads a list of what it sent in, and the words // should not hand it a job it does not have. - let desk_group = path.strip_prefix("questions/").and_then(|p| p.split('/').next()).unwrap_or_default(); - let own: Vec<&str> = plan.needs.iter().filter(|n| follows_own(n, desk_group)).map(|n| n.lands_in.as_str()).collect(); + let desk_group = path + .strip_prefix("questions/") + .and_then(|p| p.split('/').next()) + .unwrap_or_default(); + let own: Vec<&str> = plan + .needs + .iter() + .filter(|n| follows_own(n, desk_group)) + .map(|n| n.lands_in.as_str()) + .collect(); let mut desk_purpose = DESK_PURPOSE.to_string(); if !own.is_empty() { desk_purpose.push_str(&format!(" On this page, the list(s) {} show the signed-in person ONLY the cases they sent in themselves: they are following their own case, not working through other people's, so head those lists as theirs ('Your cases') and say nothing of a queue.", own.iter().map(|b| format!("`{b}`")).collect::>().join(", "))); @@ -2627,7 +3195,11 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) - // goes - a heading like "Your card on the front page" names a // place, and the colleague reading it does not know what they are // being asked. - let own_form = plan.needs.iter().find(|n| n.enters_as.as_deref().is_some_and(|g| path == format!("questions/{g}/{}.yaml", n.id))); + let own_form = plan.needs.iter().find(|n| { + n.enters_as + .as_deref() + .is_some_and(|g| path == format!("questions/{g}/{}.yaml", n.id)) + }); let mut inside_purpose = "a page only one role inside the business can open, holding one form. `question` is a real question, the one the form answers, asked of the colleague in their own terms (\"What has been delivered?\"), never a label or the name of a place; where their answer ends up - who sees it, on which page - belongs in `page_description`.".to_string(); if own_form.is_some_and(|n| n.one_per_person) { inside_purpose.push_str(" This form is the colleague's own record, one each: it opens filled in with what they have, and saving replaces it, so ask about them (\"How do you present yourself?\"), say where it shows, and make the button save."); @@ -2666,7 +3238,6 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) - "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" { mails_purpose.as_str() - } 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" { @@ -2674,7 +3245,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) - } 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." + "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") { desk_purpose.as_str() } else if path.ends_with("/[key].yaml") { @@ -2687,7 +3258,18 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) - let mut context: Vec = plan .doors .iter() - .map(|d| format!("- door {}: for {}; behind it: {}", d.id, d.who, plan.needs_behind(&d.id).iter().map(|n| n.id.as_str()).collect::>().join(", "))) + .map(|d| { + format!( + "- door {}: for {}; behind it: {}", + d.id, + d.who, + plan.needs_behind(&d.id) + .iter() + .map(|n| n.id.as_str()) + .collect::>() + .join(", ") + ) + }) .collect(); context.extend( plan.needs @@ -2708,13 +3290,24 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) - } 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 value = |flag: &str| { + argv.iter() + .position(|a| a == flag) + .and_then(|i| argv.get(i + 1)) + .cloned() + }; let usage = "usage: portal-trainer build --plan --out [--words-from ] [--words ]... [--llm 'cmd'] [--lint question_lint]"; let plan_path = value("--plan").ok_or_else(|| anyhow!(usage))?; let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?); - let 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(".")))?; + 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}"); @@ -2731,10 +3324,17 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { // 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(); + let words_files: Vec<&String> = argv + .iter() + .enumerate() + .filter(|(_, a)| *a == "--words") + .filter_map(|(i, _)| argv.get(i + 1)) + .collect(); for path in words_files { - let given: BTreeMap = serde_json::from_str(&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?) - .with_context(|| format!("parsing {path}"))?; + let given: BTreeMap = serde_json::from_str( + &std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?, + ) + .with_context(|| format!("parsing {path}"))?; for (page, v) in given { match words.get(&page) { Some(base) => { @@ -2759,13 +3359,33 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { // 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> = if plan.tones.is_empty() { vec![None] } else { plan.tones.iter().map(|t| Some(t.as_str())).collect() }; + let tones: Vec> = if plan.tones.is_empty() { + vec![None] + } else { + plan.tones.iter().map(|t| Some(t.as_str())).collect() + }; let mut told: Vec = 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}"))?; + 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) => { @@ -2801,14 +3421,21 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { } 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)?)?; + std::fs::write( + out.join("words.json"), + serde_json::to_string_pretty(&words)?, + )?; println!("content repo written to {}", out.display()); for need in untried_needs(&plan) { eprintln!("note: no persona tries need {need:?}; nobody will find out whether its page reads right until a person does"); } 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 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() { @@ -2816,7 +3443,10 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { // 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")); + 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"); } @@ -2847,18 +3477,46 @@ mod tests { fn need(stages: &[&str], done: &[&str], success: &[&str], skippable: &[&str]) -> PlanNeed { let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::>(); PlanNeed { - id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None, door: None, 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(), followed_by: vec![], - grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false, paused: false, one_per_person: false, leaves_after: BTreeMap::new(), - collects: vec![], max_fields: 3, + 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(), + followed_by: vec![], + grants: BTreeMap::new(), + deadlines: BTreeMap::new(), + escalate: BTreeMap::new(), + public_states: vec![], + presents: false, + paused: false, + one_per_person: false, + leaves_after: BTreeMap::new(), + collects: vec![], + max_fields: 3, } } fn map(pairs: &[(&str, &[&str])]) -> BTreeMap> { - pairs.iter().map(|(k, vs)| (k.to_string(), vs.iter().map(|v| v.to_string()).collect())).collect() + 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 { @@ -2877,11 +3535,16 @@ mod tests { 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(); + 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/")); + 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 "); @@ -2889,12 +3552,30 @@ mod tests { 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")); + assert!(states["fixed"]["mail"]["subject"] + .as_str() + .unwrap() + .contains("Fixed")); // A submitter mail needs the email; mails need a sender. - let no_email = yaml.replace(" - { name: email, type: email }\n", "").replace(" moves_by: { dropped: [submitter, roofers] }\n", ""); - assert!(serde_yaml::from_str::(&no_email).unwrap().validate().unwrap_err().to_string().contains("required `email`")); - let no_from = yaml.replace("mail: { from: \"B \", reply_to: i@b.example }\n", ""); - assert!(serde_yaml::from_str::(&no_from).unwrap().validate().unwrap_err().to_string().contains("mail: { from")); + let no_email = yaml + .replace(" - { name: email, type: email }\n", "") + .replace(" moves_by: { dropped: [submitter, roofers] }\n", ""); + assert!(serde_yaml::from_str::(&no_email) + .unwrap() + .validate() + .unwrap_err() + .to_string() + .contains("required `email`")); + let no_from = yaml.replace( + "mail: { from: \"B \", reply_to: i@b.example }\n", + "", + ); + assert!(serde_yaml::from_str::(&no_from) + .unwrap() + .validate() + .unwrap_err() + .to_string() + .contains("mail: { from")); } #[test] @@ -2916,21 +3597,34 @@ mod tests { 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.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}"); + 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"], &[]); + 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(); + 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"); @@ -2938,7 +3632,12 @@ mod tests { #[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"], &[])); + 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"]); @@ -2951,7 +3650,14 @@ mod tests { #[test] fn a_case_can_be_sent_back_for_what_it_was_missing() { let mut n = need( - &["open", "incomplete", "complete", "on_hearing", "approved", "refused"], + &[ + "open", + "incomplete", + "complete", + "on_hearing", + "approved", + "refused", + ], &["approved", "refused"], &["approved"], &["incomplete"], @@ -2973,7 +3679,14 @@ mod tests { #[test] fn a_good_ending_can_sit_partway_along() { let mut n = need( - &["open", "reconsidered", "before_the_board", "changed_by_us", "upheld", "overturned"], + &[ + "open", + "reconsidered", + "before_the_board", + "changed_by_us", + "upheld", + "overturned", + ], &["changed_by_us", "upheld", "overturned"], &["changed_by_us", "upheld"], &[], @@ -2997,10 +3710,22 @@ mod tests { 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")); + 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] @@ -3012,18 +3737,33 @@ mod tests { 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[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 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"); + 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"], &[]); + 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"]); @@ -3033,14 +3773,24 @@ mod tests { #[test] fn a_skippable_stage_can_be_passed_over() { - let m = machine(&need(&["open", "awaiting_papers", "approved", "declined"], &["approved", "declined"], &["approved"], &["awaiting_papers"])); + 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"], &[])); + let m = machine(&need( + &["open", "in_hand", "closed"], + &["closed"], + &["closed"], + &[], + )); assert_eq!(m["open"], ["in_hand"]); assert_eq!(m["in_hand"], ["closed"]); } @@ -3064,10 +3814,16 @@ mod tests { 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]["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[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"); @@ -3086,7 +3842,10 @@ mod tests { 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"}}}})); + 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"; @@ -3097,14 +3856,24 @@ mod tests { 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", - ]); + 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")); @@ -3135,14 +3904,22 @@ mod tests { 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")); + 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`")); + assert!(plan + .validate() + .unwrap_err() + .to_string() + .contains("required `email`")); } #[test] @@ -3165,10 +3942,16 @@ mod tests { 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!(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] | .[] |")); + 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"); @@ -3177,13 +3960,27 @@ mod tests { // 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(); + let spectator: Plan = + serde_yaml::from_str(&yaml.replace(" watched_by: [public]\n", "")).unwrap(); assert!(spectator.spectators()[0].contains("play nothing")); // Three features is a menu; a `for` must name a need. - let three = yaml.replace(" - { id: keep, text: We keep them fixed. }\n", " - { id: keep, text: x }\n - { id: more, text: y }\n"); - assert!(serde_yaml::from_str::(&three).unwrap().validate().unwrap_err().to_string().contains("one or two")); + let three = yaml.replace( + " - { id: keep, text: We keep them fixed. }\n", + " - { id: keep, text: x }\n - { id: more, text: y }\n", + ); + assert!(serde_yaml::from_str::(&three) + .unwrap() + .validate() + .unwrap_err() + .to_string() + .contains("one or two")); let bad = yaml.replace("for: leak", "for: nothing"); - assert!(serde_yaml::from_str::(&bad).unwrap().validate().unwrap_err().to_string().contains("nothing")); + assert!(serde_yaml::from_str::(&bad) + .unwrap() + .validate() + .unwrap_err() + .to_string() + .contains("nothing")); } #[test] @@ -3194,12 +3991,18 @@ mod tests { 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(); + 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(); + 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")); @@ -3214,12 +4017,23 @@ mod tests { // 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(); + 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(); + 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(); @@ -3228,7 +4042,11 @@ mod tests { 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")); + assert!(clash + .validate() + .unwrap_err() + .to_string() + .contains("also a group")); } #[test] @@ -3242,25 +4060,50 @@ mod tests { 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")); + 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")); + 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 the_words_model_is_told_who_opens_each_mail() { let plan: Plan = serde_yaml::from_str("business: Vel\ncontact: { name: S, email: s@v.example }\nmail: { from: v@v.example }\nneeds:\n - id: notice\n who: en nabo\n wants: et oppslag\n lands_in: oppslag\n handled_by: redaktor\n stages: [open, tatt_ned]\n done_when: [tatt_ned]\n mails: { open: s@v.example }\n watched_by: [public]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: answer\n who: en nabo\n wants: svare\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").unwrap(); let prompt = words_prompt(&plan, "aggregates.yaml", &json!({}), None); - assert!(prompt.contains("`oppslag` entering `open`: read by someone at the business (s@v.example)"), "{prompt}"); - assert!(prompt.contains("`svar` entering `open`: read by the person who posted the record this answers")); + assert!( + prompt.contains( + "`oppslag` entering `open`: read by someone at the business (s@v.example)" + ), + "{prompt}" + ); + assert!(prompt.contains( + "`svar` entering `open`: read by the person who posted the record this answers" + )); } #[test] fn a_need_no_persona_tries_is_named() { let plan: Plan = serde_yaml::from_str("business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: post@b.example }\nneeds:\n - id: ask\n who: a customer\n wants: an answer\n lands_in: asks\n handled_by: desk\n stages: [open, done]\n done_when: [done]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: card\n who: one of us\n wants: to be seen\n enters_as: desk\n lands_in: cards\n handled_by: desk\n stages: [open, gone]\n done_when: [gone]\n max_fields: 1\n collects:\n - { name: name }\n - id: later\n who: someone\n wants: something\n paused: true\n lands_in: later\n handled_by: desk\n stages: [open, done]\n done_when: [done]\n max_fields: 1\n collects:\n - { name: email, type: email }\npersonas:\n - { id: p, need: ask, text: I need an answer }\n").unwrap(); - assert_eq!(untried_needs(&plan), vec!["card"], "the inside need is untried; the paused one is not asked for"); + assert_eq!( + untried_needs(&plan), + vec!["card"], + "the inside need is untried; the paused one is not asked for" + ); } #[test] @@ -3272,18 +4115,30 @@ mod tests { // `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 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 |= {where: .where, what: .what}"); + assert_eq!( + resource["jq"], + "sort_by(.submitted_ms) | reverse | .[] | .responses |= {where: .where, what: .what}" + ); 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(); + 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")); + 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] @@ -3293,10 +4148,25 @@ mod tests { 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 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."); @@ -3309,32 +4179,68 @@ mod tests { 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}"); + 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")); + 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`")); + 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")); + 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`")); + 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 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}"); + 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")); + 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(); @@ -3350,7 +4256,11 @@ mod tests { 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")); + assert!(plan + .validate() + .unwrap_err() + .to_string() + .contains("must agree")); } } @@ -3362,8 +4272,10 @@ mod tests { /// form), keep their fallback. Hand-edited wording survives a rebuild. pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result> { let fallback = fallback_words(plan); - let tokened: BTreeMap = - fallback.iter().map(|(path, v)| (path.clone(), tokenize(v, path, ""))).collect(); + let tokened: BTreeMap = fallback + .iter() + .map(|(path, v)| (path.clone(), tokenize(v, path, ""))) + .collect(); let rendered = render(plan, &tokened)?; let mut found: std::collections::HashMap = std::collections::HashMap::new(); for (path, text) in &rendered { @@ -3373,7 +4285,9 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result(text), serde_yaml::from_str::(&live), @@ -3391,27 +4305,41 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result = Vec::new(); for live in yaml_files(&repo.join("questions")) { - let Ok(page) = serde_yaml::from_str::(&live) else { continue }; + let Ok(page) = serde_yaml::from_str::(&live) else { + continue; + }; if let Some(alts) = page.get("alternatives").and_then(|a| a.as_sequence()) { pool.extend(alts.iter().cloned()); } } let mut used = vec![false; pool.len()]; for text in rendered.values() { - let Ok(page) = serde_yaml::from_str::(text) else { continue }; - let Some(alts) = page.get("alternatives").and_then(|a| a.as_sequence()) else { continue }; + let Ok(page) = serde_yaml::from_str::(text) else { + continue; + }; + let Some(alts) = page.get("alternatives").and_then(|a| a.as_sequence()) else { + continue; + }; for alt in alts { - let ida: Vec<(&str, String)> = identity(alt).into_iter().filter(|(k, v)| strong_identity(k, v)).collect(); + 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))) { + 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()) + 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, @@ -3420,7 +4348,11 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result bool { match key { - "action" => value.starts_with("/received/") || value.starts_with("/status/") || value.starts_with("/decide/"), + "action" => { + value.starts_with("/received/") + || value.starts_with("/status/") + || value.starts_with("/decide/") + } "name" => false, _ => true, } @@ -3441,7 +4373,9 @@ fn tokens_in(v: &serde_yaml::Value) -> Vec { /// The text of every `.yaml` under `dir`, any depth. fn yaml_files(dir: &Path) -> Vec { let mut out = Vec::new(); - let Ok(entries) = std::fs::read_dir(dir) else { return out }; + let Ok(entries) = std::fs::read_dir(dir) else { + return out; + }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { @@ -3491,7 +4425,12 @@ fn identity(v: &serde_yaml::Value) -> Vec<(&'static str, String)> { // 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 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())); } @@ -3507,15 +4446,33 @@ fn token(path: &str, pointer: &str) -> String { 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::Object(m) => Value::Object( + m.iter() + .map(|(k, x)| (k.clone(), tokenize(x, path, &format!("{pointer}/{k}")))) + .collect(), + ), Value::String(_) => Value::String(token(path, pointer)), other => other.clone(), } } -fn untokenize(v: &Value, path: &str, pointer: &str, found: &std::collections::HashMap) -> Value { +fn untokenize( + v: &Value, + path: &str, + pointer: &str, + found: &std::collections::HashMap, +) -> Value { match v { - Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), untokenize(x, path, &format!("{pointer}/{k}"), found))).collect()), + Value::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(), @@ -3524,7 +4481,11 @@ fn untokenize(v: &Value, path: &str, pointer: &str, found: &std::collections::Ha } } -fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::collections::HashMap) { +fn collect_words( + a: &serde_yaml::Value, + b: &serde_yaml::Value, + found: &mut std::collections::HashMap, +) { match (a, b) { (serde_yaml::Value::Mapping(x), serde_yaml::Value::Mapping(y)) => { for (k, va) in x { @@ -3545,12 +4506,24 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std:: // 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()))) => + 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(); + 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())); + found.insert( + t.as_str().unwrap_or_default().to_string(), + Value::Array(words.iter().map(|w| json!(w)).collect()), + ); } } } @@ -3564,12 +4537,20 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std:: // 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 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) + (i < y.len() && !used[i] && identity(&y[i]).iter().all(|(k, _)| *k == "name")) + .then_some(i) } else if !strong.is_empty() { matching(&strong) } else { @@ -3581,9 +4562,15 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std:: } } } - (serde_yaml::Value::String(t), serde_yaml::Value::String(live)) if t.starts_with('\u{1}') => { + (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() }; + 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 @@ -3593,8 +4580,21 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std:: } } // 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())); + (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(), + ), + ); } _ => {} } @@ -3613,17 +4613,33 @@ mod invite_tests { 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(); + 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 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"); + 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); } @@ -3631,14 +4647,26 @@ mod invite_tests { #[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(); + 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")); + 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] @@ -3653,15 +4681,38 @@ mod invite_tests { 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"); + 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"]])); + 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())); @@ -3671,12 +4722,26 @@ mod invite_tests { 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"]); + 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(); + let blank: Plan = + serde_yaml::from_str(&PLAN.replace("business: Vel", "business: Vel\ntones: [\"\"]")) + .unwrap(); assert!(blank.validate().unwrap_err().to_string().contains("tones")); } @@ -3688,17 +4753,31 @@ mod invite_tests { let files = render(&plan, &fallback_words(&plan)).unwrap(); let mine: Value = serde_yaml::from_str(&files["questions/customers/index.yaml"]).unwrap(); let resource = &mine["alternatives"][0]["features"][0]["resource"]; - assert_eq!(resource["where"], json!({ "field": "email", "equals": "{user.email}" })); + assert_eq!( + resource["where"], + json!({ "field": "email", "equals": "{user.email}" }) + ); assert_eq!(resource["transitions"], json!([])); // The office sees everything. let office: Value = serde_yaml::from_str(&files["questions/office/index.yaml"]).unwrap(); - assert!(office["alternatives"][0]["features"][0]["resource"].get("where").is_none()); + assert!(office["alternatives"][0]["features"][0]["resource"] + .get("where") + .is_none()); // The form takes the file. assert!(files["questions/index.yaml"].contains("type: file")); // A group the case grants does not invite into itself. assert!(!files["questions/customers/index.yaml"].contains("invite:")); - let no_mail: Plan = serde_yaml::from_str(&yaml.replace(" - { name: email, type: email }\n", "").replace(" grants: { accepted: customers }\n", "")).unwrap(); - assert!(no_mail.validate().unwrap_err().to_string().contains("followed_by")); + let no_mail: Plan = serde_yaml::from_str( + &yaml + .replace(" - { name: email, type: email }\n", "") + .replace(" grants: { accepted: customers }\n", ""), + ) + .unwrap(); + assert!(no_mail + .validate() + .unwrap_err() + .to_string() + .contains("followed_by")); } #[test] @@ -3712,7 +4791,11 @@ mod invite_tests { 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")); + 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(); @@ -3721,34 +4804,85 @@ mod invite_tests { 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_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."); + 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")); + 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(); + 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")]); + 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")]); + 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")); + 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] @@ -3764,12 +4898,25 @@ mod invite_tests { 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" })); + 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 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"); @@ -3777,15 +4924,32 @@ mod invite_tests { // 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:?}"); + 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")); + assert!(both + .validate() + .unwrap_err() + .to_string() + .contains("not both")); } #[test] @@ -3795,25 +4959,35 @@ mod invite_tests { 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 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 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(); + 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(); + 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"); @@ -3821,14 +4995,23 @@ mod invite_tests { // 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"]); + 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 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")); } @@ -3840,7 +5023,8 @@ mod invite_tests { // 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"); + 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); @@ -3850,14 +5034,28 @@ mod invite_tests { // 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(); + 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"); + 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"); + 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); } @@ -3873,17 +5071,25 @@ mod invite_tests { 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}"); + 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!( + 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(); + 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")); } @@ -3905,10 +5111,21 @@ mod invite_tests { 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 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")); + 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); } @@ -3916,20 +5133,28 @@ mod invite_tests { 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"); + 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 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(); + 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"); + assert_eq!( + lifted["questions/index.yaml"]["needs"]["report"]["heading"], + "Report a hazard" + ); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/trainer/card.rs b/src/trainer/card.rs index aa8bcc4..0bdfa06 100644 --- a/src/trainer/card.rs +++ b/src/trainer/card.rs @@ -27,23 +27,42 @@ pub fn alternative(alt: &Value, full: bool) -> String { out.push_str(&format!(": {d}")); } out.push('\n'); - if let Some(button) = alt["consequence"].as_array().and_then(|c| c.first()).and_then(Value::as_str) { + if let Some(button) = alt["consequence"] + .as_array() + .and_then(|c| c.first()) + .and_then(Value::as_str) + { out.push_str(&format!(" Button: {button}\n")); } - for e in alt["encouragements"].as_array().into_iter().flatten().filter_map(Value::as_str) { + for e in alt["encouragements"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + { out.push_str(&format!(" {e}\n")); } for f in alt["features"].as_array().into_iter().flatten() { - let (name, desc, icon) = (s(&f["name"]).trim(), s(&f["description"]).trim(), s(&f["icon"]).trim()); + let (name, desc, icon) = ( + s(&f["name"]).trim(), + s(&f["description"]).trim(), + s(&f["icon"]).trim(), + ); let mut line = String::from(" "); if !icon.is_empty() { - line.push_str(&format!("[icon {}] ", icon.rsplit(':').next().unwrap_or(icon))); + line.push_str(&format!( + "[icon {}] ", + icon.rsplit(':').next().unwrap_or(icon) + )); } if !name.is_empty() { line.push_str(name); } if !desc.is_empty() { - line.push_str(&format!("{}{desc}", if name.is_empty() { "" } else { ": " })); + line.push_str(&format!( + "{}{desc}", + if name.is_empty() { "" } else { ": " } + )); } if !line.trim().is_empty() { out.push_str(line.trim_end()); @@ -56,8 +75,16 @@ pub fn alternative(alt: &Value, full: bool) -> String { .map(|r| { let label = r["label"].as_str().unwrap_or_else(|| s(&r["name"])); let hint = s(&r["placeholder"]).trim(); - let opt = if r["optional"] == true { " (optional)" } else { "" }; - if hint.is_empty() { format!("{label}{opt}") } else { format!("{label}{opt} e.g. {hint}") } + let opt = if r["optional"] == true { + " (optional)" + } else { + "" + }; + if hint.is_empty() { + format!("{label}{opt}") + } else { + format!("{label}{opt} e.g. {hint}") + } }) .collect(); if !fields.is_empty() { diff --git a/src/trainer/mod.rs b/src/trainer/mod.rs index eb8a487..d869b39 100644 --- a/src/trainer/mod.rs +++ b/src/trainer/mod.rs @@ -74,7 +74,15 @@ pub fn run(argv: Vec) -> anyhow::Result<()> { // sampling, so a score comes with its spread. 0 is the run // every earlier score used. let seed: u64 = value("--seed").and_then(|s| s.parse().ok()).unwrap_or(0); - print!("{}", answer_blind(&cmd, &std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?, repo.as_deref(), seed)?); + print!( + "{}", + answer_blind( + &cmd, + &std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?, + repo.as_deref(), + seed + )? + ); return Ok(()); } let opts = parse_args()?; @@ -103,11 +111,16 @@ pub fn run(argv: Vec) -> anyhow::Result<()> { std::fs::write(opts.out.join("needs.yaml"), needs)?; // A placeholder front door, so the lint gets as far as parsing // the spec; every need fails, a parse error is what we look for. - std::fs::write(opts.out.join("questions/index.yaml"), "name: Placeholder?\n")?; + std::fs::write( + opts.out.join("questions/index.yaml"), + "name: Placeholder?\n", + )?; let (_, report) = lint(&opts, &[])?; if let Some(line) = report.lines().find(|l| l.contains("parsing needs.yaml")) { eprintln!("needs round {round}: {line}"); - feedback = format!("\n## Your last attempt failed to parse\n\n{line}\n\n```yaml\n{needs}\n```\n"); + feedback = format!( + "\n## Your last attempt failed to parse\n\n{line}\n\n```yaml\n{needs}\n```\n" + ); continue; } eprintln!("needs round {round}: needs.yaml parses, frozen"); @@ -116,9 +129,14 @@ pub fn run(argv: Vec) -> anyhow::Result<()> { break; } if !feedback.is_empty() { - bail!("needs.yaml never parsed in {} round(s); the replies are in {}/llm/", opts.rounds, opts.out.display()); + bail!( + "needs.yaml never parsed in {} round(s); the replies are in {}/llm/", + opts.rounds, + opts.out.display() + ); } - let needs = std::fs::read_to_string(opts.out.join("needs.yaml")).context("no needs.yaml was produced")?; + let needs = std::fs::read_to_string(opts.out.join("needs.yaml")) + .context("no needs.yaml was produced")?; // Phase 2: structure. The page-writing model sees the needs and // never the personas: pages written with the test in view route @@ -165,12 +183,22 @@ pub fn run(argv: Vec) -> anyhow::Result<()> { Ok(files) => apply(&opts.out, files)?, Err(_) => eprintln!("structure round {round}: the reply had no file blocks"), } - let (ok, r, found) = lint_findings(&opts, &opts.out.join(format!("findings/round-{round}.jsonl")))?; + let (ok, r, found) = lint_findings( + &opts, + &opts.out.join(format!("findings/round-{round}.jsonl")), + )?; report = r; let Some(found) = found else { tracked = false; let fails = report.lines().filter(|l| l.starts_with("FAIL")).count(); - eprintln!("structure round {round}: {}", if ok { "green".to_string() } else { format!("{fails} FAIL line(s)") }); + eprintln!( + "structure round {round}: {}", + if ok { + "green".to_string() + } else { + format!("{fails} FAIL line(s)") + } + ); log.push(format!("structure round {round}: ok={ok} fails={fails}")); if ok { green = true; @@ -179,8 +207,14 @@ pub fn run(argv: Vec) -> anyhow::Result<()> { continue; }; let previous = ledger.clone(); - let progress = ledger.update(round, found.into_iter().filter(|f| f.level == "fail").collect()); - let line = format!("structure round {round}: {}", progress.summary(ledger.open.len())); + let progress = ledger.update( + round, + found.into_iter().filter(|f| f.level == "fail").collect(), + ); + let line = format!( + "structure round {round}: {}", + progress.summary(ledger.open.len()) + ); eprintln!("{line}"); log.push(line); undone.clear(); @@ -201,8 +235,14 @@ pub fn run(argv: Vec) -> anyhow::Result<()> { "## Your last change was undone\n\nIt reopened findings an earlier round had closed:\n\n{}\nClose the open findings without touching what made these pass.\n\n", progress.reopened.iter().map(|f| format!("- [{}] {}\n", f.id, f.message)).collect::() ); - eprintln!("structure round {round}: reopened {} closed finding(s), undone", progress.reopened.len()); - log.push(format!("structure round {round}: undone (reopened {})", progress.reopened.len())); + eprintln!( + "structure round {round}: reopened {} closed finding(s), undone", + progress.reopened.len() + ); + log.push(format!( + "structure round {round}: undone (reopened {})", + progress.reopened.len() + )); continue; } // No progress, no more rounds: another pass at the same findings @@ -214,8 +254,15 @@ pub fn run(argv: Vec) -> anyhow::Result<()> { } } if !green { - let open = if tracked { format!("\n\nStill open:\n{}", ledger.open_list(opts.rounds + 1)) } else { String::new() }; - std::fs::write(opts.out.join("run.log"), format!("{}{open}\n\n{report}", log.join("\n")))?; + let open = if tracked { + format!("\n\nStill open:\n{}", ledger.open_list(opts.rounds + 1)) + } else { + String::new() + }; + std::fs::write( + opts.out.join("run.log"), + format!("{}{open}\n\n{report}", log.join("\n")), + )?; bail!("structure did not go green; what is still open is in run.log"); } @@ -224,22 +271,48 @@ pub fn run(argv: Vec) -> anyhow::Result<()> { let mut defects = String::new(); let mut summary = Vec::new(); for skim in [true, false] { - let tasks_path = opts.out.join(if skim { "tasks-skim.jsonl" } else { "tasks.jsonl" }); - let mut args = vec!["--needs-tasks".to_string(), tasks_path.display().to_string()]; + let tasks_path = opts.out.join(if skim { + "tasks-skim.jsonl" + } else { + "tasks.jsonl" + }); + let mut args = vec![ + "--needs-tasks".to_string(), + tasks_path.display().to_string(), + ]; if skim { args.push("--skim".to_string()); } lint(&opts, &args)?; - let answers = answer_blind(opts.visitor.as_deref().unwrap_or(&opts.llm), &std::fs::read_to_string(&tasks_path)?, Some(&opts.out), 0)?; - let answers_path = opts.out.join(if skim { "answers-skim.jsonl" } else { "answers.jsonl" }); + let answers = answer_blind( + opts.visitor.as_deref().unwrap_or(&opts.llm), + &std::fs::read_to_string(&tasks_path)?, + Some(&opts.out), + 0, + )?; + let answers_path = opts.out.join(if skim { + "answers-skim.jsonl" + } else { + "answers.jsonl" + }); std::fs::write(&answers_path, answers)?; - let mut args = vec!["--needs-score".to_string(), answers_path.display().to_string()]; + let mut args = vec![ + "--needs-score".to_string(), + answers_path.display().to_string(), + ]; if skim { args.push("--skim".to_string()); } let (_, scored) = lint(&opts, &args)?; - let mode = if skim { "headings only" } else { "headings and descriptions" }; - for line in scored.lines().filter(|l| l.starts_with("MISROUTE") || l.starts_with("HESITANT")) { + let mode = if skim { + "headings only" + } else { + "headings and descriptions" + }; + for line in scored + .lines() + .filter(|l| l.starts_with("MISROUTE") || l.starts_with("HESITANT")) + { defects.push_str(&format!("[{mode}] {line}\n")); } if let Some(line) = scored.lines().find(|l| l.starts_with("wording:")) { @@ -397,7 +470,9 @@ fn lint_findings(opts: &Opts, out: &Path) -> anyhow::Result<(bool, String, Optio let found = raw .lines() .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str::(l).with_context(|| format!("reading {}", out.display()))) + .map(|l| { + serde_json::from_str::(l).with_context(|| format!("reading {}", out.display())) + }) .collect::>>()?; Ok((ok, report, Some(found))) } @@ -438,7 +513,12 @@ impl Progress { impl Ledger { fn update(&mut self, round: usize, fails: Vec) -> Progress { let now: BTreeMap = fails.into_iter().map(|f| (f.id.clone(), f)).collect(); - let fixed: Vec = self.open.values().filter(|f| !now.contains_key(&f.id)).cloned().collect(); + let fixed: Vec = self + .open + .values() + .filter(|f| !now.contains_key(&f.id)) + .cloned() + .collect(); let mut new = Vec::new(); let mut reopened = Vec::new(); let mut still = 0; @@ -457,7 +537,12 @@ impl Ledger { self.since.remove(&f.id); } self.open = now; - Progress { fixed, new, still, reopened } + Progress { + fixed, + new, + still, + reopened, + } } /// The open findings for a prompt or a log: id, how long open, the @@ -470,7 +555,11 @@ impl Ledger { Some(&first) if first + 1 < round => format!(", open since round {first}"), _ => String::new(), }; - let scope = if f.need.is_empty() { String::new() } else { format!(" {:?}", f.need) }; + let scope = if f.need.is_empty() { + String::new() + } else { + format!(" {:?}", f.need) + }; format!("- [{}] {}{scope}{age}: {}\n", f.id, f.code, f.message) }) .collect() @@ -481,8 +570,20 @@ impl Ledger { /// plus aggregates.yaml - when that is enough to act on. `None` when a /// finding is about getting around the site (a path, an entry page, a /// missing desk) or names no page: then the model needs to see it all. -fn named_files<'a>(out: &Path, findings: impl Iterator) -> Option> { - const WHOLE_SITE: &[&str] = &["schema", "entry-missing", "entry-gated", "path-missing", "path-closed", "path-over-budget", "desk-missing", "need-duplicate"]; +fn named_files<'a>( + out: &Path, + findings: impl Iterator, +) -> Option> { + const WHOLE_SITE: &[&str] = &[ + "schema", + "entry-missing", + "entry-gated", + "path-missing", + "path-closed", + "path-over-budget", + "desk-missing", + "need-duplicate", + ]; let mut files = vec![out.join("aggregates.yaml")]; for f in findings { if WHOLE_SITE.contains(&f.code.as_str()) { @@ -490,12 +591,17 @@ fn named_files<'a>(out: &Path, findings: impl Iterator) -> O } let text = format!("{} {}", f.message, f.fix.as_deref().unwrap_or_default()); for page in text.split_whitespace().filter(|w| w.starts_with('/')) { - let page = page.trim_end_matches(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-' || c == '/')); + let page = page.trim_end_matches(|c: char| { + !(c.is_alphanumeric() || c == '_' || c == '-' || c == '/') + }); let rel = page.trim_start_matches('/'); let candidates = if rel.is_empty() { vec![out.join("questions/index.yaml")] } else { - vec![out.join(format!("questions/{rel}.yaml")), out.join(format!("questions/{rel}/index.yaml"))] + vec![ + out.join(format!("questions/{rel}.yaml")), + out.join(format!("questions/{rel}/index.yaml")), + ] }; if let Some(path) = candidates.into_iter().find(|p| p.exists()) { if !files.contains(&path) { @@ -512,7 +618,10 @@ fn dump_files(out: &Path, files: &[PathBuf]) -> anyhow::Result { let mut text = String::new(); for path in files.iter().filter(|p| p.exists()) { let rel = path.strip_prefix(out).unwrap_or(path).display().to_string(); - text.push_str(&format!("<<>>\n{}<<>>\n\n", std::fs::read_to_string(path)?)); + text.push_str(&format!( + "<<>>\n{}<<>>\n\n", + std::fs::read_to_string(path)? + )); } Ok(text) } @@ -569,7 +678,9 @@ fn parse_files(text: &str) -> anyhow::Result> { /// trusted: repo-relative, yaml, and only where content lives. fn allowed(path: &str) -> bool { let p = Path::new(path); - let plain = p.components().all(|c| matches!(c, std::path::Component::Normal(_))); + let plain = p + .components() + .all(|c| matches!(c, std::path::Component::Normal(_))); plain && p.extension().and_then(|e| e.to_str()) == Some("yaml") && (matches!(path, "aggregates.yaml" | "site.yaml") || path.starts_with("questions/")) @@ -640,8 +751,15 @@ fn restore(out: &Path, before: Vec<(PathBuf, String)>) -> anyhow::Result<()> { fn dump(out: &Path) -> anyhow::Result { let mut text = String::new(); for path in content_files(out)? { - let rel = path.strip_prefix(out).unwrap_or(&path).display().to_string(); - text.push_str(&format!("<<>>\n{}<<>>\n\n", std::fs::read_to_string(&path)?)); + let rel = path + .strip_prefix(out) + .unwrap_or(&path) + .display() + .to_string(); + text.push_str(&format!( + "<<>>\n{}<<>>\n\n", + std::fs::read_to_string(&path)? + )); } Ok(text) } @@ -659,7 +777,12 @@ fn dump(out: &Path) -> anyhow::Result { /// card portal renders: button, encouragement, sections with icon and /// guidance, fields with placeholders. Headings-only tasks (`--skim`, /// where the task's text is the heading itself) stay headings only. -pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_offset: u64) -> anyhow::Result { +pub(crate) fn answer_blind( + cmd: &str, + tasks: &str, + repo: Option<&Path>, + seed_offset: u64, +) -> anyhow::Result { let mut answers = String::new(); for line in tasks.lines().filter(|l| !l.trim().is_empty()) { let task: serde_json::Value = serde_json::from_str(line)?; @@ -673,8 +796,14 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off .flatten() .map(|(k, v)| (k.as_str(), v.as_str().unwrap_or_default())) .collect(); - let skim = options.iter().all(|(name, text)| text.is_empty() || text == name); - let page = if skim { None } else { repo.and_then(|r| rate::load_page(r, task["page"].as_str().unwrap_or("/"))) }; + let skim = options + .iter() + .all(|(name, text)| text.is_empty() || text == name); + let page = if skim { + None + } else { + repo.and_then(|r| rate::load_page(r, task["page"].as_str().unwrap_or("/"))) + }; let seed = fnv(id).wrapping_add(seed_offset.wrapping_mul(0x9E37_79B9_7F4A_7C15) as _); shuffle(&mut options, seed); // Options are numbered and the answer is the number: with a @@ -702,14 +831,25 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off let mut head = page.as_ref().map(card::page_head).unwrap_or_default(); // What the page says before it offers anything (the opener, the // look around): read, not picked. - if let Some(context) = task["state"]["page"].as_str().filter(|c| !c.trim().is_empty()) { + if let Some(context) = task["state"]["page"] + .as_str() + .filter(|c| !c.trim().is_empty()) + { head.push_str(&format!("The page first says:\n{context}\n\n")); } let prompt = format!( "You are this person: {body}\n\n{instructions}\n{head}The page offers these options:\n\n{}\n\nWhich one do you pick? Judge only from your own situation and the wording in front of you. Give your honest confidence between 0 and 1; if two options fit you about equally, confidence is low.\nReply as JSON: {{\"choice\": , \"confidence\": 0.00}}", listed.join("\n\n") ); - let reply = llm_env(cmd, &prompt, &[("FORMAT", "json"), ("SEED", &seed.to_string()), ("TEMPERATURE", "0.2")])?; + let reply = llm_env( + cmd, + &prompt, + &[ + ("FORMAT", "json"), + ("SEED", &seed.to_string()), + ("TEMPERATURE", "0.2"), + ], + )?; let parsed = simulate::first_object(&reply).unwrap_or(serde_json::Value::Null); let names: Vec<&str> = options.iter().map(|(n, _)| *n).collect(); let raw = match &parsed["choice"] { @@ -722,7 +862,9 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off _ => simulate::resolve_choice(&names, &raw), }; let confidence = parsed["confidence"].as_f64().unwrap_or(0.0); - answers.push_str(&serde_json::to_string(&serde_json::json!({ "id": id, "choice": choice, "confidence": confidence }))?); + answers.push_str(&serde_json::to_string( + &serde_json::json!({ "id": id, "choice": choice, "confidence": confidence }), + )?); answers.push('\n'); } Ok(answers) @@ -730,7 +872,9 @@ pub(crate) fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>, seed_off /// FNV-1a over the task id: a seed that is the same on every run. fn fnv(text: &str) -> u32 { - text.bytes().fold(0x811c_9dc5u32, |h, b| (h ^ u32::from(b)).wrapping_mul(0x0100_0193)) + text.bytes().fold(0x811c_9dc5u32, |h, b| { + (h ^ u32::from(b)).wrapping_mul(0x0100_0193) + }) } /// Fisher-Yates on a small xorshift, so the order is fixed by the seed. @@ -803,21 +947,45 @@ mod ledger_tests { use super::*; fn f(id: &str, code: &str, message: &str) -> Finding { - Finding { id: id.into(), level: "fail".into(), code: code.into(), need: "hire".into(), message: message.into(), fix: None } + Finding { + id: id.into(), + level: "fail".into(), + code: code.into(), + need: "hire".into(), + message: message.into(), + fix: None, + } } #[test] fn a_round_is_told_what_it_closed_kept_added_and_broke() { let mut ledger = Ledger::default(); let p = ledger.update(1, vec![f("a", "strand", "x"), f("b", "strand", "y")]); - assert_eq!((p.fixed.len(), p.new.len(), p.still, p.reopened.len()), (0, 2, 0, 0)); - let p = ledger.update(2, vec![f("b", "strand", "y"), f("c", "edge-unoffered", "z")]); - assert_eq!((p.fixed.len(), p.new.len(), p.still, p.reopened.len()), (1, 1, 1, 0)); - assert_eq!(p.summary(ledger.open.len()), "2 open - fixed 1, still open 1, new 1, reopened 0"); + assert_eq!( + (p.fixed.len(), p.new.len(), p.still, p.reopened.len()), + (0, 2, 0, 0) + ); + let p = ledger.update( + 2, + vec![f("b", "strand", "y"), f("c", "edge-unoffered", "z")], + ); + assert_eq!( + (p.fixed.len(), p.new.len(), p.still, p.reopened.len()), + (1, 1, 1, 0) + ); + assert_eq!( + p.summary(ledger.open.len()), + "2 open - fixed 1, still open 1, new 1, reopened 0" + ); // "a" was closed in round 2; its return is a regression. let p = ledger.update(3, vec![f("a", "strand", "x"), f("b", "strand", "y")]); - assert_eq!(p.reopened.iter().map(|f| f.id.as_str()).collect::>(), ["a"]); - assert!(ledger.open_list(4).contains("[b] strand \"hire\", open since round 1: y")); + assert_eq!( + p.reopened.iter().map(|f| f.id.as_str()).collect::>(), + ["a"] + ); + assert!(ledger + .open_list(4) + .contains("[b] strand \"hire\", open since round 1: y")); let p = ledger.update(4, vec![]); assert_eq!(p.summary(ledger.open.len()), "green (fixed 2)"); } @@ -830,11 +998,22 @@ mod ledger_tests { std::fs::write(dir.join("questions/desk/index.yaml"), "x").unwrap(); std::fs::write(dir.join("questions/index.yaml"), "x").unwrap(); let mut strand = f("a", "strand", "records strand in \"quoted\""); - strand.fix = Some("add `{ from: quoted, to: won, label: ... }` to the transitions on /desk".into()); + strand.fix = + Some("add `{ from: quoted, to: won, label: ... }` to the transitions on /desk".into()); let files = named_files(&dir, [&strand].into_iter()).unwrap(); - assert_eq!(files, vec![dir.join("aggregates.yaml"), dir.join("questions/desk/index.yaml")]); + assert_eq!( + files, + vec![ + dir.join("aggregates.yaml"), + dir.join("questions/desk/index.yaml") + ] + ); // A finding about getting around the site needs all of it. - assert!(named_files(&dir, [&strand, &f("b", "path-missing", "no path")].into_iter()).is_none()); + assert!(named_files( + &dir, + [&strand, &f("b", "path-missing", "no path")].into_iter() + ) + .is_none()); std::fs::remove_dir_all(&dir).ok(); } } diff --git a/src/trainer/plan.rs b/src/trainer/plan.rs index d3a87d0..b9482b8 100644 --- a/src/trainer/plan.rs +++ b/src/trainer/plan.rs @@ -284,7 +284,11 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { let text = std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?; let doc: serde_yaml::Value = serde_yaml::from_str(&text).with_context(|| format!("parsing {path}"))?; - Some(doc.get("personas").cloned().ok_or_else(|| anyhow!("{path} has no `personas:`"))?) + Some( + doc.get("personas") + .cloned() + .ok_or_else(|| anyhow!("{path} has no `personas:`"))?, + ) } None => None, }; @@ -323,13 +327,14 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { if let Some(list) = personas.as_mut() { let mapped = map_personas(&cmd, &plan, list)?; if mapped > 0 { - eprintln!("plan round {round}: {mapped} persona(s) mapped to this plan's needs"); + eprintln!( + "plan round {round}: {mapped} persona(s) mapped to this plan's needs" + ); } text.push_str("\n# Held out from every model that writes words.\n"); - text.push_str(&serde_yaml::to_string(&serde_yaml::Mapping::from_iter([( - serde_yaml::Value::from("personas"), - list.clone(), - )]))?); + text.push_str(&serde_yaml::to_string(&serde_yaml::Mapping::from_iter([ + (serde_yaml::Value::from("personas"), list.clone()), + ]))?); } let whole: Plan = serde_yaml::from_str(&text).context("the plan with its personas attached")?; @@ -411,25 +416,42 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { /// not have, or a persona the model leaves out, stays as it was. fn map_personas(cmd: &str, plan: &Plan, personas: &mut serde_yaml::Value) -> anyhow::Result { let ids: Vec<&str> = plan.needs.iter().map(|n| n.id.as_str()).collect(); - let Some(list) = personas.as_sequence_mut() else { return Ok(0) }; + let Some(list) = personas.as_sequence_mut() else { + return Ok(0); + }; let strangers: Vec<(String, String)> = list .iter() .filter(|p| !ids.contains(&p["need"].as_str().unwrap_or_default())) - .map(|p| (p["id"].as_str().unwrap_or_default().to_string(), p["text"].as_str().unwrap_or_default().to_string())) + .map(|p| { + ( + p["id"].as_str().unwrap_or_default().to_string(), + p["text"].as_str().unwrap_or_default().to_string(), + ) + }) .filter(|(id, _)| !id.is_empty()) .collect(); if strangers.is_empty() { return Ok(0); } - let needs: Vec = plan.needs.iter().map(|n| format!("- {}: {} - wants {}", n.id, n.who, n.wants)).collect(); - let people: Vec = strangers.iter().map(|(id, text)| format!("- {id}: {text}")).collect(); + let needs: Vec = plan + .needs + .iter() + .map(|n| format!("- {}: {} - wants {}", n.id, n.who, n.wants)) + .collect(); + let people: Vec = strangers + .iter() + .map(|(id, text)| format!("- {id}: {text}")) + .collect(); let prompt = format!( "A site has these needs, one per kind of visitor:\n{}\n\nThese people arrive at it:\n{}\n\nFor each person, which need is theirs? Reply with a JSON object mapping each person's id to one need id from the list, and nothing else. Leave a person out if no need fits.", needs.join("\n"), people.join("\n") ); - let reply = crate::trainer::llm_with(cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.0")])?; - let Some(mapping) = first_object(&reply) else { return Ok(0) }; + let reply = + crate::trainer::llm_with(cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.0")])?; + let Some(mapping) = first_object(&reply) else { + return Ok(0); + }; let mut mapped = 0; for persona in list.iter_mut() { let id = persona["id"].as_str().unwrap_or_default().to_string(); diff --git a/src/trainer/rate.rs b/src/trainer/rate.rs index 9828a53..c5b3e82 100644 --- a/src/trainer/rate.rs +++ b/src/trainer/rate.rs @@ -34,11 +34,16 @@ use crate::trainer::card; use crate::trainer::simulate::ask; fn flag(argv: &[String], name: &str) -> Option { - argv.iter().position(|a| a == name).and_then(|i| argv.get(i + 1)).cloned() + argv.iter() + .position(|a| a == name) + .and_then(|i| argv.get(i + 1)) + .cloned() } fn load(path: &str) -> anyhow::Result { - Ok(serde_json::from_str(&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?)?) + Ok(serde_json::from_str( + &std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?, + )?) } /// LIX = words per sentence + 100 * long words / words. @@ -50,7 +55,11 @@ pub fn lix(text: &str) -> f64 { if words.is_empty() { return 0.0; } - let sentences = text.split(['.', '!', '?', ':', '\n']).filter(|s| s.chars().any(char::is_alphabetic)).count().max(1); + let sentences = text + .split(['.', '!', '?', ':', '\n']) + .filter(|s| s.chars().any(char::is_alphabetic)) + .count() + .max(1); let long = words.iter().filter(|w| w.chars().count() > 6).count(); words.len() as f64 / sentences as f64 + 100.0 * long as f64 / words.len() as f64 } @@ -74,7 +83,10 @@ fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutte }; // The cards that lead nowhere (the opener, the look around) are // read before the options; the gutted twin loses them too. - if let Some(context) = step["context"].as_str().filter(|c| !c.trim().is_empty() && !gutted) { + if let Some(context) = step["context"] + .as_str() + .filter(|c| !c.trim().is_empty() && !gutted) + { text.push_str(&format!("\n{context}\n")); } for (i, option) in step["options"].as_array().into_iter().flatten().enumerate() { @@ -105,7 +117,12 @@ fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutte // tells a reader how many things they will be asked, and a model // answers "can you tell what you will be asked" with yes to that // (measured: 2 of 2 on every need). - let fields: Vec<&str> = step["fields"].as_array().into_iter().flatten().filter_map(|f| f["label"].as_str()).collect(); + let fields: Vec<&str> = step["fields"] + .as_array() + .into_iter() + .flatten() + .filter_map(|f| f["label"].as_str()) + .collect(); if !gutted && !fields.is_empty() { text.push_str(&format!( "\nThe form under \"{}\" asks for: {}.\n", @@ -121,13 +138,24 @@ fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutte fn finish_page_text(mut text: String, followup: Option<&Value>) -> String { if let Some(next) = followup { - text.push_str(&format!("\nAfter you send it, the next page says:\n{}\n", next["name"].as_str().unwrap_or_default())); - if let Some(d) = next["description"].as_str().filter(|d| !d.trim().is_empty()) { + text.push_str(&format!( + "\nAfter you send it, the next page says:\n{}\n", + next["name"].as_str().unwrap_or_default() + )); + if let Some(d) = next["description"] + .as_str() + .filter(|d| !d.trim().is_empty()) + { text.push_str(&format!("{}\n", d.trim())); } for alt in next["alternatives"].as_array().into_iter().flatten() { let d = alt["description"].as_str().unwrap_or_default().trim(); - text.push_str(&format!("- {}{}{}\n", alt["name"].as_str().unwrap_or_default(), if d.is_empty() { "" } else { ": " }, d)); + text.push_str(&format!( + "- {}{}{}\n", + alt["name"].as_str().unwrap_or_default(), + if d.is_empty() { "" } else { ": " }, + d + )); } } text @@ -137,12 +165,30 @@ fn finish_page_text(mut text: String, followup: Option<&Value>) -> String { /// description, and the followup. Headings and labels are left out. fn prose_text(step: &Value, followup: Option<&Value>) -> String { let mut prose = vec![step["question"].as_str().unwrap_or_default().to_string()]; - prose.extend(step["options"].as_array().into_iter().flatten().filter_map(|o| o["description"].as_str()).map(str::to_string)); + prose.extend( + step["options"] + .as_array() + .into_iter() + .flatten() + .filter_map(|o| o["description"].as_str()) + .map(str::to_string), + ); if let Some(next) = followup { prose.extend(next["description"].as_str().map(str::to_string)); - prose.extend(next["alternatives"].as_array().into_iter().flatten().filter_map(|a| a["description"].as_str()).map(str::to_string)); + prose.extend( + next["alternatives"] + .as_array() + .into_iter() + .flatten() + .filter_map(|a| a["description"].as_str()) + .map(str::to_string), + ); } - prose.into_iter().filter(|p| !p.trim().is_empty()).collect::>().join("\n") + prose + .into_iter() + .filter(|p| !p.trim().is_empty()) + .collect::>() + .join("\n") } /// The page a visitor lands on after the step's alternative: read from @@ -152,7 +198,11 @@ fn followup_page(repo: Option<&Path>, step: &Value) -> Option { let repo = repo?; let page = load_page(repo, step["page"].as_str()?)?; let alternative = step["alternative"].as_str()?; - let action = page["alternatives"].as_array()?.iter().find(|a| a["name"] == alternative)?["action"].as_str()?; + let action = page["alternatives"] + .as_array()? + .iter() + .find(|a| a["name"] == alternative)?["action"] + .as_str()?; load_page(repo, action) } @@ -187,15 +237,33 @@ fn s(v: &Value) -> String { /// the button for it. The outcome is put in plain words from the /// state's own name, never from the label, and the buttons are listed /// in a seeded shuffle. -fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize, words: &Value) -> anyhow::Result { +fn button_pick( + cmd: &str, + group: &str, + bucket: &str, + from: &str, + to: &str, + labels: &[&str], + seed: usize, + words: &Value, +) -> anyhow::Result { // The state in the plan's own words when the spec carries them, // else the id made readable. - let plain = |id: &str| words[id].as_str().map(str::to_string).unwrap_or_else(|| id.replace(['_', '-'], " ")); + let plain = |id: &str| { + words[id] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| id.replace(['_', '-'], " ")) + }; let mut order: Vec<&str> = labels.to_vec(); // A fixed rotation per move: the right answer is not always first. let by = seed % order.len().max(1); order.rotate_left(by); - let listed: Vec = order.iter().enumerate().map(|(i, l)| format!("{}. {l}", i + 1)).collect(); + let listed: Vec = order + .iter() + .enumerate() + .map(|(i, l)| format!("{}. {l}", i + 1)) + .collect(); let prompt = format!( "You work on the {} desk of a small organisation. A case in \"{}\" was \"{}\" and has just become \"{}\". The desk shows these buttons for it:\n\n{}\n\nWhich button records what just happened? Reply as JSON: {{\"choice\": }}", plain(group), @@ -206,7 +274,10 @@ fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, label ); let reply = ask(cmd, &prompt, &seed.to_string())?; let n = reply["choice"].as_u64().unwrap_or(0) as usize; - Ok(order.get(n.wrapping_sub(1)).map(|l| l.to_string()).unwrap_or_default()) + Ok(order + .get(n.wrapping_sub(1)) + .map(|l| l.to_string()) + .unwrap_or_default()) } /// Every `mail:` on a state in the repo's aggregates.yaml, as @@ -228,7 +299,12 @@ pub(crate) fn mail_texts(repo: &Path) -> Option> { } out.split_whitespace().collect::>().join(" ") }; - Some(raw_mails(repo, &doc).into_iter().map(|(name, subject, body)| (name, strip(&subject), strip(&body))).collect()) + Some( + raw_mails(repo, &doc) + .into_iter() + .map(|(name, subject, body)| (name, strip(&subject), strip(&body))) + .collect(), + ) } /// Every mail the site sends, as written: the state mails in @@ -248,10 +324,17 @@ fn raw_mails(repo: &Path, aggregates: &Value) -> Vec<(String, String, String)> { } } } - let site: Value = std::fs::read_to_string(repo.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null); + let site: Value = std::fs::read_to_string(repo.join("site.yaml")) + .ok() + .and_then(|t| serde_yaml::from_str(&t).ok()) + .unwrap_or(Value::Null); let invite = &site["mail"]["invite"]; if invite.is_object() { - out.push(("invite".to_string(), invite["subject"].as_str().unwrap_or_default().to_string(), invite["body"].as_str().unwrap_or_default().to_string())); + out.push(( + "invite".to_string(), + invite["subject"].as_str().unwrap_or_default().to_string(), + invite["body"].as_str().unwrap_or_default().to_string(), + )); } out } @@ -265,7 +348,12 @@ fn as_received(template: &str, site_name: &str) -> String { out.push_str(&rest[..i]); let after = &rest[i + 1..]; match after.find('}') { - Some(j) if after[..j].chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') && j > 0 => { + Some(j) + if after[..j] + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') + && j > 0 => + { out.push_str(&match &after[..j] { "site" => "https://example.site".to_string(), "chain" => "8f3a1c".to_string(), @@ -287,19 +375,38 @@ fn as_received(template: &str, site_name: &str) -> String { } const MAIL_QUESTIONS: [(&str, &str); 3] = [ - ("happened", "Do you know, from this mail alone, what has happened and to what?"), - ("now", "Do you know what, if anything, you can or must do now, and how?"), - ("who", "Do you know who sent it, and how you would answer them?"), + ( + "happened", + "Do you know, from this mail alone, what has happened and to what?", + ), + ( + "now", + "Do you know what, if anything, you can or must do now, and how?", + ), + ( + "who", + "Do you know who sent it, and how you would answer them?", + ), ]; /// Three yes-or-no questions on one mail, from the person it is for. -fn mail_understanding(cmd: &str, reader: &str, subject: &str, body: &str, seed: usize) -> anyhow::Result<[bool; 3]> { +fn mail_understanding( + cmd: &str, + reader: &str, + subject: &str, + body: &str, + seed: usize, +) -> anyhow::Result<[bool; 3]> { let prompt = format!( "You are {reader}. This mail has just arrived:\n---\nSubject: {subject}\n\n{body}\n---\n\nAnswer three questions as yourself. Say yes only when the mail itself tells you; if you would be guessing, say no.\n{}\nReply as JSON: {{\"happened\": true or false, \"now\": true or false, \"who\": true or false}}", MAIL_QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::>().join("\n") ); let reply = ask(cmd, &prompt, &seed.to_string())?; - Ok([reply["happened"] == true, reply["now"] == true, reply["who"] == true]) + Ok([ + reply["happened"] == true, + reply["now"] == true, + reply["who"] == true, + ]) } /// `/` is `questions/index.yaml`; `/a/b` is `questions/a/b.yaml` or @@ -309,9 +416,14 @@ pub(crate) fn load_page(repo: &Path, route: &str) -> Option { let candidates = if rel.is_empty() { vec![repo.join("questions/index.yaml")] } else { - vec![repo.join(format!("questions/{rel}.yaml")), repo.join(format!("questions/{rel}/index.yaml"))] + vec![ + repo.join(format!("questions/{rel}.yaml")), + repo.join(format!("questions/{rel}/index.yaml")), + ] }; - let text = candidates.iter().find_map(|p| std::fs::read_to_string(p).ok())?; + let text = candidates + .iter() + .find_map(|p| std::fs::read_to_string(p).ok())?; serde_yaml::from_str(&text).ok() } @@ -326,7 +438,12 @@ const MAX_REQUIRED: usize = 5; fn page_load(step: &Value) -> (usize, usize) { ( step["options"].as_array().map_or(0, Vec::len), - step["fields"].as_array().into_iter().flatten().filter(|f| f["optional"] != true).count(), + step["fields"] + .as_array() + .into_iter() + .flatten() + .filter(|f| f["optional"] != true) + .count(), ) } @@ -344,7 +461,12 @@ fn understanding(cmd: &str, who: &str, page: &str, seed: usize) -> anyhow::Resul QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::>().join("\n") ); let reply = ask(cmd, &prompt, &seed.to_string())?; - Ok([reply["which"] == true, reply["where"] == true, reply["asked"] == true, reply["next"] == true]) + Ok([ + reply["which"] == true, + reply["where"] == true, + reply["asked"] == true, + reply["next"] == true, + ]) } pub fn rate(argv: &[String]) -> anyhow::Result<()> { @@ -365,7 +487,10 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { // that desk's buttons for the bucket. let desks = desk_buttons(&model); if !desks.is_empty() { - println!("{:<20} {:<24} {:>8} {}", "desk", "bucket", "buttons", "mistaken for"); + println!( + "{:<20} {:<24} {:>8} {}", + "desk", "bucket", "buttons", "mistaken for" + ); for (page, group, bucket, moves) in &desks { // A desk row shows only the buttons that start from the // row's state, so those are the ones the worker chooses @@ -374,12 +499,25 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { let mut asked = 0; let mut mistaken = Vec::new(); for (i, (from, to, label)) in moves.iter().enumerate() { - let labels: Vec<&str> = moves.iter().filter(|(f, _, _)| f == from).map(|(_, _, l)| l.as_str()).collect(); + let labels: Vec<&str> = moves + .iter() + .filter(|(f, _, _)| f == from) + .map(|(_, _, l)| l.as_str()) + .collect(); if labels.len() < 2 { continue; } asked += 1; - let picked = button_pick(&cmd, group, bucket, from, to, &labels, i, &model["buckets"][bucket]["words"])?; + let picked = button_pick( + &cmd, + group, + bucket, + from, + to, + &labels, + i, + &model["buckets"][bucket]["words"], + )?; if picked == *label { right += 1; } else { @@ -389,8 +527,18 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { if asked == 0 { continue; } - println!("{:<20} {:<24} {:>3} of {:<2} {}", page, bucket, right, asked, mistaken.join("; ")); - report.insert(format!("desk {page} {bucket}"), json!({ "right": right, "of": asked, "mistaken": mistaken })); + println!( + "{:<20} {:<24} {:>3} of {:<2} {}", + page, + bucket, + right, + asked, + mistaken.join("; ") + ); + report.insert( + format!("desk {page} {bucket}"), + json!({ "right": right, "of": asked, "mistaken": mistaken }), + ); } println!(); } @@ -404,12 +552,23 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { if let Some(mails) = repo.as_deref().and_then(mail_texts) { if !mails.is_empty() { let dir = repo.as_deref().unwrap_or(Path::new(".")); - let site: Value = std::fs::read_to_string(dir.join("site.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null); + let site: Value = std::fs::read_to_string(dir.join("site.yaml")) + .ok() + .and_then(|t| serde_yaml::from_str(&t).ok()) + .unwrap_or(Value::Null); let site_name = site["title"].as_str().unwrap_or("the site").to_string(); - let aggregates: Value = std::fs::read_to_string(dir.join("aggregates.yaml")).ok().and_then(|t| serde_yaml::from_str(&t).ok()).unwrap_or(Value::Null); + let aggregates: Value = std::fs::read_to_string(dir.join("aggregates.yaml")) + .ok() + .and_then(|t| serde_yaml::from_str(&t).ok()) + .unwrap_or(Value::Null); let raw = raw_mails(dir, &aggregates); - println!("{:<34} {:>5} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}", "mail", "LIX", "", "words", "happened", "now", "who", "gutted twin says"); - for (i, ((name, subject, body), (_, raw_subject, raw_body))) in mails.iter().zip(&raw).enumerate() { + println!( + "{:<34} {:>5} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}", + "mail", "LIX", "", "words", "happened", "now", "who", "gutted twin says" + ); + for (i, ((name, subject, body), (_, raw_subject, raw_body))) in + mails.iter().zip(&raw).enumerate() + { let text = format!("{subject}. {body}"); let score = lix(&text); let reader = if name == "invite" { @@ -417,30 +576,59 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { } else { format!("someone who sent a form to {site_name} some time ago") }; - let (subject_in, body_in) = (as_received(raw_subject, &site_name), as_received(raw_body, &site_name)); + let (subject_in, body_in) = ( + as_received(raw_subject, &site_name), + as_received(raw_body, &site_name), + ); let got = mail_understanding(&cmd, &reader, &subject_in, &body_in, i)?; let twin = mail_understanding(&cmd, &reader, "News", "There is news.", i)?; let yn = |b: bool| if b { "yes" } else { "no" }; - let failed: Vec<&str> = MAIL_QUESTIONS.iter().zip(twin).filter(|(_, t)| *t).map(|((k, _), _)| *k).collect(); + let failed: Vec<&str> = MAIL_QUESTIONS + .iter() + .zip(twin) + .filter(|(_, t)| *t) + .map(|((k, _), _)| *k) + .collect(); println!( "{:<34} {:>5.0} {:<14} {:>5} {:>8} {:>5} {:>5} {:<16}", - name, score, lix_band(score), text.split_whitespace().count(), yn(got[0]), yn(got[1]), yn(got[2]), - if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) } + name, + score, + lix_band(score), + text.split_whitespace().count(), + yn(got[0]), + yn(got[1]), + yn(got[2]), + if failed.is_empty() { + "ok".to_string() + } else { + format!("FAILED: {}", failed.join(", ")) + } + ); + report.insert( + format!("mail {name}"), + json!({ + "lix": score, "words": text.split_whitespace().count(), + "understanding": { "happened": got[0], "now": got[1], "who": got[2] }, + "control_ok": failed.is_empty(), + }), ); - report.insert(format!("mail {name}"), json!({ - "lix": score, "words": text.split_whitespace().count(), - "understanding": { "happened": got[0], "now": got[1], "who": got[2] }, - "control_ok": failed.is_empty(), - })); } println!(); } } - println!("{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}", "need", "LIX", "", "which", "where", "asked", "next", "gutted twin says", "page load"); + println!( + "{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}", + "need", "LIX", "", "which", "where", "asked", "next", "gutted twin says", "page load" + ); for need in model["needs"].as_array().into_iter().flatten() { let id = need["id"].as_str().unwrap_or_default(); let steps = need["steps"].as_array().cloned().unwrap_or_default(); - let personas: Vec<&Value> = model["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).collect(); + let personas: Vec<&Value> = model["personas"] + .as_array() + .into_iter() + .flatten() + .filter(|p| p["need"] == id) + .collect(); if personas.is_empty() { continue; } @@ -450,15 +638,30 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { // after sending. let mut pages = Vec::new(); for (i, step) in steps.iter().enumerate() { - let followup = if i + 1 == steps.len() { followup_page(repo.as_deref(), step) } else { None }; - let page = repo.as_deref().and_then(|r| load_page(r, step["page"].as_str().unwrap_or("/"))); - let row = if steps.len() > 1 { format!("{id} @{}", step["page"].as_str().unwrap_or_default()) } else { id.to_string() }; + let followup = if i + 1 == steps.len() { + followup_page(repo.as_deref(), step) + } else { + None + }; + let page = repo + .as_deref() + .and_then(|r| load_page(r, step["page"].as_str().unwrap_or("/"))); + let row = if steps.len() > 1 { + format!("{id} @{}", step["page"].as_str().unwrap_or_default()) + } else { + id.to_string() + }; let readability = lix(&prose_text(step, followup.as_ref())); let (mut yes, mut control) = ([0usize; N], [0usize; N]); for (i, persona) in personas.iter().enumerate() { let who = persona["text"].as_str().unwrap_or_default(); for (tally, gutted) in [(&mut yes, false), (&mut control, true)] { - let answers = understanding(&cmd, who, &page_text(step, page.as_ref(), followup.as_ref(), gutted), i)?; + let answers = understanding( + &cmd, + who, + &page_text(step, page.as_ref(), followup.as_ref(), gutted), + i, + )?; for (slot, answer) in tally.iter_mut().zip(answers) { *slot += answer as usize; } @@ -466,12 +669,25 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { } let n = personas.len(); let measured = |k: usize| k != 3 || followup.is_some(); - let share = |k: usize| if measured(k) { format!("{}/{n}", yes[k]) } else { "-".to_string() }; + let share = |k: usize| { + if measured(k) { + format!("{}/{n}", yes[k]) + } else { + "-".to_string() + } + }; // The twin has no headings, no descriptions, no cards and no // followup: every question must collapse on it, or that // question is agreeing with anything. - let control_ok: Vec = (0..N).map(|k| !measured(k) || control[k] * 2 < n.max(1)).collect(); - let failed: Vec<&str> = QUESTIONS.iter().zip(&control_ok).filter(|(_, ok)| !**ok).map(|((k, _), _)| *k).collect(); + let control_ok: Vec = (0..N) + .map(|k| !measured(k) || control[k] * 2 < n.max(1)) + .collect(); + let failed: Vec<&str> = QUESTIONS + .iter() + .zip(&control_ok) + .filter(|(_, ok)| !**ok) + .map(|((k, _), _)| *k) + .collect(); let (options, required) = page_load(step); println!( "{:<28} {:>6.0} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}", @@ -487,12 +703,24 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { control[0], control[1], control[2], - if measured(3) { control[3].to_string() } else { "-".to_string() }, - if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) } + if measured(3) { + control[3].to_string() + } else { + "-".to_string() + }, + if failed.is_empty() { + "ok".to_string() + } else { + format!("FAILED: {}", failed.join(", ")) + } ), format!( "{options} options, {required} required{}", - if options > MAX_OPTIONS || required > MAX_REQUIRED { " - SPLIT" } else { "" } + if options > MAX_OPTIONS || required > MAX_REQUIRED { + " - SPLIT" + } else { + "" + } ), ); pages.push(json!({ @@ -505,9 +733,15 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { "control_ok": failed.is_empty(), })); } - report.insert(id.to_string(), json!({ "steps": pages.len(), "pages": pages })); + report.insert( + id.to_string(), + json!({ "steps": pages.len(), "pages": pages }), + ); } - std::fs::write(out.join("rate.json"), serde_json::to_string_pretty(&report)?)?; + std::fs::write( + out.join("rate.json"), + serde_json::to_string_pretty(&report)?, + )?; Ok(()) } @@ -517,22 +751,49 @@ pub fn prefer(argv: &[String]) -> anyhow::Result<()> { let b = load(&flag(argv, "--b").ok_or_else(|| anyhow!(usage))?)?; let cmd = flag(argv, "--llm").unwrap_or_else(|| "claude -p".to_string()); - println!("{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}", "need", "A", "B", "no preference", "A", "B", "no preference", "LIX A->B"); + println!( + "{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}", + "need", "A", "B", "no preference", "A", "B", "no preference", "LIX A->B" + ); println!("{:<28} {:^26} {:^26}", "", "friendlier", "clearer"); for need_a in a["needs"].as_array().into_iter().flatten() { let id = need_a["id"].as_str().unwrap_or_default(); - let Some(need_b) = b["needs"].as_array().into_iter().flatten().find(|n| n["id"] == id) else { continue }; - let (Some(step_a), Some(step_b)) = (need_a["steps"].as_array().and_then(|s| s.first()), need_b["steps"].as_array().and_then(|s| s.first())) else { continue }; - let (text_a, text_b) = (page_text(step_a, None, None, false), page_text(step_b, None, None, false)); + let Some(need_b) = b["needs"] + .as_array() + .into_iter() + .flatten() + .find(|n| n["id"] == id) + else { + continue; + }; + let (Some(step_a), Some(step_b)) = ( + need_a["steps"].as_array().and_then(|s| s.first()), + need_b["steps"].as_array().and_then(|s| s.first()), + ) else { + continue; + }; + let (text_a, text_b) = ( + page_text(step_a, None, None, false), + page_text(step_b, None, None, false), + ); if text_a == text_b { continue; } // [friendlier, clearer] x [A, B, none] let mut wins = [[0usize; 3]; 2]; - for (i, persona) in a["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).enumerate() { + for (i, persona) in a["personas"] + .as_array() + .into_iter() + .flatten() + .filter(|p| p["need"] == id) + .enumerate() + { let who = persona["text"].as_str().unwrap_or_default(); let mut picks: [[&str; 2]; 2] = [["", ""], ["", ""]]; - for (order, (first, second)) in [(&text_a, &text_b), (&text_b, &text_a)].into_iter().enumerate() { + for (order, (first, second)) in [(&text_a, &text_b), (&text_b, &text_a)] + .into_iter() + .enumerate() + { let prompt = format!( "You are this person: {who}\n\nTwo versions of the same web page. They lead to the same thing; only the wording differs.\n\n=== VERSION 1 ===\n{first}\n=== VERSION 2 ===\n{second}\n\nAs yourself: which version makes you feel more welcome and at ease, and which one makes it clearer what to do?\nReply as JSON: {{\"friendlier\": 1 or 2, \"clearer\": 1 or 2}}" ); @@ -554,7 +815,13 @@ pub fn prefer(argv: &[String]) -> anyhow::Result<()> { } println!( "{:<28} {:>5} {:>5} {:>14} {:>5} {:>5} {:>14} {:>9}", - id, wins[0][0], wins[0][1], wins[0][2], wins[1][0], wins[1][1], wins[1][2], + id, + wins[0][0], + wins[0][1], + wins[0][2], + wins[1][0], + wins[1][1], + wins[1][2], format!("{:.0}->{:.0}", lix(&text_a), lix(&text_b)) ); } @@ -592,29 +859,46 @@ mod tests { {"name": "Get a price", "description": "For owners.", "consequence": ["Send it"], "encouragements": ["We answer within two days."], "features": [{"name": "Your brief", "requirements": [{"name": "email", "label": "Email"}]}]}, {"name": "Work with us", "description": "For crews."}]}); let rich = page_text(&step, Some(&repo_page), Some(&next), false); - assert!(rich.contains("Button: Send it") && rich.contains("We answer within two days.") && rich.contains("Asks for: Email")); + assert!( + rich.contains("Button: Send it") + && rich.contains("We answer within two days.") + && rich.contains("Asks for: Email") + ); assert!(!rich.contains("asks for: Email, The build")); let twin = page_text(&step, Some(&repo_page), Some(&next), true); assert!(!twin.contains("Send it") && !twin.contains("Your brief")); let twin = page_text(&step, None, Some(&next), true); assert!(twin.contains("What brings you here?") && twin.contains("Option 2")); assert!(!twin.contains("asks for")); - assert!(!twin.contains("Get a price") && !twin.contains("Email") && !twin.contains("project manager")); + assert!( + !twin.contains("Get a price") + && !twin.contains("Email") + && !twin.contains("project manager") + ); // Readability is measured on the sentences, not the headings. let prose = prose_text(&step, Some(&next)); - assert_eq!(prose, "What brings you here?\nFor owners.\nFor crews.\nA project manager reads it."); + assert_eq!( + prose, + "What brings you here?\nFor owners.\nFor crews.\nA project manager reads it." + ); } #[test] fn a_mail_is_read_as_it_arrives_and_the_invite_is_one_of_them() { - let got = as_received("Hei {name}, se {site}/decide/x?chain={chain}. {link} {ikke en nøkkel}", "Tomter Vel"); + let got = as_received( + "Hei {name}, se {site}/decide/x?chain={chain}. {link} {ikke en nøkkel}", + "Tomter Vel", + ); assert_eq!(got, "Hei [name], se https://example.site/decide/x?chain=8f3a1c. https://id.example.site/reset/8f3a1c {ikke en nøkkel}"); let dir = std::env::temp_dir().join(format!("portal-trainer-mails-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("aggregates.yaml"), "aggregates:\n - bucket: b\n initial: open\n states:\n open: { event: received, mail: { to: submitter, subject: Fått, body: \"Vi har det, {name}.\" } }\n").unwrap(); std::fs::write(dir.join("site.yaml"), "title: T\nmail: { from: x@y.no, invite: { subject: Konto, body: \"Hei {name}\\n{link}\" } }\n").unwrap(); let mails = mail_texts(&dir).unwrap(); - assert_eq!(mails.iter().map(|m| m.0.as_str()).collect::>(), ["b:open", "invite"]); + assert_eq!( + mails.iter().map(|m| m.0.as_str()).collect::>(), + ["b:open", "invite"] + ); assert_eq!(mails[0].2, "Vi har det, ."); std::fs::remove_dir_all(dir).ok(); } @@ -623,8 +907,16 @@ mod tests { fn the_followup_page_is_read_from_the_repo_by_following_the_action() { let dir = std::env::temp_dir().join(format!("portal-trainer-rate-{}", std::process::id())); std::fs::create_dir_all(dir.join("questions/received")).unwrap(); - std::fs::write(dir.join("questions/index.yaml"), "name: What?\nalternatives:\n - name: Get a price\n action: /received/price\n").unwrap(); - std::fs::write(dir.join("questions/received/price.yaml"), "name: What happens now?\ndescription: We read it.\nalternatives:\n - name: Back\n").unwrap(); + std::fs::write( + dir.join("questions/index.yaml"), + "name: What?\nalternatives:\n - name: Get a price\n action: /received/price\n", + ) + .unwrap(); + std::fs::write( + dir.join("questions/received/price.yaml"), + "name: What happens now?\ndescription: We read it.\nalternatives:\n - name: Back\n", + ) + .unwrap(); let step = json!({"page": "/", "alternative": "Get a price"}); let next = followup_page(Some(&dir), &step).unwrap(); assert_eq!(next["name"], "What happens now?"); diff --git a/src/trainer/report.rs b/src/trainer/report.rs index e096589..492bb46 100644 --- a/src/trainer/report.rs +++ b/src/trainer/report.rs @@ -47,19 +47,35 @@ struct Showing { /// Per bucket, per storyline index, how many arrived and how many /// ended in a state the need calls success (or, without `success`, /// any finished state). -fn showings(counts: &BTreeMap>>, needs: &[Need]) -> BTreeMap> { +fn showings( + counts: &BTreeMap>>, + needs: &[Need], +) -> BTreeMap> { let mut out = BTreeMap::new(); for (bucket, per_story) in counts { let well: Vec<&str> = needs .iter() .filter(|n| &n.lands_in == bucket) - .flat_map(|n| if n.success.is_empty() { n.done_when.iter() } else { n.success.iter() }) + .flat_map(|n| { + if n.success.is_empty() { + n.done_when.iter() + } else { + n.success.iter() + } + }) .map(String::as_str) .collect(); - let arity = per_story.keys().filter_map(|k| k.parse::().ok()).max().map(|m| m + 1).unwrap_or(1); + let arity = per_story + .keys() + .filter_map(|k| k.parse::().ok()) + .max() + .map(|m| m + 1) + .unwrap_or(1); let mut rows: Vec = (0..arity).map(|_| Showing::default()).collect(); for (story, states) in per_story { - let Ok(n) = story.parse::() else { continue }; + let Ok(n) = story.parse::() else { + continue; + }; for (state, count) in states { rows[n].arrived += count; if well.contains(&state.as_str()) { @@ -73,16 +89,32 @@ fn showings(counts: &BTreeMap>>, } 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 site = value("--site").ok_or_else(|| anyhow!("usage: portal-trainer report --site [--needs ]"))?; + let value = |flag: &str| { + argv.iter() + .position(|a| a == flag) + .and_then(|i| argv.get(i + 1)) + .cloned() + }; + let site = value("--site").ok_or_else(|| { + anyhow!("usage: portal-trainer report --site [--needs ]") + })?; let url = format!("{}/report", site.trim_end_matches('/')); - let fetched = std::process::Command::new("curl").args(["-sSf", "--max-time", "30", &url]).output().context("running curl")?; + let fetched = std::process::Command::new("curl") + .args(["-sSf", "--max-time", "30", &url]) + .output() + .context("running curl")?; if !fetched.status.success() { - bail!("could not read {url}: {}", String::from_utf8_lossy(&fetched.stderr).trim()); + bail!( + "could not read {url}: {}", + String::from_utf8_lossy(&fetched.stderr).trim() + ); } - let report: Report = serde_json::from_slice(&fetched.stdout).with_context(|| format!("reading {url}"))?; + let report: Report = + serde_json::from_slice(&fetched.stdout).with_context(|| format!("reading {url}"))?; let needs: Needs = match value("--needs") { - Some(path) => serde_yaml::from_str(&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?)?, + Some(path) => serde_yaml::from_str( + &std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?, + )?, None => Needs::default(), }; println!("{site}: {} storyline(s)", report.storylines); @@ -97,11 +129,26 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { for (bucket, per_story) in &rows { println!("\n{bucket}"); for (n, showing) in per_story.iter().enumerate() { - let rate = if showing.arrived > 0 { format!("{:.0}%", 100.0 * showing.ended_well as f64 / showing.arrived as f64) } else { "-".to_string() }; - println!(" storyline {n}: {} arrived, {} ended well ({rate})", showing.arrived, showing.ended_well); + let rate = if showing.arrived > 0 { + format!( + "{:.0}%", + 100.0 * showing.ended_well as f64 / showing.arrived as f64 + ) + } else { + "-".to_string() + }; + println!( + " storyline {n}: {} arrived, {} ended well ({rate})", + showing.arrived, showing.ended_well + ); } if per_story.len() > 1 { - let best = per_story.iter().enumerate().max_by_key(|(_, s)| (s.ended_well, s.arrived)).map(|(n, _)| n).unwrap_or(0); + let best = per_story + .iter() + .enumerate() + .max_by_key(|(_, s)| (s.ended_well, s.arrived)) + .map(|(n, _)| n) + .unwrap_or(0); println!(" most so far: storyline {best}"); } } @@ -116,12 +163,32 @@ mod tests { fn a_storyline_is_scored_on_what_arrived_and_what_ended_well() { let counts: BTreeMap>> = serde_json::from_str(r#"{"hazards": {"0": {"open": 3, "fixed": 4}, "1": {"open": 1, "fixed": 9, "dropped": 2}}}"#).unwrap(); - let needs = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec!["fixed".into()] }]; + let needs = vec![Need { + lands_in: "hazards".into(), + done_when: vec!["fixed".into(), "dropped".into()], + success: vec!["fixed".into()], + }]; let rows = showings(&counts, &needs); - assert_eq!(rows["hazards"][0], Showing { arrived: 7, ended_well: 4 }); - assert_eq!(rows["hazards"][1], Showing { arrived: 12, ended_well: 9 }); + assert_eq!( + rows["hazards"][0], + Showing { + arrived: 7, + ended_well: 4 + } + ); + assert_eq!( + rows["hazards"][1], + Showing { + arrived: 12, + ended_well: 9 + } + ); // Without a success list, any finished state counts. - let any = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec![] }]; + let any = vec![Need { + lands_in: "hazards".into(), + done_when: vec!["fixed".into(), "dropped".into()], + success: vec![], + }]; assert_eq!(showings(&counts, &any)["hazards"][1].ended_well, 11); } } diff --git a/src/trainer/score.rs b/src/trainer/score.rs index ce396ca..301a1f9 100644 --- a/src/trainer/score.rs +++ b/src/trainer/score.rs @@ -11,7 +11,12 @@ use std::path::{Path, PathBuf}; use std::process::Command; pub fn run(argv: &[String]) -> anyhow::Result<()> { - let flag = |name: &str| argv.iter().position(|a| a == name).and_then(|i| argv.get(i + 1)).cloned(); + let flag = |name: &str| { + argv.iter() + .position(|a| a == name) + .and_then(|i| argv.get(i + 1)) + .cloned() + }; let usage = "usage: portal-trainer score --site --out [--iris ] [--llm 'cmd']"; let site = PathBuf::from(flag("--site").ok_or_else(|| anyhow!(usage))?); let out = PathBuf::from(flag("--out").ok_or_else(|| anyhow!(usage))?); @@ -27,52 +32,102 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { let tasks_skim = out.join("tasks-skim.jsonl"); let model = out.join("model.json"); let mut log = String::new(); - log.push_str(&lint(&iris, &questions, &["--needs-tasks", &s(&tasks_full), "--needs-sim", &s(&model)])?); - log.push_str(&lint(&iris, &questions, &["--needs-tasks", &s(&tasks_skim), "--skim"])?); + log.push_str(&lint( + &iris, + &questions, + &["--needs-tasks", &s(&tasks_full), "--needs-sim", &s(&model)], + )?); + log.push_str(&lint( + &iris, + &questions, + &["--needs-tasks", &s(&tasks_skim), "--skim"], + )?); std::fs::write(out.join("iris.log"), &log)?; let mut summary = serde_json::Map::new(); for (mode, tasks, skim) in [("full", &tasks_full, false), ("skim", &tasks_skim, true)] { eprintln!("score: the visitor answers {mode}"); - let answers = crate::trainer::answer_blind(&cmd, &std::fs::read_to_string(tasks)?, Some(&site), 0)?; + let answers = + crate::trainer::answer_blind(&cmd, &std::fs::read_to_string(tasks)?, Some(&site), 0)?; let answers_path = out.join(format!("answers-{mode}.jsonl")); std::fs::write(&answers_path, &answers)?; let (tasks_arg, answers_arg) = (s(tasks), s(&answers_path)); - let mut args = vec!["--needs-tasks", tasks_arg.as_str(), "--needs-score", answers_arg.as_str()]; + let mut args = vec![ + "--needs-tasks", + tasks_arg.as_str(), + "--needs-score", + answers_arg.as_str(), + ]; if skim { args.push("--skim"); } let report = lint(&iris, &questions, &args)?; - println!("== {}", if skim { "headings only" } else { "with descriptions" }); + println!( + "== {}", + if skim { + "headings only" + } else { + "with descriptions" + } + ); let mut right = None; for line in report.lines() { - if line.starts_with("MISROUTE") || line.starts_with("HESITANT") || line.starts_with("UNANSWERED") { + if line.starts_with("MISROUTE") + || line.starts_with("HESITANT") + || line.starts_with("UNANSWERED") + { println!("{line}"); } if let Some(rest) = line.strip_prefix("wording: ") { // "wording: 19/27 routed right (70%)" - let mut parts = rest.split_whitespace().next().unwrap_or_default().split('/'); + let mut parts = rest + .split_whitespace() + .next() + .unwrap_or_default() + .split('/'); let a: usize = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0); let b: usize = parts.next().and_then(|x| x.parse().ok()).unwrap_or(0); right = Some((a, b)); println!("{line}"); } } - let (a, b) = right.ok_or_else(|| anyhow!("iris printed no wording score for {mode}; its output:\n{report}"))?; + let (a, b) = right.ok_or_else(|| { + anyhow!("iris printed no wording score for {mode}; its output:\n{report}") + })?; summary.insert(mode.to_string(), serde_json::json!({ "right": a, "of": b })); } println!("== rate"); let rated = out.join("rated"); - let rate_argv: Vec = ["rate", "--model", &s(&model), "--out", &s(&rated), "--repo", &s(&site), "--llm", &cmd].iter().map(|x| x.to_string()).collect(); + let rate_argv: Vec = [ + "rate", + "--model", + &s(&model), + "--out", + &s(&rated), + "--repo", + &s(&site), + "--llm", + &cmd, + ] + .iter() + .map(|x| x.to_string()) + .collect(); crate::trainer::rate::rate(&rate_argv)?; summary.insert("site".into(), serde_json::json!(s(&site))); summary.insert("visitor".into(), serde_json::json!(cmd)); - std::fs::write(out.join("summary.json"), serde_json::to_string_pretty(&serde_json::Value::Object(summary.clone()))?)?; + std::fs::write( + out.join("summary.json"), + serde_json::to_string_pretty(&serde_json::Value::Object(summary.clone()))?, + )?; println!( "== summary: with descriptions {} of {}, headings only {} of {} (rate.json in {})", - summary["full"]["right"], summary["full"]["of"], summary["skim"]["right"], summary["skim"]["of"], rated.display() + summary["full"]["right"], + summary["full"]["of"], + summary["skim"]["right"], + summary["skim"]["of"], + rated.display() ); Ok(()) } @@ -92,9 +147,17 @@ fn lint(iris: &str, questions: &Path, args: &[&str]) -> anyhow::Result { .args(args) .output() .with_context(|| format!("running {iris} (pass --iris if it is not on PATH)"))?; - let text = format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); if !output.status.success() { - bail!("{iris} check --path {} {} failed:\n{text}", questions.display(), args.join(" ")); + bail!( + "{iris} check --path {} {} failed:\n{text}", + questions.display(), + args.join(" ") + ); } Ok(text) } diff --git a/src/trainer/simulate.rs b/src/trainer/simulate.rs index 6d06558..c23b1b8 100644 --- a/src/trainer/simulate.rs +++ b/src/trainer/simulate.rs @@ -127,7 +127,12 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { continue; } let bucket = &model["buckets"][need["lands_in"].as_str().unwrap_or_default()]; - let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + let done: Vec<&str> = need["done_when"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); for (run, route) in all_routes(bucket, &done).into_iter().enumerate() { let mut states = vec![bucket["initial"].as_str().unwrap_or("open").to_string()]; states.extend(route.iter().map(|m| m.to.clone())); @@ -137,7 +142,10 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { run, target: states.last().cloned().unwrap_or_default(), record: BTreeMap::from([("note".to_string(), json!("dry run"))]), - pressed: route.iter().map(|m| format!("{} -> {}", m.from, m.label)).collect(), + pressed: route + .iter() + .map(|m| format!("{} -> {}", m.from, m.label)) + .collect(), moved_by: route.iter().map(|m| m.by.clone()).collect(), touches: route.len(), ask_backs: Vec::new(), @@ -148,9 +156,16 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { writeln!(cases_file, "{}", serde_json::to_string(&case)?)?; } } - std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?; + std::fs::write( + out.join("score.json"), + serde_json::to_string_pretty(&scores)?, + )?; let relays: usize = scores.values().map(|s| s.cases).sum(); - println!("dry run: {relays} route(s) across {} need(s) written to {}", scores.len(), out.join("cases.jsonl").display()); + println!( + "dry run: {relays} route(s) across {} need(s) written to {}", + scores.len(), + out.join("cases.jsonl").display() + ); return Ok(()); } @@ -180,7 +195,10 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { writeln!(cases_file, "{}", serde_json::to_string(&case)?)?; } } - std::fs::write(out.join("score.json"), serde_json::to_string_pretty(&scores)?)?; + std::fs::write( + out.join("score.json"), + serde_json::to_string_pretty(&scores)?, + )?; print_scores(&scores); Ok(()) } @@ -257,7 +275,8 @@ fn play( ); let reply = ask(visitor_cmd, &prompt, &seed(i * 10 + 1))?; if reply["abandon"] == true { - case.outcome = Outcome::Abandoned(reply["why"].as_str().unwrap_or_default().to_string()); + case.outcome = + Outcome::Abandoned(reply["why"].as_str().unwrap_or_default().to_string()); return Ok(case); } if let Some(values) = reply["values"].as_object() { @@ -277,9 +296,18 @@ fn play( // collected, could this role really do what the button claims? let bucket_name = need["lands_in"].as_str().unwrap_or_default(); let bucket = &model["buckets"][bucket_name]; - let done: Vec<&str> = need["done_when"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + let done: Vec<&str> = need["done_when"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); let initial = bucket["initial"].as_str().unwrap_or("open").to_string(); - let target = done.get(run % done.len().max(1)).copied().unwrap_or_default().to_string(); + let target = done + .get(run % done.len().max(1)) + .copied() + .unwrap_or_default() + .to_string(); case.target = target.clone(); case.states.push(initial.clone()); let Some(route) = route_to(bucket, &initial, &target) else { @@ -304,7 +332,11 @@ fn play( serde_json::to_string_pretty(&case.record)?, ); let reply = ask(handler_cmd, &prompt, &seed(100 + step))?; - let question = reply["need_to_know"].as_str().unwrap_or_default().trim().to_string(); + let question = reply["need_to_know"] + .as_str() + .unwrap_or_default() + .trim() + .to_string(); if reply["can_act"] == false && !question.is_empty() { let answer = ask( visitor_cmd, @@ -314,7 +346,10 @@ fn play( &seed(200 + step), )?; let n = case.ask_backs.len() + 1; - case.record.insert(format!("follow_up_{n}"), json!({ "asked": question, "answered": answer["answer"] })); + case.record.insert( + format!("follow_up_{n}"), + json!({ "asked": question, "answered": answer["answer"] }), + ); case.ask_backs.push(AskBack { state: from.clone(), button: label.clone(), @@ -343,21 +378,47 @@ pub(crate) struct Move { /// desk, and the submitter's own link. Roles overlap on one aggregate, /// so a route is a relay, not one desk's work. pub(crate) fn all_moves(bucket: &Value) -> Vec { - let legal = |from: &str, to: &str| bucket["transitions"][from].as_array().is_some_and(|a| a.iter().any(|x| x == to)); + let legal = |from: &str, to: &str| { + bucket["transitions"][from] + .as_array() + .is_some_and(|a| a.iter().any(|x| x == to)) + }; let mut moves: Vec = Vec::new(); for desk in bucket["desks"].as_array().into_iter().flatten() { for m in desk["moves"].as_array().into_iter().flatten() { - let (Some(from), Some(label), Some(to)) = (m["from"].as_str(), m["label"].as_str(), m["to"].as_str()) else { continue }; + let (Some(from), Some(label), Some(to)) = + (m["from"].as_str(), m["label"].as_str(), m["to"].as_str()) + else { + continue; + }; if legal(from, to) && !moves.iter().any(|x| x.from == from && x.to == to) { - moves.push(Move { from: from.into(), label: label.into(), to: to.into(), by: desk["group"].as_str().unwrap_or("ungated").into() }); + moves.push(Move { + from: from.into(), + label: label.into(), + to: to.into(), + by: desk["group"].as_str().unwrap_or("ungated").into(), + }); } } } - let states: Vec = bucket["transitions"].as_object().map(|t| t.keys().cloned().collect()).unwrap_or_default(); - for to in bucket["self_moves"].as_array().into_iter().flatten().filter_map(Value::as_str) { + let states: Vec = bucket["transitions"] + .as_object() + .map(|t| t.keys().cloned().collect()) + .unwrap_or_default(); + for to in bucket["self_moves"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + { for from in &states { if legal(from, to) && !moves.iter().any(|x| &x.from == from && x.to == to) { - moves.push(Move { from: from.clone(), label: "their own link".into(), to: to.into(), by: SUBMITTER.into() }); + moves.push(Move { + from: from.clone(), + label: "their own link".into(), + to: to.into(), + by: SUBMITTER.into(), + }); } } } @@ -388,7 +449,13 @@ fn route_to(bucket: &Value, from: &str, to: &str) -> Option> { /// dry run walks, so that each button of each role is pressed at least /// once against the real engine. pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec> { - fn walk(state: &str, done: &[&str], moves: &[Move], path: &mut Vec, out: &mut Vec>) { + fn walk( + state: &str, + done: &[&str], + moves: &[Move], + path: &mut Vec, + out: &mut Vec>, + ) { if done.contains(&state) { out.push(path.clone()); return; @@ -403,7 +470,13 @@ pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec> { } } let mut out = Vec::new(); - walk(bucket["initial"].as_str().unwrap_or("open"), done, &all_moves(bucket), &mut Vec::new(), &mut out); + walk( + bucket["initial"].as_str().unwrap_or("open"), + done, + &all_moves(bucket), + &mut Vec::new(), + &mut out, + ); out } @@ -424,7 +497,11 @@ pub(crate) fn resolve_choice(names: &[&str], raw: &str) -> String { /// be (`FORMAT=json`, honoured by engines/ollama.sh) and the first /// object in the reply is taken either way. pub(crate) fn ask(cmd: &str, prompt: &str, seed: &str) -> anyhow::Result { - let reply = llm_with(cmd, prompt, &[("FORMAT", "json"), ("SEED", seed), ("TEMPERATURE", "0.7")])?; + let reply = llm_with( + cmd, + prompt, + &[("FORMAT", "json"), ("SEED", seed), ("TEMPERATURE", "0.7")], + )?; Ok(first_object(&reply).unwrap_or(Value::Null)) } @@ -462,7 +539,11 @@ fn tally(score: &mut NeedScore, case: &Case) { score.buttons.entry(button.clone()).or_default().0 += 1; } for a in &case.ask_backs { - score.buttons.entry(format!("{} -> {}", a.state, a.button)).or_default().1 += 1; + score + .buttons + .entry(format!("{} -> {}", a.state, a.button)) + .or_default() + .1 += 1; } match &case.outcome { Outcome::Finished(_) => score.finished += 1, @@ -476,7 +557,9 @@ fn tally(score: &mut NeedScore, case: &Case) { } Outcome::Abandoned(why) => { score.abandoned += 1; - score.abandon_reasons.push(format!("{}: {why}", case.persona)); + score + .abandon_reasons + .push(format!("{}: {why}", case.persona)); } } } @@ -501,7 +584,15 @@ fn per_case(n: usize, s: &NeedScore) -> String { fn print_scores(scores: &BTreeMap) { println!( "\n{:<28} {:>5} {:>9} {:>9} {:>10} {:>9} {:>8} {:>10} {:>13}", - "need", "cases", "finished", "misroute", "abandoned", "no route", "touches", "ask-backs", "unanswerable" + "need", + "cases", + "finished", + "misroute", + "abandoned", + "no route", + "touches", + "ask-backs", + "unanswerable" ); for (need, s) in scores { println!( @@ -529,7 +620,11 @@ fn print_scores(scores: &BTreeMap) { " {need} [{} / {}]{} {}", a.state, a.button, - if a.answerable { "" } else { " (visitor did not know)" }, + if a.answerable { + "" + } else { + " (visitor did not know)" + }, a.question ); } @@ -550,10 +645,16 @@ fn print_scores(scores: &BTreeMap) { pub fn compare(argv: &[String]) -> anyhow::Result<()> { let (a, b) = match (argv.get(1), argv.get(2)) { (Some(a), Some(b)) => (a, b), - _ => return Err(anyhow!("usage: portal-trainer compare ")), + _ => { + return Err(anyhow!( + "usage: portal-trainer compare " + )) + } }; let load = |p: &String| -> anyhow::Result> { - Ok(serde_json::from_str(&std::fs::read_to_string(p).with_context(|| format!("reading {p}"))?)?) + Ok(serde_json::from_str( + &std::fs::read_to_string(p).with_context(|| format!("reading {p}"))?, + )?) }; let (a, b) = (load(a)?, load(b)?); println!( @@ -565,11 +666,27 @@ pub fn compare(argv: &[String]) -> anyhow::Result<()> { println!( "{:<28} {:>14} {:>14} {:>14} {:>16} {:>16}", need, - format!("{} -> {}", pct(sa.misrouted, sa.cases), pct(sb.misrouted, sb.cases)), - format!("{} -> {}", pct(sa.abandoned, sa.cases), pct(sb.abandoned, sb.cases)), + format!( + "{} -> {}", + pct(sa.misrouted, sa.cases), + pct(sb.misrouted, sb.cases) + ), + format!( + "{} -> {}", + pct(sa.abandoned, sa.cases), + pct(sb.abandoned, sb.cases) + ), format!("{} -> {}", pct(sa.stuck, sa.cases), pct(sb.stuck, sb.cases)), - format!("{} -> {}", per_case(sa.touches, sa), per_case(sb.touches, sb)), - format!("{} -> {}", per_case(sa.ask_backs, sa), per_case(sb.ask_backs, sb)), + format!( + "{} -> {}", + per_case(sa.touches, sa), + per_case(sb.touches, sb) + ), + format!( + "{} -> {}", + per_case(sa.ask_backs, sa), + per_case(sb.ask_backs, sb) + ), ); } Ok(()) @@ -589,8 +706,15 @@ mod tests { #[test] fn a_choice_is_matched_by_its_heading_however_the_model_dresses_it() { - let names = ["Ask about working", "Ask about working with us", "Get a price"]; - assert_eq!(resolve_choice(&names, "Ask about working with us: For carpenters who..."), "Ask about working with us"); + let names = [ + "Ask about working", + "Ask about working with us", + "Get a price", + ]; + assert_eq!( + resolve_choice(&names, "Ask about working with us: For carpenters who..."), + "Ask about working with us" + ); assert_eq!(resolve_choice(&names, " get a PRICE "), "Get a price"); assert_eq!(resolve_choice(&names, "Something else"), "Something else"); } @@ -620,7 +744,10 @@ mod tests { // Losing a quoted job is the other group's button, so the way // from open to that ending is a relay: two roles, one case. let route = route_to(&bucket(), "surveyed", "lost").unwrap(); - let relay: Vec<(&str, &str)> = route.iter().map(|m| (m.label.as_str(), m.by.as_str())).collect(); + let relay: Vec<(&str, &str)> = route + .iter() + .map(|m| (m.label.as_str(), m.by.as_str())) + .collect(); assert_eq!(relay, [("Quote sent", "pm"), ("Theirs", "other")]); assert_eq!(route_to(&bucket(), "open", "open").unwrap().len(), 0); assert!(route_to(&bucket(), "won", "open").is_none()); @@ -630,7 +757,10 @@ mod tests { fn the_submitters_own_link_is_a_move_like_any_other() { let mut b = bucket(); b["self_moves"] = json!(["lost"]); - let theirs: Vec = all_moves(&b).into_iter().filter(|m| m.by == SUBMITTER).collect(); + let theirs: Vec = all_moves(&b) + .into_iter() + .filter(|m| m.by == SUBMITTER) + .collect(); // Only where the machine allows it and no desk already holds it: // open->lost is the project manager's and quoted->lost the other // group's, which leaves surveyed->lost to the link. @@ -641,7 +771,10 @@ mod tests { #[test] fn a_dry_run_walks_every_route_to_every_ending() { let routes = all_routes(&bucket(), &["won", "lost"]); - let ends: Vec = routes.iter().map(|r| format!("{}:{}", r.len(), r.last().unwrap().to)).collect(); + let ends: Vec = routes + .iter() + .map(|r| format!("{}:{}", r.len(), r.last().unwrap().to)) + .collect(); // open->lost; open->surveyed->quoted->won; and the relay // open->surveyed->quoted->lost, closed by the other group. assert_eq!(ends.len(), 3, "{ends:?}"); @@ -664,15 +797,28 @@ mod tests { moved_by: vec!["pm".into(), "pm".into()], touches: 3, ask_backs: vec![ - AskBack { state: "open".into(), button: "Looked".into(), question: "Which floor?".into(), answerable: true }, - AskBack { state: "surveyed".into(), button: "Quote sent".into(), question: "Load-bearing?".into(), answerable: false }, + AskBack { + state: "open".into(), + button: "Looked".into(), + question: "Which floor?".into(), + answerable: true, + }, + AskBack { + state: "surveyed".into(), + button: "Quote sent".into(), + question: "Load-bearing?".into(), + answerable: false, + }, ], outcome, }; tally(&mut score, &base(Outcome::Finished("won".into()))); tally(&mut score, &base(Outcome::Stuck("no route".into()))); tally(&mut score, &base(Outcome::Misrouted("Wrong door".into()))); - assert_eq!((score.cases, score.finished, score.stuck, score.misrouted), (3, 1, 1, 1)); + assert_eq!( + (score.cases, score.finished, score.stuck, score.misrouted), + (3, 1, 1, 1) + ); assert_eq!((score.ask_backs, score.unanswerable), (6, 3)); } } diff --git a/src/upload.rs b/src/upload.rs index 803381f..7768935 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -130,13 +130,18 @@ pub async fn upload( } } - let (filename, content_type, bytes) = - file.ok_or((StatusCode::BAD_REQUEST, "no file field in request".to_string()))?; + let (filename, content_type, bytes) = file.ok_or(( + StatusCode::BAD_REQUEST, + "no file field in request".to_string(), + ))?; // Nothing has been written yet. Everything below decides whether // anything will be. if !may_upload(&state, &session, &question_id, &alternative).await { - return Err((StatusCode::FORBIDDEN, "not authorized to upload here".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "not authorized to upload here".to_string(), + )); } let key = format!("{}-{filename}", uuid::Uuid::new_v4()); @@ -235,7 +240,10 @@ alternatives: #[test] fn an_upload_must_name_a_page_that_asked_for_a_file() { let qs = questions(PAGE); - assert_eq!(page_taking_files(&qs, "/", "Send drawings"), Some("/".to_string())); + assert_eq!( + page_taking_files(&qs, "/", "Send drawings"), + Some("/".to_string()) + ); // An alternative on the same page that takes no file. assert_eq!(page_taking_files(&qs, "/", "Just ask"), None); // Names that are not there at all. @@ -246,7 +254,10 @@ alternatives: #[test] fn a_closed_alternative_takes_nothing() { - let qs = questions(&PAGE.replace(" record_as: enquiries\n", " record_as: enquiries\n disabled: true\n")); + let qs = questions(&PAGE.replace( + " record_as: enquiries\n", + " record_as: enquiries\n disabled: true\n", + )); assert_eq!(page_taking_files(&qs, "/", "Send drawings"), None); } } @@ -277,15 +288,24 @@ pub async fn attachment( // cannot be used to learn which buckets, records or fields exist. let missing = || (StatusCode::NOT_FOUND, "not found".to_string()); - let store = state.jetstream.get_key_value(&bucket).await.map_err(|_| missing())?; - let bytes = store.get(&record).await.map_err(|_| missing())?.ok_or_else(missing)?; + let store = state + .jetstream + .get_key_value(&bucket) + .await + .map_err(|_| missing())?; + let bytes = store + .get(&record) + .await + .map_err(|_| missing())? + .ok_or_else(missing)?; let as_listed: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| missing())?; // Through a list the caller may read, and one that shows this // record: a list of "my cases" opens my files and nobody else's. if !may_read_record(&state, &session, &bucket, &as_listed).await { return Err(missing()); } - let answer: crate::answers::Answer = serde_json::from_value(as_listed).map_err(|_| missing())?; + let answer: crate::answers::Answer = + serde_json::from_value(as_listed).map_err(|_| missing())?; let key = answer .responses .get(&field) @@ -310,13 +330,22 @@ pub async fn attachment( .body .collect() .await - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("reading the file failed: {e}")))? + .map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + format!("reading the file failed: {e}"), + ) + })? .into_bytes(); // The stored key is `-`; the person who gets // the file should see the name they sent, not the uuid. let filename = key.split_once('-').map(|(_, rest)| rest).unwrap_or(key); - let filename = filename.rsplit('/').next().unwrap_or(filename).replace(['"', '\\'], ""); + let filename = filename + .rsplit('/') + .next() + .unwrap_or(filename) + .replace(['"', '\\'], ""); Ok(( [ (axum::http::header::CONTENT_TYPE, content_type), @@ -349,7 +378,10 @@ async fn may_read_record( question.alternatives.iter().any(|alternative| { alternative.features.iter().any(|feature| { feature.resource.as_ref().and_then(|r| r.bucket()) == Some(bucket) - && feature.resource.as_ref().is_some_and(|r| crate::resource::matches_filter(r, record, ¶ms)) + && feature + .resource + .as_ref() + .is_some_and(|r| crate::resource::matches_filter(r, record, ¶ms)) && access.allows( &principal, crate::access::Action::Read, diff --git a/tests/jetstream.rs b/tests/jetstream.rs index daa9aa7..794da41 100644 --- a/tests/jetstream.rs +++ b/tests/jetstream.rs @@ -34,27 +34,51 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en let now = chrono::Utc::now().timestamp_millis(); // The submission: a KV record in the initial state, plus the first event. - store_answer(&js, &schemas, &bucket, "rec-1".into(), "/", "Hire us", &json!({"email": "a@b.example"}), now, 0) - .await - .expect("store"); + store_answer( + &js, + &schemas, + &bucket, + "rec-1".into(), + "/", + "Hire us", + &json!({"email": "a@b.example"}), + now, + 0, + ) + .await + .expect("store"); let store = js.get_key_value(&bucket).await.expect("bucket exists"); - let stored: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap(); + let stored: Answer = + serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap(); assert_eq!(stored.state, "open"); assert_eq!(stored.responses["email"], "a@b.example"); // A desk move the machine allows. - let moved = aggregates::transition(&js, schema, "rec-1", "quoted", json!({"by": "test"}), now + 1) - .await - .expect("open -> quoted is declared"); + let moved = aggregates::transition( + &js, + schema, + "rec-1", + "quoted", + json!({"by": "test"}), + now + 1, + ) + .await + .expect("open -> quoted is declared"); assert_eq!(moved.state, "quoted"); // Moves the machine does not allow are refused, whoever asks. let back = aggregates::transition(&js, schema, "rec-1", "open", json!({}), now + 2).await; - assert!(matches!(back, Err(TransitionError::DisallowedTransition { .. })), "{back:?}"); + assert!( + matches!(back, Err(TransitionError::DisallowedTransition { .. })), + "{back:?}" + ); let skip = aggregates::transition(&js, schema, "rec-1", "won", json!({}), now + 2).await; assert!(skip.is_ok(), "quoted -> won is declared: {skip:?}"); let nobody = aggregates::transition(&js, schema, "rec-9", "quoted", json!({}), now + 3).await; - assert!(matches!(nobody, Err(TransitionError::UnknownAggregate)), "{nobody:?}"); + assert!( + matches!(nobody, Err(TransitionError::UnknownAggregate)), + "{nobody:?}" + ); // The record reads back from its own event log, in the state the // accepted moves left it, with a sequence that moved forward. @@ -67,7 +91,8 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en // A reply to the mail this record was sent becomes a note on it, // through the same store the desks read - and moves nothing: the // record is still where the desk left it. - let mut record: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap(); + let mut record: Answer = + serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap(); let state_before_the_note = record.state.clone(); let reply = portal::mail::Incoming { v: portal::mail::CONTRACT, @@ -81,13 +106,20 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en }; let note = portal::mail::note_for(&reply, &record).expect("the address matches the record"); record.notes.push(note); - store.put("rec-1", serde_json::to_vec(&record).unwrap().into()).await.expect("note stored"); + store + .put("rec-1", serde_json::to_vec(&record).unwrap().into()) + .await + .expect("note stored"); - let read_back: Answer = serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap(); + let read_back: Answer = + serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap(); assert_eq!(read_back.notes.len(), 1); assert_eq!(read_back.notes[0].text, "Thursday suits us."); assert_eq!(read_back.notes[0].from, "a@b.example"); - assert_eq!(read_back.state, state_before_the_note, "a reply is a note, never a decision"); + assert_eq!( + read_back.state, state_before_the_note, + "a reply is a note, never a decision" + ); // The same reply read twice adds nothing. assert!(portal::mail::note_for(&reply, &read_back).is_err()); // And the event log is untouched by it. @@ -122,17 +154,39 @@ async fn a_session_survives_the_process_that_made_it() { // A second store over the same JetStream is what the next boot of // the binary has: nothing carried over in memory. - let next_boot = portal::sessions::NatsStore::open(&js).await.expect("reopen"); - let found = next_boot.load(&record.id).await.expect("load").expect("still there"); + let next_boot = portal::sessions::NatsStore::open(&js) + .await + .expect("reopen"); + let found = next_boot + .load(&record.id) + .await + .expect("load") + .expect("still there"); assert_eq!(found.data.get("who"), Some(&json!("ingrid"))); // `create` never writes over somebody else's session: asked to // create one whose id is taken, it takes a new id instead. - let mut collide = Record { id: record.id, data: Default::default(), expiry_date: record.expiry_date }; - next_boot.create(&mut collide).await.expect("create beside it"); - assert_ne!(collide.id, record.id, "an id already in use must not be reused"); + let mut collide = Record { + id: record.id, + data: Default::default(), + expiry_date: record.expiry_date, + }; + next_boot + .create(&mut collide) + .await + .expect("create beside it"); + assert_ne!( + collide.id, record.id, + "an id already in use must not be reused" + ); assert_eq!( - next_boot.load(&record.id).await.expect("load").expect("original intact").data.get("who"), + next_boot + .load(&record.id) + .await + .expect("load") + .expect("original intact") + .data + .get("who"), Some(&json!("ingrid")), "the original session must be untouched" ); @@ -146,11 +200,19 @@ async fn a_session_survives_the_process_that_made_it() { - tower_sessions::cookie::time::Duration::minutes(1), }; next_boot.save(&stale).await.expect("save stale"); - assert!(next_boot.load(&stale.id).await.expect("load stale").is_none()); + assert!(next_boot + .load(&stale.id) + .await + .expect("load stale") + .is_none()); // Signing out leaves nothing to read back. next_boot.delete(&record.id).await.expect("delete"); - assert!(next_boot.load(&record.id).await.expect("load after delete").is_none()); + assert!(next_boot + .load(&record.id) + .await + .expect("load after delete") + .is_none()); next_boot.delete(&collide.id).await.ok(); } @@ -175,12 +237,20 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back let questions = std::collections::HashMap::new(); let access = portal::access::Policy::from_content(&questions, &aggregates); let state = portal::server::AppState { - leptos_options: leptos::config::LeptosOptions::builder().output_name("test").build(), + leptos_options: leptos::config::LeptosOptions::builder() + .output_name("test") + .build(), nats, jetstream: js, - questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(questions))), - aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(aggregates))), - site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(portal::content::Storylines::one(Default::default()))), + questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee( + portal::content::Storylines::one(questions), + )), + aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee( + portal::content::Storylines::one(aggregates), + )), + site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee( + portal::content::Storylines::one(Default::default()), + )), access: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(access)), gitea_base: String::new(), content_raw_base: String::new(), @@ -198,17 +268,37 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back // A desk invites Kari by phone number; a decision grants her a // second group by address. Both are invitations until she is seen. - let by_phone = Identity { sub: None, phone: Some("+4791234567".into()), email: None }; - let by_mail = Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) }; - let first = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant"); + let by_phone = Identity { + sub: None, + phone: Some("+4791234567".into()), + email: None, + }; + let by_mail = Identity { + sub: None, + phone: None, + email: Some("kari@vel.no".into()), + }; + let first = memberships::grant(&state, styret, "Kari", &by_phone, "desk") + .await + .expect("grant"); assert!(first.created && first.reset_link.is_none()); - let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant again"); - assert!(!again.created, "a second invite into the same group finds the first"); - memberships::grant(&state, medlemmer, "Kari", &by_mail, "husstander:abc").await.expect("grant by mail"); + let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk") + .await + .expect("grant again"); + assert!( + !again.created, + "a second invite into the same group finds the first" + ); + memberships::grant(&state, medlemmer, "Kari", &by_mail, "husstander:abc") + .await + .expect("grant by mail"); let records = memberships::all(&state).await; assert_eq!(records.len(), 2); assert!(records.iter().all(|r| r.state == INVITED)); - assert!(memberships::groups_of(&records, &by_phone).is_empty(), "an invitation is not yet a group"); + assert!( + memberships::groups_of(&records, &by_phone).is_empty(), + "an invitation is not yet a group" + ); // She signs in: the provider knows her subject, phone and address. let kari = portal::auth::User { @@ -233,18 +323,47 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back } // A desk takes her out of styret; the record stays, as removed. - assert!(memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke")); - assert!(!memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke twice"), "already out"); + assert!(memberships::revoke(&state, styret, &by_phone, "ola") + .await + .expect("revoke")); + assert!( + !memberships::revoke(&state, styret, &by_phone, "ola") + .await + .expect("revoke twice"), + "already out" + ); let records = memberships::all(&state).await; - let styret_record = records.iter().find(|r| memberships::group_of(r) == styret).unwrap(); + let styret_record = records + .iter() + .find(|r| memberships::group_of(r) == styret) + .unwrap(); assert_eq!(styret_record.state, REMOVED); - assert_eq!(memberships::groups_of(&records, &Identity { sub: Some("vipps-abc".into()), phone: None, email: None }), vec![medlemmer]); + assert_eq!( + memberships::groups_of( + &records, + &Identity { + sub: Some("vipps-abc".into()), + phone: None, + email: None + } + ), + vec![medlemmer] + ); // Invited again: the same record, invited, and hers at the next sign-in. - let back = memberships::grant(&state, styret, "Kari", &by_mail, "desk").await.expect("re-grant"); + let back = memberships::grant(&state, styret, "Kari", &by_mail, "desk") + .await + .expect("re-grant"); assert!(back.created); let records = memberships::all(&state).await; - assert_eq!(records.iter().find(|r| memberships::group_of(r) == styret).unwrap().state, INVITED); + assert_eq!( + records + .iter() + .find(|r| memberships::group_of(r) == styret) + .unwrap() + .state, + INVITED + ); let groups = memberships::on_signed_in(&state, &kari).await; assert!(groups.contains(&styret.to_string())); } @@ -265,15 +384,74 @@ async fn a_card_of_ones_own_is_replaced_not_added_to_even_on_a_double_click() { // Kari's first card, then two sends of her second pressed at once, // and Ola's card beside them. - store_answer(&js, &schemas, &bucket, "k1".into(), "/kontor/ansatt", "a", &card("Kari", "kari"), now, 0).await.unwrap(); - store_answer(&js, &schemas, &bucket, "o1".into(), "/kontor/ansatt", "a", &card("Ola", "ola"), now + 1, 0).await.unwrap(); - store_answer(&js, &schemas, &bucket, "k2".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap(); - store_answer(&js, &schemas, &bucket, "k3".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap(); + store_answer( + &js, + &schemas, + &bucket, + "k1".into(), + "/kontor/ansatt", + "a", + &card("Kari", "kari"), + now, + 0, + ) + .await + .unwrap(); + store_answer( + &js, + &schemas, + &bucket, + "o1".into(), + "/kontor/ansatt", + "a", + &card("Ola", "ola"), + now + 1, + 0, + ) + .await + .unwrap(); + store_answer( + &js, + &schemas, + &bucket, + "k2".into(), + "/kontor/ansatt", + "a", + &card("Kari N.", "kari"), + now + 2, + 0, + ) + .await + .unwrap(); + store_answer( + &js, + &schemas, + &bucket, + "k3".into(), + "/kontor/ansatt", + "a", + &card("Kari N.", "kari"), + now + 2, + 0, + ) + .await + .unwrap(); // Both racing sends settle after storing; both must keep the same one. - let (a, b) = tokio::join!(keep_newest_own(&js, &bucket, "open", "kari"), keep_newest_own(&js, &bucket, "open", "kari")); + let (a, b) = tokio::join!( + keep_newest_own(&js, &bucket, "open", "kari"), + keep_newest_own(&js, &bucket, "open", "kari") + ); let _ = (a, b); let kari = own_records(&js, &bucket, "kari").await; - assert_eq!(kari.iter().map(|a| a.id.as_str()).collect::>(), vec!["k3"], "one card, the newest"); - assert_eq!(own_records(&js, &bucket, "ola").await.len(), 1, "nobody else's card is touched"); + assert_eq!( + kari.iter().map(|a| a.id.as_str()).collect::>(), + vec!["k3"], + "one card, the newest" + ); + assert_eq!( + own_records(&js, &bucket, "ola").await.len(), + 1, + "nobody else's card is touched" + ); }