Skip to main content

pacenotes_server/store/
publish_guards.rs

1//! What publishing a new manifest would take away from the live one without
2//! anyone deciding it, which `manifest gen` refuses unless told otherwise.
3
4/// Games the current manifest serves that `next` would take off the server
5/// entirely because their schema is missing or broken, typically after an
6/// upgrade that did not copy `schema.toml` in; a game whose packages all left
7/// on purpose keeps its schema and is not listed.
8pub fn unpublished_games(
9    current: &pacenotes_shared::Manifest,
10    next: &pacenotes_shared::Manifest,
11) -> Vec<String> {
12    let mut games: Vec<String> = current
13        .packages
14        .iter()
15        .map(|package| package.game.clone())
16        .filter(|game| !next.schemas.contains_key(game))
17        .filter(|game| !next.packages.iter().any(|package| &package.game == game))
18        .collect();
19    games.sort();
20    games.dedup();
21    games
22}
23
24/// Values the current manifest carries that publishing `next` would drop, one
25/// line per package and field.
26///
27/// `manifest gen` once carried every value forward, so a catalogue can hold
28/// metadata no sidecar has. A value a sidecar changed is not a loss, nor is a
29/// package whose zip is gone, nor a version nobody chose.
30pub fn metadata_losses(
31    current: &pacenotes_shared::Manifest,
32    next: &pacenotes_shared::Manifest,
33) -> Vec<String> {
34    let mut losses = Vec::new();
35    for old in &current.packages {
36        let Some(new) = next.packages.iter().find(|package| {
37            package.game == old.game
38                && package.directory == old.directory
39                && package.name == old.name
40        }) else {
41            continue;
42        };
43        let id = old.download_path();
44        if crate::store::is_chosen_version(&old.version)
45            && new
46                .version
47                .starts_with(crate::store::DERIVED_VERSION_PREFIX)
48        {
49            losses.push(format!("{id}: version {:?}", old.version));
50        }
51        let optional_fields = [
52            ("display_name", &old.display_name, &new.display_name),
53            ("description", &old.description, &new.description),
54            (
55                "exclusive_group",
56                &old.exclusive_group,
57                &new.exclusive_group,
58            ),
59        ];
60        for (field, before, after) in optional_fields {
61            if let (Some(value), None) = (before, after) {
62                losses.push(format!("{id}: {field} {value:?}"));
63            }
64        }
65        if !old.requires.is_empty() && new.requires.is_empty() {
66            losses.push(format!("{id}: requires {:?}", old.requires));
67        }
68    }
69    losses
70}
71
72#[cfg(test)]
73mod tests {
74    /// What a server upgraded without copying `schema.toml` in would publish.
75    #[test]
76    fn a_live_game_losing_its_schema_is_detected() {
77        let tmp = tempfile::tempdir().unwrap();
78        let dir = tmp.path();
79        crate::store::fixture_tree::write(
80            &crate::store::fixture_tree::package_path(dir, "rbr", "public/language/polish.zip"),
81            b"x",
82        );
83        let next = crate::store::fixture_tree::generate(dir).manifest;
84        assert!(next.packages.is_empty());
85        let live = crate::store::fixture_tree::legacy_manifest();
86        assert_eq!(super::unpublished_games(&live, &next), vec!["rbr"]);
87    }
88
89    /// A game whose packages all left on purpose still has its schema.
90    #[test]
91    fn a_game_emptied_on_purpose_is_not_unpublished() {
92        let tmp = tempfile::tempdir().unwrap();
93        let dir = tmp.path();
94        crate::store::fixture_tree::declare(dir, "rbr", &["language"]);
95        std::fs::create_dir_all(dir.join("rbr").join(crate::store::PACKAGES_DIR)).unwrap();
96        let next = crate::store::fixture_tree::generate(dir).manifest;
97        let live = crate::store::fixture_tree::legacy_manifest();
98        assert!(super::unpublished_games(&live, &next).is_empty());
99    }
100
101    fn basic_on_disk(dir: &std::path::Path, sidecar: Option<&[u8]>) -> pacenotes_shared::Manifest {
102        let zip = crate::store::fixture_tree::package_path(dir, "rbr", "public/basic.zip");
103        crate::store::fixture_tree::write_package(dir, "rbr", "public/basic.zip", b"hello");
104        let _ = std::fs::remove_file(zip.with_extension("toml"));
105        if let Some(sidecar) = sidecar {
106            crate::store::fixture_tree::write(&zip.with_extension("toml"), sidecar);
107        }
108        crate::store::fixture_tree::generate(dir).manifest
109    }
110
111    #[test]
112    fn dropping_manifest_only_metadata_is_detected() {
113        let tmp = tempfile::tempdir().unwrap();
114        let next = basic_on_disk(tmp.path(), None);
115
116        let losses = super::metadata_losses(&crate::store::fixture_tree::legacy_manifest(), &next)
117            .join("\n");
118        for field in [
119            "version",
120            "display_name",
121            "description",
122            "exclusive_group",
123            "requires",
124        ] {
125            assert!(losses.contains(field), "{field} missing from {losses}");
126        }
127        assert!(losses.contains("rbr/public/basic"), "{losses}");
128    }
129
130    /// The operator speaking, not a loss.
131    #[test]
132    fn a_value_a_sidecar_changed_is_not_a_loss() {
133        let tmp = tempfile::tempdir().unwrap();
134        let next = basic_on_disk(
135            tmp.path(),
136            Some(
137                br#"
138                    version = "3.0.0"
139                    display_name = "Other"
140                    description = "Other"
141                    exclusive_group = "other"
142                    requires = ["public/language/english"]
143                "#,
144            ),
145        );
146        let current = crate::store::fixture_tree::legacy_manifest();
147        assert!(super::metadata_losses(&current, &next).is_empty());
148    }
149
150    #[test]
151    fn the_legacy_default_version_is_not_a_loss() {
152        let tmp = tempfile::tempdir().unwrap();
153        let next = basic_on_disk(tmp.path(), None);
154        let mut defaulted = crate::store::fixture_tree::legacy_manifest();
155        let package = &mut defaulted.packages[0];
156        package.version = "0.0.0".into();
157        package.display_name = None;
158        package.description = None;
159        package.exclusive_group = None;
160        package.requires.clear();
161        assert!(super::metadata_losses(&defaulted, &next).is_empty());
162    }
163
164    /// Deleting a zip unpublishes the package, which is its own deliberate act.
165    #[test]
166    fn a_package_whose_zip_is_gone_is_not_a_loss() {
167        let current = crate::store::fixture_tree::legacy_manifest();
168        assert!(super::metadata_losses(&current, &Default::default()).is_empty());
169    }
170}