Files
portal/src/content/variants.rs
T

291 lines
10 KiB
Rust
Raw Normal View History

//! 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);
}
}