Skip to main content

opys_bundle/
option.rs

1//! Options: what a bundle lets whoever launches it choose.
2//!
3//! A manifest's rules test features and its strings name variables, and
4//! nothing in it says which of those are a player's to set. A bundle's head
5//! does: a tree of options, each with what a launcher needs to draw it. An
6//! option fills a variable, except a `feature`, which switches a feature on
7//! and holds the options that only matter while it is.
8//!
9//! This is the schema alone. What was chosen, and turning that into the
10//! `vars` and `features` an install takes, belongs to whoever shows the form.
11
12use std::collections::BTreeSet;
13
14use serde::{Deserialize, Serialize};
15
16use crate::localized::Localized;
17
18/// One value a `select` offers, and what to call it.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct Choice {
22    pub value: String,
23    pub label: Localized,
24}
25
26/// What every option says about itself to the person choosing.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Label {
29    pub title: Localized,
30    pub subtitle: Option<Localized>,
31}
32
33/// One option. `name` is the variable it fills or, for a `Feature`, the
34/// feature it switches.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(try_from = "OptionWire", into = "OptionWire")]
37pub enum OptionDef {
38    Slider {
39        name: String,
40        label: Label,
41        min: f64,
42        max: f64,
43        step: f64,
44        default: f64,
45        unit: Option<Localized>,
46    },
47    Select {
48        name: String,
49        label: Label,
50        choices: Vec<Choice>,
51        default: String,
52    },
53    Text {
54        name: String,
55        label: Label,
56        placeholder: Option<Localized>,
57        default: Option<String>,
58    },
59    File {
60        name: String,
61        label: Label,
62    },
63    Directory {
64        name: String,
65        label: Label,
66    },
67    Feature {
68        name: String,
69        label: Label,
70        default: bool,
71        options: Vec<OptionDef>,
72    },
73}
74
75impl OptionDef {
76    pub fn name(&self) -> &str {
77        match self {
78            OptionDef::Slider { name, .. }
79            | OptionDef::Select { name, .. }
80            | OptionDef::Text { name, .. }
81            | OptionDef::File { name, .. }
82            | OptionDef::Directory { name, .. }
83            | OptionDef::Feature { name, .. } => name,
84        }
85    }
86
87    pub fn label(&self) -> &Label {
88        match self {
89            OptionDef::Slider { label, .. }
90            | OptionDef::Select { label, .. }
91            | OptionDef::Text { label, .. }
92            | OptionDef::File { label, .. }
93            | OptionDef::Directory { label, .. }
94            | OptionDef::Feature { label, .. } => label,
95        }
96    }
97
98    /// Every text a player reads on this option and the ones under it.
99    pub fn texts(&self) -> Vec<&Localized> {
100        let label = self.label();
101        let mut texts: Vec<&Localized> = std::iter::once(&label.title)
102            .chain(label.subtitle.as_ref())
103            .collect();
104        match self {
105            OptionDef::Slider { unit, .. } => texts.extend(unit),
106            OptionDef::Select { choices, .. } => texts.extend(choices.iter().map(|c| &c.label)),
107            OptionDef::Text { placeholder, .. } => texts.extend(placeholder),
108            OptionDef::Feature { options, .. } => {
109                texts.extend(options.iter().flat_map(OptionDef::texts))
110            }
111            OptionDef::File { .. } | OptionDef::Directory { .. } => {}
112        }
113        texts
114    }
115}
116
117/// Discriminated by which field is present, like every wire shape here, and
118/// that field holds the name: `{ "slider": "xmx", … }`. A struct of options
119/// rather than an untagged enum, so a field on the wrong kind is named.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub(crate) struct OptionWire {
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    slider: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    select: Option<String>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    text: Option<String>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    file: Option<String>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    directory: Option<String>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    feature: Option<String>,
135
136    title: Localized,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    subtitle: Option<Localized>,
139
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    min: Option<f64>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    max: Option<f64>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    step: Option<f64>,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    unit: Option<Localized>,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    choices: Option<Vec<Choice>>,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    placeholder: Option<Localized>,
152    /// A number on a slider, a string on a select or a text, a boolean on a
153    /// feature: the one field whose type depends on the kind.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    default: Option<serde_json::Value>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    options: Option<Vec<OptionDef>>,
158}
159
160const KINDS: &str = "`slider`, `select`, `text`, `file`, `directory`, `feature`";
161
162impl TryFrom<OptionWire> for OptionDef {
163    type Error = String;
164
165    fn try_from(raw: OptionWire) -> Result<Self, Self::Error> {
166        let kinds = [
167            ("slider", &raw.slider),
168            ("select", &raw.select),
169            ("text", &raw.text),
170            ("file", &raw.file),
171            ("directory", &raw.directory),
172            ("feature", &raw.feature),
173        ];
174        let mut named = kinds
175            .iter()
176            .filter_map(|(kind, name)| name.as_ref().map(|name| (*kind, name.clone())));
177        let (kind, name) = named
178            .next()
179            .ok_or_else(|| format!("an option is one of {KINDS}, and this names none"))?;
180        if let Some((other, _)) = named.next() {
181            return Err(format!(
182                "an option is one kind, and this is both `{kind}` and `{other}`"
183            ));
184        }
185
186        // Each kind takes what is its own and refuses the rest, so a `min` on
187        // a select is an error rather than a field nothing reads.
188        let present = [
189            ("min", raw.min.is_some()),
190            ("max", raw.max.is_some()),
191            ("step", raw.step.is_some()),
192            ("unit", raw.unit.is_some()),
193            ("choices", raw.choices.is_some()),
194            ("placeholder", raw.placeholder.is_some()),
195            ("default", raw.default.is_some()),
196            ("options", raw.options.is_some()),
197        ];
198        let allowed: &[&str] = match kind {
199            "slider" => &["min", "max", "step", "unit", "default"],
200            "select" => &["choices", "default"],
201            "text" => &["placeholder", "default"],
202            "feature" => &["default", "options"],
203            _ => &[],
204        };
205        if let Some((field, _)) = present
206            .iter()
207            .find(|(field, is)| *is && !allowed.contains(field))
208        {
209            return Err(format!("`{field}` does not belong on a {kind} (`{name}`)"));
210        }
211
212        let missing = |field: &str| format!("{kind} `{name}` has no `{field}`");
213        let label = Label {
214            title: raw.title,
215            subtitle: raw.subtitle,
216        };
217        Ok(match kind {
218            "slider" => OptionDef::Slider {
219                min: raw.min.ok_or_else(|| missing("min"))?,
220                max: raw.max.ok_or_else(|| missing("max"))?,
221                step: raw.step.ok_or_else(|| missing("step"))?,
222                default: raw
223                    .default
224                    .ok_or_else(|| missing("default"))?
225                    .as_f64()
226                    .ok_or_else(|| format!("the `default` of slider `{name}` is a number"))?,
227                unit: raw.unit,
228                name,
229                label,
230            },
231            "select" => OptionDef::Select {
232                choices: raw.choices.ok_or_else(|| missing("choices"))?,
233                default: match raw.default.ok_or_else(|| missing("default"))? {
234                    serde_json::Value::String(value) => value,
235                    _ => return Err(format!("the `default` of select `{name}` is a string")),
236                },
237                name,
238                label,
239            },
240            "text" => OptionDef::Text {
241                placeholder: raw.placeholder,
242                default: match raw.default {
243                    None => None,
244                    Some(serde_json::Value::String(value)) => Some(value),
245                    Some(_) => return Err(format!("the `default` of text `{name}` is a string")),
246                },
247                name,
248                label,
249            },
250            "file" => OptionDef::File { name, label },
251            "directory" => OptionDef::Directory { name, label },
252            _ => OptionDef::Feature {
253                default: match raw.default {
254                    None => false,
255                    Some(serde_json::Value::Bool(value)) => value,
256                    Some(_) => {
257                        return Err(format!("the `default` of feature `{name}` is a boolean"))
258                    }
259                },
260                options: raw.options.unwrap_or_default(),
261                name,
262                label,
263            },
264        })
265    }
266}
267
268impl From<OptionDef> for OptionWire {
269    fn from(option: OptionDef) -> Self {
270        let labelled = |label: Label| OptionWire {
271            title: label.title,
272            subtitle: label.subtitle,
273            slider: None,
274            select: None,
275            text: None,
276            file: None,
277            directory: None,
278            feature: None,
279            min: None,
280            max: None,
281            step: None,
282            unit: None,
283            choices: None,
284            placeholder: None,
285            default: None,
286            options: None,
287        };
288        match option {
289            OptionDef::Slider {
290                name,
291                label,
292                min,
293                max,
294                step,
295                default,
296                unit,
297            } => OptionWire {
298                slider: Some(name),
299                min: Some(min),
300                max: Some(max),
301                step: Some(step),
302                default: Some(default.into()),
303                unit,
304                ..labelled(label)
305            },
306            OptionDef::Select {
307                name,
308                label,
309                choices,
310                default,
311            } => OptionWire {
312                select: Some(name),
313                choices: Some(choices),
314                default: Some(default.into()),
315                ..labelled(label)
316            },
317            OptionDef::Text {
318                name,
319                label,
320                placeholder,
321                default,
322            } => OptionWire {
323                text: Some(name),
324                placeholder,
325                default: default.map(Into::into),
326                ..labelled(label)
327            },
328            OptionDef::File { name, label } => OptionWire {
329                file: Some(name),
330                ..labelled(label)
331            },
332            OptionDef::Directory { name, label } => OptionWire {
333                directory: Some(name),
334                ..labelled(label)
335            },
336            // What a feature leaves out reads back as what it was: off, and
337            // holding nothing.
338            OptionDef::Feature {
339                name,
340                label,
341                default,
342                options,
343            } => OptionWire {
344                feature: Some(name),
345                default: default.then_some(true.into()),
346                options: (!options.is_empty()).then_some(options),
347                ..labelled(label)
348            },
349        }
350    }
351}
352
353/// A bundle's options, checked as a whole: a tree that has been read is one a
354/// form can be drawn from without looking at it twice.
355#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
356#[serde(try_from = "Vec<OptionDef>", into = "Vec<OptionDef>")]
357pub struct Options(Vec<OptionDef>);
358
359impl Options {
360    /// Refuses a tree that would mean two things or nothing: a variable or a
361    /// feature named twice, a slider with no range, a select whose default is
362    /// not among its choices.
363    pub fn new(options: Vec<OptionDef>) -> Result<Self, String> {
364        let mut seen = Seen::default();
365        options
366            .iter()
367            .try_for_each(|option| check(option, &mut seen))?;
368        Ok(Options(options))
369    }
370
371    pub fn is_empty(&self) -> bool {
372        self.0.is_empty()
373    }
374
375    pub fn as_slice(&self) -> &[OptionDef] {
376        &self.0
377    }
378
379    /// Every text a player reads anywhere in the tree.
380    pub fn texts(&self) -> Vec<&Localized> {
381        self.0.iter().flat_map(OptionDef::texts).collect()
382    }
383}
384
385impl TryFrom<Vec<OptionDef>> for Options {
386    type Error = String;
387
388    fn try_from(options: Vec<OptionDef>) -> Result<Self, Self::Error> {
389        Options::new(options)
390    }
391}
392
393impl From<Options> for Vec<OptionDef> {
394    fn from(options: Options) -> Self {
395        options.0
396    }
397}
398
399/// The names taken so far. A variable and a feature are named apart, since
400/// nothing reads one where it reads the other.
401#[derive(Default)]
402struct Seen<'a> {
403    vars: BTreeSet<&'a str>,
404    features: BTreeSet<&'a str>,
405}
406
407fn check<'a>(option: &'a OptionDef, seen: &mut Seen<'a>) -> Result<(), String> {
408    let name = option.name();
409    if name.is_empty() {
410        return Err("an option has an empty name".to_owned());
411    }
412    let (taken, what) = match option {
413        OptionDef::Feature { .. } => (&mut seen.features, "feature"),
414        _ => (&mut seen.vars, "variable"),
415    };
416    if !taken.insert(name) {
417        return Err(format!("{what} `{name}` is an option twice"));
418    }
419
420    match option {
421        OptionDef::Slider {
422            min,
423            max,
424            step,
425            default,
426            ..
427        } => {
428            if ![min, max, step, default].iter().all(|n| n.is_finite()) {
429                return Err(format!("slider `{name}` has a number that is not finite"));
430            }
431            if min >= max {
432                return Err(format!("slider `{name}` has no range: {min} to {max}"));
433            }
434            if *step <= 0.0 {
435                return Err(format!("slider `{name}` has a step of {step}"));
436            }
437            if default < min || default > max {
438                return Err(format!(
439                    "the default of slider `{name}`, {default}, is outside {min} to {max}"
440                ));
441            }
442        }
443        OptionDef::Select {
444            choices, default, ..
445        } => {
446            let mut values = BTreeSet::new();
447            if let Some(twice) = choices.iter().find(|c| !values.insert(c.value.as_str())) {
448                return Err(format!("select `{name}` offers `{}` twice", twice.value));
449            }
450            if !values.contains(default.as_str()) {
451                return Err(format!(
452                    "the default of select `{name}`, `{default}`, is not one of its choices"
453                ));
454            }
455        }
456        OptionDef::Feature { options, .. } => {
457            options.iter().try_for_each(|child| check(child, seen))?;
458        }
459        OptionDef::Text { .. } | OptionDef::File { .. } | OptionDef::Directory { .. } => {}
460    }
461    Ok(())
462}