Skip to main content

modde_core/profile/
mod.rs

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