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
23pub(crate) const RUNNER_SCHEMA_DESCRIPTION: &str = concat!(
24 "Runner id. Built-in runner IDs: codex, opencode, gemini, claude, cursor, kimi, pi. ",
25 "Plugin runner IDs are also supported as non-empty strings."
26);
27
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Hash)]
29pub enum Runner {
30 Codex,
31 Opencode,
32 Gemini,
33 Cursor,
34 #[default]
35 Claude,
36 Kimi,
37 Pi,
38 Plugin(String),
39}
40
41impl Runner {
42 pub fn as_str(&self) -> &str {
44 match self {
45 Runner::Codex => "codex",
46 Runner::Opencode => "opencode",
47 Runner::Gemini => "gemini",
48 Runner::Cursor => "cursor",
49 Runner::Claude => "claude",
50 Runner::Kimi => "kimi",
51 Runner::Pi => "pi",
52 Runner::Plugin(id) => id.as_str(),
53 }
54 }
55
56 pub fn id(&self) -> &str {
57 self.as_str()
58 }
59
60 pub fn is_plugin(&self) -> bool {
61 matches!(self, Runner::Plugin(_))
62 }
63}
64
65impl std::fmt::Display for Runner {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 write!(f, "{}", self.id())
68 }
69}
70
71impl std::str::FromStr for Runner {
72 type Err = &'static str;
73
74 fn from_str(value: &str) -> Result<Self, Self::Err> {
75 let token = value.trim();
76 if token.is_empty() {
77 return Err("runner must be non-empty");
78 }
79 Ok(match token.to_lowercase().as_str() {
80 "codex" => Runner::Codex,
81 "opencode" => Runner::Opencode,
82 "gemini" => Runner::Gemini,
83 "cursor" => Runner::Cursor,
84 "claude" => Runner::Claude,
85 "kimi" => Runner::Kimi,
86 "pi" => Runner::Pi,
87 _ => Runner::Plugin(token.to_string()),
88 })
89 }
90}
91
92impl Serialize for Runner {
94 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
95 s.serialize_str(self.id())
96 }
97}
98
99impl<'de> Deserialize<'de> for Runner {
100 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
101 let raw = String::deserialize(d)?;
102 raw.parse::<Runner>().map_err(serde::de::Error::custom)
103 }
104}
105
106impl JsonSchema for Runner {
108 fn schema_name() -> Cow<'static, str> {
109 "Runner".into()
110 }
111
112 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
113 let mut schema = <String as JsonSchema>::json_schema(generator);
114 let obj = schema.ensure_object();
115 obj.entry("description".to_string())
116 .or_insert_with(|| json!(RUNNER_SCHEMA_DESCRIPTION));
117 obj.insert(
118 "examples".to_string(),
119 json!(["claude", "acme.super_runner"]),
120 );
121 schema
122 }
123}
124
125#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
126#[serde(rename_all = "snake_case")]
127pub enum ClaudePermissionMode {
128 #[default]
129 AcceptEdits,
130 BypassPermissions,
131}
132
133#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
134#[serde(rename_all = "snake_case")]
135pub enum RunnerOutputFormat {
136 #[default]
138 StreamJson,
139 Json,
141 Text,
143}
144
145impl std::str::FromStr for RunnerOutputFormat {
146 type Err = &'static str;
147
148 fn from_str(value: &str) -> Result<Self, Self::Err> {
149 match normalize_enum_token(value).as_str() {
150 "stream_json" => Ok(RunnerOutputFormat::StreamJson),
151 "json" => Ok(RunnerOutputFormat::Json),
152 "text" => Ok(RunnerOutputFormat::Text),
153 _ => Err("output_format must be 'stream_json', 'json', or 'text'"),
154 }
155 }
156}
157
158#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
159#[serde(rename_all = "snake_case")]
160pub enum RunnerVerbosity {
161 Quiet,
162 #[default]
163 Normal,
164 Verbose,
165}
166
167impl std::str::FromStr for RunnerVerbosity {
168 type Err = &'static str;
169
170 fn from_str(value: &str) -> Result<Self, Self::Err> {
171 match normalize_enum_token(value).as_str() {
172 "quiet" => Ok(RunnerVerbosity::Quiet),
173 "normal" => Ok(RunnerVerbosity::Normal),
174 "verbose" => Ok(RunnerVerbosity::Verbose),
175 _ => Err("verbosity must be 'quiet', 'normal', or 'verbose'"),
176 }
177 }
178}
179
180#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
181#[serde(rename_all = "snake_case")]
182pub enum RunnerApprovalMode {
183 Default,
185 AutoEdits,
187 #[default]
189 Yolo,
190 Safe,
192}
193
194impl std::str::FromStr for RunnerApprovalMode {
195 type Err = &'static str;
196
197 fn from_str(value: &str) -> Result<Self, Self::Err> {
198 match normalize_enum_token(value).as_str() {
199 "default" => Ok(RunnerApprovalMode::Default),
200 "auto_edits" => Ok(RunnerApprovalMode::AutoEdits),
201 "yolo" => Ok(RunnerApprovalMode::Yolo),
202 "safe" => Ok(RunnerApprovalMode::Safe),
203 _ => Err("approval_mode must be 'default', 'auto_edits', 'yolo', or 'safe'"),
204 }
205 }
206}
207
208#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
209#[serde(rename_all = "snake_case")]
210pub enum RunnerSandboxMode {
211 #[default]
212 Default,
213 Enabled,
214 Disabled,
215}
216
217impl std::str::FromStr for RunnerSandboxMode {
218 type Err = &'static str;
219
220 fn from_str(value: &str) -> Result<Self, Self::Err> {
221 match normalize_enum_token(value).as_str() {
222 "default" => Ok(RunnerSandboxMode::Default),
223 "enabled" => Ok(RunnerSandboxMode::Enabled),
224 "disabled" => Ok(RunnerSandboxMode::Disabled),
225 _ => Err("sandbox must be 'default', 'enabled', or 'disabled'"),
226 }
227 }
228}
229
230#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
231#[serde(rename_all = "snake_case")]
232pub enum RunnerPlanMode {
233 #[default]
234 Default,
235 Enabled,
236 Disabled,
237}
238
239impl std::str::FromStr for RunnerPlanMode {
240 type Err = &'static str;
241
242 fn from_str(value: &str) -> Result<Self, Self::Err> {
243 match normalize_enum_token(value).as_str() {
244 "default" => Ok(RunnerPlanMode::Default),
245 "enabled" => Ok(RunnerPlanMode::Enabled),
246 "disabled" => Ok(RunnerPlanMode::Disabled),
247 _ => Err("plan_mode must be 'default', 'enabled', or 'disabled'"),
248 }
249 }
250}
251
252#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, JsonSchema)]
253#[serde(rename_all = "snake_case")]
254pub enum UnsupportedOptionPolicy {
255 Ignore,
256 #[default]
257 Warn,
258 Error,
259}
260
261impl std::str::FromStr for UnsupportedOptionPolicy {
262 type Err = &'static str;
263
264 fn from_str(value: &str) -> Result<Self, Self::Err> {
265 match normalize_enum_token(value).as_str() {
266 "ignore" => Ok(UnsupportedOptionPolicy::Ignore),
267 "warn" => Ok(UnsupportedOptionPolicy::Warn),
268 "error" => Ok(UnsupportedOptionPolicy::Error),
269 _ => Err("unsupported_option_policy must be 'ignore', 'warn', or 'error'"),
270 }
271 }
272}
273
274fn normalize_enum_token(value: &str) -> String {
275 value.trim().to_lowercase().replace('-', "_")
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
279#[serde(default, deny_unknown_fields)]
280pub struct RunnerCliConfigRoot {
281 pub defaults: RunnerCliOptionsPatch,
283
284 pub runners: BTreeMap<Runner, RunnerCliOptionsPatch>,
286}
287
288impl RunnerCliConfigRoot {
289 pub fn merge_from(&mut self, other: Self) {
290 self.defaults.merge_from(other.defaults);
291 for (runner, patch) in other.runners {
292 self.runners
293 .entry(runner)
294 .and_modify(|existing| existing.merge_from(patch.clone()))
295 .or_insert(patch);
296 }
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
301#[serde(default, deny_unknown_fields)]
302pub struct RunnerCliOptionsPatch {
303 pub output_format: Option<RunnerOutputFormat>,
305
306 pub verbosity: Option<RunnerVerbosity>,
308
309 pub approval_mode: Option<RunnerApprovalMode>,
311
312 pub sandbox: Option<RunnerSandboxMode>,
314
315 pub plan_mode: Option<RunnerPlanMode>,
317
318 pub unsupported_option_policy: Option<UnsupportedOptionPolicy>,
320}
321
322impl RunnerCliOptionsPatch {
323 pub fn merge_from(&mut self, other: Self) {
324 if other.output_format.is_some() {
325 self.output_format = other.output_format;
326 }
327 if other.verbosity.is_some() {
328 self.verbosity = other.verbosity;
329 }
330 if other.approval_mode.is_some() {
331 self.approval_mode = other.approval_mode;
332 }
333 if other.sandbox.is_some() {
334 self.sandbox = other.sandbox;
335 }
336 if other.plan_mode.is_some() {
337 self.plan_mode = other.plan_mode;
338 }
339 if other.unsupported_option_policy.is_some() {
340 self.unsupported_option_policy = other.unsupported_option_policy;
341 }
342 }
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
346#[serde(default, deny_unknown_fields)]
347pub struct MergeRunnerConfig {
348 pub runner: Option<Runner>,
349 pub model: Option<Model>,
350 pub reasoning_effort: Option<ReasoningEffort>,
351}
352
353#[allow(dead_code)]
354impl MergeRunnerConfig {
355 pub fn merge_from(&mut self, other: Self) {
356 if other.runner.is_some() {
357 self.runner = other.runner;
358 }
359 if other.model.is_some() {
360 self.model = other.model;
361 }
362 if other.reasoning_effort.is_some() {
363 self.reasoning_effort = other.reasoning_effort;
364 }
365 }
366}
367
368#[cfg(test)]
369mod tests;