1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4use smallvec::SmallVec;
5
6use crate::db::ModdeDb;
7pub use crate::db::ProfileSummary;
8use crate::error::{CoreError, Result};
9use crate::installer::{InstallMethod, InstallStatus};
10use crate::nexus_id::{NexusFileId, NexusModId};
11use crate::resolver::{GameId, LoadOrderRule};
12use crate::save::{SaveFingerprint, SaveManager};
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct EnabledMod {
17 pub mod_id: String,
18 #[serde(default)]
20 pub display_name: Option<String>,
21 pub enabled: bool,
22 #[serde(default)]
23 pub version: Option<String>,
24 #[serde(default)]
29 pub fomod_config: Option<String>,
30
31 #[serde(default)]
33 pub nexus_mod_id: Option<NexusModId>,
34 #[serde(default)]
35 pub nexus_file_id: Option<NexusFileId>,
36 #[serde(default)]
37 pub nexus_game_domain: Option<String>,
38 #[serde(default)]
39 pub installed_timestamp: Option<i64>,
40
41 #[serde(default)]
43 pub category_id: Option<i64>,
44 #[serde(default)]
45 pub notes: Option<String>,
46 #[serde(
48 default,
49 deserialize_with = "tags_serde::deserialize",
50 serialize_with = "tags_serde::serialize",
51 skip_serializing_if = "Vec::is_empty"
52 )]
53 pub tags: Vec<String>,
54
55 #[serde(default)]
61 pub lock: Option<LockReason>,
62
63 #[serde(
68 default,
69 deserialize_with = "install_method_serde::deserialize",
70 serialize_with = "install_method_serde::serialize",
71 skip_serializing_if = "Option::is_none"
72 )]
73 pub install_method: Option<InstallMethod>,
74
75 #[serde(default)]
78 pub source_archive_hash: Option<String>,
79
80 #[serde(
83 default,
84 deserialize_with = "install_status_serde::deserialize",
85 serialize_with = "install_status_serde::serialize",
86 skip_serializing_if = "Option::is_none"
87 )]
88 pub install_status: Option<InstallStatus>,
89}
90
91mod install_status_serde {
92 use serde::{Deserialize, Deserializer, Serializer};
93
94 use crate::installer::InstallStatus;
95
96 #[allow(clippy::ref_option)] pub fn serialize<S>(status: &Option<InstallStatus>, serializer: S) -> Result<S::Ok, S::Error>
98 where
99 S: Serializer,
100 {
101 match status {
102 Some(status) => serializer.serialize_some(status.as_str()),
103 None => serializer.serialize_none(),
104 }
105 }
106
107 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<InstallStatus>, D::Error>
108 where
109 D: Deserializer<'de>,
110 {
111 let raw = Option::<String>::deserialize(deserializer)?;
112 match raw.as_deref() {
113 None | Some("") => Ok(None),
114 Some(value) => InstallStatus::parse(value).map(Some).ok_or_else(|| {
115 serde::de::Error::custom(format!("unknown install_status: {value}"))
116 }),
117 }
118 }
119}
120
121mod install_method_serde {
122 use serde::{Deserialize, Deserializer, Serializer};
123
124 use crate::installer::InstallMethod;
125
126 #[allow(clippy::ref_option)] pub fn serialize<S>(method: &Option<InstallMethod>, serializer: S) -> Result<S::Ok, S::Error>
128 where
129 S: Serializer,
130 {
131 match method {
132 Some(method) => {
133 let encoded = toml::to_string(method).map_err(serde::ser::Error::custom)?;
134 serializer.serialize_some(&encoded)
135 }
136 None => serializer.serialize_none(),
137 }
138 }
139
140 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<InstallMethod>, D::Error>
141 where
142 D: Deserializer<'de>,
143 {
144 let raw = Option::<String>::deserialize(deserializer)?;
145 match raw.as_deref() {
146 None | Some("") => Ok(None),
147 Some(value) => toml::from_str::<InstallMethod>(value)
148 .map(Some)
149 .map_err(serde::de::Error::custom),
150 }
151 }
152}
153
154mod tags_serde {
155 use serde::{Deserialize, Deserializer, Serializer};
156
157 #[derive(Deserialize)]
158 #[serde(untagged)]
159 enum Tags {
160 JsonString(String),
161 List(Vec<String>),
162 }
163
164 pub fn serialize<S>(tags: &[String], serializer: S) -> Result<S::Ok, S::Error>
165 where
166 S: Serializer,
167 {
168 let encoded = serde_json::to_string(tags).map_err(serde::ser::Error::custom)?;
169 serializer.serialize_str(&encoded)
170 }
171
172 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
173 where
174 D: Deserializer<'de>,
175 {
176 match Tags::deserialize(deserializer)? {
177 Tags::JsonString(value) if value.is_empty() => Ok(Vec::new()),
178 Tags::JsonString(value) => {
179 serde_json::from_str::<Vec<String>>(&value).map_err(serde::de::Error::custom)
180 }
181 Tags::List(tags) => Ok(tags),
182 }
183 }
184}
185
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192pub enum ProfileSource {
193 #[default]
194 Manual,
195 NexusCollection {
196 slug: String,
197 version: String,
198 },
199 Wabbajack {
200 manifest_hash: String,
201 },
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
209pub enum LockReason {
210 Wabbajack { manifest_hash: String },
214 NexusCollection { slug: String, version: String },
216 TomlImport { source_path: String },
220 Manual {
222 #[serde(default)]
223 note: Option<String>,
224 },
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
234pub struct LoadOrderLock {
235 pub reason: LockReason,
236 pub locked_at: String,
238}
239
240impl LoadOrderLock {
241 #[must_use]
243 pub fn now(reason: LockReason) -> Self {
244 Self {
245 reason,
246 locked_at: current_utc_timestamp(),
247 }
248 }
249}
250
251fn current_utc_timestamp() -> String {
256 use std::time::{SystemTime, UNIX_EPOCH};
257 let secs = SystemTime::now()
258 .duration_since(UNIX_EPOCH)
259 .map_or(0, |d| d.as_secs() as i64);
260
261 let days = secs.div_euclid(86_400);
263 let sod = secs.rem_euclid(86_400) as u32;
264 let (h, rem) = (sod / 3600, sod % 3600);
265 let (m, s) = (rem / 60, rem % 60);
266
267 let z = days + 719_468;
269 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
270 let doe = (z - era * 146_097) as u32;
271 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
272 let y_off = era * 400 + i64::from(yoe);
273 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
274 let mp = (5 * doy + 2) / 153;
275 let d = doy - (153 * mp + 2) / 5 + 1;
276 let m_civ = if mp < 10 { mp + 3 } else { mp - 9 };
277 let y = if m_civ <= 2 { y_off + 1 } else { y_off };
278
279 format!("{y:04}-{m_civ:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct Profile {
285 #[serde(skip)]
287 pub id: Option<i64>,
288 pub name: String,
289 pub game_id: GameId,
290 pub source: ProfileSource,
291 pub mods: Vec<EnabledMod>,
292 pub overrides: PathBuf,
293 #[serde(default)]
296 pub load_order_rules: SmallVec<[LoadOrderRule; 4]>,
297 #[serde(default)]
301 pub load_order_lock: Option<LoadOrderLock>,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum ReorderDirection {
314 Up,
315 Down,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum ReorderError {
323 ProfileLocked { reason: LockReason },
325 ModPinned { mod_id: String, reason: LockReason },
327 ModNotFound { mod_id: String },
329 AdjacentPinned {
332 neighbor_id: String,
333 reason: LockReason,
334 },
335 AtBoundary,
337}
338
339pub fn try_reorder(
355 profile: &mut Profile,
356 mod_id: &str,
357 direction: ReorderDirection,
358) -> std::result::Result<(), ReorderError> {
359 if let Some(lock) = profile.load_order_lock.as_ref() {
360 return Err(ReorderError::ProfileLocked {
361 reason: lock.reason.clone(),
362 });
363 }
364
365 let idx = profile
366 .mods
367 .iter()
368 .position(|m| m.mod_id == mod_id)
369 .ok_or_else(|| ReorderError::ModNotFound {
370 mod_id: mod_id.to_string(),
371 })?;
372
373 if let Some(reason) = profile.mods[idx].lock.as_ref() {
374 return Err(ReorderError::ModPinned {
375 mod_id: mod_id.to_string(),
376 reason: reason.clone(),
377 });
378 }
379
380 let target_idx = match direction {
381 ReorderDirection::Up if idx > 0 => idx - 1,
382 ReorderDirection::Down if idx + 1 < profile.mods.len() => idx + 1,
383 _ => return Err(ReorderError::AtBoundary),
384 };
385
386 if let Some(reason) = profile.mods[target_idx].lock.as_ref() {
387 return Err(ReorderError::AdjacentPinned {
388 neighbor_id: profile.mods[target_idx].mod_id.clone(),
389 reason: reason.clone(),
390 });
391 }
392
393 profile.mods.swap(idx, target_idx);
394 Ok(())
395}
396
397pub fn validate_profile_name(name: &str) -> Result<()> {
400 if name.is_empty() {
401 return Err(CoreError::Validation("profile name cannot be empty".into()));
402 }
403 if name.len() > 255 {
404 return Err(CoreError::Validation(
405 "profile name too long (max 255 characters)".into(),
406 ));
407 }
408 if name.contains(['/', '\\', '\0', ':', '*', '?', '"', '<', '>', '|']) {
410 return Err(CoreError::Validation(
411 "profile name contains invalid characters (/ \\ NUL : * ? \" < > |)".into(),
412 ));
413 }
414 Ok(())
415}
416
417pub struct ProfileManager {
419 db: ModdeDb,
420}
421
422impl ProfileManager {
423 pub async fn open() -> Result<Self> {
425 let db = ModdeDb::open().await?;
426 Ok(Self { db })
427 }
428
429 pub fn with_db(db: ModdeDb) -> Self {
431 Self { db }
432 }
433
434 pub fn db(&self) -> &ModdeDb {
436 &self.db
437 }
438
439 pub async fn list(&self) -> Result<Vec<ProfileSummary>> {
441 self.db.list_profiles(None).await
442 }
443
444 pub async fn list_for_game(&self, game_id: &GameId) -> Result<Vec<ProfileSummary>> {
446 self.db.list_profiles(Some(game_id)).await
447 }
448
449 pub async fn load(&self, name: &str, game_id: Option<&GameId>) -> Result<Profile> {
451 match game_id {
452 Some(gid) => self.db.load_profile(name, gid).await,
453 None => self.db.load_profile_by_name(name).await,
454 }
455 }
456
457 pub async fn create(&self, profile: &Profile) -> Result<i64> {
459 validate_profile_name(&profile.name)?;
460 self.db.create_profile(profile).await
461 }
462
463 pub async fn update(&self, profile: &Profile) -> Result<()> {
465 self.db.update_profile(profile).await
466 }
467
468 pub async fn create_or_update(&self, profile: &Profile) -> Result<i64> {
470 validate_profile_name(&profile.name)?;
471 match self.db.create_profile(profile).await {
472 Ok(id) => Ok(id),
473 Err(CoreError::Database(_)) => {
474 self.db.update_profile(profile).await?;
475 let loaded = self
477 .db
478 .load_profile(&profile.name, &profile.game_id)
479 .await?;
480 Ok(loaded.id.unwrap_or(0))
481 }
482 Err(e) => Err(e),
483 }
484 }
485
486 pub async fn delete(&self, name: &str, game_id: Option<&GameId>) -> Result<()> {
488 if let Some(gid) = game_id {
489 self.db.delete_profile(name, gid).await
490 } else {
491 let profile = self.db.load_profile_by_name(name).await?;
493 self.db.delete_profile(name, &profile.game_id).await
494 }
495 }
496
497 pub async fn import_toml(&self, profiles_dir: &Path) -> Result<usize> {
499 self.db.import_toml_profiles(profiles_dir).await
500 }
501
502 #[must_use]
504 pub fn staging_dir(name: &str) -> PathBuf {
505 crate::paths::profiles_dir().join(name).join("staging")
506 }
507
508 #[must_use]
510 pub fn default_overrides(name: &str) -> PathBuf {
511 crate::paths::profiles_dir().join(name).join("overrides")
512 }
513
514 pub async fn activate(
528 &self,
529 name: &str,
530 game_id: &GameId,
531 save_dir: Option<&Path>,
532 ) -> Result<ActivateResult> {
533 self.activate_with_fingerprint(name, game_id, save_dir, None)
534 .await
535 }
536
537 pub async fn activate_with_fingerprint(
539 &self,
540 name: &str,
541 game_id: &GameId,
542 save_dir: Option<&Path>,
543 fingerprint: Option<&SaveFingerprint>,
544 ) -> Result<ActivateResult> {
545 let profile = self.db.load_profile(name, game_id).await?;
546 let profile_id = profile
547 .id
548 .ok_or_else(|| CoreError::Other("profile has no database ID".into()))?;
549
550 if let Some(dir) = save_dir {
551 let sm = SaveManager::new(&self.db);
552
553 if let Some(count) = sm.detect_unadopted(game_id, dir).await? {
555 return Ok(ActivateResult::AdoptionRequired { save_count: count });
556 }
557
558 let current = self.db.get_active_profile(game_id).await?;
560 let current_name = current.map(|(_, name)| name);
561
562 sm.activate_with_fingerprint(game_id, name, current_name.as_deref(), dir, fingerprint)?;
563 }
564
565 self.db.set_active_profile(game_id, profile_id).await?;
566
567 Ok(ActivateResult::Activated)
568 }
569
570 pub async fn try_profile(
575 &self,
576 name: &str,
577 game_id: &GameId,
578 save_dir: Option<&Path>,
579 ) -> Result<()> {
580 self.try_profile_with_fingerprint(name, game_id, save_dir, None)
581 .await
582 }
583
584 pub async fn try_profile_with_fingerprint(
586 &self,
587 name: &str,
588 game_id: &GameId,
589 save_dir: Option<&Path>,
590 fingerprint: Option<&SaveFingerprint>,
591 ) -> Result<()> {
592 let (current_id, current_name) = self
593 .db
594 .get_active_profile(game_id)
595 .await?
596 .ok_or_else(|| CoreError::NoActiveProfile(game_id.to_string()))?;
597
598 let new_profile = self.db.load_profile(name, game_id).await?;
599 let new_id = new_profile
600 .id
601 .ok_or_else(|| CoreError::Other("profile has no database ID".into()))?;
602
603 self.db.push_experiment(game_id, current_id).await?;
605
606 if let Some(dir) = save_dir {
607 let sm = SaveManager::new(&self.db);
608 sm.activate_with_fingerprint(game_id, name, Some(¤t_name), dir, fingerprint)?;
609 }
610
611 self.db.set_active_profile(game_id, new_id).await?;
612
613 Ok(())
614 }
615
616 pub async fn rollback(&self, game_id: &GameId, save_dir: Option<&Path>) -> Result<String> {
622 self.rollback_with_fingerprint(game_id, save_dir, None)
623 .await
624 }
625
626 pub async fn rollback_with_fingerprint(
628 &self,
629 game_id: &GameId,
630 save_dir: Option<&Path>,
631 fingerprint: Option<&SaveFingerprint>,
632 ) -> Result<String> {
633 let prev_id = self
634 .db
635 .pop_experiment(game_id)
636 .await?
637 .ok_or_else(|| CoreError::NotInExperiment(game_id.to_string()))?;
638
639 let (_current_id, current_name) = self
640 .db
641 .get_active_profile(game_id)
642 .await?
643 .ok_or_else(|| CoreError::NoActiveProfile(game_id.to_string()))?;
644
645 let prev_profile = self.db.load_profile_by_id(prev_id).await?;
646
647 if let Some(dir) = save_dir {
648 let sm = SaveManager::new(&self.db);
649 sm.activate_with_fingerprint(
650 game_id,
651 &prev_profile.name,
652 Some(¤t_name),
653 dir,
654 fingerprint,
655 )?;
656 }
657
658 self.db.set_active_profile(game_id, prev_id).await?;
659
660 Ok(prev_profile.name.clone())
661 }
662
663 pub async fn commit(&self, game_id: &GameId) -> Result<()> {
665 let depth = self.db.experiment_depth(game_id).await?;
666 if depth == 0 {
667 return Err(CoreError::NotInExperiment(game_id.to_string()));
668 }
669 self.db.clear_experiment_stack(game_id).await?;
670 Ok(())
671 }
672
673 pub async fn active(&self, game_id: &GameId) -> Result<Option<ActiveProfileInfo>> {
675 let (profile_id, _name) = match self.db.get_active_profile(game_id).await? {
676 Some(pair) => pair,
677 None => return Ok(None),
678 };
679
680 let profile = self.db.load_profile_by_id(profile_id).await?;
681 let experiment_depth = self.db.experiment_depth(game_id).await?;
682
683 Ok(Some(ActiveProfileInfo {
684 profile,
685 experiment_depth,
686 }))
687 }
688
689 pub async fn fork(&self, source_name: &str, new_name: &str, game_id: &GameId) -> Result<i64> {
697 self.fork_with_options(source_name, new_name, game_id, ForkOptions::default())
698 .await
699 }
700
701 pub async fn fork_with_options(
704 &self,
705 source_name: &str,
706 new_name: &str,
707 game_id: &GameId,
708 options: ForkOptions,
709 ) -> Result<i64> {
710 validate_profile_name(new_name)?;
711 let source = self.db.load_profile(source_name, game_id).await?;
712
713 let mut mods = source.mods.clone();
716 let mut load_order_lock = source.load_order_lock.clone();
717 if options.unlock {
718 load_order_lock = None;
719 for m in &mut mods {
720 m.lock = None;
721 }
722 }
723
724 let new_profile = Profile {
725 id: None,
726 name: new_name.to_string(),
727 game_id: game_id.clone(),
728 source: source.source.clone(),
729 mods,
730 overrides: Self::default_overrides(new_name),
731 load_order_rules: source.load_order_rules.clone(),
732 load_order_lock,
733 };
734
735 let new_id = self.db.create_profile(&new_profile).await?;
736
737 SaveManager::fork_saves(game_id, source_name, new_name)?;
739
740 Ok(new_id)
741 }
742}
743
744#[derive(Debug, Clone, Copy, Default)]
749pub struct ForkOptions {
750 pub unlock: bool,
755}
756
757#[derive(Debug)]
759pub struct ActiveProfileInfo {
760 pub profile: Profile,
761 pub experiment_depth: usize,
762}
763
764#[derive(Debug)]
766pub enum ActivateResult {
767 Activated,
769 AdoptionRequired { save_count: usize },
771}