Skip to main content

modde_core/
save.rs

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// ── Save Fingerprint ─────────────────────────────────────────────
20
21/// A fingerprint of the save-breaking mods active when a save was captured.
22///
23/// Computed as SHA-256 over the sorted list of enabled, save-breaking mod IDs
24/// (and their versions). Two profiles with the same save-breaking mods produce
25/// the same fingerprint, regardless of cosmetic mod differences.
26///
27/// Stored as a `Mod-Fingerprint:` trailer in save vault commit messages.
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct SaveFingerprint {
30    /// Hex-encoded SHA-256 hash (first 16 characters for display).
31    pub hash: String,
32    /// The save-breaking mod IDs that contributed to this fingerprint.
33    /// Typically 5–15 mods; `SmallVec<[_; 8]>` keeps ≤8 inline (no heap allocation).
34    pub mod_ids: SmallVec<[String; 8]>,
35}
36
37/// The git commit trailer key used to store the fingerprint.
38const FINGERPRINT_TRAILER: &str = "Mod-Fingerprint";
39
40/// The git commit trailer key for the human-readable mod list.
41const MODS_TRAILER: &str = "Save-Breaking-Mods";
42
43impl SaveFingerprint {
44    /// Compute a fingerprint from a list of mods and a classification function.
45    ///
46    /// `classify` takes a `mod_id` and returns whether it's save-breaking.
47    /// This is intentionally a callback so the caller can resolve staging
48    /// paths and call `GamePlugin::classify_mod` — keeping modde-core
49    /// independent of modde-games.
50    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    /// Short hash for display (first 12 hex chars).
76    #[must_use]
77    pub fn short_hash(&self) -> &str {
78        &self.hash[..self.hash.len().min(12)]
79    }
80
81    /// Empty fingerprint (no save-breaking mods).
82    #[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    /// Format as git commit trailer lines.
96    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    /// Parse from a git commit message (looks for trailer lines).
105    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/// Result of comparing two fingerprints.
126#[derive(Debug, Clone)]
127pub enum FingerprintCheck {
128    /// Fingerprints match — saves are compatible.
129    Compatible,
130    /// No fingerprint stored in the snapshot (pre-fingerprint era).
131    NoFingerprint,
132    /// Fingerprints differ — saves may be incompatible.
133    Mismatch {
134        /// Mods present in the snapshot but not the current profile.
135        /// Typically 1–5 mods; `SmallVec<[_; 4]>` avoids heap for common diffs.
136        removed: SmallVec<[String; 4]>,
137        /// Mods present in the current profile but not the snapshot.
138        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/// A snapshot entry from the save vault history.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct SaveSnapshot {
152    /// Full commit hash.
153    pub id: String,
154    /// Commit message.
155    pub message: String,
156    /// Unix timestamp.
157    pub timestamp: i64,
158    /// Number of files in this snapshot.
159    pub file_count: usize,
160    /// Mod fingerprint extracted from the commit, if present.
161    pub fingerprint: Option<SaveFingerprint>,
162    /// Profile name extracted from the commit message.
163    pub profile_name: Option<String>,
164    /// Character/player name extracted from the save label.
165    pub character_name: Option<String>,
166    /// Save label (e.g. "Save 14").
167    pub save_label: Option<String>,
168    /// Save category (e.g. "manual", "auto", "quick").
169    pub category: Option<String>,
170}
171
172impl SaveSnapshot {
173    /// First 8 characters of the commit hash — computed on demand
174    /// instead of storing a redundant heap allocation.
175    #[must_use]
176    pub fn short_id(&self) -> &str {
177        &self.id[..self.id.len().min(8)]
178    }
179
180    /// Human-readable title for display: character + save label, or first message line.
181    #[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    /// Parse structured metadata from the commit message.
195    ///
196    /// Handles these formats produced by `describe_capture()`:
197    /// - `"capture: Lydia — Save 14 [manual]"`
198    /// - `"capture: 3 saves — Lydia (slots 1, 2); Orc Mage (slot 5)"`
199    /// - `"capture saves for profile 'Default'"`
200    /// - `"capture (FO76 cache — server saves not tracked): ..."`
201    fn parse_metadata_from_message(&mut self) {
202        let first_line = self.message.lines().next().unwrap_or("").trim();
203
204        // Extract profile name from "capture saves for profile 'Name'"
205        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        // Strip capture prefix: "capture: " or "capture (FO76 ...): "
213        let body = if let Some(rest) = first_line.strip_prefix("capture: ") {
214            rest
215        } else if first_line.starts_with("capture (") {
216            // "capture (FO76 cache — server saves not tracked): Lydia — Save 14 [manual]"
217            if let Some(idx) = first_line.find("): ") {
218                &first_line[idx + 3..]
219            } else {
220                return;
221            }
222        } else {
223            return;
224        };
225
226        // Extract category from trailing "[manual]", "[auto]", "[quick]", etc.
227        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; // used above via self.category
239
240        // Extract character name and save label from "Lydia — Save 14"
241        if let Some((char_part, save_part)) = body.split_once(" — ") {
242            // Check if it's a multi-save summary like "3 saves — Lydia (slots 1, 2); ..."
243            if char_part.ends_with("saves")
244                && char_part.chars().next().is_some_and(|c| c.is_ascii_digit())
245            {
246                // Multi-save: use the whole body as the label
247                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            // Single item without separator — use as label
254            self.save_label = Some(body.to_string());
255        }
256    }
257
258    /// Check whether this snapshot's fingerprint is compatible with the given fingerprint.
259    #[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(&current_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
294/// Manages save files using a git-backed vault per game.
295///
296/// Each game gets a git repository at `<modde_data>/saves/<game_id>/`.
297/// Each profile is a branch in that repository. This gives us branching,
298/// history, and stacking for free.
299///
300/// The caller is responsible for resolving the game's save directory
301/// (e.g. via `GamePlugin::save_directory()`) and passing it in.
302pub 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    // ── Vault management ─────────────────────────────────────────
312
313    /// Initialize a git-backed save vault for a game if it doesn't exist.
314    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        // Create an initial empty commit on `main` so we have a root
328        {
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    /// Open an existing vault repo, or initialize it if it doesn't exist.
350    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    // ── Branch operations ────────────────────────────────────────
361
362    /// Ensure a branch exists for a profile. Creates a branch if needed.
363    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    /// Checkout a profile's branch, updating the working directory.
389    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    // ── Save transfer ────────────────────────────────────────────
422
423    /// Capture saves from the game's save directory into the vault, committing them.
424    /// Returns the number of files captured.
425    ///
426    /// If `fingerprint` is provided, it is embedded in the commit message as
427    /// trailer lines so that future restores can warn about mod mismatches.
428    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        // Stage and commit
454        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        // Handle deletions — remove index entries for files no longer on disk
463        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        // Skip commit if tree is identical to HEAD (no actual changes)
493        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    /// Capture saves without a fingerprint (backwards-compatible convenience method).
518    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    /// Deploy saves from the vault to the game's save directory.
528    /// Returns the number of files deployed.
529    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 only active saves. Steam Cloud metadata and modde's parked
540        // inactive saves must stay in place.
541        clear_active_save_dir(game_save_dir)?;
542
543        std::fs::create_dir_all(game_save_dir)?;
544
545        // Copy from vault working tree to game dir (skip .git and live metadata
546        // captured by older versions).
547        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    // ── High-level operations ────────────────────────────────────
556
557    /// Full activate flow: capture current profile's saves, checkout + deploy new.
558    ///
559    /// If `fingerprint` is provided, it is embedded in the capture commit for
560    /// the *current* profile's saves (the ones being put away).
561    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    /// Full activate flow with an optional mod fingerprint.
572    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    /// Fork saves from one profile's branch to a new profile's branch.
592    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    // ── History & restore ──────────────────────────────────────
621
622    /// List commit history for a profile's save branch.
623    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            // Count files in tree
661            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    /// Check whether restoring a snapshot is compatible with the current mod fingerprint.
687    ///
688    /// Returns `FingerprintCheck` without performing the restore — use this
689    /// to warn the user before calling `restore`.
690    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    /// Restore saves from a specific commit to the game save directory.
727    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        // Resolve commit
737        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        // Checkout that commit's tree into the vault working directory
745        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        // Reset the branch to point at this commit
755        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        // Deploy from vault to game
767        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    /// List file paths in a specific snapshot's git tree.
785    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    // ── Adoption ─────────────────────────────────────────────────
800
801    /// Check if a game save directory has saves but no profile is active.
802    /// Returns the number of unadopted saves, or None if no saves found.
803    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    /// Adopt existing saves from the game's save directory into a profile's vault.
824    /// Returns the number of files adopted.
825    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    // ── DB-level save tracking ───────────────────────────────────
836
837    /// Assign a save file or directory to a profile.
838    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    /// Remove a save assignment.
843    pub fn unassign(&self, path: &Path) -> Result<()> {
844        self.db.unassign_save(path)
845    }
846
847    /// List all saves assigned to a profile.
848    pub fn list(&self, profile_id: i64) -> Result<Vec<SaveEntry>> {
849        self.db.list_saves(profile_id)
850    }
851
852    /// Scan a save directory and return paths not yet assigned to any profile.
853    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
871// ── Helpers ──────────────────────────────────────────────────────
872
873fn vault_signature() -> Signature<'static> {
874    Signature::now("modde", "modde@localhost").expect("failed to create git signature")
875}
876
877/// Sanitize a profile name for use as a git branch name.
878fn sanitize_branch_name(name: &str) -> String {
879    name.chars()
880        .map(|c| match c {
881            ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\' => '-',
882            c => c,
883        })
884        .collect()
885}
886
887// ── Timestamp formatting ────────────────────────────────────────
888
889/// Format a Unix timestamp as `"YYYY-MM-DD HH:MM:SS"` (UTC).
890///
891/// Uses a pure-arithmetic Euclidean civil-date algorithm — no chrono dependency.
892#[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/// Format a Unix timestamp as a short date: `"Apr 12 14:30"`.
906#[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    // Include year if it differs from current year (approximate: 2026)
926    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/// Break a Unix timestamp into `(year, month, day, hour, minute, second)`.
947#[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    // Civil date from days since 1970-01-01 (Euclidean algorithm)
956    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
970/// Recursively count blob entries in a git tree.
971fn 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
987/// Recursively collect file paths in a git tree.
988fn 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
1010/// Remove active root save entries while preserving Steam Cloud metadata and
1011/// modde's parked inactive profile saves.
1012fn 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
1034/// Park active root save entries under `.modde/profiles/<profile>/`.
1035///
1036/// This is intentionally in the live save tree: Steam Cloud sees inactive
1037/// saves as moved rather than simply deleted, while the game only sees saves
1038/// restored at the root.
1039fn 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
1106/// Copy all files/dirs from `src` to `dst`, returning the count of files copied.
1107fn copy_dir_contents(src: &Path, dst: &Path) -> Result<usize> {
1108    copy_dir_contents_filtered(src, dst, |_| true)
1109}
1110
1111/// Copy all files/dirs from `src` to `dst`, skipping entries where `filter(name)` returns false.
1112fn 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}