nexo_driver_loop/acceptance/
custom.rs1use std::path::Path;
5use std::sync::Arc;
6use std::time::Duration;
7
8use async_trait::async_trait;
9use dashmap::DashMap;
10use serde_json::Value;
11
12use crate::acceptance::shell::ShellRunner;
13use crate::error::DriverError;
14
15#[async_trait]
16pub trait CustomVerifier: Send + Sync + 'static {
17 async fn verify(&self, args: &Value, workspace: &Path) -> Result<Option<String>, DriverError>;
20}
21
22#[derive(Default)]
23pub struct CustomVerifierRegistry {
24 inner: DashMap<String, Arc<dyn CustomVerifier>>,
25}
26
27impl CustomVerifierRegistry {
28 pub fn new() -> Self {
29 Self::default()
30 }
31
32 pub fn register(&self, name: impl Into<String>, v: Arc<dyn CustomVerifier>) {
33 self.inner.insert(name.into(), v);
34 }
35
36 pub fn get(&self, name: &str) -> Option<Arc<dyn CustomVerifier>> {
37 self.inner.get(name).map(|e| e.value().clone())
38 }
39
40 pub fn with_builtins(self) -> Self {
42 let np: Arc<dyn CustomVerifier> = Arc::new(NoPathsTouched::default());
43 let gc: Arc<dyn CustomVerifier> = Arc::new(GitClean::default());
44 self.register("no_paths_touched", np);
45 self.register("git_clean", gc);
46 self
47 }
48}
49
50#[derive(Default)]
56pub struct NoPathsTouched {
57 pub shell: ShellRunner,
58}
59
60#[async_trait]
61impl CustomVerifier for NoPathsTouched {
62 async fn verify(&self, args: &Value, workspace: &Path) -> Result<Option<String>, DriverError> {
63 let prefixes: Vec<String> = args
64 .get("prefixes")
65 .and_then(|v| v.as_array())
66 .map(|arr| {
67 arr.iter()
68 .filter_map(|e| e.as_str().map(|s| s.to_string()))
69 .collect()
70 })
71 .unwrap_or_default();
72 if prefixes.is_empty() {
73 return Ok(None);
74 }
75 if !workspace.join(".git").exists() {
76 return Ok(None);
77 }
78 let res = self
79 .shell
80 .run(
81 "git diff --name-only HEAD",
82 workspace,
83 Duration::from_secs(30),
84 )
85 .await?;
86 if res.timed_out || res.exit_code != Some(0) {
87 return Ok(Some(format!(
88 "no_paths_touched: git diff failed (exit {:?})",
89 res.exit_code
90 )));
91 }
92 let mut hits = Vec::new();
93 for line in res.stdout.lines() {
94 let line = line.trim();
95 if line.is_empty() {
96 continue;
97 }
98 if prefixes.iter().any(|p| line.starts_with(p)) {
99 hits.push(line.to_string());
100 }
101 }
102 if hits.is_empty() {
103 Ok(None)
104 } else {
105 Ok(Some(format!(
106 "no_paths_touched: modified prefixed paths: {}",
107 hits.join(", ")
108 )))
109 }
110 }
111}
112
113#[derive(Default)]
116pub struct GitClean {
117 pub shell: ShellRunner,
118}
119
120#[async_trait]
121impl CustomVerifier for GitClean {
122 async fn verify(&self, _args: &Value, workspace: &Path) -> Result<Option<String>, DriverError> {
123 if !workspace.join(".git").exists() {
124 return Ok(None);
125 }
126 let res = self
127 .shell
128 .run("git status --porcelain", workspace, Duration::from_secs(30))
129 .await?;
130 if res.timed_out || res.exit_code != Some(0) {
131 return Ok(Some(format!(
132 "git_clean: git status failed (exit {:?})",
133 res.exit_code
134 )));
135 }
136 if res.stdout.trim().is_empty() {
137 Ok(None)
138 } else {
139 Ok(Some(format!(
140 "git_clean: workspace dirty:\n{}",
141 res.stdout.trim()
142 )))
143 }
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use std::sync::Mutex;
151
152 pub struct ScriptedVerifier {
153 outcomes: Mutex<std::collections::VecDeque<Option<String>>>,
154 }
155
156 impl ScriptedVerifier {
157 pub fn new<I: IntoIterator<Item = Option<String>>>(items: I) -> Self {
158 Self {
159 outcomes: Mutex::new(items.into_iter().collect()),
160 }
161 }
162 }
163
164 #[async_trait]
165 impl CustomVerifier for ScriptedVerifier {
166 async fn verify(
167 &self,
168 _args: &Value,
169 _workspace: &Path,
170 ) -> Result<Option<String>, DriverError> {
171 Ok(self
172 .outcomes
173 .lock()
174 .unwrap()
175 .pop_front()
176 .unwrap_or(Some("scripted exhausted".into())))
177 }
178 }
179
180 #[tokio::test]
181 async fn registry_register_and_get() {
182 let r = CustomVerifierRegistry::new();
183 let s: Arc<dyn CustomVerifier> = Arc::new(ScriptedVerifier::new([None]));
184 r.register("scripted", s);
185 assert!(r.get("scripted").is_some());
186 assert!(r.get("missing").is_none());
187 }
188
189 #[tokio::test]
190 async fn scripted_returns_in_order() {
191 let v = ScriptedVerifier::new([None, Some("nope".into())]);
192 let dir = tempfile::tempdir().unwrap();
193 let r1 = v.verify(&Value::Null, dir.path()).await.unwrap();
194 let r2 = v.verify(&Value::Null, dir.path()).await.unwrap();
195 assert!(r1.is_none());
196 assert_eq!(r2.as_deref(), Some("nope"));
197 }
198
199 #[tokio::test]
200 async fn no_paths_touched_no_git_passes() {
201 let dir = tempfile::tempdir().unwrap();
203 let v = NoPathsTouched::default();
204 let args = serde_json::json!({"prefixes": ["secrets/"]});
205 assert!(v.verify(&args, dir.path()).await.unwrap().is_none());
206 }
207
208 #[tokio::test]
209 async fn git_clean_no_git_passes() {
210 let dir = tempfile::tempdir().unwrap();
211 let v = GitClean::default();
212 assert!(v.verify(&Value::Null, dir.path()).await.unwrap().is_none());
213 }
214
215 #[tokio::test]
216 async fn with_builtins_registers_two_names() {
217 let r = CustomVerifierRegistry::default().with_builtins();
218 assert!(r.get("no_paths_touched").is_some());
219 assert!(r.get("git_clean").is_some());
220 }
221}