use crate::contracts::config::{
GitPublishMode, GitRevertMode, NotificationConfig, PhaseOverrides, RunnerRetryConfig,
ScanPromptVersion, WebhookConfig,
};
use crate::contracts::model::{Model, ReasoningEffort};
use crate::contracts::runner::{
ClaudePermissionMode, Runner, RunnerApprovalMode, RunnerCliConfigRoot, RunnerCliOptionsPatch,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct CiGateConfig {
pub enabled: Option<bool>,
pub argv: Option<Vec<String>>,
}
impl CiGateConfig {
pub fn is_enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
pub fn display_string(&self) -> String {
if !self.is_enabled() {
return "disabled".to_string();
}
if let Some(argv) = &self.argv {
return format_argv(argv);
}
"<unset>".to_string()
}
pub fn merge_from(&mut self, other: Self) {
if other.enabled.is_some() {
self.enabled = other.enabled;
}
if other.argv.is_some() {
self.argv = other.argv;
}
}
}
fn format_argv(argv: &[String]) -> String {
argv.iter()
.map(|part| {
if part.is_empty() {
"\"\"".to_string()
} else if part
.chars()
.any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '\\'))
{
format!("{part:?}")
} else {
part.clone()
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct AgentConfig {
pub runner: Option<Runner>,
pub model: Option<Model>,
pub reasoning_effort: Option<ReasoningEffort>,
#[schemars(range(min = 1))]
pub iterations: Option<u8>,
pub followup_reasoning_effort: Option<ReasoningEffort>,
pub codex_bin: Option<String>,
pub opencode_bin: Option<String>,
pub gemini_bin: Option<String>,
pub claude_bin: Option<String>,
pub cursor_sdk_node_bin: Option<String>,
#[serde(skip_serializing)]
#[schemars(skip)]
pub cursor_bin: Option<String>,
pub kimi_bin: Option<String>,
pub pi_bin: Option<String>,
pub claude_permission_mode: Option<ClaudePermissionMode>,
pub runner_cli: Option<RunnerCliConfigRoot>,
pub phase_overrides: Option<PhaseOverrides>,
pub instruction_files: Option<Vec<PathBuf>>,
pub repoprompt_plan_required: Option<bool>,
pub repoprompt_tool_injection: Option<bool>,
pub ci_gate: Option<CiGateConfig>,
pub git_revert_mode: Option<GitRevertMode>,
pub git_publish_mode: Option<GitPublishMode>,
#[schemars(range(min = 1, max = 3))]
pub phases: Option<u8>,
pub notification: NotificationConfig,
pub webhook: WebhookConfig,
#[schemars(range(min = 1))]
pub session_timeout_hours: Option<u64>,
pub scan_prompt_version: Option<ScanPromptVersion>,
pub runner_retry: RunnerRetryConfig,
}
impl AgentConfig {
pub fn effective_git_publish_mode(&self) -> Option<GitPublishMode> {
self.git_publish_mode
}
pub fn effective_runner_cli_patch_for_runner(&self, runner: &Runner) -> RunnerCliOptionsPatch {
let mut patch = self
.runner_cli
.as_ref()
.map(|root| root.defaults.clone())
.unwrap_or_default();
if let Some(root) = &self.runner_cli
&& let Some(runner_patch) = root.runners.get(runner)
{
patch.merge_from(runner_patch.clone());
}
patch
}
pub fn effective_approval_mode(&self) -> Option<RunnerApprovalMode> {
let runner = self.runner.clone().unwrap_or(Runner::Codex);
self.effective_runner_cli_patch_for_runner(&runner)
.approval_mode
}
pub fn ci_gate_enabled(&self) -> bool {
self.ci_gate
.as_ref()
.map(CiGateConfig::is_enabled)
.unwrap_or(true)
}
pub fn ci_gate_display_string(&self) -> String {
self.ci_gate
.as_ref()
.map(CiGateConfig::display_string)
.unwrap_or_else(|| "make ci".to_string())
}
pub fn merge_from(&mut self, other: Self) {
if other.runner.is_some() {
self.runner = other.runner;
}
if other.model.is_some() {
self.model = other.model;
}
if other.reasoning_effort.is_some() {
self.reasoning_effort = other.reasoning_effort;
}
if other.iterations.is_some() {
self.iterations = other.iterations;
}
if other.followup_reasoning_effort.is_some() {
self.followup_reasoning_effort = other.followup_reasoning_effort;
}
if other.codex_bin.is_some() {
self.codex_bin = other.codex_bin;
}
if other.opencode_bin.is_some() {
self.opencode_bin = other.opencode_bin;
}
if other.gemini_bin.is_some() {
self.gemini_bin = other.gemini_bin;
}
if other.claude_bin.is_some() {
self.claude_bin = other.claude_bin;
}
if other.cursor_sdk_node_bin.is_some() {
self.cursor_sdk_node_bin = other.cursor_sdk_node_bin;
}
if other.kimi_bin.is_some() {
self.kimi_bin = other.kimi_bin;
}
if other.pi_bin.is_some() {
self.pi_bin = other.pi_bin;
}
if other.phases.is_some() {
self.phases = other.phases;
}
if other.claude_permission_mode.is_some() {
self.claude_permission_mode = other.claude_permission_mode;
}
if let Some(other_runner_cli) = other.runner_cli {
match &mut self.runner_cli {
Some(existing) => existing.merge_from(other_runner_cli),
None => self.runner_cli = Some(other_runner_cli),
}
}
if let Some(other_phase_overrides) = other.phase_overrides {
match &mut self.phase_overrides {
Some(existing) => existing.merge_from(other_phase_overrides),
None => self.phase_overrides = Some(other_phase_overrides),
}
}
if other.instruction_files.is_some() {
self.instruction_files = other.instruction_files;
}
if other.repoprompt_plan_required.is_some() {
self.repoprompt_plan_required = other.repoprompt_plan_required;
}
if other.repoprompt_tool_injection.is_some() {
self.repoprompt_tool_injection = other.repoprompt_tool_injection;
}
if let Some(other_ci_gate) = other.ci_gate {
match &mut self.ci_gate {
Some(existing) => existing.merge_from(other_ci_gate),
None => self.ci_gate = Some(other_ci_gate),
}
}
if other.git_revert_mode.is_some() {
self.git_revert_mode = other.git_revert_mode;
}
if other.git_publish_mode.is_some() {
self.git_publish_mode = other.git_publish_mode;
}
self.notification.merge_from(other.notification);
self.webhook.merge_from(other.webhook);
if other.session_timeout_hours.is_some() {
self.session_timeout_hours = other.session_timeout_hours;
}
if other.scan_prompt_version.is_some() {
self.scan_prompt_version = other.scan_prompt_version;
}
self.runner_retry.merge_from(other.runner_retry);
}
}