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}