1use serde::Serialize;
8
9use crate::agent::model::{AgentModel, Effort};
10use crate::agent::types::{AgentRun, AgentVersion, ClaudeResult};
11use crate::error::Result;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum AgentKind {
17 Claude,
19}
20
21impl AgentKind {
22 pub fn all() -> &'static [AgentKind] {
24 &[AgentKind::Claude]
25 }
26
27 pub fn spec(self) -> &'static AgentSpec {
30 match self {
31 AgentKind::Claude => &AGENTS[0],
32 }
33 }
34
35 pub fn as_str(self) -> &'static str {
37 self.spec().binary
38 }
39
40 pub fn parse(s: &str) -> Option<AgentKind> {
42 AgentKind::all().iter().copied().find(|k| k.as_str() == s)
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ResultFormat {
50 SingleObject,
52}
53
54#[derive(Debug, Clone, Copy)]
56pub struct AgentSpec {
57 pub kind: AgentKind,
59 pub binary: &'static str,
61 pub version_args: &'static [&'static str],
63 pub run_args: &'static [&'static str],
66 pub prompt_positional: bool,
68 pub json_args: &'static [&'static str],
70 pub model_flag: &'static str,
73 pub result_format: ResultFormat,
75}
76
77pub static AGENTS: &[AgentSpec] = &[AgentSpec {
79 kind: AgentKind::Claude,
80 binary: "claude",
81 version_args: &["--version"],
82 run_args: &["-p"],
83 prompt_positional: true,
84 json_args: &["--output-format", "json"],
85 model_flag: "--model",
86 result_format: ResultFormat::SingleObject,
87}];
88
89pub fn version_argv(spec: &AgentSpec) -> Vec<String> {
91 spec.version_args.iter().map(|s| s.to_string()).collect()
92}
93
94pub fn prompt_argv(spec: &AgentSpec, prompt: &str, model: AgentModel) -> Vec<String> {
100 let mut argv: Vec<String> = spec.run_args.iter().map(|s| s.to_string()).collect();
101 if spec.prompt_positional {
102 argv.push(prompt.to_string());
103 }
104 argv.extend(spec.json_args.iter().map(|s| s.to_string()));
105 if !spec.model_flag.is_empty() {
106 argv.push(spec.model_flag.to_string());
107 argv.push(model.id().to_string());
108 }
109 argv
110}
111
112pub fn apply_effort(effort: Effort, prompt: &str) -> String {
117 match effort.directive() {
118 Some(directive) => format!("{directive}\n\n{prompt}"),
119 None => prompt.to_string(),
120 }
121}
122
123pub fn parse_version(raw_stdout: &str) -> AgentVersion {
127 let raw = raw_stdout.lines().next().unwrap_or("").trim().to_string();
128 AgentVersion {
129 version: extract_version(&raw),
130 raw,
131 }
132}
133
134fn extract_version(text: &str) -> Option<String> {
137 let bytes = text.as_bytes();
138 let mut i = 0;
139 while i < bytes.len() {
140 if !bytes[i].is_ascii_digit() {
141 i += 1;
142 continue;
143 }
144 let start = i;
145 while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
146 i += 1;
147 }
148 let token = text[start..i].trim_end_matches('.');
149 if token.split('.').count() >= 2 && token.split('.').all(|part| !part.is_empty()) {
150 return Some(token.to_string());
151 }
152 }
153 None
154}
155
156pub fn parse_result(kind: AgentKind, format: ResultFormat, stdout: &str) -> Result<AgentRun> {
159 match format {
160 ResultFormat::SingleObject => {
161 let raw: serde_json::Value = serde_json::from_str(stdout)?;
162 let parsed: ClaudeResult = serde_json::from_value(raw.clone())?;
163 Ok(AgentRun {
164 kind,
165 is_error: parsed.is_error,
166 result: parsed.result,
167 raw,
168 })
169 }
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn every_kind_has_a_matching_spec() {
179 for &kind in AgentKind::all() {
180 assert_eq!(kind.spec().kind, kind);
181 }
182 }
183
184 #[test]
185 fn kind_parse_roundtrips_and_rejects_unknown() {
186 for &kind in AgentKind::all() {
187 assert_eq!(AgentKind::parse(kind.as_str()), Some(kind));
188 }
189 assert_eq!(AgentKind::parse("nope"), None);
190 }
191
192 #[test]
193 fn kind_serializes_lowercase() {
194 assert_eq!(
195 serde_json::to_string(&AgentKind::Claude).unwrap(),
196 "\"claude\""
197 );
198 }
199
200 #[test]
201 fn version_argv_is_version_args() {
202 assert_eq!(
203 version_argv(AgentKind::Claude.spec()),
204 vec!["--version".to_string()]
205 );
206 }
207
208 #[test]
209 fn prompt_argv_orders_run_then_prompt_then_json_then_model() {
210 let argv = prompt_argv(AgentKind::Claude.spec(), "do a thing", AgentModel::Sonnet);
211 assert_eq!(
212 argv,
213 vec![
214 "-p".to_string(),
215 "do a thing".to_string(),
216 "--output-format".to_string(),
217 "json".to_string(),
218 "--model".to_string(),
219 "sonnet".to_string(),
220 ]
221 );
222 let tricky = prompt_argv(
224 AgentKind::Claude.spec(),
225 "a \"quoted\" $arg; rm -rf",
226 AgentModel::Opus,
227 );
228 assert_eq!(tricky[1], "a \"quoted\" $arg; rm -rf");
229 assert_eq!(tricky[tricky.len() - 2], "--model");
231 assert_eq!(tricky[tricky.len() - 1], "opus");
232 }
233
234 #[test]
235 fn apply_effort_prefixes_directive_except_baseline() {
236 assert_eq!(apply_effort(Effort::Medium, "draft this"), "draft this");
238 let high = apply_effort(Effort::High, "draft this");
240 assert!(high.ends_with("\n\ndraft this"));
241 assert!(high.starts_with(Effort::High.directive().unwrap()));
242 let low = apply_effort(Effort::Low, "draft this");
243 assert!(low.starts_with(Effort::Low.directive().unwrap()));
244 }
245
246 #[test]
247 fn parse_version_extracts_semver() {
248 assert_eq!(
249 parse_version("1.2.3 (Claude Code)").version,
250 Some("1.2.3".to_string())
251 );
252 assert_eq!(parse_version("claude 0.4").version, Some("0.4".to_string()));
253 assert_eq!(
254 parse_version("v2.10.0\nextra line").version,
255 Some("2.10.0".to_string())
256 );
257 assert_eq!(parse_version("1.2.").version, Some("1.2".to_string()));
259 assert_eq!(parse_version("build 12").version, None);
260 let none = parse_version("weird-output");
261 assert_eq!(none.version, None);
262 assert_eq!(none.raw, "weird-output");
263 }
264
265 #[test]
266 fn parse_result_single_object_ok() {
267 let run = parse_result(
268 AgentKind::Claude,
269 ResultFormat::SingleObject,
270 r#"{"is_error": false, "result": "done", "extra": 1}"#,
271 )
272 .unwrap();
273 assert!(!run.is_error);
274 assert_eq!(run.result, "done");
275 assert_eq!(run.kind, AgentKind::Claude);
276 assert_eq!(run.raw.get("extra").and_then(|v| v.as_i64()), Some(1));
278 }
279
280 #[test]
281 fn parse_result_single_object_error_flag() {
282 let run = parse_result(
283 AgentKind::Claude,
284 ResultFormat::SingleObject,
285 r#"{"is_error": true, "result": "boom"}"#,
286 )
287 .unwrap();
288 assert!(run.is_error);
289 assert_eq!(run.result, "boom");
290 }
291
292 #[test]
293 fn parse_result_rejects_malformed_json() {
294 let err =
295 parse_result(AgentKind::Claude, ResultFormat::SingleObject, "not json").unwrap_err();
296 assert!(matches!(err, crate::error::Error::Json(_)));
297 }
298}