1use anyhow::Result;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use crate::storage::Database;
17use super::types::Version;
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub struct SnapshotId(String);
22
23impl SnapshotId {
24 pub fn from_hash(hash: &[u8]) -> Self {
26 Self(format!("{:x}", Sha256::digest(hash)))
27 }
28
29 pub fn from_str(s: impl Into<String>) -> Self {
31 Self(s.into())
32 }
33
34 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl std::fmt::Display for SnapshotId {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 write!(f, "{}", &self.0[..8]) }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Snapshot {
49 pub id: SnapshotId,
51
52 pub parents: Vec<SnapshotId>,
54
55 pub message: String,
57
58 pub author: String,
60
61 pub timestamp: DateTime<Utc>,
63
64 pub tool_states: HashMap<String, ToolState>,
66
67 pub files: HashMap<PathBuf, FileSnapshot>,
69
70 pub metadata: HashMap<String, String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ToolState {
77 pub tool_name: String,
78 pub version: Version,
79 pub config: HashMap<String, serde_json::Value>,
80 pub output_files: Vec<PathBuf>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct FileSnapshot {
86 pub path: PathBuf,
87 pub hash: String,
88 pub size: u64,
89 pub modified: DateTime<Utc>,
90}
91
92impl FileSnapshot {
93 pub fn from_path(path: &Path) -> Result<Self> {
95 let content = std::fs::read(path)?;
96 let hash = format!("{:x}", Sha256::digest(&content));
97 let metadata = std::fs::metadata(path)?;
98 let modified = metadata.modified()
99 .map(|t| DateTime::<Utc>::from(t))
100 .unwrap_or_else(|_| Utc::now());
101
102 Ok(Self {
103 path: path.to_path_buf(),
104 hash,
105 size: metadata.len(),
106 modified,
107 })
108 }
109
110 pub fn has_changed(&self) -> Result<bool> {
112 if !self.path.exists() {
113 return Ok(true);
114 }
115
116 let content = std::fs::read(&self.path)?;
117 let current_hash = format!("{:x}", Sha256::digest(&content));
118 Ok(current_hash != self.hash)
119 }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Branch {
125 pub name: String,
126 pub head: SnapshotId,
127 pub created_at: DateTime<Utc>,
128 pub updated_at: DateTime<Utc>,
129}
130
131pub struct SnapshotManager {
133 _db: Database,
134 snapshots_path: PathBuf,
135 branches_path: PathBuf,
136 current_branch: String,
137}
138
139impl SnapshotManager {
140 pub fn new(forge_dir: &Path) -> Result<Self> {
142 let db_path = forge_dir.join("forge.db"); let db = Database::new(&db_path)?;
144
145 let snapshots_path = forge_dir.join("snapshots");
146 let branches_path = forge_dir.join("branches.json");
147
148 std::fs::create_dir_all(&snapshots_path)?;
149
150 let current_branch = if branches_path.exists() {
152 let content = std::fs::read_to_string(&branches_path)?;
153 let branches: HashMap<String, Branch> = serde_json::from_str(&content)?;
154 branches.keys().next().cloned().unwrap_or_else(|| "main".to_string())
155 } else {
156 "main".to_string()
157 };
158
159 Ok(Self {
160 _db: db,
161 snapshots_path,
162 branches_path,
163 current_branch,
164 })
165 }
166
167 pub fn create_snapshot(
169 &mut self,
170 message: impl Into<String>,
171 tool_states: HashMap<String, ToolState>,
172 files: Vec<PathBuf>,
173 ) -> Result<SnapshotId> {
174 let author = whoami::username();
175 let timestamp = Utc::now();
176
177 let mut file_snapshots = HashMap::new();
179 for file in files {
180 if file.exists() {
181 let snapshot = FileSnapshot::from_path(&file)?;
182 file_snapshots.insert(file, snapshot);
183 }
184 }
185
186 let parents = self.get_branch_head(&self.current_branch)?
188 .map(|head| vec![head])
189 .unwrap_or_default();
190
191 let content = serde_json::to_vec(&(&tool_states, &file_snapshots))?;
193 let id = SnapshotId::from_hash(&content);
194
195 let snapshot = Snapshot {
196 id: id.clone(),
197 parents,
198 message: message.into(),
199 author,
200 timestamp,
201 tool_states,
202 files: file_snapshots,
203 metadata: HashMap::new(),
204 };
205
206 self.save_snapshot(&snapshot)?;
208
209 let current_branch = self.current_branch.clone();
211 self.update_branch_head(¤t_branch, id.clone())?;
212
213 tracing::info!("Created snapshot {} on branch {}", id, self.current_branch);
214 Ok(id)
215 }
216
217 pub fn get_snapshot(&self, id: &SnapshotId) -> Result<Option<Snapshot>> {
219 let snapshot_file = self.snapshots_path.join(format!("{}.json", id.as_str()));
220
221 if !snapshot_file.exists() {
222 return Ok(None);
223 }
224
225 let content = std::fs::read_to_string(&snapshot_file)?;
226 let snapshot: Snapshot = serde_json::from_str(&content)?;
227 Ok(Some(snapshot))
228 }
229
230 pub fn create_branch(&mut self, name: impl Into<String>) -> Result<()> {
232 let name = name.into();
233
234 let head = self.get_branch_head(&self.current_branch)?
235 .ok_or_else(|| anyhow::anyhow!("Current branch has no commits"))?;
236
237 let branch = Branch {
238 name: name.clone(),
239 head,
240 created_at: Utc::now(),
241 updated_at: Utc::now(),
242 };
243
244 self.save_branch(&branch)?;
245 tracing::info!("Created branch {}", name);
246 Ok(())
247 }
248
249 pub fn checkout_branch(&mut self, name: impl Into<String>) -> Result<()> {
251 let name = name.into();
252
253 if !self.branch_exists(&name) {
254 anyhow::bail!("Branch {} does not exist", name);
255 }
256
257 self.current_branch = name.clone();
258 tracing::info!("Switched to branch {}", name);
259 Ok(())
260 }
261
262 pub fn current_branch(&self) -> &str {
264 &self.current_branch
265 }
266
267 pub fn list_branches(&self) -> Result<Vec<Branch>> {
269 if !self.branches_path.exists() {
270 return Ok(vec![]);
271 }
272
273 let content = std::fs::read_to_string(&self.branches_path)?;
274 let branches: HashMap<String, Branch> = serde_json::from_str(&content)?;
275 Ok(branches.into_values().collect())
276 }
277
278 pub fn history(&self, limit: usize) -> Result<Vec<Snapshot>> {
280 let head = self.get_branch_head(&self.current_branch)?;
281
282 if head.is_none() {
283 return Ok(vec![]);
284 }
285
286 let mut history = Vec::new();
287 let mut current = head;
288
289 while let Some(id) = current {
290 if history.len() >= limit {
291 break;
292 }
293
294 if let Some(snapshot) = self.get_snapshot(&id)? {
295 current = snapshot.parents.first().cloned();
296 history.push(snapshot);
297 } else {
298 break;
299 }
300 }
301
302 Ok(history)
303 }
304
305 pub fn merge(&mut self, source_branch: impl Into<String>, message: impl Into<String>) -> Result<SnapshotId> {
307 let source_branch = source_branch.into();
308
309 let source_head = self.get_branch_head(&source_branch)?
310 .ok_or_else(|| anyhow::anyhow!("Source branch has no commits"))?;
311
312 let target_head = self.get_branch_head(&self.current_branch)?
313 .ok_or_else(|| anyhow::anyhow!("Current branch has no commits"))?;
314
315 let source_snap = self.get_snapshot(&source_head)?
317 .ok_or_else(|| anyhow::anyhow!("Source snapshot not found"))?;
318
319 let target_snap = self.get_snapshot(&target_head)?
320 .ok_or_else(|| anyhow::anyhow!("Target snapshot not found"))?;
321
322 let mut merged_states = target_snap.tool_states.clone();
324 for (name, state) in source_snap.tool_states {
325 merged_states.entry(name).or_insert(state);
326 }
327
328 let mut merged_files = target_snap.files.clone();
330 for (path, file) in source_snap.files {
331 merged_files.entry(path).or_insert(file);
332 }
333
334 let author = whoami::username();
336 let timestamp = Utc::now();
337
338 let content = serde_json::to_vec(&(&merged_states, &merged_files))?;
339 let id = SnapshotId::from_hash(&content);
340
341 let snapshot = Snapshot {
342 id: id.clone(),
343 parents: vec![target_head, source_head],
344 message: message.into(),
345 author,
346 timestamp,
347 tool_states: merged_states,
348 files: merged_files,
349 metadata: HashMap::new(),
350 };
351
352 self.save_snapshot(&snapshot)?;
353
354 let current_branch = self.current_branch.clone();
356 self.update_branch_head(¤t_branch, id.clone())?;
357
358 tracing::info!("Merged {} into {} ({})", source_branch, self.current_branch, id);
359 Ok(id)
360 }
361
362 pub fn diff(&self, from: &SnapshotId, to: &SnapshotId) -> Result<SnapshotDiff> {
364 let from_snap = self.get_snapshot(from)?
365 .ok_or_else(|| anyhow::anyhow!("From snapshot not found"))?;
366
367 let to_snap = self.get_snapshot(to)?
368 .ok_or_else(|| anyhow::anyhow!("To snapshot not found"))?;
369
370 let mut added_files = Vec::new();
371 let mut modified_files = Vec::new();
372 let mut deleted_files = Vec::new();
373
374 for (path, to_file) in &to_snap.files {
376 match from_snap.files.get(path) {
377 Some(from_file) => {
378 if from_file.hash != to_file.hash {
379 modified_files.push(path.clone());
380 }
381 }
382 None => {
383 added_files.push(path.clone());
384 }
385 }
386 }
387
388 for path in from_snap.files.keys() {
390 if !to_snap.files.contains_key(path) {
391 deleted_files.push(path.clone());
392 }
393 }
394
395 Ok(SnapshotDiff {
396 from: from.clone(),
397 to: to.clone(),
398 added_files,
399 modified_files,
400 deleted_files,
401 })
402 }
403
404 fn save_snapshot(&self, snapshot: &Snapshot) -> Result<()> {
407 let snapshot_file = self.snapshots_path.join(format!("{}.json", snapshot.id.as_str()));
408 let content = serde_json::to_string_pretty(snapshot)?;
409 std::fs::write(snapshot_file, content)?;
410 Ok(())
411 }
412
413 fn save_branch(&self, branch: &Branch) -> Result<()> {
414 let mut branches = if self.branches_path.exists() {
415 let content = std::fs::read_to_string(&self.branches_path)?;
416 serde_json::from_str(&content)?
417 } else {
418 HashMap::new()
419 };
420
421 branches.insert(branch.name.clone(), branch.clone());
422
423 let content = serde_json::to_string_pretty(&branches)?;
424 std::fs::write(&self.branches_path, content)?;
425 Ok(())
426 }
427
428 fn get_branch_head(&self, name: &str) -> Result<Option<SnapshotId>> {
429 if !self.branches_path.exists() {
430 return Ok(None);
431 }
432
433 let content = std::fs::read_to_string(&self.branches_path)?;
434 let branches: HashMap<String, Branch> = serde_json::from_str(&content)?;
435
436 Ok(branches.get(name).map(|b| b.head.clone()))
437 }
438
439 fn update_branch_head(&mut self, name: &str, head: SnapshotId) -> Result<()> {
440 let mut branches: HashMap<String, Branch> = if self.branches_path.exists() {
441 let content = std::fs::read_to_string(&self.branches_path)?;
442 serde_json::from_str(&content)?
443 } else {
444 HashMap::new()
445 };
446
447 if let Some(branch) = branches.get_mut(name) {
448 branch.head = head;
449 branch.updated_at = Utc::now();
450 } else {
451 branches.insert(name.to_string(), Branch {
452 name: name.to_string(),
453 head,
454 created_at: Utc::now(),
455 updated_at: Utc::now(),
456 });
457 }
458
459 let content = serde_json::to_string_pretty(&branches)?;
460 std::fs::write(&self.branches_path, content)?;
461 Ok(())
462 }
463
464 fn branch_exists(&self, name: &str) -> bool {
465 if !self.branches_path.exists() {
466 return false;
467 }
468
469 if let Ok(content) = std::fs::read_to_string(&self.branches_path) {
470 if let Ok(branches) = serde_json::from_str::<HashMap<String, Branch>>(&content) {
471 return branches.contains_key(name);
472 }
473 }
474
475 false
476 }
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct SnapshotDiff {
482 pub from: SnapshotId,
483 pub to: SnapshotId,
484 pub added_files: Vec<PathBuf>,
485 pub modified_files: Vec<PathBuf>,
486 pub deleted_files: Vec<PathBuf>,
487}
488
489impl SnapshotDiff {
490 pub fn has_changes(&self) -> bool {
492 !self.added_files.is_empty() ||
493 !self.modified_files.is_empty() ||
494 !self.deleted_files.is_empty()
495 }
496
497 pub fn total_changes(&self) -> usize {
499 self.added_files.len() + self.modified_files.len() + self.deleted_files.len()
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use tempfile::TempDir;
507
508 #[test]
509 fn test_create_snapshot() {
510 let temp_dir = TempDir::new().unwrap();
511 let mut manager = SnapshotManager::new(temp_dir.path()).unwrap();
512
513 let mut tool_states = HashMap::new();
514 tool_states.insert("test-tool".to_string(), ToolState {
515 tool_name: "test-tool".to_string(),
516 version: Version::new(1, 0, 0),
517 config: HashMap::new(),
518 output_files: vec![],
519 });
520
521 let id = manager.create_snapshot("Initial commit", tool_states, vec![]).unwrap();
522
523 let snapshot = manager.get_snapshot(&id).unwrap().unwrap();
524 assert_eq!(snapshot.message, "Initial commit");
525 assert_eq!(snapshot.tool_states.len(), 1);
526 }
527
528 #[test]
529 fn test_branching() {
530 let temp_dir = TempDir::new().unwrap();
531 let mut manager = SnapshotManager::new(temp_dir.path()).unwrap();
532
533 manager.create_snapshot("Initial", HashMap::new(), vec![]).unwrap();
535
536 manager.create_branch("feature").unwrap();
538
539 manager.checkout_branch("feature").unwrap();
541 assert_eq!(manager.current_branch(), "feature");
542
543 let branches = manager.list_branches().unwrap();
545 assert!(branches.iter().any(|b| b.name == "feature"));
546 }
547}