use serde::{Deserialize, Serialize};
use crate::core::Harness;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DispatchMechanism {
InSession,
Cli,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
#[value(rename_all = "kebab-case")]
pub enum RunMode {
Interactive,
Hybrid,
Headless,
}
impl RunMode {
pub fn mechanism(self) -> DispatchMechanism {
match self {
RunMode::Interactive => DispatchMechanism::InSession,
RunMode::Hybrid | RunMode::Headless => DispatchMechanism::Cli,
}
}
pub fn default_for(harness: Harness) -> RunMode {
match harness {
Harness::ClaudeCode => RunMode::Interactive,
Harness::Codex | Harness::OpenCode => RunMode::Hybrid,
}
}
pub fn as_str(self) -> &'static str {
match self {
RunMode::Interactive => "interactive",
RunMode::Hybrid => "hybrid",
RunMode::Headless => "headless",
}
}
}
pub fn resolve_run_mode(harness: Harness, requested: Option<RunMode>) -> Result<RunMode, String> {
let mode = requested.unwrap_or_else(|| RunMode::default_for(harness));
let supported: &[RunMode] = match harness {
Harness::ClaudeCode => &[RunMode::Interactive, RunMode::Hybrid, RunMode::Headless],
Harness::Codex => &[RunMode::Hybrid, RunMode::Headless],
Harness::OpenCode => &[RunMode::Hybrid],
};
if supported.contains(&mode) {
return Ok(mode);
}
let supported_list = supported
.iter()
.map(|m| m.as_str())
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"--run-mode {} is not supported for --harness {}; supported: {}",
mode.as_str(),
harness_label(harness),
supported_list,
))
}
fn harness_label(harness: Harness) -> &'static str {
match harness {
Harness::ClaudeCode => "claude-code",
Harness::Codex => "codex",
Harness::OpenCode => "opencode",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HarnessRunCapabilities {
pub mechanism: DispatchMechanism,
pub supports_guard: bool,
pub supports_bootstrap_with_no_stage: bool,
pub supports_stage_name_with_no_stage: bool,
}
pub fn capabilities_for(harness: Harness) -> HarnessRunCapabilities {
match harness {
Harness::ClaudeCode => HarnessRunCapabilities {
mechanism: DispatchMechanism::InSession,
supports_guard: true,
supports_bootstrap_with_no_stage: true,
supports_stage_name_with_no_stage: true,
},
Harness::Codex => HarnessRunCapabilities {
mechanism: DispatchMechanism::Cli,
supports_guard: true,
supports_bootstrap_with_no_stage: false,
supports_stage_name_with_no_stage: false,
},
Harness::OpenCode => HarnessRunCapabilities {
mechanism: DispatchMechanism::Cli,
supports_guard: false,
supports_bootstrap_with_no_stage: true,
supports_stage_name_with_no_stage: true,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capabilities_capture_run_option_support_by_harness() {
let claude = capabilities_for(Harness::ClaudeCode);
assert_eq!(claude.mechanism, DispatchMechanism::InSession);
assert!(claude.supports_guard);
assert!(claude.supports_bootstrap_with_no_stage);
assert!(claude.supports_stage_name_with_no_stage);
let codex = capabilities_for(Harness::Codex);
assert_eq!(codex.mechanism, DispatchMechanism::Cli);
assert!(codex.supports_guard);
assert!(!codex.supports_bootstrap_with_no_stage);
assert!(!codex.supports_stage_name_with_no_stage);
let opencode = capabilities_for(Harness::OpenCode);
assert_eq!(opencode.mechanism, DispatchMechanism::Cli);
assert!(!opencode.supports_guard);
assert!(opencode.supports_bootstrap_with_no_stage);
assert!(opencode.supports_stage_name_with_no_stage);
}
#[test]
fn run_mode_mechanism_maps_each_mode() {
assert_eq!(
RunMode::Interactive.mechanism(),
DispatchMechanism::InSession
);
assert_eq!(RunMode::Hybrid.mechanism(), DispatchMechanism::Cli);
assert_eq!(RunMode::Headless.mechanism(), DispatchMechanism::Cli);
}
#[test]
fn run_mode_default_per_harness_preserves_today() {
assert_eq!(
RunMode::default_for(Harness::ClaudeCode),
RunMode::Interactive
);
assert_eq!(RunMode::default_for(Harness::Codex), RunMode::Hybrid);
assert_eq!(RunMode::default_for(Harness::OpenCode), RunMode::Hybrid);
}
#[test]
fn resolve_run_mode_defaults_when_unspecified() {
assert_eq!(
resolve_run_mode(Harness::ClaudeCode, None).unwrap(),
RunMode::Interactive
);
assert_eq!(
resolve_run_mode(Harness::Codex, None).unwrap(),
RunMode::Hybrid
);
}
#[test]
fn resolve_run_mode_accepts_claude_hybrid() {
assert_eq!(
resolve_run_mode(Harness::ClaudeCode, Some(RunMode::Hybrid)).unwrap(),
RunMode::Hybrid
);
}
#[test]
fn resolve_run_mode_rejects_interactive_for_cli_harnesses() {
let err = resolve_run_mode(Harness::Codex, Some(RunMode::Interactive)).unwrap_err();
assert!(err.contains("interactive"), "message was: {err}");
assert!(err.contains("codex"), "message was: {err}");
assert!(resolve_run_mode(Harness::OpenCode, Some(RunMode::Interactive)).is_err());
}
#[test]
fn resolve_run_mode_accepts_claude_headless() {
assert_eq!(
resolve_run_mode(Harness::ClaudeCode, Some(RunMode::Headless)).unwrap(),
RunMode::Headless
);
}
#[test]
fn resolve_run_mode_accepts_codex_headless() {
assert_eq!(
resolve_run_mode(Harness::Codex, Some(RunMode::Headless)).unwrap(),
RunMode::Headless
);
}
#[test]
fn run_mode_serde_roundtrips_kebab_case() {
assert_eq!(
serde_json::to_string(&RunMode::Hybrid).unwrap(),
"\"hybrid\""
);
let parsed: RunMode = serde_json::from_str("\"headless\"").unwrap();
assert_eq!(parsed, RunMode::Headless);
}
}