2026-09-30 15:11:27 +02:00
//! Storylines: a text may be several, and `[n]` runs through the site.
//!
//! Anywhere content says something to a person - a page's `name` and
//! `description`, an alternative's, a feature's, a `label`, a
//! `placeholder`, a mail's `subject` and `body`, a list's `empty` line,
//! the `consequence` buttons and `encouragements` - it may give a list
//! instead of one string:
//!
//! ```yaml
//! description:
//! - Dugnad, 17. mai og komiteene trenger flere hender. Si hva du kan.
//! - Naboene gjør det meste selv. Her melder du deg til det du har lyst på.
//! ```
//!
//! Every such list has the same length, the site's *arity*, and a
//! visitor is given one index for the whole site (a cookie, see
//! `main.rs`), so they read one storyline from the front door to the
//! mail they get. A single string is every storyline. What a visitor
//! did is recorded with the index (`AnswerSubmitted.variant`), so the
//! storylines can be compared on what people actually did, and nothing
//! else about them.
//!
//! This is done to the YAML before the structs see it: the files are
//! expanded once per storyline, and everything downstream keeps its one
//! string per field. Keys that name things to the machine - a field's
//! `name`, a select's `options`, states, groups, paths - never vary.
//!
//! The lint holds every list to the arity, so a storyline cannot have
//! a hole in it.
use serde_yaml ::Value ;
/// Keys whose value is one string to a person.
2026-10-07 11:18:46 +02:00
const TEXT : & [ & str ] = & [
"name" ,
"description" ,
"label" ,
"placeholder" ,
"subject" ,
"body" ,
"empty" ,
"title" ,
];
2026-09-30 15:11:27 +02:00
/// Keys whose value is a list of strings to a person; their storylines
/// are a list of lists.
const TEXT_LIST : & [ & str ] = & [ "consequence" , "encouragements" ];
/// Where in a document a key is not text but an identifier: a
/// requirement's `name` is the stored field, never varied.
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.
2026-10-07 11:18:46 +02:00
key == "name"
&& path
. iter ()
. rev ()
. nth ( 2 )
. is_some_and ( | p | p == "requirements" )
2026-09-30 15:11:27 +02:00
}
fn is_strings ( seq : & [ Value ]) -> bool {
! seq . is_empty () && seq . iter (). all ( | v | v . is_string ())
}
fn is_lists_of_strings ( seq : & [ Value ]) -> bool {
2026-10-07 11:18:46 +02:00
! seq . is_empty ()
&& seq . iter (). all ( | v | {
v . as_sequence ()
. is_some_and ( | s | s . iter (). all ( | x | x . is_string ()))
})
2026-09-30 15:11:27 +02:00
}
/// The storyline counts found in a document, with where each was.
fn counts ( value : & Value , path : & mut Vec < String > , out : & mut Vec < ( String , usize ) > ) {
match value {
Value ::Mapping ( map ) => {
for ( k , v ) in map {
let key = k . as_str (). unwrap_or_default (). to_string ();
path . push ( key . clone ());
let variants = match v {
2026-10-07 11:18:46 +02:00
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 ())
}
2026-09-30 15:11:27 +02:00
_ => None ,
};
match variants {
Some ( n ) => out . push (( path . join ( "." ), n )),
None => counts ( v , path , out ),
}
path . pop ();
}
}
Value ::Sequence ( seq ) => {
for ( i , v ) in seq . iter (). enumerate () {
path . push ( i . to_string ());
counts ( v , path , out );
path . pop ();
}
}
_ => {}
}
}
/// One storyline of a document: every list of texts becomes its `n`th.
fn pick ( value : & mut Value , path : & mut Vec < String > , n : usize ) {
match value {
Value ::Mapping ( map ) => {
for ( k , v ) in map . iter_mut () {
let key = k . as_str (). unwrap_or_default (). to_string ();
path . push ( key . clone ());
let chosen = match v {
2026-10-07 11:18:46 +02:00
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 ())
}
2026-09-30 15:11:27 +02:00
_ => None ,
};
match chosen {
Some ( c ) => * v = c ,
None => pick ( v , path , n ),
}
path . pop ();
}
}
Value ::Sequence ( seq ) => {
for ( i , v ) in seq . iter_mut (). enumerate () {
path . push ( i . to_string ());
pick ( v , path , n );
path . pop ();
}
}
_ => {}
}
}
/// How many storylines these files tell: the length every list of
/// texts has, or 1 when there is none. A list of another length is an
/// error that names it.
pub fn arity ( files : & [( String , String )]) -> anyhow ::Result < usize > {
let mut found : Vec < ( String , String , usize ) > = Vec ::new ();
for ( rel , raw ) in files {
2026-10-07 11:18:46 +02:00
let Ok ( value ) = serde_yaml ::from_str ::< Value > ( raw ) else {
continue ;
};
2026-09-30 15:11:27 +02:00
let mut out = Vec ::new ();
counts ( & value , & mut Vec ::new (), & mut out );
found . extend ( out . into_iter (). map ( | ( p , n ) | ( rel . clone (), p , n )));
}
let arity = found . iter (). map ( | ( _ , _ , n ) | * n ). max (). unwrap_or ( 1 );
if let Some (( rel , p , n )) = found . iter (). find ( | ( _ , _ , n ) | * n != arity ) {
anyhow ::bail! ( "{rel}: {p} tells {n} storyline(s), but the site tells {arity}; every list of texts has the same length" );
}
Ok ( arity . max ( 1 ))
}
/// The files as storyline `n` tells them.
pub fn storyline ( files : & [( String , String )], n : usize ) -> Vec < ( String , String ) > {
files
. iter ()
. map ( | ( rel , raw ) | {
2026-10-07 11:18:46 +02:00
let Ok ( mut value ) = serde_yaml ::from_str ::< Value > ( raw ) else {
return ( rel . clone (), raw . clone ());
};
2026-09-30 15:11:27 +02:00
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 );
2026-10-07 11:18:46 +02:00
(
rel . clone (),
serde_yaml ::to_string ( & value ). unwrap_or_else ( | _ | raw . clone ()),
)
2026-09-30 15:11:27 +02:00
})
. collect ()
}
/// One thing per storyline, and the first as what the machinery reads
/// when nobody in particular is asking: a seed, a lint, a desk sweep.
#[derive(Clone, Debug)]
pub struct Storylines < T > {
pub each : Vec < T > ,
}
impl < T > Storylines < T > {
pub fn one ( item : T ) -> Self {
Storylines { each : vec ! [ item ] }
}
pub fn arity ( & self ) -> usize {
self . each . len (). max ( 1 )
}
/// Storyline `n`, or the last for an index from a site that told
/// more storylines than this one does.
pub fn at ( & self , n : usize ) -> & T {
& self . each [ n . min ( self . each . len () - 1 )]
}
}
impl < T > std ::ops ::Deref for Storylines < T > {
type Target = T ;
fn deref ( & self ) -> & T {
& self . each [ 0 ]
}
}
#[cfg(test)]
mod tests {
use super ::* ;
const PAGE : & str = "name: [Vil du hjelpe til?, Trenger naboene deg?] \n description: Alle trenger flere hender. \n alternatives: \n - name: [Bli med, Meld deg] \n description: [Si hva du kan., Si hva du har lyst på.] \n consequence: [[Send], [Meld meg]] \n features: \n - name: Skjema \n requirements: \n - name: [navn] \n label: [Navn, Hva heter du?] \n - name: hva \n type: select \n options: [Dugnad, Komité] \n " ;
#[test]
fn every_list_of_texts_is_a_storyline_and_identifiers_are_not () {
let files = vec! [( "index.yaml" . to_string (), PAGE . to_string ())];
assert_eq! ( arity ( & files ). unwrap (), 2 );
let second = storyline ( & files , 1 );
let v : Value = serde_yaml ::from_str ( & second [ 0 ]. 1 ). unwrap ();
assert_eq! ( v [ "name" ], "Trenger naboene deg?" );
assert_eq! ( v [ "description" ], "Alle trenger flere hender." );
assert_eq! ( v [ "alternatives" ][ 0 ][ "name" ], "Meld deg" );
2026-10-07 11:18:46 +02:00
assert_eq! (
v [ "alternatives" ][ 0 ][ "consequence" ],
serde_yaml ::to_value ( vec! [ "Meld meg" ]). unwrap ()
);
2026-09-30 15:11:27 +02:00
// A requirement's name is the stored field: left as written.
2026-10-07 11:18:46 +02:00
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?"
);
2026-09-30 15:11:27 +02:00
// Options are values people choose from and are stored: untouched.
2026-10-07 11:18:46 +02:00
assert_eq! (
v [ "alternatives" ][ 0 ][ "features" ][ 0 ][ "requirements" ][ 1 ][ "options" ],
serde_yaml ::to_value ( vec! [ "Dugnad" , "Komité" ]). unwrap ()
);
2026-09-30 15:11:27 +02:00
}
#[test]
fn a_site_with_no_lists_tells_one_storyline_and_a_short_list_is_refused () {
2026-10-07 11:18:46 +02:00
let plain = vec! [(
"index.yaml" . to_string (),
"name: Hva? \n alternatives: \n - name: A \n consequence: [Send] \n " . to_string (),
)];
2026-09-30 15:11:27 +02:00
assert_eq! ( arity ( & plain ). unwrap (), 1 );
assert_eq! ( storyline ( & plain , 0 )[ 0 ]. 1 , plain [ 0 ]. 1 );
let uneven = vec! [
( "index.yaml" . to_string (), PAGE . to_string ()),
( "b.yaml" . to_string (), "name: [En, To, Tre] \n " . to_string ()),
];
let err = arity ( & uneven ). unwrap_err (). to_string ();
2026-10-07 11:18:46 +02:00
assert! (
err . contains ( "b.yaml: name tells 3" ) && err . contains ( "tells 2" )
|| err . contains ( "tells 3" ),
"{err}"
);
2026-09-30 15:11:27 +02:00
}
#[test]
fn the_first_storyline_is_what_the_machinery_reads () {
2026-10-07 11:18:46 +02:00
let s = Storylines {
each : vec ! [ "a" , "b" ],
};
2026-09-30 15:11:27 +02:00
assert_eq! ( * s , "a" );
assert_eq! ( * s . at ( 1 ), "b" );
assert_eq! ( * s . at ( 7 ), "b" );
assert_eq! ( s . arity (), 2 );
}
}