Skip to main content

modde_games/
scanner_patterns.rs

1//! Composable scanning rules that turn common mod-layout conventions
2//! (one-directory-per-mod, one-file-per-mod, grouped-by-stem) into
3//! [`DiscoveredMod`] entries, so game scanners can be assembled declaratively.
4
5use std::collections::BTreeMap;
6use std::path::Path;
7
8use anyhow::Context;
9
10use crate::traits::{DiscoveredFile, DiscoveredMod, ModSource, walk_files_relative};
11
12/// Treats each immediate subdirectory of `rel_dir` as one mod.
13#[derive(Debug, Clone, Copy)]
14pub struct DirectoryModRule {
15    pub rel_dir: &'static str,
16    pub mod_id_prefix: &'static str,
17    pub source_location: &'static str,
18    pub confidence: f64,
19    pub marker_file: Option<&'static str>,
20    pub marker_confidence: Option<f64>,
21}
22
23impl DirectoryModRule {
24    /// Scan `install` under this rule, appending discovered mods to `out`.
25    pub fn scan(self, install: &Path, out: &mut Vec<DiscoveredMod>) -> anyhow::Result<()> {
26        let dir = install.join(self.rel_dir);
27        if !dir.is_dir() {
28            return Ok(());
29        }
30
31        for entry in std::fs::read_dir(&dir)
32            .with_context(|| format!("failed to read directory: {}", dir.display()))?
33            .flatten()
34        {
35            if !entry.path().is_dir() {
36                continue;
37            }
38
39            let name = entry.file_name().to_string_lossy().to_string();
40            let files = walk_files_relative(install, &entry.path());
41            if files.is_empty() {
42                continue;
43            }
44
45            let confidence = self
46                .marker_file
47                .filter(|marker| entry.path().join(marker).exists())
48                .and(self.marker_confidence)
49                .unwrap_or(self.confidence);
50
51            out.push(DiscoveredMod {
52                mod_id: format!("{}/{name}", self.mod_id_prefix),
53                display_name: name,
54                version: None,
55                files,
56                source: ModSource::Filesystem {
57                    location: self.source_location.into(),
58                },
59                confidence,
60            });
61        }
62
63        Ok(())
64    }
65}
66
67/// Treats each file with `extension` directly inside `rel_dir` as one mod.
68#[derive(Debug, Clone, Copy)]
69pub struct SingleFileModRule {
70    pub rel_dir: &'static str,
71    pub extension: &'static str,
72    pub ignored_prefixes: &'static [&'static str],
73    pub mod_id_prefix: &'static str,
74    pub source_location: &'static str,
75    pub confidence: f64,
76}
77
78impl SingleFileModRule {
79    /// Scan `install` under this rule, appending discovered mods to `out`.
80    pub fn scan(self, install: &Path, out: &mut Vec<DiscoveredMod>) -> anyhow::Result<()> {
81        let dir = install.join(self.rel_dir);
82        if !dir.is_dir() {
83            return Ok(());
84        }
85
86        for entry in std::fs::read_dir(&dir)
87            .with_context(|| format!("failed to read directory: {}", dir.display()))?
88            .flatten()
89        {
90            let path = entry.path();
91            if path.is_dir()
92                || !path
93                    .extension()
94                    .and_then(|e| e.to_str())
95                    .is_some_and(|ext| ext.eq_ignore_ascii_case(self.extension))
96            {
97                continue;
98            }
99
100            let stem = path
101                .file_stem()
102                .and_then(|s| s.to_str())
103                .unwrap_or("unknown");
104            let stem_lower = stem.to_lowercase();
105            if self
106                .ignored_prefixes
107                .iter()
108                .any(|prefix| stem_lower.starts_with(&prefix.to_lowercase()))
109            {
110                continue;
111            }
112            let size = path.metadata().map_or(0, |m| m.len());
113            let rel = path
114                .strip_prefix(install)
115                .unwrap_or(&path)
116                .to_string_lossy()
117                .replace('\\', "/");
118
119            out.push(DiscoveredMod {
120                mod_id: format!("{}/{stem}", self.mod_id_prefix),
121                display_name: stem.to_string(),
122                version: None,
123                files: vec![DiscoveredFile {
124                    rel_path: rel,
125                    size,
126                }],
127                source: ModSource::Filesystem {
128                    location: self.source_location.into(),
129                },
130                confidence: self.confidence,
131            });
132        }
133
134        Ok(())
135    }
136}
137
138/// Groups files sharing a stem (across the configured `extensions`) into a
139/// single mod — e.g. a `.pak` plus its sidecar `.ucas`/`.utoc`.
140#[derive(Debug, Clone, Copy)]
141pub struct FileGroupRule {
142    pub rel_dir: &'static str,
143    pub extensions: &'static [&'static str],
144    pub mod_id_prefix: &'static str,
145    pub source_location: &'static str,
146    pub confidence: f64,
147}
148
149impl FileGroupRule {
150    /// Scan `install`'s `rel_dir`, appending grouped mods to `out`.
151    pub fn scan(self, install: &Path, out: &mut Vec<DiscoveredMod>) {
152        let dir = install.join(self.rel_dir);
153        self.scan_dir(install, &dir, out);
154    }
155
156    /// Scan an explicit `dir` (relative paths still resolved against `install`).
157    pub fn scan_dir(self, install: &Path, dir: &Path, out: &mut Vec<DiscoveredMod>) {
158        if !dir.is_dir() {
159            return;
160        }
161
162        let mut by_stem: BTreeMap<String, Vec<DiscoveredFile>> = BTreeMap::new();
163        let Ok(entries) = std::fs::read_dir(dir) else {
164            return;
165        };
166
167        for entry in entries.flatten() {
168            let path = entry.path();
169            if path.is_dir() {
170                for file in walk_files_relative(install, &path) {
171                    if let Some(stem) = stem_for_extensions(&file.rel_path, self.extensions) {
172                        by_stem.entry(stem).or_default().push(file);
173                    }
174                }
175                continue;
176            }
177
178            let Ok(meta) = path.metadata() else {
179                continue;
180            };
181            let Ok(rel) = path.strip_prefix(install) else {
182                continue;
183            };
184            let rel = rel.to_string_lossy().to_string();
185            let Some(stem) = stem_for_extensions(&rel, self.extensions) else {
186                continue;
187            };
188
189            by_stem.entry(stem).or_default().push(DiscoveredFile {
190                rel_path: rel,
191                size: meta.len(),
192            });
193        }
194
195        for (stem, files) in by_stem {
196            out.push(DiscoveredMod {
197                mod_id: format!("{}/{stem}", self.mod_id_prefix),
198                display_name: stem,
199                version: None,
200                files,
201                source: ModSource::Filesystem {
202                    location: self.source_location.into(),
203                },
204                confidence: self.confidence,
205            });
206        }
207    }
208}
209
210fn stem_for_extensions(rel: &str, extensions: &[&str]) -> Option<String> {
211    let lower = rel.to_lowercase();
212    if !extensions
213        .iter()
214        .any(|ext| lower.ends_with(&format!(".{ext}")))
215    {
216        return None;
217    }
218    Path::new(rel)
219        .file_stem()
220        .and_then(|s| s.to_str())
221        .map(std::string::ToString::to_string)
222}