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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
11pub enum Severity {
12 Error,
13 Warning,
14 Info,
15}
16
17#[derive(Debug, Clone)]
19pub struct DiagFix {
20 pub label: String,
21 pub description: String,
22}
23
24#[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
35pub 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
46pub 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
54pub 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
95pub 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
120pub 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
173pub 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
216pub 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
262pub trait DiagnosticRule: Send + Sync {
264 fn name(&self) -> &str;
265 fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic>;
266}
267
268pub 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#[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 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}