Skip to main content

vtcode_core/tools/
command_resolver.rs

1//! Command resolution system
2//! Maps command names to their actual filesystem paths
3//! Used by policy evaluator to validate and log command locations
4
5use hashbrown::HashMap;
6use std::path::PathBuf;
7use tracing::{debug, warn};
8
9/// Result of attempting to resolve a command to a filesystem path
10#[derive(Debug, Clone)]
11pub struct CommandResolution {
12    /// The original command name (e.g., "cargo")
13    pub command: String,
14
15    /// Full path if found in system PATH (e.g., "/Users/user/.cargo/bin/cargo")
16    pub resolved_path: Option<PathBuf>,
17
18    /// Whether command was found in system PATH
19    pub found: bool,
20
21    /// Environment used for resolution
22    pub search_paths: Vec<PathBuf>,
23}
24
25/// Resolver with built-in caching to avoid repeated PATH searches
26pub struct CommandResolver {
27    /// Cache of already-resolved commands
28    cache: HashMap<String, CommandResolution>,
29
30    /// Cache hit count for metrics
31    cache_hits: usize,
32
33    /// Cache miss count for metrics
34    cache_misses: usize,
35}
36
37impl CommandResolver {
38    /// Create a new resolver with empty cache
39    pub fn new() -> Self {
40        Self {
41            cache: HashMap::new(),
42            cache_hits: 0,
43            cache_misses: 0,
44        }
45    }
46
47    /// Resolve a command to its filesystem path
48    ///
49    /// # Example
50    /// ```no_run
51    /// let mut resolver = CommandResolver::new();
52    /// let cargo = resolver.resolve("cargo");
53    /// assert_eq!(cargo.command, "cargo");
54    /// assert!(cargo.found);
55    /// assert_eq!(cargo.resolved_path, Some("/Users/user/.cargo/bin/cargo".into()));
56    /// ```
57    pub fn resolve(&mut self, cmd: &str) -> CommandResolution {
58        // Extract base command (first word only)
59        let base_cmd = cmd.split_whitespace().next().unwrap_or(cmd);
60
61        // Check cache first
62        if let Some(cached) = self.cache.get(base_cmd) {
63            self.cache_hits += 1;
64            debug!(
65                command = base_cmd,
66                cache_hits = self.cache_hits,
67                "Command resolution cache hit"
68            );
69            return cached.clone();
70        }
71
72        self.cache_misses += 1;
73
74        // Try to find command in system PATH
75        let resolution = if let Ok(path) = which::which(base_cmd) {
76            CommandResolution {
77                command: base_cmd.to_string(),
78                resolved_path: Some(path.clone()),
79                found: true,
80                search_paths: Self::get_search_paths(),
81            }
82        } else {
83            warn!(command = base_cmd, "Command not found in PATH");
84            CommandResolution {
85                command: base_cmd.to_string(),
86                resolved_path: None,
87                found: false,
88                search_paths: Self::get_search_paths(),
89            }
90        };
91
92        // Cache the result
93        self.cache.insert(base_cmd.to_string(), resolution.clone());
94        resolution
95    }
96
97    /// Get current PATH directories being searched
98    fn get_search_paths() -> Vec<PathBuf> {
99        std::env::var_os("PATH")
100            .map(|paths| std::env::split_paths(&paths).collect())
101            .unwrap_or_default()
102    }
103
104    /// Clear the resolution cache
105    pub fn clear_cache(&mut self) {
106        self.cache.clear();
107        debug!("Command resolver cache cleared");
108    }
109
110    /// Get cache statistics
111    pub fn cache_stats(&self) -> (usize, usize) {
112        (self.cache_hits, self.cache_misses)
113    }
114}
115
116impl Default for CommandResolver {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_resolve_common_command() {
128        let mut resolver = CommandResolver::new();
129        let ls = resolver.resolve("ls");
130        assert_eq!(ls.command, "ls");
131        // ls should be found on any Unix system
132        assert!(ls.found);
133    }
134
135    #[test]
136    fn test_cache_hits() {
137        let mut resolver = CommandResolver::new();
138        resolver.resolve("ls");
139        resolver.resolve("ls");
140        let (hits, misses) = resolver.cache_stats();
141        assert_eq!(hits, 1);
142        assert_eq!(misses, 1);
143    }
144
145    #[test]
146    fn test_nonexistent_command() {
147        let mut resolver = CommandResolver::new();
148        let fake = resolver.resolve("this_command_definitely_does_not_exist_xyz");
149        assert_eq!(fake.command, "this_command_definitely_does_not_exist_xyz");
150        assert!(!fake.found);
151    }
152
153    #[test]
154    fn test_extract_base_command() {
155        let mut resolver = CommandResolver::new();
156        // Should extract "cargo" from "cargo fmt"
157        let resolution = resolver.resolve("cargo fmt --check");
158        assert_eq!(resolution.command, "cargo");
159    }
160}