1use std::path::{Path, PathBuf};
2
3use crate::collision::CollisionReport;
4use crate::profile::Profile;
5use crate::resolver::ConflictMap;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
9pub enum Severity {
10 Error,
11 Warning,
12 Info,
13}
14
15#[derive(Debug, Clone)]
17pub struct DiagFix {
18 pub label: String,
19 pub description: String,
20}
21
22#[derive(Debug, Clone)]
24pub struct Diagnostic {
25 pub severity: Severity,
26 pub title: String,
27 pub detail: String,
28 pub affected_mod: Option<String>,
29 pub affected_file: Option<PathBuf>,
30 pub fix: Option<DiagFix>,
31}
32
33pub struct DiagContext<'a> {
35 pub game_id: &'a str,
36 pub profile: &'a Profile,
37 pub conflict_map: &'a ConflictMap,
38 pub collision_report: Option<&'a CollisionReport>,
39 pub store_dir: &'a Path,
40 pub staging_dir: &'a Path,
41}
42
43pub struct ShadowedModRule;
47
48impl DiagnosticRule for ShadowedModRule {
49 fn name(&self) -> &str {
50 "shadowed-mod"
51 }
52
53 fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
54 let Some(report) = ctx.collision_report else {
55 return Vec::new();
56 };
57 report
58 .shadowed_mods
59 .iter()
60 .map(|sm| {
61 let by: Vec<&str> = sm.shadowed_by.iter().map(|m| m.as_str()).collect();
62 Diagnostic {
63 severity: Severity::Warning,
64 title: format!("Mod \"{}\" is completely shadowed", sm.mod_id),
65 detail: format!(
66 "All {} files are overridden by: {}. Consider disabling this mod.",
67 sm.file_count,
68 by.join(", ")
69 ),
70 affected_mod: Some(sm.mod_id.to_string()),
71 affected_file: None,
72 fix: Some(DiagFix {
73 label: "Disable mod".to_string(),
74 description: format!(
75 "Disable \"{}\" to reduce deployment size",
76 sm.mod_id
77 ),
78 }),
79 }
80 })
81 .collect()
82 }
83}
84
85pub struct DangerousCollisionRule;
87
88impl DiagnosticRule for DangerousCollisionRule {
89 fn name(&self) -> &str {
90 "dangerous-collision"
91 }
92
93 fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
94 let Some(report) = ctx.collision_report else {
95 return Vec::new();
96 };
97 report
98 .pairs
99 .iter()
100 .filter(|p| p.max_severity == crate::collision::CollisionSeverity::Dangerous)
101 .map(|pair| {
102 let dangerous_files: Vec<&str> = pair
103 .files
104 .iter()
105 .filter(|f| f.severity == crate::collision::CollisionSeverity::Dangerous)
106 .map(|f| f.file_path.as_str())
107 .collect();
108 Diagnostic {
109 severity: Severity::Warning,
110 title: format!(
111 "Dangerous collision: {} vs {}",
112 pair.loser, pair.winner
113 ),
114 detail: format!(
115 "{} script/plugin/DLL files conflict: {}",
116 dangerous_files.len(),
117 dangerous_files.join(", ")
118 ),
119 affected_mod: Some(pair.loser.to_string()),
120 affected_file: None,
121 fix: Some(DiagFix {
122 label: "Review load order".to_string(),
123 description: format!(
124 "Check that \"{}\" winning over \"{}\" is intentional for these files",
125 pair.winner, pair.loser
126 ),
127 }),
128 }
129 })
130 .collect()
131 }
132}
133
134pub trait DiagnosticRule: Send + Sync {
136 fn name(&self) -> &str;
137 fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic>;
138}
139
140pub struct DiagnosticEngine {
142 rules: Vec<Box<dyn DiagnosticRule>>,
143}
144
145impl DiagnosticEngine {
146 pub fn new() -> Self {
147 Self { rules: Vec::new() }
148 }
149
150 pub fn add_rule(&mut self, rule: Box<dyn DiagnosticRule>) {
151 self.rules.push(rule);
152 }
153
154 pub fn run_all(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
155 let mut results: Vec<Diagnostic> = self.rules
156 .iter()
157 .flat_map(|r| r.check(ctx))
158 .collect();
159 results.sort_by_key(|d| d.severity);
160 results
161 }
162}
163
164impl Default for DiagnosticEngine {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::profile::{EnabledMod, Profile, ProfileSource};
174 use crate::resolver::{ConflictMap, GameId};
175 use smallvec::smallvec;
176 use std::path::PathBuf;
177
178 struct MockRule {
180 name: &'static str,
181 diagnostics: Vec<Diagnostic>,
182 }
183
184 impl DiagnosticRule for MockRule {
185 fn name(&self) -> &str {
186 self.name
187 }
188
189 fn check(&self, _ctx: &DiagContext) -> Vec<Diagnostic> {
190 self.diagnostics.clone()
191 }
192 }
193
194 fn make_context() -> (Profile, ConflictMap, tempfile::TempDir, tempfile::TempDir) {
195 let profile = Profile {
196 id: None,
197 name: "test".to_string(),
198 game_id: GameId::from("skyrim-se"),
199 source: ProfileSource::Manual,
200 mods: vec![],
201 overrides: PathBuf::from("/tmp/overrides"),
202 load_order_rules: smallvec![],
203 load_order_lock: None,
204 };
205 let conflict_map = ConflictMap::default();
206 let store = tempfile::tempdir().unwrap();
207 let staging = tempfile::tempdir().unwrap();
208 (profile, conflict_map, store, staging)
209 }
210
211 #[test]
212 fn test_engine_runs_all_rules() {
213 let mut engine = DiagnosticEngine::new();
214
215 engine.add_rule(Box::new(MockRule {
216 name: "rule-a",
217 diagnostics: vec![Diagnostic {
218 severity: Severity::Warning,
219 title: "Warning A".to_string(),
220 detail: "detail".to_string(),
221 affected_mod: None,
222 affected_file: None,
223 fix: None,
224 }],
225 }));
226
227 engine.add_rule(Box::new(MockRule {
228 name: "rule-b",
229 diagnostics: vec![Diagnostic {
230 severity: Severity::Error,
231 title: "Error B".to_string(),
232 detail: "detail".to_string(),
233 affected_mod: None,
234 affected_file: None,
235 fix: None,
236 }],
237 }));
238
239 let (profile, conflict_map, store, staging) = make_context();
240 let ctx = DiagContext {
241 game_id: "skyrim-se",
242 profile: &profile,
243 conflict_map: &conflict_map,
244 collision_report: None,
245 store_dir: store.path(),
246 staging_dir: staging.path(),
247 };
248
249 let results = engine.run_all(&ctx);
250 assert_eq!(results.len(), 2);
251 }
252
253 #[test]
254 fn test_diagnostics_sorted_by_severity() {
255 let mut engine = DiagnosticEngine::new();
256
257 engine.add_rule(Box::new(MockRule {
258 name: "mixed",
259 diagnostics: vec![
260 Diagnostic {
261 severity: Severity::Info,
262 title: "Info".to_string(),
263 detail: "detail".to_string(),
264 affected_mod: None,
265 affected_file: None,
266 fix: None,
267 },
268 Diagnostic {
269 severity: Severity::Error,
270 title: "Error".to_string(),
271 detail: "detail".to_string(),
272 affected_mod: None,
273 affected_file: None,
274 fix: None,
275 },
276 Diagnostic {
277 severity: Severity::Warning,
278 title: "Warning".to_string(),
279 detail: "detail".to_string(),
280 affected_mod: None,
281 affected_file: None,
282 fix: None,
283 },
284 ],
285 }));
286
287 let (profile, conflict_map, store, staging) = make_context();
288 let ctx = DiagContext {
289 game_id: "skyrim-se",
290 profile: &profile,
291 conflict_map: &conflict_map,
292 collision_report: None,
293 store_dir: store.path(),
294 staging_dir: staging.path(),
295 };
296
297 let results = engine.run_all(&ctx);
298 assert_eq!(results.len(), 3);
299 assert_eq!(results[0].severity, Severity::Error);
300 assert_eq!(results[1].severity, Severity::Warning);
301 assert_eq!(results[2].severity, Severity::Info);
302 }
303
304 #[test]
305 fn test_empty_engine() {
306 let engine = DiagnosticEngine::new();
307
308 let (profile, conflict_map, store, staging) = make_context();
309 let ctx = DiagContext {
310 game_id: "skyrim-se",
311 profile: &profile,
312 conflict_map: &conflict_map,
313 collision_report: None,
314 store_dir: store.path(),
315 staging_dir: staging.path(),
316 };
317
318 let results = engine.run_all(&ctx);
319 assert!(results.is_empty());
320 }
321}