225 lines
9.3 KiB
Rust
225 lines
9.3 KiB
Rust
//! 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.
|
||
|
|
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"];
|
||
|
|
|
||
|
|
/// 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.
|
||
|
|
key == "name" && path.iter().rev().nth(2).is_some_and(|p| p == "requirements")
|
||
|
|
}
|
||
|
|
|
||
|
|
fn is_strings(seq: &[Value]) -> bool {
|
||
|
|
!seq.is_empty() && seq.iter().all(|v| v.is_string())
|
||
|
|
}
|
||
|
|
|
||
|
|
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())))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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 {
|
||
|
|
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 {
|
||
|
|
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 {
|
||
|
|
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 {
|
||
|
|
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 {
|
||
|
|
let Ok(value) = serde_yaml::from_str::<Value>(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)));
|
||
|
|
}
|
||
|
|
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)| {
|
||
|
|
let Ok(mut value) = serde_yaml::from_str::<Value>(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()))
|
||
|
|
})
|
||
|
|
.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?]\ndescription: Alle trenger flere hender.\nalternatives:\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");
|
||
|
|
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?");
|
||
|
|
// 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());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[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())];
|
||
|
|
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();
|
||
|
|
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"] };
|
||
|
|
assert_eq!(*s, "a");
|
||
|
|
assert_eq!(*s.at(1), "b");
|
||
|
|
assert_eq!(*s.at(7), "b");
|
||
|
|
assert_eq!(s.arity(), 2);
|
||
|
|
}
|
||
|
|
}
|