use std::path::{Path, PathBuf};
use std::fs;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{Result, ToriiError};
use crate::core::GitRepo;
#[derive(Debug, Serialize, Deserialize)]
pub struct SnapshotMetadata {
pub id: String,
pub timestamp: DateTime<Utc>,
pub name: Option<String>,
pub branch: String,
pub commit_hash: Option<String>,
}
pub struct SnapshotManager {
repo_path: PathBuf,
snapshots_dir: PathBuf,
}
impl SnapshotManager {
pub fn new<P: AsRef<Path>>(repo_path: P) -> Result<Self> {
let repo_path = repo_path.as_ref().to_path_buf();
let snapshots_dir = repo_path.join(".torii").join("snapshots");
fs::create_dir_all(&snapshots_dir)?;
Ok(Self {
repo_path,
snapshots_dir,
})
}
pub fn create_snapshot(&self, name: Option<&str>) -> Result<String> {
let repo = GitRepo::open(&self.repo_path)?;
let timestamp = Utc::now();
let mut id = timestamp.format("%Y%m%d_%H%M%S_%3f").to_string();
let mut snapshot_dir = self.snapshots_dir.join(&id);
let mut suffix = 0;
while snapshot_dir.exists() {
suffix += 1;
id = format!("{}_{}", timestamp.format("%Y%m%d_%H%M%S_%3f"), suffix);
snapshot_dir = self.snapshots_dir.join(&id);
}
fs::create_dir_all(&snapshot_dir)?;
let branch = repo.get_current_branch()?;
let metadata = SnapshotMetadata {
id: id.clone(),
timestamp,
name: name.map(String::from),
branch,
commit_hash: None,
};
let metadata_path = snapshot_dir.join("metadata.json");
let metadata_json = serde_json::to_string_pretty(&metadata)?;
fs::write(metadata_path, metadata_json)?;
self.create_bundle(&snapshot_dir, &repo)?;
Ok(id)
}
fn create_bundle(&self, snapshot_dir: &Path, repo: &GitRepo) -> Result<()> {
let mut revwalk = repo.repository().revwalk()?;
revwalk.push_head()?;
let git_dir = self.repo_path.join(".git");
let snapshot_git = snapshot_dir.join("git_backup");
self.copy_dir_recursive(&git_dir, &snapshot_git)?;
Ok(())
}
fn copy_dir_recursive(&self, src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if file_type.is_dir() {
self.copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
pub fn list_snapshots(&self) -> Result<()> {
let entries = fs::read_dir(&self.snapshots_dir)?;
println!("📸 Snapshots:");
println!();
for entry in entries {
let entry = entry?;
if entry.file_type()?.is_dir() {
let metadata_path = entry.path().join("metadata.json");
if metadata_path.exists() {
let metadata_json = fs::read_to_string(metadata_path)?;
let metadata: SnapshotMetadata = serde_json::from_str(&metadata_json)?;
let name_str = metadata.name
.as_ref()
.map(|n| format!(" ({})", n))
.unwrap_or_default();
println!(" {} - {}{}",
metadata.id,
metadata.timestamp.format("%Y-%m-%d %H:%M:%S"),
name_str
);
println!(" Branch: {}", metadata.branch);
}
}
}
Ok(())
}
pub fn restore_snapshot(&self, id: &str) -> Result<()> {
let snapshot_dir = self.snapshots_dir.join(id);
if !snapshot_dir.exists() {
return Err(ToriiError::Snapshot(format!("Snapshot not found: {}", id)));
}
let snapshot_git = snapshot_dir.join("git_backup");
let git_dir = self.repo_path.join(".git");
fs::remove_dir_all(&git_dir)?;
self.copy_dir_recursive(&snapshot_git, &git_dir)?;
{
let repo = git2::Repository::discover(&self.repo_path)
.map_err(|e| ToriiError::Git(e))?;
let head = repo.head()
.map_err(|e| ToriiError::Git(e))?
.peel_to_commit()
.map_err(|e| ToriiError::Git(e))?;
repo.reset(
head.as_object(),
git2::ResetType::Hard,
Some(git2::build::CheckoutBuilder::default().force()),
).map_err(|e| ToriiError::Git(e))?;
}
Ok(())
}
pub fn delete_snapshot(&self, id: &str) -> Result<()> {
let snapshot_dir = self.snapshots_dir.join(id);
if !snapshot_dir.exists() {
return Err(ToriiError::Snapshot(format!("Snapshot not found: {}", id)));
}
fs::remove_dir_all(snapshot_dir)?;
Ok(())
}
pub fn configure_auto_snapshot(&self, enable: bool, interval: Option<u32>) -> Result<()> {
let config_path = self.repo_path.join(".torii").join("config.json");
#[derive(Serialize, Deserialize)]
struct Config {
auto_snapshot_enabled: bool,
auto_snapshot_interval_minutes: u32,
}
let config = Config {
auto_snapshot_enabled: enable,
auto_snapshot_interval_minutes: interval.unwrap_or(30),
};
let config_json = serde_json::to_string_pretty(&config)?;
fs::write(config_path, config_json)?;
Ok(())
}
pub fn stash(&self, name: Option<&str>, include_untracked: bool) -> Result<()> {
let stash_name = name.unwrap_or("WIP");
let mut repo = git2::Repository::discover(&self.repo_path)
.map_err(ToriiError::Git)?;
let mut opts = git2::StatusOptions::new();
opts.include_untracked(include_untracked)
.recurse_untracked_dirs(include_untracked);
let is_empty = {
let statuses = repo.statuses(Some(&mut opts)).map_err(ToriiError::Git)?;
statuses.is_empty()
};
if is_empty {
return Err(ToriiError::Snapshot(
"Nothing to stash — working tree is clean.".to_string(),
));
}
let signature = repo.signature().or_else(|_| {
git2::Signature::now("torii", "torii@local")
}).map_err(ToriiError::Git)?;
let mut flags = git2::StashFlags::DEFAULT;
if include_untracked {
flags |= git2::StashFlags::INCLUDE_UNTRACKED;
}
let oid = repo.stash_save2(&signature, Some(stash_name), Some(flags))
.map_err(ToriiError::Git)?;
println!("📦 Stashed changes");
println!(" stash@{{0}}: {}", &oid.to_string()[..7]);
println!(" Name: {}", stash_name);
if include_untracked {
println!(" Untracked files included");
}
println!();
println!("💡 To restore: torii snapshot unstash");
Ok(())
}
pub fn unstash(&self, id: Option<&str>, keep: bool) -> Result<()> {
let mut repo = git2::Repository::discover(&self.repo_path)
.map_err(ToriiError::Git)?;
let index: usize = match id {
Some(s) => s.trim_start_matches("stash@{").trim_end_matches('}')
.parse()
.map_err(|_| ToriiError::Snapshot(
format!("invalid stash index `{}` (use a number: 0, 1, …)", s)
))?,
None => 0,
};
let mut count = 0;
repo.stash_foreach(|_, _, _| { count += 1; true }).map_err(ToriiError::Git)?;
if count == 0 {
return Err(ToriiError::Snapshot("No stash found".to_string()));
}
if index >= count {
return Err(ToriiError::Snapshot(format!(
"stash@{{{}}} doesn't exist (have {} stash{})", index, count,
if count == 1 { "" } else { "es" }
)));
}
println!("🔄 Restoring stash@{{{}}}", index);
if keep {
let mut opts = git2::StashApplyOptions::new();
opts.reinstantiate_index();
repo.stash_apply(index, Some(&mut opts)).map_err(ToriiError::Git)?;
println!(" Stash kept (use `torii snapshot unstash {} --no-keep` to drop)", index);
} else {
repo.stash_pop(index, None).map_err(ToriiError::Git)?;
println!(" Stash popped");
}
println!("✅ Stash restored");
Ok(())
}
pub fn undo(&self) -> Result<()> {
let mut snapshots: Vec<_> = fs::read_dir(&self.snapshots_dir)?
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name().to_string_lossy().to_string();
name.starts_with("before-") || name.contains("auto-")
})
.collect();
snapshots.sort_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()));
let latest = snapshots.last()
.ok_or_else(|| ToriiError::Snapshot("No operation to undo".to_string()))?;
let snapshot_id = latest.file_name().to_string_lossy().to_string();
println!("🔄 Undoing last operation...");
println!(" Restoring snapshot: {}", snapshot_id);
self.restore_snapshot(&snapshot_id)?;
println!("✅ Operation undone");
Ok(())
}
}