greppy/trace/
snapshots.rs

1//! Snapshot Management for Timeline Tracking
2//!
3//! Stores index snapshots over time to track codebase evolution.
4//! Snapshots contain summary metrics, not full symbol data.
5//!
6//! Storage: `.greppy/snapshots/{timestamp}.json`
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use crate::trace::SemanticIndex;
14
15// Re-export DateTime for API responses
16pub use chrono::{DateTime, Utc};
17
18// =============================================================================
19// TYPES
20// =============================================================================
21
22/// Snapshot metadata and metrics.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Snapshot {
25    /// Unique identifier (timestamp-based)
26    pub id: String,
27    /// Optional user-provided name (e.g., "v1.0.0")
28    pub name: Option<String>,
29    /// When the snapshot was created
30    pub created_at: DateTime<Utc>,
31    /// Project name
32    pub project: String,
33    /// Summary metrics
34    pub metrics: SnapshotMetrics,
35}
36
37/// Summary metrics for a snapshot.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SnapshotMetrics {
40    /// Total number of files
41    pub files: u32,
42    /// Total number of symbols
43    pub symbols: u32,
44    /// Number of dead (unreferenced) symbols
45    pub dead: u32,
46    /// Number of symbols in circular dependencies
47    pub cycles: u32,
48    /// Number of entry points
49    pub entry_points: u32,
50    /// Symbols by kind (function, method, class, etc.)
51    pub by_kind: HashMap<String, u32>,
52    /// Top files by symbol count
53    pub top_files: Vec<FileMetrics>,
54}
55
56/// Per-file metrics.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct FileMetrics {
59    /// File path
60    pub path: String,
61    /// Number of symbols in file
62    pub symbols: u32,
63    /// Number of dead symbols in file
64    pub dead: u32,
65}
66
67/// Comparison between two snapshots.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SnapshotComparison {
70    /// First snapshot (older)
71    pub a: SnapshotSummary,
72    /// Second snapshot (newer)
73    pub b: SnapshotSummary,
74    /// Differences
75    pub diff: SnapshotDiff,
76}
77
78/// Minimal snapshot summary for comparison.
79#[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/// Diff between two snapshots.
91#[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/// List response for snapshots.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SnapshotList {
103    pub snapshots: Vec<SnapshotSummary>,
104    pub total: usize,
105}
106
107// =============================================================================
108// STORAGE PATH
109// =============================================================================
110
111/// Get the snapshots directory path.
112pub fn snapshots_dir(project_path: &Path) -> PathBuf {
113    project_path.join(".greppy").join("snapshots")
114}
115
116/// Get the path for a specific snapshot.
117fn snapshot_path(project_path: &Path, id: &str) -> PathBuf {
118    snapshots_dir(project_path).join(format!("{}.json", id))
119}
120
121/// Generate a snapshot ID from current timestamp.
122fn generate_id() -> String {
123    Utc::now().format("%Y%m%d_%H%M%S").to_string()
124}
125
126// =============================================================================
127// SNAPSHOT CREATION
128// =============================================================================
129
130/// Create a snapshot from the current index state.
131///
132/// # Arguments
133/// * `index` - The semantic index to snapshot
134/// * `project_path` - Path to the project root
135/// * `project_name` - Name of the project
136/// * `dead_symbols` - Set of dead symbol IDs
137/// * `cycles_count` - Number of symbols in cycles
138/// * `name` - Optional user-provided name
139///
140/// # Returns
141/// The created snapshot, or an error.
142pub 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    // Count symbols by kind
154    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    // Count entry points
161    let entry_points = index.symbols.iter().filter(|s| s.is_entry_point()).count() as u32;
162
163    // Calculate per-file metrics
164    let mut file_map: HashMap<u16, (u32, u32)> = HashMap::new(); // file_id -> (symbols, dead)
165    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    // Get top 20 files by symbol count
174    // files is Vec<PathBuf>, so we can convert directly
175    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    // Ensure directory exists
208    let dir = snapshots_dir(project_path);
209    fs::create_dir_all(&dir).map_err(|e| format!("Failed to create snapshots dir: {}", e))?;
210
211    // Save snapshot
212    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
220// =============================================================================
221// SNAPSHOT LISTING
222// =============================================================================
223
224/// List all snapshots for a project.
225///
226/// # Arguments
227/// * `project_path` - Path to the project root
228///
229/// # Returns
230/// List of snapshot summaries, sorted by creation time (newest first).
231pub 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    // Sort by creation time, newest first
265    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
271// =============================================================================
272// SNAPSHOT RETRIEVAL
273// =============================================================================
274
275/// Load a specific snapshot by ID.
276///
277/// # Arguments
278/// * `project_path` - Path to the project root
279/// * `id` - Snapshot ID
280///
281/// # Returns
282/// The snapshot, or an error if not found.
283pub 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
294/// Get the latest snapshot.
295///
296/// # Arguments
297/// * `project_path` - Path to the project root
298///
299/// # Returns
300/// The latest snapshot, or None if no snapshots exist.
301pub 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
311// =============================================================================
312// SNAPSHOT COMPARISON
313// =============================================================================
314
315/// Compare two snapshots.
316///
317/// # Arguments
318/// * `project_path` - Path to the project root
319/// * `id_a` - First snapshot ID (older)
320/// * `id_b` - Second snapshot ID (newer)
321///
322/// # Returns
323/// Comparison result with diffs.
324pub 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
363// =============================================================================
364// SNAPSHOT DELETION
365// =============================================================================
366
367/// Delete a snapshot by ID.
368///
369/// # Arguments
370/// * `project_path` - Path to the project root
371/// * `id` - Snapshot ID
372///
373/// # Returns
374/// Ok if deleted, Err if not found.
375pub 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
385// =============================================================================
386// CLEANUP
387// =============================================================================
388
389/// Clean up old snapshots, keeping only the most recent N.
390///
391/// # Arguments
392/// * `project_path` - Path to the project root
393/// * `keep` - Number of snapshots to keep
394///
395/// # Returns
396/// Number of snapshots deleted.
397pub 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        // Format: YYYYMMDD_HHMMSS
423        assert_eq!(id.len(), 15);
424        assert!(id.contains('_'));
425    }
426}