Skip to main content

batuta/
analyzer.rs

1use crate::types::*;
2use anyhow::{Context, Result};
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8#[cfg(feature = "native")]
9use tracing::{debug, info, warn};
10
11#[cfg(feature = "native")]
12use walkdir::WalkDir;
13
14// Stub macros for WASM build
15#[cfg(not(feature = "native"))]
16macro_rules! info {
17    ($($arg:tt)*) => {{}};
18}
19
20#[cfg(not(feature = "native"))]
21macro_rules! debug {
22    ($($arg:tt)*) => {{}};
23}
24
25#[cfg(not(feature = "native"))]
26macro_rules! warn {
27    ($($arg:tt)*) => {{}};
28}
29
30/// Analyze a project directory
31#[allow(clippy::cognitive_complexity)]
32pub fn analyze_project(
33    path: &Path,
34    include_tdg: bool,
35    include_languages: bool,
36    include_dependencies: bool,
37) -> Result<ProjectAnalysis> {
38    contract_pre_analyze!(path);
39    info!("Starting project analysis at {:?}", path);
40
41    let mut analysis = ProjectAnalysis::new(path.to_path_buf());
42
43    // Always detect languages for file/line counts (needed by TDG display)
44    if include_languages || include_tdg {
45        info!("Detecting languages...");
46        let stats = detect_languages(path)?;
47        analysis.languages = stats;
48
49        // Determine primary language (most lines of code)
50        if let Some(primary) = analysis.languages.first() {
51            analysis.primary_language = Some(primary.language.clone());
52        }
53
54        // Calculate total files and lines
55        analysis.total_files = analysis.languages.iter().map(|s| s.file_count).sum();
56        analysis.total_lines = analysis.languages.iter().map(|s| s.line_count).sum();
57    }
58
59    if include_dependencies {
60        info!("Analyzing dependencies...");
61        analysis.dependencies = detect_dependencies(path)?;
62    }
63
64    if include_tdg {
65        info!("Calculating TDG score...");
66        analysis.tdg_score = calculate_tdg_score(path);
67    }
68
69    Ok(analysis)
70}
71
72/// Detect programming languages in the project (stub when native disabled)
73#[cfg(not(feature = "native"))]
74fn detect_languages(_path: &Path) -> Result<Vec<LanguageStats>> {
75    Ok(Vec::new())
76}
77
78/// Detect programming languages in the project
79#[cfg(feature = "native")]
80fn detect_languages(path: &Path) -> Result<Vec<LanguageStats>> {
81    let mut language_stats: HashMap<Language, (usize, usize)> = HashMap::new();
82
83    for entry in
84        WalkDir::new(path).follow_links(false).into_iter().filter_entry(|e| !is_ignored(e.path()))
85    {
86        let entry = entry?;
87        if !entry.file_type().is_file() {
88            continue;
89        }
90
91        if let Some(lang) = detect_language_from_path(entry.path()) {
92            let line_count = count_lines(entry.path()).unwrap_or(0);
93            let stats = language_stats.entry(lang).or_insert((0, 0));
94            stats.0 += 1; // file count
95            stats.1 += line_count; // line count
96        }
97    }
98
99    // Convert to LanguageStats and sort by line count (descending)
100    let total_lines: usize = language_stats.values().map(|(_, lines)| lines).sum();
101    let mut stats: Vec<LanguageStats> = language_stats
102        .into_iter()
103        .map(|(language, (file_count, line_count))| LanguageStats {
104            language,
105            file_count,
106            line_count,
107            percentage: if total_lines > 0 {
108                (line_count as f64 / total_lines as f64) * 100.0
109            } else {
110                0.0
111            },
112        })
113        .collect();
114
115    stats.sort_by(|a, b| b.line_count.cmp(&a.line_count));
116
117    Ok(stats)
118}
119
120/// Detect language from file extension
121fn detect_language_from_path(path: &Path) -> Option<Language> {
122    let extension = path.extension()?.to_str()?;
123
124    match extension {
125        "py" | "pyx" | "pyi" => Some(Language::Python),
126        "c" | "h" => Some(Language::C),
127        "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => Some(Language::Cpp),
128        "rs" => Some(Language::Rust),
129        "sh" | "bash" | "zsh" => Some(Language::Shell),
130        "js" | "jsx" | "mjs" => Some(Language::JavaScript),
131        "ts" | "tsx" => Some(Language::TypeScript),
132        "go" => Some(Language::Go),
133        "java" => Some(Language::Java),
134        _ => None,
135    }
136}
137
138/// Count non-empty lines in a file
139fn count_lines(path: &Path) -> Result<usize> {
140    let content = fs::read_to_string(path).context("Failed to read file")?;
141    Ok(content.lines().filter(|line| !line.trim().is_empty()).count())
142}
143
144/// Check if path should be ignored (common directories to skip)
145fn is_ignored(path: &Path) -> bool {
146    let ignore_names = [
147        ".git",
148        ".svn",
149        ".hg",
150        "node_modules",
151        "target",
152        "build",
153        "dist",
154        "__pycache__",
155        ".pytest_cache",
156        ".venv",
157        "venv",
158        ".idea",
159        ".vscode",
160    ];
161
162    path.components().any(|c| {
163        if let Some(name) = c.as_os_str().to_str() {
164            ignore_names.contains(&name)
165        } else {
166            false
167        }
168    })
169}
170
171/// Detect dependency managers and files
172fn detect_dependencies(path: &Path) -> Result<Vec<DependencyInfo>> {
173    let mut deps = Vec::new();
174
175    // Python
176    if let Some(info) = check_dependency_file(path, "requirements.txt", DependencyManager::Pip) {
177        deps.push(info);
178    }
179    if let Some(info) = check_dependency_file(path, "Pipfile", DependencyManager::Pipenv) {
180        deps.push(info);
181    }
182    if let Some(info) = check_poetry_deps(path) {
183        deps.push(info);
184    }
185    if let Some(info) = check_dependency_file(path, "environment.yml", DependencyManager::Conda) {
186        deps.push(info);
187    }
188
189    // Rust
190    if let Some(info) = check_dependency_file(path, "Cargo.toml", DependencyManager::Cargo) {
191        deps.push(info);
192    }
193
194    // JavaScript/Node
195    if let Some(info) = check_dependency_file(path, "package.json", DependencyManager::Npm) {
196        deps.push(info);
197    }
198    if let Some(info) = check_dependency_file(path, "yarn.lock", DependencyManager::Yarn) {
199        deps.push(info);
200    }
201
202    // Go
203    if let Some(info) = check_dependency_file(path, "go.mod", DependencyManager::GoMod) {
204        deps.push(info);
205    }
206
207    // Java
208    if let Some(info) = check_dependency_file(path, "pom.xml", DependencyManager::Maven) {
209        deps.push(info);
210    }
211    if let Some(info) = check_dependency_file(path, "build.gradle", DependencyManager::Gradle) {
212        deps.push(info);
213    }
214
215    // C/C++
216    if let Some(info) = check_dependency_file(path, "Makefile", DependencyManager::Make) {
217        deps.push(info);
218    }
219
220    Ok(deps)
221}
222
223fn check_dependency_file(
224    base_path: &Path,
225    filename: &str,
226    manager: DependencyManager,
227) -> Option<DependencyInfo> {
228    let file_path = base_path.join(filename);
229    if file_path.exists() {
230        debug!("Found dependency file: {:?}", file_path);
231        let count = count_dependencies(&file_path, &manager);
232        Some(DependencyInfo { manager, file_path, count })
233    } else {
234        None
235    }
236}
237
238fn check_poetry_deps(base_path: &Path) -> Option<DependencyInfo> {
239    let file_path = base_path.join("pyproject.toml");
240    if file_path.exists() {
241        if let Ok(content) = fs::read_to_string(&file_path) {
242            if content.contains("[tool.poetry]") {
243                debug!("Found Poetry project: {:?}", file_path);
244                let count = count_dependencies(&file_path, &DependencyManager::Poetry);
245                return Some(DependencyInfo {
246                    manager: DependencyManager::Poetry,
247                    file_path,
248                    count,
249                });
250            }
251        }
252    }
253    None
254}
255
256fn count_pip_dependencies(content: &str) -> usize {
257    content
258        .lines()
259        .filter(|line| {
260            let trimmed = line.trim();
261            !trimmed.is_empty() && !trimmed.starts_with('#')
262        })
263        .count()
264}
265
266fn count_cargo_dependencies(content: &str) -> usize {
267    let mut in_deps = false;
268    let mut count = 0;
269
270    for line in content.lines() {
271        let trimmed = line.trim();
272        if trimmed == "[dependencies]" || trimmed == "[dev-dependencies]" {
273            in_deps = true;
274        } else if trimmed.starts_with('[') {
275            in_deps = false;
276        } else if in_deps && !trimmed.is_empty() && !trimmed.starts_with('#') {
277            count += 1;
278        }
279    }
280    count
281}
282
283fn count_npm_dependencies(content: &str) -> Option<usize> {
284    let json: serde_json::Value = serde_json::from_str(content).ok()?;
285    let deps = json.get("dependencies").and_then(|d| d.as_object());
286    let dev_deps = json.get("devDependencies").and_then(|d| d.as_object());
287    Some(deps.map(|d| d.len()).unwrap_or(0) + dev_deps.map(|d| d.len()).unwrap_or(0))
288}
289
290fn count_dependencies(path: &Path, manager: &DependencyManager) -> Option<usize> {
291    let content = fs::read_to_string(path).ok()?;
292
293    match manager {
294        DependencyManager::Pip => Some(count_pip_dependencies(&content)),
295        DependencyManager::Cargo => Some(count_cargo_dependencies(&content)),
296        DependencyManager::Npm => count_npm_dependencies(&content),
297        _ => None,
298    }
299}
300
301/// Calculate TDG score using PMAT, with fallback for when PMAT is unavailable
302fn calculate_tdg_score(path: &Path) -> Option<f64> {
303    debug!("Running PMAT TDG analysis...");
304
305    // Try to use PMAT first
306    if let Some(score) = calculate_tdg_with_pmat(path) {
307        return Some(score);
308    }
309
310    // Fallback: basic heuristic TDG score when PMAT unavailable
311    debug!("PMAT unavailable, using fallback TDG calculation");
312    calculate_tdg_fallback(path)
313}
314
315/// Parse PMAT output for score line: "Overall Score: 100.0/100 (A+)"
316fn parse_pmat_score_line(line: &str) -> Option<f64> {
317    if !line.contains("Overall Score:") {
318        return None;
319    }
320    let score_str = line.split(':').nth(1)?;
321    let score = score_str.trim().split('/').next()?;
322    score.trim().parse::<f64>().ok()
323}
324
325/// Calculate TDG using external PMAT tool
326fn calculate_tdg_with_pmat(path: &Path) -> Option<f64> {
327    let output = Command::new("pmat").arg("tdg").arg(path).output().ok()?;
328
329    if !output.status.success() {
330        warn!("PMAT TDG command failed");
331        return None;
332    }
333
334    let stdout = String::from_utf8_lossy(&output.stdout);
335    stdout.lines().find_map(parse_pmat_score_line)
336}
337
338/// Fallback TDG calculation (stub when native disabled)
339#[cfg(not(feature = "native"))]
340fn calculate_tdg_fallback(_path: &Path) -> Option<f64> {
341    None
342}
343
344/// Fallback TDG calculation using basic heuristics
345/// This provides a reasonable estimate when PMAT is not available
346#[cfg(feature = "native")]
347fn calculate_tdg_fallback(path: &Path) -> Option<f64> {
348    let mut score: f64 = 100.0;
349
350    // Check for tests directory or #[test] presence
351    let has_tests = path.join("tests").exists()
352        || WalkDir::new(path)
353            .max_depth(3)
354            .into_iter()
355            .filter_map(|e| e.ok())
356            .filter(|e| e.path().extension().is_some_and(|ext| ext == "rs"))
357            .take(10)
358            .any(|e| {
359                std::fs::read_to_string(e.path())
360                    .ok()
361                    .is_some_and(|content| content.contains("#[test]"))
362            });
363
364    if !has_tests {
365        score -= 10.0; // Deduct for no tests
366    }
367
368    // Check for README
369    if !path.join("README.md").exists() && !path.join("README").exists() {
370        score -= 5.0;
371    }
372
373    // Check for CI configuration
374    let has_ci = path.join(".github/workflows").exists()
375        || path.join(".gitlab-ci.yml").exists()
376        || path.join(".circleci").exists();
377    if !has_ci {
378        score -= 5.0;
379    }
380
381    // Check for license
382    let has_license = path.join("LICENSE").exists()
383        || path.join("LICENSE.md").exists()
384        || path.join("LICENSE.txt").exists();
385    if !has_license {
386        score -= 5.0;
387    }
388
389    Some(score.max(0.0))
390}
391
392#[cfg(test)]
393#[path = "analyzer_tests.rs"]
394mod tests;