1use std::{
5 fmt::Debug,
6 path::{Path, PathBuf},
7 sync::Arc,
8};
9
10use crate::{
11 Result,
12 harness::{Part, PartResult, Scope},
13 hook::{Answer, Event, HookInput, Output, ToolCall, claude_names},
14 integration::{Integration, Item},
15};
16
17pub trait Harness: Debug + Send + Sync {
24 fn id(&self) -> &str;
27
28 fn scopes(&self) -> &[Scope];
30
31 fn render(&self, integration: &Integration, cx: &Context) -> Result<Vec<Part>>;
36
37 fn reads(&self, item: Item, cx: &Context) -> Result<Reads>;
41
42 fn hook_events(&self) -> &[Event] {
45 &Event::ALL
46 }
47
48 fn parse_hook(&self, event: Event, text: &str) -> serde_json::Result<HookInput>;
50
51 fn answer(&self, event: Event, answer: &Answer) -> Result<Output>;
54
55 fn notes(&self, cx: &Context, parts: &[PartResult]) -> Vec<String> {
59 let _ = (cx, parts);
60 Vec::new()
61 }
62
63 fn program(&self) -> Option<&str> {
67 None
68 }
69
70 fn claude_tool(&self, call: &ToolCall) -> ToolCall {
73 claude_names::by_kind(call)
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
80#[non_exhaustive]
81pub struct Context {
82 pub tool: String,
84 pub scope: Scope,
86 pub root: PathBuf,
88 pub user_root: Option<PathBuf>,
90}
91
92impl Context {
93 pub fn new(
95 tool: impl Into<String>,
96 scope: Scope,
97 root: impl Into<PathBuf>,
98 user_root: Option<PathBuf>,
99 ) -> Self {
100 Context {
101 tool: tool.into(),
102 scope,
103 root: root.into(),
104 user_root,
105 }
106 }
107
108 pub fn path(&self, rel: &str) -> PathBuf {
110 self.root.join(rel)
111 }
112
113 pub fn relative(&self, path: &Path) -> Option<String> {
116 let abs = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
117 let (path, root) = (abs(path), abs(&self.root));
118 let rel = path
119 .strip_prefix(&root)
120 .ok()
121 .map(Path::to_path_buf)
122 .or_else(|| {
123 let (p, r) = (path.canonicalize().ok()?, root.canonicalize().ok()?);
124 p.strip_prefix(r).ok().map(Path::to_path_buf)
125 })?;
126 let parts: Vec<String> = rel
127 .components()
128 .map(|c| c.as_os_str().to_string_lossy().into_owned())
129 .collect();
130 (!parts.is_empty()).then(|| parts.join("/"))
131 }
132
133 pub fn is_file(&self, rel: &str) -> bool {
135 self.path(rel).is_file()
136 }
137
138 pub fn user_path(&self, rel: &str) -> Option<PathBuf> {
140 self.user_root.as_deref().map(|h: &Path| h.join(rel))
141 }
142}
143
144#[derive(Debug, Clone, Default, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct Reads {
148 pub always: Vec<String>,
150 pub maybe: Vec<String>,
152}
153
154impl Reads {
155 pub fn always<I: IntoIterator<Item = S>, S: Into<String>>(locations: I) -> Self {
157 Reads {
158 always: locations.into_iter().map(Into::into).collect(),
159 maybe: Vec::new(),
160 }
161 }
162
163 #[must_use]
165 pub fn maybe<I: IntoIterator<Item = S>, S: Into<String>>(mut self, locations: I) -> Self {
166 self.maybe.extend(locations.into_iter().map(Into::into));
167 self
168 }
169
170 pub fn loads(&self, location: &str) -> bool {
172 self.always.iter().any(|l| l == location)
173 }
174
175 pub fn may_load(&self, location: &str) -> bool {
177 self.loads(location) || self.maybe.iter().any(|l| l == location)
178 }
179}
180
181pub fn builtin() -> Vec<Arc<dyn Harness>> {
186 vec![
187 Arc::new(crate::claude::Claude),
188 Arc::new(crate::codex::Codex),
189 Arc::new(crate::factory::Factory),
190 Arc::new(crate::gemini::Gemini),
191 Arc::new(crate::copilot::Copilot),
192 Arc::new(crate::cursor::Cursor),
193 Arc::new(crate::pi::Pi),
194 Arc::new(crate::opencode::OpenCode),
195 Arc::new(crate::kilo::Kilo),
196 Arc::new(crate::qwen::Qwen),
197 Arc::new(crate::devin::Devin),
198 Arc::new(crate::agents_md::AgentsMd),
199 ]
200}
201
202pub fn find(id: &str) -> Option<Arc<dyn Harness>> {
204 builtin().into_iter().find(|h| h.id() == id)
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn reads_and_context() {
213 let r = Reads::always(["AGENTS.md"]).maybe(["CLAUDE.md"]);
214 assert!(r.loads("AGENTS.md") && !r.loads("CLAUDE.md"));
215 assert!(r.may_load("CLAUDE.md") && !r.may_load("X"));
216 let cx = Context::new("t", Scope::Project, "/p", Some("/h".into()));
217 assert_eq!(cx.path("a/b"), Path::new("/p/a/b"));
218 assert_eq!(cx.user_path(".x"), Some(PathBuf::from("/h/.x")));
219 assert!(!cx.is_file("nope"));
220 assert_eq!(
221 Context::new("t", Scope::User, "/h", None).user_path("x"),
222 None
223 );
224 }
225
226 #[test]
228 fn built_in_harnesses_are_found_by_id() {
229 let ids: Vec<_> = builtin().iter().map(|h| h.id().to_string()).collect();
230 for id in &ids {
231 assert_eq!(find(id).unwrap().id(), id);
232 }
233 assert!(find("nope").is_none());
234 }
235}
236
237#[cfg(test)]
238mod properties {
239 use proptest::prelude::*;
240 use serde_json::Value;
241
242 use super::*;
243 use crate::{
244 integration::{Agent, Command, Hook, McpServer, Skill},
245 test_support::temp_dir,
246 };
247
248 fn arb_integration() -> impl Strategy<Value = Integration> {
249 (
250 prop::option::of("[a-z ]{1,12}"),
251 prop::collection::vec("[a-z]{1,6}", 0..3),
252 prop::collection::vec(0usize..7, 0..3),
253 prop::bool::ANY,
254 prop::bool::ANY,
255 )
256 .prop_map(|(block, skills, events, agent, command)| {
257 let mut i = Integration::new();
258 if let Some(b) = block {
259 i = i.instructions(format!("{b}\n"));
260 }
261 for s in skills {
262 i = i.skill(Skill::new(s.clone(), "d", format!("# {s}\n")));
263 }
264 for e in events {
265 i = i.hook(Hook::new(Event::ALL[e], "t hook {harness} {event}"));
266 }
267 if agent {
268 i = i.agent(Agent::new("a", "d", "p\n"));
269 }
270 if command {
271 i = i.command(Command::new("c", "d", "p $ARGUMENTS\n"));
272 }
273 i.mcp_server(McpServer::stdio("t", "t", ["mcp"]))
274 .allow_command("t")
275 })
276 }
277
278 fn arb_answer() -> impl Strategy<Value = Answer> {
279 prop_oneof![
280 prop::option::of("[a-z]{0,5}").prop_map(|stderr| Answer::Allow { stderr }),
281 "[a-z \"]{0,8}".prop_map(|reason| Answer::Deny { reason }),
282 "[a-z \"]{0,8}".prop_map(|reason| Answer::Continue { reason }),
283 "[a-z \"]{0,8}".prop_map(|text| Answer::Context { text }),
284 ]
285 }
286
287 fn arb_object() -> impl Strategy<Value = Value> {
288 let leaf = prop_oneof![
289 Just(Value::Null),
290 any::<bool>().prop_map(Value::from),
291 (0i64..100).prop_map(Value::from),
292 "[a-z_]{0,6}".prop_map(Value::from),
293 ];
294 let keys = prop::sample::select(vec![
295 "session_id",
296 "cwd",
297 "tool_name",
298 "tool_input",
299 "toolName",
300 "toolArgs",
301 "stop_hook_active",
302 "loop_count",
303 "prompt",
304 "workspace_roots",
305 "cursor_version",
306 "conversation_id",
307 "event",
308 "continuing",
309 "x",
310 ]);
311 prop::collection::btree_map(keys, leaf, 0..6)
312 .prop_map(|m| Value::Object(m.into_iter().map(|(k, v)| (k.to_string(), v)).collect()))
313 }
314
315 proptest! {
316 #![proptest_config(ProptestConfig::with_cases(64))]
317
318 #[test]
320 fn answers_are_well_formed(event in 0usize..7, answer in arb_answer()) {
321 for h in builtin() {
322 let event = Event::ALL[event];
323 match h.answer(event, &answer) {
324 Ok(out) => {
325 prop_assert_eq!(out.exit, 0);
326 let v: Value = serde_json::from_str(&out.stdout).unwrap();
327 prop_assert!(v.is_object(), "{}: {}", h.id(), out.stdout);
328 }
329 Err(e) => prop_assert!(matches!(e, crate::Error::Unsupported { .. }), "{e}"),
330 }
331 }
332 }
333
334 #[test]
336 fn any_object_parses(event in 0usize..7, raw in arb_object()) {
337 for h in builtin() {
338 let input = h.parse_hook(Event::ALL[event], &raw.to_string()).unwrap();
339 prop_assert_eq!(input.event, Some(Event::ALL[event]));
340 }
341 }
342
343 #[test]
345 fn shared_locations_get_equal_parts(i in arb_integration(), user in any::<bool>()) {
346 let dir = temp_dir("har-p2");
347 let scope = if user { Scope::User } else { Scope::Project };
348 let cx = Context::new("t", scope, &dir, Some(dir.clone()));
349 let mut seen: Vec<(String, String, String, Part)> = Vec::new();
350 for h in builtin() {
351 for p in h.render(&i, &cx).unwrap() {
352 let at = p.location();
353 if let Some((_, _, other, q)) = seen.iter().find(|(n, l, _, _)| n == p.name() && *l == at) {
354 prop_assert!(p.same_content(q), "{} and {other} differ at {at}", h.id());
355 }
356 seen.push((p.name().to_string(), at, h.id().to_string(), p));
357 }
358 }
359 std::fs::remove_dir_all(&dir).unwrap();
360 }
361 }
362}