vtcode_core/tools/
command_cache.rs1use crate::acp::permission_cache::PermissionGrant;
2pub use crate::acp::permission_cache::ToolPermissionCache as PermissionCache;
3use crate::cache::{CacheKey, EvictionPolicy, UnifiedCache};
4use crate::tools::shell::ShellOutput;
5use hashbrown::HashMap;
6use once_cell::sync::Lazy;
7use parking_lot::Mutex;
8use std::path::{Path, PathBuf};
9use tokio::sync::{Mutex as TokioMutex, oneshot};
10use vtcode_config::CommandCacheConfig;
11
12#[derive(Debug, Clone, Hash, Eq, PartialEq)]
13struct CommandCacheKey {
14 command: String,
15 cwd: PathBuf,
16}
17
18impl CacheKey for CommandCacheKey {
19 fn to_cache_key(&self) -> String {
20 format!("{}::{}", self.command, self.cwd.display())
21 }
22}
23
24fn command_cache_key(command: &str, cwd: &Path) -> CommandCacheKey {
25 CommandCacheKey {
26 command: command.to_string(),
27 cwd: cwd.to_path_buf(),
28 }
29}
30
31struct CommandCache {
32 inner: Mutex<CommandCacheInner>,
33}
34
35struct CommandCacheInner {
36 config: CommandCacheConfig,
37 cache: UnifiedCache<CommandCacheKey, ShellOutput>,
38}
39
40static COMMAND_CACHE: Lazy<CommandCache> = Lazy::new(|| CommandCache::new(CommandCacheConfig::default()));
41
42pub type InFlightResult = Result<ShellOutput, String>;
43
44pub struct InFlightToken(CommandCacheKey);
45
46pub enum InFlightState {
47 Owner(InFlightToken),
48 Wait(oneshot::Receiver<InFlightResult>),
49}
50
51static IN_FLIGHT: Lazy<TokioMutex<HashMap<CommandCacheKey, Vec<oneshot::Sender<InFlightResult>>>>> =
52 Lazy::new(|| TokioMutex::new(HashMap::new()));
53
54impl PermissionCache {
55 pub fn get(&mut self, key: &str) -> Option<bool> {
56 match self.get_permission(key) {
57 Some(PermissionGrant::Denied) => Some(false),
58 Some(_) => Some(true),
59 None => None,
60 }
61 }
62
63 pub fn put(&mut self, key: &str, allowed: bool, _reason: &str) {
64 let grant = if allowed {
65 PermissionGrant::Session
66 } else {
67 PermissionGrant::Denied
68 };
69 self.cache_grant(key.to_string(), grant);
70 }
71}
72
73impl CommandCache {
74 fn new(config: CommandCacheConfig) -> Self {
75 let cache = Self::build_cache(&config);
76 Self {
77 inner: Mutex::new(CommandCacheInner { config, cache }),
78 }
79 }
80
81 fn build_cache(config: &CommandCacheConfig) -> UnifiedCache<CommandCacheKey, ShellOutput> {
82 UnifiedCache::new(
83 config.max_entries.max(1),
84 std::time::Duration::from_millis(config.ttl_ms),
85 EvictionPolicy::Lru,
86 )
87 }
88
89 fn configure(&self, config: &CommandCacheConfig) {
90 let mut inner = self.inner.lock();
91 inner.config = config.clone();
92 inner.cache = Self::build_cache(config);
93 }
94
95 fn allowlisted_with_config(cfg: &CommandCacheConfig, command: &str) -> bool {
96 if !cfg.enabled {
97 return false;
98 }
99 let trimmed = command.trim();
100 cfg.allowlist.iter().any(|entry| {
101 let entry = entry.trim();
102 trimmed == entry || (trimmed.starts_with(entry) && trimmed.as_bytes().get(entry.len()) == Some(&b' '))
103 })
104 }
105
106 fn allowlisted(&self, command: &str) -> bool {
107 let inner = self.inner.lock();
108 Self::allowlisted_with_config(&inner.config, command)
109 }
110
111 fn get(&self, command: &str, cwd: &Path) -> Option<ShellOutput> {
112 let inner = self.inner.lock();
113 if !Self::allowlisted_with_config(&inner.config, command) {
114 return None;
115 }
116 let key = command_cache_key(command, cwd);
117 inner.cache.get_owned(&key)
118 }
119
120 fn put(&self, command: &str, cwd: &Path, output: ShellOutput) {
121 let inner = self.inner.lock();
122 if !Self::allowlisted_with_config(&inner.config, command) || output.exit_code != 0 {
123 return;
124 }
125 let key = command_cache_key(command, cwd);
126 let size = (output.stdout.len() + output.stderr.len()) as u64;
127 inner.cache.insert(key, output, size);
128 }
129}
130
131pub fn configure_command_cache(config: &CommandCacheConfig) {
132 COMMAND_CACHE.configure(config);
133}
134
135pub fn get_cached_output(command: &str, cwd: &Path) -> Option<ShellOutput> {
136 COMMAND_CACHE.get(command, cwd)
137}
138
139pub fn cache_output(command: &str, cwd: &Path, output: ShellOutput) {
140 COMMAND_CACHE.put(command, cwd, output);
141}
142
143pub async fn enter_inflight(command: &str, cwd: &Path) -> Option<InFlightState> {
144 if !COMMAND_CACHE.allowlisted(command) {
145 return None;
146 }
147
148 let key = command_cache_key(command, cwd);
149
150 let mut inflight = IN_FLIGHT.lock().await;
151 if let Some(waiters) = inflight.get_mut(&key) {
152 let (tx, rx) = oneshot::channel();
153 waiters.push(tx);
154 return Some(InFlightState::Wait(rx));
155 }
156
157 inflight.insert(key.clone(), Vec::new());
158 Some(InFlightState::Owner(InFlightToken(key)))
159}
160
161pub async fn finish_inflight(token: InFlightToken, result: InFlightResult) {
162 let key = token.0;
163 let waiters = {
167 let mut inflight = IN_FLIGHT.lock().await;
168 inflight.remove(&key)
169 };
170 if let Some(waiters) = waiters {
171 for waiter in waiters {
172 let _ = waiter.send(result.clone());
173 }
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 fn test_config() -> CommandCacheConfig {
182 CommandCacheConfig {
183 enabled: true,
184 ttl_ms: 10_000,
185 max_entries: 8,
186 allowlist: vec!["git status".to_string(), "echo".to_string()],
187 }
188 }
189
190 #[test]
191 fn allowlist_matches_prefix() {
192 let cache = CommandCache::new(test_config());
193 assert!(cache.allowlisted("git status"));
194 assert!(cache.allowlisted("git status -s"));
195 assert!(!cache.allowlisted("git diff"));
196 }
197
198 #[test]
199 fn cache_stores_only_successes() {
200 let cache = CommandCache::new(test_config());
201 let cwd = Path::new("/tmp");
202
203 let failed = ShellOutput {
204 stdout: "nope".to_string(),
205 stderr: "err".to_string(),
206 exit_code: 1,
207 };
208 cache.put("echo bad", cwd, failed);
209 assert!(cache.get("echo bad", cwd).is_none());
210
211 let ok = ShellOutput {
212 stdout: "ok".to_string(),
213 stderr: String::new(),
214 exit_code: 0,
215 };
216 cache.put("echo ok", cwd, ok.clone());
217 let cached = cache.get("echo ok", cwd).expect("cached output");
218 assert_eq!(cached.stdout, ok.stdout);
219 }
220}