thermite/
model.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{self, Value};
3use std::{
4    collections::{BTreeMap, HashMap},
5    hash::{Hash, Hasher},
6};
7use std::{
8    fs,
9    path::{Path, PathBuf},
10};
11
12use crate::{error::ThermiteError, CORE_MODS};
13
14#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
15#[serde(rename_all = "PascalCase")]
16pub struct ModJSON {
17    pub name: String,
18    pub description: String,
19    pub version: String,
20    pub load_priority: Option<i32>,
21    pub required_on_client: Option<bool>,
22    #[serde(default)]
23    pub con_vars: Vec<Value>,
24    #[serde(default)]
25    pub scripts: Vec<Value>,
26    #[serde(default)]
27    pub localisation: Vec<String>,
28    #[serde(flatten)]
29    pub _extra: HashMap<String, Value>,
30}
31
32#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
33pub struct Mod {
34    pub name: String,
35    ///The latest version of the mod
36    pub latest: String,
37    #[serde(default)]
38    pub installed: bool,
39    #[serde(default)]
40    pub upgradable: bool,
41    #[serde(default)]
42    pub global: bool,
43    ///A map of each version of a mod
44    pub versions: BTreeMap<String, ModVersion>,
45    pub author: String,
46}
47
48impl Mod {
49    #[must_use]
50    pub fn get_latest(&self) -> Option<&ModVersion> {
51        self.versions.get(&self.latest)
52    }
53
54    #[must_use]
55    pub fn get_version(&self, version: impl AsRef<str>) -> Option<&ModVersion> {
56        self.versions.get(version.as_ref())
57    }
58}
59
60#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
61pub struct ModVersion {
62    pub name: String,
63    pub full_name: String,
64    pub version: String,
65    pub url: String,
66    pub desc: String,
67    pub deps: Vec<String>,
68    pub installed: bool,
69    pub global: bool,
70    pub file_size: u64,
71}
72
73impl ModVersion {
74    #[must_use]
75    pub fn file_size_string(&self) -> String {
76        if self.file_size / 1_000_000 >= 1 {
77            let size = self.file_size / 1_048_576;
78
79            format!("{size:.2} MB")
80        } else {
81            let size = self.file_size / 1024;
82            format!("{size:.2} KB")
83        }
84    }
85}
86
87impl From<&Self> for ModVersion {
88    fn from(value: &Self) -> Self {
89        value.clone()
90    }
91}
92
93impl AsRef<Self> for ModVersion {
94    fn as_ref(&self) -> &Self {
95        self
96    }
97}
98
99#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
100pub struct Manifest {
101    pub name: String,
102    pub version_number: String,
103    pub website_url: String,
104    pub description: String,
105    pub dependencies: Vec<String>,
106}
107
108// enabledmods.json
109
110/// Represents an enabledmods.json file. Core mods will default to `true` if not present when deserializing.
111#[derive(Clone, Debug, Deserialize, Serialize)]
112pub struct EnabledMods {
113    #[serde(rename = "Northstar.Client", default = "default_mod_state")]
114    pub client: bool,
115    #[serde(rename = "Northstar.Custom", default = "default_mod_state")]
116    pub custom: bool,
117    #[serde(rename = "Northstar.CustomServers", default = "default_mod_state")]
118    pub servers: bool,
119    #[serde(flatten)]
120    pub mods: BTreeMap<String, bool>,
121    ///Path to the file to read & write
122    #[serde(skip)]
123    path: Option<PathBuf>,
124}
125
126fn default_mod_state() -> bool {
127    true
128}
129
130impl Hash for EnabledMods {
131    fn hash<H: Hasher>(&self, state: &mut H) {
132        self.client.hash(state);
133        self.custom.hash(state);
134        self.servers.hash(state);
135        self.mods.hash(state);
136    }
137}
138
139impl Default for EnabledMods {
140    fn default() -> Self {
141        Self {
142            client: true,
143            custom: true,
144            servers: true,
145            mods: BTreeMap::new(),
146            path: None,
147        }
148    }
149}
150
151impl EnabledMods {
152    /// Attempts to read an `EnabledMods` from the path
153    ///
154    /// # Errors
155    /// - The file doesn't exist
156    /// - The file isn't formatted properly
157    pub fn load(path: impl AsRef<Path>) -> Result<Self, ThermiteError> {
158        let raw = fs::read_to_string(path)?;
159
160        json5::from_str(&raw).map_err(Into::into)
161    }
162
163    /// Returns a default `EnabledMods` with the path property set
164    pub fn default_with_path(path: impl AsRef<Path>) -> Self {
165        Self {
166            path: Some(path.as_ref().to_path_buf()),
167            ..Default::default()
168        }
169    }
170    /// Saves the file using the path it was loaded from
171    ///
172    /// # Errors
173    /// - If the path isn't set
174    /// - If there is an IO error
175    pub fn save(&self) -> Result<(), ThermiteError> {
176        let parsed = serde_json::to_string_pretty(self)?;
177        if let Some(path) = &self.path {
178            if let Some(p) = path.parent() {
179                fs::create_dir_all(p)?;
180            }
181
182            fs::write(path, parsed)?;
183            Ok(())
184        } else {
185            Err(ThermiteError::MissingPath)
186        }
187    }
188
189    /// Saves the file using the provided path
190    ///
191    /// # Errors
192    /// - If there is an IO error
193    #[deprecated(
194        since = "0.9",
195        note = "prefer explicitly setting the path and then saving"
196    )]
197    pub fn save_with_path(&mut self, path: impl AsRef<Path>) -> Result<(), ThermiteError> {
198        self.path = Some(path.as_ref().to_owned());
199        self.save()
200    }
201
202    /// Path the file will be written to
203    #[must_use]
204    pub const fn path(&self) -> Option<&PathBuf> {
205        self.path.as_ref()
206    }
207
208    pub fn set_path(&mut self, path: impl Into<Option<PathBuf>>) {
209        self.path = path.into();
210    }
211
212    /// Returns the current state of a mod
213    ///
214    /// # Warning
215    /// Returns `true` if a mod is missing from the file
216    pub fn is_enabled(&self, name: impl AsRef<str>) -> bool {
217        self.mods.get(name.as_ref()).copied().unwrap_or(true)
218    }
219
220    /// Get the current state of a mod if it exists
221    pub fn get(&self, name: impl AsRef<str>) -> Option<bool> {
222        if CORE_MODS.contains(&name.as_ref()) {
223            Some(match name.as_ref() {
224                "Northstar.Client" => self.client,
225                "Northstar.Custom" => self.custom,
226                "Northstar.CustomServers" => self.servers,
227                _ => unimplemented!(),
228            })
229        } else {
230            self.mods.get(name.as_ref()).copied()
231        }
232    }
233
234    /// Updates or inserts a mod's state
235    pub fn set(&mut self, name: impl AsRef<str>, val: bool) -> Option<bool> {
236        if CORE_MODS.contains(&name.as_ref().to_lowercase().as_str()) {
237            let prev = self.get(&name);
238            match name.as_ref().to_lowercase().as_str() {
239                "northstar.client" => self.client = val,
240                "northstar.custom" => self.custom = val,
241                "northstar.customservers" => self.servers = val,
242                _ => unimplemented!(),
243            }
244            prev
245        } else {
246            self.mods.insert(name.as_ref().to_string(), val)
247        }
248    }
249}
250
251/// Represents an installed package
252#[derive(Debug, Clone, PartialEq, Eq, Default)]
253pub struct InstalledMod {
254    pub manifest: Manifest,
255    pub mod_json: ModJSON,
256    pub author: String,
257    pub path: PathBuf,
258}
259
260impl PartialOrd for InstalledMod {
261    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
262        Some(self.cmp(other))
263    }
264}
265
266/// [InstalledMod]s are ordered by their author, then manifest name, then mod.json name
267impl Ord for InstalledMod {
268    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
269        match self.author.cmp(&other.author) {
270            std::cmp::Ordering::Equal => match self.manifest.name.cmp(&other.manifest.name) {
271                std::cmp::Ordering::Equal => self.mod_json.name.cmp(&other.mod_json.name),
272                ord => ord,
273            },
274            ord => ord,
275        }
276    }
277}
278
279#[cfg(test)]
280mod test {
281    use std::collections::HashMap;
282
283    use crate::core::utils::TempDir;
284
285    use super::{EnabledMods, InstalledMod, Manifest, ModJSON};
286
287    const TEST_MOD_JSON: &str = r#"{
288        "Name": "Test",
289        "Description": "Test",
290        "Version": "0.1.0",
291        "LoadPriority": 1,
292        "RequiredOnClient": false,
293        "ConVars": [],
294        "Scripts": [],
295        "Localisation": []
296    }"#;
297
298    #[test]
299    fn serialize_mod_json() {
300        let test_data = ModJSON {
301            name: "Test".into(),
302            description: "Test".into(),
303            version: "0.1.0".into(),
304            load_priority: 1.into(),
305            required_on_client: false.into(),
306            con_vars: vec![],
307            scripts: vec![],
308            localisation: vec![],
309            _extra: HashMap::new(),
310        };
311
312        let ser = json5::to_string(&test_data);
313
314        assert!(ser.is_ok());
315    }
316
317    #[test]
318    fn deserialize_mod_json() {
319        let test_data = ModJSON {
320            name: "Test".into(),
321            description: "Test".into(),
322            version: "0.1.0".into(),
323            load_priority: 1.into(),
324            required_on_client: false.into(),
325            con_vars: vec![],
326            scripts: vec![],
327            localisation: vec![],
328            _extra: HashMap::new(),
329        };
330
331        let de = json5::from_str::<ModJSON>(TEST_MOD_JSON);
332
333        assert!(de.is_ok());
334        assert_eq!(test_data, de.unwrap());
335    }
336
337    const TEST_MANIFEST: &str = r#"{
338        "name": "Test",
339        "version_number": "0.1.0",
340        "website_url": "https://example.com",
341        "description": "Test",
342        "dependencies": []
343    }"#;
344
345    #[test]
346    fn deserialize_manifest() {
347        let expected = Manifest {
348            name: "Test".into(),
349            version_number: "0.1.0".into(),
350            website_url: "https://example.com".into(),
351            description: "Test".into(),
352            dependencies: vec![],
353        };
354
355        let de = json5::from_str(TEST_MANIFEST);
356
357        assert!(de.is_ok());
358        assert_eq!(expected, de.unwrap());
359    }
360
361    #[test]
362    fn save_enabled_mods() {
363        let dir =
364            TempDir::create("./test_autosave_enabled_mods").expect("Unable to create temp dir");
365        let path = dir.join("enabled_mods.json");
366        {
367            let mut mods = EnabledMods::default_with_path(&path);
368            mods.set("TestMod", false);
369            mods.save().expect("Write enabledmods.json");
370        }
371
372        let mods = EnabledMods::load(&path);
373
374        if let Err(e) = mods {
375            panic!("Failed to load enabled_mods: {e}");
376        }
377
378        let test_mod = mods.unwrap().get("TestMod");
379        assert!(test_mod.is_some());
380        // this value should be false, so we assert the inverse
381        assert!(!test_mod.unwrap());
382    }
383
384    #[test]
385    fn mod_ordering_by_author() {
386        let author1 = "hello".to_string();
387        let author2 = "world".to_string();
388
389        let expected = author1.cmp(&author2);
390
391        let mod1 = InstalledMod {
392            author: author1,
393            ..Default::default()
394        };
395
396        let mod2 = InstalledMod {
397            author: author2,
398            ..Default::default()
399        };
400
401        assert_eq!(expected, mod1.cmp(&mod2));
402    }
403
404    #[test]
405    fn mod_ordering_by_manifest_name() {
406        let author = "foo".to_string();
407
408        let name1 = "hello".to_string();
409        let name2 = "world".to_string();
410
411        let expected = name1.cmp(&name2);
412
413        let mod1 = InstalledMod {
414            author: author.clone(),
415            manifest: Manifest {
416                name: name1,
417                ..Default::default()
418            },
419            ..Default::default()
420        };
421
422        let mod2 = InstalledMod {
423            author: author.clone(),
424            manifest: Manifest {
425                name: name2,
426                ..Default::default()
427            },
428            ..Default::default()
429        };
430
431        assert_eq!(expected, mod1.cmp(&mod2));
432    }
433
434    #[test]
435    fn mod_ordering_by_mod_json_name() {
436        let author = "foo".to_string();
437        let manifest = Manifest {
438            name: "bar".to_string(),
439            ..Default::default()
440        };
441
442        let name1 = "hello".to_string();
443        let name2 = "world".to_string();
444
445        let expected = name1.cmp(&name2);
446
447        let mod1 = InstalledMod {
448            author: author.clone(),
449            manifest: manifest.clone(),
450            mod_json: ModJSON {
451                name: name1,
452                ..Default::default()
453            },
454            ..Default::default()
455        };
456
457        let mod2 = InstalledMod {
458            author: author.clone(),
459            manifest: manifest,
460            mod_json: ModJSON {
461                name: name2,
462                ..Default::default()
463            },
464            ..Default::default()
465        };
466
467        assert_eq!(expected, mod1.cmp(&mod2));
468    }
469}