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
150const 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
179const 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
202const 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#[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#[derive(Debug, Clone)]
243pub struct SaveEntry {
244 pub path: PathBuf,
245 pub label: Option<String>,
246 pub assigned_at: String,
247}
248
249#[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#[derive(Debug, Clone)]
261pub struct HiddenFile {
262 pub mod_id: String,
263 pub rel_path: String,
264}
265
266#[derive(Debug, Clone)]
268pub struct PluginEntry {
269 pub plugin_name: String,
270 pub sort_index: i64,
271 pub enabled: bool,
272}
273
274#[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
283pub struct ModdeDb {
285 conn: Connection,
286}
287
288impl ModdeDb {
289 pub fn open() -> Result<Self> {
291 let path = crate::paths::db_path();
292 Self::open_at(&path)
293 }
294
295 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn assign_save(&self, profile_id: i64, path: &Path, label: Option<&str>) -> Result<()> {
741 let path_str = path.to_string_lossy();
742
743 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1624fn 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
1680fn 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
1713fn 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
1725pub 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#[derive(Debug, Clone)]
1795pub struct ToolConfigRow {
1796 pub tool_id: String,
1797 pub enabled: bool,
1798 pub settings_json: String,
1799}
1800
1801#[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#[derive(Debug, Clone, PartialEq, Eq)]
1816pub struct ToolSettingHistoryEdge {
1817 pub parent_node_id: String,
1818 pub child_node_id: String,
1819}
1820
1821#[derive(Debug, Clone)]
1823pub struct ToolAppliedFileRow {
1824 pub tool_id: String,
1825 pub rel_path: String,
1826}
1827
1828#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}