1pub mod scanner;
4pub mod parser;
5
6use anyhow::Result;
7use crate::project::ProjectRoot;
8use parser::{FileInsight, ParserRegistry};
9
10pub struct ScanOutput {
14 pub insights: Vec<FileInsight>,
15 pub files_failed: usize,
17}
18
19pub fn scan_and_parse_at(root: &ProjectRoot) -> Result<ScanOutput> {
25 scan_and_parse_cached_at(root, &None, &std::collections::HashSet::new())
26}
27
28pub fn scan_and_parse_cached_at(
38 root: &ProjectRoot,
39 cache_path: &Option<std::path::PathBuf>,
40 changed_files: &std::collections::HashSet<std::path::PathBuf>,
41) -> Result<ScanOutput> {
42 let scanner = scanner::Scanner::new(root.path());
43 let files = scanner
48 .scan()?
49 .into_iter()
50 .map(|f| f.strip_prefix(root.path()).map(|p| p.to_path_buf()).unwrap_or(f))
51 .collect::<Vec<_>>();
52
53 let mut cache = load_insights_cache(cache_path);
54 let registry = ParserRegistry::new();
55
56 let mut insights = Vec::new();
57 let mut reused = 0usize;
58 let mut files_failed = 0usize;
59 for file in &files {
60 let processor = match registry.get_for_file(file) {
61 Some(p) => p,
62 None => continue,
63 };
64 let abs = if file.is_absolute() { file.clone() } else { root.path().join(file) };
67 let source = match std::fs::read_to_string(&abs) {
68 Ok(s) => s,
69 Err(e) => {
70 tracing::warn!("跳过非 UTF-8 文件 {}: {}", abs.display(), e);
71 files_failed += 1;
72 continue;
73 }
74 };
75
76 let fingerprint = fingerprint_of(&source);
78 let key = file.to_string_lossy().to_string();
79 let cached = cache.get(&key);
80 let use_cache = !changed_files.contains(file)
81 && cached.is_some_and(|c| c.fingerprint == fingerprint);
82 if use_cache
84 && let Some(c) = cached
85 {
86 insights.push(c.insight.clone());
87 reused += 1;
88 continue;
89 }
90
91 match processor.parse(&source, file) {
92 Ok(insight) => {
93 let cached = CachedInsight {
94 path: key,
95 fingerprint,
96 insight: insight.clone(),
97 };
98 cache.insert(cached.path.clone(), cached);
99 insights.push(insight);
100 }
101 Err(e) => {
102 tracing::error!("解析失败 {}: {}", file.display(), e);
103 files_failed += 1;
104 }
105 }
106 }
107
108 let valid_keys: std::collections::HashSet<&std::path::Path> =
113 files.iter().map(|f| f.as_path()).collect();
114 cache.retain(|path, _| valid_keys.contains(std::path::Path::new(path)));
115
116 if let Some(path) = cache_path
118 && let Err(e) = save_insights_cache(path, &cache)
119 {
120 tracing::warn!("解析缓存写入失败: {}", e);
121 }
122
123 tracing::info!(
124 "扫描完成: 共 {} 个文件, 成功解析 {} 个(缓存复用 {} 个, 失败 {} 个)",
125 files.len(),
126 insights.len(),
127 reused,
128 files_failed
129 );
130 Ok(ScanOutput { insights, files_failed })
131}
132
133#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
135pub struct CachedInsight {
136 pub path: String,
137 pub fingerprint: String,
138 pub insight: FileInsight,
139}
140
141fn load_insights_cache(cache_path: &Option<std::path::PathBuf>) -> std::collections::HashMap<String, CachedInsight> {
143 let Some(path) = cache_path else {
144 return std::collections::HashMap::new();
145 };
146 if !path.exists() {
147 return std::collections::HashMap::new();
148 }
149 match std::fs::read_to_string(path) {
150 Ok(content) => match serde_json::from_str::<Vec<CachedInsight>>(&content) {
151 Ok(list) => list.into_iter().map(|c| (c.path.clone(), c)).collect(),
152 Err(e) => {
153 tracing::warn!("解析缓存损坏(将全量重建): {}: {}", path.display(), e);
154 std::collections::HashMap::new()
155 }
156 },
157 Err(e) => {
158 tracing::warn!("解析缓存读取失败(将全量重建): {}: {}", path.display(), e);
159 std::collections::HashMap::new()
160 }
161 }
162}
163
164fn save_insights_cache(cache_path: &std::path::Path, cache: &std::collections::HashMap<String, CachedInsight>) -> Result<()> {
166 if let Some(parent) = cache_path.parent() {
167 std::fs::create_dir_all(parent)?;
168 }
169 let mut list: Vec<&CachedInsight> = cache.values().collect();
170 list.sort_by(|a, b| a.path.cmp(&b.path));
171 crate::fs::write_file_atomic(cache_path, &serde_json::to_string_pretty(&list)?)
174}
175
176fn fingerprint_of(source: &str) -> String {
179 use sha2::{Digest, Sha256};
180 let mut hasher = Sha256::new();
181 hasher.update(source.as_bytes());
182 hex::encode(hasher.finalize())
183}
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use std::path::PathBuf;
188
189 fn temp_project(tag: &str) -> ProjectRoot {
191 let dir = std::env::temp_dir().join(format!("code_repo_wiki_cache_{}_{}", tag, std::process::id()));
192 let _ = std::fs::remove_dir_all(&dir);
193 std::fs::create_dir_all(dir.join("src")).unwrap();
194 std::fs::write(dir.join("src").join("a.rs"), "pub fn alpha() {}\n").unwrap();
195 std::fs::write(dir.join("src").join("b.rs"), "pub fn beta() {}\n").unwrap();
196 ProjectRoot::new(dir)
197 }
198
199 fn cache_path(root: &ProjectRoot) -> std::path::PathBuf {
200 root.path().join(".state").join("insights_cache.json")
201 }
202
203 #[test]
205 fn test_cached_scan_writes_cache_file() {
206 let root = temp_project("write");
207 let cp = Some(cache_path(&root));
208 let insights = scan_and_parse_cached_at(&root, &cp, &std::collections::HashSet::new()).unwrap().insights;
209 assert_eq!(insights.len(), 2, "两个 .rs 文件都应解析");
210
211 let content = std::fs::read_to_string(cache_path(&root)).unwrap();
212 let list: Vec<CachedInsight> = serde_json::from_str(&content).unwrap();
213 assert_eq!(list.len(), 2, "缓存应含两个条目");
214 let _ = std::fs::remove_dir_all(root.path());
215 }
216
217 #[test]
220 fn test_cached_scan_reparses_on_fingerprint_change() {
221 let root = temp_project("invalidate");
222 let cp = Some(cache_path(&root));
223 let first = scan_and_parse_cached_at(&root, &cp, &std::collections::HashSet::new()).unwrap().insights;
224 let alpha_source = first.iter().find(|i| i.path.ends_with("a.rs")).unwrap().source.clone();
225 assert!(alpha_source.contains("alpha"), "初始内容含 alpha");
226
227 std::fs::write(root.path().join("src").join("a.rs"), "pub fn alpha_v2() {}\n").unwrap();
229 let second = scan_and_parse_cached_at(&root, &cp, &std::collections::HashSet::new()).unwrap().insights;
230 let alpha2_source = second.iter().find(|i| i.path.ends_with("a.rs")).unwrap().source.clone();
231 assert!(
232 alpha2_source.contains("alpha_v2") && !alpha2_source.contains("alpha()"),
233 "指纹变化后应重解析出新内容, 实际: {alpha2_source}"
234 );
235 let beta_source = second.iter().find(|i| i.path.ends_with("b.rs")).unwrap().source.clone();
237 assert!(beta_source.contains("beta"), "未变更文件应正常复用");
238
239 let _ = std::fs::remove_dir_all(root.path());
240 }
241
242 #[test]
244 fn test_cached_scan_rebuilds_on_corrupt_cache() {
245 let root = temp_project("corrupt");
246 let cp = Some(cache_path(&root));
247 scan_and_parse_cached_at(&root, &cp, &std::collections::HashSet::new()).unwrap();
249 std::fs::write(cache_path(&root), "{ 垃圾内容").unwrap();
250
251 let insights = scan_and_parse_cached_at(&root, &cp, &std::collections::HashSet::new()).unwrap().insights;
252 assert_eq!(insights.len(), 2, "损坏缓存应触发全量重建而非失败");
253 assert!(insights.iter().any(|i| i.source.contains("alpha")));
254
255 let _ = std::fs::remove_dir_all(root.path());
256 }
257
258 #[test]
260 fn test_cached_scan_without_cache_path() {
261 let root = temp_project("nocache");
262 let insights = scan_and_parse_cached_at(&root, &None, &std::collections::HashSet::new()).unwrap().insights;
263 assert_eq!(insights.len(), 2);
264 assert!(!root.path().join(".state").exists(), "无缓存路径时不应创建 .state 目录");
265 let _ = std::fs::remove_dir_all(root.path());
266 }
267
268 #[test]
271 fn test_cached_scan_forced_reparse_by_changed_set() {
272 let root = temp_project("forced");
273 let cp = Some(cache_path(&root));
274 let _ = scan_and_parse_cached_at(&root, &cp, &std::collections::HashSet::new()).unwrap();
275
276 let mut changed = std::collections::HashSet::new();
277 changed.insert(PathBuf::from("src/a.rs"));
278 let insights = scan_and_parse_cached_at(&root, &cp, &changed).unwrap().insights;
279 assert_eq!(insights.len(), 2, "强制重解析不改变结果集合");
280 let _ = std::fs::remove_dir_all(root.path());
281 }
282
283 #[test]
286 fn test_scan_counts_failed_files() {
287 let dir = std::env::temp_dir().join(format!("code_repo_wiki_failed_cnt_{}", std::process::id()));
288 let _ = std::fs::remove_dir_all(&dir);
289 std::fs::create_dir_all(dir.join("src")).unwrap();
290 std::fs::write(dir.join("src").join("ok.rs"), "pub fn ok() {}\n").unwrap();
292 std::fs::write(dir.join("src").join("bad.rs"), [0xFFu8, 0xFE, 0x00]).unwrap();
294 std::fs::write(dir.join("src").join("notes.txt"), "text").unwrap();
296
297 let root = ProjectRoot::new(dir.clone());
298 let out = scan_and_parse_at(&root).unwrap();
299
300 assert_eq!(out.insights.len(), 1, "只有正常文件被解析");
301 assert_eq!(out.files_failed, 1, "非 UTF-8 文件应计数为失败");
302
303 let _ = std::fs::remove_dir_all(&dir);
304 }
305}