1use std::fs;
4use std::path::{Path, PathBuf};
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::analyzer::stats::AnalysisResult;
10use crate::error::{Error, Result};
11use crate::insight::DeltaValue;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Snapshot {
15 pub version: u32,
16 pub timestamp: DateTime<Utc>,
17 pub label: Option<String>,
18 pub git_commit: Option<String>,
19 pub git_branch: Option<String>,
20 pub result: AnalysisResult,
21}
22
23#[derive(Debug, Clone, Serialize)]
24pub struct SnapshotMeta {
25 pub timestamp: DateTime<Utc>,
26 pub label: Option<String>,
27 pub git_commit: Option<String>,
28 pub file_path: PathBuf,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32pub enum LangStatus {
33 Added,
34 Removed,
35 Changed,
36}
37
38impl std::fmt::Display for LangStatus {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 LangStatus::Added => write!(f, "+"),
42 LangStatus::Removed => write!(f, "-"),
43 LangStatus::Changed => write!(f, "~"),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Serialize)]
49pub struct LanguageTrend {
50 pub language: String,
51 pub status: LangStatus,
52 pub files: DeltaValue<usize>,
53 pub code: DeltaValue<usize>,
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct TrendDelta {
58 pub files: DeltaValue<usize>,
59 pub lines: DeltaValue<usize>,
60 pub code: DeltaValue<usize>,
61 pub comment: DeltaValue<usize>,
62 pub blank: DeltaValue<usize>,
63 pub complexity: DeltaValue<usize>,
64 pub functions: DeltaValue<usize>,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct TrendReport {
69 pub from: SnapshotMeta,
70 pub to: SnapshotMeta,
71 pub delta: TrendDelta,
72 pub by_language: Vec<LanguageTrend>,
73}
74
75fn snapshots_dir(project_root: &Path) -> PathBuf {
76 project_root.join(".codelens").join("snapshots")
77}
78
79pub fn save_snapshot(
80 project_root: &Path,
81 result: AnalysisResult,
82 label: Option<String>,
83 git_commit: Option<String>,
84 git_branch: Option<String>,
85) -> Result<PathBuf> {
86 let dir = snapshots_dir(project_root);
87 fs::create_dir_all(&dir).map_err(|e| Error::FileRead {
88 path: dir.clone(),
89 source: e,
90 })?;
91
92 let gitignore = project_root.join(".codelens").join(".gitignore");
93 if !gitignore.exists() {
94 let _ = fs::write(
95 &gitignore,
96 "# codelens snapshots - uncomment the next line to stop tracking\n# *\n",
97 );
98 }
99
100 let now = Utc::now();
101 let snapshot = Snapshot {
102 version: 1,
103 timestamp: now,
104 label,
105 git_commit,
106 git_branch,
107 result,
108 };
109
110 let filename = now.format("%Y-%m-%dT%H-%M-%SZ").to_string() + ".json";
111 let path = dir.join(&filename);
112 let json = serde_json::to_string_pretty(&snapshot)?;
113 fs::write(&path, json).map_err(|e| Error::FileRead {
114 path: path.clone(),
115 source: e,
116 })?;
117
118 Ok(path)
119}
120
121pub fn list_snapshots(project_root: &Path) -> Result<Vec<SnapshotMeta>> {
122 let dir = snapshots_dir(project_root);
123 if !dir.exists() {
124 return Ok(vec![]);
125 }
126
127 let mut metas = Vec::new();
128 let entries = fs::read_dir(&dir).map_err(|e| Error::FileRead {
129 path: dir.clone(),
130 source: e,
131 })?;
132
133 for entry in entries {
134 let entry = entry.map_err(|e| Error::FileRead {
135 path: dir.clone(),
136 source: e,
137 })?;
138 let path = entry.path();
139 if path.extension().is_some_and(|e| e == "json") {
140 if let Ok(meta) = read_snapshot_meta(&path) {
141 metas.push(meta);
142 }
143 }
144 }
145
146 metas.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
147 Ok(metas)
148}
149
150fn read_snapshot_meta(path: &Path) -> Result<SnapshotMeta> {
151 let content = fs::read_to_string(path).map_err(|e| Error::FileRead {
152 path: path.to_path_buf(),
153 source: e,
154 })?;
155 let snapshot: Snapshot = serde_json::from_str(&content)?;
156 Ok(SnapshotMeta {
157 timestamp: snapshot.timestamp,
158 label: snapshot.label,
159 git_commit: snapshot.git_commit,
160 file_path: path.to_path_buf(),
161 })
162}
163
164fn load_snapshot(path: &Path) -> Result<Snapshot> {
165 let content = fs::read_to_string(path).map_err(|e| Error::FileRead {
166 path: path.to_path_buf(),
167 source: e,
168 })?;
169 let snapshot: Snapshot = serde_json::from_str(&content)?;
170 Ok(snapshot)
171}
172
173pub fn resolve_snapshot(project_root: &Path, reference: &str) -> Result<PathBuf> {
174 let metas = list_snapshots(project_root)?;
175 if metas.is_empty() {
176 return Err(Error::NoSnapshots {
177 path: snapshots_dir(project_root),
178 });
179 }
180
181 if reference == "latest" {
182 return Ok(metas.last().unwrap().file_path.clone());
183 }
184
185 if let Some(offset_str) = reference.strip_prefix("latest~") {
186 let offset: usize = offset_str.parse().map_err(|_| Error::SnapshotNotFound {
187 id: reference.to_string(),
188 })?;
189 let idx = metas
190 .len()
191 .checked_sub(1 + offset)
192 .ok_or(Error::SnapshotNotFound {
193 id: reference.to_string(),
194 })?;
195 return Ok(metas[idx].file_path.clone());
196 }
197
198 for meta in metas.iter().rev() {
200 let ts = meta.timestamp.format("%Y-%m-%d").to_string();
201 if ts.starts_with(reference) {
202 return Ok(meta.file_path.clone());
203 }
204 }
205
206 Err(Error::SnapshotNotFound {
207 id: reference.to_string(),
208 })
209}
210
211pub fn diff(project_root: &Path, from_ref: &str, to_ref: &str) -> Result<TrendReport> {
212 let from_path = resolve_snapshot(project_root, from_ref)?;
213 let to_path = resolve_snapshot(project_root, to_ref)?;
214
215 let from_snap = load_snapshot(&from_path)?;
216 let to_snap = load_snapshot(&to_path)?;
217
218 let from_summary = &from_snap.result.summary;
219 let to_summary = &to_snap.result.summary;
220
221 let delta = TrendDelta {
222 files: DeltaValue::new(from_summary.total_files, to_summary.total_files),
223 lines: DeltaValue::new(from_summary.lines.total, to_summary.lines.total),
224 code: DeltaValue::new(from_summary.lines.code, to_summary.lines.code),
225 comment: DeltaValue::new(from_summary.lines.comment, to_summary.lines.comment),
226 blank: DeltaValue::new(from_summary.lines.blank, to_summary.lines.blank),
227 complexity: DeltaValue::new(
228 from_summary.complexity.cyclomatic,
229 to_summary.complexity.cyclomatic,
230 ),
231 functions: DeltaValue::new(
232 from_summary.complexity.functions,
233 to_summary.complexity.functions,
234 ),
235 };
236
237 let mut by_language = Vec::new();
238 let mut seen_langs = std::collections::HashSet::new();
239
240 for (lang, to_stats) in &to_summary.by_language {
241 seen_langs.insert(lang.clone());
242 if let Some(from_stats) = from_summary.by_language.get(lang) {
243 by_language.push(LanguageTrend {
244 language: lang.clone(),
245 status: LangStatus::Changed,
246 files: DeltaValue::new(from_stats.files, to_stats.files),
247 code: DeltaValue::new(from_stats.lines.code, to_stats.lines.code),
248 });
249 } else {
250 by_language.push(LanguageTrend {
251 language: lang.clone(),
252 status: LangStatus::Added,
253 files: DeltaValue::new(0, to_stats.files),
254 code: DeltaValue::new(0, to_stats.lines.code),
255 });
256 }
257 }
258
259 for (lang, from_stats) in &from_summary.by_language {
260 if !seen_langs.contains(lang) {
261 by_language.push(LanguageTrend {
262 language: lang.clone(),
263 status: LangStatus::Removed,
264 files: DeltaValue::new(from_stats.files, 0),
265 code: DeltaValue::new(from_stats.lines.code, 0),
266 });
267 }
268 }
269
270 by_language.sort_by(|a, b| {
271 b.code
272 .signed_delta()
273 .unsigned_abs()
274 .cmp(&a.code.signed_delta().unsigned_abs())
275 });
276
277 Ok(TrendReport {
278 from: SnapshotMeta {
279 timestamp: from_snap.timestamp,
280 label: from_snap.label,
281 git_commit: from_snap.git_commit,
282 file_path: from_path,
283 },
284 to: SnapshotMeta {
285 timestamp: to_snap.timestamp,
286 label: to_snap.label,
287 git_commit: to_snap.git_commit,
288 file_path: to_path,
289 },
290 delta,
291 by_language,
292 })
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::analyzer::stats::{FileStats, LineStats, Summary};
299 use std::time::Duration;
300 use tempfile::TempDir;
301
302 fn make_result(code: usize, files: usize) -> AnalysisResult {
303 let file_stats: Vec<FileStats> = (0..files)
304 .map(|i| FileStats {
305 path: PathBuf::from(format!("file_{i}.rs")),
306 language: "Rust".to_string(),
307 lines: LineStats {
308 total: code / files.max(1),
309 code: code / files.max(1),
310 comment: 0,
311 blank: 0,
312 },
313 size: 1000,
314 complexity: Default::default(),
315 })
316 .collect();
317 AnalysisResult {
318 summary: Summary::from_file_stats(&file_stats),
319 files: file_stats,
320 elapsed: Duration::from_millis(50),
321 scanned_files: files,
322 skipped_files: 0,
323 }
324 }
325
326 #[test]
327 fn test_save_and_list() {
328 let dir = TempDir::new().unwrap();
329 let result = make_result(100, 2);
330 let path = save_snapshot(dir.path(), result, Some("v1.0".into()), None, None).unwrap();
331 assert!(path.exists());
332 let metas = list_snapshots(dir.path()).unwrap();
333 assert_eq!(metas.len(), 1);
334 assert_eq!(metas[0].label.as_deref(), Some("v1.0"));
335 }
336
337 #[test]
338 fn test_gitignore_created() {
339 let dir = TempDir::new().unwrap();
340 save_snapshot(dir.path(), make_result(10, 1), None, None, None).unwrap();
341 assert!(dir.path().join(".codelens/.gitignore").exists());
342 }
343
344 #[test]
345 fn test_resolve_latest() {
346 let dir = TempDir::new().unwrap();
347 save_snapshot(dir.path(), make_result(10, 1), None, None, None).unwrap();
348 std::thread::sleep(std::time::Duration::from_millis(1100)); save_snapshot(
350 dir.path(),
351 make_result(20, 2),
352 Some("second".into()),
353 None,
354 None,
355 )
356 .unwrap();
357 let path = resolve_snapshot(dir.path(), "latest").unwrap();
358 let content = fs::read_to_string(&path).unwrap();
359 assert!(content.contains("\"scanned_files\": 2"));
360 }
361
362 #[test]
363 fn test_resolve_latest_offset() {
364 let dir = TempDir::new().unwrap();
365 save_snapshot(
366 dir.path(),
367 make_result(10, 1),
368 Some("first".into()),
369 None,
370 None,
371 )
372 .unwrap();
373 std::thread::sleep(std::time::Duration::from_millis(1100));
374 save_snapshot(
375 dir.path(),
376 make_result(20, 2),
377 Some("second".into()),
378 None,
379 None,
380 )
381 .unwrap();
382 let path = resolve_snapshot(dir.path(), "latest~1").unwrap();
383 let content = fs::read_to_string(&path).unwrap();
384 assert!(content.contains("\"scanned_files\": 1"));
385 }
386
387 #[test]
388 fn test_resolve_not_found() {
389 let dir = TempDir::new().unwrap();
390 let result = resolve_snapshot(dir.path(), "latest");
391 assert!(result.is_err());
392 }
393
394 #[test]
395 fn test_diff() {
396 let dir = TempDir::new().unwrap();
397 save_snapshot(
398 dir.path(),
399 make_result(100, 5),
400 Some("v1".into()),
401 None,
402 None,
403 )
404 .unwrap();
405 std::thread::sleep(std::time::Duration::from_millis(1100));
406 save_snapshot(
407 dir.path(),
408 make_result(150, 7),
409 Some("v2".into()),
410 None,
411 None,
412 )
413 .unwrap();
414 let report = diff(dir.path(), "latest~1", "latest").unwrap();
415 assert_eq!(report.from.label.as_deref(), Some("v1"));
416 assert_eq!(report.to.label.as_deref(), Some("v2"));
417 assert_eq!(report.delta.files.from, 5);
418 assert_eq!(report.delta.files.to, 7);
419 assert_eq!(report.delta.code.signed_delta(), 47);
421 }
422
423 #[test]
424 fn test_list_empty() {
425 let dir = TempDir::new().unwrap();
426 let metas = list_snapshots(dir.path()).unwrap();
427 assert!(metas.is_empty());
428 }
429
430 #[test]
431 fn test_lang_status_display() {
432 assert_eq!(LangStatus::Added.to_string(), "+");
433 assert_eq!(LangStatus::Removed.to_string(), "-");
434 assert_eq!(LangStatus::Changed.to_string(), "~");
435 }
436}