Skip to main content

modde_games/bethesda/
diagnostics.rs

1use std::path::PathBuf;
2
3use modde_core::diagnostics::{
4    DiagContext, DiagFix, Diagnostic, DiagnosticEngine, DiagnosticRule, Severity,
5};
6
7use super::plugin_header::{self, PluginWarning};
8
9/// Rule: Check for plugins with missing master dependencies.
10pub struct MissingMasterRule;
11
12impl DiagnosticRule for MissingMasterRule {
13    fn name(&self) -> &str {
14        "missing-masters"
15    }
16
17    fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
18        let plugin_dir = ctx.staging_dir;
19
20        let active_plugins: Vec<&str> = collect_active_plugins(ctx);
21        if active_plugins.is_empty() {
22            return Vec::new();
23        }
24
25        let warnings = plugin_header::validate_plugins(plugin_dir, &active_plugins, false);
26
27        warnings
28            .into_iter()
29            .filter_map(|w| match w {
30                PluginWarning::MissingMaster { plugin, master } => Some(Diagnostic {
31                    severity: Severity::Error,
32                    title: format!("Missing master: {master}"),
33                    detail: format!(
34                        "Plugin '{plugin}' requires master '{master}' which is not in the load order. \
35                         The game will crash on load."
36                    ),
37                    affected_mod: Some(plugin),
38                    affected_file: Some(PathBuf::from(&master)),
39                    fix: Some(DiagFix {
40                        label: "Install missing master".to_string(),
41                        description: format!("Install the mod that provides '{master}' and enable it."),
42                    }),
43                }),
44                _ => None,
45            })
46            .collect()
47    }
48}
49
50/// Rule: Check for Form 43 (Oldrim) plugins in SSE/AE.
51pub struct Form43Rule;
52
53impl DiagnosticRule for Form43Rule {
54    fn name(&self) -> &str {
55        "form-43"
56    }
57
58    fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
59        // Only applies to skyrim-se and skyrim-ae
60        if ctx.game_id != "skyrim-se" && ctx.game_id != "skyrim-ae" {
61            return Vec::new();
62        }
63
64        let plugin_dir = ctx.staging_dir;
65
66        let active_plugins: Vec<&str> = collect_active_plugins(ctx);
67        if active_plugins.is_empty() {
68            return Vec::new();
69        }
70
71        let warnings = plugin_header::validate_plugins(plugin_dir, &active_plugins, true);
72
73        warnings
74            .into_iter()
75            .filter_map(|w| match w {
76                PluginWarning::Form43 { plugin, version } => Some(Diagnostic {
77                    severity: Severity::Warning,
78                    title: format!("Form 43 plugin: {plugin}"),
79                    detail: format!(
80                        "Plugin '{plugin}' uses Form 43 (v{version:.2}), the Oldrim format. \
81                         This can cause crashes in Skyrim SE/AE. Resave it in Creation Kit."
82                    ),
83                    affected_mod: Some(plugin),
84                    affected_file: None,
85                    fix: Some(DiagFix {
86                        label: "Resave in Creation Kit".to_string(),
87                        description: "Open the plugin in Creation Kit (SSE) and save it to convert to Form 44.".to_string(),
88                    }),
89                }),
90                _ => None,
91            })
92            .collect()
93    }
94}
95
96/// Rule: Check for mods with no files in the store.
97pub struct EmptyModRule;
98
99impl DiagnosticRule for EmptyModRule {
100    fn name(&self) -> &str {
101        "empty-mod"
102    }
103
104    fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
105        ctx.profile
106            .mods
107            .iter()
108            .filter(|m| m.enabled)
109            .filter_map(|m| {
110                let mod_dir = ctx.store_dir.join(&m.mod_id);
111                let is_empty = if mod_dir.exists() {
112                    match std::fs::read_dir(&mod_dir) {
113                        Ok(mut entries) => entries.next().is_none(),
114                        Err(_) => true,
115                    }
116                } else {
117                    true
118                };
119
120                if is_empty {
121                    Some(Diagnostic {
122                        severity: Severity::Warning,
123                        title: format!("Empty mod: {}", m.mod_id),
124                        detail: format!(
125                            "Mod '{}' is enabled but has no files in the store directory. \
126                             It may not have been downloaded or extracted correctly.",
127                            m.mod_id
128                        ),
129                        affected_mod: Some(m.mod_id.clone()),
130                        affected_file: Some(mod_dir),
131                        fix: Some(DiagFix {
132                            label: "Re-install mod".to_string(),
133                            description: format!(
134                                "Re-download and install '{}', or disable it if it is no longer needed.",
135                                m.mod_id
136                            ),
137                        }),
138                    })
139                } else {
140                    None
141                }
142            })
143            .collect()
144    }
145}
146
147/// Rule: Check if overrides directory has unexpected files.
148pub struct OrphanedOverridesRule;
149
150impl DiagnosticRule for OrphanedOverridesRule {
151    fn name(&self) -> &str {
152        "orphaned-overrides"
153    }
154
155    fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
156        let overrides_dir = &ctx.profile.overrides;
157        if !overrides_dir.exists() {
158            return Vec::new();
159        }
160
161        let has_files = match std::fs::read_dir(overrides_dir) {
162            Ok(mut entries) => entries.next().is_some(),
163            Err(_) => false,
164        };
165
166        if has_files {
167            vec![Diagnostic {
168                severity: Severity::Info,
169                title: "Overrides directory has files".to_string(),
170                detail: format!(
171                    "The overrides directory '{}' contains files. \
172                     These files take highest priority and override all mods. \
173                     Review them to ensure they are intentional.",
174                    overrides_dir.display()
175                ),
176                affected_mod: None,
177                affected_file: Some(overrides_dir.clone()),
178                fix: None,
179            }]
180        } else {
181            Vec::new()
182        }
183    }
184}
185
186/// Create a pre-configured diagnostics engine with all Bethesda rules.
187pub fn bethesda_diagnostics() -> DiagnosticEngine {
188    let mut engine = DiagnosticEngine::new();
189    engine.add_rule(Box::new(MissingMasterRule));
190    engine.add_rule(Box::new(Form43Rule));
191    engine.add_rule(Box::new(EmptyModRule));
192    engine.add_rule(Box::new(OrphanedOverridesRule));
193    engine
194}
195
196/// Collect active plugin filenames from the profile's enabled mods.
197///
198/// Looks for `.esp`, `.esm`, and `.esl` files in each enabled mod's store directory.
199fn collect_active_plugins<'a>(ctx: &'a DiagContext<'a>) -> Vec<&'a str> {
200    // For plugin validation, we need the actual plugin filenames.
201    // The mod_id entries in the profile that end with plugin extensions are the plugins.
202    // In practice, plugin names come from the staging/data dir, but we approximate
203    // by filtering mod IDs that look like plugin filenames.
204    ctx.profile
205        .mods
206        .iter()
207        .filter(|m| m.enabled)
208        .map(|m| m.mod_id.as_str())
209        .filter(|id| {
210            let lower = id.to_lowercase();
211            lower.ends_with(".esp") || lower.ends_with(".esm") || lower.ends_with(".esl")
212        })
213        .collect()
214}