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
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    /// Returns the string representation of the runner.
43    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
92// Keep config/CLI stable: serialize as a single string token.
93impl 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
106// Schema: treat as string; docs enumerate built-ins, but allow arbitrary plugin ids.
107impl 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    /// Newline-delimited JSON objects (required for Ralph's streaming parser).
137    #[default]
138    StreamJson,
139    /// JSON output (may not be streaming; currently treated as unsupported by Ralph execution).
140    Json,
141    /// Plain text output (currently treated as unsupported by Ralph execution).
142    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    /// Do not apply any approval flags; runner defaults apply.
184    Default,
185    /// Attempt to auto-approve edits but not all tool actions (runner-specific).
186    AutoEdits,
187    /// Bypass approvals / run headless (runner-specific).
188    #[default]
189    Yolo,
190    /// Strict safety mode. Warning: some runners may become interactive and hang.
191    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    /// Default normalized runner CLI options applied to all runners (unless overridden).
282    pub defaults: RunnerCliOptionsPatch,
283
284    /// Optional per-runner overrides, merged leaf-wise over `defaults`.
285    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    /// Desired output format for runner execution.
304    pub output_format: Option<RunnerOutputFormat>,
305
306    /// Desired verbosity (when supported by the runner).
307    pub verbosity: Option<RunnerVerbosity>,
308
309    /// Desired approval/permission behavior.
310    pub approval_mode: Option<RunnerApprovalMode>,
311
312    /// Desired sandbox behavior (when supported by the runner).
313    pub sandbox: Option<RunnerSandboxMode>,
314
315    /// Desired plan/read-only behavior (when supported by the runner).
316    pub plan_mode: Option<RunnerPlanMode>,
317
318    /// Policy for unsupported options (warn/error/ignore).
319    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;