use std::{
fmt::Debug,
path::{Path, PathBuf},
sync::Arc,
};
use crate::{
Result,
harness::{Part, PartResult, Scope},
hook::{Answer, Event, HookInput, Output, ToolCall, claude_names},
integration::{Integration, Item},
};
pub trait Harness: Debug + Send + Sync {
fn id(&self) -> &str;
fn scopes(&self) -> &[Scope];
fn render(&self, integration: &Integration, cx: &Context) -> Result<Vec<Part>>;
fn reads(&self, item: Item, cx: &Context) -> Result<Reads>;
fn hook_events(&self) -> &[Event] {
&Event::ALL
}
fn parse_hook(&self, event: Event, text: &str) -> serde_json::Result<HookInput>;
fn answer(&self, event: Event, answer: &Answer) -> Result<Output>;
fn notes(&self, cx: &Context, parts: &[PartResult]) -> Vec<String> {
let _ = (cx, parts);
Vec::new()
}
fn program(&self) -> Option<&str> {
None
}
fn claude_tool(&self, call: &ToolCall) -> ToolCall {
claude_names::by_kind(call)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Context {
pub tool: String,
pub scope: Scope,
pub root: PathBuf,
pub user_root: Option<PathBuf>,
}
impl Context {
pub fn new(
tool: impl Into<String>,
scope: Scope,
root: impl Into<PathBuf>,
user_root: Option<PathBuf>,
) -> Self {
Context {
tool: tool.into(),
scope,
root: root.into(),
user_root,
}
}
pub fn path(&self, rel: &str) -> PathBuf {
self.root.join(rel)
}
pub fn relative(&self, path: &Path) -> Option<String> {
let abs = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
let (path, root) = (abs(path), abs(&self.root));
let rel = path
.strip_prefix(&root)
.ok()
.map(Path::to_path_buf)
.or_else(|| {
let (p, r) = (path.canonicalize().ok()?, root.canonicalize().ok()?);
p.strip_prefix(r).ok().map(Path::to_path_buf)
})?;
let parts: Vec<String> = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
(!parts.is_empty()).then(|| parts.join("/"))
}
pub fn is_file(&self, rel: &str) -> bool {
self.path(rel).is_file()
}
pub fn user_path(&self, rel: &str) -> Option<PathBuf> {
self.user_root.as_deref().map(|h: &Path| h.join(rel))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct Reads {
pub always: Vec<String>,
pub maybe: Vec<String>,
}
impl Reads {
pub fn always<I: IntoIterator<Item = S>, S: Into<String>>(locations: I) -> Self {
Reads {
always: locations.into_iter().map(Into::into).collect(),
maybe: Vec::new(),
}
}
#[must_use]
pub fn maybe<I: IntoIterator<Item = S>, S: Into<String>>(mut self, locations: I) -> Self {
self.maybe.extend(locations.into_iter().map(Into::into));
self
}
pub fn loads(&self, location: &str) -> bool {
self.always.iter().any(|l| l == location)
}
pub fn may_load(&self, location: &str) -> bool {
self.loads(location) || self.maybe.iter().any(|l| l == location)
}
}
pub fn builtin() -> Vec<Arc<dyn Harness>> {
vec![
Arc::new(crate::claude::Claude),
Arc::new(crate::codex::Codex),
Arc::new(crate::factory::Factory),
Arc::new(crate::gemini::Gemini),
Arc::new(crate::copilot::Copilot),
Arc::new(crate::cursor::Cursor),
Arc::new(crate::pi::Pi),
Arc::new(crate::opencode::OpenCode),
Arc::new(crate::kilo::Kilo),
Arc::new(crate::qwen::Qwen),
Arc::new(crate::devin::Devin),
Arc::new(crate::agents_md::AgentsMd),
]
}
pub fn find(id: &str) -> Option<Arc<dyn Harness>> {
builtin().into_iter().find(|h| h.id() == id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_and_context() {
let r = Reads::always(["AGENTS.md"]).maybe(["CLAUDE.md"]);
assert!(r.loads("AGENTS.md") && !r.loads("CLAUDE.md"));
assert!(r.may_load("CLAUDE.md") && !r.may_load("X"));
let cx = Context::new("t", Scope::Project, "/p", Some("/h".into()));
assert_eq!(cx.path("a/b"), Path::new("/p/a/b"));
assert_eq!(cx.user_path(".x"), Some(PathBuf::from("/h/.x")));
assert!(!cx.is_file("nope"));
assert_eq!(
Context::new("t", Scope::User, "/h", None).user_path("x"),
None
);
}
#[test]
fn built_in_harnesses_are_found_by_id() {
let ids: Vec<_> = builtin().iter().map(|h| h.id().to_string()).collect();
for id in &ids {
assert_eq!(find(id).unwrap().id(), id);
}
assert!(find("nope").is_none());
}
}
#[cfg(test)]
mod properties {
use proptest::prelude::*;
use serde_json::Value;
use super::*;
use crate::{
integration::{Agent, Command, Hook, McpServer, Skill},
test_support::temp_dir,
};
fn arb_integration() -> impl Strategy<Value = Integration> {
(
prop::option::of("[a-z ]{1,12}"),
prop::collection::vec("[a-z]{1,6}", 0..3),
prop::collection::vec(0usize..7, 0..3),
prop::bool::ANY,
prop::bool::ANY,
)
.prop_map(|(block, skills, events, agent, command)| {
let mut i = Integration::new();
if let Some(b) = block {
i = i.instructions(format!("{b}\n"));
}
for s in skills {
i = i.skill(Skill::new(s.clone(), "d", format!("# {s}\n")));
}
for e in events {
i = i.hook(Hook::new(Event::ALL[e], "t hook {harness} {event}"));
}
if agent {
i = i.agent(Agent::new("a", "d", "p\n"));
}
if command {
i = i.command(Command::new("c", "d", "p $ARGUMENTS\n"));
}
i.mcp_server(McpServer::stdio("t", "t", ["mcp"]))
.allow_command("t")
})
}
fn arb_answer() -> impl Strategy<Value = Answer> {
prop_oneof![
prop::option::of("[a-z]{0,5}").prop_map(|stderr| Answer::Allow { stderr }),
"[a-z \"]{0,8}".prop_map(|reason| Answer::Deny { reason }),
"[a-z \"]{0,8}".prop_map(|reason| Answer::Continue { reason }),
"[a-z \"]{0,8}".prop_map(|text| Answer::Context { text }),
]
}
fn arb_object() -> impl Strategy<Value = Value> {
let leaf = prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::from),
(0i64..100).prop_map(Value::from),
"[a-z_]{0,6}".prop_map(Value::from),
];
let keys = prop::sample::select(vec![
"session_id",
"cwd",
"tool_name",
"tool_input",
"toolName",
"toolArgs",
"stop_hook_active",
"loop_count",
"prompt",
"workspace_roots",
"cursor_version",
"conversation_id",
"event",
"continuing",
"x",
]);
prop::collection::btree_map(keys, leaf, 0..6)
.prop_map(|m| Value::Object(m.into_iter().map(|(k, v)| (k.to_string(), v)).collect()))
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn answers_are_well_formed(event in 0usize..7, answer in arb_answer()) {
for h in builtin() {
let event = Event::ALL[event];
match h.answer(event, &answer) {
Ok(out) => {
prop_assert_eq!(out.exit, 0);
let v: Value = serde_json::from_str(&out.stdout).unwrap();
prop_assert!(v.is_object(), "{}: {}", h.id(), out.stdout);
}
Err(e) => prop_assert!(matches!(e, crate::Error::Unsupported { .. }), "{e}"),
}
}
}
#[test]
fn any_object_parses(event in 0usize..7, raw in arb_object()) {
for h in builtin() {
let input = h.parse_hook(Event::ALL[event], &raw.to_string()).unwrap();
prop_assert_eq!(input.event, Some(Event::ALL[event]));
}
}
#[test]
fn shared_locations_get_equal_parts(i in arb_integration(), user in any::<bool>()) {
let dir = temp_dir("har-p2");
let scope = if user { Scope::User } else { Scope::Project };
let cx = Context::new("t", scope, &dir, Some(dir.clone()));
let mut seen: Vec<(String, String, String, Part)> = Vec::new();
for h in builtin() {
for p in h.render(&i, &cx).unwrap() {
let at = p.location();
if let Some((_, _, other, q)) = seen.iter().find(|(n, l, _, _)| n == p.name() && *l == at) {
prop_assert!(p.same_content(q), "{} and {other} differ at {at}", h.id());
}
seen.push((p.name().to_string(), at, h.id().to_string(), p));
}
}
std::fs::remove_dir_all(&dir).unwrap();
}
}
}