syncable_cli/analyzer/tool_management/
detector.rs

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