Skip to main content

ralph/contracts/
runner.rs

1//! Runner-related configuration contracts.
2//!
3//! Responsibilities:
4//! - Define runner identity (`Runner`) as a string-serialized value (built-ins + plugins).
5//! - Define runner CLI normalization types (approval/sandbox/plan/etc).
6//!
7//! Not handled here:
8//! - Plugin discovery / registry (see `crate::plugins`).
9//! - Runner execution dispatch (see `crate::runner`).
10//!
11//! Invariants/assumptions:
12//! - `Runner` MUST serialize to a single string token for config/CLI stability.
13//! - Unknown tokens are treated as plugin runner ids (non-empty, trimmed).
14
15use 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    /// Returns the string representation of the runner.
38    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
87// Keep config/CLI stable: serialize as a single string token.
88impl 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
101// Schema: treat as string; docs enumerate built-ins, but allow arbitrary plugin ids.
102impl 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    /// Newline-delimited JSON objects (required for Ralph's streaming parser).
135    #[default]
136    StreamJson,
137    /// JSON output (may not be streaming; currently treated as unsupported by Ralph execution).
138    Json,
139    /// Plain text output (currently treated as unsupported by Ralph execution).
140    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    /// Do not apply any approval flags; runner defaults apply.
182    Default,
183    /// Attempt to auto-approve edits but not all tool actions (runner-specific).
184    AutoEdits,
185    /// Bypass approvals / run headless (runner-specific).
186    #[default]
187    Yolo,
188    /// Strict safety mode. Warning: some runners may become interactive and hang.
189    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    /// Default normalized runner CLI options applied to all runners (unless overridden).
280    pub defaults: RunnerCliOptionsPatch,
281
282    /// Optional per-runner overrides, merged leaf-wise over `defaults`.
283    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    /// Desired output format for runner execution.
302    pub output_format: Option<RunnerOutputFormat>,
303
304    /// Desired verbosity (when supported by the runner).
305    pub verbosity: Option<RunnerVerbosity>,
306
307    /// Desired approval/permission behavior.
308    pub approval_mode: Option<RunnerApprovalMode>,
309
310    /// Desired sandbox behavior (when supported by the runner).
311    pub sandbox: Option<RunnerSandboxMode>,
312
313    /// Desired plan/read-only behavior (when supported by the runner).
314    pub plan_mode: Option<RunnerPlanMode>,
315
316    /// Policy for unsupported options (warn/error/ignore).
317    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}