codesynapse_core/
incremental.rs1use crate::cache::FileCache;
2use crate::error::Result;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7const MANIFEST_FILE: &str = "manifest.json";
8
9#[derive(Debug, Serialize, Deserialize, Default)]
10pub struct Manifest {
11 pub files: HashMap<String, String>,
12}
13
14impl Manifest {
15 pub fn load(output_dir: &Path) -> Option<Self> {
16 let path = output_dir.join(MANIFEST_FILE);
17 let text = std::fs::read_to_string(&path).ok()?;
18 serde_json::from_str(&text).ok()
19 }
20
21 pub fn save(&self, output_dir: &Path) -> Result<()> {
22 std::fs::create_dir_all(output_dir)?;
23 let path = output_dir.join(MANIFEST_FILE);
24 let json = serde_json::to_string_pretty(self)?;
25 std::fs::write(path, json)?;
26 Ok(())
27 }
28}
29
30pub struct IncrementalScan {
31 pub is_incremental: bool,
32 pub changed_files: Vec<PathBuf>,
33 pub unchanged_files: Vec<PathBuf>,
34}
35
36pub struct IncrementalBuilder {
37 output_dir: PathBuf,
38}
39
40impl IncrementalBuilder {
41 pub fn new(output_dir: PathBuf) -> Self {
42 Self { output_dir }
43 }
44
45 pub fn scan(&self, files: &[PathBuf]) -> IncrementalScan {
46 let manifest = Manifest::load(&self.output_dir);
47 let is_incremental = manifest.is_some();
48 let prev = manifest.unwrap_or_default();
49
50 let mut changed = Vec::new();
51 let mut unchanged = Vec::new();
52
53 for path in files {
54 let key = path.to_string_lossy().to_string();
55 let current_hash = std::fs::read(path)
56 .map(|b| FileCache::compute_hash(&b))
57 .unwrap_or_default();
58 match prev.files.get(&key) {
59 Some(h) if h == ¤t_hash => unchanged.push(path.clone()),
60 _ => changed.push(path.clone()),
61 }
62 }
63
64 IncrementalScan {
65 is_incremental,
66 changed_files: changed,
67 unchanged_files: unchanged,
68 }
69 }
70
71 pub fn write_manifest(&self, files: &[PathBuf]) -> Result<()> {
72 let mut manifest = Manifest::default();
73 for path in files {
74 let key = path.to_string_lossy().to_string();
75 let hash = std::fs::read(path)
76 .map(|b| FileCache::compute_hash(&b))
77 .unwrap_or_default();
78 manifest.files.insert(key, hash);
79 }
80 manifest.save(&self.output_dir)
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87 use tempfile::tempdir;
88
89 fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
90 let p = dir.join(name);
91 std::fs::write(&p, content).unwrap();
92 p
93 }
94
95 #[test]
96 fn incremental_manifest_written() {
97 let tmp = tempdir().unwrap();
98 let out = tmp.path().join("out");
99 std::fs::create_dir_all(&out).unwrap();
100
101 let src = tmp.path().join("src");
102 std::fs::create_dir_all(&src).unwrap();
103 let f = write_file(&src, "foo.py", "def foo(): pass");
104
105 let builder = IncrementalBuilder::new(out.clone());
106 assert!(!out.join("manifest.json").exists());
107
108 builder.write_manifest(&[f]).unwrap();
109
110 assert!(out.join("manifest.json").exists());
111 let m = Manifest::load(&out).unwrap();
112 assert_eq!(m.files.len(), 1);
113 }
114
115 #[test]
116 fn incremental_mode_detected_via_manifest() {
117 let tmp = tempdir().unwrap();
118 let out = tmp.path().join("out");
119 std::fs::create_dir_all(&out).unwrap();
120
121 let src = tmp.path().join("src");
122 std::fs::create_dir_all(&src).unwrap();
123 let f = write_file(&src, "intro.py", "x = 1");
124
125 let builder = IncrementalBuilder::new(out.clone());
126 builder.write_manifest(std::slice::from_ref(&f)).unwrap();
127
128 let scan = builder.scan(&[f]);
129 assert!(scan.is_incremental);
130 assert_eq!(scan.unchanged_files.len(), 1);
131 assert!(scan.changed_files.is_empty());
132 }
133
134 #[test]
135 fn incremental_no_manifest_full_scan() {
136 let tmp = tempdir().unwrap();
137 let out = tmp.path().join("out");
138 std::fs::create_dir_all(&out).unwrap();
139
140 let src = tmp.path().join("src");
141 std::fs::create_dir_all(&src).unwrap();
142 let f = write_file(&src, "api.py", "def api(): pass");
143
144 let builder = IncrementalBuilder::new(out.clone());
145 let scan = builder.scan(&[f]);
146
147 assert!(!scan.is_incremental);
148 assert_eq!(scan.changed_files.len(), 1);
149 assert!(scan.unchanged_files.is_empty());
150 }
151}