1use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use serde_json::json;
18use std::borrow::Cow;
19use std::collections::BTreeMap;
20
21use crate::contracts::model::{Model, ReasoningEffort};
22
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Hash)]
24pub enum Runner {
25 Codex,
26 Opencode,
27 Gemini,
28 Cursor,
29 #[default]
30 Claude,
31 Kimi,
32 Pi,
33 Plugin(String),
34}
35
36impl Runner {
37 pub fn as_str(&self) -> &str {
39 match self {
40 Runner::Codex => "codex",
41 Runner::Opencode => "opencode",
42 Runner::Gemini => "gemini",
43 Runner::Cursor => "cursor",
44 Runner::Claude => "claude",
45 Runner::Kimi => "kimi",
46 Runner::Pi => "pi",
47 Runner::Plugin(id) => id.as_str(),
48 }
49 }
50
51 pub fn id(&self) -> &str {
52 self.as_str()
53 }
54
55 pub fn is_plugin(&self) -> bool {
56 matches!(self, Runner::Plugin(_))
57 }
58}
59
60impl std::fmt::Display for Runner {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 write!(f, "{}", self.id())
63 }
64}
65
66impl std::str::FromStr for Runner {
67 type Err = &'static str;
68
69 fn from_str(value: &str) -> Result<Self, Self::Err> {
70 let token = value.trim();
71 if token.is_empty() {
72 return Err("runner must be non-empty");
73 }
74 Ok(match token.to_lowercase().as_str() {
75 "codex" => Runner::Codex,
76 "opencode" => Runner::Opencode,
77 "gemini" => Runner::Gemini,
78 "cursor" => Runner::Cursor,
79 "claude" => Runner::Claude,
80 "kimi" => Runner::Kimi,
81 "pi" => Runner::Pi,
82 _ => Runner::Plugin(token.to_string()),
83 })
84 }
85}
86
87impl Serialize for Runner {
89 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
90 s.serialize_str(self.id())
91 }
92}
93
94impl<'de> Deserialize<'de> for Runner {
95 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
96 let raw = String::deserialize(d)?;
97 raw.parse::<Runner>().map_err(serde::de::Error::custom)
98 }
99}
100
101impl JsonSchema for Runner {
103 fn schema_name() -> Cow<'static, str> {
104 "Runner".into()
105 }
106
107 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
108 let mut schema = <String as JsonSchema>::json_schema(generator);
109 let obj = schema.ensure_object();
110 obj.entry("description".to_string()).or_insert_with(|| {
111 json!(
112 "Runner id (built-ins: codex, opencode, gemini, cursor, claude, kimi, pi; plugin runners: any other non-empty string)"
113 )
114 });
115 obj.insert(
116 "examples".to_string(),
117 json!(["claude", "acme.super_runner"]),
118 );
119 schema
120 }
121}
122
123#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
124#[serde(rename_all = "snake_case")]
125pub enum ClaudePermissionMode {
126 #[default]
127 AcceptEdits,
128 BypassPermissions,
129}
130
131#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
132#[serde(rename_all = "snake_case")]
133pub enum RunnerOutputFormat {
134 #[default]
136 StreamJson,
137 Json,
139 Text,
141}
142
143impl std::str::FromStr for RunnerOutputFormat {
144 type Err = &'static str;
145
146 fn from_str(value: &str) -> Result<Self, Self::Err> {
147 match normalize_enum_token(value).as_str() {
148 "stream_json" => Ok(RunnerOutputFormat::StreamJson),
149 "json" => Ok(RunnerOutputFormat::Json),
150 "text" => Ok(RunnerOutputFormat::Text),
151 _ => Err("output_format must be 'stream_json', 'json', or 'text'"),
152 }
153 }
154}
155
156#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
157#[serde(rename_all = "snake_case")]
158pub enum RunnerVerbosity {
159 Quiet,
160 #[default]
161 Normal,
162 Verbose,
163}
164
165impl std::str::FromStr for RunnerVerbosity {
166 type Err = &'static str;
167
168 fn from_str(value: &str) -> Result<Self, Self::Err> {
169 match normalize_enum_token(value).as_str() {
170 "quiet" => Ok(RunnerVerbosity::Quiet),
171 "normal" => Ok(RunnerVerbosity::Normal),
172 "verbose" => Ok(RunnerVerbosity::Verbose),
173 _ => Err("verbosity must be 'quiet', 'normal', or 'verbose'"),
174 }
175 }
176}
177
178#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
179#[serde(rename_all = "snake_case")]
180pub enum RunnerApprovalMode {
181 Default,
183 AutoEdits,
185 #[default]
187 Yolo,
188 Safe,
190}
191
192impl std::str::FromStr for RunnerApprovalMode {
193 type Err = &'static str;
194
195 fn from_str(value: &str) -> Result<Self, Self::Err> {
196 match normalize_enum_token(value).as_str() {
197 "default" => Ok(RunnerApprovalMode::Default),
198 "auto_edits" => Ok(RunnerApprovalMode::AutoEdits),
199 "yolo" => Ok(RunnerApprovalMode::Yolo),
200 "safe" => Ok(RunnerApprovalMode::Safe),
201 _ => Err("approval_mode must be 'default', 'auto_edits', 'yolo', or 'safe'"),
202 }
203 }
204}
205
206#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
207#[serde(rename_all = "snake_case")]
208pub enum RunnerSandboxMode {
209 #[default]
210 Default,
211 Enabled,
212 Disabled,
213}
214
215impl std::str::FromStr for RunnerSandboxMode {
216 type Err = &'static str;
217
218 fn from_str(value: &str) -> Result<Self, Self::Err> {
219 match normalize_enum_token(value).as_str() {
220 "default" => Ok(RunnerSandboxMode::Default),
221 "enabled" => Ok(RunnerSandboxMode::Enabled),
222 "disabled" => Ok(RunnerSandboxMode::Disabled),
223 _ => Err("sandbox must be 'default', 'enabled', or 'disabled'"),
224 }
225 }
226}
227
228#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
229#[serde(rename_all = "snake_case")]
230pub enum RunnerPlanMode {
231 #[default]
232 Default,
233 Enabled,
234 Disabled,
235}
236
237impl std::str::FromStr for RunnerPlanMode {
238 type Err = &'static str;
239
240 fn from_str(value: &str) -> Result<Self, Self::Err> {
241 match normalize_enum_token(value).as_str() {
242 "default" => Ok(RunnerPlanMode::Default),
243 "enabled" => Ok(RunnerPlanMode::Enabled),
244 "disabled" => Ok(RunnerPlanMode::Disabled),
245 _ => Err("plan_mode must be 'default', 'enabled', or 'disabled'"),
246 }
247 }
248}
249
250#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
251#[serde(rename_all = "snake_case")]
252pub enum UnsupportedOptionPolicy {
253 Ignore,
254 #[default]
255 Warn,
256 Error,
257}
258
259impl std::str::FromStr for UnsupportedOptionPolicy {
260 type Err = &'static str;
261
262 fn from_str(value: &str) -> Result<Self, Self::Err> {
263 match normalize_enum_token(value).as_str() {
264 "ignore" => Ok(UnsupportedOptionPolicy::Ignore),
265 "warn" => Ok(UnsupportedOptionPolicy::Warn),
266 "error" => Ok(UnsupportedOptionPolicy::Error),
267 _ => Err("unsupported_option_policy must be 'ignore', 'warn', or 'error'"),
268 }
269 }
270}
271
272fn normalize_enum_token(value: &str) -> String {
273 value.trim().to_lowercase().replace('-', "_")
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
277#[serde(default, deny_unknown_fields)]
278pub struct RunnerCliConfigRoot {
279 pub defaults: RunnerCliOptionsPatch,
281
282 pub runners: BTreeMap<Runner, RunnerCliOptionsPatch>,
284}
285
286impl RunnerCliConfigRoot {
287 pub fn merge_from(&mut self, other: Self) {
288 self.defaults.merge_from(other.defaults);
289 for (runner, patch) in other.runners {
290 self.runners
291 .entry(runner)
292 .and_modify(|existing| existing.merge_from(patch.clone()))
293 .or_insert(patch);
294 }
295 }
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
299#[serde(default, deny_unknown_fields)]
300pub struct RunnerCliOptionsPatch {
301 pub output_format: Option<RunnerOutputFormat>,
303
304 pub verbosity: Option<RunnerVerbosity>,
306
307 pub approval_mode: Option<RunnerApprovalMode>,
309
310 pub sandbox: Option<RunnerSandboxMode>,
312
313 pub plan_mode: Option<RunnerPlanMode>,
315
316 pub unsupported_option_policy: Option<UnsupportedOptionPolicy>,
318}
319
320impl RunnerCliOptionsPatch {
321 pub fn merge_from(&mut self, other: Self) {
322 if other.output_format.is_some() {
323 self.output_format = other.output_format;
324 }
325 if other.verbosity.is_some() {
326 self.verbosity = other.verbosity;
327 }
328 if other.approval_mode.is_some() {
329 self.approval_mode = other.approval_mode;
330 }
331 if other.sandbox.is_some() {
332 self.sandbox = other.sandbox;
333 }
334 if other.plan_mode.is_some() {
335 self.plan_mode = other.plan_mode;
336 }
337 if other.unsupported_option_policy.is_some() {
338 self.unsupported_option_policy = other.unsupported_option_policy;
339 }
340 }
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
344#[serde(default, deny_unknown_fields)]
345pub struct MergeRunnerConfig {
346 pub runner: Option<Runner>,
347 pub model: Option<Model>,
348 pub reasoning_effort: Option<ReasoningEffort>,
349}
350
351#[allow(dead_code)]
352impl MergeRunnerConfig {
353 pub fn merge_from(&mut self, other: Self) {
354 if other.runner.is_some() {
355 self.runner = other.runner;
356 }
357 if other.model.is_some() {
358 self.model = other.model;
359 }
360 if other.reasoning_effort.is_some() {
361 self.reasoning_effort = other.reasoning_effort;
362 }
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::{
369 Runner, RunnerApprovalMode, RunnerOutputFormat, RunnerPlanMode, RunnerSandboxMode,
370 RunnerVerbosity, UnsupportedOptionPolicy,
371 };
372
373 #[test]
374 fn runner_cli_enums_from_str_accept_hyphenated_tokens() {
375 assert_eq!(
376 "stream-json".parse::<RunnerOutputFormat>().unwrap(),
377 RunnerOutputFormat::StreamJson
378 );
379 assert_eq!(
380 "auto-edits".parse::<RunnerApprovalMode>().unwrap(),
381 RunnerApprovalMode::AutoEdits
382 );
383 assert_eq!(
384 "verbose".parse::<RunnerVerbosity>().unwrap(),
385 RunnerVerbosity::Verbose
386 );
387 assert_eq!(
388 "disabled".parse::<RunnerSandboxMode>().unwrap(),
389 RunnerSandboxMode::Disabled
390 );
391 assert_eq!(
392 "enabled".parse::<RunnerPlanMode>().unwrap(),
393 RunnerPlanMode::Enabled
394 );
395 assert_eq!(
396 "error".parse::<UnsupportedOptionPolicy>().unwrap(),
397 UnsupportedOptionPolicy::Error
398 );
399 }
400
401 #[test]
402 fn runner_parses_built_ins() {
403 assert_eq!("codex".parse::<Runner>().unwrap(), Runner::Codex);
404 assert_eq!("opencode".parse::<Runner>().unwrap(), Runner::Opencode);
405 assert_eq!("gemini".parse::<Runner>().unwrap(), Runner::Gemini);
406 assert_eq!("cursor".parse::<Runner>().unwrap(), Runner::Cursor);
407 assert_eq!("claude".parse::<Runner>().unwrap(), Runner::Claude);
408 assert_eq!("kimi".parse::<Runner>().unwrap(), Runner::Kimi);
409 assert_eq!("pi".parse::<Runner>().unwrap(), Runner::Pi);
410 }
411
412 #[test]
413 fn runner_parses_plugin_id() {
414 assert_eq!(
415 "acme.super_runner".parse::<Runner>().unwrap(),
416 Runner::Plugin("acme.super_runner".to_string())
417 );
418 assert_eq!(
419 "my-custom-runner".parse::<Runner>().unwrap(),
420 Runner::Plugin("my-custom-runner".to_string())
421 );
422 }
423
424 #[test]
425 fn runner_rejects_empty() {
426 assert!("".parse::<Runner>().is_err());
427 assert!(" ".parse::<Runner>().is_err());
428 }
429
430 #[test]
431 fn runner_serde_roundtrip_is_string() {
432 let runner = Runner::Plugin("acme.runner".to_string());
433 let json = serde_json::to_string(&runner).unwrap();
434 assert_eq!(json, "\"acme.runner\"");
435 let back: Runner = serde_json::from_str(&json).unwrap();
436 assert_eq!(runner, back);
437 }
438
439 #[test]
440 fn runner_built_in_serde_roundtrip() {
441 let runner = Runner::Claude;
442 let json = serde_json::to_string(&runner).unwrap();
443 assert_eq!(json, "\"claude\"");
444 let back: Runner = serde_json::from_str(&json).unwrap();
445 assert_eq!(runner, back);
446 }
447
448 #[test]
449 fn runner_display_uses_id() {
450 assert_eq!(Runner::Codex.to_string(), "codex");
451 assert_eq!(
452 Runner::Plugin("custom.runner".to_string()).to_string(),
453 "custom.runner"
454 );
455 }
456
457 #[test]
458 fn runner_is_plugin_detects_plugin_variant() {
459 assert!(!Runner::Codex.is_plugin());
460 assert!(!Runner::Claude.is_plugin());
461 assert!(Runner::Plugin("x".to_string()).is_plugin());
462 }
463}