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::nexus_id::{NexusFileId, NexusIdError, NexusModId};
10use crate::profile::{EnabledMod, LoadOrderLock, LockReason, Profile, ProfileSource};
11use crate::resolver::{GameId, LoadOrderRule, ModId};
12
13const CURRENT_SCHEMA_VERSION: u32 = 10;
14
15const SCHEMA_V1: &str = "
16PRAGMA journal_mode = WAL;
17PRAGMA foreign_keys = ON;
18
19CREATE TABLE IF NOT EXISTS profiles (
20    id          INTEGER PRIMARY KEY AUTOINCREMENT,
21    name        TEXT NOT NULL,
22    game_id     TEXT NOT NULL,
23    source_type TEXT NOT NULL DEFAULT 'manual',
24    source_data TEXT,
25    overrides   TEXT NOT NULL,
26    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
27    updated_at  TEXT NOT NULL DEFAULT (datetime('now')),
28    UNIQUE(name, game_id)
29);
30
31CREATE TABLE IF NOT EXISTS profile_mods (
32    id          INTEGER PRIMARY KEY AUTOINCREMENT,
33    profile_id  INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
34    mod_id      TEXT NOT NULL,
35    enabled     INTEGER NOT NULL DEFAULT 1,
36    version     TEXT,
37    fomod_config TEXT,
38    sort_index  INTEGER NOT NULL,
39    UNIQUE(profile_id, mod_id)
40);
41
42CREATE TABLE IF NOT EXISTS load_order_rules (
43    id          INTEGER PRIMARY KEY AUTOINCREMENT,
44    profile_id  INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
45    rule_type   TEXT NOT NULL,
46    mod_a       TEXT NOT NULL,
47    mod_b       TEXT NOT NULL
48);
49
50CREATE TABLE IF NOT EXISTS saves (
51    id          INTEGER PRIMARY KEY AUTOINCREMENT,
52    profile_id  INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
53    path        TEXT NOT NULL UNIQUE,
54    label       TEXT,
55    assigned_at TEXT NOT NULL DEFAULT (datetime('now'))
56);
57
58CREATE TABLE IF NOT EXISTS stock_snapshots (
59    id          INTEGER PRIMARY KEY AUTOINCREMENT,
60    game_id     TEXT NOT NULL UNIQUE,
61    snapshot_path TEXT NOT NULL,
62    tree_hash   TEXT NOT NULL,
63    file_count  INTEGER NOT NULL,
64    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
65);
66
67CREATE TABLE IF NOT EXISTS active_profiles (
68    game_id     TEXT PRIMARY KEY,
69    profile_id  INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
70    activated_at TEXT NOT NULL DEFAULT (datetime('now'))
71);
72
73CREATE TABLE IF NOT EXISTS experiment_stack (
74    id          INTEGER PRIMARY KEY AUTOINCREMENT,
75    game_id     TEXT NOT NULL,
76    profile_id  INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
77    depth       INTEGER NOT NULL,
78    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
79);
80
81CREATE INDEX IF NOT EXISTS idx_profiles_game ON profiles(game_id);
82CREATE INDEX IF NOT EXISTS idx_mods_profile ON profile_mods(profile_id);
83CREATE INDEX IF NOT EXISTS idx_rules_profile ON load_order_rules(profile_id);
84CREATE INDEX IF NOT EXISTS idx_saves_profile ON saves(profile_id);
85CREATE INDEX IF NOT EXISTS idx_experiment_game ON experiment_stack(game_id, depth);
86";
87
88const SCHEMA_V2: &str = "
89-- Per-file hiding (MO2-style .mohidden equivalent)
90CREATE TABLE IF NOT EXISTS hidden_files (
91    id         INTEGER PRIMARY KEY AUTOINCREMENT,
92    profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
93    mod_id     TEXT NOT NULL,
94    rel_path   TEXT NOT NULL,
95    hidden_at  TEXT NOT NULL DEFAULT (datetime('now')),
96    UNIQUE(profile_id, mod_id, rel_path)
97);
98
99-- Independent plugin ordering (separate from mod install priority)
100CREATE TABLE IF NOT EXISTS plugin_order (
101    id          INTEGER PRIMARY KEY AUTOINCREMENT,
102    profile_id  INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
103    plugin_name TEXT NOT NULL,
104    sort_index  INTEGER NOT NULL,
105    enabled     INTEGER NOT NULL DEFAULT 1,
106    UNIQUE(profile_id, plugin_name)
107);
108
109-- Mod categories with collapsible separators
110CREATE TABLE IF NOT EXISTS mod_categories (
111    id         INTEGER PRIMARY KEY AUTOINCREMENT,
112    profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
113    name       TEXT NOT NULL,
114    color      TEXT,
115    sort_index INTEGER NOT NULL,
116    UNIQUE(profile_id, name)
117);
118
119CREATE INDEX IF NOT EXISTS idx_hidden_profile ON hidden_files(profile_id);
120CREATE INDEX IF NOT EXISTS idx_plugin_order_profile ON plugin_order(profile_id);
121CREATE INDEX IF NOT EXISTS idx_categories_profile ON mod_categories(profile_id);
122";
123
124const SCHEMA_V3: &str = "
125-- Per-game tool/overlay configurations (MangoHud, vkBasalt, GameMode, etc.)
126CREATE TABLE IF NOT EXISTS game_tools (
127    id          INTEGER PRIMARY KEY AUTOINCREMENT,
128    game_id     TEXT NOT NULL,
129    tool_id     TEXT NOT NULL,
130    enabled     INTEGER NOT NULL DEFAULT 0,
131    settings    TEXT NOT NULL DEFAULT '{}',
132    updated_at  TEXT NOT NULL DEFAULT (datetime('now')),
133    UNIQUE(game_id, tool_id)
134);
135
136-- Files applied by tools to game directories (for revert tracking)
137CREATE TABLE IF NOT EXISTS tool_applied_files (
138    id          INTEGER PRIMARY KEY AUTOINCREMENT,
139    game_id     TEXT NOT NULL,
140    tool_id     TEXT NOT NULL,
141    rel_path    TEXT NOT NULL,
142    applied_at  TEXT NOT NULL DEFAULT (datetime('now')),
143    UNIQUE(game_id, tool_id, rel_path)
144);
145
146CREATE INDEX IF NOT EXISTS idx_game_tools_game ON game_tools(game_id);
147CREATE INDEX IF NOT EXISTS idx_tool_files_game ON tool_applied_files(game_id, tool_id);
148";
149
150// Schema V8 adds the installer pipeline's state:
151//
152// * Three new columns on `profile_mods` capturing how a mod was installed:
153//   `install_method` (TOML-serialized `InstallMethod`), `source_archive_hash`
154//   (xxh64 of the downloaded archive), and `install_status` (one of
155//   `installed | unknown | pending_user_input | failed`).
156//
157// * A new `installed_mod_files` table that records the concrete file
158//   manifest for every installed mod so uninstall can remove exactly the
159//   files it staged — no orphaned files, no collateral damage. The
160//   `merge_group` column is reserved for the future script-merge feature;
161//   it is written but not read by current code.
162const SCHEMA_V8: &str = "
163CREATE TABLE IF NOT EXISTS installed_mod_files (
164    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
165    profile_id          INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
166    mod_id              TEXT NOT NULL,
167    rel_path            TEXT NOT NULL,
168    origin_rel_path     TEXT NOT NULL,
169    size                INTEGER NOT NULL,
170    merge_group         TEXT,
171    UNIQUE(profile_id, mod_id, rel_path)
172);
173
174CREATE INDEX IF NOT EXISTS idx_imf_profile_mod ON installed_mod_files(profile_id, mod_id);
175CREATE INDEX IF NOT EXISTS idx_imf_merge_group ON installed_mod_files(merge_group)
176    WHERE merge_group IS NOT NULL;
177";
178
179// Schema V9 adds MO2-style executable definitions. These are distinct
180// from `game_tools`: tools model optional overlays/config patchers, while
181// executables model named launch targets such as xEdit, BodySlide, Nemesis,
182// or a game-specific helper with persistent args/environment/output routing.
183const SCHEMA_V9: &str = "
184CREATE TABLE IF NOT EXISTS executable_configs (
185    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
186    game_id             TEXT NOT NULL,
187    name                TEXT NOT NULL,
188    executable_path     TEXT NOT NULL,
189    arguments           TEXT NOT NULL DEFAULT '[]',
190    working_dir         TEXT,
191    environment         TEXT NOT NULL DEFAULT '{}',
192    wine_dll_overrides  TEXT,
193    output_mod          TEXT NOT NULL DEFAULT '__overwrite__',
194    enabled             INTEGER NOT NULL DEFAULT 1,
195    updated_at          TEXT NOT NULL DEFAULT (datetime('now')),
196    UNIQUE(game_id, name)
197);
198
199CREATE INDEX IF NOT EXISTS idx_executable_configs_game ON executable_configs(game_id);
200";
201
202// Schema V10 adds a generalized DAG of game tool settings. `game_tools`
203// remains the current-state table; each mutation also appends a node and an
204// edge from the previous current node, allowing restore operations to branch.
205const SCHEMA_V10: &str = "
206CREATE TABLE IF NOT EXISTS tool_setting_nodes (
207    id          INTEGER PRIMARY KEY AUTOINCREMENT,
208    node_id     TEXT NOT NULL UNIQUE,
209    game_id     TEXT NOT NULL,
210    tool_id     TEXT NOT NULL,
211    enabled     INTEGER NOT NULL,
212    settings    TEXT NOT NULL,
213    reason      TEXT NOT NULL,
214    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
215);
216
217CREATE TABLE IF NOT EXISTS tool_setting_edges (
218    id              INTEGER PRIMARY KEY AUTOINCREMENT,
219    parent_node_id  TEXT NOT NULL,
220    child_node_id   TEXT NOT NULL,
221    created_at      TEXT NOT NULL DEFAULT (datetime('now')),
222    UNIQUE(parent_node_id, child_node_id)
223);
224
225CREATE INDEX IF NOT EXISTS idx_tool_setting_nodes_tool
226    ON tool_setting_nodes(game_id, tool_id, created_at);
227CREATE INDEX IF NOT EXISTS idx_tool_setting_edges_child
228    ON tool_setting_edges(child_node_id);
229";
230
231/// Summary view of a profile (without loading all mods).
232#[derive(Debug, Clone, PartialEq)]
233pub struct ProfileSummary {
234    pub id: i64,
235    pub name: String,
236    pub game_id: GameId,
237    pub mod_count: usize,
238    pub source_type: String,
239}
240
241/// A save file/directory assigned to a profile.
242#[derive(Debug, Clone)]
243pub struct SaveEntry {
244    pub path: PathBuf,
245    pub label: Option<String>,
246    pub assigned_at: String,
247}
248
249/// Metadata for a stock game snapshot stored in the database.
250#[derive(Debug, Clone)]
251pub struct SnapshotMeta {
252    pub game_id: GameId,
253    pub snapshot_path: PathBuf,
254    pub tree_hash: String,
255    pub file_count: usize,
256    pub created_at: String,
257}
258
259/// A hidden file entry — prevents a specific file from a mod from being deployed.
260#[derive(Debug, Clone)]
261pub struct HiddenFile {
262    pub mod_id: String,
263    pub rel_path: String,
264}
265
266/// A plugin entry in the plugin load order (independent of mod install priority).
267#[derive(Debug, Clone)]
268pub struct PluginEntry {
269    pub plugin_name: String,
270    pub sort_index: i64,
271    pub enabled: bool,
272}
273
274/// A mod category for organizing the mod list.
275#[derive(Debug, Clone)]
276pub struct ModCategory {
277    pub id: Option<i64>,
278    pub name: String,
279    pub color: Option<String>,
280    pub sort_index: i64,
281}
282
283/// SQLite-backed persistent storage for modde.
284pub struct ModdeDb {
285    conn: Connection,
286}
287
288impl ModdeDb {
289    /// Open the database at the default XDG path, creating it if needed.
290    pub fn open() -> Result<Self> {
291        let path = crate::paths::db_path();
292        Self::open_at(&path)
293    }
294
295    /// Open the database at a specific path (useful for testing).
296    pub fn open_at(path: &Path) -> Result<Self> {
297        if let Some(parent) = path.parent() {
298            std::fs::create_dir_all(parent)?;
299        }
300        let conn = Connection::open(path)?;
301        let db = Self { conn };
302        db.migrate()?;
303        Ok(db)
304    }
305
306    /// Open an in-memory database (for testing).
307    pub fn open_memory() -> Result<Self> {
308        let conn = Connection::open_in_memory()?;
309        let db = Self { conn };
310        db.migrate()?;
311        Ok(db)
312    }
313
314    fn migrate(&self) -> Result<()> {
315        let version: u32 = self
316            .conn
317            .pragma_query_value(None, "user_version", |row| row.get(0))?;
318
319        if version < 1 {
320            self.conn.execute_batch(SCHEMA_V1)?;
321            info!(from = version, to = 1, "database schema migrated to V1");
322        }
323
324        if version < 2 {
325            self.conn.execute_batch(SCHEMA_V2)?;
326            self.add_column_if_missing("profile_mods", "nexus_mod_id", "INTEGER")?;
327            self.add_column_if_missing("profile_mods", "nexus_file_id", "INTEGER")?;
328            self.add_column_if_missing("profile_mods", "nexus_game_domain", "TEXT")?;
329            self.add_column_if_missing("profile_mods", "installed_timestamp", "INTEGER")?;
330            self.add_column_if_missing(
331                "profile_mods",
332                "category_id",
333                "INTEGER REFERENCES mod_categories(id)",
334            )?;
335            self.add_column_if_missing("profile_mods", "notes", "TEXT")?;
336            self.add_column_if_missing("profile_mods", "tags", "TEXT")?;
337            info!(
338                from = version.max(1),
339                to = 2,
340                "database schema migrated to V2"
341            );
342        }
343
344        if version < 3 {
345            self.conn.execute_batch(SCHEMA_V3)?;
346            info!(
347                from = version.max(2),
348                to = 3,
349                "database schema migrated to V3"
350            );
351        }
352
353        if version < 6 {
354            // Add display_name column if it doesn't already exist.
355            let has_display_name = self
356                .conn
357                .prepare("SELECT display_name FROM profile_mods LIMIT 0")
358                .is_ok();
359            if !has_display_name {
360                self.conn
361                    .execute_batch("ALTER TABLE profile_mods ADD COLUMN display_name TEXT;")?;
362            }
363            info!(
364                from = version.max(5),
365                to = 6,
366                "database schema migrated to V6"
367            );
368        }
369
370        if version < 7 {
371            // Load order lock (V7): profile-level + per-mod locks. See
372            // `crates/modde-core/src/profile/mod.rs` for `LoadOrderLock` /
373            // `LockReason`. Columns are TOML-encoded to match the existing
374            // `source_data` convention.
375            let has_load_order_lock = self
376                .conn
377                .prepare("SELECT load_order_lock FROM profiles LIMIT 0")
378                .is_ok();
379            if !has_load_order_lock {
380                self.conn
381                    .execute_batch("ALTER TABLE profiles ADD COLUMN load_order_lock TEXT;")?;
382            }
383            let has_lock_reason = self
384                .conn
385                .prepare("SELECT lock_reason FROM profile_mods LIMIT 0")
386                .is_ok();
387            if !has_lock_reason {
388                self.conn
389                    .execute_batch("ALTER TABLE profile_mods ADD COLUMN lock_reason TEXT;")?;
390            }
391            info!(
392                from = version.max(6),
393                to = 7,
394                "database schema migrated to V7"
395            );
396        }
397
398        if version < 8 {
399            // Installer pipeline (V8): per-mod install method + file
400            // manifest. See `crates/modde-core/src/installer/` for the
401            // pipeline that produces these values.
402            let has_install_method = self
403                .conn
404                .prepare("SELECT install_method FROM profile_mods LIMIT 0")
405                .is_ok();
406            if !has_install_method {
407                self.conn
408                    .execute_batch("ALTER TABLE profile_mods ADD COLUMN install_method TEXT;")?;
409            }
410            let has_source_archive_hash = self
411                .conn
412                .prepare("SELECT source_archive_hash FROM profile_mods LIMIT 0")
413                .is_ok();
414            if !has_source_archive_hash {
415                self.conn.execute_batch(
416                    "ALTER TABLE profile_mods ADD COLUMN source_archive_hash TEXT;",
417                )?;
418            }
419            let has_install_status = self
420                .conn
421                .prepare("SELECT install_status FROM profile_mods LIMIT 0")
422                .is_ok();
423            if !has_install_status {
424                self.conn
425                    .execute_batch("ALTER TABLE profile_mods ADD COLUMN install_status TEXT;")?;
426            }
427            self.conn.execute_batch(SCHEMA_V8)?;
428            info!(
429                from = version.max(7),
430                to = 8,
431                "database schema migrated to V8"
432            );
433        }
434
435        if version < 9 {
436            self.conn.execute_batch(SCHEMA_V9)?;
437            info!(
438                from = version.max(8),
439                to = 9,
440                "database schema migrated to V9"
441            );
442        }
443
444        if version < 10 {
445            self.conn.execute_batch(SCHEMA_V10)?;
446            self.add_column_if_missing("game_tools", "current_node_id", "TEXT")?;
447            info!(
448                from = version.max(9),
449                to = 10,
450                "database schema migrated to V10"
451            );
452        }
453
454        if version < CURRENT_SCHEMA_VERSION {
455            self.conn
456                .pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)?;
457        }
458
459        // Ensure WAL and FK are always on (they reset per-connection).
460        self.conn
461            .execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")?;
462
463        Ok(())
464    }
465
466    fn add_column_if_missing(&self, table: &str, column: &str, definition: &str) -> Result<()> {
467        if self.column_exists(table, column)? {
468            return Ok(());
469        }
470
471        self.conn.execute_batch(&format!(
472            "ALTER TABLE {table} ADD COLUMN {column} {definition};"
473        ))?;
474        Ok(())
475    }
476
477    fn column_exists(&self, table: &str, column: &str) -> Result<bool> {
478        let mut stmt = self.conn.prepare(&format!("PRAGMA table_info({table})"))?;
479        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
480        for name in rows {
481            if name? == column {
482                return Ok(true);
483            }
484        }
485        Ok(false)
486    }
487
488    // ── Profile CRUD ──────────────────────────────────────────────
489
490    /// Create a new profile, returning its database ID.
491    pub fn create_profile(&self, profile: &Profile) -> Result<i64> {
492        let (source_type, source_data) = encode_source(&profile.source);
493        let load_order_lock = encode_lock(profile.load_order_lock.as_ref());
494
495        self.conn.execute(
496            "INSERT INTO profiles (name, game_id, source_type, source_data, overrides, load_order_lock)
497             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
498            params![
499                profile.name,
500                profile.game_id,
501                source_type,
502                source_data,
503                profile.overrides.to_string_lossy().as_ref(),
504                load_order_lock,
505            ],
506        )?;
507
508        let profile_id = self.conn.last_insert_rowid();
509
510        self.insert_mods(profile_id, &profile.mods)?;
511        self.insert_rules(profile_id, &profile.load_order_rules)?;
512
513        Ok(profile_id)
514    }
515
516    /// Load a profile by name and `game_id`.
517    pub fn load_profile(&self, name: &str, game_id: &GameId) -> Result<Profile> {
518        let (id, source_type, source_data, overrides, load_order_lock) = self
519            .conn
520            .query_row(
521                "SELECT id, source_type, source_data, overrides, load_order_lock FROM profiles
522                 WHERE name = ?1 AND game_id = ?2",
523                params![name, game_id],
524                |row| {
525                    Ok((
526                        row.get::<_, i64>(0)?,
527                        row.get::<_, String>(1)?,
528                        row.get::<_, Option<String>>(2)?,
529                        row.get::<_, String>(3)?,
530                        row.get::<_, Option<String>>(4)?,
531                    ))
532                },
533            )
534            .map_err(|e| match e {
535                rusqlite::Error::QueryReturnedNoRows => {
536                    CoreError::ProfileNotFound(format!("{name} (game: {game_id})"))
537                }
538                other => CoreError::Database(other),
539            })?;
540
541        self.assemble_profile(
542            id,
543            name,
544            game_id,
545            &source_type,
546            source_data.as_deref(),
547            &overrides,
548            load_order_lock.as_deref(),
549        )
550    }
551
552    /// Load a profile by its database ID.
553    pub fn load_profile_by_id(&self, id: i64) -> Result<Profile> {
554        let (name, game_id, source_type, source_data, overrides, load_order_lock) = self
555            .conn
556            .query_row(
557                "SELECT name, game_id, source_type, source_data, overrides, load_order_lock
558                 FROM profiles WHERE id = ?1",
559                params![id],
560                |row| {
561                    Ok((
562                        row.get::<_, String>(0)?,
563                        row.get::<_, String>(1)?,
564                        row.get::<_, String>(2)?,
565                        row.get::<_, Option<String>>(3)?,
566                        row.get::<_, String>(4)?,
567                        row.get::<_, Option<String>>(5)?,
568                    ))
569                },
570            )
571            .map_err(|e| match e {
572                rusqlite::Error::QueryReturnedNoRows => {
573                    CoreError::ProfileNotFound(format!("id={id}"))
574                }
575                other => CoreError::Database(other),
576            })?;
577
578        self.assemble_profile(
579            id,
580            &name,
581            &GameId::from(game_id),
582            &source_type,
583            source_data.as_deref(),
584            &overrides,
585            load_order_lock.as_deref(),
586        )
587    }
588
589    /// Load a profile by name only. Errors with `AmbiguousProfile` if multiple games match.
590    pub fn load_profile_by_name(&self, name: &str) -> Result<Profile> {
591        let mut stmt = self.conn.prepare(
592            "SELECT id, game_id, source_type, source_data, overrides, load_order_lock
593             FROM profiles WHERE name = ?1",
594        )?;
595
596        let rows: Vec<(i64, String, String, Option<String>, String, Option<String>)> = stmt
597            .query_map(params![name], |row| {
598                Ok((
599                    row.get(0)?,
600                    row.get(1)?,
601                    row.get(2)?,
602                    row.get(3)?,
603                    row.get(4)?,
604                    row.get(5)?,
605                ))
606            })?
607            .collect::<std::result::Result<Vec<_>, _>>()?;
608
609        match rows.len() {
610            0 => Err(CoreError::ProfileNotFound(name.to_string())),
611            1 => {
612                let (id, game_id, source_type, source_data, overrides, load_order_lock) = &rows[0];
613                self.assemble_profile(
614                    *id,
615                    name,
616                    &GameId::from(game_id.clone()),
617                    source_type,
618                    source_data.as_deref(),
619                    overrides,
620                    load_order_lock.as_deref(),
621                )
622            }
623            _ => {
624                let games: SmallVec<[GameId; 4]> = rows
625                    .iter()
626                    .map(|(_, g, _, _, _, _)| GameId::from(g.clone()))
627                    .collect();
628                Err(CoreError::AmbiguousProfile {
629                    name: name.to_string(),
630                    games,
631                })
632            }
633        }
634    }
635
636    /// Update an existing profile (identified by name + `game_id`).
637    pub fn update_profile(&self, profile: &Profile) -> Result<()> {
638        let (source_type, source_data) = encode_source(&profile.source);
639        let load_order_lock = encode_lock(profile.load_order_lock.as_ref());
640
641        let profile_id: i64 = self
642            .conn
643            .query_row(
644                "SELECT id FROM profiles WHERE name = ?1 AND game_id = ?2",
645                params![profile.name, profile.game_id],
646                |row| row.get(0),
647            )
648            .map_err(|e| match e {
649                rusqlite::Error::QueryReturnedNoRows => CoreError::ProfileNotFound(format!(
650                    "{} (game: {})",
651                    profile.name, profile.game_id
652                )),
653                other => CoreError::Database(other),
654            })?;
655
656        self.conn.execute(
657            "UPDATE profiles SET source_type = ?1, source_data = ?2, overrides = ?3,
658                    load_order_lock = ?4, updated_at = datetime('now')
659             WHERE id = ?5",
660            params![
661                source_type,
662                source_data,
663                profile.overrides.to_string_lossy().as_ref(),
664                load_order_lock,
665                profile_id,
666            ],
667        )?;
668
669        // Replace mods and rules
670        self.conn.execute(
671            "DELETE FROM profile_mods WHERE profile_id = ?1",
672            params![profile_id],
673        )?;
674        self.conn.execute(
675            "DELETE FROM load_order_rules WHERE profile_id = ?1",
676            params![profile_id],
677        )?;
678
679        self.insert_mods(profile_id, &profile.mods)?;
680        self.insert_rules(profile_id, &profile.load_order_rules)?;
681
682        Ok(())
683    }
684
685    /// Delete a profile by name and `game_id`.
686    pub fn delete_profile(&self, name: &str, game_id: &GameId) -> Result<()> {
687        let changes = self.conn.execute(
688            "DELETE FROM profiles WHERE name = ?1 AND game_id = ?2",
689            params![name, game_id],
690        )?;
691        if changes == 0 {
692            return Err(CoreError::ProfileNotFound(format!(
693                "{name} (game: {game_id})"
694            )));
695        }
696        Ok(())
697    }
698
699    /// List profile summaries, optionally filtered by game.
700    pub fn list_profiles(&self, game_id: Option<&GameId>) -> Result<Vec<ProfileSummary>> {
701        let (sql, bind) = match game_id {
702            Some(gid) => (
703                "SELECT p.id, p.name, p.game_id, p.source_type,
704                        (SELECT COUNT(*) FROM profile_mods WHERE profile_id = p.id) as mod_count
705                 FROM profiles p WHERE p.game_id = ?1 ORDER BY p.name",
706                Some(gid.to_string()),
707            ),
708            None => (
709                "SELECT p.id, p.name, p.game_id, p.source_type,
710                        (SELECT COUNT(*) FROM profile_mods WHERE profile_id = p.id) as mod_count
711                 FROM profiles p ORDER BY p.game_id, p.name",
712                None,
713            ),
714        };
715
716        let mut stmt = self.conn.prepare(sql)?;
717
718        let row_mapper = |row: &rusqlite::Row<'_>| {
719            Ok(ProfileSummary {
720                id: row.get(0)?,
721                name: row.get(1)?,
722                game_id: GameId::from(row.get::<_, String>(2)?),
723                source_type: row.get(3)?,
724                mod_count: row.get::<_, i64>(4)? as usize,
725            })
726        };
727
728        let summaries = match &bind {
729            Some(gid) => stmt.query_map(params![gid], row_mapper)?,
730            None => stmt.query_map([], row_mapper)?,
731        }
732        .collect::<std::result::Result<Vec<_>, _>>()?;
733
734        Ok(summaries)
735    }
736
737    // ── Save CRUD ─────────────────────────────────────────────────
738
739    /// Assign a save to a profile.
740    pub fn assign_save(&self, profile_id: i64, path: &Path, label: Option<&str>) -> Result<()> {
741        let path_str = path.to_string_lossy();
742
743        // Check if already assigned to a different profile
744        let existing: Option<(i64, String)> = self
745            .conn
746            .query_row(
747                "SELECT s.profile_id, p.name FROM saves s
748                 JOIN profiles p ON p.id = s.profile_id
749                 WHERE s.path = ?1",
750                params![path_str.as_ref()],
751                |row| Ok((row.get(0)?, row.get(1)?)),
752            )
753            .ok();
754
755        if let Some((existing_id, existing_name)) = existing {
756            if existing_id != profile_id {
757                return Err(CoreError::SaveAlreadyAssigned {
758                    path: path_str.to_string(),
759                    profile: existing_name,
760                });
761            }
762            // Already assigned to this profile — update label
763            self.conn.execute(
764                "UPDATE saves SET label = ?1 WHERE path = ?2",
765                params![label, path_str.as_ref()],
766            )?;
767            return Ok(());
768        }
769
770        self.conn.execute(
771            "INSERT INTO saves (profile_id, path, label) VALUES (?1, ?2, ?3)",
772            params![profile_id, path_str.as_ref(), label],
773        )?;
774
775        Ok(())
776    }
777
778    /// Remove a save assignment.
779    pub fn unassign_save(&self, path: &Path) -> Result<()> {
780        let path_str = path.to_string_lossy();
781        self.conn.execute(
782            "DELETE FROM saves WHERE path = ?1",
783            params![path_str.as_ref()],
784        )?;
785        Ok(())
786    }
787
788    /// List all saves assigned to a profile.
789    pub fn list_saves(&self, profile_id: i64) -> Result<Vec<SaveEntry>> {
790        let mut stmt = self.conn.prepare(
791            "SELECT path, label, assigned_at FROM saves WHERE profile_id = ?1 ORDER BY assigned_at",
792        )?;
793
794        let saves = stmt
795            .query_map(params![profile_id], |row| {
796                Ok(SaveEntry {
797                    path: PathBuf::from(row.get::<_, String>(0)?),
798                    label: row.get(1)?,
799                    assigned_at: row.get(2)?,
800                })
801            })?
802            .collect::<std::result::Result<Vec<_>, _>>()?;
803
804        Ok(saves)
805    }
806
807    /// Check if a save path is assigned to any profile.
808    pub fn is_save_assigned(&self, path: &Path) -> Result<bool> {
809        let path_str = path.to_string_lossy();
810        let count: i64 = self.conn.query_row(
811            "SELECT COUNT(*) FROM saves WHERE path = ?1",
812            params![path_str.as_ref()],
813            |row| row.get(0),
814        )?;
815        Ok(count > 0)
816    }
817
818    // ── Active Profile Tracking ────────────────────────────────────
819
820    /// Set the active profile for a game, replacing any previous one.
821    pub fn set_active_profile(&self, game_id: &GameId, profile_id: i64) -> Result<()> {
822        self.conn.execute(
823            "INSERT INTO active_profiles (game_id, profile_id)
824             VALUES (?1, ?2)
825             ON CONFLICT(game_id) DO UPDATE SET
826                profile_id = excluded.profile_id,
827                activated_at = datetime('now')",
828            params![game_id, profile_id],
829        )?;
830        Ok(())
831    }
832
833    /// Get the active profile for a game, returning (`profile_id`, `profile_name`).
834    pub fn get_active_profile(&self, game_id: &GameId) -> Result<Option<(i64, String)>> {
835        let result = self.conn.query_row(
836            "SELECT a.profile_id, p.name FROM active_profiles a
837             JOIN profiles p ON p.id = a.profile_id
838             WHERE a.game_id = ?1",
839            params![game_id],
840            |row| Ok((row.get(0)?, row.get(1)?)),
841        );
842
843        match result {
844            Ok(pair) => Ok(Some(pair)),
845            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
846            Err(e) => Err(e.into()),
847        }
848    }
849
850    /// Clear the active profile for a game.
851    pub fn clear_active_profile(&self, game_id: &GameId) -> Result<()> {
852        self.conn.execute(
853            "DELETE FROM active_profiles WHERE game_id = ?1",
854            params![game_id],
855        )?;
856        Ok(())
857    }
858
859    // ── Experiment Stack ──────────────────────────────────────────
860
861    /// Push a profile onto the experiment stack for a game.
862    pub fn push_experiment(&self, game_id: &GameId, profile_id: i64) -> Result<()> {
863        let depth = self.experiment_depth(game_id)?;
864        self.conn.execute(
865            "INSERT INTO experiment_stack (game_id, profile_id, depth)
866             VALUES (?1, ?2, ?3)",
867            params![game_id, profile_id, depth as i64],
868        )?;
869        Ok(())
870    }
871
872    /// Pop the top entry from the experiment stack, returning the `profile_id`.
873    pub fn pop_experiment(&self, game_id: &GameId) -> Result<Option<i64>> {
874        let result = self.conn.query_row(
875            "SELECT id, profile_id FROM experiment_stack
876             WHERE game_id = ?1 ORDER BY depth DESC LIMIT 1",
877            params![game_id],
878            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
879        );
880
881        match result {
882            Ok((id, profile_id)) => {
883                self.conn
884                    .execute("DELETE FROM experiment_stack WHERE id = ?1", params![id])?;
885                Ok(Some(profile_id))
886            }
887            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
888            Err(e) => Err(e.into()),
889        }
890    }
891
892    /// Get the experiment stack depth for a game.
893    pub fn experiment_depth(&self, game_id: &GameId) -> Result<usize> {
894        let count: i64 = self.conn.query_row(
895            "SELECT COUNT(*) FROM experiment_stack WHERE game_id = ?1",
896            params![game_id],
897            |row| row.get(0),
898        )?;
899        Ok(count as usize)
900    }
901
902    /// Clear the entire experiment stack for a game.
903    pub fn clear_experiment_stack(&self, game_id: &GameId) -> Result<()> {
904        self.conn.execute(
905            "DELETE FROM experiment_stack WHERE game_id = ?1",
906            params![game_id],
907        )?;
908        Ok(())
909    }
910
911    // ── Stock Snapshots ───────────────────────────────────────────
912
913    /// Insert or update a stock snapshot record.
914    pub fn upsert_snapshot(
915        &self,
916        game_id: &GameId,
917        snapshot_path: &Path,
918        tree_hash: &str,
919        file_count: usize,
920    ) -> Result<()> {
921        self.conn.execute(
922            "INSERT INTO stock_snapshots (game_id, snapshot_path, tree_hash, file_count)
923             VALUES (?1, ?2, ?3, ?4)
924             ON CONFLICT(game_id) DO UPDATE SET
925                snapshot_path = excluded.snapshot_path,
926                tree_hash = excluded.tree_hash,
927                file_count = excluded.file_count,
928                created_at = datetime('now')",
929            params![
930                game_id,
931                snapshot_path.to_string_lossy().as_ref(),
932                tree_hash,
933                file_count as i64,
934            ],
935        )?;
936        Ok(())
937    }
938
939    /// Get snapshot metadata for a game.
940    pub fn get_snapshot(&self, game_id: &GameId) -> Result<Option<SnapshotMeta>> {
941        let result = self.conn.query_row(
942            "SELECT game_id, snapshot_path, tree_hash, file_count, created_at
943             FROM stock_snapshots WHERE game_id = ?1",
944            params![game_id],
945            |row| {
946                Ok(SnapshotMeta {
947                    game_id: GameId::from(row.get::<_, String>(0)?),
948                    snapshot_path: PathBuf::from(row.get::<_, String>(1)?),
949                    tree_hash: row.get(2)?,
950                    file_count: row.get::<_, i64>(3)? as usize,
951                    created_at: row.get(4)?,
952                })
953            },
954        );
955
956        match result {
957            Ok(meta) => Ok(Some(meta)),
958            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
959            Err(e) => Err(e.into()),
960        }
961    }
962
963    // ── Hidden Files ─────────────────────────────────────────────
964
965    /// Hide a file from a mod in a profile (prevents deployment).
966    pub fn hide_file(&self, profile_id: i64, mod_id: &ModId, rel_path: &str) -> Result<()> {
967        self.conn.execute(
968            "INSERT OR IGNORE INTO hidden_files (profile_id, mod_id, rel_path)
969             VALUES (?1, ?2, ?3)",
970            params![profile_id, mod_id, rel_path],
971        )?;
972        Ok(())
973    }
974
975    /// Unhide a previously hidden file.
976    pub fn unhide_file(&self, profile_id: i64, mod_id: &ModId, rel_path: &str) -> Result<()> {
977        self.conn.execute(
978            "DELETE FROM hidden_files WHERE profile_id = ?1 AND mod_id = ?2 AND rel_path = ?3",
979            params![profile_id, mod_id, rel_path],
980        )?;
981        Ok(())
982    }
983
984    /// List all hidden files for a profile.
985    pub fn list_hidden_files(&self, profile_id: i64) -> Result<Vec<HiddenFile>> {
986        let mut stmt = self
987            .conn
988            .prepare("SELECT mod_id, rel_path FROM hidden_files WHERE profile_id = ?1")?;
989        let files = stmt
990            .query_map(params![profile_id], |row| {
991                Ok(HiddenFile {
992                    mod_id: row.get(0)?,
993                    rel_path: row.get(1)?,
994                })
995            })?
996            .collect::<std::result::Result<Vec<_>, _>>()?;
997        Ok(files)
998    }
999
1000    /// List hidden files for a specific mod in a profile.
1001    pub fn list_hidden_files_for_mod(
1002        &self,
1003        profile_id: i64,
1004        mod_id: &ModId,
1005    ) -> Result<Vec<String>> {
1006        let mut stmt = self
1007            .conn
1008            .prepare("SELECT rel_path FROM hidden_files WHERE profile_id = ?1 AND mod_id = ?2")?;
1009        let paths = stmt
1010            .query_map(params![profile_id, mod_id], |row| row.get(0))?
1011            .collect::<std::result::Result<Vec<_>, _>>()?;
1012        Ok(paths)
1013    }
1014
1015    // ── Plugin Order ─────────────────────────────────────────────
1016
1017    /// Set the plugin order for a profile (replaces any existing order).
1018    pub fn set_plugin_order(&self, profile_id: i64, plugins: &[PluginEntry]) -> Result<()> {
1019        self.conn.execute(
1020            "DELETE FROM plugin_order WHERE profile_id = ?1",
1021            params![profile_id],
1022        )?;
1023        let mut stmt = self.conn.prepare(
1024            "INSERT INTO plugin_order (profile_id, plugin_name, sort_index, enabled)
1025             VALUES (?1, ?2, ?3, ?4)",
1026        )?;
1027        for plugin in plugins {
1028            stmt.execute(params![
1029                profile_id,
1030                plugin.plugin_name,
1031                plugin.sort_index,
1032                plugin.enabled,
1033            ])?;
1034        }
1035        Ok(())
1036    }
1037
1038    /// Get the plugin order for a profile.
1039    pub fn get_plugin_order(&self, profile_id: i64) -> Result<Vec<PluginEntry>> {
1040        let mut stmt = self.conn.prepare(
1041            "SELECT plugin_name, sort_index, enabled FROM plugin_order
1042             WHERE profile_id = ?1 ORDER BY sort_index",
1043        )?;
1044        let plugins = stmt
1045            .query_map(params![profile_id], |row| {
1046                Ok(PluginEntry {
1047                    plugin_name: row.get(0)?,
1048                    sort_index: row.get(1)?,
1049                    enabled: row.get(2)?,
1050                })
1051            })?
1052            .collect::<std::result::Result<Vec<_>, _>>()?;
1053        Ok(plugins)
1054    }
1055
1056    /// Toggle a plugin's enabled state.
1057    pub fn toggle_plugin(&self, profile_id: i64, plugin_name: &str, enabled: bool) -> Result<()> {
1058        self.conn.execute(
1059            "UPDATE plugin_order SET enabled = ?1 WHERE profile_id = ?2 AND plugin_name = ?3",
1060            params![enabled, profile_id, plugin_name],
1061        )?;
1062        Ok(())
1063    }
1064
1065    // ── Mod Categories ───────────────────────────────────────────
1066
1067    /// Create a mod category, returning its ID.
1068    pub fn create_category(&self, profile_id: i64, category: &ModCategory) -> Result<i64> {
1069        self.conn.execute(
1070            "INSERT INTO mod_categories (profile_id, name, color, sort_index)
1071             VALUES (?1, ?2, ?3, ?4)",
1072            params![
1073                profile_id,
1074                category.name,
1075                category.color,
1076                category.sort_index
1077            ],
1078        )?;
1079        Ok(self.conn.last_insert_rowid())
1080    }
1081
1082    /// Update a category.
1083    pub fn update_category(
1084        &self,
1085        category_id: i64,
1086        name: &str,
1087        color: Option<&str>,
1088        sort_index: i64,
1089    ) -> Result<()> {
1090        self.conn.execute(
1091            "UPDATE mod_categories SET name = ?1, color = ?2, sort_index = ?3 WHERE id = ?4",
1092            params![name, color, sort_index, category_id],
1093        )?;
1094        Ok(())
1095    }
1096
1097    /// Delete a category (nullifies `category_id` on affected mods).
1098    pub fn delete_category(&self, category_id: i64) -> Result<()> {
1099        self.conn.execute(
1100            "UPDATE profile_mods SET category_id = NULL WHERE category_id = ?1",
1101            params![category_id],
1102        )?;
1103        self.conn.execute(
1104            "DELETE FROM mod_categories WHERE id = ?1",
1105            params![category_id],
1106        )?;
1107        Ok(())
1108    }
1109
1110    /// List categories for a profile.
1111    pub fn list_categories(&self, profile_id: i64) -> Result<Vec<ModCategory>> {
1112        let mut stmt = self.conn.prepare(
1113            "SELECT id, name, color, sort_index FROM mod_categories
1114             WHERE profile_id = ?1 ORDER BY sort_index",
1115        )?;
1116        let cats = stmt
1117            .query_map(params![profile_id], |row| {
1118                Ok(ModCategory {
1119                    id: Some(row.get(0)?),
1120                    name: row.get(1)?,
1121                    color: row.get(2)?,
1122                    sort_index: row.get(3)?,
1123                })
1124            })?
1125            .collect::<std::result::Result<Vec<_>, _>>()?;
1126        Ok(cats)
1127    }
1128
1129    /// Assign a mod to a category.
1130    pub fn set_mod_category(
1131        &self,
1132        profile_id: i64,
1133        mod_id: &ModId,
1134        category_id: Option<i64>,
1135    ) -> Result<()> {
1136        self.conn.execute(
1137            "UPDATE profile_mods SET category_id = ?1 WHERE profile_id = ?2 AND mod_id = ?3",
1138            params![category_id, profile_id, mod_id],
1139        )?;
1140        Ok(())
1141    }
1142
1143    /// Set notes for a mod.
1144    pub fn set_mod_notes(
1145        &self,
1146        profile_id: i64,
1147        mod_id: &ModId,
1148        notes: Option<&str>,
1149    ) -> Result<()> {
1150        self.conn.execute(
1151            "UPDATE profile_mods SET notes = ?1 WHERE profile_id = ?2 AND mod_id = ?3",
1152            params![notes, profile_id, mod_id],
1153        )?;
1154        Ok(())
1155    }
1156
1157    /// Set tags for a mod (stored as a JSON array in the TEXT column).
1158    pub fn set_mod_tags(&self, profile_id: i64, mod_id: &ModId, tags: &[String]) -> Result<()> {
1159        let encoded_tags = encode_tags(tags)?;
1160        self.conn.execute(
1161            "UPDATE profile_mods SET tags = ?1 WHERE profile_id = ?2 AND mod_id = ?3",
1162            params![encoded_tags, profile_id, mod_id],
1163        )?;
1164        Ok(())
1165    }
1166
1167    /// Set Nexus metadata for a mod.
1168    pub fn set_mod_nexus_meta(
1169        &self,
1170        profile_id: i64,
1171        mod_id: &ModId,
1172        nexus_mod_id: NexusModId,
1173        nexus_file_id: NexusFileId,
1174        nexus_game_domain: &str,
1175        installed_timestamp: i64,
1176    ) -> Result<()> {
1177        self.conn.execute(
1178            "UPDATE profile_mods SET nexus_mod_id = ?1, nexus_file_id = ?2,
1179                    nexus_game_domain = ?3, installed_timestamp = ?4
1180             WHERE profile_id = ?5 AND mod_id = ?6",
1181            params![
1182                nexus_mod_id.to_i64()?,
1183                nexus_file_id.to_i64()?,
1184                nexus_game_domain,
1185                installed_timestamp,
1186                profile_id,
1187                mod_id
1188            ],
1189        )?;
1190        Ok(())
1191    }
1192
1193    // ── Installer tracking (V8) ───────────────────────────────────
1194
1195    /// Persist an installer's decision and file manifest for a single
1196    /// mod row, atomically.
1197    ///
1198    /// Steps in one transaction:
1199    /// 1. Write the encoded `install_method`, `source_archive_hash`, and
1200    ///    `install_status` back to `profile_mods`.
1201    /// 2. Wipe any previous `installed_mod_files` rows for this mod
1202    ///    (so retries don't leave orphans in the manifest).
1203    /// 3. Insert one row per `plan.staged_files`.
1204    ///
1205    /// Callers are expected to have already written the `EnabledMod` into
1206    /// the profile (via `update_profile` / `create_profile`); this method
1207    /// just enriches the existing row with install metadata and files.
1208    pub fn record_install(
1209        &mut self,
1210        profile_id: i64,
1211        mod_id: &ModId,
1212        plan: &InstallPlan,
1213        status: InstallStatus,
1214    ) -> Result<()> {
1215        let tx = self.conn.transaction()?;
1216
1217        let method_toml = encode_install_method(&plan.method)?;
1218        tx.execute(
1219            "UPDATE profile_mods
1220                SET install_method = ?1,
1221                    source_archive_hash = ?2,
1222                    install_status = ?3
1223              WHERE profile_id = ?4 AND mod_id = ?5",
1224            params![
1225                method_toml,
1226                plan.source_archive_hash,
1227                status.as_str(),
1228                profile_id,
1229                mod_id,
1230            ],
1231        )?;
1232
1233        tx.execute(
1234            "DELETE FROM installed_mod_files WHERE profile_id = ?1 AND mod_id = ?2",
1235            params![profile_id, mod_id],
1236        )?;
1237
1238        {
1239            let mut stmt = tx.prepare(
1240                "INSERT INTO installed_mod_files
1241                    (profile_id, mod_id, rel_path, origin_rel_path, size, merge_group)
1242                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1243            )?;
1244            for file in &plan.staged_files {
1245                stmt.execute(params![
1246                    profile_id,
1247                    mod_id,
1248                    file.rel_path,
1249                    file.origin_rel_path,
1250                    file.size as i64,
1251                    file.merge_group,
1252                ])?;
1253            }
1254        }
1255
1256        tx.commit()?;
1257        Ok(())
1258    }
1259
1260    /// Return every file staged by `mod_id` in `profile_id`, sorted by
1261    /// relative path for deterministic uninstall order.
1262    pub fn installed_files_for_mod(
1263        &self,
1264        profile_id: i64,
1265        mod_id: &ModId,
1266    ) -> Result<Vec<StagedFile>> {
1267        let mut stmt = self.conn.prepare(
1268            "SELECT rel_path, origin_rel_path, size, merge_group
1269               FROM installed_mod_files
1270              WHERE profile_id = ?1 AND mod_id = ?2
1271           ORDER BY rel_path",
1272        )?;
1273        let files = stmt
1274            .query_map(params![profile_id, mod_id], |row| {
1275                let size_i: i64 = row.get(2)?;
1276                Ok(StagedFile {
1277                    rel_path: row.get(0)?,
1278                    origin_rel_path: row.get(1)?,
1279                    size: size_i.max(0) as u64,
1280                    merge_group: row.get(3)?,
1281                })
1282            })?
1283            .collect::<std::result::Result<Vec<_>, _>>()?;
1284        Ok(files)
1285    }
1286
1287    /// Remove a mod from `profile_mods` and return its staged files so
1288    /// the caller can unlink them from the store / deploy dir. Runs in
1289    /// one transaction — either the manifest and row both disappear or
1290    /// neither does.
1291    pub fn remove_installed_mod(
1292        &mut self,
1293        profile_id: i64,
1294        mod_id: &ModId,
1295    ) -> Result<Vec<StagedFile>> {
1296        let files = self.installed_files_for_mod(profile_id, mod_id)?;
1297        let tx = self.conn.transaction()?;
1298        tx.execute(
1299            "DELETE FROM installed_mod_files WHERE profile_id = ?1 AND mod_id = ?2",
1300            params![profile_id, mod_id],
1301        )?;
1302        tx.execute(
1303            "DELETE FROM profile_mods WHERE profile_id = ?1 AND mod_id = ?2",
1304            params![profile_id, mod_id],
1305        )?;
1306        tx.commit()?;
1307        Ok(files)
1308    }
1309
1310    /// Return every file tagged with `merge_group`, across all mods in
1311    /// `profile_id`. Reserved for the future script-merge feature —
1312    /// unused by current code, but exposed now so the V8 schema can
1313    /// carry a stable query shape.
1314    pub fn files_in_merge_group(
1315        &self,
1316        profile_id: i64,
1317        merge_group: &str,
1318    ) -> Result<Vec<(String, StagedFile)>> {
1319        let mut stmt = self.conn.prepare(
1320            "SELECT mod_id, rel_path, origin_rel_path, size, merge_group
1321               FROM installed_mod_files
1322              WHERE profile_id = ?1 AND merge_group = ?2
1323           ORDER BY mod_id, rel_path",
1324        )?;
1325        let rows = stmt
1326            .query_map(params![profile_id, merge_group], |row| {
1327                let size_i: i64 = row.get(3)?;
1328                Ok((
1329                    row.get::<_, String>(0)?,
1330                    StagedFile {
1331                        rel_path: row.get(1)?,
1332                        origin_rel_path: row.get(2)?,
1333                        size: size_i.max(0) as u64,
1334                        merge_group: row.get(4)?,
1335                    },
1336                ))
1337            })?
1338            .collect::<std::result::Result<Vec<_>, _>>()?;
1339        Ok(rows)
1340    }
1341
1342    // ── TOML Import ───────────────────────────────────────────────
1343
1344    /// Import existing TOML profile files into the database.
1345    /// Returns the number of profiles imported.
1346    pub fn import_toml_profiles(&self, profiles_dir: &Path) -> Result<usize> {
1347        if !profiles_dir.exists() {
1348            return Ok(0);
1349        }
1350
1351        let mut count = 0usize;
1352
1353        for entry in std::fs::read_dir(profiles_dir)? {
1354            let entry = entry?;
1355            if !entry.file_type()?.is_dir() {
1356                continue;
1357            }
1358
1359            let toml_path = entry.path().join("profile.toml");
1360            if !toml_path.exists() {
1361                continue;
1362            }
1363
1364            let content = match std::fs::read_to_string(&toml_path) {
1365                Ok(c) => c,
1366                Err(e) => {
1367                    tracing::warn!(path = %toml_path.display(), error = %e, "skipping unreadable profile");
1368                    continue;
1369                }
1370            };
1371
1372            #[allow(deprecated)]
1373            let mut profile: Profile = match toml::from_str(&content) {
1374                Ok(p) => p,
1375                Err(e) => {
1376                    tracing::warn!(path = %toml_path.display(), error = %e, "skipping unparseable profile");
1377                    continue;
1378                }
1379            };
1380
1381            // Preserve-before-overwrite: if the TOML file already carried
1382            // a lock (e.g. a Wabbajack-installed profile that was previously
1383            // exported), honor that provenance. Only stamp a fresh
1384            // `TomlImport` lock when the parsed profile has no lock — so
1385            // round-tripping an already-locked profile through TOML doesn't
1386            // destroy its origin.
1387            if profile.load_order_lock.is_none() {
1388                profile.load_order_lock = Some(LoadOrderLock::now(LockReason::TomlImport {
1389                    source_path: toml_path.display().to_string(),
1390                }));
1391            }
1392
1393            // Skip if already in DB
1394            let exists: bool = self.conn.query_row(
1395                "SELECT COUNT(*) > 0 FROM profiles WHERE name = ?1 AND game_id = ?2",
1396                params![profile.name, profile.game_id],
1397                |row| row.get(0),
1398            )?;
1399
1400            if exists {
1401                tracing::debug!(name = %profile.name, game = %profile.game_id, "profile already in DB, skipping");
1402                continue;
1403            }
1404
1405            self.create_profile(&profile)?;
1406            info!(name = %profile.name, game = %profile.game_id, "imported TOML profile");
1407            count += 1;
1408        }
1409
1410        Ok(count)
1411    }
1412
1413    // ── Internal helpers ──────────────────────────────────────────
1414
1415    fn nexus_mod_id_from_row(
1416        row: &rusqlite::Row<'_>,
1417        idx: usize,
1418    ) -> rusqlite::Result<Option<NexusModId>> {
1419        let raw: Option<i64> = row.get(idx)?;
1420        raw.map(NexusModId::try_from)
1421            .transpose()
1422            .map_err(Self::nexus_id_row_error)
1423    }
1424
1425    fn nexus_file_id_from_row(
1426        row: &rusqlite::Row<'_>,
1427        idx: usize,
1428    ) -> rusqlite::Result<Option<NexusFileId>> {
1429        let raw: Option<i64> = row.get(idx)?;
1430        raw.map(NexusFileId::try_from)
1431            .transpose()
1432            .map_err(Self::nexus_id_row_error)
1433    }
1434
1435    fn nexus_id_row_error(err: NexusIdError) -> rusqlite::Error {
1436        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Integer, Box::new(err))
1437    }
1438
1439    fn core_row_error(err: CoreError) -> rusqlite::Error {
1440        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(err))
1441    }
1442
1443    fn insert_mods(&self, profile_id: i64, mods: &[EnabledMod]) -> Result<()> {
1444        let mut stmt = self.conn.prepare(
1445            "INSERT INTO profile_mods (profile_id, mod_id, display_name, enabled, version, fomod_config, sort_index,
1446                    nexus_mod_id, nexus_file_id, nexus_game_domain, installed_timestamp,
1447                    category_id, notes, tags, lock_reason,
1448                    install_method, source_archive_hash, install_status)
1449             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
1450        )?;
1451
1452        for (idx, m) in mods.iter().enumerate() {
1453            let lock_reason = encode_lock_reason(m.lock.as_ref());
1454            let nexus_mod_id = m.nexus_mod_id.map(NexusModId::to_i64).transpose()?;
1455            let nexus_file_id = m.nexus_file_id.map(NexusFileId::to_i64).transpose()?;
1456            let tags = encode_tags(&m.tags)?;
1457            let install_method = m
1458                .install_method
1459                .as_ref()
1460                .map(encode_install_method)
1461                .transpose()?;
1462            let install_status = m.install_status.map(InstallStatus::as_str);
1463            stmt.execute(params![
1464                profile_id,
1465                m.mod_id,
1466                m.display_name,
1467                m.enabled,
1468                m.version,
1469                m.fomod_config,
1470                idx as i64,
1471                nexus_mod_id,
1472                nexus_file_id,
1473                m.nexus_game_domain,
1474                m.installed_timestamp,
1475                m.category_id,
1476                m.notes,
1477                tags,
1478                lock_reason,
1479                install_method,
1480                m.source_archive_hash,
1481                install_status,
1482            ])?;
1483        }
1484
1485        Ok(())
1486    }
1487
1488    fn insert_rules(&self, profile_id: i64, rules: &[LoadOrderRule]) -> Result<()> {
1489        let mut stmt = self.conn.prepare(
1490            "INSERT INTO load_order_rules (profile_id, rule_type, mod_a, mod_b)
1491             VALUES (?1, ?2, ?3, ?4)",
1492        )?;
1493
1494        for rule in rules {
1495            let (rule_type, mod_a, mod_b) = match rule {
1496                LoadOrderRule::LoadAfter { mod_id, after } => {
1497                    ("load_after", mod_id.as_str(), after.as_str())
1498                }
1499                LoadOrderRule::LoadBefore { mod_id, before } => {
1500                    ("load_before", mod_id.as_str(), before.as_str())
1501                }
1502                LoadOrderRule::Incompatible { mod_a, mod_b } => {
1503                    ("incompatible", mod_a.as_str(), mod_b.as_str())
1504                }
1505            };
1506            stmt.execute(params![profile_id, rule_type, mod_a, mod_b])?;
1507        }
1508
1509        Ok(())
1510    }
1511
1512    fn load_mods(&self, profile_id: i64) -> Result<Vec<EnabledMod>> {
1513        let mut stmt = self.conn.prepare(
1514            "SELECT mod_id, display_name, enabled, version, fomod_config,
1515                    nexus_mod_id, nexus_file_id, nexus_game_domain, installed_timestamp,
1516                    category_id, notes, tags, lock_reason,
1517                    install_method, source_archive_hash, install_status
1518             FROM profile_mods WHERE profile_id = ?1 ORDER BY sort_index",
1519        )?;
1520
1521        let mods = stmt
1522            .query_map(params![profile_id], |row| {
1523                let lock_reason_raw: Option<String> = row.get(12)?;
1524                let nexus_mod_id = Self::nexus_mod_id_from_row(row, 5)?;
1525                let nexus_file_id = Self::nexus_file_id_from_row(row, 6)?;
1526                let tags_raw: Option<String> = row.get(11)?;
1527                let install_method_raw: Option<String> = row.get(13)?;
1528                let install_status_raw: Option<String> = row.get(15)?;
1529                Ok(EnabledMod {
1530                    mod_id: row.get(0)?,
1531                    display_name: row.get(1)?,
1532                    enabled: row.get(2)?,
1533                    version: row.get(3)?,
1534                    fomod_config: row.get(4)?,
1535                    nexus_mod_id,
1536                    nexus_file_id,
1537                    nexus_game_domain: row.get(7)?,
1538                    installed_timestamp: row.get(8)?,
1539                    category_id: row.get(9)?,
1540                    notes: row.get(10)?,
1541                    tags: decode_tags(tags_raw.as_deref()).map_err(Self::core_row_error)?,
1542                    lock: decode_lock_reason(lock_reason_raw.as_deref())
1543                        .map_err(Self::core_row_error)?,
1544                    install_method: decode_install_method(install_method_raw.as_deref())
1545                        .map_err(Self::core_row_error)?,
1546                    source_archive_hash: row.get(14)?,
1547                    install_status: decode_install_status(install_status_raw.as_deref())
1548                        .map_err(Self::core_row_error)?,
1549                })
1550            })?
1551            .collect::<std::result::Result<Vec<_>, _>>()?;
1552
1553        Ok(mods)
1554    }
1555
1556    fn load_rules(&self, profile_id: i64) -> Result<SmallVec<[LoadOrderRule; 4]>> {
1557        let mut stmt = self.conn.prepare(
1558            "SELECT rule_type, mod_a, mod_b FROM load_order_rules WHERE profile_id = ?1",
1559        )?;
1560
1561        let rules = stmt
1562            .query_map(params![profile_id], |row| {
1563                let rule_type: String = row.get(0)?;
1564                let mod_a: String = row.get(1)?;
1565                let mod_b: String = row.get(2)?;
1566                Ok((rule_type, mod_a, mod_b))
1567            })?
1568            .collect::<std::result::Result<Vec<_>, _>>()?;
1569
1570        let mut result = SmallVec::with_capacity(rules.len());
1571        for (rule_type, mod_a, mod_b) in rules {
1572            let rule = match rule_type.as_str() {
1573                "load_after" => LoadOrderRule::LoadAfter {
1574                    mod_id: ModId::from(mod_a),
1575                    after: ModId::from(mod_b),
1576                },
1577                "load_before" => LoadOrderRule::LoadBefore {
1578                    mod_id: ModId::from(mod_a),
1579                    before: ModId::from(mod_b),
1580                },
1581                "incompatible" => LoadOrderRule::Incompatible {
1582                    mod_a: ModId::from(mod_a),
1583                    mod_b: ModId::from(mod_b),
1584                },
1585                other => {
1586                    tracing::warn!(rule_type = other, "unknown load order rule type, skipping");
1587                    continue;
1588                }
1589            };
1590            result.push(rule);
1591        }
1592
1593        Ok(result)
1594    }
1595
1596    fn assemble_profile(
1597        &self,
1598        id: i64,
1599        name: &str,
1600        game_id: &GameId,
1601        source_type: &str,
1602        source_data: Option<&str>,
1603        overrides: &str,
1604        load_order_lock_raw: Option<&str>,
1605    ) -> Result<Profile> {
1606        let source = decode_source(source_type, source_data)?;
1607        let mods = self.load_mods(id)?;
1608        let load_order_rules = self.load_rules(id)?;
1609        let load_order_lock = decode_lock(load_order_lock_raw)?;
1610
1611        Ok(Profile {
1612            id: Some(id),
1613            name: name.to_string(),
1614            game_id: game_id.clone(),
1615            source,
1616            mods,
1617            overrides: PathBuf::from(overrides),
1618            load_order_rules,
1619            load_order_lock,
1620        })
1621    }
1622}
1623
1624// ── Source encoding ──────────────────────────────────────────
1625
1626fn encode_source(source: &ProfileSource) -> (&'static str, Option<String>) {
1627    match source {
1628        ProfileSource::Manual => ("manual", None),
1629        ProfileSource::NexusCollection { slug, version } => {
1630            let data = format!("slug = {slug:?}\nversion = {version:?}");
1631            ("nexus_collection", Some(data))
1632        }
1633        ProfileSource::Wabbajack { manifest_hash } => {
1634            let data = format!("manifest_hash = {manifest_hash:?}");
1635            ("wabbajack", Some(data))
1636        }
1637    }
1638}
1639
1640fn decode_source(source_type: &str, source_data: Option<&str>) -> Result<ProfileSource> {
1641    match source_type {
1642        "manual" => Ok(ProfileSource::Manual),
1643        "nexus_collection" => {
1644            let data = source_data.unwrap_or_default();
1645            let table: toml::Table = toml::from_str(data).map_err(|e| {
1646                CoreError::Other(
1647                    format!("failed to parse nexus_collection source data: {e}").into(),
1648                )
1649            })?;
1650            let slug = table
1651                .get("slug")
1652                .and_then(|v| v.as_str())
1653                .unwrap_or_default()
1654                .to_string();
1655            let version = table
1656                .get("version")
1657                .and_then(|v| v.as_str())
1658                .unwrap_or_default()
1659                .to_string();
1660            Ok(ProfileSource::NexusCollection { slug, version })
1661        }
1662        "wabbajack" => {
1663            let data = source_data.unwrap_or_default();
1664            let table: toml::Table = toml::from_str(data).map_err(|e| {
1665                CoreError::Other(format!("failed to parse wabbajack source data: {e}").into())
1666            })?;
1667            let manifest_hash = table
1668                .get("manifest_hash")
1669                .and_then(|v| v.as_str())
1670                .unwrap_or_default()
1671                .to_string();
1672            Ok(ProfileSource::Wabbajack { manifest_hash })
1673        }
1674        other => Err(CoreError::Other(
1675            format!("unknown profile source type: {other}").into(),
1676        )),
1677    }
1678}
1679
1680// ── Load order lock encoding ─────────────────────────────────
1681//
1682// Lock state lives in TEXT columns (TOML-encoded) to match the existing
1683// `source_data` convention above. A NULL column means "no lock".
1684
1685fn encode_lock(lock: Option<&LoadOrderLock>) -> Option<String> {
1686    lock.map(|l| toml::to_string(l).expect("LoadOrderLock should always serialize"))
1687}
1688
1689fn decode_lock(raw: Option<&str>) -> Result<Option<LoadOrderLock>> {
1690    match raw {
1691        None => Ok(None),
1692        Some(s) if s.is_empty() => Ok(None),
1693        Some(s) => toml::from_str::<LoadOrderLock>(s)
1694            .map(Some)
1695            .map_err(|e| CoreError::Other(format!("failed to parse load_order_lock: {e}").into())),
1696    }
1697}
1698
1699fn encode_lock_reason(reason: Option<&LockReason>) -> Option<String> {
1700    reason.map(|r| toml::to_string(r).expect("LockReason should always serialize"))
1701}
1702
1703fn decode_lock_reason(raw: Option<&str>) -> Result<Option<LockReason>> {
1704    match raw {
1705        None => Ok(None),
1706        Some(s) if s.is_empty() => Ok(None),
1707        Some(s) => toml::from_str::<LockReason>(s)
1708            .map(Some)
1709            .map_err(|e| CoreError::Other(format!("failed to parse lock_reason: {e}").into())),
1710    }
1711}
1712
1713// ── Installer method encoding (V8) ───────────────────────────
1714//
1715// `InstallMethod` is a serde-friendly enum, so we TOML-encode it like
1716// the other metadata blobs stored on `profile_mods`. Decoding is
1717// surfaced via `crate::installer::InstallMethod`'s own deserialize
1718// — callers decode directly when they need a typed value.
1719
1720fn encode_install_method(method: &InstallMethod) -> Result<String> {
1721    toml::to_string(method)
1722        .map_err(|e| CoreError::Other(format!("failed to encode install_method: {e}").into()))
1723}
1724
1725/// Parse the TOML-encoded `install_method` column back into a typed
1726/// [`InstallMethod`]. Returns `None` for NULL / empty strings.
1727pub fn decode_install_method(raw: Option<&str>) -> Result<Option<InstallMethod>> {
1728    match raw {
1729        None => Ok(None),
1730        Some(s) if s.is_empty() => Ok(None),
1731        Some(s) => toml::from_str::<InstallMethod>(s)
1732            .map(Some)
1733            .map_err(|e| CoreError::Other(format!("failed to parse install_method: {e}").into())),
1734    }
1735}
1736
1737fn encode_tags(tags: &[String]) -> Result<Option<String>> {
1738    if tags.is_empty() {
1739        Ok(None)
1740    } else {
1741        serde_json::to_string(tags)
1742            .map(Some)
1743            .map_err(CoreError::Json)
1744    }
1745}
1746
1747fn decode_tags(raw: Option<&str>) -> Result<Vec<String>> {
1748    match raw {
1749        None => Ok(Vec::new()),
1750        Some(s) if s.is_empty() => Ok(Vec::new()),
1751        Some(s) => serde_json::from_str::<Vec<String>>(s)
1752            .map_err(|e| CoreError::Other(format!("failed to parse tags JSON: {e}").into())),
1753    }
1754}
1755
1756fn decode_install_status(raw: Option<&str>) -> Result<Option<InstallStatus>> {
1757    match raw {
1758        None => Ok(None),
1759        Some(s) if s.is_empty() => Ok(None),
1760        Some(s) => InstallStatus::parse(s)
1761            .map(Some)
1762            .ok_or_else(|| CoreError::Other(format!("unknown install_status: {s}").into())),
1763    }
1764}
1765
1766fn new_tool_setting_node_id(game_id: &GameId, tool_id: &str) -> String {
1767    let nanos = std::time::SystemTime::now()
1768        .duration_since(std::time::UNIX_EPOCH)
1769        .map_or(0, |duration| duration.as_nanos());
1770    let game = sanitize_node_id_part(game_id.as_str());
1771    let tool = sanitize_node_id_part(tool_id);
1772    format!("tool-{game}-{tool}-{nanos}-{}", std::process::id())
1773}
1774
1775fn sanitize_node_id_part(value: &str) -> String {
1776    value
1777        .chars()
1778        .map(|ch| {
1779            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1780                ch
1781            } else {
1782                '-'
1783            }
1784        })
1785        .collect()
1786}
1787
1788// ── Tool config types (re-exported from modde_games::tools) ──────────
1789
1790/// Per-game tool configuration stored in the database.
1791///
1792/// This is a DB-layer representation; the full `ToolConfig` with
1793/// `serde_json::Value` settings lives in `modde_games::tools`.
1794#[derive(Debug, Clone)]
1795pub struct ToolConfigRow {
1796    pub tool_id: String,
1797    pub enabled: bool,
1798    pub settings_json: String,
1799}
1800
1801/// One versioned tool settings node in the per-tool DAG.
1802#[derive(Debug, Clone, PartialEq, Eq)]
1803pub struct ToolSettingHistoryNode {
1804    pub node_id: String,
1805    pub game_id: String,
1806    pub tool_id: String,
1807    pub enabled: bool,
1808    pub settings_json: String,
1809    pub reason: String,
1810    pub created_at: String,
1811    pub is_current: bool,
1812}
1813
1814/// One parent-child edge in the per-tool settings DAG.
1815#[derive(Debug, Clone, PartialEq, Eq)]
1816pub struct ToolSettingHistoryEdge {
1817    pub parent_node_id: String,
1818    pub child_node_id: String,
1819}
1820
1821/// A file applied by a tool to a game directory.
1822#[derive(Debug, Clone)]
1823pub struct ToolAppliedFileRow {
1824    pub tool_id: String,
1825    pub rel_path: String,
1826}
1827
1828/// A named executable launch target for a game.
1829#[derive(Debug, Clone, PartialEq, Eq)]
1830pub struct ExecutableConfigRow {
1831    pub game_id: String,
1832    pub name: String,
1833    pub executable_path: PathBuf,
1834    pub arguments_json: String,
1835    pub working_dir: Option<PathBuf>,
1836    pub environment_json: String,
1837    pub wine_dll_overrides: Option<String>,
1838    pub output_mod: String,
1839    pub enabled: bool,
1840}
1841
1842impl ModdeDb {
1843    // ── Game Tool CRUD ────────────────────────────────────────────
1844
1845    /// Save (insert or update) a tool configuration for a game.
1846    pub fn save_tool_config(
1847        &self,
1848        game_id: &GameId,
1849        tool_id: &str,
1850        enabled: bool,
1851        settings_json: &str,
1852    ) -> Result<()> {
1853        self.save_tool_config_with_reason(game_id, tool_id, enabled, settings_json, "update")
1854    }
1855
1856    /// Save a tool configuration and append a history node.
1857    pub fn save_tool_config_with_reason(
1858        &self,
1859        game_id: &GameId,
1860        tool_id: &str,
1861        enabled: bool,
1862        settings_json: &str,
1863        reason: &str,
1864    ) -> Result<()> {
1865        let parent_node_id = self.current_tool_setting_node_id(game_id, tool_id)?;
1866        let node_id = new_tool_setting_node_id(game_id, tool_id);
1867        let reason = if reason.trim().is_empty() {
1868            "update"
1869        } else {
1870            reason.trim()
1871        };
1872
1873        self.conn.execute(
1874            "INSERT INTO tool_setting_nodes (node_id, game_id, tool_id, enabled, settings, reason)
1875             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1876            params![
1877                node_id,
1878                game_id,
1879                tool_id,
1880                i32::from(enabled),
1881                settings_json,
1882                reason,
1883            ],
1884        )?;
1885        if let Some(parent_node_id) = parent_node_id {
1886            self.conn.execute(
1887                "INSERT OR IGNORE INTO tool_setting_edges (parent_node_id, child_node_id)
1888                 VALUES (?1, ?2)",
1889                params![parent_node_id, node_id],
1890            )?;
1891        }
1892        self.conn.execute(
1893            "INSERT INTO game_tools (game_id, tool_id, enabled, settings, updated_at, current_node_id)
1894             VALUES (?1, ?2, ?3, ?4, datetime('now'), ?5)
1895             ON CONFLICT(game_id, tool_id) DO UPDATE SET
1896                 enabled = excluded.enabled,
1897                 settings = excluded.settings,
1898                 updated_at = excluded.updated_at,
1899                 current_node_id = excluded.current_node_id",
1900            params![game_id, tool_id, i32::from(enabled), settings_json, node_id],
1901        )?;
1902        Ok(())
1903    }
1904
1905    /// Load recent settings history nodes for a tool.
1906    pub fn list_tool_setting_history(
1907        &self,
1908        game_id: &GameId,
1909        tool_id: &str,
1910        limit: usize,
1911    ) -> Result<Vec<ToolSettingHistoryNode>> {
1912        let current_node_id = self.current_tool_setting_node_id(game_id, tool_id)?;
1913        let mut stmt = self.conn.prepare(
1914            "SELECT node_id, game_id, tool_id, enabled, settings, reason, created_at
1915             FROM tool_setting_nodes
1916             WHERE game_id = ?1 AND tool_id = ?2
1917             ORDER BY id DESC
1918             LIMIT ?3",
1919        )?;
1920
1921        let rows = stmt
1922            .query_map(params![game_id, tool_id, limit as i64], |row| {
1923                let node_id: String = row.get(0)?;
1924                Ok(ToolSettingHistoryNode {
1925                    is_current: current_node_id.as_deref() == Some(node_id.as_str()),
1926                    node_id,
1927                    game_id: row.get(1)?,
1928                    tool_id: row.get(2)?,
1929                    enabled: row.get::<_, i32>(3)? != 0,
1930                    settings_json: row.get(4)?,
1931                    reason: row.get(5)?,
1932                    created_at: row.get(6)?,
1933                })
1934            })?
1935            .collect::<std::result::Result<Vec<_>, _>>()?;
1936
1937        Ok(rows)
1938    }
1939
1940    /// Load DAG edges for a tool's recorded settings history.
1941    pub fn list_tool_setting_edges(
1942        &self,
1943        game_id: &GameId,
1944        tool_id: &str,
1945    ) -> Result<Vec<ToolSettingHistoryEdge>> {
1946        let mut stmt = self.conn.prepare(
1947            "SELECT e.parent_node_id, e.child_node_id
1948             FROM tool_setting_edges e
1949             JOIN tool_setting_nodes child ON child.node_id = e.child_node_id
1950             WHERE child.game_id = ?1 AND child.tool_id = ?2
1951             ORDER BY e.id",
1952        )?;
1953        let rows = stmt
1954            .query_map(params![game_id, tool_id], |row| {
1955                Ok(ToolSettingHistoryEdge {
1956                    parent_node_id: row.get(0)?,
1957                    child_node_id: row.get(1)?,
1958                })
1959            })?
1960            .collect::<std::result::Result<Vec<_>, _>>()?;
1961
1962        Ok(rows)
1963    }
1964
1965    /// Restore a settings node by appending a new child node with copied state.
1966    pub fn restore_tool_setting_node(
1967        &self,
1968        game_id: &GameId,
1969        tool_id: &str,
1970        node_id: &str,
1971    ) -> Result<()> {
1972        let (enabled, settings_json): (bool, String) = self.conn.query_row(
1973            "SELECT enabled, settings FROM tool_setting_nodes
1974             WHERE game_id = ?1 AND tool_id = ?2 AND node_id = ?3",
1975            params![game_id, tool_id, node_id],
1976            |row| Ok((row.get::<_, i32>(0)? != 0, row.get(1)?)),
1977        )?;
1978        let reason = format!("restore:{node_id}");
1979        self.save_tool_config_with_reason(game_id, tool_id, enabled, &settings_json, &reason)
1980    }
1981
1982    fn current_tool_setting_node_id(
1983        &self,
1984        game_id: &GameId,
1985        tool_id: &str,
1986    ) -> Result<Option<String>> {
1987        let result = self.conn.query_row(
1988            "SELECT current_node_id FROM game_tools WHERE game_id = ?1 AND tool_id = ?2",
1989            params![game_id, tool_id],
1990            |row| row.get(0),
1991        );
1992
1993        match result {
1994            Ok(node_id) => Ok(node_id),
1995            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1996            Err(e) => Err(e.into()),
1997        }
1998    }
1999
2000    /// Load all tool configurations for a game.
2001    pub fn load_tool_configs(&self, game_id: &GameId) -> Result<Vec<ToolConfigRow>> {
2002        let mut stmt = self
2003            .conn
2004            .prepare("SELECT tool_id, enabled, settings FROM game_tools WHERE game_id = ?1")?;
2005
2006        let rows = stmt
2007            .query_map(params![game_id], |row| {
2008                Ok(ToolConfigRow {
2009                    tool_id: row.get(0)?,
2010                    enabled: row.get::<_, i32>(1)? != 0,
2011                    settings_json: row.get(2)?,
2012                })
2013            })?
2014            .collect::<std::result::Result<Vec<_>, _>>()?;
2015
2016        Ok(rows)
2017    }
2018
2019    /// Load a single tool configuration for a game.
2020    pub fn load_tool_config(
2021        &self,
2022        game_id: &GameId,
2023        tool_id: &str,
2024    ) -> Result<Option<ToolConfigRow>> {
2025        let result = self.conn.query_row(
2026            "SELECT tool_id, enabled, settings FROM game_tools
2027             WHERE game_id = ?1 AND tool_id = ?2",
2028            params![game_id, tool_id],
2029            |row| {
2030                Ok(ToolConfigRow {
2031                    tool_id: row.get(0)?,
2032                    enabled: row.get::<_, i32>(1)? != 0,
2033                    settings_json: row.get(2)?,
2034                })
2035            },
2036        );
2037
2038        match result {
2039            Ok(row) => Ok(Some(row)),
2040            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2041            Err(e) => Err(e.into()),
2042        }
2043    }
2044
2045    /// Record files applied by a tool to a game directory.
2046    pub fn save_applied_files(
2047        &self,
2048        game_id: &GameId,
2049        tool_id: &str,
2050        rel_paths: &[String],
2051    ) -> Result<()> {
2052        let mut stmt = self.conn.prepare(
2053            "INSERT OR IGNORE INTO tool_applied_files (game_id, tool_id, rel_path)
2054             VALUES (?1, ?2, ?3)",
2055        )?;
2056
2057        for path in rel_paths {
2058            stmt.execute(params![game_id, tool_id, path])?;
2059        }
2060
2061        Ok(())
2062    }
2063
2064    /// Load files previously applied by a tool.
2065    pub fn load_applied_files(&self, game_id: &GameId, tool_id: &str) -> Result<Vec<String>> {
2066        let mut stmt = self.conn.prepare(
2067            "SELECT rel_path FROM tool_applied_files
2068             WHERE game_id = ?1 AND tool_id = ?2",
2069        )?;
2070
2071        let rows = stmt
2072            .query_map(params![game_id, tool_id], |row| row.get(0))?
2073            .collect::<std::result::Result<Vec<String>, _>>()?;
2074
2075        Ok(rows)
2076    }
2077
2078    /// Clear all applied file records for a tool on a game.
2079    pub fn clear_applied_files(&self, game_id: &GameId, tool_id: &str) -> Result<()> {
2080        self.conn.execute(
2081            "DELETE FROM tool_applied_files WHERE game_id = ?1 AND tool_id = ?2",
2082            params![game_id, tool_id],
2083        )?;
2084        Ok(())
2085    }
2086
2087    // ── Executable Config CRUD ───────────────────────────────────
2088
2089    /// Save or update a named executable for a game.
2090    pub fn save_executable_config(&self, executable: &ExecutableConfigRow) -> Result<()> {
2091        self.conn.execute(
2092            "INSERT INTO executable_configs (
2093                game_id, name, executable_path, arguments, working_dir,
2094                environment, wine_dll_overrides, output_mod, enabled, updated_at
2095             )
2096             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))
2097             ON CONFLICT(game_id, name) DO UPDATE SET
2098                executable_path = excluded.executable_path,
2099                arguments = excluded.arguments,
2100                working_dir = excluded.working_dir,
2101                environment = excluded.environment,
2102                wine_dll_overrides = excluded.wine_dll_overrides,
2103                output_mod = excluded.output_mod,
2104                enabled = excluded.enabled,
2105                updated_at = excluded.updated_at",
2106            params![
2107                executable.game_id,
2108                executable.name,
2109                executable.executable_path.to_string_lossy().as_ref(),
2110                executable.arguments_json,
2111                executable
2112                    .working_dir
2113                    .as_ref()
2114                    .map(|p| p.to_string_lossy().to_string()),
2115                executable.environment_json,
2116                executable.wine_dll_overrides,
2117                executable.output_mod,
2118                i32::from(executable.enabled),
2119            ],
2120        )?;
2121        Ok(())
2122    }
2123
2124    /// Load every executable configured for a game, ordered by display name.
2125    pub fn load_executable_configs(&self, game_id: &GameId) -> Result<Vec<ExecutableConfigRow>> {
2126        let mut stmt = self.conn.prepare(
2127            "SELECT game_id, name, executable_path, arguments, working_dir,
2128                    environment, wine_dll_overrides, output_mod, enabled
2129             FROM executable_configs
2130             WHERE game_id = ?1
2131             ORDER BY name COLLATE NOCASE",
2132        )?;
2133
2134        let rows = stmt
2135            .query_map(params![game_id], executable_from_row)?
2136            .collect::<std::result::Result<Vec<_>, _>>()?;
2137
2138        Ok(rows)
2139    }
2140
2141    /// Load a single named executable for a game.
2142    pub fn load_executable_config(
2143        &self,
2144        game_id: &GameId,
2145        name: &str,
2146    ) -> Result<Option<ExecutableConfigRow>> {
2147        let result = self.conn.query_row(
2148            "SELECT game_id, name, executable_path, arguments, working_dir,
2149                    environment, wine_dll_overrides, output_mod, enabled
2150             FROM executable_configs
2151             WHERE game_id = ?1 AND name = ?2",
2152            params![game_id, name],
2153            executable_from_row,
2154        );
2155
2156        match result {
2157            Ok(row) => Ok(Some(row)),
2158            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2159            Err(e) => Err(e.into()),
2160        }
2161    }
2162
2163    /// Delete a named executable for a game. Returns whether a row was removed.
2164    pub fn delete_executable_config(&self, game_id: &GameId, name: &str) -> Result<bool> {
2165        let affected = self.conn.execute(
2166            "DELETE FROM executable_configs WHERE game_id = ?1 AND name = ?2",
2167            params![game_id, name],
2168        )?;
2169        Ok(affected > 0)
2170    }
2171}
2172
2173fn executable_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ExecutableConfigRow> {
2174    let executable_path: String = row.get(2)?;
2175    let working_dir: Option<String> = row.get(4)?;
2176    Ok(ExecutableConfigRow {
2177        game_id: row.get(0)?,
2178        name: row.get(1)?,
2179        executable_path: PathBuf::from(executable_path),
2180        arguments_json: row.get(3)?,
2181        working_dir: working_dir.map(PathBuf::from),
2182        environment_json: row.get(5)?,
2183        wine_dll_overrides: row.get(6)?,
2184        output_mod: row.get(7)?,
2185        enabled: row.get::<_, i32>(8)? != 0,
2186    })
2187}
2188
2189#[cfg(test)]
2190mod tests {
2191    use super::*;
2192
2193    fn test_db() -> ModdeDb {
2194        ModdeDb::open_memory().unwrap()
2195    }
2196
2197    fn sample_profile(name: &str, game_id: &str) -> Profile {
2198        Profile {
2199            id: None,
2200            name: name.to_string(),
2201            game_id: GameId::from(game_id),
2202            source: ProfileSource::Manual,
2203            mods: vec![
2204                EnabledMod {
2205                    mod_id: "mod_a".to_string(),
2206                    enabled: true,
2207                    version: Some("1.0".to_string()),
2208                    fomod_config: None,
2209                    ..Default::default()
2210                },
2211                EnabledMod {
2212                    mod_id: "mod_b".to_string(),
2213                    enabled: false,
2214                    version: None,
2215                    fomod_config: None,
2216                    ..Default::default()
2217                },
2218            ],
2219            overrides: PathBuf::from("/tmp/overrides"),
2220            load_order_rules: smallvec::smallvec![LoadOrderRule::LoadAfter {
2221                mod_id: ModId::from("mod_b"),
2222                after: ModId::from("mod_a"),
2223            }],
2224            load_order_lock: None,
2225        }
2226    }
2227
2228    #[test]
2229    fn create_and_load_profile() {
2230        let db = test_db();
2231        let profile = sample_profile("test", "skyrim-se");
2232
2233        let id = db.create_profile(&profile).unwrap();
2234        assert!(id > 0);
2235
2236        let loaded = db.load_profile("test", &GameId::from("skyrim-se")).unwrap();
2237        assert_eq!(loaded.name, "test");
2238        assert_eq!(loaded.game_id, "skyrim-se");
2239        assert_eq!(loaded.mods.len(), 2);
2240        assert_eq!(loaded.mods[0].mod_id, "mod_a");
2241        assert!(loaded.mods[0].enabled);
2242        assert_eq!(loaded.mods[1].mod_id, "mod_b");
2243        assert!(!loaded.mods[1].enabled);
2244        assert_eq!(loaded.load_order_rules.len(), 1);
2245    }
2246
2247    #[test]
2248    fn nexus_ids_roundtrip_with_unchanged_sqlite_values() {
2249        let db = test_db();
2250        let mut profile = sample_profile("test", "skyrim-se");
2251        profile.mods[0].nexus_mod_id = Some(NexusModId::from(42));
2252        profile.mods[0].nexus_file_id = Some(NexusFileId::from(99));
2253
2254        db.create_profile(&profile).unwrap();
2255
2256        let stored: (i64, i64) = db
2257            .conn
2258            .query_row(
2259                "SELECT nexus_mod_id, nexus_file_id FROM profile_mods WHERE mod_id = 'mod_a'",
2260                [],
2261                |row| Ok((row.get(0)?, row.get(1)?)),
2262            )
2263            .unwrap();
2264        assert_eq!(stored, (42, 99));
2265
2266        let loaded = db.load_profile("test", &GameId::from("skyrim-se")).unwrap();
2267        assert_eq!(loaded.mods[0].nexus_mod_id, Some(NexusModId::from(42)));
2268        assert_eq!(loaded.mods[0].nexus_file_id, Some(NexusFileId::from(99)));
2269    }
2270
2271    #[test]
2272    fn negative_nexus_ids_fail_closed_on_load() {
2273        let db = test_db();
2274        let profile = sample_profile("test", "skyrim-se");
2275        db.create_profile(&profile).unwrap();
2276        db.conn
2277            .execute(
2278                "UPDATE profile_mods SET nexus_mod_id = -1 WHERE mod_id = 'mod_a'",
2279                [],
2280            )
2281            .unwrap();
2282
2283        let err = db
2284            .load_profile("test", &GameId::from("skyrim-se"))
2285            .unwrap_err();
2286        assert!(matches!(err, CoreError::Database(_)));
2287    }
2288
2289    #[test]
2290    fn legacy_installer_metadata_loads_typed_and_roundtrips_storage() {
2291        let db = test_db();
2292        let profile = sample_profile("test", "skyrim-se");
2293        db.create_profile(&profile).unwrap();
2294
2295        let method_raw = encode_install_method(&InstallMethod::BareExtract).unwrap();
2296        let tags_raw = r#"["quest","ui"]"#;
2297        db.conn
2298            .execute(
2299                "UPDATE profile_mods
2300                    SET install_status = ?1, install_method = ?2, tags = ?3
2301                  WHERE mod_id = 'mod_a'",
2302                params!["pending_user_input", method_raw, tags_raw],
2303            )
2304            .unwrap();
2305
2306        let loaded = db.load_profile("test", &GameId::from("skyrim-se")).unwrap();
2307        assert_eq!(
2308            loaded.mods[0].install_status,
2309            Some(InstallStatus::PendingUserInput)
2310        );
2311        assert_eq!(
2312            loaded.mods[0].install_method,
2313            Some(InstallMethod::BareExtract)
2314        );
2315        assert_eq!(
2316            loaded.mods[0].tags,
2317            vec!["quest".to_string(), "ui".to_string()]
2318        );
2319        assert_eq!(loaded.mods[1].install_status, None);
2320
2321        db.update_profile(&loaded).unwrap();
2322        let stored: (String, String, String) = db
2323            .conn
2324            .query_row(
2325                "SELECT install_status, install_method, tags
2326                   FROM profile_mods
2327                  WHERE mod_id = 'mod_a'",
2328                [],
2329                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2330            )
2331            .unwrap();
2332        assert_eq!(
2333            stored,
2334            (
2335                "pending_user_input".to_string(),
2336                method_raw,
2337                tags_raw.to_string()
2338            )
2339        );
2340    }
2341
2342    #[test]
2343    fn load_by_name_unique() {
2344        let db = test_db();
2345        let profile = sample_profile("default", "skyrim-se");
2346        db.create_profile(&profile).unwrap();
2347
2348        let loaded = db.load_profile_by_name("default").unwrap();
2349        assert_eq!(loaded.game_id, "skyrim-se");
2350    }
2351
2352    #[test]
2353    fn load_by_name_ambiguous() {
2354        let db = test_db();
2355        db.create_profile(&sample_profile("default", "skyrim-se"))
2356            .unwrap();
2357        db.create_profile(&sample_profile("default", "fallout4"))
2358            .unwrap();
2359
2360        let err = db.load_profile_by_name("default").unwrap_err();
2361        match err {
2362            CoreError::AmbiguousProfile { name, games } => {
2363                assert_eq!(name, "default");
2364                assert!(games.contains(&GameId::from("skyrim-se")));
2365                assert!(games.contains(&GameId::from("fallout4")));
2366            }
2367            other => panic!("expected AmbiguousProfile, got: {other}"),
2368        }
2369    }
2370
2371    #[test]
2372    fn multi_profile_per_game() {
2373        let db = test_db();
2374        db.create_profile(&sample_profile("vanilla", "skyrim-se"))
2375            .unwrap();
2376        db.create_profile(&sample_profile("modded", "skyrim-se"))
2377            .unwrap();
2378        db.create_profile(&sample_profile("hardcore", "skyrim-se"))
2379            .unwrap();
2380
2381        let profiles = db.list_profiles(Some(&GameId::from("skyrim-se"))).unwrap();
2382        assert_eq!(profiles.len(), 3);
2383    }
2384
2385    #[test]
2386    fn update_profile() {
2387        let db = test_db();
2388        let mut profile = sample_profile("test", "skyrim-se");
2389        db.create_profile(&profile).unwrap();
2390
2391        profile.mods.push(EnabledMod {
2392            mod_id: "mod_c".to_string(),
2393            enabled: true,
2394            version: None,
2395            fomod_config: None,
2396            ..Default::default()
2397        });
2398
2399        db.update_profile(&profile).unwrap();
2400
2401        let loaded = db.load_profile("test", &GameId::from("skyrim-se")).unwrap();
2402        assert_eq!(loaded.mods.len(), 3);
2403    }
2404
2405    #[test]
2406    fn delete_profile() {
2407        let db = test_db();
2408        db.create_profile(&sample_profile("test", "skyrim-se"))
2409            .unwrap();
2410        db.delete_profile("test", &GameId::from("skyrim-se"))
2411            .unwrap();
2412
2413        let err = db
2414            .load_profile("test", &GameId::from("skyrim-se"))
2415            .unwrap_err();
2416        assert!(matches!(err, CoreError::ProfileNotFound(_)));
2417    }
2418
2419    #[test]
2420    fn delete_cascades_to_mods_and_saves() {
2421        let db = test_db();
2422        let id = db
2423            .create_profile(&sample_profile("test", "skyrim-se"))
2424            .unwrap();
2425        db.assign_save(id, Path::new("/saves/save1.ess"), Some("my save"))
2426            .unwrap();
2427
2428        let saves = db.list_saves(id).unwrap();
2429        assert_eq!(saves.len(), 1);
2430
2431        db.delete_profile("test", &GameId::from("skyrim-se"))
2432            .unwrap();
2433
2434        // Saves and mods should be cascade-deleted
2435        let saves = db.list_saves(id).unwrap();
2436        assert_eq!(saves.len(), 0);
2437    }
2438
2439    #[test]
2440    fn save_assignment() {
2441        let db = test_db();
2442        let id = db
2443            .create_profile(&sample_profile("test", "skyrim-se"))
2444            .unwrap();
2445
2446        db.assign_save(id, Path::new("/saves/save1.ess"), Some("Level 50"))
2447            .unwrap();
2448        db.assign_save(id, Path::new("/saves/save2.ess"), None)
2449            .unwrap();
2450
2451        let saves = db.list_saves(id).unwrap();
2452        assert_eq!(saves.len(), 2);
2453        assert_eq!(saves[0].label.as_deref(), Some("Level 50"));
2454        assert!(saves[1].label.is_none());
2455
2456        db.unassign_save(Path::new("/saves/save1.ess")).unwrap();
2457        let saves = db.list_saves(id).unwrap();
2458        assert_eq!(saves.len(), 1);
2459    }
2460
2461    #[test]
2462    fn save_already_assigned_to_different_profile() {
2463        let db = test_db();
2464        let id1 = db
2465            .create_profile(&sample_profile("profile1", "skyrim-se"))
2466            .unwrap();
2467        let id2 = db
2468            .create_profile(&sample_profile("profile2", "skyrim-se"))
2469            .unwrap();
2470
2471        db.assign_save(id1, Path::new("/saves/save1.ess"), None)
2472            .unwrap();
2473
2474        let err = db
2475            .assign_save(id2, Path::new("/saves/save1.ess"), None)
2476            .unwrap_err();
2477        assert!(matches!(err, CoreError::SaveAlreadyAssigned { .. }));
2478    }
2479
2480    #[test]
2481    fn snapshot_upsert_and_get() {
2482        let db = test_db();
2483
2484        db.upsert_snapshot(
2485            &GameId::from("skyrim-se"),
2486            Path::new("/stock/skyrim-se"),
2487            "abc123",
2488            5000,
2489        )
2490        .unwrap();
2491        let meta = db
2492            .get_snapshot(&GameId::from("skyrim-se"))
2493            .unwrap()
2494            .unwrap();
2495        assert_eq!(meta.tree_hash, "abc123");
2496        assert_eq!(meta.file_count, 5000);
2497
2498        // Upsert updates
2499        db.upsert_snapshot(
2500            &GameId::from("skyrim-se"),
2501            Path::new("/stock/skyrim-se"),
2502            "def456",
2503            5001,
2504        )
2505        .unwrap();
2506        let meta = db
2507            .get_snapshot(&GameId::from("skyrim-se"))
2508            .unwrap()
2509            .unwrap();
2510        assert_eq!(meta.tree_hash, "def456");
2511        assert_eq!(meta.file_count, 5001);
2512    }
2513
2514    #[test]
2515    fn snapshot_not_found() {
2516        let db = test_db();
2517        assert!(
2518            db.get_snapshot(&GameId::from("nonexistent"))
2519                .unwrap()
2520                .is_none()
2521        );
2522    }
2523
2524    #[test]
2525    fn list_profiles_all_and_by_game() {
2526        let db = test_db();
2527        db.create_profile(&sample_profile("vanilla", "skyrim-se"))
2528            .unwrap();
2529        db.create_profile(&sample_profile("modded", "skyrim-se"))
2530            .unwrap();
2531        db.create_profile(&sample_profile("default", "fallout4"))
2532            .unwrap();
2533
2534        let all = db.list_profiles(None).unwrap();
2535        assert_eq!(all.len(), 3);
2536
2537        let skyrim = db.list_profiles(Some(&GameId::from("skyrim-se"))).unwrap();
2538        assert_eq!(skyrim.len(), 2);
2539
2540        let fallout = db.list_profiles(Some(&GameId::from("fallout4"))).unwrap();
2541        assert_eq!(fallout.len(), 1);
2542    }
2543
2544    #[test]
2545    fn source_roundtrip_nexus_collection() {
2546        let db = test_db();
2547        let mut profile = sample_profile("test", "skyrim-se");
2548        profile.source = ProfileSource::NexusCollection {
2549            slug: "my-collection".to_string(),
2550            version: "1.2.3".to_string(),
2551        };
2552
2553        db.create_profile(&profile).unwrap();
2554        let loaded = db.load_profile("test", &GameId::from("skyrim-se")).unwrap();
2555
2556        match loaded.source {
2557            ProfileSource::NexusCollection { slug, version } => {
2558                assert_eq!(slug, "my-collection");
2559                assert_eq!(version, "1.2.3");
2560            }
2561            other => panic!("expected NexusCollection, got: {other:?}"),
2562        }
2563    }
2564
2565    #[test]
2566    fn source_roundtrip_wabbajack() {
2567        let db = test_db();
2568        let mut profile = sample_profile("test", "skyrim-se");
2569        profile.source = ProfileSource::Wabbajack {
2570            manifest_hash: "deadbeef".to_string(),
2571        };
2572
2573        db.create_profile(&profile).unwrap();
2574        let loaded = db.load_profile("test", &GameId::from("skyrim-se")).unwrap();
2575
2576        match loaded.source {
2577            ProfileSource::Wabbajack { manifest_hash } => {
2578                assert_eq!(manifest_hash, "deadbeef");
2579            }
2580            other => panic!("expected Wabbajack, got: {other:?}"),
2581        }
2582    }
2583
2584    #[test]
2585    fn profile_not_found() {
2586        let db = test_db();
2587        let err = db
2588            .load_profile("nonexistent", &GameId::from("skyrim-se"))
2589            .unwrap_err();
2590        assert!(matches!(err, CoreError::ProfileNotFound(_)));
2591    }
2592
2593    #[test]
2594    fn duplicate_profile_errors() {
2595        let db = test_db();
2596        db.create_profile(&sample_profile("test", "skyrim-se"))
2597            .unwrap();
2598
2599        let err = db
2600            .create_profile(&sample_profile("test", "skyrim-se"))
2601            .unwrap_err();
2602        assert!(matches!(err, CoreError::Database(_)));
2603    }
2604
2605    #[test]
2606    fn executable_config_roundtrip() {
2607        let db = test_db();
2608        let row = ExecutableConfigRow {
2609            game_id: "skyrim-se".to_string(),
2610            name: "xEdit".to_string(),
2611            executable_path: PathBuf::from("/tools/SSEEdit.exe"),
2612            arguments_json: serde_json::json!(["-IKnowWhatImDoing"]).to_string(),
2613            working_dir: Some(PathBuf::from("/games/Skyrim Special Edition")),
2614            environment_json: serde_json::json!({"WINESYNC": "1"}).to_string(),
2615            wine_dll_overrides: Some("dinput8=n,b".to_string()),
2616            output_mod: "xedit-output".to_string(),
2617            enabled: true,
2618        };
2619
2620        db.save_executable_config(&row).unwrap();
2621        let loaded = db
2622            .load_executable_config(&GameId::from("skyrim-se"), "xEdit")
2623            .unwrap()
2624            .unwrap();
2625        assert_eq!(loaded, row);
2626
2627        let all = db
2628            .load_executable_configs(&GameId::from("skyrim-se"))
2629            .unwrap();
2630        assert_eq!(all.len(), 1);
2631        assert!(
2632            db.delete_executable_config(&GameId::from("skyrim-se"), "xEdit")
2633                .unwrap()
2634        );
2635        assert!(
2636            db.load_executable_config(&GameId::from("skyrim-se"), "xEdit")
2637                .unwrap()
2638                .is_none()
2639        );
2640    }
2641}