1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SnapshotInfo {
19 pub id: String,
21 pub path: PathBuf,
23 pub timestamp: String,
25 pub git_branch: Option<String>,
27 pub git_commit: Option<String>,
29 pub reflex_version: String,
31 pub file_count: usize,
33 pub total_lines: usize,
35 pub edge_count: usize,
37 pub size_bytes: u64,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub content_fingerprint: Option<String>,
42}
43
44#[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#[derive(Debug)]
56pub enum EnsureSnapshotResult {
57 Created(SnapshotInfo),
59 Reused(SnapshotInfo),
61}
62
63pub fn get_snapshots_dir(cache: &CacheManager) -> PathBuf {
65 cache.path().join("snapshots")
66}
67
68pub 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
102pub 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(¤t_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
127pub 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 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 let conn = Connection::open(&snapshot_path).context("Failed to create snapshot database")?;
147
148 conn.execute_batch("PRAGMA journal_mode=WAL;")?;
150
151 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 conn.execute(
200 "ATTACH DATABASE ?1 AS source",
201 [meta_db_path.to_str().unwrap()],
202 )?;
203
204 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 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 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 conn.execute("DETACH DATABASE source", [])?;
237
238 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 let fingerprint = compute_index_fingerprint(cache)?;
267 conn.execute(
268 "INSERT INTO metadata (key, value) VALUES ('content_fingerprint', ?1)",
269 [&fingerprint],
270 )?;
271
272 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 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
305pub 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 snapshots.sort_by(|a, b| b.id.cmp(&a.id));
330
331 Ok(snapshots)
332}
333
334pub 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
343pub 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
352pub 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
359pub 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 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 let valid_snapshots: Vec<&SnapshotInfo> = snapshots
386 .iter()
387 .filter(|s| validate_snapshot(&s.path).unwrap_or(false))
388 .collect();
389
390 for snapshot in valid_snapshots.iter().take(config.daily) {
393 to_keep.insert(snapshot.id.clone());
394 }
395
396 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 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 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
445fn 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 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 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
503fn snapshot_to_week_key(id: &str) -> String {
505 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
518fn 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}