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