syncable_cli/analyzer/tool_management/
detector.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::time::{Duration, SystemTime};
5use serde::{Deserialize, Serialize};
6use log::{debug, info};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ToolStatus {
10    pub available: bool,
11    pub path: Option<PathBuf>,
12    pub version: Option<String>,
13    pub installation_source: InstallationSource,
14    pub last_checked: SystemTime,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub enum InstallationSource {
19    SystemPath,
20    UserLocal,
21    CargoHome,
22    GoHome,
23    PackageManager(String),
24    Manual,
25    NotFound,
26}
27
28#[derive(Debug, Clone)]
29pub struct ToolDetectionConfig {
30    pub cache_ttl: Duration,
31    pub enable_cache: bool,
32    pub search_user_paths: bool,
33    pub search_system_paths: bool,
34}
35
36impl Default for ToolDetectionConfig {
37    fn default() -> Self {
38        Self {
39            cache_ttl: Duration::from_secs(300), // 5 minutes
40            enable_cache: true,
41            search_user_paths: true,
42            search_system_paths: true,
43        }
44    }
45}
46
47pub struct ToolDetector {
48    cache: HashMap<String, ToolStatus>,
49    config: ToolDetectionConfig,
50}
51
52impl ToolDetector {
53    pub fn new() -> Self {
54        Self::with_config(ToolDetectionConfig::default())
55    }
56    
57    pub fn with_config(config: ToolDetectionConfig) -> Self {
58        Self {
59            cache: HashMap::new(),
60            config,
61        }
62    }
63    
64    /// Detect tool availability with caching
65    pub fn detect_tool(&mut self, tool_name: &str) -> ToolStatus {
66        if !self.config.enable_cache {
67            return self.detect_tool_real_time(tool_name);
68        }
69        
70        // Check cache first
71        if let Some(cached) = self.cache.get(tool_name) {
72            if cached.last_checked.elapsed().unwrap_or(Duration::MAX) < self.config.cache_ttl {
73                debug!("Using cached status for {}: available={}", tool_name, cached.available);
74                return cached.clone();
75            }
76        }
77        
78        // Perform real detection
79        let status = self.detect_tool_real_time(tool_name);
80        debug!("Real-time detection for {}: available={}, path={:?}", 
81               tool_name, status.available, status.path);
82        self.cache.insert(tool_name.to_string(), status.clone());
83        status
84    }
85    
86    /// Detect all vulnerability scanning tools for given languages
87    pub fn detect_all_vulnerability_tools(&mut self, languages: &[crate::analyzer::dependency_parser::Language]) -> HashMap<String, ToolStatus> {
88        let mut results = HashMap::new();
89        
90        for language in languages {
91            let tool_names = self.get_tools_for_language(language);
92            
93            for tool_name in tool_names {
94                if !results.contains_key(tool_name) {
95                    results.insert(tool_name.to_string(), self.detect_tool(tool_name));
96                }
97            }
98        }
99        
100        results
101    }
102    
103    fn get_tools_for_language(&self, language: &crate::analyzer::dependency_parser::Language) -> Vec<&'static str> {
104        match language {
105            crate::analyzer::dependency_parser::Language::Rust => vec!["cargo-audit"],
106            crate::analyzer::dependency_parser::Language::JavaScript | 
107            crate::analyzer::dependency_parser::Language::TypeScript => vec!["bun", "npm", "yarn", "pnpm"],
108            crate::analyzer::dependency_parser::Language::Python => vec!["pip-audit"],
109            crate::analyzer::dependency_parser::Language::Go => vec!["govulncheck"],
110            crate::analyzer::dependency_parser::Language::Java | 
111            crate::analyzer::dependency_parser::Language::Kotlin => vec!["grype"],
112            _ => vec![],
113        }
114    }
115    
116    /// Clear the cache to force fresh detection
117    pub fn clear_cache(&mut self) {
118        self.cache.clear();
119    }
120    
121    /// Detect bun specifically with multiple alternatives
122    pub fn detect_bun(&mut self) -> ToolStatus {
123        self.detect_tool_with_alternatives("bun", &["bun", "bunx"])
124    }
125    
126    /// Detect all JavaScript package managers
127    pub fn detect_js_package_managers(&mut self) -> HashMap<String, ToolStatus> {
128        let mut managers = HashMap::new();
129        managers.insert("bun".to_string(), self.detect_bun());
130        managers.insert("npm".to_string(), self.detect_tool("npm"));
131        managers.insert("yarn".to_string(), self.detect_tool("yarn"));
132        managers.insert("pnpm".to_string(), self.detect_tool("pnpm"));
133        managers
134    }
135    
136    /// Detect tool with alternative command names
137    pub fn detect_tool_with_alternatives(&mut self, primary_name: &str, alternatives: &[&str]) -> ToolStatus {
138        // Check cache first for primary name
139        if self.config.enable_cache {
140            if let Some(cached) = self.cache.get(primary_name) {
141                if cached.last_checked.elapsed().unwrap_or(Duration::MAX) < self.config.cache_ttl {
142                    debug!("Using cached status for {}: available={}", primary_name, cached.available);
143                    return cached.clone();
144                }
145            }
146        }
147        
148        // Try each alternative
149        for alternative in alternatives {
150            debug!("Trying to detect tool: {}", alternative);
151            let status = self.detect_tool_real_time(alternative);
152            if status.available {
153                debug!("Found {} via alternative: {}", primary_name, alternative);
154                if self.config.enable_cache {
155                    self.cache.insert(primary_name.to_string(), status.clone());
156                }
157                return status;
158            }
159        }
160        
161        // Not found
162        let not_found = ToolStatus {
163            available: false,
164            path: None,
165            version: None,
166            installation_source: InstallationSource::NotFound,
167            last_checked: SystemTime::now(),
168        };
169        
170        if self.config.enable_cache {
171            self.cache.insert(primary_name.to_string(), not_found.clone());
172        }
173        not_found
174    }
175    
176    /// Perform real-time tool detection without caching
177    fn detect_tool_real_time(&self, tool_name: &str) -> ToolStatus {
178        debug!("Starting real-time detection for {}", tool_name);
179        
180        // Try direct command first (in PATH)
181        if let Some((path, version)) = self.try_command_in_path(tool_name) {
182            info!("Found {} in PATH at {:?} with version {:?}", tool_name, path, version);
183            return ToolStatus {
184                available: true,
185                path: Some(path),
186                version,
187                installation_source: InstallationSource::SystemPath,
188                last_checked: SystemTime::now(),
189            };
190        }
191        
192        // Try alternative paths if enabled
193        if self.config.search_user_paths || self.config.search_system_paths {
194            let search_paths = self.get_tool_search_paths(tool_name);
195            debug!("Searching alternative paths for {}: {:?}", tool_name, search_paths);
196            
197            for search_path in search_paths {
198                let tool_path = search_path.join(tool_name);
199                debug!("Checking path: {:?}", tool_path);
200                
201                if let Some(version) = self.verify_tool_at_path(&tool_path, tool_name) {
202                    let source = self.determine_installation_source(&search_path);
203                    info!("Found {} at {:?} with version {:?} (source: {:?})", 
204                          tool_name, tool_path, version, source);
205                    return ToolStatus {
206                        available: true,
207                        path: Some(tool_path),
208                        version: Some(version),
209                        installation_source: source,
210                        last_checked: SystemTime::now(),
211                    };
212                }
213                
214                // Also try with .exe extension on Windows
215                #[cfg(windows)]
216                {
217                    let tool_path_exe = search_path.join(format!("{}.exe", tool_name));
218                    if let Some(version) = self.verify_tool_at_path(&tool_path_exe, tool_name) {
219                        let source = self.determine_installation_source(&search_path);
220                        info!("Found {} at {:?} with version {:?} (source: {:?})", 
221                              tool_name, tool_path_exe, version, source);
222                        return ToolStatus {
223                            available: true,
224                            path: Some(tool_path_exe),
225                            version,
226                            installation_source: source,
227                            last_checked: SystemTime::now(),
228                        };
229                    }
230                }
231            }
232        }
233        
234        // Tool not found
235        debug!("Tool {} not found in any location", tool_name);
236        ToolStatus {
237            available: false,
238            path: None,
239            version: None,
240            installation_source: InstallationSource::NotFound,
241            last_checked: SystemTime::now(),
242        }
243    }
244    
245    /// Get search paths for a specific tool
246    fn get_tool_search_paths(&self, tool_name: &str) -> Vec<PathBuf> {
247        let mut paths = Vec::new();
248        
249        if !self.config.search_user_paths && !self.config.search_system_paths {
250            return paths;
251        }
252        
253        // User-specific paths
254        if self.config.search_user_paths {
255            if let Ok(home) = std::env::var("HOME") {
256                let home_path = PathBuf::from(home);
257                
258                // Common user install locations
259                paths.push(home_path.join(".local").join("bin"));
260                paths.push(home_path.join(".cargo").join("bin"));
261                paths.push(home_path.join("go").join("bin"));
262                
263                // Tool-specific locations
264                self.add_tool_specific_paths(tool_name, &home_path, &mut paths);
265            }
266            
267            // Windows-specific paths
268            #[cfg(windows)]
269            {
270                if let Ok(userprofile) = std::env::var("USERPROFILE") {
271                    let userprofile_path = PathBuf::from(userprofile);
272                    paths.push(userprofile_path.join(".local").join("bin"));
273                    paths.push(userprofile_path.join("scoop").join("shims"));
274                    paths.push(userprofile_path.join(".cargo").join("bin"));
275                    paths.push(userprofile_path.join("go").join("bin"));
276                }
277                if let Ok(appdata) = std::env::var("APPDATA") {
278                    paths.push(PathBuf::from(appdata).join("syncable-cli").join("bin"));
279                    paths.push(PathBuf::from(appdata).join("npm"));
280                }
281                // Program Files
282                paths.push(PathBuf::from("C:\\Program Files"));
283                paths.push(PathBuf::from("C:\\Program Files (x86)"));
284            }
285        }
286        
287        // System-wide paths
288        if self.config.search_system_paths {
289            paths.push(PathBuf::from("/usr/local/bin"));
290            paths.push(PathBuf::from("/usr/bin"));
291            paths.push(PathBuf::from("/bin"));
292        }
293        
294        // Remove duplicates and non-existent paths
295        paths.sort();
296        paths.dedup();
297        paths.into_iter().filter(|p| p.exists()).collect()
298    }
299    
300    fn add_tool_specific_paths(&self, tool_name: &str, home_path: &PathBuf, paths: &mut Vec<PathBuf>) {
301        match tool_name {
302            "cargo-audit" => {
303                paths.push(home_path.join(".cargo").join("bin"));
304            }
305            "govulncheck" => {
306                paths.push(home_path.join("go").join("bin"));
307                if let Ok(gopath) = std::env::var("GOPATH") {
308                    paths.push(PathBuf::from(gopath).join("bin"));
309                }
310                if let Ok(goroot) = std::env::var("GOROOT") {
311                    paths.push(PathBuf::from(goroot).join("bin"));
312                }
313            }
314            "grype" => {
315                paths.push(home_path.join(".local").join("bin"));
316                paths.push(PathBuf::from("/opt/homebrew/bin"));
317                paths.push(PathBuf::from("/usr/local/bin"));
318            }
319            "pip-audit" => {
320                paths.push(home_path.join(".local").join("bin"));
321                if let Ok(output) = Command::new("python3")
322                    .args(&["-m", "site", "--user-base"])
323                    .output() {
324                    if let Ok(user_base) = String::from_utf8(output.stdout) {
325                        paths.push(PathBuf::from(user_base.trim()).join("bin"));
326                    }
327                }
328                if let Ok(output) = Command::new("python")
329                    .args(&["-m", "site", "--user-base"])
330                    .output() {
331                    if let Ok(user_base) = String::from_utf8(output.stdout) {
332                        paths.push(PathBuf::from(user_base.trim()).join("bin"));
333                    }
334                }
335            }
336            "bun" | "bunx" => {
337                paths.push(home_path.join(".bun").join("bin"));
338                paths.push(home_path.join(".npm-global").join("bin"));
339                paths.push(PathBuf::from("/opt/homebrew/bin"));
340                paths.push(PathBuf::from("/usr/local/bin"));
341                paths.push(home_path.join(".local").join("bin"));
342            }
343            "yarn" => {
344                paths.push(home_path.join(".yarn").join("bin"));
345                paths.push(home_path.join(".npm-global").join("bin"));
346            }
347            "pnpm" => {
348                paths.push(home_path.join(".local").join("share").join("pnpm"));
349                paths.push(home_path.join(".npm-global").join("bin"));
350            }
351            "npm" => {
352                if let Ok(node_path) = std::env::var("NODE_PATH") {
353                    paths.push(PathBuf::from(node_path).join(".bin"));
354                }
355                paths.push(home_path.join(".npm-global").join("bin"));
356                paths.push(PathBuf::from("/usr/local/lib/node_modules/.bin"));
357            }
358            _ => {}
359        }
360    }
361    
362    /// Try to run a command in PATH
363    fn try_command_in_path(&self, tool_name: &str) -> Option<(PathBuf, Option<String>)> {
364        let version_args = self.get_version_args(tool_name);
365        debug!("Trying {} with args: {:?}", tool_name, version_args);
366        
367        let output = Command::new(tool_name)
368            .args(&version_args)
369            .output()
370            .ok()?;
371            
372        if output.status.success() {
373            let version = self.parse_version_output(&output.stdout, tool_name);
374            let path = self.find_tool_path(tool_name).unwrap_or_else(|| {
375                PathBuf::from(tool_name)
376            });
377            return Some((path, version));
378        }
379        
380        // For some tools, stderr might contain version info even on non-zero exit
381        if !output.stderr.is_empty() {
382            if let Some(version) = self.parse_version_output(&output.stderr, tool_name) {
383                let path = self.find_tool_path(tool_name).unwrap_or_else(|| {
384                    PathBuf::from(tool_name)
385                });
386                return Some((path, Some(version)));
387            }
388        }
389        
390        None
391    }
392    
393    /// Verify tool installation at a specific path
394    fn verify_tool_at_path(&self, tool_path: &Path, tool_name: &str) -> Option<String> {
395        if !tool_path.exists() {
396            return None;
397        }
398        
399        let version_args = self.get_version_args(tool_name);
400        debug!("Verifying {} at {:?} with args: {:?}", tool_name, tool_path, version_args);
401        
402        let output = Command::new(tool_path)
403            .args(&version_args)
404            .output()
405            .ok()?;
406            
407        if output.status.success() {
408            self.parse_version_output(&output.stdout, tool_name)
409        } else if !output.stderr.is_empty() {
410            self.parse_version_output(&output.stderr, tool_name)
411        } else {
412            None
413        }
414    }
415    
416    /// Get appropriate version check arguments for each tool
417    fn get_version_args(&self, tool_name: &str) -> Vec<&str> {
418        match tool_name {
419            "cargo-audit" => vec!["audit", "--version"],
420            "npm" => vec!["--version"],
421            "pip-audit" => vec!["--version"],
422            "govulncheck" => vec!["-version"],
423            "grype" => vec!["version"],
424            "dependency-check" => vec!["--version"],
425            "bun" => vec!["--version"],
426            "bunx" => vec!["--version"],
427            "yarn" => vec!["--version"],
428            "pnpm" => vec!["--version"],
429            _ => vec!["--version"],
430        }
431    }
432    
433    /// Parse version information from command output
434    fn parse_version_output(&self, output: &[u8], tool_name: &str) -> Option<String> {
435        let output_str = String::from_utf8_lossy(output);
436        debug!("Parsing version output for {}: {}", tool_name, output_str.trim());
437        
438        match tool_name {
439            "cargo-audit" => {
440                for line in output_str.lines() {
441                    if line.contains("cargo-audit") {
442                        if let Some(version) = line.split_whitespace().nth(1) {
443                            return Some(version.to_string());
444                        }
445                    }
446                }
447            }
448            "grype" => {
449                for line in output_str.lines() {
450                    if line.trim_start().starts_with("grype") {
451                        if let Some(version) = line.split_whitespace().nth(1) {
452                            return Some(version.to_string());
453                        }
454                    }
455                    if line.contains("\"version\"") {
456                        if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
457                            if let Some(version) = json.get("version").and_then(|v| v.as_str()) {
458                                return Some(version.to_string());
459                            }
460                        }
461                    }
462                }
463            }
464            "govulncheck" => {
465                for line in output_str.lines() {
466                    if let Some(at_pos) = line.find('@') {
467                        let version_part = &line[at_pos + 1..];
468                        if let Some(version) = version_part.split_whitespace().next() {
469                            return Some(version.trim_start_matches('v').to_string());
470                        }
471                    }
472                    if line.contains("govulncheck") {
473                        if let Some(version) = line.split_whitespace().nth(1) {
474                            return Some(version.trim_start_matches('v').to_string());
475                        }
476                    }
477                }
478            }
479            "npm" | "yarn" | "pnpm" => {
480                if let Some(first_line) = output_str.lines().next() {
481                    let version = first_line.trim();
482                    if !version.is_empty() {
483                        return Some(version.to_string());
484                    }
485                }
486            }
487            "bun" | "bunx" => {
488                for line in output_str.lines() {
489                    let line = line.trim();
490                    if line.starts_with("bun ") {
491                        if let Some(version) = line.split_whitespace().nth(1) {
492                            return Some(version.to_string());
493                        }
494                    }
495                    if let Some(version) = extract_version_generic(line) {
496                        return Some(version);
497                    }
498                }
499            }
500            "pip-audit" => {
501                for line in output_str.lines() {
502                    if line.contains("pip-audit") {
503                        if let Some(version) = line.split_whitespace().nth(1) {
504                            return Some(version.to_string());
505                        }
506                    }
507                }
508                if let Some(version) = extract_version_generic(&output_str) {
509                    return Some(version);
510                }
511            }
512            _ => {
513                if let Some(version) = extract_version_generic(&output_str) {
514                    return Some(version);
515                }
516            }
517        }
518        
519        None
520    }
521    
522    /// Determine installation source based on path
523    fn determine_installation_source(&self, path: &Path) -> InstallationSource {
524        let path_str = path.to_string_lossy().to_lowercase();
525        
526        if path_str.contains(".cargo") {
527            InstallationSource::CargoHome
528        } else if path_str.contains("go/bin") || path_str.contains("gopath") {
529            InstallationSource::GoHome
530        } else if path_str.contains(".local") {
531            InstallationSource::UserLocal
532        } else if path_str.contains("homebrew") || path_str.contains("brew") {
533            InstallationSource::PackageManager("brew".to_string())
534        } else if path_str.contains("scoop") {
535            InstallationSource::PackageManager("scoop".to_string())
536        } else if path_str.contains("apt") || path_str.contains("/usr/bin") {
537            InstallationSource::PackageManager("apt".to_string())
538        } else if path_str.contains("/usr/local") || path_str.contains("/usr/bin") || path_str.contains("/bin") {
539            InstallationSource::SystemPath
540        } else {
541            InstallationSource::Manual
542        }
543    }
544    
545    /// Find the actual path of a tool using system commands
546    fn find_tool_path(&self, tool_name: &str) -> Option<PathBuf> {
547        #[cfg(unix)]
548        {
549            if let Ok(output) = Command::new("which").arg(tool_name).output() {
550                if output.status.success() {
551                    let output_str = String::from_utf8_lossy(&output.stdout);
552                    let path_str = output_str.trim();
553                    if !path_str.is_empty() {
554                        return Some(PathBuf::from(path_str));
555                    }
556                }
557            }
558        }
559        
560        #[cfg(windows)]
561        {
562            if let Ok(output) = Command::new("where").arg(tool_name).output() {
563                if output.status.success() {
564                    let output_str = String::from_utf8_lossy(&output.stdout);
565                    let path_str = output_str.trim();
566                    if let Some(first_path) = path_str.lines().next() {
567                        if !first_path.is_empty() {
568                            return Some(PathBuf::from(first_path));
569                        }
570                    }
571                }
572            }
573        }
574        
575        None
576    }
577}
578
579impl Default for ToolDetector {
580    fn default() -> Self {
581        Self::new()
582    }
583}
584
585/// Extract version using common patterns
586fn extract_version_generic(text: &str) -> Option<String> {
587    use regex::Regex;
588    
589    let patterns = vec![
590        r"\b(\d+\.\d+\.\d+(?:[+-][a-zA-Z0-9-.]+)?)\b",
591        r"\bv?(\d+\.\d+\.\d+)\b",
592        r"\b(\d+\.\d+)\b",
593    ];
594    
595    for pattern in patterns {
596        if let Ok(re) = Regex::new(pattern) {
597            if let Some(captures) = re.captures(text) {
598                if let Some(version) = captures.get(1) {
599                    let version_str = version.as_str();
600                    if !version_str.starts_with("127.") && !version_str.starts_with("192.") {
601                        return Some(version_str.to_string());
602                    }
603                }
604            }
605        }
606    }
607    
608    None
609}