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