thermite/core/
utils.rs

1use crate::error::ThermiteError;
2use crate::model::EnabledMods;
3use crate::model::InstalledMod;
4use crate::model::Manifest;
5use crate::model::Mod;
6
7use regex::Regex;
8use std::fmt::Debug;
9use std::fs;
10use std::ops::Deref;
11use std::path::Path;
12use std::path::PathBuf;
13use std::sync::LazyLock;
14
15use tracing::trace;
16use tracing::{debug, error};
17
18pub(crate) type ModString = (String, String, String);
19
20#[derive(Debug, Clone)]
21pub(crate) struct TempDir {
22    pub(crate) path: PathBuf,
23}
24
25impl TempDir {
26    /// # Errors
27    /// - IO errors
28    pub fn create(path: impl AsRef<Path>) -> Result<Self, std::io::Error> {
29        fs::create_dir_all(path.as_ref())?;
30        Ok(TempDir {
31            path: path.as_ref().to_path_buf(),
32        })
33    }
34}
35
36impl AsRef<Path> for TempDir {
37    fn as_ref(&self) -> &Path {
38        &self.path
39    }
40}
41
42impl Deref for TempDir {
43    type Target = Path;
44
45    fn deref(&self) -> &Self::Target {
46        &self.path
47    }
48}
49
50impl Drop for TempDir {
51    fn drop(&mut self) {
52        if let Err(e) = fs::remove_dir_all(&self.path) {
53            error!(
54                "Error removing temp directory at '{}': {}",
55                self.path.display(),
56                e
57            );
58        }
59    }
60}
61
62/// Returns a list of `Mod`s publled from an index based on the dep stings
63/// from Thunderstore
64///
65/// # Errors
66/// - A dependency string isn't formatted like `author-name`
67/// - A dependency string isn't present in the index
68pub fn resolve_deps(deps: &[impl AsRef<str>], index: &[Mod]) -> Result<Vec<Mod>, ThermiteError> {
69    let mut valid = vec![];
70    for dep in deps {
71        let dep_name = dep
72            .as_ref()
73            .split('-')
74            .nth(1)
75            .ok_or_else(|| ThermiteError::Dep(dep.as_ref().into()))?;
76
77        if dep_name.to_lowercase() == "northstar" {
78            debug!("Skip unfiltered Northstar dependency");
79            continue;
80        }
81
82        if let Some(d) = index.iter().find(|f| f.name == dep_name) {
83            valid.push(d.clone());
84        } else {
85            return Err(ThermiteError::Dep(dep.as_ref().into()));
86        }
87    }
88    Ok(valid)
89}
90
91/// Get `enabledmods.json` from the given directory, if it exists
92///
93/// # Errors
94/// - The path cannot be canonicalized (broken symlinks)
95/// - The path is not a directory
96/// - There is no `enabledmods.json` file in the provided directory
97pub fn get_enabled_mods(dir: impl AsRef<Path>) -> Result<EnabledMods, ThermiteError> {
98    let path = dir.as_ref().canonicalize()?.join("enabledmods.json");
99    if path.exists() {
100        let raw = fs::read_to_string(&path)?;
101        let mut mods: EnabledMods = serde_json::from_str(&raw)?;
102        mods.set_path(path);
103        Ok(mods)
104    } else {
105        Err(ThermiteError::MissingFile(Box::new(path)))
106    }
107}
108
109/// Search a directory for mod.json files in its children
110///
111/// Searches one level deep
112///
113/// # Errors
114/// - The path cannot be canonicalized
115/// - IO Errors
116/// - Improperly formatted JSON files
117pub fn find_mods(dir: impl AsRef<Path>) -> Result<Vec<InstalledMod>, ThermiteError> {
118    let mut res = vec![];
119    let dir = dir.as_ref().canonicalize()?;
120    debug!("Finding mods in '{}'", dir.display());
121    for child in dir.read_dir()? {
122        let child = child?;
123        if !child.file_type()?.is_dir() {
124            debug!("Skipping file {}", child.path().display());
125            continue;
126        }
127
128        let path = child.path().join("manifest.json");
129        let manifest = if path.try_exists()? {
130            let raw = fs::read_to_string(&path)?;
131            let Ok(parsed) = serde_json::from_str(&raw) else {
132                error!("Error parsing {}", path.display());
133                continue;
134            };
135            parsed
136        } else {
137            continue;
138        };
139
140        if let Some(submods) = get_submods(&manifest, child.path()) {
141            debug!(
142                "Found {} submods in {}",
143                submods.len(),
144                child.path().display()
145            );
146            trace!("{:#?}", submods);
147            let modstring =
148                parse_modstring(child.file_name().to_str().ok_or(ThermiteError::UTF8)?)?;
149            res.append(
150                &mut submods
151                    .into_iter()
152                    .map(|mut m| {
153                        m.author.clone_from(&modstring.0);
154
155                        m
156                    })
157                    .collect(),
158            );
159        } else {
160            debug!("No mods in {}", child.path().display());
161        }
162    }
163
164    Ok(res)
165}
166
167fn get_submods(manifest: &Manifest, dir: impl AsRef<Path>) -> Option<Vec<InstalledMod>> {
168    let dir = dir.as_ref();
169    debug!("Searching for submods in {}", dir.display());
170    if !dir.is_dir() {
171        debug!("Wasn't a directory, aborting");
172        return None;
173    }
174
175    let mut mods = vec![];
176    for child in dir.read_dir().ok()? {
177        let Ok(child) = child else { continue };
178        match child.file_type() {
179            Ok(ty) => {
180                if ty.is_dir() {
181                    let Some(mut next) = get_submods(manifest, child.path()) else {
182                        continue;
183                    };
184                    mods.append(&mut next);
185                } else {
186                    trace!("Is file {:?} mod.json?", child.file_name());
187                    if child.file_name() == "mod.json" {
188                        trace!("Yes");
189                        let Ok(file) = fs::read_to_string(child.path()) else {
190                            continue;
191                        };
192                        match json5::from_str(&file) {
193                            Ok(mod_json) => mods.push(InstalledMod {
194                                author: String::new(),
195                                manifest: manifest.clone(),
196                                mod_json,
197                                path: dir.to_path_buf(),
198                            }),
199                            Err(e) => {
200                                error!("Error parsing JSON in {}: {e}", child.path().display());
201                            }
202                        }
203                    } else {
204                        trace!("No");
205                    }
206                }
207            }
208            Err(e) => {
209                error!("Error {e}");
210            }
211        }
212    }
213
214    if mods.is_empty() {
215        None
216    } else {
217        Some(
218            mods.into_iter()
219                .map(|mut m| {
220                    if m.path.ends_with("/mods") {
221                        m.path.pop();
222                    }
223
224                    m
225                })
226                .collect(),
227        )
228    }
229}
230
231pub static RE: LazyLock<Regex> =
232    LazyLock::new(|| Regex::new(r"^(\w+)-(\w+)-(\d+\.\d+\.\d+)$").expect("regex"));
233
234/// Returns the parts of a `author-name-X.Y.Z` string in (`author`, `name`, `version`) order
235///
236/// # Errors
237///
238/// Returns a [ThermiteError::Name] if the input string is not in the correct format
239pub fn parse_modstring(input: impl AsRef<str>) -> Result<ModString, ThermiteError> {
240    debug!("Parsing modstring {}", input.as_ref());
241    if let Some(captures) = RE.captures(input.as_ref()) {
242        let author = captures
243            .get(1)
244            .ok_or_else(|| ThermiteError::Name(input.as_ref().into()))?
245            .as_str()
246            .to_owned();
247
248        let name = captures
249            .get(2)
250            .ok_or_else(|| ThermiteError::Name(input.as_ref().into()))?
251            .as_str()
252            .to_owned();
253
254        let version = captures
255            .get(3)
256            .ok_or_else(|| ThermiteError::Name(input.as_ref().into()))?
257            .as_str()
258            .to_owned();
259
260        Ok((author, name, version))
261    } else {
262        Err(ThermiteError::Name(input.as_ref().into()))
263    }
264}
265
266/// Checks that a string is in `author-name-X.Y.Z` format
267#[inline]
268#[must_use]
269pub fn validate_modstring(input: impl AsRef<str>) -> bool {
270    RE.is_match(input.as_ref())
271}
272
273#[cfg(feature = "steam")]
274pub(crate) mod steam {
275    use std::path::PathBuf;
276    use steamlocate::SteamDir;
277
278    use crate::TITANFALL2_STEAM_ID;
279
280    /// Returns the path to the Steam installation if it exists
281    #[must_use]
282    #[inline]
283    pub fn steam_dir() -> Result<PathBuf, steamlocate::Error> {
284        SteamDir::locate().map(|dir| dir.path().to_path_buf())
285    }
286
287    /// Returns paths to all known Steam libraries
288    #[must_use]
289    pub fn steam_libraries() -> Result<Vec<PathBuf>, steamlocate::Error> {
290        SteamDir::locate()?.library_paths()
291    }
292
293    /// Returns the path to the Titanfall installation if it exists
294    #[must_use]
295    pub fn titanfall2_dir() -> Result<PathBuf, steamlocate::Error> {
296        let steamdir = SteamDir::locate()?;
297        let Some((app, lib)) = steamdir.find_app(TITANFALL2_STEAM_ID)? else {
298            return Err(steamlocate::Error::MissingExpectedApp {
299                app_id: TITANFALL2_STEAM_ID,
300            });
301        };
302
303        Ok(lib.resolve_app_dir(&app))
304    }
305}
306
307#[cfg(all(target_os = "linux", feature = "proton"))]
308//#[deprecated(since = "0.8.0", note = "Northstar Proton is no longer required")]
309pub(crate) mod proton {
310    use flate2::read::GzDecoder;
311    use std::{
312        io::{Read, Write},
313        path::Path,
314    };
315    use tar::Archive;
316    use tracing::debug;
317    use ureq::ResponseExt;
318
319    use crate::{
320        core::manage::download,
321        error::{Result, ThermiteError},
322    };
323    const BASE_URL: &str = "https://github.com/R2NorthstarTools/NorthstarProton/releases/";
324
325    /// Returns the latest tag from the NorthstarProton repo
326    ///
327    /// # Errors
328    /// * Network error
329    /// * Unexpected URL format
330    pub fn latest_release() -> Result<String> {
331        let url = format!("{}latest", BASE_URL);
332        let res = ureq::get(&url).call()?;
333        let location = res.get_uri();
334        debug!("{url} redirected to {location}");
335
336        Ok(location
337            .path()
338            .split('/')
339            .last()
340            .ok_or_else(|| ThermiteError::Unknown("Malformed location URL".into()))?
341            .to_owned())
342    }
343
344    /// Convinience function for downloading a given tag from the NorthstarProton repo.
345    /// If you have a URL already, just use [crate::core::manage::download]
346    pub fn download_ns_proton(tag: impl AsRef<str>, output: impl Write) -> Result<u64> {
347        let url = format!(
348            "{}download/{}/NorthstarProton{}.tar.gz",
349            BASE_URL,
350            tag.as_ref(),
351            tag.as_ref().trim_matches('v')
352        );
353        download(output, url)
354    }
355
356    /// Extract the NorthstarProton tarball into a given directory.
357    /// Only supports extracting to a filesystem path.
358    ///
359    /// # Errors
360    /// * IO errors
361    pub fn install_ns_proton(archive: impl Read, dest: impl AsRef<Path>) -> Result<()> {
362        let mut tarball = Archive::new(GzDecoder::new(archive));
363        tarball.unpack(dest)?;
364
365        Ok(())
366    }
367
368    #[cfg(test)]
369    mod test {
370        use std::io::Cursor;
371
372        use crate::core::utils::TempDir;
373
374        use super::latest_release;
375
376        #[test]
377        fn get_latest_proton_version() {
378            let res = latest_release();
379            assert!(res.is_ok());
380        }
381
382        #[test]
383        fn extract_proton() {
384            let dir =
385                TempDir::create(std::env::temp_dir().join("NSPROTON_TEST")).expect("temp dir");
386            let archive = include_bytes!("test_media/NorthstarProton8-28.tar.gz");
387            let cursor = Cursor::new(archive);
388            let res = super::install_ns_proton(cursor, &dir);
389            assert!(res.is_ok());
390
391            let extracted = dir.join("NorthstarProton8-28.txt");
392            assert!(extracted.exists());
393            assert_eq!(
394                std::fs::read_to_string(extracted).expect("read file"),
395                "The real proton was too big to use as test media\n"
396            );
397        }
398    }
399}
400
401#[cfg(test)]
402mod test {
403    use std::{
404        collections::BTreeMap,
405        fs,
406        path::{Path, PathBuf},
407    };
408
409    use crate::{error::ThermiteError, model::Mod};
410
411    use super::{
412        find_mods, get_enabled_mods, parse_modstring, resolve_deps, validate_modstring, TempDir,
413    };
414
415    #[test]
416    fn temp_dir_deletes_on_drop() {
417        let test_folder = "temp_dir";
418        {
419            let temp_dir = TempDir::create(test_folder);
420            assert!(temp_dir.is_ok());
421
422            if let Ok(dir) = temp_dir {
423                let exists = dir
424                    .try_exists()
425                    .expect("Unable to check if temp dir exists");
426                assert!(exists);
427            }
428        }
429
430        let path = PathBuf::from(test_folder);
431        let exists = path
432            .try_exists()
433            .expect("Unable to check if temp dir exists");
434        assert!(!exists);
435    }
436
437    #[test]
438    fn fail_find_enabledmods() {
439        let test_folder = "fail_enabled_mods_test";
440        let temp_dir = TempDir::create(test_folder).unwrap();
441        if let Err(ThermiteError::MissingFile(path)) = get_enabled_mods(&temp_dir) {
442            assert_eq!(
443                *path,
444                temp_dir.canonicalize().unwrap().join("enabledmods.json")
445            );
446        } else {
447            panic!("enabledmods.json should not exist");
448        }
449    }
450
451    #[test]
452    fn fail_parse_enabledmods() {
453        let test_folder = "parse_enabled_mods_test";
454        let temp_dir = TempDir::create(test_folder).unwrap();
455        fs::write(temp_dir.join("enabledmods.json"), b"invalid json").unwrap();
456        if let Err(ThermiteError::Json(_)) = get_enabled_mods(temp_dir) {
457        } else {
458            panic!("enabledmods.json should not be valid json");
459        }
460    }
461
462    #[test]
463    fn pass_get_enabledmods() {
464        let test_folder = "pass_enabled_mods_test";
465        let temp_dir = TempDir::create(test_folder).unwrap();
466        fs::write(temp_dir.join("enabledmods.json"), b"{}").unwrap();
467        if let Ok(mods) = get_enabled_mods(temp_dir) {
468            assert!(mods.client);
469            assert!(mods.custom);
470            assert!(mods.servers);
471            assert!(mods.mods.is_empty());
472        } else {
473            panic!("enabledmods.json should be valid but empty");
474        }
475    }
476
477    #[test]
478    fn reolve_dependencies() {
479        let test_index: &[Mod] = &[Mod {
480            name: "test".into(),
481            latest: "0.1.0".into(),
482            upgradable: false,
483            global: false,
484            installed: false,
485            versions: BTreeMap::new(),
486            author: "Foo".into(),
487        }];
488
489        let test_deps = &["foo-test-0.1.0"];
490
491        let res = resolve_deps(test_deps, test_index);
492
493        assert!(res.is_ok());
494        assert_eq!(res.unwrap()[0], test_index[0]);
495    }
496
497    #[test]
498    fn dont_resolve_northstar_as_dependency() {
499        let test_index: &[Mod] = &[Mod {
500            name: "Northstar".into(),
501            latest: "0.1.0".into(),
502            upgradable: false,
503            global: false,
504            installed: false,
505            versions: BTreeMap::new(),
506            author: "Northstar".into(),
507        }];
508
509        let test_deps = &["Northstar-Northstar-0.1.0"];
510
511        let res = resolve_deps(test_deps, test_index);
512
513        assert!(res.is_ok());
514        assert!(res.unwrap().is_empty());
515    }
516
517    #[test]
518    fn fail_resolve_bad_deps() {
519        let test_index: &[Mod] = &[Mod {
520            name: "test".into(),
521            latest: "0.1.0".into(),
522            upgradable: false,
523            global: false,
524            installed: false,
525            versions: BTreeMap::new(),
526            author: "Foo".into(),
527        }];
528
529        let test_deps = &["foo-test@0.1.0"];
530
531        let res = resolve_deps(test_deps, test_index);
532
533        assert!(res.is_err());
534
535        let test_deps = &["foo-bar-0.1.0"];
536
537        let res = resolve_deps(test_deps, test_index);
538
539        assert!(res.is_err());
540    }
541
542    #[test]
543    fn sucessfully_validate_modstring() {
544        let test_string = "author-mod-0.1.0";
545        assert!(validate_modstring(test_string));
546    }
547
548    #[test]
549    fn fail_validate_modstring() {
550        let test_string = "invalid";
551        assert!(!validate_modstring(test_string));
552    }
553
554    #[test]
555    fn successfully_parse_modstring() {
556        let test_string = "author-mod-0.1.0";
557        let res = parse_modstring(test_string);
558
559        if let Ok(parsed) = res {
560            assert_eq!(parsed, ("author".into(), "mod".into(), "0.1.0".into()));
561        } else {
562            panic!("Valid mod string failed to be parsed");
563        }
564    }
565
566    #[test]
567    fn fail_parse_modstring() {
568        let test_string = "invalid";
569        let res = parse_modstring(test_string);
570
571        if let Err(ThermiteError::Name(name)) = res {
572            assert_eq!(name, test_string);
573        } else {
574            panic!("Invalid mod string didn't error");
575        }
576    }
577
578    const MANIFEST: &str = r#"{
579        "namespace": "northstar",
580        "name": "Northstar",
581        "description": "Titanfall 2 modding and custom server framework.",
582        "version_number": "1.22.0",
583        "dependencies": [],
584        "website_url": ""
585      }"#;
586
587    const MOD_JSON: &str = r#"{
588        "Name": "Yourname.Modname",
589        "Description": "Woo yeah wooo!",
590        "Version": "1.2.3",
591     
592        "LoadPriority": 0,
593        "ConVars": [],
594        "Scripts": [],
595        "Localisation": []
596     }"#;
597
598    fn setup_mods(path: impl AsRef<Path>) {
599        let root = path.as_ref().join("northstar-mod-1.2.3");
600        fs::create_dir_all(&root).expect("create dir");
601        fs::write(root.join("manifest.json"), MANIFEST).expect("write manifest");
602        let _mod = root.join("RealMod");
603        fs::create_dir_all(&_mod).expect("create dir");
604        fs::write(_mod.join("mod.json"), MOD_JSON).expect("write mod.json");
605    }
606
607    #[test]
608    fn discover_mods() {
609        let dir = TempDir::create("./mod_discovery").expect("Temp dir");
610        setup_mods(&dir);
611        let res = find_mods(dir);
612
613        if let Ok(mods) = res {
614            assert_eq!(mods.len(), 1, "Should be one mod");
615            assert_eq!(mods[0].manifest.name, "Northstar");
616            assert_eq!(mods[0].author, "northstar");
617            assert_eq!(mods[0].mod_json.name, "Yourname.Modname");
618        } else {
619            panic!("Mod discovery failed: {res:?}");
620        }
621    }
622}