pub mod check;
pub mod config;
pub mod execute;
pub mod invocation;
pub mod prompt;
pub mod types;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use crate::parser::ast::ExecBlock;
use crate::state::types::HarnessEvent;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarnessEntry {
pub command: String,
#[serde(default)]
pub prompt_slot: Option<String>,
pub args_mapping: String,
#[serde(default)]
pub output_mode: Option<HarnessOutputMode>,
#[serde(default)]
pub defaults: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum HarnessOutputMode {
#[serde(rename = "text")]
Text,
#[serde(rename = "stream-json")]
StreamJson,
}
impl Default for HarnessOutputMode {
fn default() -> Self {
Self::Text
}
}
pub struct RunContext {
pub run_id: Arc<RwLock<Option<String>>>,
pub project_root: String,
}
#[derive(Clone)]
pub struct HarnessExecutionContext {
pub run_id: String,
pub step_path: Vec<String>,
pub exec_ordinal: usize,
pub on_harness_event: Option<Arc<dyn Fn(HarnessEvent) + Send + Sync>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarnessConfig {
pub harness: HashMap<String, HarnessEntry>,
}
#[derive(Debug, Clone)]
pub struct ExecInvocation {
pub command: Vec<String>,
pub cwd: String,
pub env: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecResult {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub harness_events: Vec<HarnessEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckOutput {
pub result: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchOutput {
pub variant: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
pub fn resolve_harness<'a>(
config: &'a HarnessConfig,
name: &str,
) -> Result<&'a HarnessEntry, String> {
config.harness.get(name).ok_or_else(|| {
let available: Vec<&String> = config.harness.keys().collect();
format!(
"Harness \"{}\" not found. Available: {}",
name,
if available.is_empty() {
"(none)".to_string()
} else {
available
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
}
)
})
}
pub async fn dispatch_exec(
config: &HarnessConfig,
project_root: &str,
exec_block: &ExecBlock,
run_context: Option<&RunContext>,
execution_context: Option<&HarnessExecutionContext>,
) -> Result<ExecResult, String> {
let entry = resolve_harness(config, &exec_block.harness)?;
let (resolved_prompt_path, temp_path) = if let Some(ref prompt_file) = exec_block.prompt_file {
(prompt::resolve_prompt(project_root, prompt_file)?, None)
} else if let Some(ref prompt_content) = exec_block.prompt {
if entry.prompt_slot.is_none() {
(prompt::resolve_prompt(project_root, prompt_content)?, None)
} else {
let tmp_dir = std::env::temp_dir();
let tmp_path = tmp_dir.join(format!("o7-prompt-{}.txt", uuid::Uuid::new_v4()));
std::fs::write(&tmp_path, prompt_content)
.map_err(|e| format!("Failed to write temp prompt file: {}", e))?;
let path_str = tmp_path.to_string_lossy().to_string();
(path_str, Some(tmp_path))
}
} else {
return Err("Exec block must have either prompt or promptFile".to_string());
};
let mut inv =
invocation::build_invocation(entry, exec_block, &resolved_prompt_path, project_root);
if let Some(ctx) = run_context {
if let Ok(guard) = ctx.run_id.read() {
if let Some(ref run_id) = *guard {
let run_state_dir = format!("{}/.7/runs/{}", ctx.project_root, run_id);
let env = inv.env.get_or_insert_with(HashMap::new);
env.insert("RUN_ID".to_string(), run_id.clone());
env.insert("RUN_STATE_DIR".to_string(), run_state_dir);
}
}
}
let result = execute::execute(
&inv,
execute::ExecuteOptions {
output_mode: entry.output_mode.unwrap_or(HarnessOutputMode::Text),
on_event: execution_context.and_then(|ctx| ctx.on_harness_event.clone()),
step_path: execution_context.map(|ctx| ctx.step_path.clone()),
exec_ordinal: execution_context.map(|ctx| ctx.exec_ordinal).unwrap_or(0),
},
)
.await;
if let Some(ref path) = temp_path {
let _ = std::fs::remove_file(path);
}
result
}
pub async fn dispatch_check(
config: &HarnessConfig,
project_root: &str,
exec_block: &ExecBlock,
run_context: Option<&RunContext>,
) -> Result<CheckOutput, String> {
let result = dispatch_exec(config, project_root, exec_block, run_context, None).await?;
check::parse_check_result(&result.stdout)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_resolve_harness_found() {
let mut harness_map = HashMap::new();
harness_map.insert(
"echo-test".to_string(),
HarnessEntry {
command: "echo".to_string(),
prompt_slot: Some("-p".to_string()),
args_mapping: "flags".to_string(),
output_mode: None,
defaults: None,
},
);
let config = HarnessConfig {
harness: harness_map,
};
let entry = resolve_harness(&config, "echo-test").unwrap();
assert_eq!(entry.command, "echo");
}
#[test]
fn test_resolve_harness_not_found() {
let config = HarnessConfig {
harness: HashMap::new(),
};
let result = resolve_harness(&config, "missing");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("Harness \"missing\" not found"));
assert!(err.contains("(none)"));
}
#[test]
fn test_resolve_harness_not_found_shows_available() {
let mut harness_map = HashMap::new();
harness_map.insert(
"claude".to_string(),
HarnessEntry {
command: "claude".to_string(),
prompt_slot: Some("-p".to_string()),
args_mapping: "flags".to_string(),
output_mode: None,
defaults: None,
},
);
let config = HarnessConfig {
harness: harness_map,
};
let result = resolve_harness(&config, "missing");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("Harness \"missing\" not found"));
assert!(err.contains("claude"));
}
#[tokio::test]
async fn test_dispatch_exec_no_prompt() {
let config = HarnessConfig {
harness: HashMap::new(),
};
let exec_block = ExecBlock {
harness: "test".to_string(),
prompt: None,
prompt_file: None,
args: None,
line: 1,
column: 1,
};
let result = dispatch_exec(&config, "/nonexistent", &exec_block, None, None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_dispatch_exec_missing_harness() {
let config = HarnessConfig {
harness: HashMap::new(),
};
let exec_block = ExecBlock {
harness: "test".to_string(),
prompt: Some("do something".to_string()),
prompt_file: None,
args: None,
line: 1,
column: 1,
};
let result = dispatch_exec(&config, "/nonexistent", &exec_block, None, None).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[tokio::test]
async fn test_dispatch_exec_inline_prompt() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let dot7 = dir.path().join(".7");
fs::create_dir_all(&dot7).unwrap();
fs::write(
dot7.join("harnesses.toml"),
r#"
[harness.echo-test]
command = "echo"
prompt_slot = ""
args_mapping = "flags"
"#,
)
.unwrap();
let config = config::load_harness_config(dir.path().to_str().unwrap()).unwrap();
let exec_block = ExecBlock {
harness: "echo-test".to_string(),
prompt: Some("hello world".to_string()),
prompt_file: None,
args: None,
line: 1,
column: 1,
};
let result = dispatch_exec(
&config,
dir.path().to_str().unwrap(),
&exec_block,
None,
None,
)
.await;
assert!(result.is_ok(), "dispatch_exec failed: {:?}", result);
let exec_result = result.unwrap();
assert_eq!(exec_result.exit_code, 0);
}
#[tokio::test]
async fn test_dispatch_exec_with_prompt_file() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let dot7 = dir.path().join(".7");
fs::create_dir_all(&dot7).unwrap();
fs::write(
dot7.join("harnesses.toml"),
r#"
[harness.echo-test]
command = "echo"
prompt_slot = ""
args_mapping = "flags"
"#,
)
.unwrap();
let prompts_dir = dir.path().join("prompts");
fs::create_dir_all(&prompts_dir).unwrap();
fs::write(prompts_dir.join("test.md"), "test prompt content").unwrap();
let config = config::load_harness_config(dir.path().to_str().unwrap()).unwrap();
let exec_block = ExecBlock {
harness: "echo-test".to_string(),
prompt: None,
prompt_file: Some("test.md".to_string()),
args: None,
line: 1,
column: 1,
};
let result = dispatch_exec(
&config,
dir.path().to_str().unwrap(),
&exec_block,
None,
None,
)
.await;
assert!(result.is_ok(), "dispatch_exec failed: {:?}", result);
}
#[tokio::test]
async fn test_check_script_dispatch_without_prompt_slot() {
use std::fs;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let dot7 = dir.path().join(".7");
fs::create_dir_all(&dot7).unwrap();
fs::write(
dot7.join("harnesses.toml"),
r#"
[harness.check-script]
command = "bash"
args_mapping = "flags"
"#,
)
.unwrap();
let scripts_dir = dir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
let script = scripts_dir.join("check.sh");
fs::write(&script, "#!/bin/bash\necho '{\"result\": false}'").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
}
let config = config::load_harness_config(dir.path().to_str().unwrap()).unwrap();
let exec_block = ExecBlock {
harness: "check-script".to_string(),
prompt: Some("scripts/check.sh".to_string()),
prompt_file: None,
args: None,
line: 1,
column: 1,
};
let result = dispatch_exec(
&config,
dir.path().to_str().unwrap(),
&exec_block,
None,
None,
)
.await;
assert!(result.is_ok(), "dispatch failed: {:?}", result);
let exec_result = result.unwrap();
assert_eq!(exec_result.exit_code, 0);
let parsed: serde_json::Value = serde_json::from_str(&exec_result.stdout.trim()).unwrap();
assert_eq!(parsed["result"], false);
}
#[tokio::test]
async fn test_run_context_injects_env_vars() {
use std::fs;
use std::sync::{Arc, RwLock};
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let dot7 = dir.path().join(".7");
fs::create_dir_all(&dot7).unwrap();
fs::write(
dot7.join("harnesses.toml"),
r#"
[harness.check-script]
command = "bash"
args_mapping = "flags"
"#,
)
.unwrap();
let scripts_dir = dir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
fs::write(
scripts_dir.join("env-check.sh"),
"#!/bin/bash\necho \"{\\\"run_id\\\": \\\"$RUN_ID\\\", \\\"state_dir\\\": \\\"$RUN_STATE_DIR\\\"}\"",
).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(
scripts_dir.join("env-check.sh"),
fs::Permissions::from_mode(0o755),
)
.unwrap();
}
let config = config::load_harness_config(dir.path().to_str().unwrap()).unwrap();
let run_context = RunContext {
run_id: Arc::new(RwLock::new(Some("test-run-123".to_string()))),
project_root: dir.path().to_str().unwrap().to_string(),
};
let exec_block = ExecBlock {
harness: "check-script".to_string(),
prompt: Some("scripts/env-check.sh".to_string()),
prompt_file: None,
args: None,
line: 1,
column: 1,
};
let result = dispatch_exec(
&config,
dir.path().to_str().unwrap(),
&exec_block,
Some(&run_context),
None,
)
.await;
assert!(result.is_ok());
let exec_result = result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&exec_result.stdout.trim()).unwrap();
assert_eq!(parsed["run_id"], "test-run-123");
}
}