code_repo_wiki/ingest/
scanner.rs1use std::path::{Path, PathBuf};
2use anyhow::{Result, bail};
3use ignore::WalkBuilder;
4
5use crate::ingest::parser::SUPPORTED_EXTENSIONS;
6
7const 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
27pub 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
41pub struct Scanner {
45 root: PathBuf,
46}
47
48impl Scanner {
49 pub fn new(root: &Path) -> Self {
51 Self { root: root.to_path_buf() }
52 }
53
54 pub fn scan(&self) -> Result<Vec<PathBuf>> {
61 self.scan_with_limit(MAX_FILES)
62 }
63
64 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 !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 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 #[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 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 #[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 #[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 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}