driven/context/
scanner.rs1use crate::{DrivenError, Result};
4use std::collections::HashSet;
5use std::path::Path;
6use walkdir::WalkDir;
7
8#[derive(Debug, Clone, Default)]
10pub struct ScanResult {
11 pub languages: Vec<String>,
13 pub frameworks: Vec<String>,
15 pub project_type: Option<String>,
17 pub key_directories: Vec<String>,
19 pub config_files: Vec<String>,
21 pub has_tests: bool,
23 pub has_ci: bool,
25 pub has_docs: bool,
27 pub file_counts: std::collections::HashMap<String, usize>,
29}
30
31#[derive(Debug, Default)]
33pub struct ProjectScanner {
34 max_depth: usize,
36 ignore_patterns: Vec<String>,
38}
39
40impl ProjectScanner {
41 pub fn new() -> Self {
43 Self {
44 max_depth: 10,
45 ignore_patterns: vec![
46 "node_modules".to_string(),
47 "target".to_string(),
48 ".git".to_string(),
49 "dist".to_string(),
50 "build".to_string(),
51 ".next".to_string(),
52 "vendor".to_string(),
53 ],
54 }
55 }
56
57 pub fn with_max_depth(mut self, depth: usize) -> Self {
59 self.max_depth = depth;
60 self
61 }
62
63 pub fn scan(&self, path: &Path) -> Result<ScanResult> {
65 if !path.exists() {
66 return Err(DrivenError::Io(std::io::Error::new(
67 std::io::ErrorKind::NotFound,
68 format!("Path does not exist: {}", path.display()),
69 )));
70 }
71
72 let mut result = ScanResult::default();
73 let mut languages = HashSet::new();
74 let mut frameworks = HashSet::new();
75
76 for entry in WalkDir::new(path)
77 .max_depth(self.max_depth)
78 .into_iter()
79 .filter_entry(|e| !self.should_ignore(e))
80 .filter_map(|e| e.ok())
81 {
82 let entry_path = entry.path();
83
84 if entry_path.is_file() {
85 if let Some(ext) = entry_path.extension().and_then(|e| e.to_str()) {
87 *result.file_counts.entry(ext.to_string()).or_insert(0) += 1;
88
89 if let Some(lang) = self.extension_to_language(ext) {
90 languages.insert(lang);
91 }
92 }
93
94 if let Some(file_name) = entry_path.file_name().and_then(|n| n.to_str()) {
96 if self.is_config_file(file_name) {
97 result.config_files.push(file_name.to_string());
98
99 if let Some(framework) = self.config_to_framework(file_name) {
101 frameworks.insert(framework);
102 }
103 }
104
105 if file_name.contains("test") || file_name.contains("spec") {
107 result.has_tests = true;
108 }
109 }
110 } else if entry_path.is_dir() {
111 if let Some(dir_name) = entry_path.file_name().and_then(|n| n.to_str()) {
112 if self.is_key_directory(dir_name) {
114 result.key_directories.push(dir_name.to_string());
115 }
116
117 if dir_name == ".github" || dir_name == ".gitlab-ci" || dir_name == ".circleci"
119 {
120 result.has_ci = true;
121 }
122
123 if dir_name == "docs" || dir_name == "documentation" {
125 result.has_docs = true;
126 }
127
128 if dir_name == "tests" || dir_name == "test" || dir_name == "__tests__" {
130 result.has_tests = true;
131 }
132 }
133 }
134 }
135
136 result.languages = languages.into_iter().collect();
137 result.frameworks = frameworks.into_iter().collect();
138
139 result.project_type = self.detect_project_type(&result);
141
142 Ok(result)
143 }
144
145 fn should_ignore(&self, entry: &walkdir::DirEntry) -> bool {
146 entry
147 .file_name()
148 .to_str()
149 .map(|s| self.ignore_patterns.iter().any(|p| s == p))
150 .unwrap_or(false)
151 }
152
153 fn extension_to_language(&self, ext: &str) -> Option<String> {
154 match ext.to_lowercase().as_str() {
155 "rs" => Some("Rust".to_string()),
156 "ts" | "tsx" => Some("TypeScript".to_string()),
157 "js" | "jsx" | "mjs" | "cjs" => Some("JavaScript".to_string()),
158 "py" => Some("Python".to_string()),
159 "go" => Some("Go".to_string()),
160 "java" => Some("Java".to_string()),
161 "kt" | "kts" => Some("Kotlin".to_string()),
162 "swift" => Some("Swift".to_string()),
163 "c" | "h" => Some("C".to_string()),
164 "cpp" | "cc" | "hpp" => Some("C++".to_string()),
165 "cs" => Some("C#".to_string()),
166 "rb" => Some("Ruby".to_string()),
167 "php" => Some("PHP".to_string()),
168 "md" => Some("Markdown".to_string()),
169 _ => None,
170 }
171 }
172
173 fn is_config_file(&self, name: &str) -> bool {
174 matches!(
175 name,
176 "Cargo.toml"
177 | "package.json"
178 | "tsconfig.json"
179 | "pyproject.toml"
180 | "go.mod"
181 | "pom.xml"
182 | "build.gradle"
183 | "Gemfile"
184 | "composer.json"
185 | ".eslintrc.json"
186 | ".prettierrc"
187 | "tailwind.config.js"
188 | "next.config.js"
189 | "vite.config.ts"
190 | "webpack.config.js"
191 )
192 }
193
194 fn config_to_framework(&self, name: &str) -> Option<String> {
195 match name {
196 "next.config.js" | "next.config.mjs" => Some("Next.js".to_string()),
197 "vite.config.ts" | "vite.config.js" => Some("Vite".to_string()),
198 "tailwind.config.js" => Some("Tailwind CSS".to_string()),
199 "angular.json" => Some("Angular".to_string()),
200 "vue.config.js" => Some("Vue".to_string()),
201 _ => None,
202 }
203 }
204
205 fn is_key_directory(&self, name: &str) -> bool {
206 matches!(
207 name,
208 "src"
209 | "lib"
210 | "crates"
211 | "packages"
212 | "apps"
213 | "components"
214 | "api"
215 | "server"
216 | "client"
217 | "frontend"
218 | "backend"
219 )
220 }
221
222 fn detect_project_type(&self, result: &ScanResult) -> Option<String> {
223 if result.config_files.contains(&"Cargo.toml".to_string()) {
225 if result.key_directories.contains(&"crates".to_string()) {
226 return Some("Rust Workspace".to_string());
227 }
228 if result.key_directories.contains(&"src".to_string()) {
229 return Some("Rust Project".to_string());
230 }
231 }
232
233 if result.config_files.contains(&"package.json".to_string()) {
234 if result.key_directories.contains(&"packages".to_string())
235 || result.key_directories.contains(&"apps".to_string())
236 {
237 return Some("Node.js Monorepo".to_string());
238 }
239 if result.frameworks.iter().any(|f| f == "Next.js") {
240 return Some("Next.js App".to_string());
241 }
242 return Some("Node.js Project".to_string());
243 }
244
245 if result.config_files.contains(&"pyproject.toml".to_string()) {
246 return Some("Python Project".to_string());
247 }
248
249 if result.config_files.contains(&"go.mod".to_string()) {
250 return Some("Go Project".to_string());
251 }
252
253 None
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_scanner_new() {
263 let scanner = ProjectScanner::new();
264 assert_eq!(scanner.max_depth, 10);
265 }
266
267 #[test]
268 fn test_extension_to_language() {
269 let scanner = ProjectScanner::new();
270 assert_eq!(
271 scanner.extension_to_language("rs"),
272 Some("Rust".to_string())
273 );
274 assert_eq!(
275 scanner.extension_to_language("ts"),
276 Some("TypeScript".to_string())
277 );
278 assert_eq!(scanner.extension_to_language("unknown"), None);
279 }
280}