use serde::Serialize;
use crate::agent::model::{AgentModel, Effort};
use crate::agent::types::{AgentRun, AgentVersion, ClaudeResult};
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentKind {
Claude,
}
impl AgentKind {
pub fn all() -> &'static [AgentKind] {
&[AgentKind::Claude]
}
pub fn spec(self) -> &'static AgentSpec {
match self {
AgentKind::Claude => &AGENTS[0],
}
}
pub fn as_str(self) -> &'static str {
self.spec().binary
}
pub fn parse(s: &str) -> Option<AgentKind> {
AgentKind::all().iter().copied().find(|k| k.as_str() == s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResultFormat {
SingleObject,
}
#[derive(Debug, Clone, Copy)]
pub struct AgentSpec {
pub kind: AgentKind,
pub binary: &'static str,
pub version_args: &'static [&'static str],
pub run_args: &'static [&'static str],
pub prompt_positional: bool,
pub json_args: &'static [&'static str],
pub model_flag: &'static str,
pub result_format: ResultFormat,
}
pub static AGENTS: &[AgentSpec] = &[AgentSpec {
kind: AgentKind::Claude,
binary: "claude",
version_args: &["--version"],
run_args: &["-p"],
prompt_positional: true,
json_args: &["--output-format", "json"],
model_flag: "--model",
result_format: ResultFormat::SingleObject,
}];
pub fn version_argv(spec: &AgentSpec) -> Vec<String> {
spec.version_args.iter().map(|s| s.to_string()).collect()
}
pub fn prompt_argv(spec: &AgentSpec, prompt: &str, model: AgentModel) -> Vec<String> {
let mut argv: Vec<String> = spec.run_args.iter().map(|s| s.to_string()).collect();
if spec.prompt_positional {
argv.push(prompt.to_string());
}
argv.extend(spec.json_args.iter().map(|s| s.to_string()));
if !spec.model_flag.is_empty() {
argv.push(spec.model_flag.to_string());
argv.push(model.id().to_string());
}
argv
}
pub fn apply_effort(effort: Effort, prompt: &str) -> String {
match effort.directive() {
Some(directive) => format!("{directive}\n\n{prompt}"),
None => prompt.to_string(),
}
}
pub fn parse_version(raw_stdout: &str) -> AgentVersion {
let raw = raw_stdout.lines().next().unwrap_or("").trim().to_string();
AgentVersion {
version: extract_version(&raw),
raw,
}
}
fn extract_version(text: &str) -> Option<String> {
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if !bytes[i].is_ascii_digit() {
i += 1;
continue;
}
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
i += 1;
}
let token = text[start..i].trim_end_matches('.');
if token.split('.').count() >= 2 && token.split('.').all(|part| !part.is_empty()) {
return Some(token.to_string());
}
}
None
}
pub fn parse_result(kind: AgentKind, format: ResultFormat, stdout: &str) -> Result<AgentRun> {
match format {
ResultFormat::SingleObject => {
let raw: serde_json::Value = serde_json::from_str(stdout)?;
let parsed: ClaudeResult = serde_json::from_value(raw.clone())?;
Ok(AgentRun {
kind,
is_error: parsed.is_error,
result: parsed.result,
raw,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_kind_has_a_matching_spec() {
for &kind in AgentKind::all() {
assert_eq!(kind.spec().kind, kind);
}
}
#[test]
fn kind_parse_roundtrips_and_rejects_unknown() {
for &kind in AgentKind::all() {
assert_eq!(AgentKind::parse(kind.as_str()), Some(kind));
}
assert_eq!(AgentKind::parse("nope"), None);
}
#[test]
fn kind_serializes_lowercase() {
assert_eq!(
serde_json::to_string(&AgentKind::Claude).unwrap(),
"\"claude\""
);
}
#[test]
fn version_argv_is_version_args() {
assert_eq!(
version_argv(AgentKind::Claude.spec()),
vec!["--version".to_string()]
);
}
#[test]
fn prompt_argv_orders_run_then_prompt_then_json_then_model() {
let argv = prompt_argv(AgentKind::Claude.spec(), "do a thing", AgentModel::Sonnet);
assert_eq!(
argv,
vec![
"-p".to_string(),
"do a thing".to_string(),
"--output-format".to_string(),
"json".to_string(),
"--model".to_string(),
"sonnet".to_string(),
]
);
let tricky = prompt_argv(
AgentKind::Claude.spec(),
"a \"quoted\" $arg; rm -rf",
AgentModel::Opus,
);
assert_eq!(tricky[1], "a \"quoted\" $arg; rm -rf");
assert_eq!(tricky[tricky.len() - 2], "--model");
assert_eq!(tricky[tricky.len() - 1], "opus");
}
#[test]
fn apply_effort_prefixes_directive_except_baseline() {
assert_eq!(apply_effort(Effort::Medium, "draft this"), "draft this");
let high = apply_effort(Effort::High, "draft this");
assert!(high.ends_with("\n\ndraft this"));
assert!(high.starts_with(Effort::High.directive().unwrap()));
let low = apply_effort(Effort::Low, "draft this");
assert!(low.starts_with(Effort::Low.directive().unwrap()));
}
#[test]
fn parse_version_extracts_semver() {
assert_eq!(
parse_version("1.2.3 (Claude Code)").version,
Some("1.2.3".to_string())
);
assert_eq!(parse_version("claude 0.4").version, Some("0.4".to_string()));
assert_eq!(
parse_version("v2.10.0\nextra line").version,
Some("2.10.0".to_string())
);
assert_eq!(parse_version("1.2.").version, Some("1.2".to_string()));
assert_eq!(parse_version("build 12").version, None);
let none = parse_version("weird-output");
assert_eq!(none.version, None);
assert_eq!(none.raw, "weird-output");
}
#[test]
fn parse_result_single_object_ok() {
let run = parse_result(
AgentKind::Claude,
ResultFormat::SingleObject,
r#"{"is_error": false, "result": "done", "extra": 1}"#,
)
.unwrap();
assert!(!run.is_error);
assert_eq!(run.result, "done");
assert_eq!(run.kind, AgentKind::Claude);
assert_eq!(run.raw.get("extra").and_then(|v| v.as_i64()), Some(1));
}
#[test]
fn parse_result_single_object_error_flag() {
let run = parse_result(
AgentKind::Claude,
ResultFormat::SingleObject,
r#"{"is_error": true, "result": "boom"}"#,
)
.unwrap();
assert!(run.is_error);
assert_eq!(run.result, "boom");
}
#[test]
fn parse_result_rejects_malformed_json() {
let err =
parse_result(AgentKind::Claude, ResultFormat::SingleObject, "not json").unwrap_err();
assert!(matches!(err, crate::error::Error::Json(_)));
}
}