Skip to main content

code_repo_wiki/ingest/
mod.rs

1/// 扫描与解析层(单进程契约:insights_cache.json 无文件锁,
2/// 同一输出目录并发运行不被支持,见 README 限制项)
3pub mod scanner;
4pub mod parser;
5
6use anyhow::Result;
7use crate::project::ProjectRoot;
8use parser::{FileInsight, ParserRegistry};
9
10/// 扫描结果:成功解析的文件 + 解析失败文件数(B5:失败可观测,
11/// 此前失败仅在日志中出现,AnalysisStats 无计数——解析失败的文件
12/// 不会出现在 insights 中,下游(覆盖率/统计)会误以为全部成功)
13pub struct ScanOutput {
14    pub insights: Vec<FileInsight>,
15    /// 扫描范围内解析失败的文件数(非 UTF-8 读取失败 / tree-sitter 解析错误)
16    pub files_failed: usize,
17}
18
19/// 在指定项目根下执行扫描和解析(全量解析:委托缓存版,传入空变更集)
20///
21/// 扫描根与路径相对化基准都取自 root,不再依赖进程 cwd——
22/// 测试可在临时目录构造 ProjectRoot 验证扫描行为,watch 常驻进程
23/// 的 cwd 漂移不再影响扫描范围。
24pub fn scan_and_parse_at(root: &ProjectRoot) -> Result<ScanOutput> {
25    scan_and_parse_cached_at(root, &None, &std::collections::HashSet::new())
26}
27
28/// 带解析缓存的扫描(真增量扫描的 parse 层增量)
29///
30/// `cache_path` 为 Some 时启用缓存:变更集内的文件强制重新解析,其余文件
31/// 按内容指纹复用缓存结果(指纹不匹配才重新 tree-sitter 解析)。
32/// 缓存缺失/损坏时 warn 并全量重建(缓存是加速产物,重建代价可接受,
33/// 属可观测性契约内的降级路径,不静默)。`cache_path` 为 None 时全量解析。
34///
35/// 变更集路径判定用 PathBuf 组件比较(Windows 下正/反斜杠视为同一路径,
36/// 与 git diff 的正斜杠相对路径形态一致)。
37pub 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    // 扫描产出绝对路径;转换为相对扫描根的路径——
44    // 模块名派生(graph/chunk 的 Normal 组件提取)、搜索索引、指纹记录
45    // 全部以相对路径为基准,杜绝绝对路径污染模块名(此前产出
46    // RustProjects_code-repo-wiki_src 这类含机器路径的模块名)。
47    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        // 读取用绝对路径(相对路径依赖 cwd,--root 与 cwd 分离时会读错文件);
65        // insight.path 保持相对路径(下游模块名派生/指纹记录的既定基准)
66        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        // 缓存命中判定:变更集内文件强制重解析;否则按内容指纹复用
77        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        // 缓存命中直接复用(指纹一致且不在变更集内)
83        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    // N15:缓存按本次扫描文件集裁剪——被删除/移出 include 的源文件
109    // 残留缓存条目(路径+旧解析结果)随文件消失成为死数据,且其
110    // file_path 相对形态与本次扫描不一致(旧前缀目录),写回前剔除,
111    // 防止缓存无限膨胀与陈旧条目误命中(watch 长期运行的场景)
112    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    // 写回缓存(辅助产物:失败仅告警,下次扫描降级为空缓存全量重建)
117    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/// 解析缓存条目:路径(相对项目根,与 insight.path 同形态)+ 内容指纹 + 解析结果
134#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
135pub struct CachedInsight {
136    pub path: String,
137    pub fingerprint: String,
138    pub insight: FileInsight,
139}
140
141/// 读取解析缓存(路径为 None 返回空缓存;文件缺失/损坏返回空缓存并告警)
142fn 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
164/// 写回解析缓存(按路径排序保证确定性;版本演进时旧格式自然读失败 → 全量重建)
165fn 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    // 原子写(fs::write_file_atomic):缓存损坏的后果只是全量重建
172    // (warn 路径),但半截文件会增加损坏概率,原子写消除此来源
173    crate::fs::write_file_atomic(cache_path, &serde_json::to_string_pretty(&list)?)
174}
175
176/// 文件内容 SHA256 指纹(与 GenerationState::compute_file_fingerprint 同款算法,
177/// 此处直接对已读入内容计算,避免二次读盘)
178fn 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    /// 构造临时项目根:src/a.rs + src/b.rs
190    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    /// 首次扫描写缓存:缓存文件存在且为合法 JSON(可反序列化为 CachedInsight 列表)
204    #[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    /// 指纹失效重解析:修改文件内容后再次扫描,返回的 insight.source 是新内容
218    ///(若缓存错误地命中旧指纹,source 会是旧内容——source 字段是复用的直接证据)
219    #[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        // 修改 a.rs 内容(新函数 alpha_v2)
228        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        // b.rs 未变化:缓存命中(复用路径——source 仍为初始内容)
236        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    /// 缓存损坏重建:缓存文件写入垃圾后扫描仍返回正确结果(warn + 全量重建)
243    #[test]
244    fn test_cached_scan_rebuilds_on_corrupt_cache() {
245        let root = temp_project("corrupt");
246        let cp = Some(cache_path(&root));
247        // 先正常扫一次(写缓存),再破坏缓存
248        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    /// 缓存路径为 None(全量模式)时不读写缓存文件
259    #[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    /// changed_files 强制重解析:变更集内的文件即使指纹一致也重解析
269    ///(watch 语义:事件路径是变更的直接证据,不受指纹缓存影响)
270    #[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    /// B5:解析失败文件计数——扫描范围内存在非 UTF-8 .rs 文件时,
284    /// files_failed 应准确计数(此前失败仅日志可见,统计无法反映)
285    #[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        // 正常文件
291        std::fs::write(dir.join("src").join("ok.rs"), "pub fn ok() {}\n").unwrap();
292        // 非法 UTF-8 文件(read_to_string 失败 → files_failed 计数)
293        std::fs::write(dir.join("src").join("bad.rs"), [0xFFu8, 0xFE, 0x00]).unwrap();
294        // 非 .rs 文件(无处理器,不计入失败——扫描范围外)
295        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}