pacenotes_shared/
package.rs1#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
3pub struct PackageInfo {
4 pub directory: String,
6 pub name: String,
9 pub version: String,
10 pub size: u64,
12 pub sha256: String,
14 pub updated_at: i64,
16 #[serde(default = "crate::default_game")]
18 pub game: String,
19 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub exclusive_group: Option<String>,
27 #[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 pub fn category(&self) -> &str {
50 self.name.split('/').next().unwrap_or(&self.name)
51 }
52
53 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 pub fn label(&self) -> &str {
68 self.display_name.as_deref().unwrap_or_else(|| self.leaf())
69 }
70
71 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 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 #[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}