pub mod scanner;
pub mod parser;
use anyhow::Result;
use crate::project::ProjectRoot;
use parser::{FileInsight, ParserRegistry};
pub struct ScanOutput {
pub insights: Vec<FileInsight>,
pub files_failed: usize,
}
pub fn scan_and_parse_at(root: &ProjectRoot) -> Result<ScanOutput> {
scan_and_parse_cached_at(root, &None, &std::collections::HashSet::new())
}
pub fn scan_and_parse_cached_at(
root: &ProjectRoot,
cache_path: &Option<std::path::PathBuf>,
changed_files: &std::collections::HashSet<std::path::PathBuf>,
) -> Result<ScanOutput> {
let scanner = scanner::Scanner::new(root.path());
let files = scanner
.scan()?
.into_iter()
.map(|f| f.strip_prefix(root.path()).map(|p| p.to_path_buf()).unwrap_or(f))
.collect::<Vec<_>>();
let mut cache = load_insights_cache(cache_path);
let registry = ParserRegistry::new();
let mut insights = Vec::new();
let mut reused = 0usize;
let mut files_failed = 0usize;
for file in &files {
let processor = match registry.get_for_file(file) {
Some(p) => p,
None => continue,
};
let abs = if file.is_absolute() { file.clone() } else { root.path().join(file) };
let source = match std::fs::read_to_string(&abs) {
Ok(s) => s,
Err(e) => {
tracing::warn!("跳过非 UTF-8 文件 {}: {}", abs.display(), e);
files_failed += 1;
continue;
}
};
let fingerprint = fingerprint_of(&source);
let key = file.to_string_lossy().to_string();
let cached = cache.get(&key);
let use_cache = !changed_files.contains(file)
&& cached.is_some_and(|c| c.fingerprint == fingerprint);
if use_cache
&& let Some(c) = cached
{
insights.push(c.insight.clone());
reused += 1;
continue;
}
match processor.parse(&source, file) {
Ok(insight) => {
let cached = CachedInsight {
path: key,
fingerprint,
insight: insight.clone(),
};
cache.insert(cached.path.clone(), cached);
insights.push(insight);
}
Err(e) => {
tracing::error!("解析失败 {}: {}", file.display(), e);
files_failed += 1;
}
}
}
let valid_keys: std::collections::HashSet<&std::path::Path> =
files.iter().map(|f| f.as_path()).collect();
cache.retain(|path, _| valid_keys.contains(std::path::Path::new(path)));
if let Some(path) = cache_path
&& let Err(e) = save_insights_cache(path, &cache)
{
tracing::warn!("解析缓存写入失败: {}", e);
}
tracing::info!(
"扫描完成: 共 {} 个文件, 成功解析 {} 个(缓存复用 {} 个, 失败 {} 个)",
files.len(),
insights.len(),
reused,
files_failed
);
Ok(ScanOutput { insights, files_failed })
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CachedInsight {
pub path: String,
pub fingerprint: String,
pub insight: FileInsight,
}
fn load_insights_cache(cache_path: &Option<std::path::PathBuf>) -> std::collections::HashMap<String, CachedInsight> {
let Some(path) = cache_path else {
return std::collections::HashMap::new();
};
if !path.exists() {
return std::collections::HashMap::new();
}
match std::fs::read_to_string(path) {
Ok(content) => match serde_json::from_str::<Vec<CachedInsight>>(&content) {
Ok(list) => list.into_iter().map(|c| (c.path.clone(), c)).collect(),
Err(e) => {
tracing::warn!("解析缓存损坏(将全量重建): {}: {}", path.display(), e);
std::collections::HashMap::new()
}
},
Err(e) => {
tracing::warn!("解析缓存读取失败(将全量重建): {}: {}", path.display(), e);
std::collections::HashMap::new()
}
}
}
fn save_insights_cache(cache_path: &std::path::Path, cache: &std::collections::HashMap<String, CachedInsight>) -> Result<()> {
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut list: Vec<&CachedInsight> = cache.values().collect();
list.sort_by(|a, b| a.path.cmp(&b.path));
crate::fs::write_file_atomic(cache_path, &serde_json::to_string_pretty(&list)?)
}
fn fingerprint_of(source: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(source.as_bytes());
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn temp_project(tag: &str) -> ProjectRoot {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_cache_{}_{}", tag, std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src").join("a.rs"), "pub fn alpha() {}\n").unwrap();
std::fs::write(dir.join("src").join("b.rs"), "pub fn beta() {}\n").unwrap();
ProjectRoot::new(dir)
}
fn cache_path(root: &ProjectRoot) -> std::path::PathBuf {
root.path().join(".state").join("insights_cache.json")
}
#[test]
fn test_cached_scan_writes_cache_file() {
let root = temp_project("write");
let cp = Some(cache_path(&root));
let insights = scan_and_parse_cached_at(&root, &cp, &std::collections::HashSet::new()).unwrap().insights;
assert_eq!(insights.len(), 2, "两个 .rs 文件都应解析");
let content = std::fs::read_to_string(cache_path(&root)).unwrap();
let list: Vec<CachedInsight> = serde_json::from_str(&content).unwrap();
assert_eq!(list.len(), 2, "缓存应含两个条目");
let _ = std::fs::remove_dir_all(root.path());
}
#[test]
fn test_cached_scan_reparses_on_fingerprint_change() {
let root = temp_project("invalidate");
let cp = Some(cache_path(&root));
let first = scan_and_parse_cached_at(&root, &cp, &std::collections::HashSet::new()).unwrap().insights;
let alpha_source = first.iter().find(|i| i.path.ends_with("a.rs")).unwrap().source.clone();
assert!(alpha_source.contains("alpha"), "初始内容含 alpha");
std::fs::write(root.path().join("src").join("a.rs"), "pub fn alpha_v2() {}\n").unwrap();
let second = scan_and_parse_cached_at(&root, &cp, &std::collections::HashSet::new()).unwrap().insights;
let alpha2_source = second.iter().find(|i| i.path.ends_with("a.rs")).unwrap().source.clone();
assert!(
alpha2_source.contains("alpha_v2") && !alpha2_source.contains("alpha()"),
"指纹变化后应重解析出新内容, 实际: {alpha2_source}"
);
let beta_source = second.iter().find(|i| i.path.ends_with("b.rs")).unwrap().source.clone();
assert!(beta_source.contains("beta"), "未变更文件应正常复用");
let _ = std::fs::remove_dir_all(root.path());
}
#[test]
fn test_cached_scan_rebuilds_on_corrupt_cache() {
let root = temp_project("corrupt");
let cp = Some(cache_path(&root));
scan_and_parse_cached_at(&root, &cp, &std::collections::HashSet::new()).unwrap();
std::fs::write(cache_path(&root), "{ 垃圾内容").unwrap();
let insights = scan_and_parse_cached_at(&root, &cp, &std::collections::HashSet::new()).unwrap().insights;
assert_eq!(insights.len(), 2, "损坏缓存应触发全量重建而非失败");
assert!(insights.iter().any(|i| i.source.contains("alpha")));
let _ = std::fs::remove_dir_all(root.path());
}
#[test]
fn test_cached_scan_without_cache_path() {
let root = temp_project("nocache");
let insights = scan_and_parse_cached_at(&root, &None, &std::collections::HashSet::new()).unwrap().insights;
assert_eq!(insights.len(), 2);
assert!(!root.path().join(".state").exists(), "无缓存路径时不应创建 .state 目录");
let _ = std::fs::remove_dir_all(root.path());
}
#[test]
fn test_cached_scan_forced_reparse_by_changed_set() {
let root = temp_project("forced");
let cp = Some(cache_path(&root));
let _ = scan_and_parse_cached_at(&root, &cp, &std::collections::HashSet::new()).unwrap();
let mut changed = std::collections::HashSet::new();
changed.insert(PathBuf::from("src/a.rs"));
let insights = scan_and_parse_cached_at(&root, &cp, &changed).unwrap().insights;
assert_eq!(insights.len(), 2, "强制重解析不改变结果集合");
let _ = std::fs::remove_dir_all(root.path());
}
#[test]
fn test_scan_counts_failed_files() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_failed_cnt_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src").join("ok.rs"), "pub fn ok() {}\n").unwrap();
std::fs::write(dir.join("src").join("bad.rs"), [0xFFu8, 0xFE, 0x00]).unwrap();
std::fs::write(dir.join("src").join("notes.txt"), "text").unwrap();
let root = ProjectRoot::new(dir.clone());
let out = scan_and_parse_at(&root).unwrap();
assert_eq!(out.insights.len(), 1, "只有正常文件被解析");
assert_eq!(out.files_failed, 1, "非 UTF-8 文件应计数为失败");
let _ = std::fs::remove_dir_all(&dir);
}
}