z_mirror/fmm

git clone https://git.raiguard.me/z_mirror/fmm

raiguardMove old code to remove warnings59fb9e2

rewrite
16.5 KiB496 linesraw
1use once_cell::sync::OnceCell;
2use regex::Regex;
3use semver::Version;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::error::Error;
7use std::ffi::OsStr;
8use std::fs;
9use std::fs::{DirEntry, File};
10use std::io::Read;
11use std::path::{Path, PathBuf};
12use std::{cmp::Ordering, collections::HashSet};
13use thiserror::Error;
14use zip::ZipArchive;
15
16use crate::dependency::{ModDependency, ModDependencyResult, ModDependencyType};
17use crate::input::InputMod;
18
19#[derive(Deserialize, Serialize)]
20struct ModListJson {
21    mods: Vec<ModListJsonMod>,
22}
23
24#[derive(Deserialize, Serialize)]
25struct ModListJsonMod {
26    name: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    version: Option<Version>,
29    enabled: bool,
30}
31
32#[derive(Deserialize, Debug)]
33struct InfoJson {
34    dependencies: Option<Vec<String>>,
35    name: String,
36    version: Version,
37}
38
39pub struct ModsSet {
40    dir: PathBuf,
41    mods: HashMap<String, Mod>,
42}
43
44impl ModsSet {
45    // TODO: Better error formatting so the user knows which mod threw the error
46    pub fn new(path: &Path) -> Result<Self, Box<dyn Error>> {
47        // Read mod-list.json to a file
48        let mut mlj_path = path.to_owned();
49        mlj_path.push("mod-list.json");
50        let mlj_contents = std::fs::read_to_string(mlj_path)?;
51        let mut enabled_versions: HashMap<String, ModEnabledType> =
52            serde_json::from_str::<ModListJson>(&mlj_contents)?
53                .mods
54                .iter()
55                .map(|entry| {
56                    (
57                        entry.name.clone(),
58                        match (entry.enabled, &entry.version) {
59                            (true, Some(version)) => ModEnabledType::Version(version.clone()),
60                            (true, None) => ModEnabledType::Latest,
61                            _ => ModEnabledType::Disabled,
62                        },
63                    )
64                })
65                .collect();
66
67        let mut mods: HashMap<String, Mod> = HashMap::new();
68
69        // Iterate all mods in the directory
70        for entry in fs::read_dir(path)?.filter_map(|entry| {
71            // Exclude mod-list.json and mod-settings.dat
72            let entry = entry.ok()?;
73            let file_name = entry.file_name();
74            let file_name = file_name.to_str()?;
75            if file_name != "mod-list.json" && file_name != "mod-settings.dat" {
76                Some(entry)
77            } else {
78                None
79            }
80        }) {
81            // Determine the mod's structure type
82            let mod_structure = ModVersionStructure::parse(&entry)?;
83
84            // Extract contents of info.json file
85            let info = match mod_structure {
86                ModVersionStructure::Zip => {
87                    // WORKAROUND: The `zip` crate doesn't have nice iterator methods, so we need to
88                    // early-return out of a `for` loop, necessitating a separate function
89                    find_info_json_in_zip(&entry)?
90                }
91                _ => {
92                    let mut path = entry.path();
93                    path.push("info.json");
94                    fs::read_to_string(path)?
95                }
96            };
97
98            // Remove all non-UTF8 characters from the string
99            let info = info.replace(|c: char| !c.is_ascii(), "");
100
101            // Parse info string into a struct
102            match serde_json::from_str::<InfoJson>(&info) {
103                Ok(info) => {
104                    // Retrive or create mod data
105                    let mod_data = mods.entry(info.name.clone()).or_insert(Mod {
106                        name: info.name.clone(),
107                        versions: vec![],
108                        enabled: {
109                            // Move the enabled status extracted from mod-list.json into the mod object
110                            let active_version = enabled_versions.remove(&info.name);
111                            match active_version {
112                                Some(enabled_type) => enabled_type,
113                                None => ModEnabledType::Disabled,
114                            }
115                        },
116                    });
117
118                    // TODO: Optimize to not parse dependencies unless we need to insert the version
119                    let mod_version = ModVersion {
120                        entry,
121                        dependencies: info
122                            .dependencies
123                            .unwrap_or_default()
124                            .iter()
125                            .map(|dep| ModDependency::new(&dep))
126                            .collect::<ModDependencyResult>()?,
127                        structure: mod_structure,
128                        version: info.version,
129                    };
130
131                    if let Err(index) = mod_data.versions.binary_search(&mod_version) {
132                        mod_data.versions.insert(index, mod_version);
133                    }
134                }
135                Err(_) => {
136                    if let Some(file_name) = entry.file_name().to_owned().to_str() {
137                        println!("Could not read mod: {}", file_name);
138                        let file_name = file_name.replace(|c: char| !c.is_ascii(), "");
139                        // Avoid creating the regex object every time
140                        static FILENAME_REGEX: OnceCell<Regex> = OnceCell::new();
141                        let captures = FILENAME_REGEX
142                            .get_or_init(|| {
143                                Regex::new(r"^(?P<name>.*)_(?P<version>\d*\.\d*\.\d*\.)$").unwrap()
144                            })
145                            .captures(&file_name)
146                            .ok_or(ModsSetErr::ModFilenameUnreadable);
147                        // TODO: Keep a list of invalid mods
148                        // Perhaps we need to re-think the arcitecture to keep active versions and the actual versions separate
149                    }
150                }
151            }
152        }
153
154        Ok(Self {
155            dir: path.to_owned(),
156            mods,
157        })
158    }
159
160    pub fn dedup(&mut self) -> Result<(), ModsSetErr> {
161        println!("Deduplicating zipped mod versions");
162
163        for (_, mod_data) in self.mods.iter_mut() {
164            for version_data in mod_data.versions.drain(..(mod_data.versions.len() - 1)) {
165                if let ModVersionStructure::Zip = version_data.structure {
166                    // TODO: Print the removal
167                    version_data.remove_from_disk()?;
168                }
169            }
170        }
171
172        Ok(())
173    }
174
175    pub fn disable_all(&mut self, include_base_mod: bool) {
176        println!("Disabling all mods");
177
178        self.mods
179            .iter_mut()
180            .filter(|(mod_name, _)| include_base_mod || mod_name.as_str() != "base")
181            .for_each(|(_, mod_data)| mod_data.enabled = ModEnabledType::Disabled);
182    }
183
184    pub fn disable(&mut self, mod_ident: &InputMod) -> Result<(), ModsSetErr> {
185        println!("Disabling {}", mod_ident);
186
187        let mod_data = self.get_mod_mut(&mod_ident.name)?;
188
189        mod_data.enabled = ModEnabledType::Disabled;
190
191        Ok(())
192    }
193
194    pub fn enable_all(&mut self) {
195        println!("Enabling latest versions of all mods");
196
197        self.mods
198            .iter_mut()
199            .for_each(|(_, mod_data)| mod_data.enabled = ModEnabledType::Latest);
200    }
201
202    pub fn enable(&mut self, mod_ident: &InputMod) -> Result<Vec<InputMod>, ModsSetErr> {
203        println!("Enabling {}", mod_ident);
204
205        let mod_data = self.get_mod_mut(&mod_ident.name)?;
206
207        // Enable this mod
208        mod_data.enabled = match &mod_ident.version {
209            ModEnabledType::Version(version) => {
210                if mod_data
211                    .versions
212                    .binary_search_by(|stored_version| stored_version.version.cmp(version))
213                    .is_ok()
214                {
215                    // TODO: Remove clone?
216                    Ok(ModEnabledType::Version(version.clone()))
217                } else {
218                    Err(ModsSetErr::ModVersionDoesNotExist(version.clone()))
219                }
220            }
221            _ => Ok(ModEnabledType::Latest),
222        }?;
223
224        // Return a list of dependencies to enable
225        let mod_data = self.get_mod(&mod_ident.name)?;
226        let active_version = mod_data.get_active_version()?.unwrap();
227        active_version
228            .dependencies
229            .iter()
230            .filter(|dependency_ident| {
231                dependency_ident.name != "base"
232                    && matches!(
233                        dependency_ident.dep_type,
234                        ModDependencyType::NoLoadOrder | ModDependencyType::Required
235                    )
236            })
237            .map(|dependency_ident| {
238                let dependency = self
239                    .get_mod(&dependency_ident.name)
240                    // TODO: More explanative errors
241                    .map_err(|_| ModsSetErr::MatchingDependencyNotFound)?;
242                let version = if dependency_ident.version_req.is_none() {
243                    dependency.versions.last()
244                } else {
245                    let req = dependency_ident.version_req.as_ref().unwrap();
246                    dependency
247                        .versions
248                        .iter()
249                        .rev()
250                        .find(|version| req.matches(&version.version))
251                };
252
253                if version.is_none() {
254                    return Err(ModsSetErr::MatchingDependencyNotFound);
255                }
256                Ok(InputMod {
257                    name: dependency.name.clone(),
258                    version: ModEnabledType::Version(version.unwrap().version.clone()),
259                })
260            })
261            .collect::<Result<Vec<InputMod>, ModsSetErr>>()
262    }
263
264    pub fn enable_list(&mut self, initial_to_enable: Vec<InputMod>) -> Result<(), ModsSetErr> {
265        let mut to_enable = initial_to_enable;
266        let mut did_enable: HashSet<String> = HashSet::new();
267
268        while !to_enable.is_empty() {
269            let mut to_enable_next = Vec::new();
270            // Enable all of the mods
271            for mod_ident in &to_enable {
272                if did_enable.get(&mod_ident.name).is_none() {
273                    to_enable_next.append(&mut self.enable(&mod_ident)?);
274                    did_enable.insert(mod_ident.name.clone());
275                }
276            }
277            // Replace `to_enable`
278            to_enable = to_enable_next;
279        }
280        Ok(())
281    }
282
283    pub fn remove(&mut self, mod_ident: &InputMod) -> Result<(), ModsSetErr> {
284        println!("Removing {}", mod_ident);
285
286        let mod_data = self.get_mod_mut(&mod_ident.name)?;
287
288        // Extract the matching version from the versions table
289        let version_data = mod_data
290            .versions
291            .remove(mod_data.find_version(&mod_ident.version)?);
292
293        version_data.remove_from_disk()?;
294
295        // TODO: Remove mod entry if there are no more versions
296
297        Ok(())
298    }
299
300    pub fn write_mod_list(&mut self) -> Result<(), ModsSetErr> {
301        let info = ModListJson {
302            mods: self
303                .mods
304                .iter()
305                .map(|(_, mod_data)| {
306                    let (enabled, version) = match &mod_data.enabled {
307                        ModEnabledType::Disabled => (false, None),
308                        ModEnabledType::Latest => (true, None),
309                        ModEnabledType::Version(version) => (true, Some(version.clone())),
310                    };
311                    ModListJsonMod {
312                        enabled,
313                        name: mod_data.name.clone(),
314                        version,
315                    }
316                })
317                .collect(),
318        };
319
320        let mut path = self.dir.clone();
321        path.push("mod-list.json");
322        fs::write(
323            path,
324            serde_json::to_string_pretty(&info).map_err(|_| ModsSetErr::CouldNotWriteChanges)?,
325        )
326        .map_err(|_| ModsSetErr::CouldNotWriteChanges)?;
327
328        Ok(())
329    }
330
331    fn get_mod_mut(&mut self, mod_name: &str) -> Result<&mut Mod, ModsSetErr> {
332        self.mods
333            .get_mut(mod_name)
334            .ok_or(ModsSetErr::ModDoesNotExist)
335    }
336
337    fn get_mod(&self, mod_name: &str) -> Result<&Mod, ModsSetErr> {
338        self.mods.get(mod_name).ok_or(ModsSetErr::ModDoesNotExist)
339    }
340}
341
342#[derive(Debug, Error)]
343pub enum ModsSetErr {
344    #[error("Could not remove version {0}")]
345    CouldNotRemoveVersion(Version),
346    #[error("Could not write changes to mod-list.json")]
347    CouldNotWriteChanges,
348    #[error("Filesystem error")]
349    FilesystemError,
350    #[error("Invalid mod structure")]
351    InvalidModStructure,
352    #[error("Matching dependency not found")]
353    MatchingDependencyNotFound,
354    #[error("Mod does not exist")]
355    ModDoesNotExist,
356    #[error("Version {0} does not exist")]
357    ModVersionDoesNotExist(Version),
358    #[error("Could not read mod file name {0}")]
359    ModFilenameUnreadable(String),
360}
361
362// The `zip` crate doesn't have proper iterator methods, so we must use a bare `for` loop and early return
363fn find_info_json_in_zip(entry: &DirEntry) -> Result<String, Box<dyn Error>> {
364    let file = File::open(entry.path())?;
365    let mut archive = ZipArchive::new(file)?;
366    // Thus, we need to use a bare `for` loop and iterate the indices, then act on the file if we find it
367    for i in 0..archive.len() {
368        let mut file = archive.by_index(i)?;
369        if file.name().contains("info.json") {
370            let mut contents = String::new();
371            file.read_to_string(&mut contents)?;
372            return Ok(contents);
373        }
374    }
375    Err("Mod ZIP does not contain an info.json file".into())
376}
377
378#[derive(Debug)]
379struct Mod {
380    name: String,
381    versions: Vec<ModVersion>,
382    enabled: ModEnabledType,
383}
384
385impl Mod {
386    fn find_version(&self, version_ident: &ModEnabledType) -> Result<usize, ModsSetErr> {
387        match version_ident {
388            ModEnabledType::Version(version) => self
389                .versions
390                .binary_search_by(|stored_version| stored_version.version.cmp(version))
391                .map_err(|_| ModsSetErr::ModVersionDoesNotExist(version.clone())),
392            _ => Ok(self.versions.len() - 1),
393        }
394    }
395
396    fn get_active_version(&self) -> Result<Option<&ModVersion>, ModsSetErr> {
397        Ok(self.versions.get(self.find_version(&self.enabled)?))
398    }
399}
400
401#[derive(Clone, Debug)]
402pub enum ModEnabledType {
403    Disabled,
404    Latest,
405    Version(Version),
406}
407
408#[derive(Debug)]
409struct ModVersion {
410    entry: DirEntry,
411    // TODO: Use a HashSet for quick lookup?
412    dependencies: Vec<ModDependency>,
413    structure: ModVersionStructure,
414    version: Version,
415}
416
417impl ModVersion {
418    fn remove_from_disk(&self) -> Result<(), ModsSetErr> {
419        let entry = &self.entry;
420
421        if entry
422            .metadata()
423            .map_err(|_| ModsSetErr::FilesystemError)?
424            .is_dir()
425        {
426            fs::remove_dir_all(entry.path())
427        } else {
428            fs::remove_file(entry.path())
429        }
430        .map_err(|_| ModsSetErr::CouldNotRemoveVersion(self.version.clone()))
431    }
432}
433
434impl PartialOrd for ModVersion {
435    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
436        self.version.partial_cmp(&other.version)
437    }
438}
439
440// impl PartialOrd<Version> for ModVersion {
441//     fn partial_cmp(&self, other: &Version) -> Option<Ordering> {
442//         self.version.partial_cmp(other)
443//     }
444// }
445
446impl Ord for ModVersion {
447    fn cmp(&self, other: &Self) -> Ordering {
448        self.version.cmp(&other.version)
449    }
450}
451
452impl PartialEq for ModVersion {
453    fn eq(&self, other: &Self) -> bool {
454        self.version == other.version
455    }
456}
457
458// impl PartialEq<Version> for ModVersion {
459//     fn eq(&self, other: &Version) -> bool {
460//         self.version == *other
461//     }
462// }
463
464impl Eq for ModVersion {}
465
466#[derive(Debug)]
467enum ModVersionStructure {
468    Directory,
469    Invalid,
470    Symlink,
471    Zip,
472}
473
474impl ModVersionStructure {
475    fn parse(entry: &DirEntry) -> Result<Self, ModsSetErr> {
476        let path = entry.path();
477        let extension = path.extension();
478
479        if extension.is_some() && extension.unwrap() == OsStr::new("zip") {
480            return Ok(ModVersionStructure::Zip);
481        } else {
482            let file_type = entry.file_type().map_err(|_| ModsSetErr::FilesystemError)?;
483            if file_type.is_symlink() {
484                return Ok(ModVersionStructure::Symlink);
485            } else {
486                let mut path = entry.path();
487                path.push("info.json");
488                if path.exists() {
489                    return Ok(ModVersionStructure::Directory);
490                }
491            }
492        };
493
494        Err(ModsSetErr::InvalidModStructure)
495    }
496}