Skip to main content

code_repo_wiki/ingest/
scanner.rs

1use std::path::{Path, PathBuf};
2use anyhow::{Result, bail};
3use ignore::WalkBuilder;
4
5use crate::ingest::parser::SUPPORTED_EXTENSIONS;
6
7/// 默认扫描文件数上限(超过即报错,避免海量文件拖垮整条管线)
8const MAX_FILES: usize = 100_000;
9
10const BINARY_EXTENSIONS: &[&str] = &[
11    ".exe", ".dll", ".bin", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
12    ".pdf", ".ttf", ".woff", ".woff2", ".eot", ".zip", ".tar", ".gz", ".7z",
13    ".rar", ".mp3", ".mp4", ".avi", ".mov", ".wasm", ".o", ".obj", ".lib",
14    ".a", ".so", ".dylib", ".pyc", ".class",
15];
16
17fn is_binary_extension(path: &Path) -> bool {
18    path.extension()
19        .and_then(|ext| ext.to_str())
20        .map(|ext| {
21            let ext = format!(".{}", ext.to_lowercase());
22            BINARY_EXTENSIONS.contains(&ext.as_str())
23        })
24        .unwrap_or(false)
25}
26
27/// 内置噪音目录:第三方依赖与构建产物(全量扫描时的边界——不依赖 .gitignore,
28/// 许多项目未规范编写。命中目录整棵跳过,防止 node_modules/target 等爆炸)。
29/// pub:文件监听(watch.rs)共用同一清单,扫与听保持一致边界。
30pub const NOISE_DIRS: &[&str] = &[
31    "node_modules", ".venv", "venv", "vendor", "Pods", "Library",
32    "target", "dist", "build", "out", ".next", ".nuxt", ".output",
33    "coverage", ".cache", "__pycache__", ".pytest_cache", ".mypy_cache",
34    "bower_components", ".git", "obj", "bin",
35];
36
37fn is_noise_dir(name: &str) -> bool {
38    NOISE_DIRS.contains(&name)
39}
40
41/// 文件系统遍历器:全量遍历 + 内置过滤(v30+:无 include/exclude 配置,
42/// 扫描范围由「可解析语言 + 噪音目录 + 二进制 + 文件数上限」四个内置边界决定——
43/// 不同项目目录结构不同,路径模式无法通用,语言才是 code-repo-wiki 的能力边界)
44pub struct Scanner {
45    root: PathBuf,
46}
47
48impl Scanner {
49    /// 创建 Scanner,根为项目根目录
50    pub fn new(root: &Path) -> Self {
51        Self { root: root.to_path_buf() }
52    }
53
54    /// 遍历目录树,返回可解析的源文件列表
55    ///
56    /// - 使用 `ignore::WalkBuilder` 处理 .gitignore 与隐藏目录
57    /// - 跳过内置噪音目录(依赖/构建产物,见 [NOISE_DIRS])
58    /// - 只保留 [SUPPORTED_EXTENSIONS] 内的源文件
59    /// - 超过 MAX_FILES 个文件时返回错误
60    pub fn scan(&self) -> Result<Vec<PathBuf>> {
61        self.scan_with_limit(MAX_FILES)
62    }
63
64    /// 带文件数上限的扫描(上限可配置,供测试覆盖超限分支)
65    fn scan_with_limit(&self, limit: usize) -> Result<Vec<PathBuf>> {
66        let walker = WalkBuilder::new(&self.root)
67            .standard_filters(true)
68            .filter_entry(|entry| {
69                // 目录名命中噪音清单时整棵剪枝(标准过滤器已跳 .git/隐藏目录,
70                // 这里补充显式清单:node_modules/target/dist 等常见依赖与构建产物)
71                !entry
72                    .file_type()
73                    .is_some_and(|ft| ft.is_dir() && is_noise_dir(&entry.file_name().to_string_lossy()))
74            })
75            .build();
76        let mut files = Vec::new();
77
78        for result in walker {
79            let entry = match result {
80                Ok(e) => e,
81                Err(err) => {
82                    tracing::warn!("遍历目录出错: {}", err);
83                    continue;
84                }
85            };
86            if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
87                continue;
88            }
89
90            let path = entry.path();
91            if is_binary_extension(path) {
92                continue;
93            }
94            // 只保留可解析语言的源文件(v30+:按语言而非路径模式过滤——
95            // 各项目目录结构不同,但「支持的语言」是确定的通用边界)
96            let is_source = path
97                .extension()
98                .and_then(|ext| ext.to_str())
99                .map(|ext| {
100                    let ext = format!(".{}", ext.to_lowercase());
101                    SUPPORTED_EXTENSIONS.contains(&ext.as_str())
102                })
103                .unwrap_or(false);
104            if !is_source {
105                continue;
106            }
107
108            if files.len() >= limit {
109                bail!("源文件数超过上限 {limit}(噪音目录已自动跳过;若项目确需更多请精简或忽略多余内容)");
110            }
111            files.push(path.to_path_buf());
112        }
113
114        Ok(files)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::sync::atomic::{AtomicUsize, Ordering};
122
123    static DIR_SEQ: AtomicUsize = AtomicUsize::new(0);
124
125    fn scratch(_name: &str) -> PathBuf {
126        let dir = std::env::temp_dir().join(format!(
127            "code_repo_wiki_test_scanner_{}_{}",
128            std::process::id(),
129            DIR_SEQ.fetch_add(1, Ordering::SeqCst)
130        ));
131        let _ = std::fs::remove_dir_all(&dir);
132        std::fs::create_dir_all(&dir).unwrap();
133        dir
134    }
135
136    /// v30+:无任何配置时全量扫描,只收可解析语言的源文件
137    #[test]
138    fn test_scanner_default_scans_sources_only() {
139        let dir = scratch("default");
140        std::fs::create_dir_all(dir.join("src/sub")).unwrap();
141        std::fs::write(dir.join("src/a.rs"), "pub fn a() {}").unwrap();
142        std::fs::write(dir.join("src/sub/b.ts"), "export const b = 1;").unwrap();
143        // 非支持语言与二进制不进入结果
144        std::fs::write(dir.join("README.md"), "docs").unwrap();
145        std::fs::write(dir.join("pic.png"), b"\x89PNG").unwrap();
146        std::fs::write(dir.join("data.json"), "{}").unwrap();
147
148        let scanner = Scanner::new(&dir);
149        let files = scanner.scan().unwrap();
150        let names: Vec<String> = files.iter().map(|p| p.to_string_lossy().replace('\\', "/")).collect();
151        assert!(names.iter().any(|n| n.ends_with("src/a.rs")));
152        assert!(names.iter().any(|n| n.ends_with("src/sub/b.ts")));
153        assert_eq!(files.len(), 2, "非支持语言与二进制应被过滤: {names:?}");
154
155        let _ = std::fs::remove_dir_all(&dir);
156    }
157
158    /// 内置噪音目录整棵跳过(node_modules/target 等),不依赖 .gitignore
159    #[test]
160    fn test_scanner_skips_noise_dirs() {
161        let dir = scratch("noise");
162        std::fs::create_dir_all(dir.join("src")).unwrap();
163        std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
164        std::fs::create_dir_all(dir.join("target/debug")).unwrap();
165        std::fs::write(dir.join("src/main.rs"), "fn main() {}").unwrap();
166        std::fs::write(dir.join("node_modules/pkg/index.js"), "module.exports = 1;").unwrap();
167        std::fs::write(dir.join("target/debug/lib.rs"), "fn x() {}").unwrap();
168
169        let scanner = Scanner::new(&dir);
170        let files = scanner.scan().unwrap();
171        let names: Vec<String> = files.iter().map(|p| p.to_string_lossy().replace('\\', "/")).collect();
172        assert_eq!(names.len(), 1, "噪音目录应被跳过: {names:?}");
173        assert!(names[0].ends_with("src/main.rs"), "唯一产物应为主源码: {names:?}");
174
175        let _ = std::fs::remove_dir_all(&dir);
176    }
177
178    /// 文件数超限时应返回错误
179    #[test]
180    fn test_scan_with_limit_exceeds() {
181        let dir = scratch("limit");
182        for i in 0..4 {
183            std::fs::write(dir.join(format!("f{i}.rs")), "fn x() {}").unwrap();
184        }
185
186        let scanner = Scanner::new(&dir);
187        assert!(scanner.scan_with_limit(3).is_err());
188
189        // 上限之内正常返回
190        let files = scanner.scan_with_limit(10).unwrap();
191        assert_eq!(files.len(), 4);
192
193        let _ = std::fs::remove_dir_all(&dir);
194    }
195}