use anyhow::{Context, Result};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use modde_core::profile::ProfileManager;
use modde_core::save::{FingerprintCheck, SaveFingerprint, SaveManager};
use crate::SaveAction;
use super::require_save_dir;
fn resolve_profile_name(
pm: &ProfileManager,
explicit: Option<String>,
game: &str,
) -> Result<String> {
match explicit {
Some(p) => Ok(p),
None => pm.db().get_active_profile(game)?
.map(|(_, name)| name)
.ok_or_else(|| anyhow::anyhow!(
"no active profile for game '{game}'; use --profile to specify"
)),
}
}
fn compute_fingerprint(profile: &modde_core::Profile) -> SaveFingerprint {
let game_id = profile.game_id.as_str();
let game_plugin = modde_games::resolve_game_plugin(game_id);
let staging_dir = ProfileManager::staging_dir(&profile.name);
SaveFingerprint::compute(&profile.mods, |mod_id| {
let Some(plugin) = game_plugin else {
return true; };
let mod_path = staging_dir.join(mod_id);
plugin.classify_mod(&mod_path).affects_saves()
})
}
fn warn_fingerprint_mismatch(check: &FingerprintCheck) {
if let FingerprintCheck::Mismatch { removed, added } = check {
eprintln!();
eprintln!("WARNING: Save-breaking mod mismatch detected!");
if !removed.is_empty() {
eprintln!(" Mods present when saved but now MISSING (may corrupt save):");
for m in removed {
eprintln!(" - {m}");
}
}
if !added.is_empty() {
eprintln!(" Mods now ADDED that weren't present when saved:");
for m in added {
eprintln!(" + {m}");
}
}
eprintln!(" Restoring anyway. If the game crashes or behaves oddly, use");
eprintln!(" `modde save history` to find a compatible snapshot.");
eprintln!();
}
}
pub async fn handle(action: SaveAction) -> Result<()> {
let pm = ProfileManager::open().context("failed to open profile database")?;
match action {
SaveAction::Assign { path, profile, game, label } => {
let p = pm.load(&profile, game.as_deref())?;
let profile_id = p.id.ok_or_else(|| anyhow::anyhow!("profile has no database ID"))?;
let sm = SaveManager::new(pm.db());
sm.assign(profile_id, &path, label.as_deref())?;
println!("Assigned save '{}' to profile '{profile}'", path.display());
}
SaveAction::Unassign { path } => {
let sm = SaveManager::new(pm.db());
sm.unassign(&path)?;
println!("Unassigned save '{}'", path.display());
}
SaveAction::List { profile, game } => {
let p = pm.load(&profile, game.as_deref())?;
let profile_id = p.id.ok_or_else(|| anyhow::anyhow!("profile has no database ID"))?;
let sm = SaveManager::new(pm.db());
let saves = sm.list(profile_id)?;
if saves.is_empty() {
println!("No saves assigned to profile '{profile}'.");
} else {
println!("Saves for profile '{profile}':");
for s in saves {
let label = s.label.as_deref().unwrap_or("-");
println!(" {} (label: {}, assigned: {})", s.path.display(), label, s.assigned_at);
}
}
}
SaveAction::Scan { game } => {
let save_dir = require_save_dir(&game)?;
let sm = SaveManager::new(pm.db());
let unassigned = sm.list_unassigned(&save_dir)?;
if unassigned.is_empty() {
println!("No unassigned saves found for game '{game}'.");
} else {
println!("Unassigned saves for '{game}':");
for path in unassigned {
println!(" {}", path.display());
}
}
}
SaveAction::Adopt { game, profile } => {
let save_dir = require_save_dir(&game)?;
let sm = SaveManager::new(pm.db());
let count = sm.adopt(&game, &profile, &save_dir)?;
if count > 0 {
println!("Adopted {count} save file(s) from game '{game}' into profile '{profile}'.");
} else {
println!("No saves found to adopt for game '{game}'.");
}
}
SaveAction::Capture { game, profile, message } => {
let save_dir = require_save_dir(&game)?;
let sm = SaveManager::new(pm.db());
let p = pm.load(&profile, Some(&game))?;
let fp = compute_fingerprint(&p);
let count = sm.capture_with_fingerprint(&game, &profile, &save_dir, Some(&fp))?;
if count > 0 {
if let Some(msg) = message {
amend_last_commit(&game, &msg)?;
}
if !fp.is_empty() {
println!("Captured {count} save file(s) for profile '{profile}' [fingerprint: {}].", fp.short_hash());
} else {
println!("Captured {count} save file(s) for profile '{profile}'.");
}
} else {
println!("No saves to capture for game '{game}'.");
}
}
SaveAction::History { game, profile, limit } => {
let snapshots = SaveManager::history(&game, &profile, limit)?;
if snapshots.is_empty() {
println!("No save history for profile '{profile}' (game: {game}).");
} else {
println!("Save history for '{profile}' (game: {game}):");
for snap in &snapshots {
let dt = format_timestamp(snap.timestamp);
let fp_tag = snap.fingerprint.as_ref()
.map(|fp| format!(" [{}]", fp.short_hash()))
.unwrap_or_default();
println!(
" {} | {} | {} file(s){} | {}",
snap.short_id(), dt, snap.file_count, fp_tag,
snap.message.lines().next().unwrap_or("").trim()
);
}
}
}
SaveAction::Restore { game, profile, commit } => {
let save_dir = require_save_dir(&game)?;
let p = pm.load(&profile, Some(&game))?;
let current_fp = compute_fingerprint(&p);
let check = SaveManager::check_restore_compatibility(
&game, &profile, &commit, ¤t_fp,
)?;
warn_fingerprint_mismatch(&check);
let count = SaveManager::restore(&game, &profile, &commit, &save_dir)?;
println!("Restored {count} save file(s) from snapshot {commit} to game directory.");
}
SaveAction::AutoCapture { game, profile } => {
let save_dir = require_save_dir(&game)?;
let sm = SaveManager::new(pm.db());
let profile_name = resolve_profile_name(&pm, profile, &game)?;
let p = pm.load(&profile_name, Some(&game))?;
let fp = compute_fingerprint(&p);
let count = sm.capture_with_fingerprint(&game, &profile_name, &save_dir, Some(&fp))?;
if count > 0 {
if let Some(tracker) = modde_games::resolve_save_tracker(&game) {
let saves = tracker.detect_saves(&save_dir)?;
if !saves.is_empty() {
let msg = tracker.describe_capture(&saves);
amend_last_commit(&game, &msg)?;
}
}
println!("Auto-captured {count} save file(s) for profile '{profile_name}'.");
}
}
SaveAction::Watch { game, profile, interval } => {
let save_dir = require_save_dir(&game)?;
let sm = SaveManager::new(pm.db());
let profile_name = resolve_profile_name(&pm, profile, &game)?;
let p = pm.load(&profile_name, Some(&game))?;
let fp = compute_fingerprint(&p);
let tracker = modde_games::resolve_save_tracker(&game);
let debounce = std::time::Duration::from_secs(interval);
println!(
"Watching saves for '{profile_name}' (game: {game}), debounce {interval}s..."
);
println!("Press Ctrl+C to stop.");
let mut last_saves = tracker
.map(|t| t.detect_saves(&save_dir))
.transpose()?
.unwrap_or_default();
let (fs_tx, mut fs_rx) = tokio::sync::mpsc::channel::<Event>(64);
let mut watcher = RecommendedWatcher::new(
move |res: notify::Result<Event>| {
if let Ok(event) = res {
let _ = fs_tx.blocking_send(event);
}
},
notify::Config::default(),
)
.context("failed to create filesystem watcher")?;
watcher
.watch(&save_dir, RecursiveMode::NonRecursive)
.with_context(|| {
format!("failed to watch save directory: {}", save_dir.display())
})?;
loop {
let Some(event) = fs_rx.recv().await else { break };
let is_write = matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_)
);
if !is_write {
continue;
}
let is_save_file = event.paths.iter().any(|p| {
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
matches!(ext, "ess" | "dat" | "sav")
});
if !is_save_file {
continue;
}
let _ = tokio::time::timeout(debounce, async {
while fs_rx.try_recv().is_ok() {}
tokio::time::sleep(debounce).await;
})
.await;
let count = sm.capture_with_fingerprint(
&game, &profile_name, &save_dir, Some(&fp),
)?;
if count > 0 {
let current_saves = tracker
.map(|t| t.detect_saves(&save_dir))
.transpose()?
.unwrap_or_default();
let old_paths: std::collections::HashSet<_> =
last_saves.iter().map(|s| &s.rel_path).collect();
let new_saves: Vec<_> = current_saves
.iter()
.filter(|s| !old_paths.contains(&s.rel_path))
.cloned()
.collect();
if let Some(t) = tracker {
let msg = if new_saves.is_empty() {
t.describe_capture(¤t_saves)
} else {
t.describe_capture(&new_saves)
};
amend_last_commit(&game, &msg)?;
println!(
" [{}] {msg}",
format_timestamp(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
)
);
} else {
println!(" Captured {count} file(s).");
}
last_saves = current_saves;
}
}
}
}
Ok(())
}
fn amend_last_commit(game_id: &str, message: &str) -> Result<()> {
let repo = SaveManager::vault_repo(game_id)?;
let head = repo.head()
.and_then(|h| h.peel_to_commit())
.context("no HEAD commit to amend")?;
let old_msg = head.message().unwrap_or("");
let trailers: Vec<&str> = old_msg
.lines()
.filter(|line| {
line.starts_with("Mod-Fingerprint: ") || line.starts_with("Save-Breaking-Mods: ")
})
.collect();
let final_message = if trailers.is_empty() {
message.to_string()
} else {
format!("{message}\n\n{}", trailers.join("\n"))
};
head.amend(
Some("HEAD"),
None, None, None, Some(&final_message),
None, ).context("failed to amend commit")?;
Ok(())
}
fn format_timestamp(secs: i64) -> String {
modde_core::save::format_timestamp(secs)
}