1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4use smallvec::SmallVec;
5
6pub use crate::db::ProfileSummary;
7use crate::db::ModdeDb;
8use crate::error::{CoreError, Result};
9use crate::resolver::{GameId, LoadOrderRule};
10use crate::save::{SaveFingerprint, SaveManager};
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct EnabledMod {
15 pub mod_id: String,
16 #[serde(default)]
18 pub display_name: Option<String>,
19 pub enabled: bool,
20 #[serde(default)]
21 pub version: Option<String>,
22 #[serde(default)]
27 pub fomod_config: Option<String>,
28
29 #[serde(default)]
31 pub nexus_mod_id: Option<i64>,
32 #[serde(default)]
33 pub nexus_file_id: Option<i64>,
34 #[serde(default)]
35 pub nexus_game_domain: Option<String>,
36 #[serde(default)]
37 pub installed_timestamp: Option<i64>,
38
39 #[serde(default)]
41 pub category_id: Option<i64>,
42 #[serde(default)]
43 pub notes: Option<String>,
44 #[serde(default)]
46 pub tags: Option<String>,
47
48 #[serde(default)]
54 pub lock: Option<LockReason>,
55
56 #[serde(default)]
62 pub install_method: Option<String>,
63
64 #[serde(default)]
67 pub source_archive_hash: Option<String>,
68
69 #[serde(default)]
72 pub install_status: Option<String>,
73}
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
81pub enum ProfileSource {
82 #[default]
83 Manual,
84 NexusCollection { slug: String, version: String },
85 Wabbajack { manifest_hash: String },
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub enum LockReason {
94 Wabbajack { manifest_hash: String },
98 NexusCollection { slug: String, version: String },
100 TomlImport { source_path: String },
104 Manual {
106 #[serde(default)]
107 note: Option<String>,
108 },
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct LoadOrderLock {
119 pub reason: LockReason,
120 pub locked_at: String,
122}
123
124impl LoadOrderLock {
125 pub fn now(reason: LockReason) -> Self {
127 Self {
128 reason,
129 locked_at: current_utc_timestamp(),
130 }
131 }
132}
133
134fn current_utc_timestamp() -> String {
139 use std::time::{SystemTime, UNIX_EPOCH};
140 let secs = SystemTime::now()
141 .duration_since(UNIX_EPOCH)
142 .map(|d| d.as_secs() as i64)
143 .unwrap_or(0);
144
145 let days = secs.div_euclid(86_400);
147 let sod = secs.rem_euclid(86_400) as u32;
148 let (h, rem) = (sod / 3600, sod % 3600);
149 let (m, s) = (rem / 60, rem % 60);
150
151 let z = days + 719_468;
153 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
154 let doe = (z - era * 146_097) as u32;
155 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
156 let y_off = era * 400 + yoe as i64;
157 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
158 let mp = (5 * doy + 2) / 153;
159 let d = doy - (153 * mp + 2) / 5 + 1;
160 let m_civ = if mp < 10 { mp + 3 } else { mp - 9 };
161 let y = if m_civ <= 2 { y_off + 1 } else { y_off };
162
163 format!("{y:04}-{m_civ:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct Profile {
169 #[serde(skip)]
171 pub id: Option<i64>,
172 pub name: String,
173 pub game_id: GameId,
174 pub source: ProfileSource,
175 pub mods: Vec<EnabledMod>,
176 pub overrides: PathBuf,
177 #[serde(default)]
180 pub load_order_rules: SmallVec<[LoadOrderRule; 4]>,
181 #[serde(default)]
185 pub load_order_lock: Option<LoadOrderLock>,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum ReorderDirection {
198 Up,
199 Down,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
206pub enum ReorderError {
207 ProfileLocked { reason: LockReason },
209 ModPinned { mod_id: String, reason: LockReason },
211 ModNotFound { mod_id: String },
213 AdjacentPinned {
216 neighbor_id: String,
217 reason: LockReason,
218 },
219 AtBoundary,
221}
222
223pub fn try_reorder(
239 profile: &mut Profile,
240 mod_id: &str,
241 direction: ReorderDirection,
242) -> std::result::Result<(), ReorderError> {
243 if let Some(lock) = profile.load_order_lock.as_ref() {
244 return Err(ReorderError::ProfileLocked {
245 reason: lock.reason.clone(),
246 });
247 }
248
249 let idx = profile
250 .mods
251 .iter()
252 .position(|m| m.mod_id == mod_id)
253 .ok_or_else(|| ReorderError::ModNotFound {
254 mod_id: mod_id.to_string(),
255 })?;
256
257 if let Some(reason) = profile.mods[idx].lock.as_ref() {
258 return Err(ReorderError::ModPinned {
259 mod_id: mod_id.to_string(),
260 reason: reason.clone(),
261 });
262 }
263
264 let target_idx = match direction {
265 ReorderDirection::Up if idx > 0 => idx - 1,
266 ReorderDirection::Down if idx + 1 < profile.mods.len() => idx + 1,
267 _ => return Err(ReorderError::AtBoundary),
268 };
269
270 if let Some(reason) = profile.mods[target_idx].lock.as_ref() {
271 return Err(ReorderError::AdjacentPinned {
272 neighbor_id: profile.mods[target_idx].mod_id.clone(),
273 reason: reason.clone(),
274 });
275 }
276
277 profile.mods.swap(idx, target_idx);
278 Ok(())
279}
280
281pub fn validate_profile_name(name: &str) -> Result<()> {
284 if name.is_empty() {
285 return Err(CoreError::Validation("profile name cannot be empty".into()));
286 }
287 if name.len() > 255 {
288 return Err(CoreError::Validation("profile name too long (max 255 characters)".into()));
289 }
290 if name.contains(['/', '\\', '\0', ':', '*', '?', '"', '<', '>', '|']) {
292 return Err(CoreError::Validation(
293 "profile name contains invalid characters (/ \\ NUL : * ? \" < > |)".into(),
294 ));
295 }
296 Ok(())
297}
298
299pub struct ProfileManager {
301 db: ModdeDb,
302}
303
304impl ProfileManager {
305 pub fn open() -> Result<Self> {
307 let db = ModdeDb::open()?;
308 Ok(Self { db })
309 }
310
311 pub fn with_db(db: ModdeDb) -> Self {
313 Self { db }
314 }
315
316 pub fn db(&self) -> &ModdeDb {
318 &self.db
319 }
320
321 pub fn list(&self) -> Result<Vec<ProfileSummary>> {
323 self.db.list_profiles(None)
324 }
325
326 pub fn list_for_game(&self, game_id: &str) -> Result<Vec<ProfileSummary>> {
328 self.db.list_profiles(Some(game_id))
329 }
330
331 pub fn load(&self, name: &str, game_id: Option<&str>) -> Result<Profile> {
333 match game_id {
334 Some(gid) => self.db.load_profile(name, gid),
335 None => self.db.load_profile_by_name(name),
336 }
337 }
338
339 pub fn create(&self, profile: &Profile) -> Result<i64> {
341 validate_profile_name(&profile.name)?;
342 self.db.create_profile(profile)
343 }
344
345 pub fn update(&self, profile: &Profile) -> Result<()> {
347 self.db.update_profile(profile)
348 }
349
350 pub fn create_or_update(&self, profile: &Profile) -> Result<i64> {
352 validate_profile_name(&profile.name)?;
353 match self.db.create_profile(profile) {
354 Ok(id) => Ok(id),
355 Err(CoreError::Database(_)) => {
356 self.db.update_profile(profile)?;
357 let loaded = self.db.load_profile(&profile.name, profile.game_id.as_str())?;
359 Ok(loaded.id.unwrap_or(0))
360 }
361 Err(e) => Err(e),
362 }
363 }
364
365 pub fn delete(&self, name: &str, game_id: Option<&str>) -> Result<()> {
367 match game_id {
368 Some(gid) => self.db.delete_profile(name, gid),
369 None => {
370 let profile = self.db.load_profile_by_name(name)?;
372 self.db.delete_profile(name, profile.game_id.as_str())
373 }
374 }
375 }
376
377 pub fn import_toml(&self, profiles_dir: &Path) -> Result<usize> {
379 self.db.import_toml_profiles(profiles_dir)
380 }
381
382 pub fn staging_dir(name: &str) -> PathBuf {
384 crate::paths::profiles_dir().join(name).join("staging")
385 }
386
387 pub fn default_overrides(name: &str) -> PathBuf {
389 crate::paths::profiles_dir().join(name).join("overrides")
390 }
391
392 pub fn activate(
406 &self,
407 name: &str,
408 game_id: &str,
409 save_dir: Option<&Path>,
410 ) -> Result<ActivateResult> {
411 self.activate_with_fingerprint(name, game_id, save_dir, None)
412 }
413
414 pub fn activate_with_fingerprint(
416 &self,
417 name: &str,
418 game_id: &str,
419 save_dir: Option<&Path>,
420 fingerprint: Option<&SaveFingerprint>,
421 ) -> Result<ActivateResult> {
422 let profile = self.db.load_profile(name, game_id)?;
423 let profile_id = profile.id.ok_or_else(|| {
424 CoreError::Other("profile has no database ID".into())
425 })?;
426
427 if let Some(dir) = save_dir {
428 let sm = SaveManager::new(&self.db);
429
430 if let Some(count) = sm.detect_unadopted(game_id, dir)? {
432 return Ok(ActivateResult::AdoptionRequired { save_count: count });
433 }
434
435 let current = self.db.get_active_profile(game_id)?;
437 let current_name = current.map(|(_, name)| name);
438
439 sm.activate_with_fingerprint(
440 game_id,
441 name,
442 current_name.as_deref(),
443 dir,
444 fingerprint,
445 )?;
446 }
447
448 self.db.set_active_profile(game_id, profile_id)?;
449
450 Ok(ActivateResult::Activated)
451 }
452
453 pub fn try_profile(
458 &self,
459 name: &str,
460 game_id: &str,
461 save_dir: Option<&Path>,
462 ) -> Result<()> {
463 self.try_profile_with_fingerprint(name, game_id, save_dir, None)
464 }
465
466 pub fn try_profile_with_fingerprint(
468 &self,
469 name: &str,
470 game_id: &str,
471 save_dir: Option<&Path>,
472 fingerprint: Option<&SaveFingerprint>,
473 ) -> Result<()> {
474 let (current_id, current_name) = self.db.get_active_profile(game_id)?
475 .ok_or_else(|| CoreError::NoActiveProfile(game_id.to_string()))?;
476
477 let new_profile = self.db.load_profile(name, game_id)?;
478 let new_id = new_profile.id.ok_or_else(|| {
479 CoreError::Other("profile has no database ID".into())
480 })?;
481
482 self.db.push_experiment(game_id, current_id)?;
484
485 if let Some(dir) = save_dir {
486 let sm = SaveManager::new(&self.db);
487 sm.activate_with_fingerprint(
488 game_id,
489 name,
490 Some(¤t_name),
491 dir,
492 fingerprint,
493 )?;
494 }
495
496 self.db.set_active_profile(game_id, new_id)?;
497
498 Ok(())
499 }
500
501 pub fn rollback(
507 &self,
508 game_id: &str,
509 save_dir: Option<&Path>,
510 ) -> Result<String> {
511 self.rollback_with_fingerprint(game_id, save_dir, None)
512 }
513
514 pub fn rollback_with_fingerprint(
516 &self,
517 game_id: &str,
518 save_dir: Option<&Path>,
519 fingerprint: Option<&SaveFingerprint>,
520 ) -> Result<String> {
521 let prev_id = self.db.pop_experiment(game_id)?
522 .ok_or_else(|| CoreError::NotInExperiment(game_id.to_string()))?;
523
524 let (_current_id, current_name) = self.db.get_active_profile(game_id)?
525 .ok_or_else(|| CoreError::NoActiveProfile(game_id.to_string()))?;
526
527 let prev_profile = self.db.load_profile_by_id(prev_id)?;
528
529 if let Some(dir) = save_dir {
530 let sm = SaveManager::new(&self.db);
531 sm.activate_with_fingerprint(
532 game_id,
533 &prev_profile.name,
534 Some(¤t_name),
535 dir,
536 fingerprint,
537 )?;
538 }
539
540 self.db.set_active_profile(game_id, prev_id)?;
541
542 Ok(prev_profile.name.clone())
543 }
544
545 pub fn commit(&self, game_id: &str) -> Result<()> {
547 let depth = self.db.experiment_depth(game_id)?;
548 if depth == 0 {
549 return Err(CoreError::NotInExperiment(game_id.to_string()));
550 }
551 self.db.clear_experiment_stack(game_id)?;
552 Ok(())
553 }
554
555 pub fn active(&self, game_id: &str) -> Result<Option<ActiveProfileInfo>> {
557 let (profile_id, _name) = match self.db.get_active_profile(game_id)? {
558 Some(pair) => pair,
559 None => return Ok(None),
560 };
561
562 let profile = self.db.load_profile_by_id(profile_id)?;
563 let experiment_depth = self.db.experiment_depth(game_id)?;
564
565 Ok(Some(ActiveProfileInfo {
566 profile,
567 experiment_depth,
568 }))
569 }
570
571 pub fn fork(
579 &self,
580 source_name: &str,
581 new_name: &str,
582 game_id: &str,
583 ) -> Result<i64> {
584 self.fork_with_options(source_name, new_name, game_id, ForkOptions::default())
585 }
586
587 pub fn fork_with_options(
590 &self,
591 source_name: &str,
592 new_name: &str,
593 game_id: &str,
594 options: ForkOptions,
595 ) -> Result<i64> {
596 validate_profile_name(new_name)?;
597 let source = self.db.load_profile(source_name, game_id)?;
598
599 let mut mods = source.mods.clone();
602 let mut load_order_lock = source.load_order_lock.clone();
603 if options.unlock {
604 load_order_lock = None;
605 for m in &mut mods {
606 m.lock = None;
607 }
608 }
609
610 let new_profile = Profile {
611 id: None,
612 name: new_name.to_string(),
613 game_id: GameId::from(game_id),
614 source: source.source.clone(),
615 mods,
616 overrides: Self::default_overrides(new_name),
617 load_order_rules: source.load_order_rules.clone(),
618 load_order_lock,
619 };
620
621 let new_id = self.db.create_profile(&new_profile)?;
622
623 SaveManager::fork_saves(game_id, source_name, new_name)?;
625
626 Ok(new_id)
627 }
628}
629
630#[derive(Debug, Clone, Copy, Default)]
635pub struct ForkOptions {
636 pub unlock: bool,
641}
642
643#[derive(Debug)]
645pub struct ActiveProfileInfo {
646 pub profile: Profile,
647 pub experiment_depth: usize,
648}
649
650#[derive(Debug)]
652pub enum ActivateResult {
653 Activated,
655 AdoptionRequired { save_count: usize },
657}