Skip to main content

modde_core/
diagnostics.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4
5use crate::collision::CollisionReport;
6use crate::profile::Profile;
7use crate::resolver::{ConflictMap, ModId};
8
9/// Severity level for diagnostics.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
11pub enum Severity {
12    Error,
13    Warning,
14    Info,
15}
16
17/// A suggested fix for a diagnostic.
18#[derive(Debug, Clone)]
19pub struct DiagFix {
20    pub label: String,
21    pub description: String,
22}
23
24/// A single diagnostic finding.
25#[derive(Debug, Clone)]
26pub struct Diagnostic {
27    pub severity: Severity,
28    pub title: String,
29    pub detail: String,
30    pub affected_mod: Option<String>,
31    pub affected_file: Option<PathBuf>,
32    pub fix: Option<DiagFix>,
33}
34
35/// Context passed to diagnostic rules for analysis.
36pub struct DiagContext<'a> {
37    pub game_id: &'a str,
38    pub profile: &'a Profile,
39    pub active_plugins: &'a [String],
40    pub conflict_map: &'a ConflictMap,
41    pub collision_report: Option<&'a CollisionReport>,
42    pub store_dir: &'a Path,
43    pub staging_dir: &'a Path,
44}
45
46/// Shared analysis result used by the CLI, UI, and tests.
47pub struct ProfileAnalysis {
48    pub resolved_order: Vec<ModId>,
49    pub conflict_map: ConflictMap,
50    pub collision_report: Option<CollisionReport>,
51    pub missing_store_mods: Vec<ModId>,
52}
53
54/// Build real conflict and collision state for a profile.
55pub fn analyze_profile_state(
56    profile: &Profile,
57    store_dir: &Path,
58    hidden: &std::collections::HashSet<(String, String)>,
59    classifier: Option<&dyn crate::collision::CollisionClassifier>,
60) -> Result<ProfileAnalysis> {
61    let resolved_order = crate::resolver::resolve(profile)?.order;
62    let missing_store_mods = resolved_order
63        .iter()
64        .filter(|mod_id| !store_dir.join(mod_id.as_str()).exists())
65        .cloned()
66        .collect::<Vec<_>>();
67
68    let Some(classifier) = classifier else {
69        return Ok(ProfileAnalysis {
70            resolved_order,
71            conflict_map: ConflictMap::default(),
72            collision_report: None,
73            missing_store_mods,
74        });
75    };
76
77    let full_conflict_map =
78        crate::collision::build_full_conflict_map(store_dir, &resolved_order, classifier)?;
79    let collision_report = crate::collision::analyze_collisions(
80        &full_conflict_map.conflict_map,
81        &resolved_order,
82        hidden,
83        &full_conflict_map.origins,
84        classifier,
85    );
86
87    Ok(ProfileAnalysis {
88        resolved_order,
89        conflict_map: full_conflict_map.conflict_map,
90        collision_report: Some(collision_report),
91        missing_store_mods: full_conflict_map.missing_mods,
92    })
93}
94
95/// Run a diagnostic engine against a fully analyzed profile.
96pub fn run_profile_diagnostics(
97    game_id: &str,
98    profile: &Profile,
99    active_plugins: &[String],
100    store_dir: &Path,
101    staging_dir: &Path,
102    hidden: &std::collections::HashSet<(String, String)>,
103    classifier: Option<&dyn crate::collision::CollisionClassifier>,
104    engine: &DiagnosticEngine,
105) -> Result<(Vec<Diagnostic>, ProfileAnalysis)> {
106    let analysis = analyze_profile_state(profile, store_dir, hidden, classifier)?;
107    let ctx = DiagContext {
108        game_id,
109        profile,
110        active_plugins,
111        conflict_map: &analysis.conflict_map,
112        collision_report: analysis.collision_report.as_ref(),
113        store_dir,
114        staging_dir,
115    };
116
117    Ok((engine.run_all(&ctx), analysis))
118}
119
120// ── Shared diagnostic rules ─────────────────────────────────────────
121
122/// Warn about enabled mods whose store directory is missing or empty.
123pub struct StorePresenceRule;
124
125impl DiagnosticRule for StorePresenceRule {
126    fn name(&self) -> &'static str {
127        "store-presence"
128    }
129
130    fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
131        ctx.profile
132            .mods
133            .iter()
134            .filter(|m| m.enabled)
135            .filter_map(|m| {
136                let mod_dir = ctx.store_dir.join(&m.mod_id);
137                let is_empty = if mod_dir.exists() {
138                    match std::fs::read_dir(&mod_dir) {
139                        Ok(mut entries) => entries.next().is_none(),
140                        Err(_) => true,
141                    }
142                } else {
143                    true
144                };
145
146                if is_empty {
147                    Some(Diagnostic {
148                        severity: Severity::Warning,
149                        title: format!("Empty mod: {}", m.mod_id),
150                        detail: format!(
151                            "Mod '{}' is enabled but has no files in the store directory. \
152                             It may not have been downloaded or extracted correctly.",
153                            m.mod_id
154                        ),
155                        affected_mod: Some(m.mod_id.clone()),
156                        affected_file: Some(mod_dir),
157                        fix: Some(DiagFix {
158                            label: "Re-install mod".to_string(),
159                            description: format!(
160                                "Re-download and install '{}', or disable it if it is no longer needed.",
161                                m.mod_id
162                            ),
163                        }),
164                    })
165                } else {
166                    None
167                }
168            })
169            .collect()
170    }
171}
172
173// ── Collision-aware diagnostic rules ────────────────────────────────
174
175/// Warn about mods whose files are all overridden by higher-priority mods.
176pub struct ShadowedModRule;
177
178impl DiagnosticRule for ShadowedModRule {
179    fn name(&self) -> &'static str {
180        "shadowed-mod"
181    }
182
183    fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
184        let Some(report) = ctx.collision_report else {
185            return Vec::new();
186        };
187        report
188            .shadowed_mods
189            .iter()
190            .map(|sm| {
191                let by: Vec<&str> = sm
192                    .shadowed_by
193                    .iter()
194                    .map(super::resolver::ModId::as_str)
195                    .collect();
196                Diagnostic {
197                    severity: Severity::Warning,
198                    title: format!("Mod \"{}\" is completely shadowed", sm.mod_id),
199                    detail: format!(
200                        "All {} files are overridden by: {}. Consider disabling this mod.",
201                        sm.file_count,
202                        by.join(", ")
203                    ),
204                    affected_mod: Some(sm.mod_id.to_string()),
205                    affected_file: None,
206                    fix: Some(DiagFix {
207                        label: "Disable mod".to_string(),
208                        description: format!("Disable \"{}\" to reduce deployment size", sm.mod_id),
209                    }),
210                }
211            })
212            .collect()
213    }
214}
215
216/// Warn about dangerous script/plugin/DLL collisions.
217pub struct DangerousCollisionRule;
218
219impl DiagnosticRule for DangerousCollisionRule {
220    fn name(&self) -> &'static str {
221        "dangerous-collision"
222    }
223
224    fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
225        let Some(report) = ctx.collision_report else {
226            return Vec::new();
227        };
228        report
229            .pairs
230            .iter()
231            .filter(|p| p.max_severity == crate::collision::CollisionSeverity::Dangerous)
232            .map(|pair| {
233                let dangerous_files: Vec<&str> = pair
234                    .files
235                    .iter()
236                    .filter(|f| f.severity == crate::collision::CollisionSeverity::Dangerous)
237                    .map(|f| f.file_path.as_str())
238                    .collect();
239                Diagnostic {
240                    severity: Severity::Warning,
241                    title: format!("Dangerous collision: {} vs {}", pair.loser, pair.winner),
242                    detail: format!(
243                        "{} script/plugin/DLL files conflict: {}",
244                        dangerous_files.len(),
245                        dangerous_files.join(", ")
246                    ),
247                    affected_mod: Some(pair.loser.to_string()),
248                    affected_file: None,
249                    fix: Some(DiagFix {
250                        label: "Review load order".to_string(),
251                        description: format!(
252                            "Check that \"{}\" winning over \"{}\" is intentional for these files",
253                            pair.winner, pair.loser
254                        ),
255                    }),
256                }
257            })
258            .collect()
259    }
260}
261
262/// A diagnostic rule that checks for specific issues.
263pub trait DiagnosticRule: Send + Sync {
264    fn name(&self) -> &str;
265    fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic>;
266}
267
268/// Engine that runs all registered rules.
269pub struct DiagnosticEngine {
270    rules: Vec<Box<dyn DiagnosticRule>>,
271}
272
273impl DiagnosticEngine {
274    #[must_use]
275    pub fn new() -> Self {
276        Self { rules: Vec::new() }
277    }
278
279    pub fn add_rule(&mut self, rule: Box<dyn DiagnosticRule>) {
280        self.rules.push(rule);
281    }
282
283    #[must_use]
284    pub fn run_all(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
285        let mut results: Vec<Diagnostic> = self.rules.iter().flat_map(|r| r.check(ctx)).collect();
286        results.sort_by_key(|d| d.severity);
287        results
288    }
289}
290
291impl Default for DiagnosticEngine {
292    fn default() -> Self {
293        Self::new()
294    }
295}
296
297/// Create diagnostics shared by every supported game.
298#[must_use]
299pub fn base_diagnostics() -> DiagnosticEngine {
300    let mut engine = DiagnosticEngine::new();
301    engine.add_rule(Box::new(StorePresenceRule));
302    engine
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::collision::{CollisionClassifier, CollisionSeverity};
309    use crate::profile::{EnabledMod, Profile, ProfileSource};
310    use crate::resolver::{ConflictMap, GameId, ModId};
311    use smallvec::smallvec;
312    use std::path::PathBuf;
313
314    /// A mock rule that always returns a fixed set of diagnostics.
315    struct MockRule {
316        name: &'static str,
317        diagnostics: Vec<Diagnostic>,
318    }
319
320    struct TestClassifier;
321
322    impl CollisionClassifier for TestClassifier {
323        fn index_archive(&self, _archive_path: &Path) -> Result<Vec<(String, u64)>> {
324            Ok(Vec::new())
325        }
326
327        fn classify_severity(&self, _file_path: &str) -> CollisionSeverity {
328            CollisionSeverity::Unknown
329        }
330
331        fn archive_extensions(&self) -> &[&str] {
332            &[]
333        }
334    }
335
336    impl DiagnosticRule for MockRule {
337        fn name(&self) -> &str {
338            self.name
339        }
340
341        fn check(&self, _ctx: &DiagContext) -> Vec<Diagnostic> {
342            self.diagnostics.clone()
343        }
344    }
345
346    fn make_context() -> (Profile, ConflictMap, tempfile::TempDir, tempfile::TempDir) {
347        let profile = Profile {
348            id: None,
349            name: "test".to_string(),
350            game_id: GameId::from("skyrim-se"),
351            source: ProfileSource::Manual,
352            mods: vec![],
353            overrides: PathBuf::from("/tmp/overrides"),
354            load_order_rules: smallvec![],
355            load_order_lock: None,
356        };
357        let conflict_map = ConflictMap::default();
358        let store = tempfile::tempdir().unwrap();
359        let staging = tempfile::tempdir().unwrap();
360        (profile, conflict_map, store, staging)
361    }
362
363    fn enabled_mod(id: &str) -> EnabledMod {
364        EnabledMod {
365            mod_id: id.to_string(),
366            enabled: true,
367            version: None,
368            fomod_config: None,
369            ..Default::default()
370        }
371    }
372
373    fn disabled_mod(id: &str) -> EnabledMod {
374        EnabledMod {
375            enabled: false,
376            ..enabled_mod(id)
377        }
378    }
379
380    #[test]
381    fn store_presence_rule_reports_missing_and_empty_enabled_mods_only() {
382        let store = tempfile::tempdir().unwrap();
383        let staging = tempfile::tempdir().unwrap();
384        let overrides = tempfile::tempdir().unwrap();
385
386        let with_files = store.path().join("mod-with-files");
387        std::fs::create_dir_all(with_files.join("textures")).unwrap();
388        std::fs::write(with_files.join("textures/sky.dds"), b"sky").unwrap();
389        std::fs::create_dir_all(store.path().join("mod-empty")).unwrap();
390
391        let profile = Profile {
392            id: None,
393            name: "test".to_string(),
394            game_id: GameId::from("cyberpunk2077"),
395            source: ProfileSource::Manual,
396            mods: vec![
397                enabled_mod("mod-with-files"),
398                enabled_mod("mod-empty"),
399                enabled_mod("mod-missing"),
400                disabled_mod("mod-disabled-missing"),
401            ],
402            overrides: overrides.path().to_path_buf(),
403            load_order_rules: smallvec![],
404            load_order_lock: None,
405        };
406        let conflict_map = ConflictMap::default();
407        let ctx = DiagContext {
408            game_id: "cyberpunk2077",
409            profile: &profile,
410            active_plugins: &[],
411            conflict_map: &conflict_map,
412            collision_report: None,
413            store_dir: store.path(),
414            staging_dir: staging.path(),
415        };
416
417        let diagnostics = StorePresenceRule.check(&ctx);
418
419        assert_eq!(diagnostics.len(), 2);
420        assert!(diagnostics.iter().all(|d| d.severity == Severity::Warning));
421        assert!(
422            diagnostics
423                .iter()
424                .any(|d| d.affected_mod.as_deref() == Some("mod-empty"))
425        );
426        assert!(
427            diagnostics
428                .iter()
429                .any(|d| d.affected_mod.as_deref() == Some("mod-missing"))
430        );
431        assert!(!diagnostics.iter().any(|d| {
432            matches!(
433                d.affected_mod.as_deref(),
434                Some("mod-with-files" | "mod-disabled-missing")
435            )
436        }));
437    }
438
439    #[test]
440    fn base_diagnostics_includes_store_presence_rule() {
441        let store = tempfile::tempdir().unwrap();
442        let staging = tempfile::tempdir().unwrap();
443        let overrides = tempfile::tempdir().unwrap();
444        let profile = Profile {
445            id: None,
446            name: "test".to_string(),
447            game_id: GameId::from("cyberpunk2077"),
448            source: ProfileSource::Manual,
449            mods: vec![enabled_mod("mod-missing")],
450            overrides: overrides.path().to_path_buf(),
451            load_order_rules: smallvec![],
452            load_order_lock: None,
453        };
454        let conflict_map = ConflictMap::default();
455        let ctx = DiagContext {
456            game_id: "cyberpunk2077",
457            profile: &profile,
458            active_plugins: &[],
459            conflict_map: &conflict_map,
460            collision_report: None,
461            store_dir: store.path(),
462            staging_dir: staging.path(),
463        };
464
465        let results = base_diagnostics().run_all(&ctx);
466
467        assert_eq!(results.len(), 1);
468        assert_eq!(results[0].affected_mod.as_deref(), Some("mod-missing"));
469    }
470
471    #[test]
472    fn analyze_profile_state_reports_missing_store_mods() {
473        let store = tempfile::tempdir().unwrap();
474        let overrides = tempfile::tempdir().unwrap();
475        let with_files = store.path().join("mod-with-files");
476        std::fs::create_dir_all(with_files.join("textures")).unwrap();
477        std::fs::write(with_files.join("textures/sky.dds"), b"sky").unwrap();
478
479        let profile = Profile {
480            id: None,
481            name: "test".to_string(),
482            game_id: GameId::from("cyberpunk2077"),
483            source: ProfileSource::Manual,
484            mods: vec![enabled_mod("mod-with-files"), enabled_mod("mod-missing")],
485            overrides: overrides.path().to_path_buf(),
486            load_order_rules: smallvec![],
487            load_order_lock: None,
488        };
489        let hidden = std::collections::HashSet::new();
490
491        let analysis =
492            analyze_profile_state(&profile, store.path(), &hidden, Some(&TestClassifier)).unwrap();
493
494        assert_eq!(
495            analysis.missing_store_mods,
496            vec![ModId::from("mod-missing")]
497        );
498        assert!(analysis.conflict_map.files.contains_key("textures/sky.dds"));
499    }
500
501    #[test]
502    fn test_engine_runs_all_rules() {
503        let mut engine = DiagnosticEngine::new();
504
505        engine.add_rule(Box::new(MockRule {
506            name: "rule-a",
507            diagnostics: vec![Diagnostic {
508                severity: Severity::Warning,
509                title: "Warning A".to_string(),
510                detail: "detail".to_string(),
511                affected_mod: None,
512                affected_file: None,
513                fix: None,
514            }],
515        }));
516
517        engine.add_rule(Box::new(MockRule {
518            name: "rule-b",
519            diagnostics: vec![Diagnostic {
520                severity: Severity::Error,
521                title: "Error B".to_string(),
522                detail: "detail".to_string(),
523                affected_mod: None,
524                affected_file: None,
525                fix: None,
526            }],
527        }));
528
529        let (profile, conflict_map, store, staging) = make_context();
530        let ctx = DiagContext {
531            game_id: "skyrim-se",
532            profile: &profile,
533            active_plugins: &[],
534            conflict_map: &conflict_map,
535            collision_report: None,
536            store_dir: store.path(),
537            staging_dir: staging.path(),
538        };
539
540        let results = engine.run_all(&ctx);
541        assert_eq!(results.len(), 2);
542    }
543
544    #[test]
545    fn test_diagnostics_sorted_by_severity() {
546        let mut engine = DiagnosticEngine::new();
547
548        engine.add_rule(Box::new(MockRule {
549            name: "mixed",
550            diagnostics: vec![
551                Diagnostic {
552                    severity: Severity::Info,
553                    title: "Info".to_string(),
554                    detail: "detail".to_string(),
555                    affected_mod: None,
556                    affected_file: None,
557                    fix: None,
558                },
559                Diagnostic {
560                    severity: Severity::Error,
561                    title: "Error".to_string(),
562                    detail: "detail".to_string(),
563                    affected_mod: None,
564                    affected_file: None,
565                    fix: None,
566                },
567                Diagnostic {
568                    severity: Severity::Warning,
569                    title: "Warning".to_string(),
570                    detail: "detail".to_string(),
571                    affected_mod: None,
572                    affected_file: None,
573                    fix: None,
574                },
575            ],
576        }));
577
578        let (profile, conflict_map, store, staging) = make_context();
579        let ctx = DiagContext {
580            game_id: "skyrim-se",
581            profile: &profile,
582            active_plugins: &[],
583            conflict_map: &conflict_map,
584            collision_report: None,
585            store_dir: store.path(),
586            staging_dir: staging.path(),
587        };
588
589        let results = engine.run_all(&ctx);
590        assert_eq!(results.len(), 3);
591        assert_eq!(results[0].severity, Severity::Error);
592        assert_eq!(results[1].severity, Severity::Warning);
593        assert_eq!(results[2].severity, Severity::Info);
594    }
595
596    #[test]
597    fn test_empty_engine() {
598        let engine = DiagnosticEngine::new();
599
600        let (profile, conflict_map, store, staging) = make_context();
601        let ctx = DiagContext {
602            game_id: "skyrim-se",
603            profile: &profile,
604            active_plugins: &[],
605            conflict_map: &conflict_map,
606            collision_report: None,
607            store_dir: store.path(),
608            staging_dir: staging.path(),
609        };
610
611        let results = engine.run_all(&ctx);
612        assert!(results.is_empty());
613    }
614}