use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::collision::CollisionReport;
use crate::profile::Profile;
use crate::resolver::{ConflictMap, ModId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone)]
pub struct DiagFix {
pub label: String,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub severity: Severity,
pub title: String,
pub detail: String,
pub affected_mod: Option<String>,
pub affected_file: Option<PathBuf>,
pub fix: Option<DiagFix>,
}
pub struct DiagContext<'a> {
pub game_id: &'a str,
pub profile: &'a Profile,
pub active_plugins: &'a [String],
pub conflict_map: &'a ConflictMap,
pub collision_report: Option<&'a CollisionReport>,
pub store_dir: &'a Path,
pub staging_dir: &'a Path,
}
pub struct ProfileAnalysis {
pub resolved_order: Vec<ModId>,
pub conflict_map: ConflictMap,
pub collision_report: Option<CollisionReport>,
pub missing_store_mods: Vec<ModId>,
}
pub fn analyze_profile_state(
profile: &Profile,
store_dir: &Path,
hidden: &std::collections::HashSet<(String, String)>,
classifier: Option<&dyn crate::collision::CollisionClassifier>,
) -> Result<ProfileAnalysis> {
let resolved_order = crate::resolver::resolve(profile)?.order;
let missing_store_mods = resolved_order
.iter()
.filter(|mod_id| !store_dir.join(mod_id.as_str()).exists())
.cloned()
.collect::<Vec<_>>();
let Some(classifier) = classifier else {
return Ok(ProfileAnalysis {
resolved_order,
conflict_map: ConflictMap::default(),
collision_report: None,
missing_store_mods,
});
};
let full_conflict_map =
crate::collision::build_full_conflict_map(store_dir, &resolved_order, classifier)?;
let collision_report = crate::collision::analyze_collisions(
&full_conflict_map.conflict_map,
&resolved_order,
hidden,
&full_conflict_map.origins,
classifier,
);
Ok(ProfileAnalysis {
resolved_order,
conflict_map: full_conflict_map.conflict_map,
collision_report: Some(collision_report),
missing_store_mods: full_conflict_map.missing_mods,
})
}
pub fn run_profile_diagnostics(
game_id: &str,
profile: &Profile,
active_plugins: &[String],
store_dir: &Path,
staging_dir: &Path,
hidden: &std::collections::HashSet<(String, String)>,
classifier: Option<&dyn crate::collision::CollisionClassifier>,
engine: &DiagnosticEngine,
) -> Result<(Vec<Diagnostic>, ProfileAnalysis)> {
let analysis = analyze_profile_state(profile, store_dir, hidden, classifier)?;
let ctx = DiagContext {
game_id,
profile,
active_plugins,
conflict_map: &analysis.conflict_map,
collision_report: analysis.collision_report.as_ref(),
store_dir,
staging_dir,
};
Ok((engine.run_all(&ctx), analysis))
}
pub struct StorePresenceRule;
impl DiagnosticRule for StorePresenceRule {
fn name(&self) -> &'static str {
"store-presence"
}
fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
ctx.profile
.mods
.iter()
.filter(|m| m.enabled)
.filter_map(|m| {
let mod_dir = ctx.store_dir.join(&m.mod_id);
let is_empty = if mod_dir.exists() {
match std::fs::read_dir(&mod_dir) {
Ok(mut entries) => entries.next().is_none(),
Err(_) => true,
}
} else {
true
};
if is_empty {
Some(Diagnostic {
severity: Severity::Warning,
title: format!("Empty mod: {}", m.mod_id),
detail: format!(
"Mod '{}' is enabled but has no files in the store directory. \
It may not have been downloaded or extracted correctly.",
m.mod_id
),
affected_mod: Some(m.mod_id.clone()),
affected_file: Some(mod_dir),
fix: Some(DiagFix {
label: "Re-install mod".to_string(),
description: format!(
"Re-download and install '{}', or disable it if it is no longer needed.",
m.mod_id
),
}),
})
} else {
None
}
})
.collect()
}
}
pub struct ShadowedModRule;
impl DiagnosticRule for ShadowedModRule {
fn name(&self) -> &'static str {
"shadowed-mod"
}
fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
let Some(report) = ctx.collision_report else {
return Vec::new();
};
report
.shadowed_mods
.iter()
.map(|sm| {
let by: Vec<&str> = sm
.shadowed_by
.iter()
.map(super::resolver::ModId::as_str)
.collect();
Diagnostic {
severity: Severity::Warning,
title: format!("Mod \"{}\" is completely shadowed", sm.mod_id),
detail: format!(
"All {} files are overridden by: {}. Consider disabling this mod.",
sm.file_count,
by.join(", ")
),
affected_mod: Some(sm.mod_id.to_string()),
affected_file: None,
fix: Some(DiagFix {
label: "Disable mod".to_string(),
description: format!("Disable \"{}\" to reduce deployment size", sm.mod_id),
}),
}
})
.collect()
}
}
pub struct DangerousCollisionRule;
impl DiagnosticRule for DangerousCollisionRule {
fn name(&self) -> &'static str {
"dangerous-collision"
}
fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
let Some(report) = ctx.collision_report else {
return Vec::new();
};
report
.pairs
.iter()
.filter(|p| p.max_severity == crate::collision::CollisionSeverity::Dangerous)
.map(|pair| {
let dangerous_files: Vec<&str> = pair
.files
.iter()
.filter(|f| f.severity == crate::collision::CollisionSeverity::Dangerous)
.map(|f| f.file_path.as_str())
.collect();
Diagnostic {
severity: Severity::Warning,
title: format!("Dangerous collision: {} vs {}", pair.loser, pair.winner),
detail: format!(
"{} script/plugin/DLL files conflict: {}",
dangerous_files.len(),
dangerous_files.join(", ")
),
affected_mod: Some(pair.loser.to_string()),
affected_file: None,
fix: Some(DiagFix {
label: "Review load order".to_string(),
description: format!(
"Check that \"{}\" winning over \"{}\" is intentional for these files",
pair.winner, pair.loser
),
}),
}
})
.collect()
}
}
pub trait DiagnosticRule: Send + Sync {
fn name(&self) -> &str;
fn check(&self, ctx: &DiagContext) -> Vec<Diagnostic>;
}
pub struct DiagnosticEngine {
rules: Vec<Box<dyn DiagnosticRule>>,
}
impl DiagnosticEngine {
#[must_use]
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn add_rule(&mut self, rule: Box<dyn DiagnosticRule>) {
self.rules.push(rule);
}
#[must_use]
pub fn run_all(&self, ctx: &DiagContext) -> Vec<Diagnostic> {
let mut results: Vec<Diagnostic> = self.rules.iter().flat_map(|r| r.check(ctx)).collect();
results.sort_by_key(|d| d.severity);
results
}
}
impl Default for DiagnosticEngine {
fn default() -> Self {
Self::new()
}
}
#[must_use]
pub fn base_diagnostics() -> DiagnosticEngine {
let mut engine = DiagnosticEngine::new();
engine.add_rule(Box::new(StorePresenceRule));
engine
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collision::{CollisionClassifier, CollisionSeverity};
use crate::profile::{EnabledMod, Profile, ProfileSource};
use crate::resolver::{ConflictMap, GameId, ModId};
use smallvec::smallvec;
use std::path::PathBuf;
struct MockRule {
name: &'static str,
diagnostics: Vec<Diagnostic>,
}
struct TestClassifier;
impl CollisionClassifier for TestClassifier {
fn index_archive(&self, _archive_path: &Path) -> Result<Vec<(String, u64)>> {
Ok(Vec::new())
}
fn classify_severity(&self, _file_path: &str) -> CollisionSeverity {
CollisionSeverity::Unknown
}
fn archive_extensions(&self) -> &[&str] {
&[]
}
}
impl DiagnosticRule for MockRule {
fn name(&self) -> &str {
self.name
}
fn check(&self, _ctx: &DiagContext) -> Vec<Diagnostic> {
self.diagnostics.clone()
}
}
fn make_context() -> (Profile, ConflictMap, tempfile::TempDir, tempfile::TempDir) {
let profile = Profile {
id: None,
name: "test".to_string(),
game_id: GameId::from("skyrim-se"),
source: ProfileSource::Manual,
mods: vec![],
overrides: PathBuf::from("/tmp/overrides"),
load_order_rules: smallvec![],
load_order_lock: None,
};
let conflict_map = ConflictMap::default();
let store = tempfile::tempdir().unwrap();
let staging = tempfile::tempdir().unwrap();
(profile, conflict_map, store, staging)
}
fn enabled_mod(id: &str) -> EnabledMod {
EnabledMod {
mod_id: id.to_string(),
enabled: true,
version: None,
fomod_config: None,
..Default::default()
}
}
fn disabled_mod(id: &str) -> EnabledMod {
EnabledMod {
enabled: false,
..enabled_mod(id)
}
}
#[test]
fn store_presence_rule_reports_missing_and_empty_enabled_mods_only() {
let store = tempfile::tempdir().unwrap();
let staging = tempfile::tempdir().unwrap();
let overrides = tempfile::tempdir().unwrap();
let with_files = store.path().join("mod-with-files");
std::fs::create_dir_all(with_files.join("textures")).unwrap();
std::fs::write(with_files.join("textures/sky.dds"), b"sky").unwrap();
std::fs::create_dir_all(store.path().join("mod-empty")).unwrap();
let profile = Profile {
id: None,
name: "test".to_string(),
game_id: GameId::from("cyberpunk2077"),
source: ProfileSource::Manual,
mods: vec![
enabled_mod("mod-with-files"),
enabled_mod("mod-empty"),
enabled_mod("mod-missing"),
disabled_mod("mod-disabled-missing"),
],
overrides: overrides.path().to_path_buf(),
load_order_rules: smallvec![],
load_order_lock: None,
};
let conflict_map = ConflictMap::default();
let ctx = DiagContext {
game_id: "cyberpunk2077",
profile: &profile,
active_plugins: &[],
conflict_map: &conflict_map,
collision_report: None,
store_dir: store.path(),
staging_dir: staging.path(),
};
let diagnostics = StorePresenceRule.check(&ctx);
assert_eq!(diagnostics.len(), 2);
assert!(diagnostics.iter().all(|d| d.severity == Severity::Warning));
assert!(
diagnostics
.iter()
.any(|d| d.affected_mod.as_deref() == Some("mod-empty"))
);
assert!(
diagnostics
.iter()
.any(|d| d.affected_mod.as_deref() == Some("mod-missing"))
);
assert!(!diagnostics.iter().any(|d| {
matches!(
d.affected_mod.as_deref(),
Some("mod-with-files" | "mod-disabled-missing")
)
}));
}
#[test]
fn base_diagnostics_includes_store_presence_rule() {
let store = tempfile::tempdir().unwrap();
let staging = tempfile::tempdir().unwrap();
let overrides = tempfile::tempdir().unwrap();
let profile = Profile {
id: None,
name: "test".to_string(),
game_id: GameId::from("cyberpunk2077"),
source: ProfileSource::Manual,
mods: vec![enabled_mod("mod-missing")],
overrides: overrides.path().to_path_buf(),
load_order_rules: smallvec![],
load_order_lock: None,
};
let conflict_map = ConflictMap::default();
let ctx = DiagContext {
game_id: "cyberpunk2077",
profile: &profile,
active_plugins: &[],
conflict_map: &conflict_map,
collision_report: None,
store_dir: store.path(),
staging_dir: staging.path(),
};
let results = base_diagnostics().run_all(&ctx);
assert_eq!(results.len(), 1);
assert_eq!(results[0].affected_mod.as_deref(), Some("mod-missing"));
}
#[test]
fn analyze_profile_state_reports_missing_store_mods() {
let store = tempfile::tempdir().unwrap();
let overrides = tempfile::tempdir().unwrap();
let with_files = store.path().join("mod-with-files");
std::fs::create_dir_all(with_files.join("textures")).unwrap();
std::fs::write(with_files.join("textures/sky.dds"), b"sky").unwrap();
let profile = Profile {
id: None,
name: "test".to_string(),
game_id: GameId::from("cyberpunk2077"),
source: ProfileSource::Manual,
mods: vec![enabled_mod("mod-with-files"), enabled_mod("mod-missing")],
overrides: overrides.path().to_path_buf(),
load_order_rules: smallvec![],
load_order_lock: None,
};
let hidden = std::collections::HashSet::new();
let analysis =
analyze_profile_state(&profile, store.path(), &hidden, Some(&TestClassifier)).unwrap();
assert_eq!(
analysis.missing_store_mods,
vec![ModId::from("mod-missing")]
);
assert!(analysis.conflict_map.files.contains_key("textures/sky.dds"));
}
#[test]
fn test_engine_runs_all_rules() {
let mut engine = DiagnosticEngine::new();
engine.add_rule(Box::new(MockRule {
name: "rule-a",
diagnostics: vec![Diagnostic {
severity: Severity::Warning,
title: "Warning A".to_string(),
detail: "detail".to_string(),
affected_mod: None,
affected_file: None,
fix: None,
}],
}));
engine.add_rule(Box::new(MockRule {
name: "rule-b",
diagnostics: vec![Diagnostic {
severity: Severity::Error,
title: "Error B".to_string(),
detail: "detail".to_string(),
affected_mod: None,
affected_file: None,
fix: None,
}],
}));
let (profile, conflict_map, store, staging) = make_context();
let ctx = DiagContext {
game_id: "skyrim-se",
profile: &profile,
active_plugins: &[],
conflict_map: &conflict_map,
collision_report: None,
store_dir: store.path(),
staging_dir: staging.path(),
};
let results = engine.run_all(&ctx);
assert_eq!(results.len(), 2);
}
#[test]
fn test_diagnostics_sorted_by_severity() {
let mut engine = DiagnosticEngine::new();
engine.add_rule(Box::new(MockRule {
name: "mixed",
diagnostics: vec![
Diagnostic {
severity: Severity::Info,
title: "Info".to_string(),
detail: "detail".to_string(),
affected_mod: None,
affected_file: None,
fix: None,
},
Diagnostic {
severity: Severity::Error,
title: "Error".to_string(),
detail: "detail".to_string(),
affected_mod: None,
affected_file: None,
fix: None,
},
Diagnostic {
severity: Severity::Warning,
title: "Warning".to_string(),
detail: "detail".to_string(),
affected_mod: None,
affected_file: None,
fix: None,
},
],
}));
let (profile, conflict_map, store, staging) = make_context();
let ctx = DiagContext {
game_id: "skyrim-se",
profile: &profile,
active_plugins: &[],
conflict_map: &conflict_map,
collision_report: None,
store_dir: store.path(),
staging_dir: staging.path(),
};
let results = engine.run_all(&ctx);
assert_eq!(results.len(), 3);
assert_eq!(results[0].severity, Severity::Error);
assert_eq!(results[1].severity, Severity::Warning);
assert_eq!(results[2].severity, Severity::Info);
}
#[test]
fn test_empty_engine() {
let engine = DiagnosticEngine::new();
let (profile, conflict_map, store, staging) = make_context();
let ctx = DiagContext {
game_id: "skyrim-se",
profile: &profile,
active_plugins: &[],
conflict_map: &conflict_map,
collision_report: None,
store_dir: store.path(),
staging_dir: staging.path(),
};
let results = engine.run_all(&ctx);
assert!(results.is_empty());
}
}