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