Skip to main content

modde_core/
db.rs

1use std::path::{Path, PathBuf};
2
3use rusqlite::{Connection, params};
4use smallvec::SmallVec;
5use tracing::info;
6
7use crate::error::{CoreError, Result};
8use crate::installer::{InstallMethod, InstallPlan, InstallStatus, StagedFile};
9use crate::profile::{EnabledMod, LoadOrderLock, LockReason, Profile, ProfileSource};
10use crate::resolver::{GameId, LoadOrderRule, ModId};
11
12const CURRENT_SCHEMA_VERSION: u32 = 8;
13
14const SCHEMA_V1: &str = "
15PRAGMA journal_mode = WAL;
16PRAGMA foreign_keys = ON;
17
18CREATE TABLE IF NOT EXISTS profiles (
19    id          INTEGER PRIMARY KEY AUTOINCREMENT,
20    name        TEXT NOT NULL,
21    game_id     TEXT NOT NULL,
22    source_type TEXT NOT NULL DEFAULT 'manual',
23    source_data TEXT,
24    overrides   TEXT NOT NULL,
25    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
26    updated_at  TEXT NOT NULL DEFAULT (datetime('now')),
27    UNIQUE(name, game_id)
28);
29
30CREATE TABLE IF NOT EXISTS profile_mods (
31    id          INTEGER PRIMARY KEY AUTOINCREMENT,
32    profile_id  INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
33    mod_id      TEXT NOT NULL,
34    enabled     INTEGER NOT NULL DEFAULT 1,
35    version     TEXT,
36    fomod_config TEXT,
37    sort_index  INTEGER NOT NULL,
38    UNIQUE(profile_id, mod_id)
39);
40
41CREATE TABLE IF NOT EXISTS load_order_rules (
42    id          INTEGER PRIMARY KEY AUTOINCREMENT,
43    profile_id  INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
44    rule_type   TEXT NOT NULL,
45    mod_a       TEXT NOT NULL,
46    mod_b       TEXT NOT NULL
47);
48
49CREATE TABLE IF NOT EXISTS saves (
50    id          INTEGER PRIMARY KEY AUTOINCREMENT,
51    profile_id  INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
52    path        TEXT NOT NULL UNIQUE,
53    label       TEXT,
54    assigned_at TEXT NOT NULL DEFAULT (datetime('now'))
55);
56
57CREATE TABLE IF NOT EXISTS stock_snapshots (
58    id          INTEGER PRIMARY KEY AUTOINCREMENT,
59    game_id     TEXT NOT NULL UNIQUE,
60    snapshot_path TEXT NOT NULL,
61    tree_hash   TEXT NOT NULL,
62    file_count  INTEGER NOT NULL,
63    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
64);
65
66CREATE TABLE IF NOT EXISTS active_profiles (
67    game_id     TEXT PRIMARY KEY,
68    profile_id  INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
69    activated_at TEXT NOT NULL DEFAULT (datetime('now'))
70);
71
72CREATE TABLE IF NOT EXISTS experiment_stack (
73    id          INTEGER PRIMARY KEY AUTOINCREMENT,
74    game_id     TEXT NOT NULL,
75    profile_id  INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
76    depth       INTEGER NOT NULL,
77    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
78);
79
80CREATE INDEX IF NOT EXISTS idx_profiles_game ON profiles(game_id);
81CREATE INDEX IF NOT EXISTS idx_mods_profile ON profile_mods(profile_id);
82CREATE INDEX IF NOT EXISTS idx_rules_profile ON load_order_rules(profile_id);
83CREATE INDEX IF NOT EXISTS idx_saves_profile ON saves(profile_id);
84CREATE INDEX IF NOT EXISTS idx_experiment_game ON experiment_stack(game_id, depth);
85";
86
87const SCHEMA_V2: &str = "
88-- Per-file hiding (MO2-style .mohidden equivalent)
89CREATE TABLE IF NOT EXISTS hidden_files (
90    id         INTEGER PRIMARY KEY AUTOINCREMENT,
91    profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
92    mod_id     TEXT NOT NULL,
93    rel_path   TEXT NOT NULL,
94    hidden_at  TEXT NOT NULL DEFAULT (datetime('now')),
95    UNIQUE(profile_id, mod_id, rel_path)
96);
97
98-- Independent plugin ordering (separate from mod install priority)
99CREATE TABLE IF NOT EXISTS plugin_order (
100    id          INTEGER PRIMARY KEY AUTOINCREMENT,
101    profile_id  INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
102    plugin_name TEXT NOT NULL,
103    sort_index  INTEGER NOT NULL,
104    enabled     INTEGER NOT NULL DEFAULT 1,
105    UNIQUE(profile_id, plugin_name)
106);
107
108-- Mod categories with collapsible separators
109CREATE TABLE IF NOT EXISTS mod_categories (
110    id         INTEGER PRIMARY KEY AUTOINCREMENT,
111    profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
112    name       TEXT NOT NULL,
113    color      TEXT,
114    sort_index INTEGER NOT NULL,
115    UNIQUE(profile_id, name)
116);
117
118-- Extend profile_mods with Nexus metadata, categories, notes, tags
119ALTER TABLE profile_mods ADD COLUMN nexus_mod_id INTEGER;
120ALTER TABLE profile_mods ADD COLUMN nexus_file_id INTEGER;
121ALTER TABLE profile_mods ADD COLUMN nexus_game_domain TEXT;
122ALTER TABLE profile_mods ADD COLUMN installed_timestamp INTEGER;
123ALTER TABLE profile_mods ADD COLUMN category_id INTEGER REFERENCES mod_categories(id);
124ALTER TABLE profile_mods ADD COLUMN notes TEXT;
125ALTER TABLE profile_mods ADD COLUMN tags TEXT;
126
127CREATE INDEX IF NOT EXISTS idx_hidden_profile ON hidden_files(profile_id);
128CREATE INDEX IF NOT EXISTS idx_plugin_order_profile ON plugin_order(profile_id);
129CREATE INDEX IF NOT EXISTS idx_categories_profile ON mod_categories(profile_id);
130";
131
132const SCHEMA_V3: &str = "
133-- Per-game tool/overlay configurations (MangoHud, vkBasalt, GameMode, etc.)
134CREATE TABLE IF NOT EXISTS game_tools (
135    id          INTEGER PRIMARY KEY AUTOINCREMENT,
136    game_id     TEXT NOT NULL,
137    tool_id     TEXT NOT NULL,
138    enabled     INTEGER NOT NULL DEFAULT 0,
139    settings    TEXT NOT NULL DEFAULT '{}',
140    updated_at  TEXT NOT NULL DEFAULT (datetime('now')),
141    UNIQUE(game_id, tool_id)
142);
143
144-- Files applied by tools to game directories (for revert tracking)
145CREATE TABLE IF NOT EXISTS tool_applied_files (
146    id          INTEGER PRIMARY KEY AUTOINCREMENT,
147    game_id     TEXT NOT NULL,
148    tool_id     TEXT NOT NULL,
149    rel_path    TEXT NOT NULL,
150    applied_at  TEXT NOT NULL DEFAULT (datetime('now')),
151    UNIQUE(game_id, tool_id, rel_path)
152);
153
154CREATE INDEX IF NOT EXISTS idx_game_tools_game ON game_tools(game_id);
155CREATE INDEX IF NOT EXISTS idx_tool_files_game ON tool_applied_files(game_id, tool_id);
156";
157
158// Schema V8 adds the installer pipeline's state:
159//
160// * Three new columns on `profile_mods` capturing how a mod was installed:
161//   `install_method` (TOML-serialized `InstallMethod`), `source_archive_hash`
162//   (xxh64 of the downloaded archive), and `install_status` (one of
163//   `installed | unknown | pending_user_input | failed`).
164//
165// * A new `installed_mod_files` table that records the concrete file
166//   manifest for every installed mod so uninstall can remove exactly the
167//   files it staged — no orphaned files, no collateral damage. The
168//   `merge_group` column is reserved for the future script-merge feature;
169//   it is written but not read by current code.
170const SCHEMA_V8: &str = "
171CREATE TABLE IF NOT EXISTS installed_mod_files (
172    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
173    profile_id          INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
174    mod_id              TEXT NOT NULL,
175    rel_path            TEXT NOT NULL,
176    origin_rel_path     TEXT NOT NULL,
177    size                INTEGER NOT NULL,
178    merge_group         TEXT,
179    UNIQUE(profile_id, mod_id, rel_path)
180);
181
182CREATE INDEX IF NOT EXISTS idx_imf_profile_mod ON installed_mod_files(profile_id, mod_id);
183CREATE INDEX IF NOT EXISTS idx_imf_merge_group ON installed_mod_files(merge_group)
184    WHERE merge_group IS NOT NULL;
185";
186
187/// Summary view of a profile (without loading all mods).
188#[derive(Debug, Clone, PartialEq)]
189pub struct ProfileSummary {
190    pub id: i64,
191    pub name: String,
192    pub game_id: GameId,
193    pub mod_count: usize,
194    pub source_type: String,
195}
196
197/// A save file/directory assigned to a profile.
198#[derive(Debug, Clone)]
199pub struct SaveEntry {
200    pub path: PathBuf,
201    pub label: Option<String>,
202    pub assigned_at: String,
203}
204
205/// Metadata for a stock game snapshot stored in the database.
206#[derive(Debug, Clone)]
207pub struct SnapshotMeta {
208    pub game_id: GameId,
209    pub snapshot_path: PathBuf,
210    pub tree_hash: String,
211    pub file_count: usize,
212    pub created_at: String,
213}
214
215/// A hidden file entry — prevents a specific file from a mod from being deployed.
216#[derive(Debug, Clone)]
217pub struct HiddenFile {
218    pub mod_id: String,
219    pub rel_path: String,
220}
221
222/// A plugin entry in the plugin load order (independent of mod install priority).
223#[derive(Debug, Clone)]
224pub struct PluginEntry {
225    pub plugin_name: String,
226    pub sort_index: i64,
227    pub enabled: bool,
228}
229
230/// A mod category for organizing the mod list.
231#[derive(Debug, Clone)]
232pub struct ModCategory {
233    pub id: Option<i64>,
234    pub name: String,
235    pub color: Option<String>,
236    pub sort_index: i64,
237}
238
239/// SQLite-backed persistent storage for modde.
240pub struct ModdeDb {
241    conn: Connection,
242}
243
244impl ModdeDb {
245    /// Open the database at the default XDG path, creating it if needed.
246    pub fn open() -> Result<Self> {
247        let path = crate::paths::db_path();
248        Self::open_at(&path)
249    }
250
251    /// Open the database at a specific path (useful for testing).
252    pub fn open_at(path: &Path) -> Result<Self> {
253        if let Some(parent) = path.parent() {
254            std::fs::create_dir_all(parent)?;
255        }
256        let conn = Connection::open(path)?;
257        let db = Self { conn };
258        db.migrate()?;
259        Ok(db)
260    }
261
262    /// Open an in-memory database (for testing).
263    pub fn open_memory() -> Result<Self> {
264        let conn = Connection::open_in_memory()?;
265        let db = Self { conn };
266        db.migrate()?;
267        Ok(db)
268    }
269
270    fn migrate(&self) -> Result<()> {
271        let version: u32 = self
272            .conn
273            .pragma_query_value(None, "user_version", |row| row.get(0))?;
274
275        if version < 1 {
276            self.conn.execute_batch(SCHEMA_V1)?;
277            info!(from = version, to = 1, "database schema migrated to V1");
278        }
279
280        if version < 2 {
281            self.conn.execute_batch(SCHEMA_V2)?;
282            info!(from = version.max(1), to = 2, "database schema migrated to V2");
283        }
284
285        if version < 3 {
286            self.conn.execute_batch(SCHEMA_V3)?;
287            info!(from = version.max(2), to = 3, "database schema migrated to V3");
288        }
289
290        if version < 6 {
291            // Add display_name column if it doesn't already exist.
292            let has_display_name = self.conn
293                .prepare("SELECT display_name FROM profile_mods LIMIT 0")
294                .is_ok();
295            if !has_display_name {
296                self.conn.execute_batch("ALTER TABLE profile_mods ADD COLUMN display_name TEXT;")?;
297            }
298            info!(from = version.max(5), to = 6, "database schema migrated to V6");
299        }
300
301        if version < 7 {
302            // Load order lock (V7): profile-level + per-mod locks. See
303            // `crates/modde-core/src/profile/mod.rs` for `LoadOrderLock` /
304            // `LockReason`. Columns are TOML-encoded to match the existing
305            // `source_data` convention.
306            let has_load_order_lock = self.conn
307                .prepare("SELECT load_order_lock FROM profiles LIMIT 0")
308                .is_ok();
309            if !has_load_order_lock {
310                self.conn.execute_batch("ALTER TABLE profiles ADD COLUMN load_order_lock TEXT;")?;
311            }
312            let has_lock_reason = self.conn
313                .prepare("SELECT lock_reason FROM profile_mods LIMIT 0")
314                .is_ok();
315            if !has_lock_reason {
316                self.conn.execute_batch("ALTER TABLE profile_mods ADD COLUMN lock_reason TEXT;")?;
317            }
318            info!(from = version.max(6), to = 7, "database schema migrated to V7");
319        }
320
321        if version < 8 {
322            // Installer pipeline (V8): per-mod install method + file
323            // manifest. See `crates/modde-core/src/installer/` for the
324            // pipeline that produces these values.
325            let has_install_method = self
326                .conn
327                .prepare("SELECT install_method FROM profile_mods LIMIT 0")
328                .is_ok();
329            if !has_install_method {
330                self.conn.execute_batch(
331                    "ALTER TABLE profile_mods ADD COLUMN install_method TEXT;",
332                )?;
333            }
334            let has_source_archive_hash = self
335                .conn
336                .prepare("SELECT source_archive_hash FROM profile_mods LIMIT 0")
337                .is_ok();
338            if !has_source_archive_hash {
339                self.conn.execute_batch(
340                    "ALTER TABLE profile_mods ADD COLUMN source_archive_hash TEXT;",
341                )?;
342            }
343            let has_install_status = self
344                .conn
345                .prepare("SELECT install_status FROM profile_mods LIMIT 0")
346                .is_ok();
347            if !has_install_status {
348                self.conn.execute_batch(
349                    "ALTER TABLE profile_mods ADD COLUMN install_status TEXT;",
350                )?;
351            }
352            self.conn.execute_batch(SCHEMA_V8)?;
353            info!(from = version.max(7), to = 8, "database schema migrated to V8");
354        }
355
356        if version < CURRENT_SCHEMA_VERSION {
357            self.conn
358                .pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)?;
359        }
360
361        // Ensure WAL and FK are always on (they reset per-connection).
362        self.conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")?;
363
364        Ok(())
365    }
366
367    // ── Profile CRUD ──────────────────────────────────────────────
368
369    /// Create a new profile, returning its database ID.
370    pub fn create_profile(&self, profile: &Profile) -> Result<i64> {
371        let (source_type, source_data) = encode_source(&profile.source);
372        let load_order_lock = encode_lock(profile.load_order_lock.as_ref());
373
374        self.conn.execute(
375            "INSERT INTO profiles (name, game_id, source_type, source_data, overrides, load_order_lock)
376             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
377            params![
378                profile.name,
379                profile.game_id,
380                source_type,
381                source_data,
382                profile.overrides.to_string_lossy().as_ref(),
383                load_order_lock,
384            ],
385        )?;
386
387        let profile_id = self.conn.last_insert_rowid();
388
389        self.insert_mods(profile_id, &profile.mods)?;
390        self.insert_rules(profile_id, &profile.load_order_rules)?;
391
392        Ok(profile_id)
393    }
394
395    /// Load a profile by name and game_id.
396    pub fn load_profile(&self, name: &str, game_id: &str) -> Result<Profile> {
397        let (id, source_type, source_data, overrides, load_order_lock) = self
398            .conn
399            .query_row(
400                "SELECT id, source_type, source_data, overrides, load_order_lock FROM profiles
401                 WHERE name = ?1 AND game_id = ?2",
402                params![name, game_id],
403                |row| {
404                    Ok((
405                        row.get::<_, i64>(0)?,
406                        row.get::<_, String>(1)?,
407                        row.get::<_, Option<String>>(2)?,
408                        row.get::<_, String>(3)?,
409                        row.get::<_, Option<String>>(4)?,
410                    ))
411                },
412            )
413            .map_err(|e| match e {
414                rusqlite::Error::QueryReturnedNoRows => {
415                    CoreError::ProfileNotFound(format!("{name} (game: {game_id})"))
416                }
417                other => CoreError::Database(other),
418            })?;
419
420        self.assemble_profile(
421            id,
422            name,
423            game_id,
424            &source_type,
425            source_data.as_deref(),
426            &overrides,
427            load_order_lock.as_deref(),
428        )
429    }
430
431    /// Load a profile by its database ID.
432    pub fn load_profile_by_id(&self, id: i64) -> Result<Profile> {
433        let (name, game_id, source_type, source_data, overrides, load_order_lock) = self
434            .conn
435            .query_row(
436                "SELECT name, game_id, source_type, source_data, overrides, load_order_lock
437                 FROM profiles WHERE id = ?1",
438                params![id],
439                |row| {
440                    Ok((
441                        row.get::<_, String>(0)?,
442                        row.get::<_, String>(1)?,
443                        row.get::<_, String>(2)?,
444                        row.get::<_, Option<String>>(3)?,
445                        row.get::<_, String>(4)?,
446                        row.get::<_, Option<String>>(5)?,
447                    ))
448                },
449            )
450            .map_err(|e| match e {
451                rusqlite::Error::QueryReturnedNoRows => {
452                    CoreError::ProfileNotFound(format!("id={id}"))
453                }
454                other => CoreError::Database(other),
455            })?;
456
457        self.assemble_profile(
458            id,
459            &name,
460            &game_id,
461            &source_type,
462            source_data.as_deref(),
463            &overrides,
464            load_order_lock.as_deref(),
465        )
466    }
467
468    /// Load a profile by name only. Errors with `AmbiguousProfile` if multiple games match.
469    pub fn load_profile_by_name(&self, name: &str) -> Result<Profile> {
470        let mut stmt = self.conn.prepare(
471            "SELECT id, game_id, source_type, source_data, overrides, load_order_lock
472             FROM profiles WHERE name = ?1",
473        )?;
474
475        let rows: Vec<(i64, String, String, Option<String>, String, Option<String>)> = stmt
476            .query_map(params![name], |row| {
477                Ok((
478                    row.get(0)?,
479                    row.get(1)?,
480                    row.get(2)?,
481                    row.get(3)?,
482                    row.get(4)?,
483                    row.get(5)?,
484                ))
485            })?
486            .collect::<std::result::Result<Vec<_>, _>>()?;
487
488        match rows.len() {
489            0 => Err(CoreError::ProfileNotFound(name.to_string())),
490            1 => {
491                let (id, game_id, source_type, source_data, overrides, load_order_lock) = &rows[0];
492                self.assemble_profile(
493                    *id,
494                    name,
495                    game_id,
496                    source_type,
497                    source_data.as_deref(),
498                    overrides,
499                    load_order_lock.as_deref(),
500                )
501            }
502            _ => {
503                let games: SmallVec<[GameId; 4]> = rows
504                    .iter()
505                    .map(|(_, g, _, _, _, _)| GameId::from(g.clone()))
506                    .collect();
507                Err(CoreError::AmbiguousProfile {
508                    name: name.to_string(),
509                    games,
510                })
511            }
512        }
513    }
514
515    /// Update an existing profile (identified by name + game_id).
516    pub fn update_profile(&self, profile: &Profile) -> Result<()> {
517        let (source_type, source_data) = encode_source(&profile.source);
518        let load_order_lock = encode_lock(profile.load_order_lock.as_ref());
519
520        let profile_id: i64 = self
521            .conn
522            .query_row(
523                "SELECT id FROM profiles WHERE name = ?1 AND game_id = ?2",
524                params![profile.name, profile.game_id],
525                |row| row.get(0),
526            )
527            .map_err(|e| match e {
528                rusqlite::Error::QueryReturnedNoRows => {
529                    CoreError::ProfileNotFound(format!("{} (game: {})", profile.name, profile.game_id))
530                }
531                other => CoreError::Database(other),
532            })?;
533
534        self.conn.execute(
535            "UPDATE profiles SET source_type = ?1, source_data = ?2, overrides = ?3,
536                    load_order_lock = ?4, updated_at = datetime('now')
537             WHERE id = ?5",
538            params![
539                source_type,
540                source_data,
541                profile.overrides.to_string_lossy().as_ref(),
542                load_order_lock,
543                profile_id,
544            ],
545        )?;
546
547        // Replace mods and rules
548        self.conn
549            .execute("DELETE FROM profile_mods WHERE profile_id = ?1", params![profile_id])?;
550        self.conn
551            .execute("DELETE FROM load_order_rules WHERE profile_id = ?1", params![profile_id])?;
552
553        self.insert_mods(profile_id, &profile.mods)?;
554        self.insert_rules(profile_id, &profile.load_order_rules)?;
555
556        Ok(())
557    }
558
559    /// Delete a profile by name and game_id.
560    pub fn delete_profile(&self, name: &str, game_id: &str) -> Result<()> {
561        let changes = self.conn.execute(
562            "DELETE FROM profiles WHERE name = ?1 AND game_id = ?2",
563            params![name, game_id],
564        )?;
565        if changes == 0 {
566            return Err(CoreError::ProfileNotFound(format!("{name} (game: {game_id})")));
567        }
568        Ok(())
569    }
570
571    /// List profile summaries, optionally filtered by game.
572    pub fn list_profiles(&self, game_id: Option<&str>) -> Result<Vec<ProfileSummary>> {
573        let (sql, bind) = match game_id {
574            Some(gid) => (
575                "SELECT p.id, p.name, p.game_id, p.source_type,
576                        (SELECT COUNT(*) FROM profile_mods WHERE profile_id = p.id) as mod_count
577                 FROM profiles p WHERE p.game_id = ?1 ORDER BY p.name",
578                Some(gid.to_string()),
579            ),
580            None => (
581                "SELECT p.id, p.name, p.game_id, p.source_type,
582                        (SELECT COUNT(*) FROM profile_mods WHERE profile_id = p.id) as mod_count
583                 FROM profiles p ORDER BY p.game_id, p.name",
584                None,
585            ),
586        };
587
588        let mut stmt = self.conn.prepare(sql)?;
589
590        let row_mapper = |row: &rusqlite::Row<'_>| {
591            Ok(ProfileSummary {
592                id: row.get(0)?,
593                name: row.get(1)?,
594                game_id: GameId::from(row.get::<_, String>(2)?),
595                source_type: row.get(3)?,
596                mod_count: row.get(4)?,
597            })
598        };
599
600        let summaries = match &bind {
601            Some(gid) => stmt.query_map(params![gid], row_mapper)?,
602            None => stmt.query_map([], row_mapper)?,
603        }
604        .collect::<std::result::Result<Vec<_>, _>>()?;
605
606        Ok(summaries)
607    }
608
609    // ── Save CRUD ─────────────────────────────────────────────────
610
611    /// Assign a save to a profile.
612    pub fn assign_save(&self, profile_id: i64, path: &Path, label: Option<&str>) -> Result<()> {
613        let path_str = path.to_string_lossy();
614
615        // Check if already assigned to a different profile
616        let existing: Option<(i64, String)> = self
617            .conn
618            .query_row(
619                "SELECT s.profile_id, p.name FROM saves s
620                 JOIN profiles p ON p.id = s.profile_id
621                 WHERE s.path = ?1",
622                params![path_str.as_ref()],
623                |row| Ok((row.get(0)?, row.get(1)?)),
624            )
625            .ok();
626
627        if let Some((existing_id, existing_name)) = existing {
628            if existing_id != profile_id {
629                return Err(CoreError::SaveAlreadyAssigned {
630                    path: path_str.to_string(),
631                    profile: existing_name,
632                });
633            }
634            // Already assigned to this profile — update label
635            self.conn.execute(
636                "UPDATE saves SET label = ?1 WHERE path = ?2",
637                params![label, path_str.as_ref()],
638            )?;
639            return Ok(());
640        }
641
642        self.conn.execute(
643            "INSERT INTO saves (profile_id, path, label) VALUES (?1, ?2, ?3)",
644            params![profile_id, path_str.as_ref(), label],
645        )?;
646
647        Ok(())
648    }
649
650    /// Remove a save assignment.
651    pub fn unassign_save(&self, path: &Path) -> Result<()> {
652        let path_str = path.to_string_lossy();
653        self.conn
654            .execute("DELETE FROM saves WHERE path = ?1", params![path_str.as_ref()])?;
655        Ok(())
656    }
657
658    /// List all saves assigned to a profile.
659    pub fn list_saves(&self, profile_id: i64) -> Result<Vec<SaveEntry>> {
660        let mut stmt = self.conn.prepare(
661            "SELECT path, label, assigned_at FROM saves WHERE profile_id = ?1 ORDER BY assigned_at",
662        )?;
663
664        let saves = stmt
665            .query_map(params![profile_id], |row| {
666                Ok(SaveEntry {
667                    path: PathBuf::from(row.get::<_, String>(0)?),
668                    label: row.get(1)?,
669                    assigned_at: row.get(2)?,
670                })
671            })?
672            .collect::<std::result::Result<Vec<_>, _>>()?;
673
674        Ok(saves)
675    }
676
677    /// Check if a save path is assigned to any profile.
678    pub fn is_save_assigned(&self, path: &Path) -> Result<bool> {
679        let path_str = path.to_string_lossy();
680        let count: i64 = self.conn.query_row(
681            "SELECT COUNT(*) FROM saves WHERE path = ?1",
682            params![path_str.as_ref()],
683            |row| row.get(0),
684        )?;
685        Ok(count > 0)
686    }
687
688    // ── Active Profile Tracking ────────────────────────────────────
689
690    /// Set the active profile for a game, replacing any previous one.
691    pub fn set_active_profile(&self, game_id: &str, profile_id: i64) -> Result<()> {
692        self.conn.execute(
693            "INSERT INTO active_profiles (game_id, profile_id)
694             VALUES (?1, ?2)
695             ON CONFLICT(game_id) DO UPDATE SET
696                profile_id = excluded.profile_id,
697                activated_at = datetime('now')",
698            params![game_id, profile_id],
699        )?;
700        Ok(())
701    }
702
703    /// Get the active profile for a game, returning (profile_id, profile_name).
704    pub fn get_active_profile(&self, game_id: &str) -> Result<Option<(i64, String)>> {
705        let result = self.conn.query_row(
706            "SELECT a.profile_id, p.name FROM active_profiles a
707             JOIN profiles p ON p.id = a.profile_id
708             WHERE a.game_id = ?1",
709            params![game_id],
710            |row| Ok((row.get(0)?, row.get(1)?)),
711        );
712
713        match result {
714            Ok(pair) => Ok(Some(pair)),
715            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
716            Err(e) => Err(e.into()),
717        }
718    }
719
720    /// Clear the active profile for a game.
721    pub fn clear_active_profile(&self, game_id: &str) -> Result<()> {
722        self.conn.execute(
723            "DELETE FROM active_profiles WHERE game_id = ?1",
724            params![game_id],
725        )?;
726        Ok(())
727    }
728
729    // ── Experiment Stack ──────────────────────────────────────────
730
731    /// Push a profile onto the experiment stack for a game.
732    pub fn push_experiment(&self, game_id: &str, profile_id: i64) -> Result<()> {
733        let depth = self.experiment_depth(game_id)?;
734        self.conn.execute(
735            "INSERT INTO experiment_stack (game_id, profile_id, depth)
736             VALUES (?1, ?2, ?3)",
737            params![game_id, profile_id, depth as i64],
738        )?;
739        Ok(())
740    }
741
742    /// Pop the top entry from the experiment stack, returning the profile_id.
743    pub fn pop_experiment(&self, game_id: &str) -> Result<Option<i64>> {
744        let result = self.conn.query_row(
745            "SELECT id, profile_id FROM experiment_stack
746             WHERE game_id = ?1 ORDER BY depth DESC LIMIT 1",
747            params![game_id],
748            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
749        );
750
751        match result {
752            Ok((id, profile_id)) => {
753                self.conn.execute(
754                    "DELETE FROM experiment_stack WHERE id = ?1",
755                    params![id],
756                )?;
757                Ok(Some(profile_id))
758            }
759            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
760            Err(e) => Err(e.into()),
761        }
762    }
763
764    /// Get the experiment stack depth for a game.
765    pub fn experiment_depth(&self, game_id: &str) -> Result<usize> {
766        let count: i64 = self.conn.query_row(
767            "SELECT COUNT(*) FROM experiment_stack WHERE game_id = ?1",
768            params![game_id],
769            |row| row.get(0),
770        )?;
771        Ok(count as usize)
772    }
773
774    /// Clear the entire experiment stack for a game.
775    pub fn clear_experiment_stack(&self, game_id: &str) -> Result<()> {
776        self.conn.execute(
777            "DELETE FROM experiment_stack WHERE game_id = ?1",
778            params![game_id],
779        )?;
780        Ok(())
781    }
782
783    // ── Stock Snapshots ───────────────────────────────────────────
784
785    /// Insert or update a stock snapshot record.
786    pub fn upsert_snapshot(
787        &self,
788        game_id: &str,
789        snapshot_path: &Path,
790        tree_hash: &str,
791        file_count: usize,
792    ) -> Result<()> {
793        self.conn.execute(
794            "INSERT INTO stock_snapshots (game_id, snapshot_path, tree_hash, file_count)
795             VALUES (?1, ?2, ?3, ?4)
796             ON CONFLICT(game_id) DO UPDATE SET
797                snapshot_path = excluded.snapshot_path,
798                tree_hash = excluded.tree_hash,
799                file_count = excluded.file_count,
800                created_at = datetime('now')",
801            params![
802                game_id,
803                snapshot_path.to_string_lossy().as_ref(),
804                tree_hash,
805                file_count as i64,
806            ],
807        )?;
808        Ok(())
809    }
810
811    /// Get snapshot metadata for a game.
812    pub fn get_snapshot(&self, game_id: &str) -> Result<Option<SnapshotMeta>> {
813        let result = self.conn.query_row(
814            "SELECT game_id, snapshot_path, tree_hash, file_count, created_at
815             FROM stock_snapshots WHERE game_id = ?1",
816            params![game_id],
817            |row| {
818                Ok(SnapshotMeta {
819                    game_id: GameId::from(row.get::<_, String>(0)?),
820                    snapshot_path: PathBuf::from(row.get::<_, String>(1)?),
821                    tree_hash: row.get(2)?,
822                    file_count: row.get::<_, i64>(3)? as usize,
823                    created_at: row.get(4)?,
824                })
825            },
826        );
827
828        match result {
829            Ok(meta) => Ok(Some(meta)),
830            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
831            Err(e) => Err(e.into()),
832        }
833    }
834
835    // ── Hidden Files ─────────────────────────────────────────────
836
837    /// Hide a file from a mod in a profile (prevents deployment).
838    pub fn hide_file(&self, profile_id: i64, mod_id: &str, rel_path: &str) -> Result<()> {
839        self.conn.execute(
840            "INSERT OR IGNORE INTO hidden_files (profile_id, mod_id, rel_path)
841             VALUES (?1, ?2, ?3)",
842            params![profile_id, mod_id, rel_path],
843        )?;
844        Ok(())
845    }
846
847    /// Unhide a previously hidden file.
848    pub fn unhide_file(&self, profile_id: i64, mod_id: &str, rel_path: &str) -> Result<()> {
849        self.conn.execute(
850            "DELETE FROM hidden_files WHERE profile_id = ?1 AND mod_id = ?2 AND rel_path = ?3",
851            params![profile_id, mod_id, rel_path],
852        )?;
853        Ok(())
854    }
855
856    /// List all hidden files for a profile.
857    pub fn list_hidden_files(&self, profile_id: i64) -> Result<Vec<HiddenFile>> {
858        let mut stmt = self.conn.prepare(
859            "SELECT mod_id, rel_path FROM hidden_files WHERE profile_id = ?1",
860        )?;
861        let files = stmt
862            .query_map(params![profile_id], |row| {
863                Ok(HiddenFile {
864                    mod_id: row.get(0)?,
865                    rel_path: row.get(1)?,
866                })
867            })?
868            .collect::<std::result::Result<Vec<_>, _>>()?;
869        Ok(files)
870    }
871
872    /// List hidden files for a specific mod in a profile.
873    pub fn list_hidden_files_for_mod(&self, profile_id: i64, mod_id: &str) -> Result<Vec<String>> {
874        let mut stmt = self.conn.prepare(
875            "SELECT rel_path FROM hidden_files WHERE profile_id = ?1 AND mod_id = ?2",
876        )?;
877        let paths = stmt
878            .query_map(params![profile_id, mod_id], |row| row.get(0))?
879            .collect::<std::result::Result<Vec<_>, _>>()?;
880        Ok(paths)
881    }
882
883    // ── Plugin Order ─────────────────────────────────────────────
884
885    /// Set the plugin order for a profile (replaces any existing order).
886    pub fn set_plugin_order(&self, profile_id: i64, plugins: &[PluginEntry]) -> Result<()> {
887        self.conn.execute(
888            "DELETE FROM plugin_order WHERE profile_id = ?1",
889            params![profile_id],
890        )?;
891        let mut stmt = self.conn.prepare(
892            "INSERT INTO plugin_order (profile_id, plugin_name, sort_index, enabled)
893             VALUES (?1, ?2, ?3, ?4)",
894        )?;
895        for plugin in plugins {
896            stmt.execute(params![
897                profile_id,
898                plugin.plugin_name,
899                plugin.sort_index,
900                plugin.enabled,
901            ])?;
902        }
903        Ok(())
904    }
905
906    /// Get the plugin order for a profile.
907    pub fn get_plugin_order(&self, profile_id: i64) -> Result<Vec<PluginEntry>> {
908        let mut stmt = self.conn.prepare(
909            "SELECT plugin_name, sort_index, enabled FROM plugin_order
910             WHERE profile_id = ?1 ORDER BY sort_index",
911        )?;
912        let plugins = stmt
913            .query_map(params![profile_id], |row| {
914                Ok(PluginEntry {
915                    plugin_name: row.get(0)?,
916                    sort_index: row.get(1)?,
917                    enabled: row.get(2)?,
918                })
919            })?
920            .collect::<std::result::Result<Vec<_>, _>>()?;
921        Ok(plugins)
922    }
923
924    /// Toggle a plugin's enabled state.
925    pub fn toggle_plugin(&self, profile_id: i64, plugin_name: &str, enabled: bool) -> Result<()> {
926        self.conn.execute(
927            "UPDATE plugin_order SET enabled = ?1 WHERE profile_id = ?2 AND plugin_name = ?3",
928            params![enabled, profile_id, plugin_name],
929        )?;
930        Ok(())
931    }
932
933    // ── Mod Categories ───────────────────────────────────────────
934
935    /// Create a mod category, returning its ID.
936    pub fn create_category(&self, profile_id: i64, category: &ModCategory) -> Result<i64> {
937        self.conn.execute(
938            "INSERT INTO mod_categories (profile_id, name, color, sort_index)
939             VALUES (?1, ?2, ?3, ?4)",
940            params![profile_id, category.name, category.color, category.sort_index],
941        )?;
942        Ok(self.conn.last_insert_rowid())
943    }
944
945    /// Update a category.
946    pub fn update_category(&self, category_id: i64, name: &str, color: Option<&str>, sort_index: i64) -> Result<()> {
947        self.conn.execute(
948            "UPDATE mod_categories SET name = ?1, color = ?2, sort_index = ?3 WHERE id = ?4",
949            params![name, color, sort_index, category_id],
950        )?;
951        Ok(())
952    }
953
954    /// Delete a category (nullifies category_id on affected mods).
955    pub fn delete_category(&self, category_id: i64) -> Result<()> {
956        self.conn.execute(
957            "UPDATE profile_mods SET category_id = NULL WHERE category_id = ?1",
958            params![category_id],
959        )?;
960        self.conn.execute(
961            "DELETE FROM mod_categories WHERE id = ?1",
962            params![category_id],
963        )?;
964        Ok(())
965    }
966
967    /// List categories for a profile.
968    pub fn list_categories(&self, profile_id: i64) -> Result<Vec<ModCategory>> {
969        let mut stmt = self.conn.prepare(
970            "SELECT id, name, color, sort_index FROM mod_categories
971             WHERE profile_id = ?1 ORDER BY sort_index",
972        )?;
973        let cats = stmt
974            .query_map(params![profile_id], |row| {
975                Ok(ModCategory {
976                    id: Some(row.get(0)?),
977                    name: row.get(1)?,
978                    color: row.get(2)?,
979                    sort_index: row.get(3)?,
980                })
981            })?
982            .collect::<std::result::Result<Vec<_>, _>>()?;
983        Ok(cats)
984    }
985
986    /// Assign a mod to a category.
987    pub fn set_mod_category(&self, profile_id: i64, mod_id: &str, category_id: Option<i64>) -> Result<()> {
988        self.conn.execute(
989            "UPDATE profile_mods SET category_id = ?1 WHERE profile_id = ?2 AND mod_id = ?3",
990            params![category_id, profile_id, mod_id],
991        )?;
992        Ok(())
993    }
994
995    /// Set notes for a mod.
996    pub fn set_mod_notes(&self, profile_id: i64, mod_id: &str, notes: Option<&str>) -> Result<()> {
997        self.conn.execute(
998            "UPDATE profile_mods SET notes = ?1 WHERE profile_id = ?2 AND mod_id = ?3",
999            params![notes, profile_id, mod_id],
1000        )?;
1001        Ok(())
1002    }
1003
1004    /// Set tags for a mod (stored as JSON array).
1005    pub fn set_mod_tags(&self, profile_id: i64, mod_id: &str, tags: Option<&str>) -> Result<()> {
1006        self.conn.execute(
1007            "UPDATE profile_mods SET tags = ?1 WHERE profile_id = ?2 AND mod_id = ?3",
1008            params![tags, profile_id, mod_id],
1009        )?;
1010        Ok(())
1011    }
1012
1013    /// Set Nexus metadata for a mod.
1014    pub fn set_mod_nexus_meta(
1015        &self,
1016        profile_id: i64,
1017        mod_id: &str,
1018        nexus_mod_id: i64,
1019        nexus_file_id: i64,
1020        nexus_game_domain: &str,
1021        installed_timestamp: i64,
1022    ) -> Result<()> {
1023        self.conn.execute(
1024            "UPDATE profile_mods SET nexus_mod_id = ?1, nexus_file_id = ?2,
1025                    nexus_game_domain = ?3, installed_timestamp = ?4
1026             WHERE profile_id = ?5 AND mod_id = ?6",
1027            params![nexus_mod_id, nexus_file_id, nexus_game_domain, installed_timestamp, profile_id, mod_id],
1028        )?;
1029        Ok(())
1030    }
1031
1032    // ── Installer tracking (V8) ───────────────────────────────────
1033
1034    /// Persist an installer's decision and file manifest for a single
1035    /// mod row, atomically.
1036    ///
1037    /// Steps in one transaction:
1038    /// 1. Write the encoded `install_method`, `source_archive_hash`, and
1039    ///    `install_status` back to `profile_mods`.
1040    /// 2. Wipe any previous `installed_mod_files` rows for this mod
1041    ///    (so retries don't leave orphans in the manifest).
1042    /// 3. Insert one row per `plan.staged_files`.
1043    ///
1044    /// Callers are expected to have already written the EnabledMod into
1045    /// the profile (via `update_profile` / `create_profile`); this method
1046    /// just enriches the existing row with install metadata and files.
1047    pub fn record_install(
1048        &mut self,
1049        profile_id: i64,
1050        mod_id: &str,
1051        plan: &InstallPlan,
1052        status: InstallStatus,
1053    ) -> Result<()> {
1054        let tx = self.conn.transaction()?;
1055
1056        let method_toml = encode_install_method(&plan.method)?;
1057        tx.execute(
1058            "UPDATE profile_mods
1059                SET install_method = ?1,
1060                    source_archive_hash = ?2,
1061                    install_status = ?3
1062              WHERE profile_id = ?4 AND mod_id = ?5",
1063            params![
1064                method_toml,
1065                plan.source_archive_hash,
1066                status.as_str(),
1067                profile_id,
1068                mod_id,
1069            ],
1070        )?;
1071
1072        tx.execute(
1073            "DELETE FROM installed_mod_files WHERE profile_id = ?1 AND mod_id = ?2",
1074            params![profile_id, mod_id],
1075        )?;
1076
1077        {
1078            let mut stmt = tx.prepare(
1079                "INSERT INTO installed_mod_files
1080                    (profile_id, mod_id, rel_path, origin_rel_path, size, merge_group)
1081                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1082            )?;
1083            for file in &plan.staged_files {
1084                stmt.execute(params![
1085                    profile_id,
1086                    mod_id,
1087                    file.rel_path,
1088                    file.origin_rel_path,
1089                    file.size as i64,
1090                    file.merge_group,
1091                ])?;
1092            }
1093        }
1094
1095        tx.commit()?;
1096        Ok(())
1097    }
1098
1099    /// Return every file staged by `mod_id` in `profile_id`, sorted by
1100    /// relative path for deterministic uninstall order.
1101    pub fn installed_files_for_mod(
1102        &self,
1103        profile_id: i64,
1104        mod_id: &str,
1105    ) -> Result<Vec<StagedFile>> {
1106        let mut stmt = self.conn.prepare(
1107            "SELECT rel_path, origin_rel_path, size, merge_group
1108               FROM installed_mod_files
1109              WHERE profile_id = ?1 AND mod_id = ?2
1110           ORDER BY rel_path",
1111        )?;
1112        let files = stmt
1113            .query_map(params![profile_id, mod_id], |row| {
1114                let size_i: i64 = row.get(2)?;
1115                Ok(StagedFile {
1116                    rel_path: row.get(0)?,
1117                    origin_rel_path: row.get(1)?,
1118                    size: size_i.max(0) as u64,
1119                    merge_group: row.get(3)?,
1120                })
1121            })?
1122            .collect::<std::result::Result<Vec<_>, _>>()?;
1123        Ok(files)
1124    }
1125
1126    /// Remove a mod from `profile_mods` and return its staged files so
1127    /// the caller can unlink them from the store / deploy dir. Runs in
1128    /// one transaction — either the manifest and row both disappear or
1129    /// neither does.
1130    pub fn remove_installed_mod(
1131        &mut self,
1132        profile_id: i64,
1133        mod_id: &str,
1134    ) -> Result<Vec<StagedFile>> {
1135        let files = self.installed_files_for_mod(profile_id, mod_id)?;
1136        let tx = self.conn.transaction()?;
1137        tx.execute(
1138            "DELETE FROM installed_mod_files WHERE profile_id = ?1 AND mod_id = ?2",
1139            params![profile_id, mod_id],
1140        )?;
1141        tx.execute(
1142            "DELETE FROM profile_mods WHERE profile_id = ?1 AND mod_id = ?2",
1143            params![profile_id, mod_id],
1144        )?;
1145        tx.commit()?;
1146        Ok(files)
1147    }
1148
1149    /// Return every file tagged with `merge_group`, across all mods in
1150    /// `profile_id`. Reserved for the future script-merge feature —
1151    /// unused by current code, but exposed now so the V8 schema can
1152    /// carry a stable query shape.
1153    pub fn files_in_merge_group(
1154        &self,
1155        profile_id: i64,
1156        merge_group: &str,
1157    ) -> Result<Vec<(String, StagedFile)>> {
1158        let mut stmt = self.conn.prepare(
1159            "SELECT mod_id, rel_path, origin_rel_path, size, merge_group
1160               FROM installed_mod_files
1161              WHERE profile_id = ?1 AND merge_group = ?2
1162           ORDER BY mod_id, rel_path",
1163        )?;
1164        let rows = stmt
1165            .query_map(params![profile_id, merge_group], |row| {
1166                let size_i: i64 = row.get(3)?;
1167                Ok((
1168                    row.get::<_, String>(0)?,
1169                    StagedFile {
1170                        rel_path: row.get(1)?,
1171                        origin_rel_path: row.get(2)?,
1172                        size: size_i.max(0) as u64,
1173                        merge_group: row.get(4)?,
1174                    },
1175                ))
1176            })?
1177            .collect::<std::result::Result<Vec<_>, _>>()?;
1178        Ok(rows)
1179    }
1180
1181    // ── TOML Import ───────────────────────────────────────────────
1182
1183    /// Import existing TOML profile files into the database.
1184    /// Returns the number of profiles imported.
1185    pub fn import_toml_profiles(&self, profiles_dir: &Path) -> Result<usize> {
1186        if !profiles_dir.exists() {
1187            return Ok(0);
1188        }
1189
1190        let mut count = 0usize;
1191
1192        for entry in std::fs::read_dir(profiles_dir)? {
1193            let entry = entry?;
1194            if !entry.file_type()?.is_dir() {
1195                continue;
1196            }
1197
1198            let toml_path = entry.path().join("profile.toml");
1199            if !toml_path.exists() {
1200                continue;
1201            }
1202
1203            let content = match std::fs::read_to_string(&toml_path) {
1204                Ok(c) => c,
1205                Err(e) => {
1206                    tracing::warn!(path = %toml_path.display(), error = %e, "skipping unreadable profile");
1207                    continue;
1208                }
1209            };
1210
1211            #[allow(deprecated)]
1212            let mut profile: Profile = match toml::from_str(&content) {
1213                Ok(p) => p,
1214                Err(e) => {
1215                    tracing::warn!(path = %toml_path.display(), error = %e, "skipping unparseable profile");
1216                    continue;
1217                }
1218            };
1219
1220            // Preserve-before-overwrite: if the TOML file already carried
1221            // a lock (e.g. a Wabbajack-installed profile that was previously
1222            // exported), honor that provenance. Only stamp a fresh
1223            // `TomlImport` lock when the parsed profile has no lock — so
1224            // round-tripping an already-locked profile through TOML doesn't
1225            // destroy its origin.
1226            if profile.load_order_lock.is_none() {
1227                profile.load_order_lock = Some(LoadOrderLock::now(LockReason::TomlImport {
1228                    source_path: toml_path.display().to_string(),
1229                }));
1230            }
1231
1232            // Skip if already in DB
1233            let exists: bool = self
1234                .conn
1235                .query_row(
1236                    "SELECT COUNT(*) > 0 FROM profiles WHERE name = ?1 AND game_id = ?2",
1237                    params![profile.name, profile.game_id],
1238                    |row| row.get(0),
1239                )?;
1240
1241            if exists {
1242                tracing::debug!(name = %profile.name, game = %profile.game_id, "profile already in DB, skipping");
1243                continue;
1244            }
1245
1246            self.create_profile(&profile)?;
1247            info!(name = %profile.name, game = %profile.game_id, "imported TOML profile");
1248            count += 1;
1249        }
1250
1251        Ok(count)
1252    }
1253
1254    // ── Internal helpers ──────────────────────────────────────────
1255
1256    fn insert_mods(&self, profile_id: i64, mods: &[EnabledMod]) -> Result<()> {
1257        let mut stmt = self.conn.prepare(
1258            "INSERT INTO profile_mods (profile_id, mod_id, display_name, enabled, version, fomod_config, sort_index,
1259                    nexus_mod_id, nexus_file_id, nexus_game_domain, installed_timestamp,
1260                    category_id, notes, tags, lock_reason,
1261                    install_method, source_archive_hash, install_status)
1262             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
1263        )?;
1264
1265        for (idx, m) in mods.iter().enumerate() {
1266            let lock_reason = encode_lock_reason(m.lock.as_ref());
1267            stmt.execute(params![
1268                profile_id,
1269                m.mod_id,
1270                m.display_name,
1271                m.enabled,
1272                m.version,
1273                m.fomod_config,
1274                idx as i64,
1275                m.nexus_mod_id,
1276                m.nexus_file_id,
1277                m.nexus_game_domain,
1278                m.installed_timestamp,
1279                m.category_id,
1280                m.notes,
1281                m.tags,
1282                lock_reason,
1283                m.install_method,
1284                m.source_archive_hash,
1285                m.install_status,
1286            ])?;
1287        }
1288
1289        Ok(())
1290    }
1291
1292    fn insert_rules(&self, profile_id: i64, rules: &[LoadOrderRule]) -> Result<()> {
1293        let mut stmt = self.conn.prepare(
1294            "INSERT INTO load_order_rules (profile_id, rule_type, mod_a, mod_b)
1295             VALUES (?1, ?2, ?3, ?4)",
1296        )?;
1297
1298        for rule in rules {
1299            let (rule_type, mod_a, mod_b) = match rule {
1300                LoadOrderRule::LoadAfter { mod_id, after } => ("load_after", mod_id.as_str(), after.as_str()),
1301                LoadOrderRule::LoadBefore { mod_id, before } => ("load_before", mod_id.as_str(), before.as_str()),
1302                LoadOrderRule::Incompatible { mod_a, mod_b } => ("incompatible", mod_a.as_str(), mod_b.as_str()),
1303            };
1304            stmt.execute(params![profile_id, rule_type, mod_a, mod_b])?;
1305        }
1306
1307        Ok(())
1308    }
1309
1310    fn load_mods(&self, profile_id: i64) -> Result<Vec<EnabledMod>> {
1311        let mut stmt = self.conn.prepare(
1312            "SELECT mod_id, display_name, enabled, version, fomod_config,
1313                    nexus_mod_id, nexus_file_id, nexus_game_domain, installed_timestamp,
1314                    category_id, notes, tags, lock_reason,
1315                    install_method, source_archive_hash, install_status
1316             FROM profile_mods WHERE profile_id = ?1 ORDER BY sort_index",
1317        )?;
1318
1319        let mods = stmt
1320            .query_map(params![profile_id], |row| {
1321                let lock_reason_raw: Option<String> = row.get(12)?;
1322                Ok(EnabledMod {
1323                    mod_id: row.get(0)?,
1324                    display_name: row.get(1)?,
1325                    enabled: row.get(2)?,
1326                    version: row.get(3)?,
1327                    fomod_config: row.get(4)?,
1328                    nexus_mod_id: row.get(5)?,
1329                    nexus_file_id: row.get(6)?,
1330                    nexus_game_domain: row.get(7)?,
1331                    installed_timestamp: row.get(8)?,
1332                    category_id: row.get(9)?,
1333                    notes: row.get(10)?,
1334                    tags: row.get(11)?,
1335                    lock: decode_lock_reason(lock_reason_raw.as_deref())
1336                        .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
1337                    install_method: row.get(13)?,
1338                    source_archive_hash: row.get(14)?,
1339                    install_status: row.get(15)?,
1340                })
1341            })?
1342            .collect::<std::result::Result<Vec<_>, _>>()?;
1343
1344        Ok(mods)
1345    }
1346
1347    fn load_rules(&self, profile_id: i64) -> Result<SmallVec<[LoadOrderRule; 4]>> {
1348        let mut stmt = self.conn.prepare(
1349            "SELECT rule_type, mod_a, mod_b FROM load_order_rules WHERE profile_id = ?1",
1350        )?;
1351
1352        let rules = stmt
1353            .query_map(params![profile_id], |row| {
1354                let rule_type: String = row.get(0)?;
1355                let mod_a: String = row.get(1)?;
1356                let mod_b: String = row.get(2)?;
1357                Ok((rule_type, mod_a, mod_b))
1358            })?
1359            .collect::<std::result::Result<Vec<_>, _>>()?;
1360
1361        let mut result = SmallVec::with_capacity(rules.len());
1362        for (rule_type, mod_a, mod_b) in rules {
1363            let rule = match rule_type.as_str() {
1364                "load_after" => LoadOrderRule::LoadAfter {
1365                    mod_id: ModId::from(mod_a),
1366                    after: ModId::from(mod_b),
1367                },
1368                "load_before" => LoadOrderRule::LoadBefore {
1369                    mod_id: ModId::from(mod_a),
1370                    before: ModId::from(mod_b),
1371                },
1372                "incompatible" => LoadOrderRule::Incompatible {
1373                    mod_a: ModId::from(mod_a),
1374                    mod_b: ModId::from(mod_b),
1375                },
1376                other => {
1377                    tracing::warn!(rule_type = other, "unknown load order rule type, skipping");
1378                    continue;
1379                }
1380            };
1381            result.push(rule);
1382        }
1383
1384        Ok(result)
1385    }
1386
1387    fn assemble_profile(
1388        &self,
1389        id: i64,
1390        name: &str,
1391        game_id: &str,
1392        source_type: &str,
1393        source_data: Option<&str>,
1394        overrides: &str,
1395        load_order_lock_raw: Option<&str>,
1396    ) -> Result<Profile> {
1397        let source = decode_source(source_type, source_data)?;
1398        let mods = self.load_mods(id)?;
1399        let load_order_rules = self.load_rules(id)?;
1400        let load_order_lock = decode_lock(load_order_lock_raw)?;
1401
1402        Ok(Profile {
1403            id: Some(id),
1404            name: name.to_string(),
1405            game_id: GameId::from(game_id),
1406            source,
1407            mods,
1408            overrides: PathBuf::from(overrides),
1409            load_order_rules,
1410            load_order_lock,
1411        })
1412    }
1413}
1414
1415// ── Source encoding ──────────────────────────────────────────
1416
1417fn encode_source(source: &ProfileSource) -> (&'static str, Option<String>) {
1418    match source {
1419        ProfileSource::Manual => ("manual", None),
1420        ProfileSource::NexusCollection { slug, version } => {
1421            let data = format!("slug = {slug:?}\nversion = {version:?}");
1422            ("nexus_collection", Some(data))
1423        }
1424        ProfileSource::Wabbajack { manifest_hash } => {
1425            let data = format!("manifest_hash = {manifest_hash:?}");
1426            ("wabbajack", Some(data))
1427        }
1428    }
1429}
1430
1431fn decode_source(source_type: &str, source_data: Option<&str>) -> Result<ProfileSource> {
1432    match source_type {
1433        "manual" => Ok(ProfileSource::Manual),
1434        "nexus_collection" => {
1435            let data = source_data.unwrap_or_default();
1436            let table: toml::Table = toml::from_str(data).map_err(|e| {
1437                CoreError::Other(format!("failed to parse nexus_collection source data: {e}").into())
1438            })?;
1439            let slug = table
1440                .get("slug")
1441                .and_then(|v| v.as_str())
1442                .unwrap_or_default()
1443                .to_string();
1444            let version = table
1445                .get("version")
1446                .and_then(|v| v.as_str())
1447                .unwrap_or_default()
1448                .to_string();
1449            Ok(ProfileSource::NexusCollection { slug, version })
1450        }
1451        "wabbajack" => {
1452            let data = source_data.unwrap_or_default();
1453            let table: toml::Table = toml::from_str(data).map_err(|e| {
1454                CoreError::Other(format!("failed to parse wabbajack source data: {e}").into())
1455            })?;
1456            let manifest_hash = table
1457                .get("manifest_hash")
1458                .and_then(|v| v.as_str())
1459                .unwrap_or_default()
1460                .to_string();
1461            Ok(ProfileSource::Wabbajack { manifest_hash })
1462        }
1463        other => Err(CoreError::Other(format!(
1464            "unknown profile source type: {other}"
1465        ).into())),
1466    }
1467}
1468
1469// ── Load order lock encoding ─────────────────────────────────
1470//
1471// Lock state lives in TEXT columns (TOML-encoded) to match the existing
1472// `source_data` convention above. A NULL column means "no lock".
1473
1474fn encode_lock(lock: Option<&LoadOrderLock>) -> Option<String> {
1475    lock.map(|l| toml::to_string(l).expect("LoadOrderLock should always serialize"))
1476}
1477
1478fn decode_lock(raw: Option<&str>) -> Result<Option<LoadOrderLock>> {
1479    match raw {
1480        None => Ok(None),
1481        Some(s) if s.is_empty() => Ok(None),
1482        Some(s) => toml::from_str::<LoadOrderLock>(s)
1483            .map(Some)
1484            .map_err(|e| CoreError::Other(format!("failed to parse load_order_lock: {e}").into())),
1485    }
1486}
1487
1488fn encode_lock_reason(reason: Option<&LockReason>) -> Option<String> {
1489    reason.map(|r| toml::to_string(r).expect("LockReason should always serialize"))
1490}
1491
1492fn decode_lock_reason(raw: Option<&str>) -> Result<Option<LockReason>> {
1493    match raw {
1494        None => Ok(None),
1495        Some(s) if s.is_empty() => Ok(None),
1496        Some(s) => toml::from_str::<LockReason>(s)
1497            .map(Some)
1498            .map_err(|e| CoreError::Other(format!("failed to parse lock_reason: {e}").into())),
1499    }
1500}
1501
1502// ── Installer method encoding (V8) ───────────────────────────
1503//
1504// `InstallMethod` is a serde-friendly enum, so we TOML-encode it like
1505// the other metadata blobs stored on `profile_mods`. Decoding is
1506// surfaced via `crate::installer::InstallMethod`'s own deserialize
1507// — callers decode directly when they need a typed value.
1508
1509fn encode_install_method(method: &InstallMethod) -> Result<String> {
1510    toml::to_string(method)
1511        .map_err(|e| CoreError::Other(format!("failed to encode install_method: {e}").into()))
1512}
1513
1514/// Parse the TOML-encoded `install_method` column back into a typed
1515/// [`InstallMethod`]. Returns `None` for NULL / empty strings.
1516pub fn decode_install_method(raw: Option<&str>) -> Result<Option<InstallMethod>> {
1517    match raw {
1518        None => Ok(None),
1519        Some(s) if s.is_empty() => Ok(None),
1520        Some(s) => toml::from_str::<InstallMethod>(s)
1521            .map(Some)
1522            .map_err(|e| CoreError::Other(format!("failed to parse install_method: {e}").into())),
1523    }
1524}
1525
1526// ── Tool config types (re-exported from modde_games::tools) ──────────
1527
1528/// Per-game tool configuration stored in the database.
1529///
1530/// This is a DB-layer representation; the full `ToolConfig` with
1531/// `serde_json::Value` settings lives in `modde_games::tools`.
1532#[derive(Debug, Clone)]
1533pub struct ToolConfigRow {
1534    pub tool_id: String,
1535    pub enabled: bool,
1536    pub settings_json: String,
1537}
1538
1539/// A file applied by a tool to a game directory.
1540#[derive(Debug, Clone)]
1541pub struct ToolAppliedFileRow {
1542    pub tool_id: String,
1543    pub rel_path: String,
1544}
1545
1546impl ModdeDb {
1547    // ── Game Tool CRUD ────────────────────────────────────────────
1548
1549    /// Save (insert or update) a tool configuration for a game.
1550    pub fn save_tool_config(
1551        &self,
1552        game_id: &str,
1553        tool_id: &str,
1554        enabled: bool,
1555        settings_json: &str,
1556    ) -> Result<()> {
1557        self.conn.execute(
1558            "INSERT INTO game_tools (game_id, tool_id, enabled, settings, updated_at)
1559             VALUES (?1, ?2, ?3, ?4, datetime('now'))
1560             ON CONFLICT(game_id, tool_id) DO UPDATE SET
1561                 enabled = excluded.enabled,
1562                 settings = excluded.settings,
1563                 updated_at = excluded.updated_at",
1564            params![game_id, tool_id, enabled as i32, settings_json],
1565        )?;
1566        Ok(())
1567    }
1568
1569    /// Load all tool configurations for a game.
1570    pub fn load_tool_configs(&self, game_id: &str) -> Result<Vec<ToolConfigRow>> {
1571        let mut stmt = self.conn.prepare(
1572            "SELECT tool_id, enabled, settings FROM game_tools WHERE game_id = ?1",
1573        )?;
1574
1575        let rows = stmt
1576            .query_map(params![game_id], |row| {
1577                Ok(ToolConfigRow {
1578                    tool_id: row.get(0)?,
1579                    enabled: row.get::<_, i32>(1)? != 0,
1580                    settings_json: row.get(2)?,
1581                })
1582            })?
1583            .collect::<std::result::Result<Vec<_>, _>>()?;
1584
1585        Ok(rows)
1586    }
1587
1588    /// Load a single tool configuration for a game.
1589    pub fn load_tool_config(
1590        &self,
1591        game_id: &str,
1592        tool_id: &str,
1593    ) -> Result<Option<ToolConfigRow>> {
1594        let result = self.conn.query_row(
1595            "SELECT tool_id, enabled, settings FROM game_tools
1596             WHERE game_id = ?1 AND tool_id = ?2",
1597            params![game_id, tool_id],
1598            |row| {
1599                Ok(ToolConfigRow {
1600                    tool_id: row.get(0)?,
1601                    enabled: row.get::<_, i32>(1)? != 0,
1602                    settings_json: row.get(2)?,
1603                })
1604            },
1605        );
1606
1607        match result {
1608            Ok(row) => Ok(Some(row)),
1609            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1610            Err(e) => Err(e.into()),
1611        }
1612    }
1613
1614    /// Record files applied by a tool to a game directory.
1615    pub fn save_applied_files(
1616        &self,
1617        game_id: &str,
1618        tool_id: &str,
1619        rel_paths: &[String],
1620    ) -> Result<()> {
1621        let mut stmt = self.conn.prepare(
1622            "INSERT OR IGNORE INTO tool_applied_files (game_id, tool_id, rel_path)
1623             VALUES (?1, ?2, ?3)",
1624        )?;
1625
1626        for path in rel_paths {
1627            stmt.execute(params![game_id, tool_id, path])?;
1628        }
1629
1630        Ok(())
1631    }
1632
1633    /// Load files previously applied by a tool.
1634    pub fn load_applied_files(
1635        &self,
1636        game_id: &str,
1637        tool_id: &str,
1638    ) -> Result<Vec<String>> {
1639        let mut stmt = self.conn.prepare(
1640            "SELECT rel_path FROM tool_applied_files
1641             WHERE game_id = ?1 AND tool_id = ?2",
1642        )?;
1643
1644        let rows = stmt
1645            .query_map(params![game_id, tool_id], |row| row.get(0))?
1646            .collect::<std::result::Result<Vec<String>, _>>()?;
1647
1648        Ok(rows)
1649    }
1650
1651    /// Clear all applied file records for a tool on a game.
1652    pub fn clear_applied_files(&self, game_id: &str, tool_id: &str) -> Result<()> {
1653        self.conn.execute(
1654            "DELETE FROM tool_applied_files WHERE game_id = ?1 AND tool_id = ?2",
1655            params![game_id, tool_id],
1656        )?;
1657        Ok(())
1658    }
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663    use super::*;
1664
1665    fn test_db() -> ModdeDb {
1666        ModdeDb::open_memory().unwrap()
1667    }
1668
1669    fn sample_profile(name: &str, game_id: &str) -> Profile {
1670        Profile {
1671            id: None,
1672            name: name.to_string(),
1673            game_id: GameId::from(game_id),
1674            source: ProfileSource::Manual,
1675            mods: vec![
1676                EnabledMod {
1677                    mod_id: "mod_a".to_string(),
1678                    enabled: true,
1679                    version: Some("1.0".to_string()),
1680                    fomod_config: None, ..Default::default()
1681                },
1682                EnabledMod {
1683                    mod_id: "mod_b".to_string(),
1684                    enabled: false,
1685                    version: None,
1686                    fomod_config: None, ..Default::default()
1687                },
1688            ],
1689            overrides: PathBuf::from("/tmp/overrides"),
1690            load_order_rules: smallvec::smallvec![LoadOrderRule::LoadAfter {
1691                mod_id: ModId::from("mod_b"),
1692                after: ModId::from("mod_a"),
1693            }],
1694            load_order_lock: None,
1695        }
1696    }
1697
1698    #[test]
1699    fn create_and_load_profile() {
1700        let db = test_db();
1701        let profile = sample_profile("test", "skyrim-se");
1702
1703        let id = db.create_profile(&profile).unwrap();
1704        assert!(id > 0);
1705
1706        let loaded = db.load_profile("test", "skyrim-se").unwrap();
1707        assert_eq!(loaded.name, "test");
1708        assert_eq!(loaded.game_id, "skyrim-se");
1709        assert_eq!(loaded.mods.len(), 2);
1710        assert_eq!(loaded.mods[0].mod_id, "mod_a");
1711        assert!(loaded.mods[0].enabled);
1712        assert_eq!(loaded.mods[1].mod_id, "mod_b");
1713        assert!(!loaded.mods[1].enabled);
1714        assert_eq!(loaded.load_order_rules.len(), 1);
1715    }
1716
1717    #[test]
1718    fn load_by_name_unique() {
1719        let db = test_db();
1720        let profile = sample_profile("default", "skyrim-se");
1721        db.create_profile(&profile).unwrap();
1722
1723        let loaded = db.load_profile_by_name("default").unwrap();
1724        assert_eq!(loaded.game_id, "skyrim-se");
1725    }
1726
1727    #[test]
1728    fn load_by_name_ambiguous() {
1729        let db = test_db();
1730        db.create_profile(&sample_profile("default", "skyrim-se")).unwrap();
1731        db.create_profile(&sample_profile("default", "fallout4")).unwrap();
1732
1733        let err = db.load_profile_by_name("default").unwrap_err();
1734        match err {
1735            CoreError::AmbiguousProfile { name, games } => {
1736                assert_eq!(name, "default");
1737                assert!(games.contains(&GameId::from("skyrim-se")));
1738                assert!(games.contains(&GameId::from("fallout4")));
1739            }
1740            other => panic!("expected AmbiguousProfile, got: {other}"),
1741        }
1742    }
1743
1744    #[test]
1745    fn multi_profile_per_game() {
1746        let db = test_db();
1747        db.create_profile(&sample_profile("vanilla", "skyrim-se")).unwrap();
1748        db.create_profile(&sample_profile("modded", "skyrim-se")).unwrap();
1749        db.create_profile(&sample_profile("hardcore", "skyrim-se")).unwrap();
1750
1751        let profiles = db.list_profiles(Some("skyrim-se")).unwrap();
1752        assert_eq!(profiles.len(), 3);
1753    }
1754
1755    #[test]
1756    fn update_profile() {
1757        let db = test_db();
1758        let mut profile = sample_profile("test", "skyrim-se");
1759        db.create_profile(&profile).unwrap();
1760
1761        profile.mods.push(EnabledMod {
1762            mod_id: "mod_c".to_string(),
1763            enabled: true,
1764            version: None,
1765            fomod_config: None, ..Default::default()
1766        });
1767
1768        db.update_profile(&profile).unwrap();
1769
1770        let loaded = db.load_profile("test", "skyrim-se").unwrap();
1771        assert_eq!(loaded.mods.len(), 3);
1772    }
1773
1774    #[test]
1775    fn delete_profile() {
1776        let db = test_db();
1777        db.create_profile(&sample_profile("test", "skyrim-se")).unwrap();
1778        db.delete_profile("test", "skyrim-se").unwrap();
1779
1780        let err = db.load_profile("test", "skyrim-se").unwrap_err();
1781        assert!(matches!(err, CoreError::ProfileNotFound(_)));
1782    }
1783
1784    #[test]
1785    fn delete_cascades_to_mods_and_saves() {
1786        let db = test_db();
1787        let id = db.create_profile(&sample_profile("test", "skyrim-se")).unwrap();
1788        db.assign_save(id, Path::new("/saves/save1.ess"), Some("my save")).unwrap();
1789
1790        let saves = db.list_saves(id).unwrap();
1791        assert_eq!(saves.len(), 1);
1792
1793        db.delete_profile("test", "skyrim-se").unwrap();
1794
1795        // Saves and mods should be cascade-deleted
1796        let saves = db.list_saves(id).unwrap();
1797        assert_eq!(saves.len(), 0);
1798    }
1799
1800    #[test]
1801    fn save_assignment() {
1802        let db = test_db();
1803        let id = db.create_profile(&sample_profile("test", "skyrim-se")).unwrap();
1804
1805        db.assign_save(id, Path::new("/saves/save1.ess"), Some("Level 50")).unwrap();
1806        db.assign_save(id, Path::new("/saves/save2.ess"), None).unwrap();
1807
1808        let saves = db.list_saves(id).unwrap();
1809        assert_eq!(saves.len(), 2);
1810        assert_eq!(saves[0].label.as_deref(), Some("Level 50"));
1811        assert!(saves[1].label.is_none());
1812
1813        db.unassign_save(Path::new("/saves/save1.ess")).unwrap();
1814        let saves = db.list_saves(id).unwrap();
1815        assert_eq!(saves.len(), 1);
1816    }
1817
1818    #[test]
1819    fn save_already_assigned_to_different_profile() {
1820        let db = test_db();
1821        let id1 = db.create_profile(&sample_profile("profile1", "skyrim-se")).unwrap();
1822        let id2 = db.create_profile(&sample_profile("profile2", "skyrim-se")).unwrap();
1823
1824        db.assign_save(id1, Path::new("/saves/save1.ess"), None).unwrap();
1825
1826        let err = db.assign_save(id2, Path::new("/saves/save1.ess"), None).unwrap_err();
1827        assert!(matches!(err, CoreError::SaveAlreadyAssigned { .. }));
1828    }
1829
1830    #[test]
1831    fn snapshot_upsert_and_get() {
1832        let db = test_db();
1833
1834        db.upsert_snapshot("skyrim-se", Path::new("/stock/skyrim-se"), "abc123", 5000).unwrap();
1835        let meta = db.get_snapshot("skyrim-se").unwrap().unwrap();
1836        assert_eq!(meta.tree_hash, "abc123");
1837        assert_eq!(meta.file_count, 5000);
1838
1839        // Upsert updates
1840        db.upsert_snapshot("skyrim-se", Path::new("/stock/skyrim-se"), "def456", 5001).unwrap();
1841        let meta = db.get_snapshot("skyrim-se").unwrap().unwrap();
1842        assert_eq!(meta.tree_hash, "def456");
1843        assert_eq!(meta.file_count, 5001);
1844    }
1845
1846    #[test]
1847    fn snapshot_not_found() {
1848        let db = test_db();
1849        assert!(db.get_snapshot("nonexistent").unwrap().is_none());
1850    }
1851
1852    #[test]
1853    fn list_profiles_all_and_by_game() {
1854        let db = test_db();
1855        db.create_profile(&sample_profile("vanilla", "skyrim-se")).unwrap();
1856        db.create_profile(&sample_profile("modded", "skyrim-se")).unwrap();
1857        db.create_profile(&sample_profile("default", "fallout4")).unwrap();
1858
1859        let all = db.list_profiles(None).unwrap();
1860        assert_eq!(all.len(), 3);
1861
1862        let skyrim = db.list_profiles(Some("skyrim-se")).unwrap();
1863        assert_eq!(skyrim.len(), 2);
1864
1865        let fallout = db.list_profiles(Some("fallout4")).unwrap();
1866        assert_eq!(fallout.len(), 1);
1867    }
1868
1869    #[test]
1870    fn source_roundtrip_nexus_collection() {
1871        let db = test_db();
1872        let mut profile = sample_profile("test", "skyrim-se");
1873        profile.source = ProfileSource::NexusCollection {
1874            slug: "my-collection".to_string(),
1875            version: "1.2.3".to_string(),
1876        };
1877
1878        db.create_profile(&profile).unwrap();
1879        let loaded = db.load_profile("test", "skyrim-se").unwrap();
1880
1881        match loaded.source {
1882            ProfileSource::NexusCollection { slug, version } => {
1883                assert_eq!(slug, "my-collection");
1884                assert_eq!(version, "1.2.3");
1885            }
1886            other => panic!("expected NexusCollection, got: {other:?}"),
1887        }
1888    }
1889
1890    #[test]
1891    fn source_roundtrip_wabbajack() {
1892        let db = test_db();
1893        let mut profile = sample_profile("test", "skyrim-se");
1894        profile.source = ProfileSource::Wabbajack {
1895            manifest_hash: "deadbeef".to_string(),
1896        };
1897
1898        db.create_profile(&profile).unwrap();
1899        let loaded = db.load_profile("test", "skyrim-se").unwrap();
1900
1901        match loaded.source {
1902            ProfileSource::Wabbajack { manifest_hash } => {
1903                assert_eq!(manifest_hash, "deadbeef");
1904            }
1905            other => panic!("expected Wabbajack, got: {other:?}"),
1906        }
1907    }
1908
1909    #[test]
1910    fn profile_not_found() {
1911        let db = test_db();
1912        let err = db.load_profile("nonexistent", "skyrim-se").unwrap_err();
1913        assert!(matches!(err, CoreError::ProfileNotFound(_)));
1914    }
1915
1916    #[test]
1917    fn duplicate_profile_errors() {
1918        let db = test_db();
1919        db.create_profile(&sample_profile("test", "skyrim-se")).unwrap();
1920
1921        let err = db.create_profile(&sample_profile("test", "skyrim-se")).unwrap_err();
1922        assert!(matches!(err, CoreError::Database(_)));
1923    }
1924}