1use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use crate::trace::SemanticIndex;
14
15pub use chrono::{DateTime, Utc};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Snapshot {
25 pub id: String,
27 pub name: Option<String>,
29 pub created_at: DateTime<Utc>,
31 pub project: String,
33 pub metrics: SnapshotMetrics,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SnapshotMetrics {
40 pub files: u32,
42 pub symbols: u32,
44 pub dead: u32,
46 pub cycles: u32,
48 pub entry_points: u32,
50 pub by_kind: HashMap<String, u32>,
52 pub top_files: Vec<FileMetrics>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct FileMetrics {
59 pub path: String,
61 pub symbols: u32,
63 pub dead: u32,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SnapshotComparison {
70 pub a: SnapshotSummary,
72 pub b: SnapshotSummary,
74 pub diff: SnapshotDiff,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SnapshotSummary {
81 pub id: String,
82 pub name: Option<String>,
83 pub created_at: DateTime<Utc>,
84 pub files: u32,
85 pub symbols: u32,
86 pub dead: u32,
87 pub cycles: u32,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct SnapshotDiff {
93 pub files: i32,
94 pub symbols: i32,
95 pub dead: i32,
96 pub cycles: i32,
97 pub entry_points: i32,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SnapshotList {
103 pub snapshots: Vec<SnapshotSummary>,
104 pub total: usize,
105}
106
107pub fn snapshots_dir(project_path: &Path) -> PathBuf {
113 project_path.join(".greppy").join("snapshots")
114}
115
116fn snapshot_path(project_path: &Path, id: &str) -> PathBuf {
118 snapshots_dir(project_path).join(format!("{}.json", id))
119}
120
121fn generate_id() -> String {
123 Utc::now().format("%Y%m%d_%H%M%S").to_string()
124}
125
126pub fn create_snapshot(
143 index: &SemanticIndex,
144 project_path: &Path,
145 project_name: &str,
146 dead_symbols: &std::collections::HashSet<u32>,
147 cycles_count: u32,
148 name: Option<String>,
149) -> Result<Snapshot, String> {
150 let id = generate_id();
151 let now = Utc::now();
152
153 let mut by_kind: HashMap<String, u32> = HashMap::new();
155 for sym in &index.symbols {
156 let kind = format!("{:?}", sym.kind).to_lowercase();
157 *by_kind.entry(kind).or_insert(0) += 1;
158 }
159
160 let entry_points = index.symbols.iter().filter(|s| s.is_entry_point()).count() as u32;
162
163 let mut file_map: HashMap<u16, (u32, u32)> = HashMap::new(); for sym in &index.symbols {
166 let entry = file_map.entry(sym.file_id).or_insert((0, 0));
167 entry.0 += 1;
168 if dead_symbols.contains(&sym.id) {
169 entry.1 += 1;
170 }
171 }
172
173 let mut top_files: Vec<FileMetrics> = file_map
176 .iter()
177 .filter_map(|(file_id, (symbols, dead))| {
178 index
179 .files
180 .get(*file_id as usize)
181 .map(|path_buf| FileMetrics {
182 path: path_buf.to_string_lossy().to_string(),
183 symbols: *symbols,
184 dead: *dead,
185 })
186 })
187 .collect();
188 top_files.sort_by(|a, b| b.symbols.cmp(&a.symbols));
189 top_files.truncate(20);
190
191 let snapshot = Snapshot {
192 id: id.clone(),
193 name,
194 created_at: now,
195 project: project_name.to_string(),
196 metrics: SnapshotMetrics {
197 files: index.files.len() as u32,
198 symbols: index.symbols.len() as u32,
199 dead: dead_symbols.len() as u32,
200 cycles: cycles_count,
201 entry_points,
202 by_kind,
203 top_files,
204 },
205 };
206
207 let dir = snapshots_dir(project_path);
209 fs::create_dir_all(&dir).map_err(|e| format!("Failed to create snapshots dir: {}", e))?;
210
211 let path = snapshot_path(project_path, &id);
213 let json = serde_json::to_string_pretty(&snapshot)
214 .map_err(|e| format!("Failed to serialize: {}", e))?;
215 fs::write(&path, json).map_err(|e| format!("Failed to write snapshot: {}", e))?;
216
217 Ok(snapshot)
218}
219
220pub fn list_snapshots(project_path: &Path) -> Result<SnapshotList, String> {
232 let dir = snapshots_dir(project_path);
233
234 if !dir.exists() {
235 return Ok(SnapshotList {
236 snapshots: vec![],
237 total: 0,
238 });
239 }
240
241 let mut snapshots: Vec<SnapshotSummary> = vec![];
242
243 for entry in fs::read_dir(&dir).map_err(|e| format!("Failed to read dir: {}", e))? {
244 let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
245 let path = entry.path();
246
247 if path.extension().map_or(false, |ext| ext == "json") {
248 if let Ok(content) = fs::read_to_string(&path) {
249 if let Ok(snapshot) = serde_json::from_str::<Snapshot>(&content) {
250 snapshots.push(SnapshotSummary {
251 id: snapshot.id,
252 name: snapshot.name,
253 created_at: snapshot.created_at,
254 files: snapshot.metrics.files,
255 symbols: snapshot.metrics.symbols,
256 dead: snapshot.metrics.dead,
257 cycles: snapshot.metrics.cycles,
258 });
259 }
260 }
261 }
262 }
263
264 snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
266
267 let total = snapshots.len();
268 Ok(SnapshotList { snapshots, total })
269}
270
271pub fn load_snapshot(project_path: &Path, id: &str) -> Result<Snapshot, String> {
284 let path = snapshot_path(project_path, id);
285
286 if !path.exists() {
287 return Err(format!("Snapshot not found: {}", id));
288 }
289
290 let content = fs::read_to_string(&path).map_err(|e| format!("Failed to read: {}", e))?;
291 serde_json::from_str(&content).map_err(|e| format!("Failed to parse: {}", e))
292}
293
294pub fn latest_snapshot(project_path: &Path) -> Result<Option<Snapshot>, String> {
302 let list = list_snapshots(project_path)?;
303 if list.snapshots.is_empty() {
304 return Ok(None);
305 }
306
307 let latest = &list.snapshots[0];
308 load_snapshot(project_path, &latest.id).map(Some)
309}
310
311pub fn compare_snapshots(
325 project_path: &Path,
326 id_a: &str,
327 id_b: &str,
328) -> Result<SnapshotComparison, String> {
329 let a = load_snapshot(project_path, id_a)?;
330 let b = load_snapshot(project_path, id_b)?;
331
332 let diff = SnapshotDiff {
333 files: b.metrics.files as i32 - a.metrics.files as i32,
334 symbols: b.metrics.symbols as i32 - a.metrics.symbols as i32,
335 dead: b.metrics.dead as i32 - a.metrics.dead as i32,
336 cycles: b.metrics.cycles as i32 - a.metrics.cycles as i32,
337 entry_points: b.metrics.entry_points as i32 - a.metrics.entry_points as i32,
338 };
339
340 Ok(SnapshotComparison {
341 a: SnapshotSummary {
342 id: a.id,
343 name: a.name,
344 created_at: a.created_at,
345 files: a.metrics.files,
346 symbols: a.metrics.symbols,
347 dead: a.metrics.dead,
348 cycles: a.metrics.cycles,
349 },
350 b: SnapshotSummary {
351 id: b.id,
352 name: b.name,
353 created_at: b.created_at,
354 files: b.metrics.files,
355 symbols: b.metrics.symbols,
356 dead: b.metrics.dead,
357 cycles: b.metrics.cycles,
358 },
359 diff,
360 })
361}
362
363pub fn delete_snapshot(project_path: &Path, id: &str) -> Result<(), String> {
376 let path = snapshot_path(project_path, id);
377
378 if !path.exists() {
379 return Err(format!("Snapshot not found: {}", id));
380 }
381
382 fs::remove_file(&path).map_err(|e| format!("Failed to delete: {}", e))
383}
384
385pub fn cleanup_snapshots(project_path: &Path, keep: usize) -> Result<usize, String> {
398 let list = list_snapshots(project_path)?;
399
400 if list.total <= keep {
401 return Ok(0);
402 }
403
404 let mut deleted = 0;
405 for snapshot in list.snapshots.iter().skip(keep) {
406 if delete_snapshot(project_path, &snapshot.id).is_ok() {
407 deleted += 1;
408 }
409 }
410
411 Ok(deleted)
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn test_generate_id() {
420 let id = generate_id();
421 assert!(!id.is_empty());
422 assert_eq!(id.len(), 15);
424 assert!(id.contains('_'));
425 }
426}