Skip to main content

driven/context/
scanner.rs

1//! Project structure scanner
2
3use crate::{DrivenError, Result};
4use std::collections::HashSet;
5use std::path::Path;
6use walkdir::WalkDir;
7
8/// Result of scanning a project
9#[derive(Debug, Clone, Default)]
10pub struct ScanResult {
11    /// Detected programming languages
12    pub languages: Vec<String>,
13    /// Detected frameworks
14    pub frameworks: Vec<String>,
15    /// Project type
16    pub project_type: Option<String>,
17    /// Key directories
18    pub key_directories: Vec<String>,
19    /// Configuration files
20    pub config_files: Vec<String>,
21    /// Has test files
22    pub has_tests: bool,
23    /// Has CI configuration
24    pub has_ci: bool,
25    /// Has documentation
26    pub has_docs: bool,
27    /// File count by extension
28    pub file_counts: std::collections::HashMap<String, usize>,
29}
30
31/// Scans project structure
32#[derive(Debug, Default)]
33pub struct ProjectScanner {
34    /// Maximum depth to scan
35    max_depth: usize,
36    /// Patterns to ignore
37    ignore_patterns: Vec<String>,
38}
39
40impl ProjectScanner {
41    /// Create a new scanner
42    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    /// Set maximum scan depth
58    pub fn with_max_depth(mut self, depth: usize) -> Self {
59        self.max_depth = depth;
60        self
61    }
62
63    /// Scan a project directory
64    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                // Detect language from extension
86                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                // Detect configuration files
95                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                        // Detect frameworks from config files
100                        if let Some(framework) = self.config_to_framework(file_name) {
101                            frameworks.insert(framework);
102                        }
103                    }
104
105                    // Detect tests
106                    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                    // Detect key directories
113                    if self.is_key_directory(dir_name) {
114                        result.key_directories.push(dir_name.to_string());
115                    }
116
117                    // Detect CI
118                    if dir_name == ".github" || dir_name == ".gitlab-ci" || dir_name == ".circleci"
119                    {
120                        result.has_ci = true;
121                    }
122
123                    // Detect docs
124                    if dir_name == "docs" || dir_name == "documentation" {
125                        result.has_docs = true;
126                    }
127
128                    // Detect tests directory
129                    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        // Detect project type
140        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        // Check for specific patterns
224        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}