use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
pub use crate::db::ProfileSummary;
use crate::db::ModdeDb;
use crate::error::{CoreError, Result};
use crate::resolver::{GameId, LoadOrderRule};
use crate::save::{SaveFingerprint, SaveManager};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EnabledMod {
pub mod_id: String,
#[serde(default)]
pub display_name: Option<String>,
pub enabled: bool,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub fomod_config: Option<String>,
#[serde(default)]
pub nexus_mod_id: Option<i64>,
#[serde(default)]
pub nexus_file_id: Option<i64>,
#[serde(default)]
pub nexus_game_domain: Option<String>,
#[serde(default)]
pub installed_timestamp: Option<i64>,
#[serde(default)]
pub category_id: Option<i64>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub tags: Option<String>,
#[serde(default)]
pub lock: Option<LockReason>,
#[serde(default)]
pub install_method: Option<String>,
#[serde(default)]
pub source_archive_hash: Option<String>,
#[serde(default)]
pub install_status: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub enum ProfileSource {
#[default]
Manual,
NexusCollection { slug: String, version: String },
Wabbajack { manifest_hash: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum LockReason {
Wabbajack { manifest_hash: String },
NexusCollection { slug: String, version: String },
TomlImport { source_path: String },
Manual {
#[serde(default)]
note: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LoadOrderLock {
pub reason: LockReason,
pub locked_at: String,
}
impl LoadOrderLock {
pub fn now(reason: LockReason) -> Self {
Self {
reason,
locked_at: current_utc_timestamp(),
}
}
}
fn current_utc_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let days = secs.div_euclid(86_400);
let sod = secs.rem_euclid(86_400) as u32;
let (h, rem) = (sod / 3600, sod % 3600);
let (m, s) = (rem / 60, rem % 60);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y_off = era * 400 + yoe as i64;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m_civ = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m_civ <= 2 { y_off + 1 } else { y_off };
format!("{y:04}-{m_civ:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
#[serde(skip)]
pub id: Option<i64>,
pub name: String,
pub game_id: GameId,
pub source: ProfileSource,
pub mods: Vec<EnabledMod>,
pub overrides: PathBuf,
#[serde(default)]
pub load_order_rules: SmallVec<[LoadOrderRule; 4]>,
#[serde(default)]
pub load_order_lock: Option<LoadOrderLock>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReorderDirection {
Up,
Down,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReorderError {
ProfileLocked { reason: LockReason },
ModPinned { mod_id: String, reason: LockReason },
ModNotFound { mod_id: String },
AdjacentPinned {
neighbor_id: String,
reason: LockReason,
},
AtBoundary,
}
pub fn try_reorder(
profile: &mut Profile,
mod_id: &str,
direction: ReorderDirection,
) -> std::result::Result<(), ReorderError> {
if let Some(lock) = profile.load_order_lock.as_ref() {
return Err(ReorderError::ProfileLocked {
reason: lock.reason.clone(),
});
}
let idx = profile
.mods
.iter()
.position(|m| m.mod_id == mod_id)
.ok_or_else(|| ReorderError::ModNotFound {
mod_id: mod_id.to_string(),
})?;
if let Some(reason) = profile.mods[idx].lock.as_ref() {
return Err(ReorderError::ModPinned {
mod_id: mod_id.to_string(),
reason: reason.clone(),
});
}
let target_idx = match direction {
ReorderDirection::Up if idx > 0 => idx - 1,
ReorderDirection::Down if idx + 1 < profile.mods.len() => idx + 1,
_ => return Err(ReorderError::AtBoundary),
};
if let Some(reason) = profile.mods[target_idx].lock.as_ref() {
return Err(ReorderError::AdjacentPinned {
neighbor_id: profile.mods[target_idx].mod_id.clone(),
reason: reason.clone(),
});
}
profile.mods.swap(idx, target_idx);
Ok(())
}
pub fn validate_profile_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(CoreError::Validation("profile name cannot be empty".into()));
}
if name.len() > 255 {
return Err(CoreError::Validation("profile name too long (max 255 characters)".into()));
}
if name.contains(['/', '\\', '\0', ':', '*', '?', '"', '<', '>', '|']) {
return Err(CoreError::Validation(
"profile name contains invalid characters (/ \\ NUL : * ? \" < > |)".into(),
));
}
Ok(())
}
pub struct ProfileManager {
db: ModdeDb,
}
impl ProfileManager {
pub fn open() -> Result<Self> {
let db = ModdeDb::open()?;
Ok(Self { db })
}
pub fn with_db(db: ModdeDb) -> Self {
Self { db }
}
pub fn db(&self) -> &ModdeDb {
&self.db
}
pub fn list(&self) -> Result<Vec<ProfileSummary>> {
self.db.list_profiles(None)
}
pub fn list_for_game(&self, game_id: &str) -> Result<Vec<ProfileSummary>> {
self.db.list_profiles(Some(game_id))
}
pub fn load(&self, name: &str, game_id: Option<&str>) -> Result<Profile> {
match game_id {
Some(gid) => self.db.load_profile(name, gid),
None => self.db.load_profile_by_name(name),
}
}
pub fn create(&self, profile: &Profile) -> Result<i64> {
validate_profile_name(&profile.name)?;
self.db.create_profile(profile)
}
pub fn update(&self, profile: &Profile) -> Result<()> {
self.db.update_profile(profile)
}
pub fn create_or_update(&self, profile: &Profile) -> Result<i64> {
validate_profile_name(&profile.name)?;
match self.db.create_profile(profile) {
Ok(id) => Ok(id),
Err(CoreError::Database(_)) => {
self.db.update_profile(profile)?;
let loaded = self.db.load_profile(&profile.name, profile.game_id.as_str())?;
Ok(loaded.id.unwrap_or(0))
}
Err(e) => Err(e),
}
}
pub fn delete(&self, name: &str, game_id: Option<&str>) -> Result<()> {
match game_id {
Some(gid) => self.db.delete_profile(name, gid),
None => {
let profile = self.db.load_profile_by_name(name)?;
self.db.delete_profile(name, profile.game_id.as_str())
}
}
}
pub fn import_toml(&self, profiles_dir: &Path) -> Result<usize> {
self.db.import_toml_profiles(profiles_dir)
}
pub fn staging_dir(name: &str) -> PathBuf {
crate::paths::profiles_dir().join(name).join("staging")
}
pub fn default_overrides(name: &str) -> PathBuf {
crate::paths::profiles_dir().join(name).join("overrides")
}
pub fn activate(
&self,
name: &str,
game_id: &str,
save_dir: Option<&Path>,
) -> Result<ActivateResult> {
self.activate_with_fingerprint(name, game_id, save_dir, None)
}
pub fn activate_with_fingerprint(
&self,
name: &str,
game_id: &str,
save_dir: Option<&Path>,
fingerprint: Option<&SaveFingerprint>,
) -> Result<ActivateResult> {
let profile = self.db.load_profile(name, game_id)?;
let profile_id = profile.id.ok_or_else(|| {
CoreError::Other("profile has no database ID".into())
})?;
if let Some(dir) = save_dir {
let sm = SaveManager::new(&self.db);
if let Some(count) = sm.detect_unadopted(game_id, dir)? {
return Ok(ActivateResult::AdoptionRequired { save_count: count });
}
let current = self.db.get_active_profile(game_id)?;
let current_name = current.map(|(_, name)| name);
sm.activate_with_fingerprint(
game_id,
name,
current_name.as_deref(),
dir,
fingerprint,
)?;
}
self.db.set_active_profile(game_id, profile_id)?;
Ok(ActivateResult::Activated)
}
pub fn try_profile(
&self,
name: &str,
game_id: &str,
save_dir: Option<&Path>,
) -> Result<()> {
self.try_profile_with_fingerprint(name, game_id, save_dir, None)
}
pub fn try_profile_with_fingerprint(
&self,
name: &str,
game_id: &str,
save_dir: Option<&Path>,
fingerprint: Option<&SaveFingerprint>,
) -> Result<()> {
let (current_id, current_name) = self.db.get_active_profile(game_id)?
.ok_or_else(|| CoreError::NoActiveProfile(game_id.to_string()))?;
let new_profile = self.db.load_profile(name, game_id)?;
let new_id = new_profile.id.ok_or_else(|| {
CoreError::Other("profile has no database ID".into())
})?;
self.db.push_experiment(game_id, current_id)?;
if let Some(dir) = save_dir {
let sm = SaveManager::new(&self.db);
sm.activate_with_fingerprint(
game_id,
name,
Some(¤t_name),
dir,
fingerprint,
)?;
}
self.db.set_active_profile(game_id, new_id)?;
Ok(())
}
pub fn rollback(
&self,
game_id: &str,
save_dir: Option<&Path>,
) -> Result<String> {
self.rollback_with_fingerprint(game_id, save_dir, None)
}
pub fn rollback_with_fingerprint(
&self,
game_id: &str,
save_dir: Option<&Path>,
fingerprint: Option<&SaveFingerprint>,
) -> Result<String> {
let prev_id = self.db.pop_experiment(game_id)?
.ok_or_else(|| CoreError::NotInExperiment(game_id.to_string()))?;
let (_current_id, current_name) = self.db.get_active_profile(game_id)?
.ok_or_else(|| CoreError::NoActiveProfile(game_id.to_string()))?;
let prev_profile = self.db.load_profile_by_id(prev_id)?;
if let Some(dir) = save_dir {
let sm = SaveManager::new(&self.db);
sm.activate_with_fingerprint(
game_id,
&prev_profile.name,
Some(¤t_name),
dir,
fingerprint,
)?;
}
self.db.set_active_profile(game_id, prev_id)?;
Ok(prev_profile.name.clone())
}
pub fn commit(&self, game_id: &str) -> Result<()> {
let depth = self.db.experiment_depth(game_id)?;
if depth == 0 {
return Err(CoreError::NotInExperiment(game_id.to_string()));
}
self.db.clear_experiment_stack(game_id)?;
Ok(())
}
pub fn active(&self, game_id: &str) -> Result<Option<ActiveProfileInfo>> {
let (profile_id, _name) = match self.db.get_active_profile(game_id)? {
Some(pair) => pair,
None => return Ok(None),
};
let profile = self.db.load_profile_by_id(profile_id)?;
let experiment_depth = self.db.experiment_depth(game_id)?;
Ok(Some(ActiveProfileInfo {
profile,
experiment_depth,
}))
}
pub fn fork(
&self,
source_name: &str,
new_name: &str,
game_id: &str,
) -> Result<i64> {
self.fork_with_options(source_name, new_name, game_id, ForkOptions::default())
}
pub fn fork_with_options(
&self,
source_name: &str,
new_name: &str,
game_id: &str,
options: ForkOptions,
) -> Result<i64> {
validate_profile_name(new_name)?;
let source = self.db.load_profile(source_name, game_id)?;
let mut mods = source.mods.clone();
let mut load_order_lock = source.load_order_lock.clone();
if options.unlock {
load_order_lock = None;
for m in &mut mods {
m.lock = None;
}
}
let new_profile = Profile {
id: None,
name: new_name.to_string(),
game_id: GameId::from(game_id),
source: source.source.clone(),
mods,
overrides: Self::default_overrides(new_name),
load_order_rules: source.load_order_rules.clone(),
load_order_lock,
};
let new_id = self.db.create_profile(&new_profile)?;
SaveManager::fork_saves(game_id, source_name, new_name)?;
Ok(new_id)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ForkOptions {
pub unlock: bool,
}
#[derive(Debug)]
pub struct ActiveProfileInfo {
pub profile: Profile,
pub experiment_depth: usize,
}
#[derive(Debug)]
pub enum ActivateResult {
Activated,
AdoptionRequired { save_count: usize },
}