Skip to main content

modde_games/bethesda/
diagnostics.rs

1//! Bethesda-specific diagnostic rules (missing masters, Form 43 plugins in
2//! SSE/AE, orphaned overrides) and [`bethesda_diagnostics`], which assembles
3//! them onto the shared [`DiagnosticEngine`].
4
5use std::path::PathBuf;
6
7use modde_core::diagnostics::{
8    DiagContext, DiagFix, Diagnostic, DiagnosticEngine, DiagnosticRule, Severity,
9};
10
11use super::plugin_header::{self, PluginWarning};
12
13/// Rule: Check for plugins with missing master dependencies.
14pub struct MissingMasterRule;
15
16impl DiagnosticRule for MissingMasterRule {
17    fn name(&self) -> &'static str {
18        "missing-masters"
19    }
20
21    fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
22        let plugin_dir = ctx.staging_dir;
23
24        let active_plugins: Vec<&str> = collect_active_plugins(ctx);
25        if active_plugins.is_empty() {
26            return Vec::new();
27        }
28
29        let warnings = plugin_header::validate_plugins(plugin_dir, &active_plugins, false);
30
31        warnings
32            .into_iter()
33            .filter_map(|w| match w {
34                PluginWarning::MissingMaster { plugin, master } => Some(Diagnostic {
35                    severity: Severity::Error,
36                    title: format!("Missing master: {master}"),
37                    detail: format!(
38                        "Plugin '{plugin}' requires master '{master}' which is not in the load order. \
39                         The game will crash on load."
40                    ),
41                    affected_mod: Some(plugin),
42                    affected_file: Some(PathBuf::from(&master)),
43                    fix: Some(DiagFix {
44                        label: "Install missing master".to_string(),
45                        description: format!("Install the mod that provides '{master}' and enable it."),
46                    }),
47                }),
48                _ => None,
49            })
50            .collect()
51    }
52}
53
54/// Rule: Check for Form 43 (Oldrim) plugins in SSE/AE.
55pub struct Form43Rule;
56
57impl DiagnosticRule for Form43Rule {
58    fn name(&self) -> &'static str {
59        "form-43"
60    }
61
62    fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
63        // Only applies to skyrim-se and skyrim-ae
64        if ctx.game_id != "skyrim-se" && ctx.game_id != "skyrim-ae" {
65            return Vec::new();
66        }
67
68        let plugin_dir = ctx.staging_dir;
69
70        let active_plugins: Vec<&str> = collect_active_plugins(ctx);
71        if active_plugins.is_empty() {
72            return Vec::new();
73        }
74
75        let warnings = plugin_header::validate_plugins(plugin_dir, &active_plugins, true);
76
77        warnings
78            .into_iter()
79            .filter_map(|w| match w {
80                PluginWarning::Form43 { plugin, version } => Some(Diagnostic {
81                    severity: Severity::Warning,
82                    title: format!("Form 43 plugin: {plugin}"),
83                    detail: format!(
84                        "Plugin '{plugin}' uses Form 43 (v{version:.2}), the Oldrim format. \
85                         This can cause crashes in Skyrim SE/AE. Resave it in Creation Kit."
86                    ),
87                    affected_mod: Some(plugin),
88                    affected_file: None,
89                    fix: Some(DiagFix {
90                        label: "Resave in Creation Kit".to_string(),
91                        description: "Open the plugin in Creation Kit (SSE) and save it to convert to Form 44.".to_string(),
92                    }),
93                }),
94                _ => None,
95            })
96            .collect()
97    }
98}
99
100pub use modde_core::diagnostics::StorePresenceRule as EmptyModRule;
101
102/// Rule: Check if overrides directory has unexpected files.
103pub struct OrphanedOverridesRule;
104
105impl DiagnosticRule for OrphanedOverridesRule {
106    fn name(&self) -> &'static str {
107        "orphaned-overrides"
108    }
109
110    fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
111        let overrides_dir = &ctx.profile.overrides;
112        if !overrides_dir.exists() {
113            return Vec::new();
114        }
115
116        let has_files = match std::fs::read_dir(overrides_dir) {
117            Ok(mut entries) => entries.next().is_some(),
118            Err(_) => false,
119        };
120
121        if has_files {
122            vec![Diagnostic {
123                severity: Severity::Info,
124                title: "Overrides directory has files".to_string(),
125                detail: format!(
126                    "The overrides directory '{}' contains files. \
127                     These files take highest priority and override all mods. \
128                     Review them to ensure they are intentional.",
129                    overrides_dir.display()
130                ),
131                affected_mod: None,
132                affected_file: Some(overrides_dir.clone()),
133                fix: None,
134            }]
135        } else {
136            Vec::new()
137        }
138    }
139}
140
141/// Create a pre-configured diagnostics engine with all Bethesda rules.
142#[must_use]
143pub fn bethesda_diagnostics() -> DiagnosticEngine {
144    let mut engine = modde_core::diagnostics::base_diagnostics();
145    engine.add_rule(Box::new(MissingMasterRule));
146    engine.add_rule(Box::new(Form43Rule));
147    engine.add_rule(Box::new(OrphanedOverridesRule));
148    engine
149}
150
151/// Collect active plugin filenames from the profile's enabled mods.
152///
153/// Looks for `.esp`, `.esm`, and `.esl` files in each enabled mod's store directory.
154fn collect_active_plugins<'a>(ctx: &'a DiagContext<'a>) -> Vec<&'a str> {
155    ctx.active_plugins.iter().map(String::as_str).collect()
156}