use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::Result;
use smallvec::SmallVec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ContentCategory {
Plugin, Texture, Mesh, Sound, Script, Interface, Archive, Config, Binary, Other,
}
impl ContentCategory {
#[must_use]
pub fn label(self) -> &'static str {
match self {
ContentCategory::Plugin => "plugins",
ContentCategory::Texture => "textures",
ContentCategory::Mesh => "meshes",
ContentCategory::Sound => "sounds",
ContentCategory::Script => "scripts",
ContentCategory::Interface => "interfaces",
ContentCategory::Archive => "archives",
ContentCategory::Config => "configs",
ContentCategory::Binary => "binaries",
ContentCategory::Other => "other",
}
}
#[must_use]
pub fn order(self) -> u8 {
match self {
ContentCategory::Plugin => 0,
ContentCategory::Script => 1,
ContentCategory::Binary => 2,
ContentCategory::Texture => 3,
ContentCategory::Mesh => 4,
ContentCategory::Sound => 5,
ContentCategory::Interface => 6,
ContentCategory::Archive => 7,
ContentCategory::Config => 8,
ContentCategory::Other => 9,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ContentSummary {
pub counts: HashMap<ContentCategory, usize>,
}
impl ContentSummary {
#[must_use]
pub fn sorted_counts(&self) -> Vec<(ContentCategory, usize)> {
let mut entries: Vec<_> = self
.counts
.iter()
.filter(|(_, count)| **count > 0)
.map(|(cat, count)| (*cat, *count))
.collect();
entries.sort_by_key(|(cat, _)| cat.order());
entries
}
#[must_use]
pub fn display_string(&self) -> String {
let parts: Vec<String> = self
.sorted_counts()
.iter()
.map(|(cat, count)| format!("{} {}", count, cat.label()))
.collect();
if parts.is_empty() {
"No files".to_string()
} else {
parts.join(", ")
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ModSafety {
SaveBreaking,
SaveSafe,
Unknown,
}
impl ModSafety {
#[must_use]
pub fn affects_saves(self) -> bool {
matches!(self, ModSafety::SaveBreaking | ModSafety::Unknown)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeployTargetKind {
UserConfig,
UserSaves,
Custom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeployTarget {
pub id: &'static str,
pub label: &'static str,
pub kind: DeployTargetKind,
}
pub trait GamePlugin: Send + Sync {
fn game_id(&self) -> &str;
fn display_name(&self) -> &str;
fn detect_install(&self) -> Option<PathBuf> {
crate::detection::find_game_install(&modde_core::GameId::from(self.game_id()))
}
fn mod_directory(&self, install: &Path) -> PathBuf;
fn mod_root(&self, install: &Path) -> Result<PathBuf> {
Ok(self.mod_directory(install))
}
fn deploy(&self, staging: &Path, target: &Path) -> Result<()> {
modde_core::fs::deploy_symlinks(staging, target)
}
fn deploy_to_install(&self, staging: &Path, install: &Path) -> Result<()> {
let target = self.mod_root(install)?;
self.deploy(staging, &target)
}
fn post_deploy(&self, _install: &Path) -> Result<()> {
Ok(())
}
fn save_directory(&self) -> Option<PathBuf> {
None
}
fn deploy_targets(&self) -> &'static [DeployTarget] {
&[]
}
fn resolve_deploy_target(&self, _id: &str, _install: &Path) -> Option<PathBuf> {
None
}
fn supports_save_profiles(&self) -> bool {
false
}
fn classify_mod(&self, _mod_dir: &Path) -> ModSafety {
ModSafety::Unknown
}
fn wine_dll_overrides(&self, _game_dir: &Path) -> SmallVec<[String; 4]> {
SmallVec::new()
}
fn wine_dll_overrides_from_staging(&self, _staging: &Path) -> SmallVec<[String; 4]> {
SmallVec::new()
}
fn executable_dir(&self, install: &Path) -> PathBuf {
install.to_path_buf()
}
fn ini_file_names(&self) -> &[&str] {
&[]
}
fn archive_extensions(&self) -> &[&str] {
&[]
}
fn has_plugin_system(&self) -> bool {
false
}
fn steam_app_id_u32(&self) -> Option<u32> {
None
}
fn plugins_txt_folder(&self) -> Option<&str> {
None
}
fn nexus_game_domain(&self) -> Option<&str> {
None
}
fn nexus_game_id_u32(&self) -> Option<u32> {
None
}
fn analyze_mod_archive(
&self,
_extracted_dir: &Path,
) -> Option<modde_core::installer::InstallMethod> {
None
}
fn recognizes_bare_layout(&self, _extracted_dir: &Path) -> bool {
false
}
fn classify_extension(&self, ext: &str) -> ContentCategory {
match ext {
"esp" | "esm" | "esl" => ContentCategory::Plugin,
"dds" | "png" | "tga" | "jpg" => ContentCategory::Texture,
"nif" => ContentCategory::Mesh,
"wav" | "xwm" | "fuz" | "mp3" | "ogg" => ContentCategory::Sound,
"pex" | "psc" | "reds" | "lua" => ContentCategory::Script,
"swf" => ContentCategory::Interface,
"bsa" | "ba2" | "archive" => ContentCategory::Archive,
"ini" | "json" | "yaml" | "xml" | "toml" => ContentCategory::Config,
"dll" | "so" => ContentCategory::Binary,
_ => ContentCategory::Other,
}
}
fn summarize_content(&self, mod_dir: &Path) -> ContentSummary {
let mut summary = ContentSummary::default();
let mut stack = vec![mod_dir.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let cat = self.classify_extension(&ext.to_lowercase());
*summary.counts.entry(cat).or_insert(0) += 1;
}
}
}
summary
}
}
#[derive(Debug, Clone)]
pub struct DetectedSave {
pub rel_path: PathBuf,
pub category: Cow<'static, str>,
pub label: Option<String>,
pub modified: SystemTime,
}
pub struct ModClassifyConfig {
pub save_breaking_ext: &'static [&'static str],
pub cosmetic_ext: &'static [&'static str],
pub save_breaking_dirs: &'static [&'static str],
}
#[must_use]
pub fn classify_mod_by_content(mod_dir: &std::path::Path, config: &ModClassifyConfig) -> ModSafety {
if !mod_dir.exists() {
return ModSafety::Unknown;
}
let mut has_any_file = false;
let mut has_cosmetic = false;
let mut stack = vec![mod_dir.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if !config.save_breaking_dirs.is_empty() {
let rel = path.strip_prefix(mod_dir).unwrap_or(&path);
let rel_normalized = rel.to_string_lossy().to_lowercase().replace('\\', "/");
for &pattern in config.save_breaking_dirs {
if rel_normalized.contains(pattern) {
return ModSafety::SaveBreaking;
}
}
}
stack.push(path);
continue;
}
has_any_file = true;
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let ext_lower = ext.to_lowercase();
if config.save_breaking_ext.contains(&ext_lower.as_str()) {
return ModSafety::SaveBreaking;
}
if config.cosmetic_ext.contains(&ext_lower.as_str()) {
has_cosmetic = true;
}
}
}
}
if has_cosmetic && has_any_file {
ModSafety::SaveSafe
} else {
ModSafety::Unknown
}
}
pub trait SaveTracker: Send + Sync {
fn save_patterns(&self) -> SmallVec<[String; 2]>;
fn detect_saves(&self, save_dir: &Path) -> Result<Vec<DetectedSave>>;
fn exclude_patterns(&self) -> SmallVec<[String; 2]> {
SmallVec::new()
}
fn describe_capture(&self, saves: &[DetectedSave]) -> String {
match saves.len() {
0 => "capture: no new saves".into(),
1 => {
let s = &saves[0];
let name = s
.label
.as_deref()
.unwrap_or_else(|| s.rel_path.to_str().unwrap_or("unknown"));
format!("capture: {} [{}]", name, s.category)
}
n => format!("capture: {n} saves"),
}
}
}
pub struct ScanContext<'a> {
pub install_dir: &'a Path,
}
#[derive(Debug, Clone)]
pub struct DiscoveredFile {
pub rel_path: String,
pub size: u64,
}
#[derive(Debug, Clone)]
pub enum ModSource {
Filesystem { location: String },
Archive { archive_name: String },
}
#[derive(Debug, Clone)]
pub struct DiscoveredMod {
pub mod_id: String,
pub display_name: String,
pub version: Option<String>,
pub files: Vec<DiscoveredFile>,
pub source: ModSource,
pub confidence: f64,
}
pub trait ModScanner: Send + Sync {
fn scan_directories(&self) -> &[&str];
fn scan_filesystem(&self, ctx: &ScanContext<'_>) -> anyhow::Result<Vec<DiscoveredMod>>;
fn mod_id_footprint(&self, _mod_id: &str) -> Option<modde_core::scanner::ModFootprint> {
None
}
}
#[must_use]
pub fn walk_files_relative(base: &Path, dir: &Path) -> Vec<DiscoveredFile> {
let mut result = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
result.extend(walk_files_relative(base, &path));
} else if let Ok(meta) = path.metadata()
&& let Ok(rel) = path.strip_prefix(base)
{
result.push(DiscoveredFile {
rel_path: rel.to_string_lossy().to_string(),
size: meta.len(),
});
}
}
}
result
}
#[must_use]
pub fn slug(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string()
}