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#[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#[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 if include_languages || include_tdg {
45 info!("Detecting languages...");
46 let stats = detect_languages(path)?;
47 analysis.languages = stats;
48
49 if let Some(primary) = analysis.languages.first() {
51 analysis.primary_language = Some(primary.language.clone());
52 }
53
54 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#[cfg(not(feature = "native"))]
74fn detect_languages(_path: &Path) -> Result<Vec<LanguageStats>> {
75 Ok(Vec::new())
76}
77
78#[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; stats.1 += line_count; }
97 }
98
99 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
120fn 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
138fn 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
144fn 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
171fn detect_dependencies(path: &Path) -> Result<Vec<DependencyInfo>> {
173 let mut deps = Vec::new();
174
175 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 if let Some(info) = check_dependency_file(path, "Cargo.toml", DependencyManager::Cargo) {
191 deps.push(info);
192 }
193
194 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 if let Some(info) = check_dependency_file(path, "go.mod", DependencyManager::GoMod) {
204 deps.push(info);
205 }
206
207 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 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
301fn calculate_tdg_score(path: &Path) -> Option<f64> {
303 debug!("Running PMAT TDG analysis...");
304
305 if let Some(score) = calculate_tdg_with_pmat(path) {
307 return Some(score);
308 }
309
310 debug!("PMAT unavailable, using fallback TDG calculation");
312 calculate_tdg_fallback(path)
313}
314
315fn 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
325fn 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#[cfg(not(feature = "native"))]
340fn calculate_tdg_fallback(_path: &Path) -> Option<f64> {
341 None
342}
343
344#[cfg(feature = "native")]
347fn calculate_tdg_fallback(path: &Path) -> Option<f64> {
348 let mut score: f64 = 100.0;
349
350 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; }
367
368 if !path.join("README.md").exists() && !path.join("README").exists() {
370 score -= 5.0;
371 }
372
373 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 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;