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 fn open() -> Result<Self> {
425 let db = ModdeDb::open()?;
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 fn list(&self) -> Result<Vec<ProfileSummary>> {
441 self.db.list_profiles(None)
442 }
443
444 pub fn list_for_game(&self, game_id: &GameId) -> Result<Vec<ProfileSummary>> {
446 self.db.list_profiles(Some(game_id))
447 }
448
449 pub fn load(&self, name: &str, game_id: Option<&GameId>) -> Result<Profile> {
451 match game_id {
452 Some(gid) => self.db.load_profile(name, gid),
453 None => self.db.load_profile_by_name(name),
454 }
455 }
456
457 pub fn create(&self, profile: &Profile) -> Result<i64> {
459 validate_profile_name(&profile.name)?;
460 self.db.create_profile(profile)
461 }
462
463 pub fn update(&self, profile: &Profile) -> Result<()> {
465 self.db.update_profile(profile)
466 }
467
468 pub fn create_or_update(&self, profile: &Profile) -> Result<i64> {
470 validate_profile_name(&profile.name)?;
471 match self.db.create_profile(profile) {
472 Ok(id) => Ok(id),
473 Err(CoreError::Database(_)) => {
474 self.db.update_profile(profile)?;
475 let loaded = self.db.load_profile(&profile.name, &profile.game_id)?;
477 Ok(loaded.id.unwrap_or(0))
478 }
479 Err(e) => Err(e),
480 }
481 }
482
483 pub fn delete(&self, name: &str, game_id: Option<&GameId>) -> Result<()> {
485 if let Some(gid) = game_id {
486 self.db.delete_profile(name, gid)
487 } else {
488 let profile = self.db.load_profile_by_name(name)?;
490 self.db.delete_profile(name, &profile.game_id)
491 }
492 }
493
494 pub fn import_toml(&self, profiles_dir: &Path) -> Result<usize> {
496 self.db.import_toml_profiles(profiles_dir)
497 }
498
499 #[must_use]
501 pub fn staging_dir(name: &str) -> PathBuf {
502 crate::paths::profiles_dir().join(name).join("staging")
503 }
504
505 #[must_use]
507 pub fn default_overrides(name: &str) -> PathBuf {
508 crate::paths::profiles_dir().join(name).join("overrides")
509 }
510
511 pub fn activate(
525 &self,
526 name: &str,
527 game_id: &GameId,
528 save_dir: Option<&Path>,
529 ) -> Result<ActivateResult> {
530 self.activate_with_fingerprint(name, game_id, save_dir, None)
531 }
532
533 pub fn activate_with_fingerprint(
535 &self,
536 name: &str,
537 game_id: &GameId,
538 save_dir: Option<&Path>,
539 fingerprint: Option<&SaveFingerprint>,
540 ) -> Result<ActivateResult> {
541 let profile = self.db.load_profile(name, game_id)?;
542 let profile_id = profile
543 .id
544 .ok_or_else(|| CoreError::Other("profile has no database ID".into()))?;
545
546 if let Some(dir) = save_dir {
547 let sm = SaveManager::new(&self.db);
548
549 if let Some(count) = sm.detect_unadopted(game_id, dir)? {
551 return Ok(ActivateResult::AdoptionRequired { save_count: count });
552 }
553
554 let current = self.db.get_active_profile(game_id)?;
556 let current_name = current.map(|(_, name)| name);
557
558 sm.activate_with_fingerprint(game_id, name, current_name.as_deref(), dir, fingerprint)?;
559 }
560
561 self.db.set_active_profile(game_id, profile_id)?;
562
563 Ok(ActivateResult::Activated)
564 }
565
566 pub fn try_profile(&self, name: &str, game_id: &GameId, save_dir: Option<&Path>) -> Result<()> {
571 self.try_profile_with_fingerprint(name, game_id, save_dir, None)
572 }
573
574 pub fn try_profile_with_fingerprint(
576 &self,
577 name: &str,
578 game_id: &GameId,
579 save_dir: Option<&Path>,
580 fingerprint: Option<&SaveFingerprint>,
581 ) -> Result<()> {
582 let (current_id, current_name) = self
583 .db
584 .get_active_profile(game_id)?
585 .ok_or_else(|| CoreError::NoActiveProfile(game_id.to_string()))?;
586
587 let new_profile = self.db.load_profile(name, game_id)?;
588 let new_id = new_profile
589 .id
590 .ok_or_else(|| CoreError::Other("profile has no database ID".into()))?;
591
592 self.db.push_experiment(game_id, current_id)?;
594
595 if let Some(dir) = save_dir {
596 let sm = SaveManager::new(&self.db);
597 sm.activate_with_fingerprint(game_id, name, Some(¤t_name), dir, fingerprint)?;
598 }
599
600 self.db.set_active_profile(game_id, new_id)?;
601
602 Ok(())
603 }
604
605 pub fn rollback(&self, game_id: &GameId, save_dir: Option<&Path>) -> Result<String> {
611 self.rollback_with_fingerprint(game_id, save_dir, None)
612 }
613
614 pub fn rollback_with_fingerprint(
616 &self,
617 game_id: &GameId,
618 save_dir: Option<&Path>,
619 fingerprint: Option<&SaveFingerprint>,
620 ) -> Result<String> {
621 let prev_id = self
622 .db
623 .pop_experiment(game_id)?
624 .ok_or_else(|| CoreError::NotInExperiment(game_id.to_string()))?;
625
626 let (_current_id, current_name) = self
627 .db
628 .get_active_profile(game_id)?
629 .ok_or_else(|| CoreError::NoActiveProfile(game_id.to_string()))?;
630
631 let prev_profile = self.db.load_profile_by_id(prev_id)?;
632
633 if let Some(dir) = save_dir {
634 let sm = SaveManager::new(&self.db);
635 sm.activate_with_fingerprint(
636 game_id,
637 &prev_profile.name,
638 Some(¤t_name),
639 dir,
640 fingerprint,
641 )?;
642 }
643
644 self.db.set_active_profile(game_id, prev_id)?;
645
646 Ok(prev_profile.name.clone())
647 }
648
649 pub fn commit(&self, game_id: &GameId) -> Result<()> {
651 let depth = self.db.experiment_depth(game_id)?;
652 if depth == 0 {
653 return Err(CoreError::NotInExperiment(game_id.to_string()));
654 }
655 self.db.clear_experiment_stack(game_id)?;
656 Ok(())
657 }
658
659 pub fn active(&self, game_id: &GameId) -> Result<Option<ActiveProfileInfo>> {
661 let (profile_id, _name) = match self.db.get_active_profile(game_id)? {
662 Some(pair) => pair,
663 None => return Ok(None),
664 };
665
666 let profile = self.db.load_profile_by_id(profile_id)?;
667 let experiment_depth = self.db.experiment_depth(game_id)?;
668
669 Ok(Some(ActiveProfileInfo {
670 profile,
671 experiment_depth,
672 }))
673 }
674
675 pub fn fork(&self, source_name: &str, new_name: &str, game_id: &GameId) -> Result<i64> {
683 self.fork_with_options(source_name, new_name, game_id, ForkOptions::default())
684 }
685
686 pub fn fork_with_options(
689 &self,
690 source_name: &str,
691 new_name: &str,
692 game_id: &GameId,
693 options: ForkOptions,
694 ) -> Result<i64> {
695 validate_profile_name(new_name)?;
696 let source = self.db.load_profile(source_name, game_id)?;
697
698 let mut mods = source.mods.clone();
701 let mut load_order_lock = source.load_order_lock.clone();
702 if options.unlock {
703 load_order_lock = None;
704 for m in &mut mods {
705 m.lock = None;
706 }
707 }
708
709 let new_profile = Profile {
710 id: None,
711 name: new_name.to_string(),
712 game_id: game_id.clone(),
713 source: source.source.clone(),
714 mods,
715 overrides: Self::default_overrides(new_name),
716 load_order_rules: source.load_order_rules.clone(),
717 load_order_lock,
718 };
719
720 let new_id = self.db.create_profile(&new_profile)?;
721
722 SaveManager::fork_saves(game_id, source_name, new_name)?;
724
725 Ok(new_id)
726 }
727}
728
729#[derive(Debug, Clone, Copy, Default)]
734pub struct ForkOptions {
735 pub unlock: bool,
740}
741
742#[derive(Debug)]
744pub struct ActiveProfileInfo {
745 pub profile: Profile,
746 pub experiment_depth: usize,
747}
748
749#[derive(Debug)]
751pub enum ActivateResult {
752 Activated,
754 AdoptionRequired { save_count: usize },
756}