Skip to main content

codelens_core/insight/
trend.rs

1//! Trend tracking with snapshots.
2
3use 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    // Date prefix match
199    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)); // ensure different second in timestamp
349        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        // 100/5=20 per file * 5 = 100 total; 150/7=21 per file * 7 = 147 total; delta = 47
420        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}