Skip to main content

pacenotes_shared/
package.rs

1/// One installable package as listed by `GET /packages`.
2#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
3pub struct PackageInfo {
4    /// The access directory, `[a-z0-9_-]+`.
5    pub directory: String,
6    /// The path under `directory` without `.zip`, e.g. `codriver/polish/male/Kamil`.
7    /// A lookup key: check it with `valid_name`, never join it onto a filesystem path.
8    pub name: String,
9    pub version: String,
10    /// Bytes in the zip.
11    pub size: u64,
12    /// Lowercase hex sha256 of the zip, which the installer verifies after downloading.
13    pub sha256: String,
14    /// Unix seconds.
15    pub updated_at: i64,
16    /// One of `GAME_*`.
17    #[serde(default = "crate::default_game")]
18    pub game: String,
19    /// Renames the leaf only; the levels above it always come from `name`.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub display_name: Option<String>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub description: Option<String>,
24    /// Packages in one group overwrite the same files, so at most one of them is installed.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub exclusive_group: Option<String>,
27    /// `PackageRef`s in their `directory/name` text form.
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub requires: Vec<String>,
30}
31
32impl PackageInfo {
33    pub fn is_public(&self) -> bool {
34        self.directory == crate::PUBLIC_DIR
35    }
36
37    pub fn package_ref(&self) -> crate::PackageRef {
38        crate::PackageRef {
39            directory: self.directory.clone(),
40            name: self.name.clone(),
41        }
42    }
43
44    pub fn segments(&self) -> std::str::Split<'_, char> {
45        self.name.split('/')
46    }
47
48    /// The first segment; a name without a `/` is its own category.
49    pub fn category(&self) -> &str {
50        self.name.split('/').next().unwrap_or(&self.name)
51    }
52
53    /// The segments between the category and the leaf; empty for `style/Descriptive`.
54    pub fn tree_levels(&self) -> Vec<&str> {
55        let segments: Vec<&str> = self.segments().collect();
56        match segments.len() {
57            0..=2 => Vec::new(),
58            count => segments[1..count - 1].to_vec(),
59        }
60    }
61
62    pub fn leaf(&self) -> &str {
63        self.name.rsplit('/').next().unwrap_or(&self.name)
64    }
65
66    /// `display_name` when set, otherwise the leaf, never the whole path.
67    pub fn label(&self) -> &str {
68        self.display_name.as_deref().unwrap_or_else(|| self.leaf())
69    }
70
71    /// The tail of `GET /download/<game>/<directory>/<name>`.
72    pub fn download_path(&self) -> String {
73        format!("{}/{}/{}", self.game, self.directory, self.name)
74    }
75
76    pub fn requires_ref(&self, reference: &crate::PackageRef) -> bool {
77        self.required_refs()
78            .iter()
79            .any(|required| required == reference)
80    }
81
82    /// Drops malformed entries, which `manifest gen` already warned the publisher about.
83    pub fn required_refs(&self) -> Vec<crate::PackageRef> {
84        self.requires
85            .iter()
86            .filter_map(|text| crate::PackageRef::parse(text))
87            .collect()
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    #[test]
94    fn a_name_is_the_packages_place_in_the_tree() {
95        let package = crate::fixtures::package(crate::PUBLIC_DIR, "codriver/polish/male/Kamil");
96        assert_eq!(package.category(), crate::CATEGORY_CODRIVER);
97        assert_eq!(package.tree_levels(), vec!["polish", "male"]);
98        assert_eq!(package.leaf(), "Kamil");
99        assert_eq!(package.label(), "Kamil");
100
101        let package = crate::fixtures::package(crate::PUBLIC_DIR, "style/Descriptive");
102        assert_eq!(package.category(), crate::CATEGORY_STYLE);
103        assert!(package.tree_levels().is_empty());
104        assert_eq!(package.leaf(), "Descriptive");
105
106        let package = crate::fixtures::package(crate::PUBLIC_DIR, "basic");
107        assert_eq!(package.category(), "basic");
108        assert!(package.tree_levels().is_empty());
109        assert_eq!(package.leaf(), "basic");
110    }
111
112    #[test]
113    fn round_trip_keeps_install_metadata() {
114        let mut package = crate::fixtures::package("premium-voices", "codriver/polish/male/Kamil");
115        package.game = crate::GAME_DIRT_RALLY2.into();
116        package.display_name = Some("Kamil Filipek".into());
117        package.exclusive_group = Some("pacenote-config".into());
118        package.requires = vec!["public/language/english".into()];
119
120        let json = serde_json::to_string(&package).unwrap();
121        let back: super::PackageInfo = serde_json::from_str(&json).unwrap();
122        assert_eq!(back.game, crate::GAME_DIRT_RALLY2);
123        assert_eq!(back.label(), "Kamil Filipek");
124        assert_eq!(back.exclusive_group.as_deref(), Some("pacenote-config"));
125        assert_eq!(
126            back.required_refs(),
127            vec![crate::PackageRef {
128                directory: "public".into(),
129                name: "language/english".into()
130            }]
131        );
132    }
133
134    /// Retired fields are ignored rather than rejected, because a newer installer reads an older cache.
135    #[test]
136    fn an_older_package_entry_still_deserializes() {
137        let old = r#"{"directory":"public","name":"basic",
138            "version":"1.0.0","size":5,"sha256":"ab","updated_at":1,
139            "category":"pacenotes","tree_path":["ignored"]}"#;
140        let package: super::PackageInfo = serde_json::from_str(old).unwrap();
141        assert_eq!(package.game, crate::GAME_RBR);
142        assert_eq!(package.label(), "basic");
143        assert!(package.display_name.is_none());
144        assert!(package.exclusive_group.is_none());
145        assert!(package.requires.is_empty());
146        assert_eq!(package.category(), "basic");
147        assert!(package.tree_levels().is_empty());
148    }
149
150    #[test]
151    fn empty_metadata_is_not_serialized() {
152        let json =
153            serde_json::to_string(&crate::fixtures::package(crate::PUBLIC_DIR, "basic")).unwrap();
154        assert!(!json.contains("display_name"), "{json}");
155        assert!(!json.contains("exclusive_group"), "{json}");
156        assert!(!json.contains("requires"), "{json}");
157        assert!(json.contains("\"game\":\"rbr\""), "{json}");
158        assert!(!json.contains("category"), "{json}");
159        assert!(!json.contains("tree_path"), "{json}");
160    }
161
162    #[test]
163    fn malformed_requires_are_dropped_not_fatal() {
164        let mut package = crate::fixtures::package(crate::PUBLIC_DIR, "voice");
165        package.requires = vec!["public/lang".into(), "garbage".into()];
166        assert_eq!(package.required_refs().len(), 1);
167    }
168
169    #[test]
170    fn download_path_includes_the_game() {
171        let mut package = crate::fixtures::package(crate::PUBLIC_DIR, "codriver/polish/male/Kamil");
172        assert_eq!(
173            package.download_path(),
174            "rbr/public/codriver/polish/male/Kamil"
175        );
176        package.game = crate::GAME_BEAMNG.into();
177        assert_eq!(
178            package.download_path(),
179            "beamng/public/codriver/polish/male/Kamil"
180        );
181    }
182}