Skip to main content

pacenotes_installer/
schema.rs

1//! The install schemas this build embeds, compiled from `schemas/<game>.toml`
2//! by `build.rs`, and the one comparison made against the server's.
3//!
4//! A game's schema says which categories its catalogue has and which ask for a
5//! single pick; the installer takes that from its own copy, never the server's.
6//! The manifest's per-game hash of the schema's raw bytes is only compared: an
7//! unknown one stops DOWNLOADING that game, and nothing else.
8
9pub struct Schema {
10    pub game: &'static str,
11    /// The file's exact bytes, which the hash covers.
12    pub toml: &'static [u8],
13    pub categories: &'static [Category],
14}
15
16pub struct Category {
17    pub id: &'static str,
18    /// `pacenotes_shared::SELECT_ONE` or `SELECT_ANY`.
19    pub select: &'static str,
20    /// A token such as `pacenotes_shared::INSTALL_ROOT_GAME`, never a path.
21    pub install_root: &'static str,
22}
23
24include!(concat!(env!("OUT_DIR"), "/schema_table.rs"));
25
26/// Older revisions this build still accepts, per game, each with a line saying
27/// why the change does not matter; empty while `schemas/rbr.toml` has one revision.
28const ALSO_ACCEPTED: &[(&str, &[&str])] = &[];
29
30pub fn of(game: &str) -> Option<&'static Schema> {
31    SCHEMAS.iter().find(|schema| schema.game == game)
32}
33
34/// Of the schema file this build was compiled with.
35pub fn sha256_of(game: &str) -> Option<String> {
36    of(game).map(|schema| crate::sha256::hex_digest(schema.toml))
37}
38
39/// The schema we carry, or an older revision deemed compatible.
40pub fn accepts(game: &str, sha256: &str) -> bool {
41    if sha256_of(game).is_some_and(|ours| ours == sha256) {
42        return true;
43    }
44    ALSO_ACCEPTED
45        .iter()
46        .any(|(id, hashes)| *id == game && hashes.contains(&sha256))
47}
48
49/// The questions scene 5 asks and scene 4 demands a tick for, in schema order;
50/// everything else is optional, so an operator's new folder adds no question.
51pub fn choice_categories(game: &str) -> Vec<&'static str> {
52    categories(game)
53        .iter()
54        .filter(|category| category.select == pacenotes_shared::SELECT_ONE)
55        .map(|category| category.id)
56        .collect()
57}
58
59/// In file order, which is the order the tree shows them in.
60pub fn categories(game: &str) -> &'static [Category] {
61    of(game).map(|schema| schema.categories).unwrap_or(&[])
62}
63
64/// Whether every category installs somewhere this build knows how to place.
65pub fn install_roots_known(game: &str) -> bool {
66    categories(game)
67        .iter()
68        .all(|category| category.install_root == pacenotes_shared::INSTALL_ROOT_GAME)
69}
70
71/// Why a game's catalogue cannot be downloaded.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub enum Incompatible {
74    /// The server predates schemas, or publishes nothing for the game.
75    NoSchemaPublished,
76    GameUnknown,
77    Different {
78        ours: String,
79        theirs: String,
80        /// Declared by the server and unknown here, usually the whole explanation.
81        unknown_categories: Vec<String>,
82    },
83}
84
85/// An error blocks downloads for this game only; the caller leaves everything else alone.
86pub fn check(
87    game: &str,
88    published: Option<&pacenotes_shared::GameSchema>,
89) -> Result<(), Incompatible> {
90    let Some(published) = published else {
91        return Err(Incompatible::NoSchemaPublished);
92    };
93    if of(game).is_none() {
94        return Err(Incompatible::GameUnknown);
95    }
96    if accepts(game, &published.sha256) {
97        return Ok(());
98    }
99    let known: Vec<&str> = categories(game)
100        .iter()
101        .map(|category| category.id)
102        .collect();
103    Err(Incompatible::Different {
104        ours: sha256_of(game).unwrap_or_default(),
105        theirs: published.sha256.clone(),
106        unknown_categories: published
107            .categories
108            .iter()
109            .filter(|category| !known.contains(&category.id.as_str()))
110            .map(|category| category.id.clone())
111            .collect(),
112    })
113}
114
115#[cfg(test)]
116mod tests;