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 configured database backend.
424    pub async fn open() -> Result<Self> {
425        let db = ModdeDb::open().await?;
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 async fn list(&self) -> Result<Vec<ProfileSummary>> {
441        self.db.list_profiles(None).await
442    }
443
444    /// List profiles for a specific game.
445    pub async fn list_for_game(&self, game_id: &GameId) -> Result<Vec<ProfileSummary>> {
446        self.db.list_profiles(Some(game_id)).await
447    }
448
449    /// Load a profile by name. If `game_id` is None, the name must be unambiguous.
450    pub async fn load(&self, name: &str, game_id: Option<&GameId>) -> Result<Profile> {
451        match game_id {
452            Some(gid) => self.db.load_profile(name, gid).await,
453            None => self.db.load_profile_by_name(name).await,
454        }
455    }
456
457    /// Create a new profile, returning its database ID.
458    pub async fn create(&self, profile: &Profile) -> Result<i64> {
459        validate_profile_name(&profile.name)?;
460        self.db.create_profile(profile).await
461    }
462
463    /// Update an existing profile.
464    pub async fn update(&self, profile: &Profile) -> Result<()> {
465        self.db.update_profile(profile).await
466    }
467
468    /// Create a profile if it doesn't exist, or update it if it does.
469    pub async fn create_or_update(&self, profile: &Profile) -> Result<i64> {
470        validate_profile_name(&profile.name)?;
471        match self.db.create_profile(profile).await {
472            Ok(id) => Ok(id),
473            Err(CoreError::Database(_)) => {
474                self.db.update_profile(profile).await?;
475                // Return the existing ID
476                let loaded = self
477                    .db
478                    .load_profile(&profile.name, &profile.game_id)
479                    .await?;
480                Ok(loaded.id.unwrap_or(0))
481            }
482            Err(e) => Err(e),
483        }
484    }
485
486    /// Delete a profile. If `game_id` is None, the name must be unambiguous.
487    pub async fn delete(&self, name: &str, game_id: Option<&GameId>) -> Result<()> {
488        if let Some(gid) = game_id {
489            self.db.delete_profile(name, gid).await
490        } else {
491            // Resolve the game_id first
492            let profile = self.db.load_profile_by_name(name).await?;
493            self.db.delete_profile(name, &profile.game_id).await
494        }
495    }
496
497    /// Import existing TOML profile files into the database.
498    pub async fn import_toml(&self, profiles_dir: &Path) -> Result<usize> {
499        self.db.import_toml_profiles(profiles_dir).await
500    }
501
502    /// Staging directory for a profile (still on-disk).
503    #[must_use]
504    pub fn staging_dir(name: &str) -> PathBuf {
505        crate::paths::profiles_dir().join(name).join("staging")
506    }
507
508    /// Default overrides directory for a profile.
509    #[must_use]
510    pub fn default_overrides(name: &str) -> PathBuf {
511        crate::paths::profiles_dir().join(name).join("overrides")
512    }
513
514    // ── Save-aware profile management ────────────────────────────
515
516    /// Activate a profile, swapping saves automatically.
517    ///
518    /// `save_dir` is the game's save directory (resolved by the caller via
519    /// `GamePlugin::save_directory()`). If `None`, save swapping is skipped.
520    ///
521    /// `fingerprint` is the current profile's mod fingerprint. If provided,
522    /// it is embedded in the save vault commit so future restores can warn
523    /// about mod mismatches.
524    ///
525    /// If existing saves are detected with no active profile, returns
526    /// `ActivateResult::AdoptionRequired` so the caller can prompt the user.
527    pub async fn activate(
528        &self,
529        name: &str,
530        game_id: &GameId,
531        save_dir: Option<&Path>,
532    ) -> Result<ActivateResult> {
533        self.activate_with_fingerprint(name, game_id, save_dir, None)
534            .await
535    }
536
537    /// Activate with an optional mod fingerprint embedded in the save capture.
538    pub async fn activate_with_fingerprint(
539        &self,
540        name: &str,
541        game_id: &GameId,
542        save_dir: Option<&Path>,
543        fingerprint: Option<&SaveFingerprint>,
544    ) -> Result<ActivateResult> {
545        let profile = self.db.load_profile(name, game_id).await?;
546        let profile_id = profile
547            .id
548            .ok_or_else(|| CoreError::Other("profile has no database ID".into()))?;
549
550        if let Some(dir) = save_dir {
551            let sm = SaveManager::new(&self.db);
552
553            // Check for unadopted saves
554            if let Some(count) = sm.detect_unadopted(game_id, dir).await? {
555                return Ok(ActivateResult::AdoptionRequired { save_count: count });
556            }
557
558            // Get current active profile (if any) to capture its saves
559            let current = self.db.get_active_profile(game_id).await?;
560            let current_name = current.map(|(_, name)| name);
561
562            sm.activate_with_fingerprint(game_id, name, current_name.as_deref(), dir, fingerprint)?;
563        }
564
565        self.db.set_active_profile(game_id, profile_id).await?;
566
567        Ok(ActivateResult::Activated)
568    }
569
570    /// Try a profile experimentally, pushing the current profile onto the stack.
571    ///
572    /// `save_dir` is the game's save directory. If `None`, save swapping is skipped.
573    /// `fingerprint` is the current profile's mod fingerprint.
574    pub async fn try_profile(
575        &self,
576        name: &str,
577        game_id: &GameId,
578        save_dir: Option<&Path>,
579    ) -> Result<()> {
580        self.try_profile_with_fingerprint(name, game_id, save_dir, None)
581            .await
582    }
583
584    /// Try a profile experimentally with a mod fingerprint.
585    pub async fn try_profile_with_fingerprint(
586        &self,
587        name: &str,
588        game_id: &GameId,
589        save_dir: Option<&Path>,
590        fingerprint: Option<&SaveFingerprint>,
591    ) -> Result<()> {
592        let (current_id, current_name) = self
593            .db
594            .get_active_profile(game_id)
595            .await?
596            .ok_or_else(|| CoreError::NoActiveProfile(game_id.to_string()))?;
597
598        let new_profile = self.db.load_profile(name, game_id).await?;
599        let new_id = new_profile
600            .id
601            .ok_or_else(|| CoreError::Other("profile has no database ID".into()))?;
602
603        // Push current profile onto experiment stack (before switching)
604        self.db.push_experiment(game_id, current_id).await?;
605
606        if let Some(dir) = save_dir {
607            let sm = SaveManager::new(&self.db);
608            sm.activate_with_fingerprint(game_id, name, Some(&current_name), dir, fingerprint)?;
609        }
610
611        self.db.set_active_profile(game_id, new_id).await?;
612
613        Ok(())
614    }
615
616    /// Roll back to the previous profile on the experiment stack.
617    /// Returns the name of the profile we rolled back to.
618    ///
619    /// `save_dir` is the game's save directory. If `None`, save swapping is skipped.
620    /// `fingerprint` is the current profile's mod fingerprint.
621    pub async fn rollback(&self, game_id: &GameId, save_dir: Option<&Path>) -> Result<String> {
622        self.rollback_with_fingerprint(game_id, save_dir, None)
623            .await
624    }
625
626    /// Roll back with a mod fingerprint.
627    pub async fn rollback_with_fingerprint(
628        &self,
629        game_id: &GameId,
630        save_dir: Option<&Path>,
631        fingerprint: Option<&SaveFingerprint>,
632    ) -> Result<String> {
633        let prev_id = self
634            .db
635            .pop_experiment(game_id)
636            .await?
637            .ok_or_else(|| CoreError::NotInExperiment(game_id.to_string()))?;
638
639        let (_current_id, current_name) = self
640            .db
641            .get_active_profile(game_id)
642            .await?
643            .ok_or_else(|| CoreError::NoActiveProfile(game_id.to_string()))?;
644
645        let prev_profile = self.db.load_profile_by_id(prev_id).await?;
646
647        if let Some(dir) = save_dir {
648            let sm = SaveManager::new(&self.db);
649            sm.activate_with_fingerprint(
650                game_id,
651                &prev_profile.name,
652                Some(&current_name),
653                dir,
654                fingerprint,
655            )?;
656        }
657
658        self.db.set_active_profile(game_id, prev_id).await?;
659
660        Ok(prev_profile.name.clone())
661    }
662
663    /// Accept the current experiment, clearing the experiment stack.
664    pub async fn commit(&self, game_id: &GameId) -> Result<()> {
665        let depth = self.db.experiment_depth(game_id).await?;
666        if depth == 0 {
667            return Err(CoreError::NotInExperiment(game_id.to_string()));
668        }
669        self.db.clear_experiment_stack(game_id).await?;
670        Ok(())
671    }
672
673    /// Get the currently active profile and experiment depth for a game.
674    pub async fn active(&self, game_id: &GameId) -> Result<Option<ActiveProfileInfo>> {
675        let (profile_id, _name) = match self.db.get_active_profile(game_id).await? {
676            Some(pair) => pair,
677            None => return Ok(None),
678        };
679
680        let profile = self.db.load_profile_by_id(profile_id).await?;
681        let experiment_depth = self.db.experiment_depth(game_id).await?;
682
683        Ok(Some(ActiveProfileInfo {
684            profile,
685            experiment_depth,
686        }))
687    }
688
689    /// Fork a profile: clone its mods, load order rules, and save branch.
690    ///
691    /// By default this is a **faithful copy** — both the profile-level
692    /// `load_order_lock` and every per-mod pin ride along. Use
693    /// [`Self::fork_with_options`] (or `modde profile fork --unlock`) for
694    /// the "fork to diverge" workflow where the new profile starts
695    /// unlocked so it can be freely reorganised.
696    pub async fn fork(&self, source_name: &str, new_name: &str, game_id: &GameId) -> Result<i64> {
697        self.fork_with_options(source_name, new_name, game_id, ForkOptions::default())
698            .await
699    }
700
701    /// Fork a profile with explicit control over whether the new profile
702    /// inherits locks. See [`ForkOptions`] for the flags.
703    pub async fn fork_with_options(
704        &self,
705        source_name: &str,
706        new_name: &str,
707        game_id: &GameId,
708        options: ForkOptions,
709    ) -> Result<i64> {
710        validate_profile_name(new_name)?;
711        let source = self.db.load_profile(source_name, game_id).await?;
712
713        // Clone then optionally strip. Done in two steps so the decision
714        // logic is obvious — one place to look when auditing lock flow.
715        let mut mods = source.mods.clone();
716        let mut load_order_lock = source.load_order_lock.clone();
717        if options.unlock {
718            load_order_lock = None;
719            for m in &mut mods {
720                m.lock = None;
721            }
722        }
723
724        let new_profile = Profile {
725            id: None,
726            name: new_name.to_string(),
727            game_id: game_id.clone(),
728            source: source.source.clone(),
729            mods,
730            overrides: Self::default_overrides(new_name),
731            load_order_rules: source.load_order_rules.clone(),
732            load_order_lock,
733        };
734
735        let new_id = self.db.create_profile(&new_profile).await?;
736
737        // Fork the save branch
738        SaveManager::fork_saves(game_id, source_name, new_name)?;
739
740        Ok(new_id)
741    }
742}
743
744/// Options for [`ProfileManager::fork_with_options`].
745///
746/// Default is a faithful copy (all fields false). Flags opt INTO
747/// divergence from the source.
748#[derive(Debug, Clone, Copy, Default)]
749pub struct ForkOptions {
750    /// If `true`, strip both `Profile::load_order_lock` and every
751    /// `EnabledMod.lock` from the new profile. The source is untouched.
752    /// Use this for the "fork to diverge" workflow where the user wants
753    /// to freely reorder a clone of a Wabbajack/Collection profile.
754    pub unlock: bool,
755}
756
757/// Information about the currently active profile.
758#[derive(Debug)]
759pub struct ActiveProfileInfo {
760    pub profile: Profile,
761    pub experiment_depth: usize,
762}
763
764/// Result of activating a profile.
765#[derive(Debug)]
766pub enum ActivateResult {
767    /// Profile was activated successfully.
768    Activated,
769    /// Existing saves need adoption before activation can proceed.
770    AdoptionRequired { save_count: usize },
771}