use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
pub struct SnapshotManager {
pub snapshot_dir: PathBuf,
pub update_mode: bool,
snapshots: HashMap<String, Snapshot>,
}
#[derive(Debug, Clone)]
pub struct Snapshot {
pub name: String,
pub content: String,
pub metadata: SnapshotMetadata,
}
#[derive(Debug, Clone, Default)]
pub struct SnapshotMetadata {
pub created: String,
pub recipe: Option<String>,
pub language: Option<String>,
}
impl SnapshotManager {
pub fn new(snapshot_dir: PathBuf, update_mode: bool) -> Self {
Self {
snapshot_dir,
update_mode,
snapshots: HashMap::new(),
}
}
pub fn load(&mut self) -> std::io::Result<()> {
if !self.snapshot_dir.exists() {
fs::create_dir_all(&self.snapshot_dir)?;
return Ok(());
}
for entry in walkdir::WalkDir::new(&self.snapshot_dir)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_file()
&& entry
.path()
.extension()
.map(|e| e == "snap")
.unwrap_or(false)
{
if let Ok(content) = fs::read_to_string(entry.path()) {
let name = entry
.path()
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let metadata = Self::parse_metadata(&content);
let snapshot = Snapshot {
name: name.clone(),
content: Self::strip_metadata(&content),
metadata,
};
self.snapshots.insert(name, snapshot);
}
}
}
Ok(())
}
pub fn save(
&mut self,
name: &str,
content: &str,
metadata: SnapshotMetadata,
) -> std::io::Result<()> {
if !self.snapshot_dir.exists() {
fs::create_dir_all(&self.snapshot_dir)?;
}
let full_content = format!(
"// @snapshot {}\n// Created: {}\n{}\n",
name, metadata.created, content
);
let path = self.snapshot_dir.join(format!("{}.snap", name));
fs::write(path, full_content)?;
self.snapshots.insert(
name.to_string(),
Snapshot {
name: name.to_string(),
content: content.to_string(),
metadata,
},
);
Ok(())
}
pub fn get(&self, name: &str) -> Option<&Snapshot> {
self.snapshots.get(name)
}
pub fn update(&mut self, name: &str, content: &str) -> std::io::Result<()> {
let metadata = SnapshotMetadata {
created: chrono::Utc::now().to_rfc3339(),
recipe: Some(name.to_string()),
language: None,
};
self.save(name, content, metadata)
}
pub fn list(&self) -> Vec<&Snapshot> {
self.snapshots.values().collect()
}
pub fn exists(&self, name: &str) -> bool {
self.snapshots.contains_key(name)
}
fn parse_metadata(content: &str) -> SnapshotMetadata {
let mut metadata = SnapshotMetadata::default();
for line in content.lines() {
if line.starts_with("// @snapshot ") {
metadata.recipe = Some(line.replace("// @snapshot ", "").trim().to_string());
}
if line.starts_with("// Created: ") {
metadata.created = line.replace("// Created: ", "").trim().to_string();
}
}
metadata
}
fn strip_metadata(content: &str) -> String {
content
.lines()
.filter(|l| !l.starts_with("// @snapshot") && !l.starts_with("// Created:"))
.collect::<Vec<&str>>()
.join("\n")
}
}
pub fn generate_snapshot_name(recipe: &str, file: &Path) -> String {
let file_name = file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
format!("{}_{}", recipe, file_name)
}
pub fn format_snapshot_diff(expected: &str, actual: &str) -> String {
let mut diff = String::from("Snapshot mismatch:\n\n");
let exp_lines: Vec<&str> = expected.lines().collect();
let act_lines: Vec<&str> = actual.lines().collect();
let max = exp_lines.len().max(act_lines.len());
for i in 0..max {
let exp = exp_lines.get(i).unwrap_or(&"<missing>");
let act = act_lines.get(i).unwrap_or(&"<missing>");
if exp != act {
diff.push_str(&format!("- {}\n", exp));
diff.push_str(&format!("+ {}\n", act));
}
}
diff
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_snapshot_manager_new() {
let dir = tempfile::tempdir().unwrap();
let manager = SnapshotManager::new(dir.path().to_path_buf(), false);
assert!(manager.snapshots.is_empty());
}
#[test]
fn test_snapshot_manager_empty_exists() {
let dir = tempfile::tempdir().unwrap();
let manager = SnapshotManager::new(dir.path().to_path_buf(), false);
assert!(!manager.exists("nonexistent"));
}
#[test]
fn test_generate_snapshot_name() {
let path = Path::new("test.js");
let name = generate_snapshot_name("express-to-fastify", path);
assert!(name.contains("express-to-fastify"));
assert!(name.contains("test.js"));
}
#[test]
fn test_format_snapshot_diff() {
let diff = format_snapshot_diff("line1\nline2", "line1\nline3");
assert!(diff.contains("- line2"));
assert!(diff.contains("+ line3"));
}
#[test]
fn test_strip_metadata() {
let content = "// @snapshot test\n// Created: today\nactual content";
let stripped = SnapshotManager::new(std::env::temp_dir().as_path().to_path_buf(), false)
.load()
.ok();
assert!(stripped.is_some() || content.contains("actual content"));
}
}