Files
portal/src/content/mod.rs
T
Bendik Aagaard LynghaugandClaude Fable 5.1 fc998c8592
Test / test (pull_request) Successful in 56s
One record answers another, from a list
Three small things that together make a board work: a link on every
listed record to a page about it (ResourceSpec.link, {id} and {field}
in the address); a state mail whose `to` is a field of the record this
one belongs to ({notice.email}), with that record's fields in the
text, and a lint that the form carries the record field it reaches
through; and value: "{user.email}" filled from the session and hidden,
so someone the site knows is not asked who they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 06:47:36 +02:00

1965 lines
82 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Content: the YAML a site is made of, loaded from its Gitea repo,
//! resolved into a router, validated, and rendered. The types and the
//! resolution rules live here; the rest is split by concern and
//! re-exported, so every path stays `content::x`:
//!
//! - `gitea`: loading and the reload watcher;
//! - `validate`: the checks a repo must pass before it serves;
//! - `markdown`: inline markdown for descriptions;
//! - `handlers`: the Axum routes that expose content and buckets.
use serde::{Deserialize, Serialize};
// Loading is server-only; the rest is shared with the browser build.
#[cfg(feature = "ssr")]
mod gitea;
mod handlers;
mod markdown;
mod validate;
#[cfg(feature = "ssr")]
pub use gitea::*;
pub use handlers::*;
pub use markdown::*;
pub use validate::*;
/// One page: a prompt plus the paths on from it. The `questions/`
/// directory tree IS the URL tree (`index.yaml` names its directory,
/// `develop/proposal.yaml` serves `/develop/proposal`), so `id` is
/// derived from the file's path - declaring it explicitly still works
/// (and wins, with a logged warning when it disagrees) but is only
/// needed by legacy content. Loaded from plain YAML files in a content
/// directory kept in its own git repo - editing content is a
/// content-repo commit, not a Rust rebuild.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Question {
/// The URL path this page is served at. Derived from the file path
/// when absent. A `[name]` segment (from a `[name].yaml` file)
/// makes this a dynamic page: `/review/[record]` serves any
/// `/review/<value>`, with the value substituted into `{record}`
/// placeholders in the page's resource keys (see
/// `resolve_question`).
#[serde(default)]
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
/// Kanidm group required to view/submit this question - `None` means
/// open to anyone. Content-driven on purpose: a gated page like
/// "/review" is just a Question with this set, not a bespoke Rust
/// route. Inherited from the nearest ancestor `_section.yaml` when
/// not set on the question itself.
#[serde(default)]
pub qualifies: Option<String>,
/// Question id (relative refs resolve against this file's
/// directory) the visitor must have answered - their `?chain=`
/// lineage's tip - to see this page. The provenance counterpart to
/// `qualifies`' identity check. Inherited from `_section.yaml`
/// like `qualifies`.
#[serde(default)]
pub requires_chain: Option<String>,
#[serde(default)]
pub alternatives: Vec<Alternative>,
/// Who to contact if a visitor gets stuck - rendered as a small line
/// on the page. Inherited from `_section.yaml` when not set.
#[serde(default)]
pub responsible: Option<Responsible>,
/// A page that only makes sense after answering something (the
/// post-submission pages) - kept out of the question nav until the
/// visitor has earned it: their verified `?chain=` answered the
/// alternative that leads there, or it hangs off an earned page by
/// a link that records nothing (see `earned_followups`). Nothing
/// behind an answer they have not given is offered. Unset means inferred
/// from the file's place in the tree: files nested in a
/// subdirectory are followups unless they're the directory's
/// `index.yaml`; top-level files keep the historical default
/// (not a followup).
#[serde(default)]
pub followup: Option<bool>,
/// This page announces something with a time window. While the
/// window is open the page is announced in the header (see
/// `app::Announcements`) instead of listed in the footer nav; once
/// it closes the page behaves like a `followup` (visible only to
/// visitors carrying an answer chain) and a "post what happened"
/// task lands in the `portal_events` desk (see `announce.rs`).
#[serde(default)]
pub event: Option<EventConfig>,
/// Where this question is about, when it is about somewhere: a
/// still map under the question, drawn from this origin. `event`
/// makes a page an occasion with a time; this makes one a place,
/// and a `type: location` field on the page opens its map here
/// rather than on the whole world.
#[serde(default)]
pub place: Option<Place>,
}
/// A point a page is about. `zoom` is a map zoom level, 0 (the globe)
/// to 22 (a doorstep); 15 shows a street and its neighbours. `name` is
/// what to call it in words, for anyone who cannot see the map.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Place {
pub lat: f64,
pub lon: f64,
#[serde(default = "default_zoom")]
pub zoom: f64,
#[serde(default)]
pub name: Option<String>,
}
fn default_zoom() -> f64 {
15.0
}
impl Place {
/// `lat,lon,zoom`, the form a `type: location` field takes as its
/// opening view.
pub fn as_value(&self) -> String {
format!("{},{},{}", self.lat, self.lon, self.zoom)
}
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));
}
if !(0.0..=22.0).contains(&self.zoom) {
return Err(format!("zoom {} is outside 0 to 22", self.zoom));
}
if self.name.as_ref().is_some_and(|n| n.trim().is_empty()) {
return Err("place.name is empty; leave it out or say where this is".to_string());
}
Ok(())
}
}
/// A page's announcement window. `starts` is RFC 3339 with an offset
/// (`2026-09-12T18:00:00+02:00`); `duration` is a plain span like
/// `3h`, `90m`, `2d`, `1d 6h`. Optional `place` is shown verbatim.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EventConfig {
pub starts: String,
pub duration: String,
#[serde(default)]
pub place: Option<String>,
}
/// `3h`, `90m`, `2d`, `1d 6h 30m` -> milliseconds. Whole units only;
/// the smallest is a minute.
pub fn parse_duration_ms(text: &str) -> Result<i64, String> {
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 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)")),
};
total += n * per;
seen = true;
}
if !seen || total <= 0 {
return Err(format!("bad duration {text:?}: must be positive"));
}
Ok(total)
}
#[cfg(feature = "ssr")]
impl EventConfig {
/// `(starts_ms, ends_ms)` as Unix milliseconds.
pub fn window_ms(&self) -> Result<(i64, i64), String> {
let starts = chrono::DateTime::parse_from_rfc3339(&self.starts)
.map_err(|e| format!("bad starts {:?}: {e} (RFC 3339 with offset)", self.starts))?
.timestamp_millis();
Ok((starts, starts + parse_duration_ms(&self.duration)?))
}
}
impl Question {
pub fn is_followup(&self) -> bool {
self.followup.unwrap_or(false)
}
/// The announcement window is still open at `now_ms` (Unix ms).
#[cfg(feature = "ssr")]
pub fn event_live_at(&self, now_ms: i64) -> bool {
self.event
.as_ref()
.and_then(|e| e.window_ms().ok())
.is_some_and(|(_, ends)| now_ms < ends)
}
/// The announcement window has closed at `now_ms`.
#[cfg(feature = "ssr")]
pub fn event_ended_at(&self, now_ms: i64) -> bool {
self.event
.as_ref()
.and_then(|e| e.window_ms().ok())
.is_some_and(|(_, ends)| now_ms >= ends)
}
/// A dynamic page - one whose id still contains a `[name]`
/// segment. Served per-value via `resolve_question`, never listed
/// in nav, never a valid `action` target.
pub fn is_dynamic(&self) -> bool {
self.id.contains('[')
}
}
/// The signed-in person's own details into the fields that ask for
/// them: `value: "{user.email}"` becomes their address and the field
/// hidden, since asking someone who is known to say who they are is
/// the form doubting them. Without a session the placeholder is
/// emptied, and the field asks. Only the whole of a value is filled
/// this way; a value that mixes a user placeholder with other text is
/// left alone.
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 filled = match value {
"{user.email}" => user.and_then(|u| u.email.clone()),
"{user.name}" => user.map(|u| u.display_name.clone()),
_ => continue,
};
match filled.filter(|v| !v.trim().is_empty()) {
Some(v) => {
req.value = Some(v);
req.kind = "hidden".to_string();
}
None => req.value = None,
}
}
}
}
}
/// An alternative's `action` may carry `{field}` / `{field.sub}`
/// placeholders filled from the submitted responses - `/shape/{curve.key}`
/// lands on the dynamic page `/shape/[key]` for the key the visitor's
/// stroke was given. Returns `None` when a placeholder has no value
/// (or a non-scalar one) in `responses`.
pub fn template_action(action: &str, responses: &serde_json::Value) -> Option<String> {
let mut out = String::new();
let mut rest = action;
while let Some(start) = rest.find('{') {
out.push_str(&rest[..start]);
let end = rest[start..].find('}')? + start;
let path = &rest[start + 1..end];
let mut value = responses;
for seg in path.split('.') {
value = value.get(seg)?;
}
let scalar = match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => return None,
};
if scalar.is_empty() {
return None;
}
// A URL segment: keep it to what a segment can carry.
let safe: String = scalar
.chars()
.map(|c| if c.is_ascii_alphanumeric() || "-_.".contains(c) { c } else { '-' })
.collect();
out.push_str(&safe);
rest = &rest[end + 1..];
}
out.push_str(rest);
Some(out)
}
/// Directory-scoped defaults: a `_section.yaml` file applies to every
/// question at or below its directory (nearest ancestor wins per
/// field, and a question's own declaration always overrides). This is
/// what makes a URL prefix a trust boundary - "everything under
/// /review is owner-only" is one line in `review/_section.yaml`
/// instead of a flag per file. Underscore-prefixed files that aren't
/// `_section.yaml` are skipped entirely (drafts).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SectionConfig {
#[serde(default)]
pub qualifies: Option<String>,
#[serde(default)]
pub requires_chain: Option<String>,
#[serde(default)]
pub responsible: Option<Responsible>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Responsible {
pub name: String,
/// A mailto address or plain email - deliberately just a string,
/// same as everywhere else content keeps contact info simple.
pub contact: String,
}
/// Whether to render `question`'s alternatives or its sign-in gate for
/// `user`. A rendering hint for the browser, which has no policy: the
/// server decides every submit, read, move and listing through
/// `access::Policy`, and this must agree with the `open` rule there
/// (a page is open to anyone unless `qualifies` names a group).
/// Group membership is fixed at login, not re-checked live.
pub fn is_qualified(user: Option<&crate::auth::User>, question: &Question) -> bool {
match &question.qualifies {
None => true,
Some(group) => user.is_some_and(|u| u.groups.iter().any(|g| g == group)),
}
}
/// One path through a question: a short pitch, an optional next question
/// to advance to on submit, and the form (via `features`) that collects
/// what's needed to get there.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Alternative {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub action: Option<String>,
/// Shown but not takeable yet - the button renders disabled and the
/// server refuses the submission. For advertising a path before it
/// works (lysbue had the same flag).
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub consequence: Vec<String>,
#[serde(default)]
pub encouragements: Vec<String>,
/// Banner image urls, rendered above the description - one renders
/// as a plain image, several as a swipeable card deck (Swiper
/// Element, vendored in `public/`). Purely decorative, no
/// upload/hosting mechanism of their own, just already-hosted urls
/// the browser fetches directly (unlike `ResourceSource::Url`,
/// never fetched server-side, so none of that variant's SSRF
/// concern).
#[serde(default)]
pub images: Vec<String>,
#[serde(default)]
pub features: Vec<Feature>,
/// NATS KV bucket to durably store this submission into - just a
/// bucket name, never a keyword the runtime special-cases.
#[serde(default)]
pub record_as: Option<String>,
/// A transition fireable by anyone holding one specific item's own
/// reference (`?chain=` link) plus a matching `email` - the
/// anonymous, single-item counterpart to `ResourceSpec.transitions`'
/// group-gated bucket browsing. The email is a second factor
/// checked against the stored item, not the lookup key.
#[serde(default)]
pub self_transition: Option<SelfTransition>,
/// Submitting this alternative invites a person into a Kanidm
/// group: the form's `name` and `email` become an account, the
/// group membership, and a one-time link to set their own
/// credentials (see `people.rs`). Only on a page gated by
/// `qualifies`: the inviting group is that page's, the invited
/// group is this. The link lands in the runtime's `portal_invites`
/// bucket for the inviter's desk, and on `portal.people.invited`.
#[serde(default)]
pub invite: Option<Invite>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Invite {
pub group: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SelfTransition {
pub bucket: String,
pub to: String,
pub label: String,
/// The states this move may be made from. A person is in dialogue
/// with a case for as long as it runs, not only on the day they
/// sent it: an applicant written to for a missing drawing answers
/// from `incomplete`, and may withdraw from anywhere along the
/// way.
///
/// Empty means the bucket's own initial state, which is the
/// one-shot "confirm" or "unsubscribe" link every one of these was
/// before it could say otherwise. Its own initial state, not the
/// word `open`: a bucket whose records start at `pending` has a
/// confirmation link that fires from `pending`, and reading the
/// literal here is what made redoal's watch-confirm button a rule
/// the engine would never match.
#[serde(default)]
pub from: Vec<String>,
}
impl SelfTransition {
/// The states this move may be made from, with the bucket's
/// initial state standing in when the content named none.
pub fn from_states(&self, initial: &str) -> Vec<String> {
if self.from.is_empty() {
vec![initial.to_string()]
} else {
self.from.clone()
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Feature {
pub name: String,
#[serde(default)]
pub description: String,
/// Any valid CSS color - set as the feature's `--feature-accent`
/// custom property (never interpolated into a stylesheet, so a bad
/// value fails to apply instead of injecting CSS). Unset means no
/// accent border at all.
#[serde(default)]
pub color: Option<String>,
/// An Iconify icon name (`{prefix}:{name}`, e.g. `lucide:star`),
/// rendered via Iconify's public SVG API - no icon library bundled.
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub requirements: Vec<Requirement>,
/// Live data this feature pulls in. Read-only unless `transitions`
/// is non-empty, in which case listed answers get one action button
/// per transition (see `src/resource.rs`, `src/answers.rs`).
#[serde(default)]
pub resource: Option<ResourceSpec>,
}
/// A live-data read declared in content. The bucket/key are only ever
/// resolved server-side from trusted content - a client names a
/// question + feature, never a bucket directly, so a visitor can't
/// probe arbitrary buckets. No render-mode tag: what a resource
/// displays as follows from its data's shape.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ResourceSpec {
pub source: ResourceSource,
/// A single item; omit to list the whole bucket. Only meaningful
/// for a `Kv` source.
#[serde(default)]
pub key: Option<String>,
/// Kanidm group required to read this resource.
#[serde(default)]
pub requires_group: Option<String>,
/// Must be explicitly set for an anonymous-readable resource - a
/// spec with neither this nor `requires_group` is unreachable
/// (fail closed). Reads only: mutations always require
/// `requires_group` regardless of this flag.
#[serde(default)]
pub public: bool,
/// The record states this resource shows, for a `Kv` source; empty
/// shows every one. A register that is public by law is public
/// about what was decided, not about what was withdrawn or turned
/// down, and the desk that works the same bucket is a different
/// spec and still sees all of it.
#[serde(default)]
pub states: Vec<String>,
/// Show only the records whose `where.field` holds `where.equals`,
/// for a `Kv` source. With a `type: record` field on the other
/// side, this is the half that reads: an application's page lists
/// the objections that name it, because each objection stored the
/// application's id in a field and this asks for exactly those.
/// `equals` takes the page's own `{key}` like a resource key does.
#[serde(default, rename = "where")]
pub filter: Option<FieldIs>,
/// The moves a listed answer may make, one button each - empty
/// means read-only. Server calls are checked against this
/// allow-list, so a client can never fire a transition content
/// didn't declare. `Kv` sources only.
#[serde(default)]
pub transitions: Vec<Transition>,
/// A way from each listed record to a page about it: `to` is the
/// page, with `{id}` for the record's id and `{field}` for any of
/// its fields, and `label` the words on the link. The other half
/// of `type: record`, seen from the list: a notice on a board links
/// to the page where a neighbour answers it, and that page's form
/// carries the notice's id back in.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<RowLink>,
/// A jq filter (evaluated via `jaq`, no shell-out) reshaping the
/// fetched value before it reaches the frontend - e.g.
/// `.[] | {name, url: .html_url}`. `None` returns it as-is.
#[serde(default)]
pub jq: Option<String>,
/// What to say when the resource yields nothing - an empty list,
/// or `null` (a `.[] | ...` jq over an empty list produces no
/// output at all). Defaults to "Nothing here yet."; content sets
/// it where the silence needs a voice ("Nothing released yet").
#[serde(default)]
pub empty: Option<String>,
}
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket
/// this server owns) is the only mutable source; the rest are
/// read-only live pulls.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResourceSource {
Kv { bucket: String },
GiteaStarred { username: String },
/// A repo's releases - fetched with `GITEA_API_TOKEN`, so it works
/// for private repos too (shape the link with jq: a private
/// release's html_url 404s for anonymous visitors, so map it to
/// null unless the repo is public).
GiteaReleases { owner: String, repo: String },
/// Any other HTTPS JSON endpoint. Scheme-restricted and checked
/// against loopback/private/link-local addresses at fetch time
/// (`resource::fetch_url_resource`) - a server-side fetch of a
/// content-supplied URL is SSRF surface, so it fails closed.
Url { url: String },
}
impl ResourceSpec {
/// The KV bucket this resource reads/writes - `None` for a live
/// external pull.
pub fn bucket(&self) -> Option<&str> {
match &self.source {
ResourceSource::Kv { bucket } => Some(bucket),
ResourceSource::GiteaStarred { .. }
| ResourceSource::GiteaReleases { .. }
| ResourceSource::Url { .. } => None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Transition {
/// The state a row must be in for this transition's button to
/// render and its server call to be accepted. Defaults to "open".
#[serde(default = "default_transition_from")]
pub from: String,
pub to: String,
pub label: String,
}
fn default_transition_from() -> String {
crate::answers::OPEN_STATE.to_string()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Requirement {
pub name: String,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub placeholder: Option<String>,
#[serde(default = "default_requirement_type", rename = "type")]
pub kind: String,
#[serde(default)]
pub optional: bool,
/// `type: file` - accept multiple files. `type: select` - pick more
/// than one option (checkbox-style toggle) instead of exactly one
/// (radio-style); the submitted value is a JSON array of ids
/// instead of a single id string.
#[serde(default)]
pub multiple: bool,
/// `type: file` only - HTML `accept` hint (UX only, not a security
/// boundary - the upload handler re-checks content-type itself).
#[serde(default)]
pub accept: Option<String>,
/// `type: select` only - where the options come from; the same
/// `ResourceSpec` mechanism a `Feature.resource` uses.
#[serde(default)]
pub resource: Option<ResourceSpec>,
/// `type: select` only - a static list of options, as an
/// alternative to a `resource`. Each string is both the option's
/// stored value and its label. Faithful to the ancestor format's
/// inline enums.
#[serde(default)]
pub options: Vec<String>,
/// `type: select` only - which field in each item is the option's
/// stable id. Defaults to trying `_id` then `id`.
#[serde(default)]
pub id_field: Option<String>,
/// Load this field's value from a resource whenever a sibling
/// field changes - e.g. a file select populating a textarea with
/// the selected file's current content.
#[serde(default)]
pub bind: Option<Bind>,
/// A preset value the field starts with. On a dynamic page the
/// URL segment substitutes into `{name}` placeholders here, like
/// resource keys - `value: "{key}"` on a hidden field carries the
/// page's key into the answer. `{user.email}` and `{user.name}`
/// are the signed-in person's, and a field they fill is carried
/// rather than asked (see `fill_from_user`); for a visitor they
/// are empty, and the field asks as usual.
#[serde(default)]
pub value: Option<String>,
/// `type: record` only - the bucket the referenced record lives in.
/// This is how one case belongs to another: an objection names the
/// application it objects to, an appeal the decision it appeals.
/// The stored value is the parent's record id, and a submission
/// naming a record that is not there is refused, so a reference in
/// a record is one that resolved at least once.
///
/// A person never types one of these: the id is a chain hash. It
/// arrives the way it was given out - `value: "{key}"` on a dynamic
/// page, so the link in the letter carries the case it is about.
#[serde(default)]
pub of: Option<String>,
/// `type: gesture` / `type: voice` - ws(s):// URL of a redoal-relay instance
/// the drawing widget connects to for live echoes of similar
/// strokes. Absent means the widget works offline: the drawn path
/// still submits, it just never gets a network-computed key or
/// echoes.
#[serde(default)]
pub relay: Option<String>,
}
/// A field's live data source, parameterized by a sibling field's
/// value. When the sibling changes, `resource` is fetched with the
/// value as a parameter and the result replaces this field's value
/// (an empty sibling never fetches, and never clears an edit).
/// "Show the ones whose `field` is `equals`." The whole of what a
/// listing may ask about a record's own contents: one field, one
/// value, compared as strings. Anything more is a query language, and
/// a jq filter is already there for shaping what comes back.
/// A link from every listed record to a page about it (`ResourceSpec.link`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RowLink {
pub to: String,
pub label: String,
}
impl RowLink {
/// The link for one record: `{id}` is the record's id, `{name}`
/// any string field of its responses. A placeholder nothing fills
/// stays as it is, which is visible, rather than a wrong page.
pub fn href(&self, id: &str, responses: &serde_json::Value) -> String {
let mut out = self.to.replace("{id}", id);
if let Some(fields) = responses.as_object() {
for (k, v) in fields {
if let Some(s) = v.as_str() {
out = out.replace(&format!("{{{k}}}"), s);
}
}
}
out
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FieldIs {
pub field: String,
pub equals: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Bind {
/// The sibling requirement (same alternative) to watch.
pub field: String,
/// The parameter name the sibling's value is sent as - `{name}`
/// templates into a Url source's path, otherwise it's appended as
/// a query pair. Defaults to `field`.
#[serde(default)]
pub param: Option<String>,
pub resource: ResourceSpec,
}
impl Bind {
pub fn param_name(&self) -> &str {
self.param.as_deref().unwrap_or(&self.field)
}
}
fn default_requirement_type() -> String {
"text".to_string()
}
impl Requirement {
pub fn display_label(&self) -> String {
self.label.clone().unwrap_or_else(|| self.name.clone())
}
}
/// Site-wide branding declared by the content repo - `site.yaml` at
/// the repo root, sibling of `aggregates.yaml`. An absent file means
/// all defaults, which is exactly the historical uhhm look: existing
/// deployments change nothing without a content edit. Everything the
/// browser needs, so it travels through a server fn (`get_site`).
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct SiteConfig {
/// Browser-tab title and wordmark alt text. `None` falls back to
/// the compile-time `SITE_NAME`.
#[serde(default)]
pub title: Option<String>,
/// Wordmark image URL (absolute or a path this instance serves).
/// `None` falls back to `/wordmark.svg`.
#[serde(default)]
pub wordmark: Option<String>,
/// Whether to invert the wordmark in light theme. `None` inverts
/// only the built-in house wordmark (a white-stroke SVG); a
/// content-shipped logo keeps its own colours unless it sets this
/// true (e.g. a sibling site's white-stroke mark).
#[serde(default)]
pub wordmark_invert: Option<bool>,
/// Wordmark display height in rem (0.5–6.0). `None` keeps the
/// stylesheet's default. Raster logos look best at or below their
/// intrinsic pixel height.
#[serde(default)]
pub wordmark_height: Option<f32>,
/// BCP 47-ish language code for the portal's own chrome strings
/// ("Asked by", the nav lead...) and the html lang attribute.
/// `None` means "en"; unknown codes fall back to English per key.
#[serde(default)]
pub lang: Option<String>,
/// A content-repo-relative CSS file (e.g. "custom.css") layered
/// *after* the default stylesheet, so it overrides. Served
/// same-origin at `/site/<path>`; plain path only.
#[serde(default)]
pub stylesheet: Option<String>,
/// A content-repo-relative favicon (svg/png/ico), served at
/// `/site/<path>` and used in place of the built-in one.
#[serde(default)]
pub favicon: Option<String>,
#[serde(default)]
pub hero: HeroConfig,
/// Whether this instance can draw maps: set from `MAPBOX_TOKEN` on
/// the way to the browser (see `get_site`), never from content, so
/// a `type: location` field knows whether to offer a map or two
/// number inputs.
#[serde(skip)]
pub maps: bool,
/// Who this site's mail comes from. Absent means the site sends
/// nothing: a state's `mail:` and the invite mail are logged and
/// skipped, never half-sent (see `mail.rs`).
#[serde(default)]
pub mail: Option<MailConfig>,
}
/// The sender identity, and the one built-in mail's wording.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct MailConfig {
/// `Name <address>` or a bare address, on a domain the host's
/// mail server may send for.
pub from: String,
#[serde(default)]
pub reply_to: Option<String>,
/// Rewords the invite mail; the built-in text is in the site's
/// `lang`. `{name}`, `{username}`, `{email}`, `{group}`, `{link}`,
/// `{site}`, `{site_name}`, `{expires}`.
#[serde(default)]
pub invite: Option<MailTemplate>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct MailTemplate {
pub subject: String,
pub body: String,
}
/// What the landing page's hero is. `plain` is just the copy; `module`
/// hands the hero to a JavaScript module the content repo itself
/// ships (`module: hero.js`, a repo-relative path portal serves
/// same-origin at `/site/<path>`, since Gitea's raw endpoint sends no
/// CORS headers). The module exports `mount(container) -> handle` and
/// the handle has `stop()`; portal starts it at HTML parse time and
/// adopts it on hydration. The piece's look - canvases, SVG, CSS - is
/// entirely the module's: portal only reserves the box.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HeroConfig {
#[serde(default = "default_hero_kind")]
pub kind: String,
#[serde(default)]
pub module: Option<String>,
}
impl Default for HeroConfig {
fn default() -> Self {
Self { kind: default_hero_kind(), module: None }
}
}
fn default_hero_kind() -> String {
"plain".to_string()
}
#[cfg(feature = "ssr")]
impl SiteConfig {
/// Same policy as `validate_questions`: content declares, the
/// runtime refuses what it doesn't understand at load time, so a
/// typo'd hero kind is a caught rejection, not a silently plain
/// hero.
pub fn validate(&self) -> anyhow::Result<()> {
match self.hero.kind.as_str() {
"plain" => {
if self.hero.module.is_some() {
anyhow::bail!("site.yaml: hero.module only makes sense with hero.kind: module");
}
}
"module" => {
let Some(module) = &self.hero.module else {
anyhow::bail!("site.yaml: hero.kind: module needs hero.module (a repo-relative path like hero.js)");
};
if !is_safe_site_path(module) {
anyhow::bail!(
"site.yaml: hero.module {module:?} must be a plain repo-relative path (no scheme, no .., no leading /)"
);
}
}
other => anyhow::bail!("site.yaml: hero.kind {other:?} is not one of plain | module"),
}
if let Some(sheet) = &self.stylesheet {
if !is_safe_site_path(sheet) || !sheet.ends_with(".css") {
anyhow::bail!(
"site.yaml: stylesheet {sheet:?} must be a plain repo-relative .css path"
);
}
}
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 {
anyhow::bail!(
"site.yaml: favicon {icon:?} must be a plain repo-relative .svg/.png/.ico path"
);
}
}
Ok(())
}
}
/// Fetches every `*.yaml` file under `subdir` in a Gitea repo as a
/// `Question`, keyed by its own `id`. `repo_url` is the repo's normal
/// browser URL (e.g. `https://project.uhhm.no/uhhm/questions`) - the
/// Gitea host, owner and repo name are all read from it. Called once at
/// startup, and again on every `CONTENT_RELOAD_SUBJECT` message (see
/// `watch_for_reload`), over Gitea's public contents API (no auth - the
/// content repo is public).
/// The URL a file at `rel` (path relative to the questions dir, forward
/// slashes) serves: `index.yaml` names its directory, everything else
/// appends its stem.
pub fn route_from_path(rel: &str) -> String {
let stem = rel.strip_suffix(".yaml").unwrap_or(rel);
let mut segments: Vec<&str> = stem.split('/').collect();
if segments.last() == Some(&"index") {
segments.pop();
}
if segments.is_empty() {
"/".to_string()
} else {
format!("/{}", segments.join("/"))
}
}
/// The directory (as a URL prefix) of the file at `rel` - the base
/// relative references resolve against.
pub fn dir_from_path(rel: &str) -> String {
match rel.rsplit_once('/') {
Some((dir, _)) => format!("/{dir}"),
None => "/".to_string(),
}
}
/// Resolves a possibly-relative question reference (`action:`,
/// `requires_chain:`) against the referencing file's directory:
/// `/x` is absolute, `proposed` names a sibling, `../x` climbs.
pub fn resolve_ref(base_dir: &str, reference: &str) -> String {
if reference.starts_with('/') {
return reference.to_string();
}
let mut segments: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect();
for part in reference.split('/') {
match part {
"" | "." => {}
".." => {
segments.pop();
}
s => segments.push(s),
}
}
if segments.is_empty() {
"/".to_string()
} else {
format!("/{}", segments.join("/"))
}
}
/// Whether `path` matches `pattern`, capturing `[name]` segments.
/// Returns the captured (name, value) pairs on a match - empty for an
/// exact literal match.
pub fn path_matches(pattern: &str, path: &str) -> Option<Vec<(String, String)>> {
let pat: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
let got: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if pat.len() != got.len() {
return None;
}
let mut captures = Vec::new();
for (p, g) in pat.iter().zip(got.iter()) {
if let Some(name) = p.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
if g.is_empty() {
return None;
}
captures.push((name.to_string(), (*g).to_string()));
} else if p != g {
return None;
}
}
Some(captures)
}
/// The question an alternative's `action` lands on, as a question id:
/// a plain path resolves directly, a templated one
/// (`/shape/{curve.key}`) to the dynamic page whose pattern has the
/// same shape (`/shape/[key]`). `None` when no page declares it.
pub fn action_target_id(
questions: &std::collections::HashMap<String, Question>,
action: &str,
) -> Option<String> {
if questions.contains_key(action) {
return Some(action.to_string());
}
let want: Vec<&str> = action.split('/').filter(|s| !s.is_empty()).collect();
questions
.values()
.filter(|q| q.is_dynamic())
.map(|q| &q.id)
.find(|id| {
let pat: Vec<&str> = id.split('/').filter(|s| !s.is_empty()).collect();
pat.len() == want.len()
&& pat
.iter()
.zip(&want)
.all(|(p, w)| p == w || (p.starts_with('[') && w.starts_with('{')))
})
.cloned()
}
/// An alternative whose action asks nothing of the visitor: no answer
/// is recorded, so following it is navigation, not a step they have to
/// complete.
fn asks_nothing(alt: &Alternative) -> bool {
!alt.disabled && alt.record_as.is_none() && alt.self_transition.is_none()
}
/// The pages a verified answer has earned: where the answered
/// alternative sends the visitor next (a templated action resolves to
/// its dynamic pattern). When the node predates alternatives being
/// indexed, every recording alternative of that question counts - the
/// visitor did answer it, only which alternative is unknown.
pub fn unlocked_by(
questions: &std::collections::HashMap<String, Question>,
question_id: &str,
alternative: Option<&str>,
) -> Vec<String> {
let Some(question) = resolve_question(questions, question_id) else {
return Vec::new();
};
question
.alternatives
.iter()
.filter(|a| match alternative {
Some(name) => a.name == name,
None => a.record_as.is_some(),
})
.filter_map(|a| a.action.as_deref())
.filter_map(|action| action_target_id(questions, action))
.collect()
}
/// The followups a visitor may be offered, given the pages they have
/// earned: `seeds` (the page a verified chain sent them to) plus every
/// outcome page reachable from a seed or from `current` over
/// alternatives that ask nothing - never through one that records an
/// answer, since that answer is exactly what they have not given. A
/// front door is never in the result and ends the walk, so "back to
/// the start" never fans out into the site; standing on a page earns
/// nothing by itself beyond what it links to for free.
pub fn earned_followups(
questions: &std::collections::HashMap<String, Question>,
current: &str,
seeds: &[String],
is_outcome: &dyn Fn(&Question) -> bool,
) -> std::collections::HashSet<String> {
use std::collections::{HashSet, VecDeque};
let mut earned: HashSet<String> = HashSet::new();
let mut queue: VecDeque<Question> = VecDeque::new();
for seed in seeds {
if let Some(q) = questions.get(seed).filter(|q| is_outcome(q)) {
earned.insert(seed.clone());
queue.push_back(q.clone());
}
}
if let Some(q) = resolve_question(questions, current) {
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()) {
let Some(id) = action_target_id(questions, action) else {
continue;
};
let target = &questions[&id];
if !is_outcome(target) || earned.contains(&id) || path_matches(&id, current).is_some() {
continue;
}
earned.insert(id.clone());
queue.push_back(target.clone());
}
}
earned
}
/// Resolves a URL path to its question: an exact id first, else the
/// one dynamic page whose pattern matches, with each captured segment
/// substituted into `{name}` placeholders in the clone's resource keys
/// and its id set to the concrete path (so every follow-up server call
/// - resources, submissions - re-resolves the same way). The
/// substituted key is still looked up in the content-declared bucket,
/// so a visitor varies the key, never the bucket - the same trust
/// shape as a `?chain=` link, where knowing a record's key is holding
/// it.
pub fn resolve_question(
questions: &std::collections::HashMap<String, Question>,
path: &str,
) -> Option<Question> {
if let Some(q) = questions.get(path) {
return Some(q.clone());
}
for q in questions.values() {
if !q.is_dynamic() {
continue;
}
if let Some(captures) = path_matches(&q.id, path) {
let mut resolved = q.clone();
resolved.id = path.to_string();
let substitute = |key: &mut Option<String>| {
if let Some(k) = key {
for (name, value) in &captures {
*k = k.replace(&format!("{{{name}}}"), value);
}
}
};
// A url source's address takes the segment too:
// `https://relay.redoal.com/place/{key}`.
let substitute_url = |src: &mut ResourceSource| {
if let ResourceSource::Url { url } = src {
for (name, value) in &captures {
*url = url.replace(&format!("{{{name}}}"), value);
}
}
};
for alt in &mut resolved.alternatives {
for feature in &mut alt.features {
if let Some(res) = &mut feature.resource {
substitute(&mut res.key);
substitute_url(&mut res.source);
}
for req in &mut feature.requirements {
if let Some(res) = &mut req.resource {
substitute(&mut res.key);
}
substitute(&mut req.value);
}
}
}
return Some(resolved);
}
}
None
}
/// Builds the question map from raw (path, yaml) files - the shared
/// back half of both loaders (Gitea tree and `question_lint --path`).
/// Applies the whole filesystem-routing contract: derived ids,
/// relative-reference resolution, followup inference, `_section.yaml`
/// inheritance, and the tree-shape rules (no id collisions, at most
/// one dynamic page per directory).
#[cfg(feature = "ssr")]
pub fn build_questions(
files: &[(String, String)],
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
// Sections first: (directory prefix, config), shallowest first so a
// later (deeper) section overrides an outer one field-by-field.
let mut sections: Vec<(String, SectionConfig)> = Vec::new();
for (rel, raw) in files {
let name = rel.rsplit('/').next().unwrap_or(rel);
if name != "_section.yaml" {
continue;
}
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) = &section.requires_chain {
section.requires_chain = Some(resolve_ref(&dir, rc));
}
sections.push((dir, section));
}
sections.sort_by_key(|(dir, _)| dir.len());
let mut out = std::collections::HashMap::new();
let mut dynamic_dirs = std::collections::HashSet::new();
for (rel, raw) in files {
let name = rel.rsplit('/').next().unwrap_or(rel);
// Underscore files are sections or drafts, never pages.
if name.starts_with('_') {
continue;
}
let mut question: Question =
serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing {rel}: {e}"))?;
let derived = route_from_path(rel);
let dir = dir_from_path(rel);
if question.id.is_empty() {
question.id = derived.clone();
} else if question.id != derived {
tracing::warn!(
"{rel}: declared id {:?} disagrees with its path ({derived}) - the declared id wins, but consider moving the file",
question.id
);
}
for alt in &mut question.alternatives {
if let Some(action) = &alt.action {
alt.action = Some(resolve_ref(&dir, action));
}
}
if let Some(rc) = &question.requires_chain {
question.requires_chain = Some(resolve_ref(&dir, rc));
}
if question.followup.is_none() {
// Nested non-index files are flow steps and outcomes -
// followups by construction. Top-level files keep the
// historical flat-repo default.
let nested = rel.contains('/');
let is_index = name == "index.yaml";
question.followup = Some(nested && !is_index);
}
// Nearest-ancestor section fills whatever the question left
// unset (walk deepest-last, so later matches override earlier
// section values but never the question's own).
for (sdir, section) in &sections {
let applies = *sdir == "/" || dir == *sdir || dir.starts_with(&format!("{sdir}/"));
if !applies {
continue;
}
if question.qualifies.is_none() {
question.qualifies = section.qualifies.clone();
}
if question.requires_chain.is_none() {
question.requires_chain = section.requires_chain.clone();
}
if question.responsible.is_none() {
question.responsible = section.responsible.clone();
}
}
if question.is_dynamic() && !dynamic_dirs.insert(dir.clone()) {
anyhow::bail!(
"{rel}: more than one dynamic ([name].yaml) page in {dir} - matching would be ambiguous"
);
}
if let Some(previous) = out.insert(question.id.clone(), question) {
anyhow::bail!(
"{rel}: id {:?} is already declared by another file",
previous.id
);
}
}
Ok(out)
}
#[cfg(all(test, feature = "ssr"))]
mod tests {
use super::*;
fn schema_fixture() -> std::collections::HashMap<String, crate::aggregates::AggregateSchema> {
crate::aggregates::parse_aggregates_yaml(
r#"
aggregates:
- bucket: things
initial: open
states:
open: { event: opened }
middle: { event: advanced }
done: { event: finished }
transitions:
open: [middle]
middle: [done]
"#,
)
.unwrap()
}
fn question_with_transitions(transitions_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
r#"
id: /t
name: T
alternatives:
- name: A
features:
- name: ""
resource:
source: {{ kind: kv, bucket: things }}
requires_group: owners
transitions:
{transitions_yaml}
"#
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
}
fn question_with_record(field_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
r#"
id: /object
name: Object to it
alternatives:
- name: The register
features:
- name: All
resource:
public: true
source: {{ kind: kv, bucket: things }}
- name: Object
record_as: things
features:
- name: Yours
requirements:
- name: what
{field_yaml}
"#
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
}
// An objection belongs to the application it objects to. The
// bucket it points at has to be one this repo declares, or the
// reference could never resolve.
#[test]
fn a_record_can_belong_to_another_record() {
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 err = validate_questions(&nowhere, &schema_fixture()).unwrap_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();
assert!(err.to_string().contains("must say `of:"), "{err}");
// `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}");
// 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}");
}
// A state that grants a group makes an account for someone, so the
// form that fills the bucket has to have asked who they are.
#[test]
fn a_state_that_grants_a_group_needs_a_name_and_an_address_on_file() {
let granting = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: things\n initial: open\n states:\n open: { event: opened }\n middle: { event: advanced }\n done: { event: finished, grants: approved_firms }\n transitions:\n open: [middle]\n middle: [done]\n",
)
.unwrap();
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 }");
let err = validate_questions(&no_name, &granting).unwrap_err();
assert!(err.to_string().contains("grant a group"), "{err}");
// The same form is fine against a schema that grants nothing.
validate_questions(&no_name, &schema_fixture()).unwrap();
}
// A deadline may only make a move a person could have made.
#[test]
fn a_states_deadline_is_held_to_the_same_graph_as_a_button() {
let ok = 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 open: [no_answer]\n",
)
.unwrap();
assert_eq!(ok["hearings"].after_for("open").unwrap().days, 42);
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"));
}
#[test]
fn declared_edge_passes() {
let questions = question_with_transitions(
" - { from: open, to: middle, label: Advance }\n - { from: middle, to: done, label: Finish }",
);
assert!(validate_questions(&questions, &schema_fixture()).is_ok());
}
#[test]
fn from_defaults_to_open() {
let questions = question_with_transitions(" - { to: middle, label: Advance }");
assert!(validate_questions(&questions, &schema_fixture()).is_ok());
}
/// A page with one `type: location` field, and whatever else the
/// test needs bolted onto the requirement.
fn question_with_location(extra_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
r#"
id: /t
name: Where is it?
alternatives:
- name: A
features:
- name: Where
requirements:
- name: place
label: Where
type: location
optional: true
{extra_yaml}
"#
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
}
#[test]
fn a_location_field_puts_its_own_value_there() {
let questions = question_with_location("");
validate_questions(&questions, &schema_fixture()).unwrap();
// It cannot also be loaded from somewhere.
let bound = question_with_location(
" 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}");
}
#[test]
fn a_location_fields_opening_view_is_a_point_on_earth() {
let good = question_with_location(" value: \"59.8938,11.0332,15\"");
validate_questions(&good, &schema_fixture()).unwrap();
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\""] {
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}");
}
}
#[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()) };
place.validate().unwrap();
assert_eq!(place.as_value(), "59.8938,11.0332,15");
// The default is a street and its neighbours, not the globe.
let bare: Place = serde_yaml::from_str("lat: 59.8938\nlon: 11.0332").unwrap();
assert_eq!(bare.zoom, 15.0);
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()) },
] {
assert!(bad.validate().is_err(), "{bad:?}");
}
}
#[test]
fn undeclared_from_state_is_rejected() {
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"));
}
#[test]
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 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 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"));
}
#[test]
fn self_referencing_action_passes() {
let question: Question =
serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /a\n")
.unwrap();
let questions = std::collections::HashMap::from([(question.id.clone(), question)]);
assert!(validate_questions(&questions, &Default::default()).is_ok());
}
fn question_with_bind(bind_field: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
r#"
id: /b
name: B
alternatives:
- name: A
features:
- name: ""
requirements:
- name: picker
type: select
resource:
public: true
source: {{ kind: url, url: "https://x.example/list" }}
- name: body
type: textarea
bind:
field: {bind_field}
resource:
public: true
source: {{ kind: url, url: "https://x.example/item/{{picker}}" }}
"#
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
}
#[test]
fn bind_to_sibling_passes() {
assert!(validate_questions(&question_with_bind("picker"), &Default::default()).is_ok());
}
#[test]
fn bind_to_missing_sibling_is_rejected() {
let err =
validate_questions(&question_with_bind("bogus"), &Default::default()).unwrap_err();
assert!(err.to_string().contains("not another requirement"));
}
#[test]
fn bind_to_itself_is_rejected() {
let err = validate_questions(&question_with_bind("body"), &Default::default()).unwrap_err();
assert!(err.to_string().contains("not another requirement"));
}
fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
"id: /g\nname: G\nalternatives:\n - name: A\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
}
#[test]
fn gesture_with_wss_relay_passes() {
let questions = question_with_requirement(
" - { name: curve, type: gesture, relay: \"wss://relay.redoal.com\", optional: true }",
);
assert!(validate_questions(&questions, &Default::default()).is_ok());
}
#[test]
fn gesture_without_relay_passes_offline() {
let questions =
question_with_requirement(" - { name: curve, type: gesture, optional: true }");
assert!(validate_questions(&questions, &Default::default()).is_ok());
}
#[test]
fn relay_on_non_gesture_kind_is_rejected() {
let questions = question_with_requirement(
" - { 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"));
}
#[test]
fn https_relay_is_rejected() {
let questions = question_with_requirement(
" - { name: curve, type: gesture, relay: \"https://relay.redoal.com\" }",
);
let err = validate_questions(&questions, &Default::default()).unwrap_err();
assert!(err.to_string().contains("ws:// or wss://"));
}
#[test]
fn site_config_defaults_to_the_plain_hero() {
let site = SiteConfig::default();
assert_eq!(site.hero.kind, "plain");
assert!(site.validate().is_ok());
// And an empty file parses to the same thing.
let parsed: SiteConfig = serde_yaml::from_str("{}").unwrap();
assert_eq!(parsed, site);
}
#[test]
fn site_config_rejects_unknown_hero_kind() {
let site: SiteConfig = serde_yaml::from_str("hero: { kind: fireworks }").unwrap();
let err = site.validate().unwrap_err();
assert!(err.to_string().contains("fireworks"));
}
#[test]
fn site_config_module_hero_needs_a_safe_path() {
let missing: SiteConfig = serde_yaml::from_str("hero: { kind: module }").unwrap();
assert!(missing.validate().is_err());
let traversal: SiteConfig =
serde_yaml::from_str("hero: { kind: module, module: ../etc/passwd }").unwrap();
assert!(traversal.validate().is_err());
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();
assert!(ok.validate().is_ok());
}
#[test]
fn redoal_site_yaml_shape_parses() {
let site: SiteConfig = serde_yaml::from_str(
"title: redoal\nwordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg\nhero:\n kind: module\n module: hero.js\n",
)
.unwrap();
assert!(site.validate().is_ok());
assert_eq!(site.title.as_deref(), Some("redoal"));
assert_eq!(site.hero.module.as_deref(), Some("hero.js"));
}
#[test]
fn routes_derive_from_paths() {
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("review/[record].yaml"), "/review/[record]");
}
#[test]
fn relative_refs_resolve_against_the_file_dir() {
assert_eq!(resolve_ref("/develop", "proposed"), "/develop/proposed");
assert_eq!(resolve_ref("/develop", "/subscribed"), "/subscribed");
assert_eq!(resolve_ref("/develop", "../applied"), "/applied");
assert_eq!(resolve_ref("/", "applied"), "/applied");
assert_eq!(resolve_ref("/", ".."), "/");
}
#[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"), None);
assert_eq!(path_matches("/review/[record]", "/other/abc"), None);
assert_eq!(path_matches("/review", "/review"), Some(vec![]));
}
#[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()),
];
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["/develop"].alternatives[0].action.as_deref(),
Some("/develop/proposal")
);
assert_eq!(
questions["/develop/proposal"].alternatives[0].action.as_deref(),
Some("/develop/proposed")
);
// Followup inference: nested non-index files are followups,
// index files and top-level files are not (explicit wins).
assert!(!questions["/"].is_followup());
assert!(questions["/applied"].is_followup());
assert!(!questions["/develop"].is_followup());
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["/"].qualifies, None);
assert!(!questions.contains_key("/review/_draft"));
}
#[test]
fn followups_surface_by_reach_not_by_carrying_any_chain() {
use std::collections::HashSet;
let files = vec![
// Front door with three outcomes, one of them templated onto
// a dynamic page.
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: apply\n action: applied\n record_as: applicants\n - name: subscribe\n action: subscribed\n record_as: subscribers\n - name: draw\n action: /shape/{curve.key}\n record_as: gestures\n".to_string()),
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
("subscribed.yaml".to_string(), "name: Subscribed\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()),
// Nested -> followup by inference; leads on to /subscribed.
("shape/[key].yaml".to_string(), "name: Shape\nalternatives:\n - name: watch\n action: /subscribed\n record_as: subscribers\n - name: back\n action: /\n".to_string()),
// Reached only by an out-of-band link: nothing points here.
("watching.yaml".to_string(), "name: Watching\nfollowup: true\n".to_string()),
// A second, unrelated path with its own outcome.
("kontakt.yaml".to_string(), "name: Kontakt\nalternatives:\n - name: send\n action: takk\n record_as: inquiries\n".to_string()),
("takk.yaml".to_string(), "name: Takk\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()),
];
let questions = build_questions(&files).unwrap();
let is_outcome = |q: &Question| q.is_followup();
let set = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::<HashSet<_>>();
let earned = |current: &str, seeds: &[&str]| {
let seeds: Vec<String> = seeds.iter().map(|s| s.to_string()).collect();
earned_followups(&questions, current, &seeds, &is_outcome)
};
// 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, "/nowhere"), None);
// No chain, no followups: standing on the front door earns none
// of the pages its answers lead to, and standing on a followup
// one typed in earns nothing either.
assert_eq!(earned("/", &[]), set(&[]));
assert_eq!(earned("/kontakt", &[]), set(&[]));
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!(earned("/applied", &["/applied"]), set(&["/applied"]));
// An old node without the alternative: the answered question's
// recording alternatives, and nothing beyond that question.
let old: HashSet<String> = unlocked_by(&questions, "/", None).into_iter().collect();
assert_eq!(old, set(&["/applied", "/subscribed", "/shape/[key]"]));
assert!(!old.contains("/takk"));
// From an earned page, only links that ask nothing carry on:
// "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("/takk", &["/takk"]), set(&["/takk"]));
// The email-link page is earned by nothing on the site.
assert!(!earned("/", &["/applied"]).contains("/watching"));
}
#[test]
fn declared_id_wins_over_path() {
let files = vec![(
"legacy.yaml".to_string(),
"id: /somewhere-else\nname: Legacy\n".to_string(),
)];
let questions = build_questions(&files).unwrap();
assert!(questions.contains_key("/somewhere-else"));
}
#[test]
fn colliding_ids_and_ambiguous_dynamics_are_rejected() {
let collision = vec![
("a.yaml".to_string(), "name: A\n".to_string()),
("b.yaml".to_string(), "id: /a\nname: B\n".to_string()),
];
assert!(build_questions(&collision).is_err());
let ambiguous = vec![
("review/[a].yaml".to_string(), "name: A\n".to_string()),
("review/[b].yaml".to_string(), "name: B\n".to_string()),
];
assert!(build_questions(&ambiguous).is_err());
}
#[test]
fn dynamic_pages_resolve_with_key_substitution() {
let files = vec![(
"review/[record].yaml".to_string(),
"name: Record\nalternatives:\n - name: view\n features:\n - name: detail\n resource:\n public: true\n key: \"{record}\"\n source:\n kind: kv\n bucket: applicants\n".to_string(),
)];
let questions = build_questions(&files).unwrap();
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(),
Some("abc123")
);
assert!(resolve_question(&questions, "/review").is_none());
// The pattern itself still resolves exactly (it IS an id).
assert!(resolve_question(&questions, "/review/[record]").is_some());
}
#[test]
fn requires_chain_resolves_and_validates() {
let files = vec![
("shape.yaml".to_string(), "name: Shape\n".to_string()),
(
"shaped.yaml".to_string(),
"name: Shaped\nrequires_chain: shape\n".to_string(),
),
];
let questions = build_questions(&files).unwrap();
assert_eq!(questions["/shaped"].requires_chain.as_deref(), Some("/shape"));
assert!(validate_questions(&questions, &Default::default()).is_ok());
let dangling = vec![(
"shaped.yaml".to_string(),
"name: Shaped\nrequires_chain: /nowhere\n".to_string(),
)];
let questions = build_questions(&dangling).unwrap();
assert!(validate_questions(&questions, &Default::default()).is_err());
}
#[test]
fn recorded_buckets_must_be_attended() {
let write_only = vec![(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(),
)];
let questions = build_questions(&write_only).unwrap();
let err = validate_questions(&questions, &Default::default()).unwrap_err();
assert!(err.to_string().contains("unattended"), "got: {err}");
// A desk (any Kv resource over the bucket, anywhere in the
// repo) attends it.
let with_desk = vec![
(
"index.yaml".to_string(),
"name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(),
),
(
"review/index.yaml".to_string(),
"name: Desk\nalternatives:\n - name: Inbox\n features:\n - name: \"\"\n resource:\n requires_group: owners\n source:\n kind: kv\n bucket: inbox\n".to_string(),
),
];
let questions = build_questions(&with_desk).unwrap();
assert!(validate_questions(&questions, &Default::default()).is_ok());
// ..or an explicit attended_by annotation in aggregates.yaml.
let aggregates = crate::aggregates::parse_aggregates_yaml(
"aggregates:\n - bucket: black_hole\n initial: open\n attended_by: n8n nightly digest\n states:\n open: { event: submitted }\n",
)
.unwrap();
let questions = build_questions(&write_only).unwrap();
assert!(validate_questions(&questions, &aggregates).is_ok());
}
#[test]
fn actions_may_not_target_dynamic_pages() {
let files = vec![
(
"index.yaml".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()),
];
let questions = build_questions(&files).unwrap();
assert!(validate_questions(&questions, &Default::default()).is_err());
}
// ── events (announced pages) ─────────────────────────────────────────
#[test]
fn duration_parses_whole_units() {
assert_eq!(parse_duration_ms("3h").unwrap(), 3 * 3_600_000);
assert_eq!(parse_duration_ms("90m").unwrap(), 90 * 60_000);
assert_eq!(parse_duration_ms("1d 6h").unwrap(), 30 * 3_600_000);
assert!(parse_duration_ms("0h").is_err());
assert!(parse_duration_ms("3 hours").is_err());
assert!(parse_duration_ms("").is_err());
}
#[test]
fn event_window_and_liveness() {
let q: Question = serde_yaml::from_str(
"id: /opening\nname: Will you be there?\nevent:\n starts: 2026-09-12T18:00:00+02:00\n duration: 3h\n",
)
.unwrap();
let (starts, ends) = q.event.as_ref().unwrap().window_ms().unwrap();
assert_eq!(ends - starts, 3 * 3_600_000);
assert!(q.event_live_at(starts - 1));
assert!(q.event_live_at(ends - 1));
assert!(!q.event_live_at(ends));
assert!(q.event_ended_at(ends));
assert!(!q.event_ended_at(starts));
let plain: Question = serde_yaml::from_str("id: /x\nname: X\n").unwrap();
assert!(!plain.event_live_at(0) && !plain.event_ended_at(i64::MAX));
}
#[test]
fn event_validation() {
let bad: Question = serde_yaml::from_str(
"id: /e\nname: E\nevent:\n starts: next friday\n duration: 3h\n",
)
.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();
assert!(err.contains("bad starts"), "{err}");
let dynamic: Question = serde_yaml::from_str(
"id: /e/[slot]\nname: E\nevent:\n starts: 2026-09-12T18:00:00Z\n duration: 3h\n",
)
.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();
assert!(err.contains("dynamic page can't carry an event"), "{err}");
}
#[test]
fn builtin_events_schema_yields_to_content() {
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("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");
}
#[test]
fn bucket_is_read_sees_kv_resources() {
let qs = question_with_transitions(" - { to: middle, label: Go }");
assert!(bucket_is_read(&qs, "things"));
assert!(!bucket_is_read(&qs, EVENTS_BUCKET));
}
// ── inline markdown ──────────────────────────────────────────────────
#[test]
fn markdown_links_and_emphasis() {
assert_eq!(
render_inline_markdown("See [the programme](https://attac.no/x) *soon*."),
"See <a href=\"https://attac.no/x\">the programme</a> <em>soon</em>."
);
assert_eq!(render_inline_markdown("plain text"), "plain text");
assert_eq!(render_inline_markdown("a\nb"), "a b");
}
#[test]
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![], 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")));
// 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));
}
#[test]
fn markdown_drops_html_and_unsafe_links() {
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
assert_eq!(render_inline_markdown("[bad](javascript:alert(1))"), "bad");
assert_eq!(
render_inline_markdown("[ring](tel:+4791180485)"),
"<a href=\"tel:+4791180485\">ring</a>"
);
assert_eq!(
render_inline_markdown("![site](https://x.no/a.jpg)"),
"<img src=\"https://x.no/a.jpg\" alt=\"site\" />"
);
assert_eq!(render_inline_markdown("![x](data:image/png;base64,AA)"), "");
assert_eq!(
render_inline_markdown("![local](/images/a.jpg)"),
"<img src=\"/images/a.jpg\" alt=\"local\" />"
);
// Image title hints: float + max-width, title dropped.
assert_eq!(
render_inline_markdown("![J](https://x.no/j.jpg \"right 9rem\")"),
"<img class=\"md-float-right\" style=\"max-width:9rem\" src=\"https://x.no/j.jpg\" alt=\"J\" />"
);
assert_eq!(
render_inline_markdown("![J](https://x.no/j.jpg \"12\")"),
"<img style=\"max-width:12rem\" src=\"https://x.no/j.jpg\" alt=\"J\" />"
);
// Unknown hint tokens are ignored; a safe image with no title
// is untouched.
assert_eq!(
render_inline_markdown("![J](https://x.no/j.jpg \"wat\")"),
"<img src=\"https://x.no/j.jpg\" alt=\"J\" />"
);
assert_eq!(render_inline_markdown("[ok](/shape)"), "<a href=\"/shape\">ok</a>");
assert_eq!(render_inline_markdown("[mail](mailto:bl@uhhm.no)"), "<a href=\"mailto:bl@uhhm.no\">mail</a>");
}
#[test]
fn templated_action_validates_against_the_dynamic_page() {
let landing: Question = serde_yaml::from_str(
"id: /\nname: L\nalternatives:\n - name: A\n action: /shape/{curve.key}\n",
)
.unwrap();
let place: Question = serde_yaml::from_str("id: /shape/[key]\nname: P\n").unwrap();
let mut qs = std::collections::HashMap::new();
qs.insert(landing.id.clone(), landing.clone());
qs.insert(place.id.clone(), place);
validate_questions(&qs, &Default::default()).unwrap();
let mut only_landing = std::collections::HashMap::new();
only_landing.insert(landing.id.clone(), landing);
assert!(validate_questions(&only_landing, &Default::default()).is_err());
}
#[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("/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());
assert!(template_action("/shape/{curve.points}", &r).is_none());
let odd = serde_json::json!({"k": "a/b c"});
assert_eq!(template_action("/p/{k}", &odd).as_deref(), Some("/p/a-b-c"));
}
#[test]
fn dynamic_page_fills_requirement_values() {
let q: Question = serde_yaml::from_str(
"id: /shape/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: key\n type: hidden\n value: \"{key}\"\n",
)
.unwrap();
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"));
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",
)
.unwrap();
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 {
ResourceSource::Url { url } => assert_eq!(url, "https://relay.example/place/20aa98"),
_ => panic!(),
}
}
}