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