Skip to main content

modde_core/
save.rs

1use std::path::{Path, PathBuf};
2
3use git2::{Repository, Signature, IndexAddOption};
4use sha2::{Sha256, Digest};
5use tracing::info;
6
7use smallvec::SmallVec;
8
9use crate::db::{ModdeDb, SaveEntry};
10use crate::error::{CoreError, Result};
11use crate::profile::EnabledMod;
12
13// ── Save Fingerprint ─────────────────────────────────────────────
14
15/// A fingerprint of the save-breaking mods active when a save was captured.
16///
17/// Computed as SHA-256 over the sorted list of enabled, save-breaking mod IDs
18/// (and their versions). Two profiles with the same save-breaking mods produce
19/// the same fingerprint, regardless of cosmetic mod differences.
20///
21/// Stored as a `Mod-Fingerprint:` trailer in save vault commit messages.
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct SaveFingerprint {
24    /// Hex-encoded SHA-256 hash (first 16 characters for display).
25    pub hash: String,
26    /// The save-breaking mod IDs that contributed to this fingerprint.
27    /// Typically 5–15 mods; `SmallVec<[_; 8]>` keeps ≤8 inline (no heap allocation).
28    pub mod_ids: SmallVec<[String; 8]>,
29}
30
31/// The git commit trailer key used to store the fingerprint.
32const FINGERPRINT_TRAILER: &str = "Mod-Fingerprint";
33
34/// The git commit trailer key for the human-readable mod list.
35const MODS_TRAILER: &str = "Save-Breaking-Mods";
36
37impl SaveFingerprint {
38    /// Compute a fingerprint from a list of mods and a classification function.
39    ///
40    /// `classify` takes a mod_id and returns whether it's save-breaking.
41    /// This is intentionally a callback so the caller can resolve staging
42    /// paths and call `GamePlugin::classify_mod` — keeping modde-core
43    /// independent of modde-games.
44    pub fn compute(
45        mods: &[EnabledMod],
46        classify: impl Fn(&str) -> bool,
47    ) -> Self {
48        let mut breaking_ids: Vec<&str> = mods
49            .iter()
50            .filter(|m| m.enabled && classify(&m.mod_id))
51            .map(|m| m.mod_id.as_str())
52            .collect();
53        breaking_ids.sort_unstable();
54        breaking_ids.dedup();
55
56        let mut hasher = Sha256::new();
57        for id in &breaking_ids {
58            hasher.update(id.as_bytes());
59            hasher.update(b"\0");
60        }
61        let hash = format!("{:x}", hasher.finalize());
62
63        Self {
64            hash,
65            mod_ids: breaking_ids.into_iter().map(String::from).collect(),
66        }
67    }
68
69    /// Short hash for display (first 12 hex chars).
70    pub fn short_hash(&self) -> &str {
71        &self.hash[..self.hash.len().min(12)]
72    }
73
74    /// Empty fingerprint (no save-breaking mods).
75    pub fn empty() -> Self {
76        Self {
77            hash: "0".repeat(64),
78            mod_ids: SmallVec::new(),
79        }
80    }
81
82    pub fn is_empty(&self) -> bool {
83        self.mod_ids.is_empty()
84    }
85
86    /// Format as git commit trailer lines.
87    fn to_trailers(&self) -> String {
88        let mut s = format!("{FINGERPRINT_TRAILER}: {}", self.short_hash());
89        if !self.mod_ids.is_empty() {
90            s.push_str(&format!(
91                "\n{MODS_TRAILER}: {}",
92                self.mod_ids.join(", ")
93            ));
94        }
95        s
96    }
97
98    /// Parse from a git commit message (looks for trailer lines).
99    fn from_commit_message(message: &str) -> Option<Self> {
100        let mut hash = None;
101        let mut mod_ids = SmallVec::new();
102
103        for line in message.lines() {
104            if let Some(value) = line.strip_prefix(&format!("{FINGERPRINT_TRAILER}: ")) {
105                hash = Some(value.trim().to_string());
106            } else if let Some(value) = line.strip_prefix(&format!("{MODS_TRAILER}: ")) {
107                mod_ids = value
108                    .split(", ")
109                    .map(|s| s.trim().to_string())
110                    .filter(|s| !s.is_empty())
111                    .collect();
112            }
113        }
114
115        hash.map(|h| Self { hash: h, mod_ids })
116    }
117}
118
119/// Result of comparing two fingerprints.
120#[derive(Debug, Clone)]
121pub enum FingerprintCheck {
122    /// Fingerprints match — saves are compatible.
123    Compatible,
124    /// No fingerprint stored in the snapshot (pre-fingerprint era).
125    NoFingerprint,
126    /// Fingerprints differ — saves may be incompatible.
127    Mismatch {
128        /// Mods present in the snapshot but not the current profile.
129        /// Typically 1–5 mods; `SmallVec<[_; 4]>` avoids heap for common diffs.
130        removed: SmallVec<[String; 4]>,
131        /// Mods present in the current profile but not the snapshot.
132        added: SmallVec<[String; 4]>,
133    },
134}
135
136impl FingerprintCheck {
137    pub fn is_compatible(&self) -> bool {
138        matches!(self, Self::Compatible | Self::NoFingerprint)
139    }
140}
141
142/// A snapshot entry from the save vault history.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct SaveSnapshot {
145    /// Full commit hash.
146    pub id: String,
147    /// Commit message.
148    pub message: String,
149    /// Unix timestamp.
150    pub timestamp: i64,
151    /// Number of files in this snapshot.
152    pub file_count: usize,
153    /// Mod fingerprint extracted from the commit, if present.
154    pub fingerprint: Option<SaveFingerprint>,
155    /// Profile name extracted from the commit message.
156    pub profile_name: Option<String>,
157    /// Character/player name extracted from the save label.
158    pub character_name: Option<String>,
159    /// Save label (e.g. "Save 14").
160    pub save_label: Option<String>,
161    /// Save category (e.g. "manual", "auto", "quick").
162    pub category: Option<String>,
163}
164
165impl SaveSnapshot {
166    /// First 8 characters of the commit hash — computed on demand
167    /// instead of storing a redundant heap allocation.
168    pub fn short_id(&self) -> &str {
169        &self.id[..self.id.len().min(8)]
170    }
171
172    /// Human-readable title for display: character + save label, or first message line.
173    pub fn display_title(&self) -> String {
174        if let (Some(char_name), Some(label)) = (&self.character_name, &self.save_label) {
175            format!("{char_name} — {label}")
176        } else if let Some(char_name) = &self.character_name {
177            char_name.clone()
178        } else if let Some(label) = &self.save_label {
179            label.clone()
180        } else {
181            self.message.lines().next().unwrap_or("").trim().to_string()
182        }
183    }
184
185    /// Parse structured metadata from the commit message.
186    ///
187    /// Handles these formats produced by `describe_capture()`:
188    /// - `"capture: Lydia — Save 14 [manual]"`
189    /// - `"capture: 3 saves — Lydia (slots 1, 2); Orc Mage (slot 5)"`
190    /// - `"capture saves for profile 'Default'"`
191    /// - `"capture (FO76 cache — server saves not tracked): ..."`
192    fn parse_metadata_from_message(&mut self) {
193        let first_line = self.message.lines().next().unwrap_or("").trim();
194
195        // Extract profile name from "capture saves for profile 'Name'"
196        if let Some(rest) = first_line.strip_prefix("capture saves for profile '") {
197            if let Some(name) = rest.strip_suffix('\'') {
198                self.profile_name = Some(name.to_string());
199            }
200            return;
201        }
202
203        // Strip capture prefix: "capture: " or "capture (FO76 ...): "
204        let body = if let Some(rest) = first_line.strip_prefix("capture: ") {
205            rest
206        } else if first_line.starts_with("capture (") {
207            // "capture (FO76 cache — server saves not tracked): Lydia — Save 14 [manual]"
208            if let Some(idx) = first_line.find("): ") {
209                &first_line[idx + 3..]
210            } else {
211                return;
212            }
213        } else {
214            return;
215        };
216
217        // Extract category from trailing "[manual]", "[auto]", "[quick]", etc.
218        let (body, category) = if let Some(bracket_start) = body.rfind(" [") {
219            if body.ends_with(']') {
220                let cat = &body[bracket_start + 2..body.len() - 1];
221                self.category = Some(cat.to_string());
222                (&body[..bracket_start], Some(cat.to_string()))
223            } else {
224                (body, None)
225            }
226        } else {
227            (body, None)
228        };
229        let _ = category; // used above via self.category
230
231        // Extract character name and save label from "Lydia — Save 14"
232        if let Some((char_part, save_part)) = body.split_once(" — ") {
233            // Check if it's a multi-save summary like "3 saves — Lydia (slots 1, 2); ..."
234            if char_part.ends_with("saves") && char_part.chars().next().map_or(false, |c| c.is_ascii_digit()) {
235                // Multi-save: use the whole body as the label
236                self.save_label = Some(first_line.to_string());
237            } else {
238                self.character_name = Some(char_part.to_string());
239                self.save_label = Some(save_part.to_string());
240            }
241        } else if body != "no new saves" {
242            // Single item without separator — use as label
243            self.save_label = Some(body.to_string());
244        }
245    }
246
247    /// Check whether this snapshot's fingerprint is compatible with the given fingerprint.
248    pub fn check_compatibility(&self, current: &SaveFingerprint) -> FingerprintCheck {
249        let stored = match &self.fingerprint {
250            Some(fp) => fp,
251            None => return FingerprintCheck::NoFingerprint,
252        };
253
254        if stored.hash == current.hash || stored.short_hash() == current.short_hash() {
255            return FingerprintCheck::Compatible;
256        }
257
258        let stored_set: std::collections::HashSet<&str> =
259            stored.mod_ids.iter().map(|s| s.as_str()).collect();
260        let current_set: std::collections::HashSet<&str> =
261            current.mod_ids.iter().map(|s| s.as_str()).collect();
262
263        let removed: SmallVec<[String; 4]> = stored_set
264            .difference(&current_set)
265            .map(|s| s.to_string())
266            .collect();
267        let added: SmallVec<[String; 4]> = current_set
268            .difference(&stored_set)
269            .map(|s| s.to_string())
270            .collect();
271
272        FingerprintCheck::Mismatch { removed, added }
273    }
274}
275
276/// Manages save files using a git-backed vault per game.
277///
278/// Each game gets a git repository at `<modde_data>/saves/<game_id>/`.
279/// Each profile is a branch in that repository. This gives us branching,
280/// history, and stacking for free.
281///
282/// The caller is responsible for resolving the game's save directory
283/// (e.g. via `GamePlugin::save_directory()`) and passing it in.
284pub struct SaveManager<'a> {
285    db: &'a ModdeDb,
286}
287
288impl<'a> SaveManager<'a> {
289    pub fn new(db: &'a ModdeDb) -> Self {
290        Self { db }
291    }
292
293    // ── Vault management ─────────────────────────────────────────
294
295    /// Initialize a git-backed save vault for a game if it doesn't exist.
296    pub fn init_vault(game_id: &str) -> Result<Repository> {
297        let vault_path = crate::paths::save_vault_dir(game_id);
298        if vault_path.join(".git").exists() {
299            return Repository::open(&vault_path)
300                .map_err(|e| CoreError::SaveVaultError(format!("failed to open vault: {e}")));
301        }
302
303        std::fs::create_dir_all(&vault_path)?;
304        let repo = Repository::init(&vault_path)
305            .map_err(|e| CoreError::SaveVaultError(format!("failed to init vault: {e}")))?;
306
307        // Create an initial empty commit on `main` so we have a root
308        {
309            let sig = vault_signature();
310            let mut index = repo.index()
311                .map_err(|e| CoreError::SaveVaultError(format!("failed to get index: {e}")))?;
312            let tree_oid = index.write_tree()
313                .map_err(|e| CoreError::SaveVaultError(format!("failed to write tree: {e}")))?;
314            let tree = repo.find_tree(tree_oid)
315                .map_err(|e| CoreError::SaveVaultError(format!("failed to find tree: {e}")))?;
316            repo.commit(Some("HEAD"), &sig, &sig, "init save vault", &tree, &[])
317                .map_err(|e| CoreError::SaveVaultError(format!("failed to create initial commit: {e}")))?;
318        }
319
320        info!(game_id, path = %vault_path.display(), "initialized save vault");
321        Ok(repo)
322    }
323
324    /// Open an existing vault repo, or initialize it if it doesn't exist.
325    pub fn vault_repo(game_id: &str) -> Result<Repository> {
326        let vault_path = crate::paths::save_vault_dir(game_id);
327        if vault_path.join(".git").exists() {
328            Repository::open(&vault_path)
329                .map_err(|e| CoreError::SaveVaultError(format!("failed to open vault: {e}")))
330        } else {
331            Self::init_vault(game_id)
332        }
333    }
334
335    // ── Branch operations ────────────────────────────────────────
336
337    /// Ensure a branch exists for a profile. Creates a branch if needed.
338    pub fn ensure_branch(game_id: &str, profile_name: &str) -> Result<()> {
339        let repo = Self::vault_repo(game_id)?;
340        let branch_name = sanitize_branch_name(profile_name);
341
342        if repo.find_branch(&branch_name, git2::BranchType::Local).is_ok() {
343            return Ok(());
344        }
345
346        let head_commit = repo.head()
347            .and_then(|h| h.peel_to_commit())
348            .map_err(|e| CoreError::SaveVaultError(format!("failed to get HEAD: {e}")))?;
349
350        repo.branch(&branch_name, &head_commit, false)
351            .map_err(|e| CoreError::SaveVaultError(format!("failed to create branch '{branch_name}': {e}")))?;
352
353        info!(game_id, branch = %branch_name, "created save branch");
354        Ok(())
355    }
356
357    /// Checkout a profile's branch, updating the working directory.
358    pub fn checkout_branch(game_id: &str, profile_name: &str) -> Result<()> {
359        let repo = Self::vault_repo(game_id)?;
360        let branch_name = sanitize_branch_name(profile_name);
361
362        Self::ensure_branch(game_id, profile_name)?;
363
364        let branch = repo.find_branch(&branch_name, git2::BranchType::Local)
365            .map_err(|e| CoreError::SaveVaultError(format!("branch '{branch_name}' not found: {e}")))?;
366
367        let refname = branch.get().name()
368            .ok_or_else(|| CoreError::SaveVaultError("invalid branch ref name".into()))?
369            .to_string();
370
371        let obj = repo.revparse_single(&refname)
372            .map_err(|e| CoreError::SaveVaultError(format!("failed to resolve branch: {e}")))?;
373
374        repo.checkout_tree(&obj, Some(git2::build::CheckoutBuilder::new().force()))
375            .map_err(|e| CoreError::SaveVaultError(format!("checkout failed: {e}")))?;
376
377        repo.set_head(&refname)
378            .map_err(|e| CoreError::SaveVaultError(format!("failed to set HEAD: {e}")))?;
379
380        info!(game_id, branch = %branch_name, "checked out save branch");
381        Ok(())
382    }
383
384    // ── Save transfer ────────────────────────────────────────────
385
386    /// Capture saves from the game's save directory into the vault, committing them.
387    /// Returns the number of files captured.
388    ///
389    /// If `fingerprint` is provided, it is embedded in the commit message as
390    /// trailer lines so that future restores can warn about mod mismatches.
391    pub fn capture_with_fingerprint(
392        &self,
393        game_id: &str,
394        profile_name: &str,
395        game_save_dir: &Path,
396        fingerprint: Option<&SaveFingerprint>,
397    ) -> Result<usize> {
398        if !game_save_dir.exists() {
399            return Ok(0);
400        }
401
402        let repo = Self::vault_repo(game_id)?;
403        let vault_path = crate::paths::save_vault_dir(game_id);
404
405        Self::checkout_branch(game_id, profile_name)?;
406
407        let count = copy_dir_contents(game_save_dir, &vault_path)?;
408
409        if count == 0 {
410            return Ok(0);
411        }
412
413        // Stage and commit
414        let mut index = repo.index()
415            .map_err(|e| CoreError::SaveVaultError(format!("failed to get index: {e}")))?;
416
417        index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)
418            .map_err(|e| CoreError::SaveVaultError(format!("failed to stage files: {e}")))?;
419
420        // Handle deletions — remove index entries for files no longer on disk
421        let mut to_remove = Vec::new();
422        for entry in index.iter() {
423            let path = String::from_utf8_lossy(&entry.path).to_string();
424            if !vault_path.join(&path).exists() {
425                to_remove.push(path);
426            }
427        }
428        for path in &to_remove {
429            index.remove_path(Path::new(path))
430                .map_err(|e| CoreError::SaveVaultError(format!("failed to remove from index: {e}")))?;
431        }
432
433        index.write()
434            .map_err(|e| CoreError::SaveVaultError(format!("failed to write index: {e}")))?;
435
436        let tree_oid = index.write_tree()
437            .map_err(|e| CoreError::SaveVaultError(format!("failed to write tree: {e}")))?;
438        let tree = repo.find_tree(tree_oid)
439            .map_err(|e| CoreError::SaveVaultError(format!("failed to find tree: {e}")))?;
440
441        let head_commit = repo.head()
442            .and_then(|h| h.peel_to_commit())
443            .map_err(|e| CoreError::SaveVaultError(format!("failed to get HEAD: {e}")))?;
444
445        // Skip commit if tree is identical to HEAD (no actual changes)
446        if tree_oid == head_commit.tree_id() {
447            info!(game_id, profile = profile_name, "saves unchanged, skipping commit");
448            return Ok(0);
449        }
450
451        let sig = vault_signature();
452
453        let mut message = format!("capture saves for profile '{profile_name}'");
454        if let Some(fp) = fingerprint {
455            message.push_str("\n\n");
456            message.push_str(&fp.to_trailers());
457        }
458
459        repo.commit(
460            Some("HEAD"),
461            &sig,
462            &sig,
463            &message,
464            &tree,
465            &[&head_commit],
466        )
467        .map_err(|e| CoreError::SaveVaultError(format!("failed to commit: {e}")))?;
468
469        info!(game_id, profile = profile_name, count, "captured saves");
470        Ok(count)
471    }
472
473    /// Capture saves without a fingerprint (backwards-compatible convenience method).
474    pub fn capture(&self, game_id: &str, profile_name: &str, game_save_dir: &Path) -> Result<usize> {
475        self.capture_with_fingerprint(game_id, profile_name, game_save_dir, None)
476    }
477
478    /// Deploy saves from the vault to the game's save directory.
479    /// Returns the number of files deployed.
480    pub fn deploy(&self, game_id: &str, profile_name: &str, game_save_dir: &Path) -> Result<usize> {
481        Self::checkout_branch(game_id, profile_name)?;
482
483        let vault_path = crate::paths::save_vault_dir(game_id);
484
485        // Clear game save dir first
486        clear_dir(game_save_dir)?;
487
488        std::fs::create_dir_all(game_save_dir)?;
489
490        // Copy from vault working tree to game dir (skip .git)
491        let count = copy_dir_contents_filtered(&vault_path, game_save_dir, |name| {
492            name != ".git"
493        })?;
494
495        info!(game_id, profile = profile_name, count, "deployed saves");
496        Ok(count)
497    }
498
499    // ── High-level operations ────────────────────────────────────
500
501    /// Full activate flow: capture current profile's saves, checkout + deploy new.
502    ///
503    /// If `fingerprint` is provided, it is embedded in the capture commit for
504    /// the *current* profile's saves (the ones being put away).
505    pub fn activate(
506        &self,
507        game_id: &str,
508        new_profile: &str,
509        current_profile: Option<&str>,
510        game_save_dir: &Path,
511    ) -> Result<()> {
512        self.activate_with_fingerprint(game_id, new_profile, current_profile, game_save_dir, None)
513    }
514
515    /// Full activate flow with an optional mod fingerprint.
516    pub fn activate_with_fingerprint(
517        &self,
518        game_id: &str,
519        new_profile: &str,
520        current_profile: Option<&str>,
521        game_save_dir: &Path,
522        fingerprint: Option<&SaveFingerprint>,
523    ) -> Result<()> {
524        if let Some(current) = current_profile {
525            self.capture_with_fingerprint(game_id, current, game_save_dir, fingerprint)?;
526        }
527
528        Self::ensure_branch(game_id, new_profile)?;
529        self.deploy(game_id, new_profile, game_save_dir)?;
530
531        Ok(())
532    }
533
534    /// Fork saves from one profile's branch to a new profile's branch.
535    pub fn fork_saves(game_id: &str, source_profile: &str, target_profile: &str) -> Result<()> {
536        let repo = Self::vault_repo(game_id)?;
537        let source_branch = sanitize_branch_name(source_profile);
538        let target_branch = sanitize_branch_name(target_profile);
539
540        Self::ensure_branch(game_id, source_profile)?;
541
542        let branch = repo.find_branch(&source_branch, git2::BranchType::Local)
543            .map_err(|e| CoreError::SaveVaultError(format!("source branch not found: {e}")))?;
544
545        let commit = branch.get().peel_to_commit()
546            .map_err(|e| CoreError::SaveVaultError(format!("failed to get source commit: {e}")))?;
547
548        repo.branch(&target_branch, &commit, false)
549            .map_err(|e| CoreError::SaveVaultError(format!("failed to create fork branch: {e}")))?;
550
551        info!(game_id, source = source_profile, target = target_profile, "forked save branch");
552        Ok(())
553    }
554
555    // ── History & restore ──────────────────────────────────────
556
557    /// List commit history for a profile's save branch.
558    pub fn history(game_id: &str, profile_name: &str, limit: usize) -> Result<Vec<SaveSnapshot>> {
559        let repo = Self::vault_repo(game_id)?;
560        let branch_name = sanitize_branch_name(profile_name);
561
562        let branch = repo.find_branch(&branch_name, git2::BranchType::Local)
563            .map_err(|e| CoreError::SaveVaultError(format!("branch '{branch_name}' not found: {e}")))?;
564
565        let commit_oid = branch.get().target()
566            .ok_or_else(|| CoreError::SaveVaultError("branch has no target".into()))?;
567
568        let mut revwalk = repo.revwalk()
569            .map_err(|e| CoreError::SaveVaultError(format!("revwalk failed: {e}")))?;
570        revwalk.push(commit_oid)
571            .map_err(|e| CoreError::SaveVaultError(format!("revwalk push failed: {e}")))?;
572
573        let mut snapshots = Vec::new();
574        for oid in revwalk.take(limit) {
575            let oid = oid.map_err(|e| CoreError::SaveVaultError(format!("revwalk iter: {e}")))?;
576            let commit = repo.find_commit(oid)
577                .map_err(|e| CoreError::SaveVaultError(format!("find commit: {e}")))?;
578
579            let message = commit.message().unwrap_or("").to_string();
580            let time = commit.time();
581            let secs = time.seconds();
582
583            // Count files in tree
584            let tree = commit.tree()
585                .map_err(|e| CoreError::SaveVaultError(format!("commit tree: {e}")))?;
586            let file_count = count_tree_entries(&repo, &tree);
587
588            let fingerprint = SaveFingerprint::from_commit_message(&message);
589
590            let mut snap = SaveSnapshot {
591                id: oid.to_string(),
592                message,
593                timestamp: secs,
594                file_count,
595                fingerprint,
596                profile_name: None,
597                character_name: None,
598                save_label: None,
599                category: None,
600            };
601            snap.parse_metadata_from_message();
602            snapshots.push(snap);
603        }
604
605        Ok(snapshots)
606    }
607
608    /// Check whether restoring a snapshot is compatible with the current mod fingerprint.
609    ///
610    /// Returns `FingerprintCheck` without performing the restore — use this
611    /// to warn the user before calling `restore`.
612    pub fn check_restore_compatibility(
613        game_id: &str,
614        _profile_name: &str,
615        commit_id: &str,
616        current_fingerprint: &SaveFingerprint,
617    ) -> Result<FingerprintCheck> {
618        let repo = Self::vault_repo(game_id)?;
619        let obj = repo.revparse_single(commit_id)
620            .map_err(|e| CoreError::SaveVaultError(format!("could not find commit '{commit_id}': {e}")))?;
621        let commit = obj.peel_to_commit()
622            .map_err(|e| CoreError::SaveVaultError(format!("not a commit: {e}")))?;
623
624        let message = commit.message().unwrap_or("").to_string();
625        let snapshot_fp = SaveFingerprint::from_commit_message(&message);
626
627        match snapshot_fp {
628            Some(fp) => {
629                let snapshot = SaveSnapshot {
630                    id: commit_id.to_string(),
631                    message,
632                    timestamp: 0,
633                    file_count: 0,
634                    fingerprint: Some(fp),
635                    profile_name: None,
636                    character_name: None,
637                    save_label: None,
638                    category: None,
639                };
640                Ok(snapshot.check_compatibility(current_fingerprint))
641            }
642            None => Ok(FingerprintCheck::NoFingerprint),
643        }
644    }
645
646    /// Restore saves from a specific commit to the game save directory.
647    pub fn restore(game_id: &str, profile_name: &str, commit_id: &str, game_save_dir: &Path) -> Result<usize> {
648        let repo = Self::vault_repo(game_id)?;
649        let vault_path = crate::paths::save_vault_dir(game_id);
650
651        // Resolve commit
652        let obj = repo.revparse_single(commit_id)
653            .map_err(|e| CoreError::SaveVaultError(format!("could not find commit '{commit_id}': {e}")))?;
654        let commit = obj.peel_to_commit()
655            .map_err(|e| CoreError::SaveVaultError(format!("not a commit: {e}")))?;
656
657        // Checkout that commit's tree into the vault working directory
658        let branch_name = sanitize_branch_name(profile_name);
659        Self::checkout_branch(game_id, profile_name)?;
660
661        repo.checkout_tree(
662            commit.as_object(),
663            Some(git2::build::CheckoutBuilder::new().force()),
664        )
665        .map_err(|e| CoreError::SaveVaultError(format!("checkout failed: {e}")))?;
666
667        // Reset the branch to point at this commit
668        let refname = format!("refs/heads/{branch_name}");
669        repo.reference(&refname, commit.id(), true, &format!("restore to {commit_id}"))
670            .map_err(|e| CoreError::SaveVaultError(format!("failed to reset branch: {e}")))?;
671        repo.set_head(&refname)
672            .map_err(|e| CoreError::SaveVaultError(format!("failed to set HEAD: {e}")))?;
673
674        // Deploy from vault to game
675        clear_dir(game_save_dir)?;
676        std::fs::create_dir_all(game_save_dir)?;
677
678        let count = copy_dir_contents_filtered(&vault_path, game_save_dir, |name| {
679            name != ".git"
680        })?;
681
682        info!(game_id, profile = profile_name, commit = commit_id, count, "restored saves from snapshot");
683        Ok(count)
684    }
685
686    /// List file paths in a specific snapshot's git tree.
687    pub fn snapshot_file_list(game_id: &str, commit_id: &str) -> Result<Vec<String>> {
688        let repo = Self::vault_repo(game_id)?;
689        let obj = repo.revparse_single(commit_id)
690            .map_err(|e| CoreError::SaveVaultError(format!("could not find commit '{commit_id}': {e}")))?;
691        let commit = obj.peel_to_commit()
692            .map_err(|e| CoreError::SaveVaultError(format!("not a commit: {e}")))?;
693        let tree = commit.tree()
694            .map_err(|e| CoreError::SaveVaultError(format!("commit tree: {e}")))?;
695        Ok(collect_tree_paths(&repo, &tree, ""))
696    }
697
698    // ── Adoption ─────────────────────────────────────────────────
699
700    /// Check if a game save directory has saves but no profile is active.
701    /// Returns the number of unadopted saves, or None if no saves found.
702    pub fn detect_unadopted(&self, game_id: &str, game_save_dir: &Path) -> Result<Option<usize>> {
703        if self.db.get_active_profile(game_id)?.is_some() {
704            return Ok(None);
705        }
706
707        if !game_save_dir.exists() {
708            return Ok(None);
709        }
710
711        let count = std::fs::read_dir(game_save_dir)?
712            .filter_map(|e| e.ok())
713            .count();
714
715        if count > 0 {
716            Ok(Some(count))
717        } else {
718            Ok(None)
719        }
720    }
721
722    /// Adopt existing saves from the game's save directory into a profile's vault.
723    /// Returns the number of files adopted.
724    pub fn adopt(&self, game_id: &str, profile_name: &str, game_save_dir: &Path) -> Result<usize> {
725        Self::ensure_branch(game_id, profile_name)?;
726        self.capture(game_id, profile_name, game_save_dir)
727    }
728
729    // ── DB-level save tracking ───────────────────────────────────
730
731    /// Assign a save file or directory to a profile.
732    pub fn assign(&self, profile_id: i64, path: &Path, label: Option<&str>) -> Result<()> {
733        self.db.assign_save(profile_id, path, label)
734    }
735
736    /// Remove a save assignment.
737    pub fn unassign(&self, path: &Path) -> Result<()> {
738        self.db.unassign_save(path)
739    }
740
741    /// List all saves assigned to a profile.
742    pub fn list(&self, profile_id: i64) -> Result<Vec<SaveEntry>> {
743        self.db.list_saves(profile_id)
744    }
745
746    /// Scan a save directory and return paths not yet assigned to any profile.
747    pub fn list_unassigned(&self, game_save_dir: &Path) -> Result<Vec<PathBuf>> {
748        if !game_save_dir.exists() {
749            return Ok(Vec::new());
750        }
751
752        let mut unassigned = Vec::new();
753
754        for entry in std::fs::read_dir(game_save_dir)?.flatten() {
755            let path = entry.path();
756            if !self.db.is_save_assigned(&path)? {
757                unassigned.push(path);
758            }
759        }
760
761        Ok(unassigned)
762    }
763}
764
765// ── Helpers ──────────────────────────────────────────────────────
766
767fn vault_signature() -> Signature<'static> {
768    Signature::now("modde", "modde@localhost").expect("failed to create git signature")
769}
770
771/// Sanitize a profile name for use as a git branch name.
772fn sanitize_branch_name(name: &str) -> String {
773    name.chars()
774        .map(|c| match c {
775            ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\' => '-',
776            c => c,
777        })
778        .collect()
779}
780
781// ── Timestamp formatting ────────────────────────────────────────
782
783/// Format a Unix timestamp as `"YYYY-MM-DD HH:MM:SS"` (UTC).
784///
785/// Uses a pure-arithmetic Euclidean civil-date algorithm — no chrono dependency.
786pub fn format_timestamp(secs: i64) -> String {
787    use std::fmt::Write;
788    let dt = time_to_parts(secs);
789    let mut s = String::new();
790    let _ = write!(s, "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
791        dt.0, dt.1, dt.2, dt.3, dt.4, dt.5);
792    s
793}
794
795/// Format a Unix timestamp as a short date: `"Apr 12 14:30"`.
796pub fn format_timestamp_short(secs: i64) -> String {
797    let (y, m, d, hour, minute, _) = time_to_parts(secs);
798    let month = match m {
799        1 => "Jan", 2 => "Feb", 3 => "Mar", 4 => "Apr",
800        5 => "May", 6 => "Jun", 7 => "Jul", 8 => "Aug",
801        9 => "Sep", 10 => "Oct", 11 => "Nov", 12 => "Dec",
802        _ => "???",
803    };
804
805    // Include year if it differs from current year (approximate: 2026)
806    let current_year = {
807        let now_days = (std::time::SystemTime::now()
808            .duration_since(std::time::UNIX_EPOCH)
809            .unwrap_or_default()
810            .as_secs() / 86400) as i32;
811        let z = now_days + 719468;
812        let era = if z >= 0 { z } else { z - 146096 } / 146097;
813        let doe = (z - era * 146097) as u32;
814        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
815        yoe as i32 + era * 400
816    };
817
818    if y != current_year {
819        format!("{month} {d} '{:02} {hour:02}:{minute:02}", y % 100)
820    } else {
821        format!("{month} {d} {hour:02}:{minute:02}")
822    }
823}
824
825/// Break a Unix timestamp into `(year, month, day, hour, minute, second)`.
826pub fn time_to_parts(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
827    let days = (secs / 86400) as i32;
828    let time_of_day = (secs % 86400) as u32;
829    let hour = time_of_day / 3600;
830    let minute = (time_of_day % 3600) / 60;
831    let second = time_of_day % 60;
832
833    // Civil date from days since 1970-01-01 (Euclidean algorithm)
834    let z = days + 719468;
835    let era = if z >= 0 { z } else { z - 146096 } / 146097;
836    let doe = (z - era * 146097) as u32;
837    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
838    let y = yoe as i32 + era * 400;
839    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
840    let mp = (5 * doy + 2) / 153;
841    let d = doy - (153 * mp + 2) / 5 + 1;
842    let m = if mp < 10 { mp + 3 } else { mp - 9 };
843    let y = if m <= 2 { y + 1 } else { y };
844
845    (y, m, d, hour, minute, second)
846}
847
848/// Recursively count blob entries in a git tree.
849fn count_tree_entries(repo: &Repository, tree: &git2::Tree) -> usize {
850    let mut count = 0;
851    for entry in tree.iter() {
852        match entry.kind() {
853            Some(git2::ObjectType::Blob) => count += 1,
854            Some(git2::ObjectType::Tree) => {
855                if let Ok(subtree) = repo.find_tree(entry.id()) {
856                    count += count_tree_entries(repo, &subtree);
857                }
858            }
859            _ => {}
860        }
861    }
862    count
863}
864
865/// Recursively collect file paths in a git tree.
866fn collect_tree_paths(repo: &Repository, tree: &git2::Tree, prefix: &str) -> Vec<String> {
867    let mut paths = Vec::new();
868    for entry in tree.iter() {
869        let name = entry.name().unwrap_or("");
870        let full = if prefix.is_empty() {
871            name.to_string()
872        } else {
873            format!("{prefix}/{name}")
874        };
875        match entry.kind() {
876            Some(git2::ObjectType::Blob) => paths.push(full),
877            Some(git2::ObjectType::Tree) => {
878                if let Ok(subtree) = repo.find_tree(entry.id()) {
879                    paths.extend(collect_tree_paths(repo, &subtree, &full));
880                }
881            }
882            _ => {}
883        }
884    }
885    paths
886}
887
888/// Remove all entries inside a directory (but not the directory itself).
889fn clear_dir(dir: &Path) -> Result<()> {
890    if !dir.exists() {
891        return Ok(());
892    }
893    for entry in std::fs::read_dir(dir)? {
894        let entry = entry?;
895        let path = entry.path();
896        if path.is_dir() {
897            std::fs::remove_dir_all(&path)?;
898        } else {
899            std::fs::remove_file(&path)?;
900        }
901    }
902    Ok(())
903}
904
905/// Copy all files/dirs from `src` to `dst`, returning the count of files copied.
906fn copy_dir_contents(src: &Path, dst: &Path) -> Result<usize> {
907    copy_dir_contents_filtered(src, dst, |_| true)
908}
909
910/// Copy all files/dirs from `src` to `dst`, skipping entries where `filter(name)` returns false.
911fn copy_dir_contents_filtered(
912    src: &Path,
913    dst: &Path,
914    filter: impl Fn(&str) -> bool + Copy,
915) -> Result<usize> {
916    let mut count = 0usize;
917
918    if !src.exists() {
919        return Ok(0);
920    }
921
922    std::fs::create_dir_all(dst)?;
923
924    for entry in std::fs::read_dir(src)? {
925        let entry = entry?;
926        let name = entry.file_name();
927        let name_str = name.to_string_lossy();
928
929        if !filter(&name_str) {
930            continue;
931        }
932
933        let src_path = entry.path();
934        let dst_path = dst.join(&name);
935
936        if src_path.is_dir() {
937            count += copy_dir_contents_filtered(&src_path, &dst_path, filter)?;
938        } else {
939            std::fs::copy(&src_path, &dst_path)?;
940            count += 1;
941        }
942    }
943
944    Ok(count)
945}