Skip to main content

ao_core/config/
power.rs

1//! Power-related config types: `PowerConfig`, `ScmWebhookConfig`,
2//! `PluginConfig`, `DefaultsConfig`, and `RoleAgentConfig`.
3
4use super::agent::{default_orchestrator_rules, AgentConfig};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8pub(super) fn default_runtime() -> String {
9    "tmux".into()
10}
11pub(super) fn default_agent() -> String {
12    "claude-code".into()
13}
14pub(super) fn default_workspace() -> String {
15    "worktree".into()
16}
17pub(super) fn default_tracker() -> String {
18    "github".into()
19}
20
21fn default_true() -> bool {
22    true
23}
24fn is_true(b: &bool) -> bool {
25    *b
26}
27
28fn default_prevent_idle_sleep() -> bool {
29    cfg!(target_os = "macos")
30}
31
32/// SCM webhook configuration (TS: `SCMWebhookConfig`).
33///
34/// `Default` sets `enabled = true`, matching the serde default and TS
35/// behaviour (`enabled: webhook?.enabled !== false`). A zero-value
36/// `Default` would silently disable webhooks for anyone constructing
37/// this struct in Rust, which is the opposite of what the YAML path does.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ScmWebhookConfig {
40    #[serde(default = "default_true", skip_serializing_if = "is_true")]
41    pub enabled: bool,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub path: Option<String>,
44    #[serde(
45        default,
46        skip_serializing_if = "Option::is_none",
47        rename = "secretEnvVar",
48        alias = "secret_env_var"
49    )]
50    pub secret_env_var: Option<String>,
51    #[serde(
52        default,
53        skip_serializing_if = "Option::is_none",
54        rename = "signatureHeader",
55        alias = "signature_header"
56    )]
57    pub signature_header: Option<String>,
58    #[serde(
59        default,
60        skip_serializing_if = "Option::is_none",
61        rename = "eventHeader",
62        alias = "event_header"
63    )]
64    pub event_header: Option<String>,
65    #[serde(
66        default,
67        skip_serializing_if = "Option::is_none",
68        rename = "deliveryHeader",
69        alias = "delivery_header"
70    )]
71    pub delivery_header: Option<String>,
72    #[serde(
73        default,
74        skip_serializing_if = "Option::is_none",
75        rename = "maxBodyBytes",
76        alias = "max_body_bytes"
77    )]
78    pub max_body_bytes: Option<u64>,
79}
80
81impl Default for ScmWebhookConfig {
82    fn default() -> Self {
83        Self {
84            enabled: true,
85            path: None,
86            secret_env_var: None,
87            signature_header: None,
88            event_header: None,
89            delivery_header: None,
90            max_body_bytes: None,
91        }
92    }
93}
94
95/// Shared plugin config shape (tracker/scm/notifier). Allows arbitrary extra keys.
96#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
97pub struct PluginConfig {
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub plugin: Option<String>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub package: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub path: Option<String>,
104    /// SCM-only: webhook configuration (TS: `scm.webhook`).
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub webhook: Option<ScmWebhookConfig>,
107    #[serde(flatten, default)]
108    pub extra: HashMap<String, serde_yaml::Value>,
109}
110
111/// Power management settings (TS: `power.preventIdleSleep`).
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct PowerConfig {
114    #[serde(
115        default = "default_prevent_idle_sleep",
116        rename = "preventIdleSleep",
117        alias = "prevent_idle_sleep"
118    )]
119    pub prevent_idle_sleep: bool,
120}
121
122impl Default for PowerConfig {
123    fn default() -> Self {
124        Self {
125            prevent_idle_sleep: cfg!(target_os = "macos"),
126        }
127    }
128}
129
130/// Per-role agent config (TS `orchestrator` / `worker` blocks).
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct RoleAgentConfig {
133    /// Override the agent plugin for this role (e.g. "claude-code", "codex", ...).
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub agent: Option<String>,
136
137    /// Role-specific agent config overrides.
138    #[serde(
139        default,
140        skip_serializing_if = "Option::is_none",
141        rename = "agent_config",
142        alias = "agentConfig"
143    )]
144    pub agent_config: Option<AgentConfig>,
145}
146
147/// Orchestrator-wide defaults for plugin selection.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct DefaultsConfig {
150    #[serde(default = "default_runtime")]
151    pub runtime: String,
152    #[serde(default = "default_agent")]
153    pub agent: String,
154    /// Role defaults (TS: `defaults.orchestrator`, `defaults.worker`).
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub orchestrator: Option<RoleAgentConfig>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub worker: Option<RoleAgentConfig>,
159    /// Default system rules for the orchestrator session (TS: `defaults.orchestratorRules`).
160    #[serde(
161        default,
162        skip_serializing_if = "Option::is_none",
163        rename = "orchestrator_rules",
164        alias = "orchestratorRules",
165        alias = "orchestrator-rules"
166    )]
167    pub orchestrator_rules: Option<String>,
168    #[serde(default = "default_workspace")]
169    pub workspace: String,
170    #[serde(default = "default_tracker")]
171    pub tracker: String,
172    /// Optional branch namespace/prefix for agent-created worktree branches.
173    ///
174    /// If set, `ao-rs spawn` will create branches like:
175    /// - `<branch_namespace>/<short_id>` (task-first)
176    /// - `<branch_namespace>/<short_id>/<issue_branch>` (issue-first)
177    ///
178    /// Example: `ao/agent/5c452025/feat-issue-30`.
179    #[serde(
180        default,
181        skip_serializing_if = "Option::is_none",
182        rename = "branch_namespace",
183        alias = "branchNamespace",
184        alias = "branch-namespace"
185    )]
186    pub branch_namespace: Option<String>,
187    #[serde(default)]
188    pub notifiers: Vec<String>,
189}
190
191impl Default for DefaultsConfig {
192    fn default() -> Self {
193        Self {
194            runtime: default_runtime(),
195            agent: default_agent(),
196            orchestrator: None,
197            worker: None,
198            orchestrator_rules: Some(default_orchestrator_rules().to_string()),
199            workspace: default_workspace(),
200            tracker: default_tracker(),
201            branch_namespace: None,
202            notifiers: vec![],
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn defaults_config_roundtrip() {
213        let dc = DefaultsConfig::default();
214        assert_eq!(dc.runtime, "tmux");
215        assert_eq!(dc.agent, "claude-code");
216        assert_eq!(dc.workspace, "worktree");
217        assert_eq!(dc.tracker, "github");
218        assert!(dc.notifiers.is_empty());
219
220        let yaml = serde_yaml::to_string(&dc).unwrap();
221        let dc2: DefaultsConfig = serde_yaml::from_str(&yaml).unwrap();
222        assert_eq!(dc, dc2);
223    }
224
225    #[test]
226    fn power_config_default_is_platform_aware() {
227        let pc = PowerConfig::default();
228        if cfg!(target_os = "macos") {
229            assert!(
230                pc.prevent_idle_sleep,
231                "macOS: prevent_idle_sleep should default to true"
232            );
233        } else {
234            assert!(
235                !pc.prevent_idle_sleep,
236                "non-macOS: prevent_idle_sleep should default to false"
237            );
238        }
239    }
240}