Skip to main content

reflex/pulse/
snapshot.rs

1//! Snapshot engine: point-in-time capture of structural index state
2//!
3//! Snapshots capture the structural facts needed for diffing: file metadata,
4//! dependency edges, and aggregate metrics. Each snapshot is a standalone SQLite
5//! database under `.reflex/snapshots/`.
6
7use anyhow::{Context, Result};
8use chrono::{Datelike, Local};
9use rusqlite::Connection;
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12
13use crate::cache::CacheManager;
14use crate::git;
15
16/// Information about a snapshot
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SnapshotInfo {
19    /// Unique identifier (timestamp-based filename without extension)
20    pub id: String,
21    /// Full path to the snapshot database
22    pub path: PathBuf,
23    /// When the snapshot was created
24    pub timestamp: String,
25    /// Git branch at snapshot time (if available)
26    pub git_branch: Option<String>,
27    /// Git commit SHA at snapshot time (if available)
28    pub git_commit: Option<String>,
29    /// Reflex version that created the snapshot
30    pub reflex_version: String,
31    /// Number of files in the snapshot
32    pub file_count: usize,
33    /// Total line count across all files
34    pub total_lines: usize,
35    /// Number of dependency edges
36    pub edge_count: usize,
37    /// Database file size in bytes
38    pub size_bytes: u64,
39    /// blake3 hash of all (path, content_hash) pairs — used for change detection
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub content_fingerprint: Option<String>,
42}
43
44/// Report from garbage collection
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct GcReport {
47    pub snapshots_before: usize,
48    pub snapshots_after: usize,
49    pub removed: usize,
50    pub corrupted_removed: usize,
51    pub space_freed_bytes: u64,
52}
53
54/// Result of ensuring a snapshot exists
55#[derive(Debug)]
56pub enum EnsureSnapshotResult {
57    /// A new snapshot was created because the index changed (or no snapshot existed)
58    Created(SnapshotInfo),
59    /// The latest snapshot's fingerprint matches the current index
60    Reused(SnapshotInfo),
61}
62
63/// Get the snapshots directory for a cache
64pub fn get_snapshots_dir(cache: &CacheManager) -> PathBuf {
65    cache.path().join("snapshots")
66}
67
68/// Compute a deterministic fingerprint of the current index state.
69///
70/// Hashes all (path, content_hash) pairs from meta.db's files + file_branches
71/// tables, sorted by path. The streaming blake3 hasher avoids allocating all
72/// pairs in memory.
73pub fn compute_index_fingerprint(cache: &CacheManager) -> Result<String> {
74    let meta_db_path = cache.path().join("meta.db");
75    if !meta_db_path.exists() {
76        anyhow::bail!("No index found. Run `rfx index` first.");
77    }
78
79    let conn = Connection::open(&meta_db_path).context("Failed to open meta.db for fingerprint")?;
80
81    let mut stmt = conn.prepare(
82        "SELECT f.path, fb.hash
83         FROM files f
84         JOIN file_branches fb ON f.id = fb.file_id
85         ORDER BY f.path",
86    )?;
87
88    let mut hasher = blake3::Hasher::new();
89    let mut rows = stmt.query([])?;
90    while let Some(row) = rows.next()? {
91        let path: String = row.get(0)?;
92        let hash: String = row.get(1)?;
93        hasher.update(path.as_bytes());
94        hasher.update(b":");
95        hasher.update(hash.as_bytes());
96        hasher.update(b"\n");
97    }
98
99    Ok(hasher.finalize().to_hex().to_string())
100}
101
102/// Ensure a current snapshot exists.
103///
104/// Creates a new snapshot if:
105/// - No snapshots exist yet
106/// - The latest snapshot's fingerprint doesn't match the current index
107///
108/// Runs GC after creating a new snapshot.
109pub fn ensure_snapshot(
110    cache: &CacheManager,
111    retention: &super::config::RetentionConfig,
112) -> Result<EnsureSnapshotResult> {
113    let current_fingerprint = compute_index_fingerprint(cache)?;
114    let snapshots = list_snapshots(cache)?;
115
116    if let Some(latest) = snapshots.first()
117        && latest.content_fingerprint.as_deref() == Some(&current_fingerprint)
118    {
119        return Ok(EnsureSnapshotResult::Reused(latest.clone()));
120    }
121
122    let info = create_snapshot(cache)?;
123    run_gc(cache, retention)?;
124    Ok(EnsureSnapshotResult::Created(info))
125}
126
127/// Create a snapshot of the current index state
128///
129/// Copies structural data from meta.db into a standalone snapshot database.
130/// Returns info about the created snapshot.
131pub fn create_snapshot(cache: &CacheManager) -> Result<SnapshotInfo> {
132    let snapshots_dir = get_snapshots_dir(cache);
133    std::fs::create_dir_all(&snapshots_dir).context("Failed to create snapshots directory")?;
134
135    let now = Local::now();
136    let timestamp = now.format("%Y%m%d_%H%M%S").to_string();
137    let snapshot_path = snapshots_dir.join(format!("{}.db", timestamp));
138
139    // Check meta.db exists
140    let meta_db_path = cache.path().join("meta.db");
141    if !meta_db_path.exists() {
142        anyhow::bail!("No index found. Run `rfx index` first.");
143    }
144
145    // Create snapshot database
146    let conn = Connection::open(&snapshot_path).context("Failed to create snapshot database")?;
147
148    // Enable WAL mode for better concurrent access
149    conn.execute_batch("PRAGMA journal_mode=WAL;")?;
150
151    // Create snapshot schema
152    conn.execute_batch(
153        "CREATE TABLE files (
154            id INTEGER PRIMARY KEY,
155            path TEXT NOT NULL,
156            language TEXT,
157            line_count INTEGER DEFAULT 0
158        );
159
160        CREATE TABLE dependency_edges (
161            source_file_id INTEGER NOT NULL,
162            target_file_id INTEGER NOT NULL,
163            import_type TEXT NOT NULL
164        );
165
166        CREATE TABLE metrics (
167            module_path TEXT PRIMARY KEY,
168            file_count INTEGER NOT NULL,
169            total_lines INTEGER NOT NULL
170        );
171
172        CREATE TABLE metadata (
173            key TEXT PRIMARY KEY,
174            value TEXT NOT NULL
175        );
176
177        -- Compatibility view so DependencyIndex methods work transparently
178        CREATE VIEW file_dependencies AS
179        SELECT source_file_id AS file_id,
180               '' AS imported_path,
181               target_file_id AS resolved_file_id,
182               import_type,
183               0 AS line_number,
184               NULL AS imported_symbols
185        FROM dependency_edges;
186
187        -- Empty exports view for schema compatibility
188        CREATE VIEW file_exports AS
189        SELECT 0 AS id, 0 AS file_id, NULL AS exported_symbol,
190               '' AS source_path, NULL AS resolved_source_id, 0 AS line_number
191        WHERE 0;
192
193        CREATE INDEX idx_dep_edges_source ON dependency_edges(source_file_id);
194        CREATE INDEX idx_dep_edges_target ON dependency_edges(target_file_id);
195        CREATE INDEX idx_files_path ON files(path);",
196    )?;
197
198    // Attach meta.db and copy data
199    conn.execute(
200        "ATTACH DATABASE ?1 AS source",
201        [meta_db_path.to_str().unwrap()],
202    )?;
203
204    // Copy files
205    conn.execute(
206        "INSERT INTO files (id, path, language, line_count)
207         SELECT id, path, language, line_count FROM source.files",
208        [],
209    )?;
210
211    // Copy dependency edges (projected from file_dependencies)
212    conn.execute(
213        "INSERT INTO dependency_edges (source_file_id, target_file_id, import_type)
214         SELECT file_id, resolved_file_id, import_type
215         FROM source.file_dependencies
216         WHERE resolved_file_id IS NOT NULL",
217        [],
218    )?;
219
220    // Compute module metrics by aggregating files by top-level directory
221    conn.execute(
222        "INSERT INTO metrics (module_path, file_count, total_lines)
223         SELECT
224             CASE
225                 WHEN INSTR(path, '/') > 0 THEN SUBSTR(path, 1, INSTR(path, '/') - 1)
226                 ELSE '.'
227             END AS module_path,
228             COUNT(*) AS file_count,
229             COALESCE(SUM(line_count), 0) AS total_lines
230         FROM files
231         GROUP BY module_path",
232        [],
233    )?;
234
235    // Detach source
236    conn.execute("DETACH DATABASE source", [])?;
237
238    // Write metadata
239    let git_state = git::get_git_state_optional(".").unwrap_or(None);
240
241    let metadata = vec![
242        ("timestamp", now.to_rfc3339()),
243        ("reflex_version", env!("CARGO_PKG_VERSION").to_string()),
244        ("schema_version", "1".to_string()),
245    ];
246
247    for (key, value) in &metadata {
248        conn.execute(
249            "INSERT INTO metadata (key, value) VALUES (?1, ?2)",
250            rusqlite::params![key, value],
251        )?;
252    }
253
254    if let Some(ref state) = git_state {
255        conn.execute(
256            "INSERT INTO metadata (key, value) VALUES ('git_branch', ?1)",
257            [&state.branch],
258        )?;
259        conn.execute(
260            "INSERT INTO metadata (key, value) VALUES ('git_commit', ?1)",
261            [&state.commit],
262        )?;
263    }
264
265    // Store content fingerprint for change detection
266    let fingerprint = compute_index_fingerprint(cache)?;
267    conn.execute(
268        "INSERT INTO metadata (key, value) VALUES ('content_fingerprint', ?1)",
269        [&fingerprint],
270    )?;
271
272    // Gather stats for the returned info
273    let file_count: usize = conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))?;
274    let total_lines: usize = conn.query_row(
275        "SELECT COALESCE(SUM(line_count), 0) FROM files",
276        [],
277        |row| row.get(0),
278    )?;
279    let edge_count: usize = conn.query_row("SELECT COUNT(*) FROM dependency_edges", [], |row| {
280        row.get(0)
281    })?;
282
283    // Close connection before getting file size
284    drop(conn);
285
286    let size_bytes = std::fs::metadata(&snapshot_path)
287        .map(|m| m.len())
288        .unwrap_or(0);
289
290    Ok(SnapshotInfo {
291        id: timestamp.clone(),
292        path: snapshot_path,
293        timestamp: now.to_rfc3339(),
294        git_branch: git_state.as_ref().map(|s| s.branch.clone()),
295        git_commit: git_state.as_ref().map(|s| s.commit.clone()),
296        reflex_version: env!("CARGO_PKG_VERSION").to_string(),
297        file_count,
298        total_lines,
299        edge_count,
300        size_bytes,
301        content_fingerprint: Some(fingerprint),
302    })
303}
304
305/// List all available snapshots, sorted by timestamp (newest first)
306pub fn list_snapshots(cache: &CacheManager) -> Result<Vec<SnapshotInfo>> {
307    let snapshots_dir = get_snapshots_dir(cache);
308    if !snapshots_dir.exists() {
309        return Ok(Vec::new());
310    }
311
312    let mut snapshots = Vec::new();
313
314    for entry in std::fs::read_dir(&snapshots_dir)? {
315        let entry = entry?;
316        let path = entry.path();
317
318        if path.extension().is_some_and(|ext| ext == "db") {
319            match read_snapshot_info(&path) {
320                Ok(info) => snapshots.push(info),
321                Err(e) => {
322                    log::warn!("Skipping corrupted snapshot {:?}: {}", path, e);
323                }
324            }
325        }
326    }
327
328    // Sort newest first
329    snapshots.sort_by(|a, b| b.id.cmp(&a.id));
330
331    Ok(snapshots)
332}
333
334/// Get info about a specific snapshot by ID
335pub fn get_snapshot(cache: &CacheManager, id: &str) -> Result<SnapshotInfo> {
336    let snapshot_path = get_snapshots_dir(cache).join(format!("{}.db", id));
337    if !snapshot_path.exists() {
338        anyhow::bail!("Snapshot '{}' not found", id);
339    }
340    read_snapshot_info(&snapshot_path)
341}
342
343/// Delete a specific snapshot
344pub fn delete_snapshot(cache: &CacheManager, id: &str) -> Result<()> {
345    let snapshot_path = get_snapshots_dir(cache).join(format!("{}.db", id));
346    if snapshot_path.exists() {
347        std::fs::remove_file(&snapshot_path).context("Failed to delete snapshot")?;
348    }
349    Ok(())
350}
351
352/// Validate a snapshot database integrity
353pub fn validate_snapshot(path: &Path) -> Result<bool> {
354    let conn = Connection::open(path)?;
355    let result: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
356    Ok(result == "ok")
357}
358
359/// Run garbage collection based on retention policy
360///
361/// Keeps the most recent snapshots according to the retention tiers:
362/// - `daily`: Keep N most recent daily snapshots
363/// - `weekly`: Keep N most recent weekly snapshots (one per week)
364/// - `monthly`: Keep N most recent monthly snapshots (one per month)
365pub fn run_gc(cache: &CacheManager, config: &super::config::RetentionConfig) -> Result<GcReport> {
366    let snapshots = list_snapshots(cache)?;
367    let snapshots_before = snapshots.len();
368    let mut to_keep: std::collections::HashSet<String> = std::collections::HashSet::new();
369    let mut corrupted_removed = 0u64;
370
371    // First pass: remove corrupted snapshots
372    for snapshot in &snapshots {
373        match validate_snapshot(&snapshot.path) {
374            Ok(true) => {}
375            _ => {
376                log::warn!("Removing corrupted snapshot: {}", snapshot.id);
377                let _ = std::fs::remove_file(&snapshot.path);
378                corrupted_removed += 1;
379                continue;
380            }
381        }
382    }
383
384    // Collect valid snapshots with parsed timestamps
385    let valid_snapshots: Vec<&SnapshotInfo> = snapshots
386        .iter()
387        .filter(|s| validate_snapshot(&s.path).unwrap_or(false))
388        .collect();
389
390    // Already sorted newest first from list_snapshots
391    // Keep daily tier: the N most recent
392    for snapshot in valid_snapshots.iter().take(config.daily) {
393        to_keep.insert(snapshot.id.clone());
394    }
395
396    // Keep weekly tier: one per calendar week, most recent N weeks
397    let mut weeks_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
398    let mut weekly_kept = 0;
399    for snapshot in &valid_snapshots {
400        if weekly_kept >= config.weekly {
401            break;
402        }
403        let week_key = snapshot_to_week_key(&snapshot.id);
404        if weeks_seen.insert(week_key) {
405            to_keep.insert(snapshot.id.clone());
406            weekly_kept += 1;
407        }
408    }
409
410    // Keep monthly tier: one per calendar month, most recent N months
411    let mut months_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
412    let mut monthly_kept = 0;
413    for snapshot in &valid_snapshots {
414        if monthly_kept >= config.monthly {
415            break;
416        }
417        let month_key = snapshot_to_month_key(&snapshot.id);
418        if months_seen.insert(month_key) {
419            to_keep.insert(snapshot.id.clone());
420            monthly_kept += 1;
421        }
422    }
423
424    // Remove snapshots not in any keep tier
425    let mut space_freed: u64 = 0;
426    let mut removed = 0usize;
427
428    for snapshot in &valid_snapshots {
429        if !to_keep.contains(&snapshot.id) {
430            space_freed += snapshot.size_bytes;
431            let _ = std::fs::remove_file(&snapshot.path);
432            removed += 1;
433        }
434    }
435
436    Ok(GcReport {
437        snapshots_before,
438        snapshots_after: snapshots_before - removed - corrupted_removed as usize,
439        removed: removed + corrupted_removed as usize,
440        corrupted_removed: corrupted_removed as usize,
441        space_freed_bytes: space_freed,
442    })
443}
444
445/// Read snapshot info from a database file
446fn read_snapshot_info(path: &Path) -> Result<SnapshotInfo> {
447    let conn = Connection::open(path).context("Failed to open snapshot database")?;
448
449    let id = path
450        .file_stem()
451        .and_then(|s| s.to_str())
452        .unwrap_or("unknown")
453        .to_string();
454
455    // Read metadata
456    let get_meta = |key: &str| -> Option<String> {
457        conn.query_row("SELECT value FROM metadata WHERE key = ?1", [key], |row| {
458            row.get(0)
459        })
460        .ok()
461    };
462
463    let timestamp = get_meta("timestamp").unwrap_or_else(|| id.clone());
464    let git_branch = get_meta("git_branch");
465    let git_commit = get_meta("git_commit");
466    let reflex_version = get_meta("reflex_version").unwrap_or_else(|| "unknown".to_string());
467    let content_fingerprint = get_meta("content_fingerprint");
468
469    // Gather stats
470    let file_count: usize = conn
471        .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))
472        .unwrap_or(0);
473    let total_lines: usize = conn
474        .query_row(
475            "SELECT COALESCE(SUM(line_count), 0) FROM files",
476            [],
477            |row| row.get(0),
478        )
479        .unwrap_or(0);
480    let edge_count: usize = conn
481        .query_row("SELECT COUNT(*) FROM dependency_edges", [], |row| {
482            row.get(0)
483        })
484        .unwrap_or(0);
485
486    let size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
487
488    Ok(SnapshotInfo {
489        id,
490        path: path.to_path_buf(),
491        timestamp,
492        git_branch,
493        git_commit,
494        reflex_version,
495        file_count,
496        total_lines,
497        edge_count,
498        size_bytes,
499        content_fingerprint,
500    })
501}
502
503/// Extract week key from snapshot ID (YYYYMMDD_HHMMSS -> YYYY-WXX)
504fn snapshot_to_week_key(id: &str) -> String {
505    // Parse YYYYMMDD from ID
506    if id.len() >= 8 {
507        let year: i32 = id[0..4].parse().unwrap_or(2000);
508        let month: u32 = id[4..6].parse().unwrap_or(1);
509        let day: u32 = id[6..8].parse().unwrap_or(1);
510
511        if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, day) {
512            return format!("{}-W{:02}", year, date.iso_week().week());
513        }
514    }
515    id.to_string()
516}
517
518/// Extract month key from snapshot ID (YYYYMMDD_HHMMSS -> YYYY-MM)
519fn snapshot_to_month_key(id: &str) -> String {
520    if id.len() >= 6 {
521        format!("{}-{}", &id[0..4], &id[4..6])
522    } else {
523        id.to_string()
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn test_week_key() {
533        assert_eq!(snapshot_to_week_key("20260407_120000"), "2026-W15");
534    }
535
536    #[test]
537    fn test_month_key() {
538        assert_eq!(snapshot_to_month_key("20260407_120000"), "2026-04");
539        assert_eq!(snapshot_to_month_key("20261231_235959"), "2026-12");
540    }
541
542    #[test]
543    fn test_snapshot_info_serialization() {
544        let info = SnapshotInfo {
545            id: "20260407_120000".to_string(),
546            path: PathBuf::from("/tmp/test.db"),
547            timestamp: "2026-04-07T12:00:00+00:00".to_string(),
548            git_branch: Some("main".to_string()),
549            git_commit: Some("abc123".to_string()),
550            reflex_version: "1.0.5".to_string(),
551            file_count: 100,
552            total_lines: 10000,
553            edge_count: 50,
554            size_bytes: 1024,
555            content_fingerprint: None,
556        };
557        let json = serde_json::to_string(&info).unwrap();
558        assert!(json.contains("20260407_120000"));
559    }
560}