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