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