1use serde::{Deserialize, Serialize};
22
23use crate::core::Harness;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DispatchMechanism {
29 InSession,
31 Cli,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
43#[serde(rename_all = "kebab-case")]
44#[value(rename_all = "kebab-case")]
45pub enum RunMode {
46 Interactive,
48 Hybrid,
51 Headless,
54}
55
56impl RunMode {
57 pub fn mechanism(self) -> DispatchMechanism {
59 match self {
60 RunMode::Interactive => DispatchMechanism::InSession,
61 RunMode::Hybrid | RunMode::Headless => DispatchMechanism::Cli,
62 }
63 }
64
65 pub fn default_for(harness: Harness) -> RunMode {
69 match harness {
70 Harness::ClaudeCode => RunMode::Interactive,
71 Harness::Codex | Harness::OpenCode => RunMode::Hybrid,
72 }
73 }
74
75 pub fn as_str(self) -> &'static str {
78 match self {
79 RunMode::Interactive => "interactive",
80 RunMode::Hybrid => "hybrid",
81 RunMode::Headless => "headless",
82 }
83 }
84}
85
86pub fn resolve_run_mode(harness: Harness, requested: Option<RunMode>) -> Result<RunMode, String> {
90 let mode = requested.unwrap_or_else(|| RunMode::default_for(harness));
91 let supported: &[RunMode] = match harness {
92 Harness::ClaudeCode => &[RunMode::Interactive, RunMode::Hybrid, RunMode::Headless],
95 Harness::Codex => &[RunMode::Hybrid, RunMode::Headless],
98 Harness::OpenCode => &[RunMode::Hybrid],
101 };
102 if supported.contains(&mode) {
103 return Ok(mode);
104 }
105 let supported_list = supported
106 .iter()
107 .map(|m| m.as_str())
108 .collect::<Vec<_>>()
109 .join(", ");
110 Err(format!(
111 "--run-mode {} is not supported for --harness {}; supported: {}",
112 mode.as_str(),
113 harness_label(harness),
114 supported_list,
115 ))
116}
117
118fn harness_label(harness: Harness) -> &'static str {
120 match harness {
121 Harness::ClaudeCode => "claude-code",
122 Harness::Codex => "codex",
123 Harness::OpenCode => "opencode",
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct HarnessRunCapabilities {
134 pub mechanism: DispatchMechanism,
135 pub supports_guard: bool,
136 pub supports_bootstrap_with_no_stage: bool,
137 pub supports_stage_name_with_no_stage: bool,
138}
139
140pub fn capabilities_for(harness: Harness) -> HarnessRunCapabilities {
142 match harness {
143 Harness::ClaudeCode => HarnessRunCapabilities {
144 mechanism: DispatchMechanism::InSession,
145 supports_guard: true,
146 supports_bootstrap_with_no_stage: true,
147 supports_stage_name_with_no_stage: true,
148 },
149 Harness::Codex => HarnessRunCapabilities {
150 mechanism: DispatchMechanism::Cli,
151 supports_guard: true,
152 supports_bootstrap_with_no_stage: false,
153 supports_stage_name_with_no_stage: false,
154 },
155 Harness::OpenCode => HarnessRunCapabilities {
156 mechanism: DispatchMechanism::Cli,
157 supports_guard: false,
158 supports_bootstrap_with_no_stage: true,
159 supports_stage_name_with_no_stage: true,
160 },
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn capabilities_capture_run_option_support_by_harness() {
170 let claude = capabilities_for(Harness::ClaudeCode);
171 assert_eq!(claude.mechanism, DispatchMechanism::InSession);
172 assert!(claude.supports_guard);
173 assert!(claude.supports_bootstrap_with_no_stage);
174 assert!(claude.supports_stage_name_with_no_stage);
175
176 let codex = capabilities_for(Harness::Codex);
177 assert_eq!(codex.mechanism, DispatchMechanism::Cli);
178 assert!(codex.supports_guard);
179 assert!(!codex.supports_bootstrap_with_no_stage);
180 assert!(!codex.supports_stage_name_with_no_stage);
181
182 let opencode = capabilities_for(Harness::OpenCode);
183 assert_eq!(opencode.mechanism, DispatchMechanism::Cli);
184 assert!(!opencode.supports_guard);
185 assert!(opencode.supports_bootstrap_with_no_stage);
186 assert!(opencode.supports_stage_name_with_no_stage);
187 }
188
189 #[test]
190 fn run_mode_mechanism_maps_each_mode() {
191 assert_eq!(
192 RunMode::Interactive.mechanism(),
193 DispatchMechanism::InSession
194 );
195 assert_eq!(RunMode::Hybrid.mechanism(), DispatchMechanism::Cli);
196 assert_eq!(RunMode::Headless.mechanism(), DispatchMechanism::Cli);
197 }
198
199 #[test]
200 fn run_mode_default_per_harness_preserves_today() {
201 assert_eq!(
202 RunMode::default_for(Harness::ClaudeCode),
203 RunMode::Interactive
204 );
205 assert_eq!(RunMode::default_for(Harness::Codex), RunMode::Hybrid);
206 assert_eq!(RunMode::default_for(Harness::OpenCode), RunMode::Hybrid);
207 }
208
209 #[test]
210 fn resolve_run_mode_defaults_when_unspecified() {
211 assert_eq!(
212 resolve_run_mode(Harness::ClaudeCode, None).unwrap(),
213 RunMode::Interactive
214 );
215 assert_eq!(
216 resolve_run_mode(Harness::Codex, None).unwrap(),
217 RunMode::Hybrid
218 );
219 }
220
221 #[test]
222 fn resolve_run_mode_accepts_claude_hybrid() {
223 assert_eq!(
224 resolve_run_mode(Harness::ClaudeCode, Some(RunMode::Hybrid)).unwrap(),
225 RunMode::Hybrid
226 );
227 }
228
229 #[test]
230 fn resolve_run_mode_rejects_interactive_for_cli_harnesses() {
231 let err = resolve_run_mode(Harness::Codex, Some(RunMode::Interactive)).unwrap_err();
232 assert!(err.contains("interactive"), "message was: {err}");
233 assert!(err.contains("codex"), "message was: {err}");
234 assert!(resolve_run_mode(Harness::OpenCode, Some(RunMode::Interactive)).is_err());
235 }
236
237 #[test]
238 fn resolve_run_mode_accepts_claude_headless() {
239 assert_eq!(
240 resolve_run_mode(Harness::ClaudeCode, Some(RunMode::Headless)).unwrap(),
241 RunMode::Headless
242 );
243 }
244
245 #[test]
246 fn resolve_run_mode_accepts_codex_headless() {
247 assert_eq!(
248 resolve_run_mode(Harness::Codex, Some(RunMode::Headless)).unwrap(),
249 RunMode::Headless
250 );
251 }
252
253 #[test]
254 fn run_mode_serde_roundtrips_kebab_case() {
255 assert_eq!(
256 serde_json::to_string(&RunMode::Hybrid).unwrap(),
257 "\"hybrid\""
258 );
259 let parsed: RunMode = serde_json::from_str("\"headless\"").unwrap();
260 assert_eq!(parsed, RunMode::Headless);
261 }
262}