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    /// use vtcode_core::tools::CommandResolver;
52    ///
53    /// let mut resolver = CommandResolver::new();
54    /// let cargo = resolver.resolve("cargo");
55    /// assert_eq!(cargo.command, "cargo");
56    /// // `cargo.found` and `cargo.resolved_path` depend on the current PATH.
57    /// ```
58    pub fn resolve(&mut self, cmd: &str) -> CommandResolution {
59        // Extract base command (first word only)
60        let base_cmd = cmd.split_whitespace().next().unwrap_or(cmd);
61
62        // Check cache first
63        if let Some(cached) = self.cache.get(base_cmd) {
64            self.cache_hits += 1;
65            debug!(
66                command = base_cmd,
67                cache_hits = self.cache_hits,
68                "Command resolution cache hit"
69            );
70            return cached.clone();
71        }
72
73        self.cache_misses += 1;
74
75        // Try to find command in system PATH
76        let resolution = if let Ok(path) = which::which(base_cmd) {
77            CommandResolution {
78                command: base_cmd.to_string(),
79                resolved_path: Some(path.clone()),
80                found: true,
81                search_paths: Self::get_search_paths(),
82            }
83        } else {
84            warn!(command = base_cmd, "Command not found in PATH");
85            CommandResolution {
86                command: base_cmd.to_string(),
87                resolved_path: None,
88                found: false,
89                search_paths: Self::get_search_paths(),
90            }
91        };
92
93        // Cache the result
94        self.cache.insert(base_cmd.to_string(), resolution.clone());
95        resolution
96    }
97
98    /// Get current PATH directories being searched
99    fn get_search_paths() -> Vec<PathBuf> {
100        std::env::var_os("PATH")
101            .map(|paths| std::env::split_paths(&paths).collect())
102            .unwrap_or_default()
103    }
104
105    /// Clear the resolution cache
106    pub fn clear_cache(&mut self) {
107        self.cache.clear();
108        debug!("Command resolver cache cleared");
109    }
110
111    /// Get cache statistics
112    pub fn cache_stats(&self) -> (usize, usize) {
113        (self.cache_hits, self.cache_misses)
114    }
115}
116
117impl Default for CommandResolver {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_resolve_common_command() {
129        let mut resolver = CommandResolver::new();
130        let ls = resolver.resolve("ls");
131        assert_eq!(ls.command, "ls");
132        // ls should be found on any Unix system
133        assert!(ls.found);
134    }
135
136    #[test]
137    fn test_cache_hits() {
138        let mut resolver = CommandResolver::new();
139        resolver.resolve("ls");
140        resolver.resolve("ls");
141        let (hits, misses) = resolver.cache_stats();
142        assert_eq!(hits, 1);
143        assert_eq!(misses, 1);
144    }
145
146    #[test]
147    fn test_nonexistent_command() {
148        let mut resolver = CommandResolver::new();
149        let fake = resolver.resolve("this_command_definitely_does_not_exist_xyz");
150        assert_eq!(fake.command, "this_command_definitely_does_not_exist_xyz");
151        assert!(!fake.found);
152    }
153
154    #[test]
155    fn test_extract_base_command() {
156        let mut resolver = CommandResolver::new();
157        // Should extract "cargo" from "cargo fmt"
158        let resolution = resolver.resolve("cargo fmt --check");
159        assert_eq!(resolution.command, "cargo");
160    }
161}