Skip to main content

modde_core/db/
mod.rs

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