modde_games/
scanner_patterns.rs1use std::collections::BTreeMap;
6use std::path::Path;
7
8use anyhow::Context;
9
10use crate::traits::{DiscoveredFile, DiscoveredMod, ModSource, walk_files_relative};
11
12#[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 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#[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 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#[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 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 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}