vtcode_core/tools/registry/
approval_recorder.rs1use super::justification::{ApprovalPattern, JustificationManager};
6use anyhow::Result;
7use std::path::PathBuf;
8use std::sync::Arc;
9use tokio::sync::RwLock;
10
11#[derive(Clone)]
13pub struct ApprovalRecorder {
14 manager: Arc<RwLock<JustificationManager>>,
15}
16
17impl ApprovalRecorder {
18 pub fn new(cache_dir: PathBuf) -> Self {
20 let manager = JustificationManager::new(cache_dir);
21 Self {
22 manager: Arc::new(RwLock::new(manager)),
23 }
24 }
25}
26
27impl Default for ApprovalRecorder {
28 fn default() -> Self {
29 let temp_dir =
33 std::env::temp_dir().join(format!("approval_recorder_default_{}", std::process::id()));
34 Self::new(temp_dir)
35 }
36}
37
38impl ApprovalRecorder {
39 pub async fn record_approval(
41 &self,
42 approval_key: &str,
43 display_name: Option<&str>,
44 approved: bool,
45 reason: Option<String>,
46 ) -> Result<()> {
47 let manager = self.manager.write().await;
48 manager.record_decision(approval_key, display_name, approved, reason);
49 Ok(())
50 }
51
52 pub async fn get_pattern(&self, approval_key: &str) -> Option<ApprovalPattern> {
54 let manager = self.manager.read().await;
55 manager.get_pattern(approval_key)
56 }
57
58 pub async fn has_high_approval_rate(&self, approval_key: &str) -> bool {
60 let manager = self.manager.read().await;
61 if let Some(pattern) = manager.get_pattern(approval_key) {
62 pattern.has_high_approval_rate()
63 } else {
64 false
65 }
66 }
67
68 pub async fn get_learning_summary(&self, approval_key: &str) -> Option<String> {
70 let manager = self.manager.read().await;
71 manager.get_learning_summary(approval_key)
72 }
73
74 pub async fn get_approval_count(&self, approval_key: &str) -> u32 {
76 let manager = self.manager.read().await;
77 if let Some(pattern) = manager.get_pattern(approval_key) {
78 pattern.approval_count()
79 } else {
80 0
81 }
82 }
83
84 pub async fn should_auto_approve(&self, approval_key: &str) -> bool {
93 let manager = self.manager.write().await;
94 if let Err(err) = manager.refresh_patterns() {
95 tracing::debug!(
96 approval_key = %approval_key,
97 error = %err,
98 "Failed to refresh approval patterns before auto-approve check"
99 );
100 }
101 if let Some(pattern) = manager.get_pattern(approval_key) {
102 pattern.has_high_approval_rate()
103 } else {
104 false
105 }
106 }
107
108 pub async fn get_auto_approval_suggestion(
110 &self,
111 approval_key: &str,
112 fallback_display_name: &str,
113 ) -> Option<String> {
114 let manager = self.manager.read().await;
115 if let Some(pattern) = manager.get_pattern(approval_key) {
116 let rate = pattern.approval_rate();
117 if pattern.approval_count() >= 5 {
118 let display_name = pattern.display_name(fallback_display_name);
119 return Some(format!(
120 "You've approved {} {} times ({:.0}% approval rate)",
121 display_name,
122 pattern.approval_count(),
123 rate * 100.0
124 ));
125 }
126 }
127 None
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use tempfile::TempDir;
135
136 fn temp_cache_dir() -> TempDir {
137 TempDir::new().expect("temp approval cache")
138 }
139
140 #[tokio::test]
141 async fn test_approval_recording() {
142 let temp_dir = temp_cache_dir();
143 let recorder = ApprovalRecorder::new(temp_dir.path().to_path_buf());
144
145 recorder
147 .record_approval("read_file", Some("Read File"), true, None)
148 .await
149 .unwrap();
150 recorder
151 .record_approval("read_file", Some("Read File"), true, None)
152 .await
153 .unwrap();
154 recorder
155 .record_approval("read_file", Some("Read File"), false, None)
156 .await
157 .unwrap();
158
159 let pattern = recorder.get_pattern("read_file").await;
161 assert!(pattern.is_some());
162 assert_eq!(pattern.unwrap().approval_count(), 2);
163 }
164
165 #[tokio::test]
166 async fn test_auto_approval_suggestion() {
167 let temp_dir = temp_cache_dir();
168 let recorder = ApprovalRecorder::new(temp_dir.path().to_path_buf());
169
170 assert!(
172 recorder
173 .get_auto_approval_suggestion("read_file", "Read File")
174 .await
175 .is_none()
176 );
177
178 for _ in 0..5 {
180 let _ = recorder
181 .record_approval("read_file", Some("Read File"), true, None)
182 .await;
183 }
184
185 let suggestion = recorder
187 .get_auto_approval_suggestion("read_file", "Read File")
188 .await;
189 assert!(suggestion.is_some());
190 assert!(suggestion.unwrap().contains("100%"));
191 }
192
193 #[tokio::test]
194 async fn test_should_auto_approve() {
195 let temp_dir = temp_cache_dir();
196 let recorder = ApprovalRecorder::new(temp_dir.path().to_path_buf());
197
198 assert!(!recorder.should_auto_approve("run_command").await);
200
201 for _ in 0..3 {
203 let _ = recorder
204 .record_approval("run_command", Some("Run Command"), true, None)
205 .await;
206 }
207
208 assert!(recorder.should_auto_approve("run_command").await);
210 }
211
212 #[tokio::test]
213 async fn test_auto_approval_suggestion_uses_display_name() {
214 let temp_dir = temp_cache_dir();
215 let recorder = ApprovalRecorder::new(temp_dir.path().to_path_buf());
216
217 for _ in 0..5 {
218 let _ = recorder
219 .record_approval(
220 "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null",
221 Some("commands starting with `cargo test`"),
222 true,
223 None,
224 )
225 .await;
226 }
227
228 let suggestion = recorder
229 .get_auto_approval_suggestion(
230 "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null",
231 "fallback label",
232 )
233 .await
234 .expect("suggestion");
235 assert!(suggestion.contains("commands starting with `cargo test`"));
236 }
237
238 #[tokio::test]
239 async fn test_should_auto_approve_refreshes_patterns_from_disk() {
240 let temp_dir = temp_cache_dir();
244
245 let key = "find src -type f -name '*.rs' '|' sort|sandbox_permissions=\"use_default\"|additional_permissions=null";
246
247 let reader = ApprovalRecorder::new(temp_dir.path().to_path_buf());
248 assert!(!reader.should_auto_approve(key).await);
249
250 let writer = ApprovalRecorder::new(temp_dir.path().to_path_buf());
251 for _ in 0..3 {
252 writer
253 .record_approval(key, Some("find src"), true, None)
254 .await
255 .unwrap();
256 }
257
258 assert!(reader.should_auto_approve(key).await);
261 }
262
263 #[tokio::test]
264 async fn test_shell_scoped_history_does_not_reuse_tool_level_key() {
265 let temp_dir = temp_cache_dir();
266 let recorder = ApprovalRecorder::new(temp_dir.path().to_path_buf());
267
268 for _ in 0..5 {
269 let _ = recorder
270 .record_approval("unified_exec", Some("Unified Exec"), true, None)
271 .await;
272 }
273
274 assert_eq!(
275 recorder
276 .get_approval_count(
277 "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null"
278 )
279 .await,
280 0
281 );
282 assert!(
283 recorder
284 .get_auto_approval_suggestion(
285 "cargo test|sandbox_permissions=\"require_escalated\"|additional_permissions=null",
286 "commands starting with `cargo test`",
287 )
288 .await
289 .is_none()
290 );
291 }
292}