Skip to main content

modde_core/
diagnostics.rs

1use std::path::{Path, PathBuf};
2
3use crate::collision::CollisionReport;
4use crate::profile::Profile;
5use crate::resolver::ConflictMap;
6
7/// Severity level for diagnostics.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
9pub enum Severity {
10    Error,
11    Warning,
12    Info,
13}
14
15/// A suggested fix for a diagnostic.
16#[derive(Debug, Clone)]
17pub struct DiagFix {
18    pub label: String,
19    pub description: String,
20}
21
22/// A single diagnostic finding.
23#[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
33/// Context passed to diagnostic rules for analysis.
34pub 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
43// ── Collision-aware diagnostic rules ────────────────────────────────
44
45/// Warn about mods whose files are all overridden by higher-priority mods.
46pub 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
85/// Warn about dangerous script/plugin/DLL collisions.
86pub 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
134/// A diagnostic rule that checks for specific issues.
135pub trait DiagnosticRule: Send + Sync {
136    fn name(&self) -> &str;
137    fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic>;
138}
139
140/// Engine that runs all registered rules.
141pub 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    /// A mock rule that always returns a fixed set of diagnostics.
179    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}