Skip to main content

modde_core/profile/
mod.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4use smallvec::SmallVec;
5
6pub use crate::db::ProfileSummary;
7use crate::db::ModdeDb;
8use crate::error::{CoreError, Result};
9use crate::resolver::{GameId, LoadOrderRule};
10use crate::save::{SaveFingerprint, SaveManager};
11
12/// A mod entry within a profile.
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct EnabledMod {
15    pub mod_id: String,
16    /// Human-readable display name shown in UI (falls back to mod_id if None).
17    #[serde(default)]
18    pub display_name: Option<String>,
19    pub enabled: bool,
20    #[serde(default)]
21    pub version: Option<String>,
22    /// Stored FOMOD declarative config (TOML), if this mod was installed via FOMOD.
23    ///
24    /// Contains a serialized `fomod_oxide::DeclarativeConfig` that can be
25    /// re-applied during deployment to reproduce the same FOMOD selections.
26    #[serde(default)]
27    pub fomod_config: Option<String>,
28
29    // ── Nexus metadata (V2) ──────────────────────────────────────
30    #[serde(default)]
31    pub nexus_mod_id: Option<i64>,
32    #[serde(default)]
33    pub nexus_file_id: Option<i64>,
34    #[serde(default)]
35    pub nexus_game_domain: Option<String>,
36    #[serde(default)]
37    pub installed_timestamp: Option<i64>,
38
39    // ── Organization (V2) ────────────────────────────────────────
40    #[serde(default)]
41    pub category_id: Option<i64>,
42    #[serde(default)]
43    pub notes: Option<String>,
44    /// JSON-encoded array of tag strings.
45    #[serde(default)]
46    pub tags: Option<String>,
47
48    // ── Load order lock (V7) ─────────────────────────────────────
49    /// Per-mod lock: when `Some`, this mod's position cannot be changed via
50    /// `Message::ReorderMod`. Other mods may still move around it. See the
51    /// profile-level `load_order_lock` on `Profile` for the whole-profile
52    /// lock that takes precedence.
53    #[serde(default)]
54    pub lock: Option<LockReason>,
55
56    // ── Installer metadata (V8) ──────────────────────────────────
57    /// The detected install method for this mod, serialized as TOML
58    /// (matching the `lock_reason` convention). `None` for mods that
59    /// predate the installer pipeline or were installed via paths
60    /// that bypass analysis (Wabbajack directives).
61    #[serde(default)]
62    pub install_method: Option<String>,
63
64    /// xxh64 hex digest of the source archive. Lets uninstall and dossier
65    /// dumps correlate a mod row back to the original download.
66    #[serde(default)]
67    pub source_archive_hash: Option<String>,
68
69    /// Where this mod is in the install lifecycle. `None` means legacy
70    /// (pre-V8) — treat as `Installed` for display purposes.
71    #[serde(default)]
72    pub install_status: Option<String>,
73}
74
75/// Source from which a profile was created.
76///
77/// This is now *provenance-only* metadata. Load-order business logic
78/// (preventing reorder of Wabbajack / Collection / TOML-imported profiles)
79/// is driven by [`LoadOrderLock`] on the profile, not by this field.
80#[derive(Debug, Clone, Default, Serialize, Deserialize)]
81pub enum ProfileSource {
82    #[default]
83    Manual,
84    NexusCollection { slug: String, version: String },
85    Wabbajack { manifest_hash: String },
86}
87
88/// Why a profile's load order (or an individual mod) is locked.
89///
90/// Stored both at the profile level (inside [`LoadOrderLock`]) and, for
91/// per-mod pins, directly on [`EnabledMod::lock`].
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub enum LockReason {
94    /// Locked because the profile was installed from a Wabbajack modlist.
95    /// `manifest_hash` identifies which manifest so scans can verify
96    /// provenance.
97    Wabbajack { manifest_hash: String },
98    /// Locked because the profile was installed from a Nexus Collection.
99    NexusCollection { slug: String, version: String },
100    /// Locked because the profile was imported from an authoritative TOML
101    /// file (e.g. shared between machines). `source_path` records where it
102    /// came from at import time.
103    TomlImport { source_path: String },
104    /// Locked explicitly by the user via the UI or CLI.
105    Manual {
106        #[serde(default)]
107        note: Option<String>,
108    },
109}
110
111/// A profile-level load order lock.
112///
113/// When `Profile::load_order_lock` is `Some(_)`, the entire mod order is
114/// frozen: reorder attempts are refused by the UI message handler and
115/// reorder buttons are disabled in the views. The user must explicitly
116/// unlock the profile (or fork it with `--unlock`) to make changes.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct LoadOrderLock {
119    pub reason: LockReason,
120    /// ISO-8601 UTC timestamp captured at lock time (e.g. `"2026-04-10T14:23:00Z"`).
121    pub locked_at: String,
122}
123
124impl LoadOrderLock {
125    /// Construct a new lock with `locked_at` set to the current UTC time.
126    pub fn now(reason: LockReason) -> Self {
127        Self {
128            reason,
129            locked_at: current_utc_timestamp(),
130        }
131    }
132}
133
134/// Return an ISO-8601 UTC timestamp for "now", suitable for
135/// [`LoadOrderLock::locked_at`]. Uses only `std::time` + integer math so we
136/// don't take a chrono dependency for a single field. Produces values like
137/// `"2026-04-10T14:23:07Z"` — stable, sortable, and widely-parseable.
138fn current_utc_timestamp() -> String {
139    use std::time::{SystemTime, UNIX_EPOCH};
140    let secs = SystemTime::now()
141        .duration_since(UNIX_EPOCH)
142        .map(|d| d.as_secs() as i64)
143        .unwrap_or(0);
144
145    // Break into (date, time-of-day).
146    let days = secs.div_euclid(86_400);
147    let sod = secs.rem_euclid(86_400) as u32;
148    let (h, rem) = (sod / 3600, sod % 3600);
149    let (m, s) = (rem / 60, rem % 60);
150
151    // Howard Hinnant's civil_from_days (days since 1970-01-01 → Y-M-D).
152    let z = days + 719_468;
153    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
154    let doe = (z - era * 146_097) as u32;
155    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
156    let y_off = era * 400 + yoe as i64;
157    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
158    let mp = (5 * doy + 2) / 153;
159    let d = doy - (153 * mp + 2) / 5 + 1;
160    let m_civ = if mp < 10 { mp + 3 } else { mp - 9 };
161    let y = if m_civ <= 2 { y_off + 1 } else { y_off };
162
163    format!("{y:04}-{m_civ:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
164}
165
166/// A modding profile containing an ordered list of mods.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct Profile {
169    /// Database row ID (None for profiles not yet persisted).
170    #[serde(skip)]
171    pub id: Option<i64>,
172    pub name: String,
173    pub game_id: GameId,
174    pub source: ProfileSource,
175    pub mods: Vec<EnabledMod>,
176    pub overrides: PathBuf,
177    /// Load order rules — typically 0–10 per profile.
178    /// `SmallVec<[_; 4]>` keeps ≤4 rules inline (no heap allocation).
179    #[serde(default)]
180    pub load_order_rules: SmallVec<[LoadOrderRule; 4]>,
181    /// Profile-level load order lock (V7). When `Some`, the entire mod
182    /// order is frozen and reorder operations are refused until the user
183    /// explicitly unlocks the profile. See [`LoadOrderLock`] for details.
184    #[serde(default)]
185    pub load_order_lock: Option<LoadOrderLock>,
186}
187
188// ── Reorder enforcement ──────────────────────────────────────────
189//
190// Pure logic shared by the UI handler and the CLI/test harnesses for
191// attempting to move a mod one step up or down within a profile. All
192// enforcement rules (profile-level lock, per-mod lock, adjacent pin,
193// list boundary) live here so every caller refuses identically.
194
195/// Direction for [`try_reorder`]. Mirrors the UI message variant.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum ReorderDirection {
198    Up,
199    Down,
200}
201
202/// Why [`try_reorder`] refused to move a mod. Callers render these into
203/// status messages / CLI errors as they see fit — the enum carries enough
204/// structure to explain *why* without string-matching.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum ReorderError {
207    /// The whole profile is locked (Wabbajack/Collection/TomlImport/Manual).
208    ProfileLocked { reason: LockReason },
209    /// The target mod itself carries a per-mod pin.
210    ModPinned { mod_id: String, reason: LockReason },
211    /// The mod_id does not exist in `profile.mods`.
212    ModNotFound { mod_id: String },
213    /// The swap partner (one step up/down) is pinned — moving would
214    /// shift it, violating its per-mod pin contract.
215    AdjacentPinned {
216        neighbor_id: String,
217        reason: LockReason,
218    },
219    /// The mod is already at the top/bottom of the list.
220    AtBoundary,
221}
222
223/// Attempt to move `mod_id` one step `direction` within `profile.mods`.
224///
225/// On success, mutates `profile.mods` in place (swap with the adjacent
226/// entry) and returns `Ok(())`. On refusal, returns a structured
227/// [`ReorderError`] without touching the profile.
228///
229/// Enforcement precedence (short-circuits on the first match):
230/// 1. Profile-level `load_order_lock` → `ProfileLocked`
231/// 2. Mod not found → `ModNotFound`
232/// 3. Target mod has `lock` → `ModPinned`
233/// 4. Target direction goes out of bounds → `AtBoundary`
234/// 5. Adjacent (swap-partner) mod has `lock` → `AdjacentPinned`
235///
236/// The UI handler and `modde profile reorder` CLI path (if/when added)
237/// both call this; see `crates/modde-ui/src/app.rs::Message::ReorderMod`.
238pub fn try_reorder(
239    profile: &mut Profile,
240    mod_id: &str,
241    direction: ReorderDirection,
242) -> std::result::Result<(), ReorderError> {
243    if let Some(lock) = profile.load_order_lock.as_ref() {
244        return Err(ReorderError::ProfileLocked {
245            reason: lock.reason.clone(),
246        });
247    }
248
249    let idx = profile
250        .mods
251        .iter()
252        .position(|m| m.mod_id == mod_id)
253        .ok_or_else(|| ReorderError::ModNotFound {
254            mod_id: mod_id.to_string(),
255        })?;
256
257    if let Some(reason) = profile.mods[idx].lock.as_ref() {
258        return Err(ReorderError::ModPinned {
259            mod_id: mod_id.to_string(),
260            reason: reason.clone(),
261        });
262    }
263
264    let target_idx = match direction {
265        ReorderDirection::Up if idx > 0 => idx - 1,
266        ReorderDirection::Down if idx + 1 < profile.mods.len() => idx + 1,
267        _ => return Err(ReorderError::AtBoundary),
268    };
269
270    if let Some(reason) = profile.mods[target_idx].lock.as_ref() {
271        return Err(ReorderError::AdjacentPinned {
272            neighbor_id: profile.mods[target_idx].mod_id.clone(),
273            reason: reason.clone(),
274        });
275    }
276
277    profile.mods.swap(idx, target_idx);
278    Ok(())
279}
280
281/// Validate that a profile name is safe for use as a filesystem directory
282/// and is not empty or excessively long.
283pub fn validate_profile_name(name: &str) -> Result<()> {
284    if name.is_empty() {
285        return Err(CoreError::Validation("profile name cannot be empty".into()));
286    }
287    if name.len() > 255 {
288        return Err(CoreError::Validation("profile name too long (max 255 characters)".into()));
289    }
290    // Check for filesystem-unsafe characters
291    if name.contains(['/', '\\', '\0', ':', '*', '?', '"', '<', '>', '|']) {
292        return Err(CoreError::Validation(
293            "profile name contains invalid characters (/ \\ NUL : * ? \" < > |)".into(),
294        ));
295    }
296    Ok(())
297}
298
299/// SQLite-backed profile manager.
300pub struct ProfileManager {
301    db: ModdeDb,
302}
303
304impl ProfileManager {
305    /// Open the profile manager using the default database path.
306    pub fn open() -> Result<Self> {
307        let db = ModdeDb::open()?;
308        Ok(Self { db })
309    }
310
311    /// Create a profile manager with a custom database (for testing).
312    pub fn with_db(db: ModdeDb) -> Self {
313        Self { db }
314    }
315
316    /// Access the underlying database.
317    pub fn db(&self) -> &ModdeDb {
318        &self.db
319    }
320
321    /// List profile summaries, optionally filtered by game.
322    pub fn list(&self) -> Result<Vec<ProfileSummary>> {
323        self.db.list_profiles(None)
324    }
325
326    /// List profiles for a specific game.
327    pub fn list_for_game(&self, game_id: &str) -> Result<Vec<ProfileSummary>> {
328        self.db.list_profiles(Some(game_id))
329    }
330
331    /// Load a profile by name. If `game_id` is None, the name must be unambiguous.
332    pub fn load(&self, name: &str, game_id: Option<&str>) -> Result<Profile> {
333        match game_id {
334            Some(gid) => self.db.load_profile(name, gid),
335            None => self.db.load_profile_by_name(name),
336        }
337    }
338
339    /// Create a new profile, returning its database ID.
340    pub fn create(&self, profile: &Profile) -> Result<i64> {
341        validate_profile_name(&profile.name)?;
342        self.db.create_profile(profile)
343    }
344
345    /// Update an existing profile.
346    pub fn update(&self, profile: &Profile) -> Result<()> {
347        self.db.update_profile(profile)
348    }
349
350    /// Create a profile if it doesn't exist, or update it if it does.
351    pub fn create_or_update(&self, profile: &Profile) -> Result<i64> {
352        validate_profile_name(&profile.name)?;
353        match self.db.create_profile(profile) {
354            Ok(id) => Ok(id),
355            Err(CoreError::Database(_)) => {
356                self.db.update_profile(profile)?;
357                // Return the existing ID
358                let loaded = self.db.load_profile(&profile.name, profile.game_id.as_str())?;
359                Ok(loaded.id.unwrap_or(0))
360            }
361            Err(e) => Err(e),
362        }
363    }
364
365    /// Delete a profile. If `game_id` is None, the name must be unambiguous.
366    pub fn delete(&self, name: &str, game_id: Option<&str>) -> Result<()> {
367        match game_id {
368            Some(gid) => self.db.delete_profile(name, gid),
369            None => {
370                // Resolve the game_id first
371                let profile = self.db.load_profile_by_name(name)?;
372                self.db.delete_profile(name, profile.game_id.as_str())
373            }
374        }
375    }
376
377    /// Import existing TOML profile files into the database.
378    pub fn import_toml(&self, profiles_dir: &Path) -> Result<usize> {
379        self.db.import_toml_profiles(profiles_dir)
380    }
381
382    /// Staging directory for a profile (still on-disk).
383    pub fn staging_dir(name: &str) -> PathBuf {
384        crate::paths::profiles_dir().join(name).join("staging")
385    }
386
387    /// Default overrides directory for a profile.
388    pub fn default_overrides(name: &str) -> PathBuf {
389        crate::paths::profiles_dir().join(name).join("overrides")
390    }
391
392    // ── Save-aware profile management ────────────────────────────
393
394    /// Activate a profile, swapping saves automatically.
395    ///
396    /// `save_dir` is the game's save directory (resolved by the caller via
397    /// `GamePlugin::save_directory()`). If `None`, save swapping is skipped.
398    ///
399    /// `fingerprint` is the current profile's mod fingerprint. If provided,
400    /// it is embedded in the save vault commit so future restores can warn
401    /// about mod mismatches.
402    ///
403    /// If existing saves are detected with no active profile, returns
404    /// `ActivateResult::AdoptionRequired` so the caller can prompt the user.
405    pub fn activate(
406        &self,
407        name: &str,
408        game_id: &str,
409        save_dir: Option<&Path>,
410    ) -> Result<ActivateResult> {
411        self.activate_with_fingerprint(name, game_id, save_dir, None)
412    }
413
414    /// Activate with an optional mod fingerprint embedded in the save capture.
415    pub fn activate_with_fingerprint(
416        &self,
417        name: &str,
418        game_id: &str,
419        save_dir: Option<&Path>,
420        fingerprint: Option<&SaveFingerprint>,
421    ) -> Result<ActivateResult> {
422        let profile = self.db.load_profile(name, game_id)?;
423        let profile_id = profile.id.ok_or_else(|| {
424            CoreError::Other("profile has no database ID".into())
425        })?;
426
427        if let Some(dir) = save_dir {
428            let sm = SaveManager::new(&self.db);
429
430            // Check for unadopted saves
431            if let Some(count) = sm.detect_unadopted(game_id, dir)? {
432                return Ok(ActivateResult::AdoptionRequired { save_count: count });
433            }
434
435            // Get current active profile (if any) to capture its saves
436            let current = self.db.get_active_profile(game_id)?;
437            let current_name = current.map(|(_, name)| name);
438
439            sm.activate_with_fingerprint(
440                game_id,
441                name,
442                current_name.as_deref(),
443                dir,
444                fingerprint,
445            )?;
446        }
447
448        self.db.set_active_profile(game_id, profile_id)?;
449
450        Ok(ActivateResult::Activated)
451    }
452
453    /// Try a profile experimentally, pushing the current profile onto the stack.
454    ///
455    /// `save_dir` is the game's save directory. If `None`, save swapping is skipped.
456    /// `fingerprint` is the current profile's mod fingerprint.
457    pub fn try_profile(
458        &self,
459        name: &str,
460        game_id: &str,
461        save_dir: Option<&Path>,
462    ) -> Result<()> {
463        self.try_profile_with_fingerprint(name, game_id, save_dir, None)
464    }
465
466    /// Try a profile experimentally with a mod fingerprint.
467    pub fn try_profile_with_fingerprint(
468        &self,
469        name: &str,
470        game_id: &str,
471        save_dir: Option<&Path>,
472        fingerprint: Option<&SaveFingerprint>,
473    ) -> Result<()> {
474        let (current_id, current_name) = self.db.get_active_profile(game_id)?
475            .ok_or_else(|| CoreError::NoActiveProfile(game_id.to_string()))?;
476
477        let new_profile = self.db.load_profile(name, game_id)?;
478        let new_id = new_profile.id.ok_or_else(|| {
479            CoreError::Other("profile has no database ID".into())
480        })?;
481
482        // Push current profile onto experiment stack (before switching)
483        self.db.push_experiment(game_id, current_id)?;
484
485        if let Some(dir) = save_dir {
486            let sm = SaveManager::new(&self.db);
487            sm.activate_with_fingerprint(
488                game_id,
489                name,
490                Some(&current_name),
491                dir,
492                fingerprint,
493            )?;
494        }
495
496        self.db.set_active_profile(game_id, new_id)?;
497
498        Ok(())
499    }
500
501    /// Roll back to the previous profile on the experiment stack.
502    /// Returns the name of the profile we rolled back to.
503    ///
504    /// `save_dir` is the game's save directory. If `None`, save swapping is skipped.
505    /// `fingerprint` is the current profile's mod fingerprint.
506    pub fn rollback(
507        &self,
508        game_id: &str,
509        save_dir: Option<&Path>,
510    ) -> Result<String> {
511        self.rollback_with_fingerprint(game_id, save_dir, None)
512    }
513
514    /// Roll back with a mod fingerprint.
515    pub fn rollback_with_fingerprint(
516        &self,
517        game_id: &str,
518        save_dir: Option<&Path>,
519        fingerprint: Option<&SaveFingerprint>,
520    ) -> Result<String> {
521        let prev_id = self.db.pop_experiment(game_id)?
522            .ok_or_else(|| CoreError::NotInExperiment(game_id.to_string()))?;
523
524        let (_current_id, current_name) = self.db.get_active_profile(game_id)?
525            .ok_or_else(|| CoreError::NoActiveProfile(game_id.to_string()))?;
526
527        let prev_profile = self.db.load_profile_by_id(prev_id)?;
528
529        if let Some(dir) = save_dir {
530            let sm = SaveManager::new(&self.db);
531            sm.activate_with_fingerprint(
532                game_id,
533                &prev_profile.name,
534                Some(&current_name),
535                dir,
536                fingerprint,
537            )?;
538        }
539
540        self.db.set_active_profile(game_id, prev_id)?;
541
542        Ok(prev_profile.name.clone())
543    }
544
545    /// Accept the current experiment, clearing the experiment stack.
546    pub fn commit(&self, game_id: &str) -> Result<()> {
547        let depth = self.db.experiment_depth(game_id)?;
548        if depth == 0 {
549            return Err(CoreError::NotInExperiment(game_id.to_string()));
550        }
551        self.db.clear_experiment_stack(game_id)?;
552        Ok(())
553    }
554
555    /// Get the currently active profile and experiment depth for a game.
556    pub fn active(&self, game_id: &str) -> Result<Option<ActiveProfileInfo>> {
557        let (profile_id, _name) = match self.db.get_active_profile(game_id)? {
558            Some(pair) => pair,
559            None => return Ok(None),
560        };
561
562        let profile = self.db.load_profile_by_id(profile_id)?;
563        let experiment_depth = self.db.experiment_depth(game_id)?;
564
565        Ok(Some(ActiveProfileInfo {
566            profile,
567            experiment_depth,
568        }))
569    }
570
571    /// Fork a profile: clone its mods, load order rules, and save branch.
572    ///
573    /// By default this is a **faithful copy** — both the profile-level
574    /// `load_order_lock` and every per-mod pin ride along. Use
575    /// [`Self::fork_with_options`] (or `modde profile fork --unlock`) for
576    /// the "fork to diverge" workflow where the new profile starts
577    /// unlocked so it can be freely reorganised.
578    pub fn fork(
579        &self,
580        source_name: &str,
581        new_name: &str,
582        game_id: &str,
583    ) -> Result<i64> {
584        self.fork_with_options(source_name, new_name, game_id, ForkOptions::default())
585    }
586
587    /// Fork a profile with explicit control over whether the new profile
588    /// inherits locks. See [`ForkOptions`] for the flags.
589    pub fn fork_with_options(
590        &self,
591        source_name: &str,
592        new_name: &str,
593        game_id: &str,
594        options: ForkOptions,
595    ) -> Result<i64> {
596        validate_profile_name(new_name)?;
597        let source = self.db.load_profile(source_name, game_id)?;
598
599        // Clone then optionally strip. Done in two steps so the decision
600        // logic is obvious — one place to look when auditing lock flow.
601        let mut mods = source.mods.clone();
602        let mut load_order_lock = source.load_order_lock.clone();
603        if options.unlock {
604            load_order_lock = None;
605            for m in &mut mods {
606                m.lock = None;
607            }
608        }
609
610        let new_profile = Profile {
611            id: None,
612            name: new_name.to_string(),
613            game_id: GameId::from(game_id),
614            source: source.source.clone(),
615            mods,
616            overrides: Self::default_overrides(new_name),
617            load_order_rules: source.load_order_rules.clone(),
618            load_order_lock,
619        };
620
621        let new_id = self.db.create_profile(&new_profile)?;
622
623        // Fork the save branch
624        SaveManager::fork_saves(game_id, source_name, new_name)?;
625
626        Ok(new_id)
627    }
628}
629
630/// Options for [`ProfileManager::fork_with_options`].
631///
632/// Default is a faithful copy (all fields false). Flags opt INTO
633/// divergence from the source.
634#[derive(Debug, Clone, Copy, Default)]
635pub struct ForkOptions {
636    /// If `true`, strip both `Profile::load_order_lock` and every
637    /// `EnabledMod.lock` from the new profile. The source is untouched.
638    /// Use this for the "fork to diverge" workflow where the user wants
639    /// to freely reorder a clone of a Wabbajack/Collection profile.
640    pub unlock: bool,
641}
642
643/// Information about the currently active profile.
644#[derive(Debug)]
645pub struct ActiveProfileInfo {
646    pub profile: Profile,
647    pub experiment_depth: usize,
648}
649
650/// Result of activating a profile.
651#[derive(Debug)]
652pub enum ActivateResult {
653    /// Profile was activated successfully.
654    Activated,
655    /// Existing saves need adoption before activation can proceed.
656    AdoptionRequired { save_count: usize },
657}