1use std::fmt::Write;
2use std::path::{Path, PathBuf};
3
4use git2::{IndexAddOption, Repository, RepositoryInitOptions, Signature};
5use sha2::{Digest, Sha256};
6use tracing::info;
7
8use smallvec::SmallVec;
9
10use crate::db::{ModdeDb, SaveEntry};
11use crate::error::{CoreError, Result};
12use crate::profile::EnabledMod;
13use crate::resolver::GameId;
14
15const STEAM_CLOUD_MARKER: &str = "steam_autocloud.vdf";
16const MODDE_LIVE_STATE_DIR: &str = ".modde";
17const MODDE_PROFILE_PARK_DIR: &str = "profiles";
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct SaveFingerprint {
30 pub hash: String,
32 pub mod_ids: SmallVec<[String; 8]>,
35}
36
37const FINGERPRINT_TRAILER: &str = "Mod-Fingerprint";
39
40const MODS_TRAILER: &str = "Save-Breaking-Mods";
42
43impl SaveFingerprint {
44 pub fn compute(mods: &[EnabledMod], classify: impl Fn(&str) -> bool) -> Self {
51 let mut breaking_ids: Vec<&str> = mods
52 .iter()
53 .filter(|m| m.enabled && classify(&m.mod_id))
54 .map(|m| m.mod_id.as_str())
55 .collect();
56 breaking_ids.sort_unstable();
57 breaking_ids.dedup();
58
59 let mut hasher = Sha256::new();
60 for id in &breaking_ids {
61 hasher.update(id.as_bytes());
62 hasher.update(b"\0");
63 }
64 let mut hash = String::with_capacity(64);
65 for byte in hasher.finalize() {
66 write!(&mut hash, "{byte:02x}").expect("writing to String cannot fail");
67 }
68
69 Self {
70 hash,
71 mod_ids: breaking_ids.into_iter().map(String::from).collect(),
72 }
73 }
74
75 #[must_use]
77 pub fn short_hash(&self) -> &str {
78 &self.hash[..self.hash.len().min(12)]
79 }
80
81 #[must_use]
83 pub fn empty() -> Self {
84 Self {
85 hash: "0".repeat(64),
86 mod_ids: SmallVec::new(),
87 }
88 }
89
90 #[must_use]
91 pub fn is_empty(&self) -> bool {
92 self.mod_ids.is_empty()
93 }
94
95 fn to_trailers(&self) -> String {
97 let mut s = format!("{FINGERPRINT_TRAILER}: {}", self.short_hash());
98 if !self.mod_ids.is_empty() {
99 s.push_str(&format!("\n{MODS_TRAILER}: {}", self.mod_ids.join(", ")));
100 }
101 s
102 }
103
104 fn from_commit_message(message: &str) -> Option<Self> {
106 let mut hash = None;
107 let mut mod_ids = SmallVec::new();
108
109 for line in message.lines() {
110 if let Some(value) = line.strip_prefix(&format!("{FINGERPRINT_TRAILER}: ")) {
111 hash = Some(value.trim().to_string());
112 } else if let Some(value) = line.strip_prefix(&format!("{MODS_TRAILER}: ")) {
113 mod_ids = value
114 .split(", ")
115 .map(|s| s.trim().to_string())
116 .filter(|s| !s.is_empty())
117 .collect();
118 }
119 }
120
121 hash.map(|h| Self { hash: h, mod_ids })
122 }
123}
124
125#[derive(Debug, Clone)]
127pub enum FingerprintCheck {
128 Compatible,
130 NoFingerprint,
132 Mismatch {
134 removed: SmallVec<[String; 4]>,
137 added: SmallVec<[String; 4]>,
139 },
140}
141
142impl FingerprintCheck {
143 #[must_use]
144 pub fn is_compatible(&self) -> bool {
145 matches!(self, Self::Compatible | Self::NoFingerprint)
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct SaveSnapshot {
152 pub id: String,
154 pub message: String,
156 pub timestamp: i64,
158 pub file_count: usize,
160 pub fingerprint: Option<SaveFingerprint>,
162 pub profile_name: Option<String>,
164 pub character_name: Option<String>,
166 pub save_label: Option<String>,
168 pub category: Option<String>,
170}
171
172impl SaveSnapshot {
173 #[must_use]
176 pub fn short_id(&self) -> &str {
177 &self.id[..self.id.len().min(8)]
178 }
179
180 #[must_use]
182 pub fn display_title(&self) -> String {
183 if let (Some(char_name), Some(label)) = (&self.character_name, &self.save_label) {
184 format!("{char_name} — {label}")
185 } else if let Some(char_name) = &self.character_name {
186 char_name.clone()
187 } else if let Some(label) = &self.save_label {
188 label.clone()
189 } else {
190 self.message.lines().next().unwrap_or("").trim().to_string()
191 }
192 }
193
194 fn parse_metadata_from_message(&mut self) {
202 let first_line = self.message.lines().next().unwrap_or("").trim();
203
204 if let Some(rest) = first_line.strip_prefix("capture saves for profile '") {
206 if let Some(name) = rest.strip_suffix('\'') {
207 self.profile_name = Some(name.to_string());
208 }
209 return;
210 }
211
212 let body = if let Some(rest) = first_line.strip_prefix("capture: ") {
214 rest
215 } else if first_line.starts_with("capture (") {
216 if let Some(idx) = first_line.find("): ") {
218 &first_line[idx + 3..]
219 } else {
220 return;
221 }
222 } else {
223 return;
224 };
225
226 let (body, category) = if let Some(bracket_start) = body.rfind(" [") {
228 if body.ends_with(']') {
229 let cat = &body[bracket_start + 2..body.len() - 1];
230 self.category = Some(cat.to_string());
231 (&body[..bracket_start], Some(cat.to_string()))
232 } else {
233 (body, None)
234 }
235 } else {
236 (body, None)
237 };
238 let _ = category; if let Some((char_part, save_part)) = body.split_once(" — ") {
242 if char_part.ends_with("saves")
244 && char_part.chars().next().is_some_and(|c| c.is_ascii_digit())
245 {
246 self.save_label = Some(first_line.to_string());
248 } else {
249 self.character_name = Some(char_part.to_string());
250 self.save_label = Some(save_part.to_string());
251 }
252 } else if body != "no new saves" {
253 self.save_label = Some(body.to_string());
255 }
256 }
257
258 #[must_use]
260 pub fn check_compatibility(&self, current: &SaveFingerprint) -> FingerprintCheck {
261 let stored = match &self.fingerprint {
262 Some(fp) => fp,
263 None => return FingerprintCheck::NoFingerprint,
264 };
265
266 if stored.hash == current.hash || stored.short_hash() == current.short_hash() {
267 return FingerprintCheck::Compatible;
268 }
269
270 let stored_set: std::collections::HashSet<&str> = stored
271 .mod_ids
272 .iter()
273 .map(std::string::String::as_str)
274 .collect();
275 let current_set: std::collections::HashSet<&str> = current
276 .mod_ids
277 .iter()
278 .map(std::string::String::as_str)
279 .collect();
280
281 let removed: SmallVec<[String; 4]> = stored_set
282 .difference(¤t_set)
283 .map(std::string::ToString::to_string)
284 .collect();
285 let added: SmallVec<[String; 4]> = current_set
286 .difference(&stored_set)
287 .map(std::string::ToString::to_string)
288 .collect();
289
290 FingerprintCheck::Mismatch { removed, added }
291 }
292}
293
294pub struct SaveManager<'a> {
303 db: &'a ModdeDb,
304}
305
306impl<'a> SaveManager<'a> {
307 pub fn new(db: &'a ModdeDb) -> Self {
308 Self { db }
309 }
310
311 pub fn init_vault(game_id: &GameId) -> Result<Repository> {
315 let vault_path = crate::paths::save_vault_dir(game_id);
316 if vault_path.join(".git").exists() {
317 return Repository::open(&vault_path)
318 .map_err(|e| CoreError::SaveVaultError(format!("failed to open vault: {e}")));
319 }
320
321 std::fs::create_dir_all(&vault_path)?;
322 let mut opts = RepositoryInitOptions::new();
323 opts.external_template(false).initial_head("main");
324 let repo = Repository::init_opts(&vault_path, &opts)
325 .map_err(|e| CoreError::SaveVaultError(format!("failed to init vault: {e}")))?;
326
327 {
329 let sig = vault_signature();
330 let mut index = repo
331 .index()
332 .map_err(|e| CoreError::SaveVaultError(format!("failed to get index: {e}")))?;
333 let tree_oid = index
334 .write_tree()
335 .map_err(|e| CoreError::SaveVaultError(format!("failed to write tree: {e}")))?;
336 let tree = repo
337 .find_tree(tree_oid)
338 .map_err(|e| CoreError::SaveVaultError(format!("failed to find tree: {e}")))?;
339 repo.commit(Some("HEAD"), &sig, &sig, "init save vault", &tree, &[])
340 .map_err(|e| {
341 CoreError::SaveVaultError(format!("failed to create initial commit: {e}"))
342 })?;
343 }
344
345 info!(game_id = %game_id, path = %vault_path.display(), "initialized save vault");
346 Ok(repo)
347 }
348
349 pub fn vault_repo(game_id: &GameId) -> Result<Repository> {
351 let vault_path = crate::paths::save_vault_dir(game_id);
352 if vault_path.join(".git").exists() {
353 Repository::open(&vault_path)
354 .map_err(|e| CoreError::SaveVaultError(format!("failed to open vault: {e}")))
355 } else {
356 Self::init_vault(game_id)
357 }
358 }
359
360 pub fn ensure_branch(game_id: &GameId, profile_name: &str) -> Result<()> {
364 let repo = Self::vault_repo(game_id)?;
365 let branch_name = sanitize_branch_name(profile_name);
366
367 if repo
368 .find_branch(&branch_name, git2::BranchType::Local)
369 .is_ok()
370 {
371 return Ok(());
372 }
373
374 let head_commit = repo
375 .head()
376 .and_then(|h| h.peel_to_commit())
377 .map_err(|e| CoreError::SaveVaultError(format!("failed to get HEAD: {e}")))?;
378
379 repo.branch(&branch_name, &head_commit, false)
380 .map_err(|e| {
381 CoreError::SaveVaultError(format!("failed to create branch '{branch_name}': {e}"))
382 })?;
383
384 info!(game_id = %game_id, branch = %branch_name, "created save branch");
385 Ok(())
386 }
387
388 pub fn checkout_branch(game_id: &GameId, profile_name: &str) -> Result<()> {
390 let repo = Self::vault_repo(game_id)?;
391 let branch_name = sanitize_branch_name(profile_name);
392
393 Self::ensure_branch(game_id, profile_name)?;
394
395 let branch = repo
396 .find_branch(&branch_name, git2::BranchType::Local)
397 .map_err(|e| {
398 CoreError::SaveVaultError(format!("branch '{branch_name}' not found: {e}"))
399 })?;
400
401 let refname = branch
402 .get()
403 .name()
404 .ok_or_else(|| CoreError::SaveVaultError("invalid branch ref name".into()))?
405 .to_string();
406
407 let obj = repo
408 .revparse_single(&refname)
409 .map_err(|e| CoreError::SaveVaultError(format!("failed to resolve branch: {e}")))?;
410
411 repo.checkout_tree(&obj, Some(git2::build::CheckoutBuilder::new().force()))
412 .map_err(|e| CoreError::SaveVaultError(format!("checkout failed: {e}")))?;
413
414 repo.set_head(&refname)
415 .map_err(|e| CoreError::SaveVaultError(format!("failed to set HEAD: {e}")))?;
416
417 info!(game_id = %game_id, branch = %branch_name, "checked out save branch");
418 Ok(())
419 }
420
421 pub fn capture_with_fingerprint(
429 &self,
430 game_id: &GameId,
431 profile_name: &str,
432 game_save_dir: &Path,
433 fingerprint: Option<&SaveFingerprint>,
434 ) -> Result<usize> {
435 if !game_save_dir.exists() {
436 return Ok(0);
437 }
438
439 let repo = Self::vault_repo(game_id)?;
440 let vault_path = crate::paths::save_vault_dir(game_id);
441
442 Self::checkout_branch(game_id, profile_name)?;
443
444 remove_live_metadata_from_vault(&vault_path)?;
445
446 let count =
447 copy_dir_contents_filtered(game_save_dir, &vault_path, |name| !is_live_metadata(name))?;
448
449 if count == 0 {
450 return Ok(0);
451 }
452
453 let mut index = repo
455 .index()
456 .map_err(|e| CoreError::SaveVaultError(format!("failed to get index: {e}")))?;
457
458 index
459 .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)
460 .map_err(|e| CoreError::SaveVaultError(format!("failed to stage files: {e}")))?;
461
462 let mut to_remove = Vec::new();
464 for entry in index.iter() {
465 let path = String::from_utf8_lossy(&entry.path).to_string();
466 if !vault_path.join(&path).exists() {
467 to_remove.push(path);
468 }
469 }
470 for path in &to_remove {
471 index.remove_path(Path::new(path)).map_err(|e| {
472 CoreError::SaveVaultError(format!("failed to remove from index: {e}"))
473 })?;
474 }
475
476 index
477 .write()
478 .map_err(|e| CoreError::SaveVaultError(format!("failed to write index: {e}")))?;
479
480 let tree_oid = index
481 .write_tree()
482 .map_err(|e| CoreError::SaveVaultError(format!("failed to write tree: {e}")))?;
483 let tree = repo
484 .find_tree(tree_oid)
485 .map_err(|e| CoreError::SaveVaultError(format!("failed to find tree: {e}")))?;
486
487 let head_commit = repo
488 .head()
489 .and_then(|h| h.peel_to_commit())
490 .map_err(|e| CoreError::SaveVaultError(format!("failed to get HEAD: {e}")))?;
491
492 if tree_oid == head_commit.tree_id() {
494 info!(
495 game_id = %game_id,
496 profile = profile_name,
497 "saves unchanged, skipping commit"
498 );
499 return Ok(0);
500 }
501
502 let sig = vault_signature();
503
504 let mut message = format!("capture saves for profile '{profile_name}'");
505 if let Some(fp) = fingerprint {
506 message.push_str("\n\n");
507 message.push_str(&fp.to_trailers());
508 }
509
510 repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&head_commit])
511 .map_err(|e| CoreError::SaveVaultError(format!("failed to commit: {e}")))?;
512
513 info!(game_id = %game_id, profile = profile_name, count, "captured saves");
514 Ok(count)
515 }
516
517 pub fn capture(
519 &self,
520 game_id: &GameId,
521 profile_name: &str,
522 game_save_dir: &Path,
523 ) -> Result<usize> {
524 self.capture_with_fingerprint(game_id, profile_name, game_save_dir, None)
525 }
526
527 pub fn deploy(
530 &self,
531 game_id: &GameId,
532 profile_name: &str,
533 game_save_dir: &Path,
534 ) -> Result<usize> {
535 Self::checkout_branch(game_id, profile_name)?;
536
537 let vault_path = crate::paths::save_vault_dir(game_id);
538
539 clear_active_save_dir(game_save_dir)?;
542
543 std::fs::create_dir_all(game_save_dir)?;
544
545 let count = copy_dir_contents_filtered(&vault_path, game_save_dir, |name| {
548 name != ".git" && !is_live_metadata(name)
549 })?;
550
551 info!(game_id = %game_id, profile = profile_name, count, "deployed saves");
552 Ok(count)
553 }
554
555 pub fn activate(
562 &self,
563 game_id: &GameId,
564 new_profile: &str,
565 current_profile: Option<&str>,
566 game_save_dir: &Path,
567 ) -> Result<()> {
568 self.activate_with_fingerprint(game_id, new_profile, current_profile, game_save_dir, None)
569 }
570
571 pub fn activate_with_fingerprint(
573 &self,
574 game_id: &GameId,
575 new_profile: &str,
576 current_profile: Option<&str>,
577 game_save_dir: &Path,
578 fingerprint: Option<&SaveFingerprint>,
579 ) -> Result<()> {
580 if let Some(current) = current_profile {
581 self.capture_with_fingerprint(game_id, current, game_save_dir, fingerprint)?;
582 park_active_saves(game_save_dir, current)?;
583 }
584
585 Self::ensure_branch(game_id, new_profile)?;
586 self.deploy(game_id, new_profile, game_save_dir)?;
587
588 Ok(())
589 }
590
591 pub fn fork_saves(game_id: &GameId, source_profile: &str, target_profile: &str) -> Result<()> {
593 let repo = Self::vault_repo(game_id)?;
594 let source_branch = sanitize_branch_name(source_profile);
595 let target_branch = sanitize_branch_name(target_profile);
596
597 Self::ensure_branch(game_id, source_profile)?;
598
599 let branch = repo
600 .find_branch(&source_branch, git2::BranchType::Local)
601 .map_err(|e| CoreError::SaveVaultError(format!("source branch not found: {e}")))?;
602
603 let commit = branch
604 .get()
605 .peel_to_commit()
606 .map_err(|e| CoreError::SaveVaultError(format!("failed to get source commit: {e}")))?;
607
608 repo.branch(&target_branch, &commit, false)
609 .map_err(|e| CoreError::SaveVaultError(format!("failed to create fork branch: {e}")))?;
610
611 info!(
612 game_id = %game_id,
613 source = source_profile,
614 target = target_profile,
615 "forked save branch"
616 );
617 Ok(())
618 }
619
620 pub fn history(
624 game_id: &GameId,
625 profile_name: &str,
626 limit: usize,
627 ) -> Result<Vec<SaveSnapshot>> {
628 let repo = Self::vault_repo(game_id)?;
629 let branch_name = sanitize_branch_name(profile_name);
630
631 let branch = repo
632 .find_branch(&branch_name, git2::BranchType::Local)
633 .map_err(|e| {
634 CoreError::SaveVaultError(format!("branch '{branch_name}' not found: {e}"))
635 })?;
636
637 let commit_oid = branch
638 .get()
639 .target()
640 .ok_or_else(|| CoreError::SaveVaultError("branch has no target".into()))?;
641
642 let mut revwalk = repo
643 .revwalk()
644 .map_err(|e| CoreError::SaveVaultError(format!("revwalk failed: {e}")))?;
645 revwalk
646 .push(commit_oid)
647 .map_err(|e| CoreError::SaveVaultError(format!("revwalk push failed: {e}")))?;
648
649 let mut snapshots = Vec::new();
650 for oid in revwalk.take(limit) {
651 let oid = oid.map_err(|e| CoreError::SaveVaultError(format!("revwalk iter: {e}")))?;
652 let commit = repo
653 .find_commit(oid)
654 .map_err(|e| CoreError::SaveVaultError(format!("find commit: {e}")))?;
655
656 let message = commit.message().unwrap_or("").to_string();
657 let time = commit.time();
658 let secs = time.seconds();
659
660 let tree = commit
662 .tree()
663 .map_err(|e| CoreError::SaveVaultError(format!("commit tree: {e}")))?;
664 let file_count = count_tree_entries(&repo, &tree);
665
666 let fingerprint = SaveFingerprint::from_commit_message(&message);
667
668 let mut snap = SaveSnapshot {
669 id: oid.to_string(),
670 message,
671 timestamp: secs,
672 file_count,
673 fingerprint,
674 profile_name: None,
675 character_name: None,
676 save_label: None,
677 category: None,
678 };
679 snap.parse_metadata_from_message();
680 snapshots.push(snap);
681 }
682
683 Ok(snapshots)
684 }
685
686 pub fn check_restore_compatibility(
691 game_id: &GameId,
692 _profile_name: &str,
693 commit_id: &str,
694 current_fingerprint: &SaveFingerprint,
695 ) -> Result<FingerprintCheck> {
696 let repo = Self::vault_repo(game_id)?;
697 let obj = repo.revparse_single(commit_id).map_err(|e| {
698 CoreError::SaveVaultError(format!("could not find commit '{commit_id}': {e}"))
699 })?;
700 let commit = obj
701 .peel_to_commit()
702 .map_err(|e| CoreError::SaveVaultError(format!("not a commit: {e}")))?;
703
704 let message = commit.message().unwrap_or("").to_string();
705 let snapshot_fp = SaveFingerprint::from_commit_message(&message);
706
707 match snapshot_fp {
708 Some(fp) => {
709 let snapshot = SaveSnapshot {
710 id: commit_id.to_string(),
711 message,
712 timestamp: 0,
713 file_count: 0,
714 fingerprint: Some(fp),
715 profile_name: None,
716 character_name: None,
717 save_label: None,
718 category: None,
719 };
720 Ok(snapshot.check_compatibility(current_fingerprint))
721 }
722 None => Ok(FingerprintCheck::NoFingerprint),
723 }
724 }
725
726 pub fn restore(
728 game_id: &GameId,
729 profile_name: &str,
730 commit_id: &str,
731 game_save_dir: &Path,
732 ) -> Result<usize> {
733 let repo = Self::vault_repo(game_id)?;
734 let vault_path = crate::paths::save_vault_dir(game_id);
735
736 let obj = repo.revparse_single(commit_id).map_err(|e| {
738 CoreError::SaveVaultError(format!("could not find commit '{commit_id}': {e}"))
739 })?;
740 let commit = obj
741 .peel_to_commit()
742 .map_err(|e| CoreError::SaveVaultError(format!("not a commit: {e}")))?;
743
744 let branch_name = sanitize_branch_name(profile_name);
746 Self::checkout_branch(game_id, profile_name)?;
747
748 repo.checkout_tree(
749 commit.as_object(),
750 Some(git2::build::CheckoutBuilder::new().force()),
751 )
752 .map_err(|e| CoreError::SaveVaultError(format!("checkout failed: {e}")))?;
753
754 let refname = format!("refs/heads/{branch_name}");
756 repo.reference(
757 &refname,
758 commit.id(),
759 true,
760 &format!("restore to {commit_id}"),
761 )
762 .map_err(|e| CoreError::SaveVaultError(format!("failed to reset branch: {e}")))?;
763 repo.set_head(&refname)
764 .map_err(|e| CoreError::SaveVaultError(format!("failed to set HEAD: {e}")))?;
765
766 clear_active_save_dir(game_save_dir)?;
768 std::fs::create_dir_all(game_save_dir)?;
769
770 let count = copy_dir_contents_filtered(&vault_path, game_save_dir, |name| {
771 name != ".git" && !is_live_metadata(name)
772 })?;
773
774 info!(
775 game_id = %game_id,
776 profile = profile_name,
777 commit = commit_id,
778 count,
779 "restored saves from snapshot"
780 );
781 Ok(count)
782 }
783
784 pub fn snapshot_file_list(game_id: &GameId, commit_id: &str) -> Result<Vec<String>> {
786 let repo = Self::vault_repo(game_id)?;
787 let obj = repo.revparse_single(commit_id).map_err(|e| {
788 CoreError::SaveVaultError(format!("could not find commit '{commit_id}': {e}"))
789 })?;
790 let commit = obj
791 .peel_to_commit()
792 .map_err(|e| CoreError::SaveVaultError(format!("not a commit: {e}")))?;
793 let tree = commit
794 .tree()
795 .map_err(|e| CoreError::SaveVaultError(format!("commit tree: {e}")))?;
796 Ok(collect_tree_paths(&repo, &tree, ""))
797 }
798
799 pub fn detect_unadopted(
804 &self,
805 game_id: &GameId,
806 game_save_dir: &Path,
807 ) -> Result<Option<usize>> {
808 if self.db.get_active_profile(game_id)?.is_some() {
809 return Ok(None);
810 }
811
812 if !game_save_dir.exists() {
813 return Ok(None);
814 }
815
816 let count = std::fs::read_dir(game_save_dir)?
817 .filter_map(std::result::Result::ok)
818 .count();
819
820 if count > 0 { Ok(Some(count)) } else { Ok(None) }
821 }
822
823 pub fn adopt(
826 &self,
827 game_id: &GameId,
828 profile_name: &str,
829 game_save_dir: &Path,
830 ) -> Result<usize> {
831 Self::ensure_branch(game_id, profile_name)?;
832 self.capture(game_id, profile_name, game_save_dir)
833 }
834
835 pub fn assign(&self, profile_id: i64, path: &Path, label: Option<&str>) -> Result<()> {
839 self.db.assign_save(profile_id, path, label)
840 }
841
842 pub fn unassign(&self, path: &Path) -> Result<()> {
844 self.db.unassign_save(path)
845 }
846
847 pub fn list(&self, profile_id: i64) -> Result<Vec<SaveEntry>> {
849 self.db.list_saves(profile_id)
850 }
851
852 pub fn list_unassigned(&self, game_save_dir: &Path) -> Result<Vec<PathBuf>> {
854 if !game_save_dir.exists() {
855 return Ok(Vec::new());
856 }
857
858 let mut unassigned = Vec::new();
859
860 for entry in std::fs::read_dir(game_save_dir)?.flatten() {
861 let path = entry.path();
862 if !self.db.is_save_assigned(&path)? {
863 unassigned.push(path);
864 }
865 }
866
867 Ok(unassigned)
868 }
869}
870
871fn vault_signature() -> Signature<'static> {
874 Signature::now("modde", "modde@localhost").expect("failed to create git signature")
875}
876
877fn sanitize_branch_name(name: &str) -> String {
879 name.chars()
880 .map(|c| match c {
881 ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\' => '-',
882 c => c,
883 })
884 .collect()
885}
886
887#[must_use]
893pub fn format_timestamp(secs: i64) -> String {
894 use std::fmt::Write;
895 let dt = time_to_parts(secs);
896 let mut s = String::new();
897 let _ = write!(
898 s,
899 "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
900 dt.0, dt.1, dt.2, dt.3, dt.4, dt.5
901 );
902 s
903}
904
905#[must_use]
907pub fn format_timestamp_short(secs: i64) -> String {
908 let (y, m, d, hour, minute, _) = time_to_parts(secs);
909 let month = match m {
910 1 => "Jan",
911 2 => "Feb",
912 3 => "Mar",
913 4 => "Apr",
914 5 => "May",
915 6 => "Jun",
916 7 => "Jul",
917 8 => "Aug",
918 9 => "Sep",
919 10 => "Oct",
920 11 => "Nov",
921 12 => "Dec",
922 _ => "???",
923 };
924
925 let current_year = {
927 let now_days = (std::time::SystemTime::now()
928 .duration_since(std::time::UNIX_EPOCH)
929 .unwrap_or_default()
930 .as_secs()
931 / 86400) as i32;
932 let z = now_days + 719468;
933 let era = if z >= 0 { z } else { z - 146096 } / 146097;
934 let doe = (z - era * 146097) as u32;
935 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
936 yoe as i32 + era * 400
937 };
938
939 if y == current_year {
940 format!("{month} {d} {hour:02}:{minute:02}")
941 } else {
942 format!("{month} {d} '{:02} {hour:02}:{minute:02}", y % 100)
943 }
944}
945
946#[must_use]
948pub fn time_to_parts(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
949 let days = (secs / 86400) as i32;
950 let time_of_day = (secs % 86400) as u32;
951 let hour = time_of_day / 3600;
952 let minute = (time_of_day % 3600) / 60;
953 let second = time_of_day % 60;
954
955 let z = days + 719468;
957 let era = if z >= 0 { z } else { z - 146096 } / 146097;
958 let doe = (z - era * 146097) as u32;
959 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
960 let y = yoe as i32 + era * 400;
961 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
962 let mp = (5 * doy + 2) / 153;
963 let d = doy - (153 * mp + 2) / 5 + 1;
964 let m = if mp < 10 { mp + 3 } else { mp - 9 };
965 let y = if m <= 2 { y + 1 } else { y };
966
967 (y, m, d, hour, minute, second)
968}
969
970fn count_tree_entries(repo: &Repository, tree: &git2::Tree) -> usize {
972 let mut count = 0;
973 for entry in tree {
974 match entry.kind() {
975 Some(git2::ObjectType::Blob) => count += 1,
976 Some(git2::ObjectType::Tree) => {
977 if let Ok(subtree) = repo.find_tree(entry.id()) {
978 count += count_tree_entries(repo, &subtree);
979 }
980 }
981 _ => {}
982 }
983 }
984 count
985}
986
987fn collect_tree_paths(repo: &Repository, tree: &git2::Tree, prefix: &str) -> Vec<String> {
989 let mut paths = Vec::new();
990 for entry in tree {
991 let name = entry.name().unwrap_or("");
992 let full = if prefix.is_empty() {
993 name.to_string()
994 } else {
995 format!("{prefix}/{name}")
996 };
997 match entry.kind() {
998 Some(git2::ObjectType::Blob) => paths.push(full),
999 Some(git2::ObjectType::Tree) => {
1000 if let Ok(subtree) = repo.find_tree(entry.id()) {
1001 paths.extend(collect_tree_paths(repo, &subtree, &full));
1002 }
1003 }
1004 _ => {}
1005 }
1006 }
1007 paths
1008}
1009
1010fn clear_active_save_dir(dir: &Path) -> Result<()> {
1013 if !dir.exists() {
1014 return Ok(());
1015 }
1016 for entry in std::fs::read_dir(dir)? {
1017 let entry = entry?;
1018 let name = entry.file_name();
1019 let name = name.to_string_lossy();
1020 if is_live_metadata(&name) {
1021 continue;
1022 }
1023
1024 let path = entry.path();
1025 if path.is_dir() {
1026 std::fs::remove_dir_all(&path)?;
1027 } else {
1028 std::fs::remove_file(&path)?;
1029 }
1030 }
1031 Ok(())
1032}
1033
1034fn park_active_saves(game_save_dir: &Path, profile_name: &str) -> Result<()> {
1040 if !game_save_dir.exists() {
1041 return Ok(());
1042 }
1043
1044 let parked_dir = game_save_dir
1045 .join(MODDE_LIVE_STATE_DIR)
1046 .join(MODDE_PROFILE_PARK_DIR)
1047 .join(sanitize_path_component(profile_name));
1048
1049 if parked_dir.exists() {
1050 std::fs::remove_dir_all(&parked_dir)?;
1051 }
1052 std::fs::create_dir_all(&parked_dir)?;
1053
1054 for entry in std::fs::read_dir(game_save_dir)? {
1055 let entry = entry?;
1056 let name = entry.file_name();
1057 let name_str = name.to_string_lossy();
1058 if is_live_metadata(&name_str) {
1059 continue;
1060 }
1061
1062 let src = entry.path();
1063 let dst = parked_dir.join(&name);
1064 if std::fs::rename(&src, &dst).is_err() {
1065 if src.is_dir() {
1066 copy_dir_contents(&src, &dst)?;
1067 std::fs::remove_dir_all(&src)?;
1068 } else {
1069 std::fs::copy(&src, &dst)?;
1070 std::fs::remove_file(&src)?;
1071 }
1072 }
1073 }
1074
1075 Ok(())
1076}
1077
1078fn remove_live_metadata_from_vault(vault_path: &Path) -> Result<()> {
1079 for name in [STEAM_CLOUD_MARKER, MODDE_LIVE_STATE_DIR] {
1080 let path = vault_path.join(name);
1081 if path.is_dir() {
1082 std::fs::remove_dir_all(path)?;
1083 } else if path.exists() {
1084 std::fs::remove_file(path)?;
1085 }
1086 }
1087 Ok(())
1088}
1089
1090fn is_live_metadata(name: &str) -> bool {
1091 name.eq_ignore_ascii_case(STEAM_CLOUD_MARKER) || name == MODDE_LIVE_STATE_DIR
1092}
1093
1094fn sanitize_path_component(name: &str) -> String {
1095 name.chars()
1096 .map(|c| {
1097 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
1098 c
1099 } else {
1100 '-'
1101 }
1102 })
1103 .collect()
1104}
1105
1106fn copy_dir_contents(src: &Path, dst: &Path) -> Result<usize> {
1108 copy_dir_contents_filtered(src, dst, |_| true)
1109}
1110
1111fn copy_dir_contents_filtered(
1113 src: &Path,
1114 dst: &Path,
1115 filter: impl Fn(&str) -> bool + Copy,
1116) -> Result<usize> {
1117 let mut count = 0usize;
1118
1119 if !src.exists() {
1120 return Ok(0);
1121 }
1122
1123 std::fs::create_dir_all(dst)?;
1124
1125 for entry in std::fs::read_dir(src)? {
1126 let entry = entry?;
1127 let name = entry.file_name();
1128 let name_str = name.to_string_lossy();
1129
1130 if !filter(&name_str) {
1131 continue;
1132 }
1133
1134 let src_path = entry.path();
1135 let dst_path = dst.join(&name);
1136
1137 if src_path.is_dir() {
1138 count += copy_dir_contents_filtered(&src_path, &dst_path, filter)?;
1139 } else {
1140 std::fs::copy(&src_path, &dst_path)?;
1141 count += 1;
1142 }
1143 }
1144
1145 Ok(count)
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::*;
1151
1152 #[test]
1153 fn park_active_saves_preserves_steam_cloud_marker() {
1154 let tmp = tempfile::tempdir().unwrap();
1155 let save_dir = tmp.path();
1156 std::fs::write(save_dir.join("Save1.ess"), b"save").unwrap();
1157 std::fs::write(save_dir.join(STEAM_CLOUD_MARKER), b"marker").unwrap();
1158
1159 park_active_saves(save_dir, "vanilla profile").unwrap();
1160
1161 assert!(!save_dir.join("Save1.ess").exists());
1162 assert_eq!(
1163 std::fs::read(save_dir.join(STEAM_CLOUD_MARKER)).unwrap(),
1164 b"marker"
1165 );
1166 assert!(
1167 save_dir
1168 .join(MODDE_LIVE_STATE_DIR)
1169 .join(MODDE_PROFILE_PARK_DIR)
1170 .join("vanilla-profile")
1171 .join("Save1.ess")
1172 .exists()
1173 );
1174 }
1175
1176 #[test]
1177 fn clear_active_save_dir_keeps_cloud_and_modde_metadata() {
1178 let tmp = tempfile::tempdir().unwrap();
1179 let save_dir = tmp.path();
1180 let modde_dir = save_dir.join(MODDE_LIVE_STATE_DIR);
1181 std::fs::create_dir_all(&modde_dir).unwrap();
1182 std::fs::write(save_dir.join("Save1.ess"), b"save").unwrap();
1183 std::fs::write(save_dir.join(STEAM_CLOUD_MARKER), b"marker").unwrap();
1184 std::fs::write(modde_dir.join("state"), b"state").unwrap();
1185
1186 clear_active_save_dir(save_dir).unwrap();
1187
1188 assert!(!save_dir.join("Save1.ess").exists());
1189 assert!(save_dir.join(STEAM_CLOUD_MARKER).exists());
1190 assert!(modde_dir.join("state").exists());
1191 }
1192}