use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::info;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub created_at: String,
pub stout_version: String,
#[serde(default)]
pub formulas: Vec<FormulaSnapshot>,
#[serde(default)]
pub casks: Vec<CaskSnapshot>,
#[serde(default)]
pub pinned: Vec<String>,
#[serde(default)]
pub taps: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormulaSnapshot {
pub name: String,
pub version: String,
#[serde(default)]
pub revision: u32,
#[serde(default)]
pub requested: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaskSnapshot {
pub token: String,
pub version: String,
}
impl Snapshot {
pub fn new(name: &str, description: Option<&str>) -> Self {
Self {
name: name.to_string(),
description: description.map(|s| s.to_string()),
created_at: current_timestamp(),
stout_version: env!("CARGO_PKG_VERSION").to_string(),
formulas: Vec::new(),
casks: Vec::new(),
pinned: Vec::new(),
taps: Vec::new(),
}
}
pub fn add_formula(&mut self, name: &str, version: &str, revision: u32, requested: bool) {
self.formulas.push(FormulaSnapshot {
name: name.to_string(),
version: version.to_string(),
revision,
requested,
});
}
pub fn add_cask(&mut self, token: &str, version: &str) {
self.casks.push(CaskSnapshot {
token: token.to_string(),
version: version.to_string(),
});
}
pub fn formula_count(&self) -> usize {
self.formulas.len()
}
pub fn cask_count(&self) -> usize {
self.casks.len()
}
pub fn requested_formulas(&self) -> Vec<&str> {
self.formulas
.iter()
.filter(|f| f.requested)
.map(|f| f.name.as_str())
.collect()
}
}
pub struct SnapshotManager {
snapshots_dir: PathBuf,
}
impl SnapshotManager {
pub fn new(stout_dir: &Path) -> Self {
Self {
snapshots_dir: stout_dir.join("snapshots"),
}
}
fn ensure_dir(&self) -> Result<()> {
std::fs::create_dir_all(&self.snapshots_dir)?;
Ok(())
}
fn snapshot_path(&self, name: &str) -> PathBuf {
self.snapshots_dir.join(format!("{}.json", name))
}
pub fn save(&self, snapshot: &Snapshot) -> Result<PathBuf> {
self.ensure_dir()?;
let path = self.snapshot_path(&snapshot.name);
let json = serde_json::to_string_pretty(snapshot)?;
std::fs::write(&path, json)?;
info!("Saved snapshot '{}' to {}", snapshot.name, path.display());
Ok(path)
}
pub fn load(&self, name: &str) -> Result<Snapshot> {
let path = self.snapshot_path(name);
if !path.exists() {
return Err(Error::SnapshotNotFound(name.to_string()));
}
let json = std::fs::read_to_string(&path)?;
let snapshot: Snapshot = serde_json::from_str(&json)?;
Ok(snapshot)
}
pub fn list(&self) -> Result<Vec<SnapshotInfo>> {
self.ensure_dir()?;
let mut snapshots = Vec::new();
for entry in std::fs::read_dir(&self.snapshots_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
if let Ok(snapshot) = self.load_info(&path) {
snapshots.push(snapshot);
}
}
}
snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(snapshots)
}
fn load_info(&self, path: &Path) -> Result<SnapshotInfo> {
let json = std::fs::read_to_string(path)?;
let snapshot: Snapshot = serde_json::from_str(&json)?;
Ok(SnapshotInfo {
name: snapshot.name,
description: snapshot.description,
created_at: snapshot.created_at,
formula_count: snapshot.formulas.len(),
cask_count: snapshot.casks.len(),
})
}
pub fn delete(&self, name: &str) -> Result<()> {
let path = self.snapshot_path(name);
if !path.exists() {
return Err(Error::SnapshotNotFound(name.to_string()));
}
std::fs::remove_file(&path)?;
info!("Deleted snapshot '{}'", name);
Ok(())
}
pub fn exists(&self, name: &str) -> bool {
self.snapshot_path(name).exists()
}
pub fn export(&self, name: &str) -> Result<String> {
let snapshot = self.load(name)?;
Ok(serde_json::to_string_pretty(&snapshot)?)
}
pub fn import(&self, json: &str) -> Result<String> {
let snapshot: Snapshot = serde_json::from_str(json)?;
self.save(&snapshot)?;
Ok(snapshot.name)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SnapshotInfo {
pub name: String,
pub description: Option<String>,
pub created_at: String,
pub formula_count: usize,
pub cask_count: usize,
}
fn current_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let days_since_epoch = secs / 86400;
let remaining_secs = secs % 86400;
let hours = remaining_secs / 3600;
let minutes = (remaining_secs % 3600) / 60;
let seconds = remaining_secs % 60;
let years = 1970 + (days_since_epoch / 365);
let day_of_year = days_since_epoch % 365;
let month = (day_of_year / 30).min(11) + 1;
let day = (day_of_year % 30) + 1;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
years, month, day, hours, minutes, seconds
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_snapshot_creation() {
let mut snapshot = Snapshot::new("test", Some("Test snapshot"));
snapshot.add_formula("jq", "1.7.1", 0, true);
snapshot.add_formula("oniguruma", "6.9.9", 0, false);
snapshot.add_cask("firefox", "130.0");
assert_eq!(snapshot.formula_count(), 2);
assert_eq!(snapshot.cask_count(), 1);
assert_eq!(snapshot.requested_formulas(), vec!["jq"]);
}
#[test]
fn test_snapshot_manager() {
let dir = tempdir().unwrap();
let manager = SnapshotManager::new(dir.path());
let mut snapshot = Snapshot::new("test", Some("Test"));
snapshot.add_formula("jq", "1.7.1", 0, true);
manager.save(&snapshot).unwrap();
assert!(manager.exists("test"));
let loaded = manager.load("test").unwrap();
assert_eq!(loaded.name, "test");
assert_eq!(loaded.formula_count(), 1);
let list = manager.list().unwrap();
assert_eq!(list.len(), 1);
manager.delete("test").unwrap();
assert!(!manager.exists("test"));
}
}