Skip to main content

nexo_driver_loop/acceptance/
custom.rs

1//! `CustomVerifier` trait + registry + the two built-ins shipped in
2//! 67.5: `no_paths_touched` and `git_clean`.
3
4use 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    /// `Ok(None)` ⇒ pass; `Ok(Some(message))` ⇒ fail; `Err` ⇒
18    /// infrastructure problem (propagated up).
19    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    /// Register the two 67.5 built-ins under their canonical names.
41    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/// Fails when any path Claude touched (per `git diff --name-only HEAD`)
51/// starts with one of the configured `prefixes`.
52///
53/// `args`: `{"prefixes": ["secrets/", "private/"]}`. Empty prefixes
54/// → pass. Workspace not a git repo → pass (verifier irrelevant).
55#[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/// Fails when `git status --porcelain` returns any output. Workspace
114/// not a git repo → pass.
115#[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        // No .git → verifier irrelevant.
202        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}