Skip to main content

modde_games/
traits.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6use anyhow::Result;
7use smallvec::SmallVec;
8
9/// Content types a game can have.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum ContentCategory {
12    Plugin,    // .esp, .esm, .esl
13    Texture,   // .dds, .png, .tga
14    Mesh,      // .nif
15    Sound,     // .wav, .xwm, .fuz
16    Script,    // .pex, .psc, .reds, .lua
17    Interface, // .swf
18    Archive,   // .bsa, .ba2, .archive
19    Config,    // .ini, .json, .yaml, .xml
20    Binary,    // .dll
21    Other,
22}
23
24impl ContentCategory {
25    /// Human-readable label for display.
26    pub fn label(self) -> &'static str {
27        match self {
28            ContentCategory::Plugin => "plugins",
29            ContentCategory::Texture => "textures",
30            ContentCategory::Mesh => "meshes",
31            ContentCategory::Sound => "sounds",
32            ContentCategory::Script => "scripts",
33            ContentCategory::Interface => "interfaces",
34            ContentCategory::Archive => "archives",
35            ContentCategory::Config => "configs",
36            ContentCategory::Binary => "binaries",
37            ContentCategory::Other => "other",
38        }
39    }
40
41    /// Display order (lower = shown first).
42    pub fn order(self) -> u8 {
43        match self {
44            ContentCategory::Plugin => 0,
45            ContentCategory::Script => 1,
46            ContentCategory::Binary => 2,
47            ContentCategory::Texture => 3,
48            ContentCategory::Mesh => 4,
49            ContentCategory::Sound => 5,
50            ContentCategory::Interface => 6,
51            ContentCategory::Archive => 7,
52            ContentCategory::Config => 8,
53            ContentCategory::Other => 9,
54        }
55    }
56}
57
58/// Summary of content types found in a mod.
59#[derive(Debug, Clone, Default)]
60pub struct ContentSummary {
61    pub counts: HashMap<ContentCategory, usize>,
62}
63
64impl ContentSummary {
65    /// Return counts sorted by display order, excluding zero counts.
66    pub fn sorted_counts(&self) -> Vec<(ContentCategory, usize)> {
67        let mut entries: Vec<_> = self.counts.iter()
68            .filter(|(_, count)| **count > 0)
69            .map(|(cat, count)| (*cat, *count))
70            .collect();
71        entries.sort_by_key(|(cat, _)| cat.order());
72        entries
73    }
74
75    /// Format as a human-readable string like "5 textures, 2 meshes, 1 plugin".
76    pub fn display_string(&self) -> String {
77        let parts: Vec<String> = self.sorted_counts().iter()
78            .map(|(cat, count)| format!("{} {}", count, cat.label()))
79            .collect();
80        if parts.is_empty() {
81            "No files".to_string()
82        } else {
83            parts.join(", ")
84        }
85    }
86}
87
88/// Whether a mod is safe to add/remove without breaking existing saves.
89///
90/// Mods that alter game logic (scripts, gameplay tweaks, new items/quests)
91/// will corrupt or break saves if removed mid-playthrough. Cosmetic mods
92/// (textures, meshes, UI themes) can be freely toggled.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
94pub enum ModSafety {
95    /// Alters game logic — removing this mod will break saves that depend on it.
96    /// Examples: REDscript mods, CET lua scripts, .tweak overrides, ESP/ESM plugins.
97    SaveBreaking,
98    /// Cosmetic only — safe to add/remove without affecting saves.
99    /// Examples: texture replacers, mesh swaps, UI reskins.
100    SaveSafe,
101    /// Cannot determine automatically (e.g. mod not installed locally, or mixed content).
102    /// Treated as `SaveBreaking` for safety when computing fingerprints.
103    Unknown,
104}
105
106impl ModSafety {
107    /// Returns `true` if this mod should be included in save fingerprints.
108    pub fn affects_saves(self) -> bool {
109        matches!(self, ModSafety::SaveBreaking | ModSafety::Unknown)
110    }
111}
112
113/// Trait implemented by each supported game.
114pub trait GamePlugin: Send + Sync {
115    /// Unique game identifier (e.g. "skyrim-se").
116    fn game_id(&self) -> &str;
117
118    /// Human-readable display name.
119    fn display_name(&self) -> &str;
120
121    /// Attempt to detect the game's install location.
122    /// Default: delegates to `detection::find_game_install(self.game_id())`.
123    fn detect_install(&self) -> Option<PathBuf> {
124        crate::detection::find_game_install(self.game_id())
125    }
126
127    /// Return the mod directory relative to the install path.
128    fn mod_directory(&self, install: &Path) -> PathBuf;
129
130    /// Deploy staged mods into the game's mod directory.
131    /// Default: recursive symlink farm via `modde_core::fs::deploy_symlinks`.
132    fn deploy(&self, staging: &Path, target: &Path) -> Result<()> {
133        modde_core::fs::deploy_symlinks(staging, target)
134    }
135
136    /// Run any post-deployment steps (e.g. REDmod deploy).
137    fn post_deploy(&self, _install: &Path) -> Result<()> {
138        Ok(())
139    }
140
141    /// Return the save directory for this game, if known.
142    fn save_directory(&self) -> Option<PathBuf> {
143        None
144    }
145
146    /// Classify whether a mod is save-breaking based on its installed content.
147    ///
148    /// `mod_dir` is the path to the mod's staging directory. The game plugin
149    /// inspects the files within to determine if the mod alters game logic
150    /// (scripts, plugins, tweaks) or is purely cosmetic (textures, meshes).
151    ///
152    /// Default: `Unknown` (conservative — included in fingerprints).
153    fn classify_mod(&self, _mod_dir: &Path) -> ModSafety {
154        ModSafety::Unknown
155    }
156
157    /// Scan the game directory for proxy/hook DLLs that need Wine `n,b` overrides.
158    ///
159    /// Returns DLL base names (without extension) that should be added to
160    /// `WINEDLLOVERRIDES` as `name=n,b` so Wine loads the native version
161    /// instead of its built-in stub.
162    fn wine_dll_overrides(&self, _game_dir: &Path) -> SmallVec<[String; 4]> {
163        SmallVec::new()
164    }
165
166    /// Scan the staging directory for proxy DLLs that mods deploy.
167    /// This catches DLLs that may have been deleted by other tools (e.g. fgmod)
168    /// from the game directory but are still needed.
169    fn wine_dll_overrides_from_staging(&self, _staging: &Path) -> SmallVec<[String; 4]> {
170        SmallVec::new()
171    }
172
173    /// Return the directory containing the game executable, relative to the install root.
174    /// Used to locate proxy DLLs that need Wine overrides.
175    fn executable_dir(&self, install: &Path) -> PathBuf {
176        install.to_path_buf()
177    }
178
179    // ── DRY trait methods ─────────────────────────────────────────
180    fn ini_file_names(&self) -> &[&str] { &[] }
181    fn archive_extensions(&self) -> &[&str] { &[] }
182    fn has_plugin_system(&self) -> bool { false }
183    fn steam_app_id_u32(&self) -> Option<u32> { None }
184    fn plugins_txt_folder(&self) -> Option<&str> { None }
185    fn nexus_game_domain(&self) -> Option<&str> { None }
186
187    /// Numeric Nexus game ID. Required by the GraphQL v2 API for
188    /// browse/search queries (which take `gameId: Int`, not a domain
189    /// string). Games that only speak REST can leave this `None`.
190    fn nexus_game_id_u32(&self) -> Option<u32> { None }
191
192    // ── Install-method detection (V8 installer pipeline) ────────
193
194    /// Claim an extracted archive as a game-specific install method.
195    ///
196    /// Runs **before** the generic probes (FOMOD, BAIN, DLL overlay) in
197    /// [`modde_core::installer::analyze`], so a game can authoritatively
198    /// identify layouts it knows about — e.g. Cyberpunk recognizing a
199    /// REDmod by `info.json` + `archives/` presence, or ENB for Bethesda.
200    ///
201    /// Return `None` to fall through to the generic probes.
202    fn analyze_mod_archive(
203        &self,
204        _extracted_dir: &Path,
205    ) -> Option<modde_core::installer::InstallMethod> {
206        None
207    }
208
209    /// Decide whether an extracted archive drops cleanly into the game's
210    /// mod dir without any staging (e.g. a Skyrim archive with a
211    /// top-level `Data/` directory, or a Cyberpunk archive with `r6/`).
212    ///
213    /// Called as the last fallback by
214    /// [`modde_core::installer::analyze`] — if this returns `true` the
215    /// plan becomes `InstallMethod::BareExtract`, otherwise the analyzer
216    /// falls through to [`InstallMethod::Unknown`] and the caller dumps
217    /// a dossier for the skill path.
218    fn recognizes_bare_layout(&self, _extracted_dir: &Path) -> bool {
219        false
220    }
221
222    /// Classify a file extension into a content category.
223    fn classify_extension(&self, ext: &str) -> ContentCategory {
224        match ext {
225            "esp" | "esm" | "esl" => ContentCategory::Plugin,
226            "dds" | "png" | "tga" | "jpg" => ContentCategory::Texture,
227            "nif" => ContentCategory::Mesh,
228            "wav" | "xwm" | "fuz" | "mp3" | "ogg" => ContentCategory::Sound,
229            "pex" | "psc" | "reds" | "lua" => ContentCategory::Script,
230            "swf" => ContentCategory::Interface,
231            "bsa" | "ba2" | "archive" => ContentCategory::Archive,
232            "ini" | "json" | "yaml" | "xml" | "toml" => ContentCategory::Config,
233            "dll" | "so" => ContentCategory::Binary,
234            _ => ContentCategory::Other,
235        }
236    }
237
238    /// Scan a mod directory and return a content summary.
239    fn summarize_content(&self, mod_dir: &Path) -> ContentSummary {
240        let mut summary = ContentSummary::default();
241        let mut stack = vec![mod_dir.to_path_buf()];
242        while let Some(dir) = stack.pop() {
243            let entries = match std::fs::read_dir(&dir) {
244                Ok(e) => e,
245                Err(_) => continue,
246            };
247            for entry in entries.flatten() {
248                let path = entry.path();
249                if path.is_dir() {
250                    stack.push(path);
251                    continue;
252                }
253                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
254                    let cat = self.classify_extension(&ext.to_lowercase());
255                    *summary.counts.entry(cat).or_insert(0) += 1;
256                }
257            }
258        }
259        summary
260    }
261}
262
263/// A detected save file or directory within a game's save directory.
264#[derive(Debug, Clone)]
265pub struct DetectedSave {
266    /// Path relative to the game's save directory.
267    pub rel_path: PathBuf,
268    /// Category: "manual", "auto", "quick", "point-of-no-return", etc.
269    /// Uses `Cow<'static, str>` because categories are almost always
270    /// static string literals, avoiding heap allocation in the common case.
271    pub category: Cow<'static, str>,
272    /// Human-readable label (e.g. custom name from NamedSaves).
273    pub label: Option<String>,
274    /// Last modification time.
275    pub modified: SystemTime,
276}
277
278/// Configuration for extension-based mod classification.
279///
280/// Both Bethesda and Cyberpunk games classify mods by scanning file extensions
281/// (and optionally directory paths). This struct captures the game-specific
282/// lists so the shared walker can be reused via static dispatch.
283pub struct ModClassifyConfig {
284    /// File extensions that indicate save-breaking content (lowercase, no dot).
285    pub save_breaking_ext: &'static [&'static str],
286    /// File extensions that indicate cosmetic-only content (lowercase, no dot).
287    pub cosmetic_ext: &'static [&'static str],
288    /// Directory path fragments (relative, `/`-separated) that signal save-breaking content.
289    /// Checked via `contains()` on the normalized relative path. Empty slice to skip.
290    pub save_breaking_dirs: &'static [&'static str],
291}
292
293/// Classify a mod by walking its directory and checking file extensions / directory paths
294/// against the provided configuration. Returns early on the first save-breaking indicator.
295pub fn classify_mod_by_content(mod_dir: &std::path::Path, config: &ModClassifyConfig) -> ModSafety {
296    if !mod_dir.exists() {
297        return ModSafety::Unknown;
298    }
299
300    let mut has_any_file = false;
301    let mut has_cosmetic = false;
302
303    let mut stack = vec![mod_dir.to_path_buf()];
304    while let Some(dir) = stack.pop() {
305        let entries = match std::fs::read_dir(&dir) {
306            Ok(e) => e,
307            Err(_) => continue,
308        };
309
310        for entry in entries.flatten() {
311            let path = entry.path();
312
313            if path.is_dir() {
314                if !config.save_breaking_dirs.is_empty() {
315                    let rel = path.strip_prefix(mod_dir).unwrap_or(&path);
316                    let rel_normalized = rel.to_string_lossy().to_lowercase().replace('\\', "/");
317                    for &pattern in config.save_breaking_dirs {
318                        if rel_normalized.contains(pattern) {
319                            return ModSafety::SaveBreaking;
320                        }
321                    }
322                }
323                stack.push(path);
324                continue;
325            }
326
327            has_any_file = true;
328
329            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
330                let ext_lower = ext.to_lowercase();
331                if config.save_breaking_ext.contains(&ext_lower.as_str()) {
332                    return ModSafety::SaveBreaking;
333                }
334                if config.cosmetic_ext.contains(&ext_lower.as_str()) {
335                    has_cosmetic = true;
336                }
337            }
338        }
339    }
340
341    if has_cosmetic && has_any_file {
342        ModSafety::SaveSafe
343    } else {
344        ModSafety::Unknown
345    }
346}
347
348/// Game-specific save detection and classification.
349///
350/// Implemented per-game alongside `GamePlugin`. The core `SaveManager` handles
351/// the git vault; this trait tells it *what* to look for and how to describe it.
352pub trait SaveTracker: Send + Sync {
353    /// Glob patterns matching save entries in the save directory.
354    /// Typically 1–3 patterns per game; `SmallVec<[_; 2]>` avoids heap allocation.
355    fn save_patterns(&self) -> SmallVec<[String; 2]>;
356
357    /// Scan the save directory and return all detected saves with classification.
358    fn detect_saves(&self, save_dir: &Path) -> Result<Vec<DetectedSave>>;
359
360    /// Patterns to exclude from auto-capture triggers (files that exist in
361    /// the save dir but aren't actual saves, e.g. global settings).
362    /// Typically 0–2 patterns; `SmallVec<[_; 2]>` avoids heap allocation.
363    fn exclude_patterns(&self) -> SmallVec<[String; 2]> {
364        SmallVec::new()
365    }
366
367    /// Generate a human-readable commit message for a capture.
368    fn describe_capture(&self, saves: &[DetectedSave]) -> String {
369        match saves.len() {
370            0 => "capture: no new saves".into(),
371            1 => {
372                let s = &saves[0];
373                let name = s.label.as_deref()
374                    .unwrap_or_else(|| s.rel_path.to_str().unwrap_or("unknown"));
375                format!("capture: {} [{}]", name, s.category)
376            }
377            n => format!("capture: {} saves", n),
378        }
379    }
380}
381
382// ── Mod Scanner ─────────────────────────────────────────────────
383
384pub struct ScanContext<'a> {
385    pub install_dir: &'a Path,
386}
387
388#[derive(Debug, Clone)]
389pub struct DiscoveredFile {
390    pub rel_path: String,
391    pub size: u64,
392}
393
394#[derive(Debug, Clone)]
395pub enum ModSource {
396    Filesystem { location: String },
397    Archive { archive_name: String },
398}
399
400#[derive(Debug, Clone)]
401pub struct DiscoveredMod {
402    pub mod_id: String,
403    pub display_name: String,
404    pub version: Option<String>,
405    pub files: Vec<DiscoveredFile>,
406    pub source: ModSource,
407    pub confidence: f64,
408}
409
410pub trait ModScanner: Send + Sync {
411    fn scan_directories(&self) -> &[&str];
412    fn scan_filesystem(&self, ctx: &ScanContext<'_>) -> anyhow::Result<Vec<DiscoveredMod>>;
413
414    /// Inverse of [`ModScanner::scan_filesystem`]'s mod_id scheme: given
415    /// a mod_id this scanner would produce, return the filesystem footprint
416    /// that mod owns (directory subtree or single file).
417    ///
418    /// Used by `modde_core::scanner::detect_stale_duplicates` to correlate
419    /// profile rows with a Wabbajack manifest's install directives. The
420    /// default impl returns `None`, which causes the dedup path to skip
421    /// the row. Game plugins that want their filesystem-scanner rows to
422    /// participate in dedup should override this.
423    fn mod_id_footprint(&self, _mod_id: &str) -> Option<modde_core::scanner::ModFootprint> {
424        None
425    }
426}
427
428pub fn walk_files_relative(base: &Path, dir: &Path) -> Vec<DiscoveredFile> {
429    let mut result = Vec::new();
430    if let Ok(entries) = std::fs::read_dir(dir) {
431        for entry in entries.flatten() {
432            let path = entry.path();
433            if path.is_dir() {
434                result.extend(walk_files_relative(base, &path));
435            } else if let Ok(meta) = path.metadata() {
436                if let Ok(rel) = path.strip_prefix(base) {
437                    result.push(DiscoveredFile {
438                        rel_path: rel.to_string_lossy().to_string(),
439                        size: meta.len(),
440                    });
441                }
442            }
443        }
444    }
445    result
446}
447
448pub fn slug(s: &str) -> String {
449    s.to_lowercase()
450        .chars()
451        .map(|c| if c.is_alphanumeric() { c } else { '-' })
452        .collect::<String>()
453        .trim_matches('-')
454        .to_string()
455}