Skip to main content

vtcode_core/tools/
command_cache.rs

1use 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    // Remove under the lock, then notify outside it: cloning the result per
164    // waiter must not extend the critical section (fast-Tokio principle:
165    // keep lock-held work to a single map operation).
166    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}