Skip to main content

coda_core/
config.rs

1//! Configuration types for CODA projects.
2//!
3//! Defines `CodaConfig` which is loaded from `.coda/config.yml` in a
4//! user's repository. All fields use snake_case to match YAML conventions.
5
6use serde::{Deserialize, Serialize};
7
8/// Top-level CODA project configuration loaded from `.coda/config.yml`.
9///
10/// # Examples
11///
12/// ```
13/// use coda_core::CodaConfig;
14///
15/// // Create with defaults
16/// let config = CodaConfig::default();
17/// assert_eq!(config.version, 1);
18/// assert_eq!(config.agent.max_retries, 3);
19///
20/// // Deserialize from YAML
21/// let yaml = serde_yaml::to_string(&config).unwrap();
22/// let loaded: CodaConfig = serde_yaml::from_str(&yaml).unwrap();
23/// assert_eq!(loaded.version, config.version);
24/// ```
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(default)]
27pub struct CodaConfig {
28    /// Configuration schema version.
29    pub version: u32,
30
31    /// Agent behavior configuration.
32    pub agent: AgentConfig,
33
34    /// Precommit check commands run after each phase.
35    pub checks: Vec<String>,
36
37    /// Prompt template configuration.
38    pub prompts: PromptsConfig,
39
40    /// Git workflow configuration.
41    pub git: GitConfig,
42
43    /// Code review configuration.
44    pub review: ReviewConfig,
45}
46
47/// Agent configuration controlling model and budget limits.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(default)]
50pub struct AgentConfig {
51    /// Model identifier to use (e.g., `"claude-opus-4-6"`).
52    pub model: String,
53
54    /// Maximum budget in USD for a single `coda run` invocation.
55    pub max_budget_usd: f64,
56
57    /// Maximum retry attempts for a single phase on failure.
58    pub max_retries: u32,
59
60    /// Maximum agent conversation turns per phase.
61    pub max_turns: u32,
62}
63
64/// Configuration for prompt template directories.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(default)]
67pub struct PromptsConfig {
68    /// Additional directories to load custom prompt templates from.
69    /// Templates in these directories override built-in templates with
70    /// the same name.
71    pub extra_dirs: Vec<String>,
72}
73
74/// Git workflow configuration.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(default)]
77pub struct GitConfig {
78    /// Whether to automatically commit after each phase completes.
79    pub auto_commit: bool,
80
81    /// Prefix for feature branch names (e.g., `"feature"` produces
82    /// `feature/<slug>`).
83    pub branch_prefix: String,
84
85    /// Base branch to fork feature worktrees from (e.g., `"main"` or
86    /// `"master"`). When set to `"auto"`, the actual default branch is
87    /// detected at runtime via `git symbolic-ref`.
88    pub base_branch: String,
89}
90
91/// Code review configuration.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(default)]
94pub struct ReviewConfig {
95    /// Whether code review is enabled.
96    pub enabled: bool,
97
98    /// Maximum number of review rounds to prevent infinite loops.
99    pub max_review_rounds: u32,
100}
101
102impl Default for CodaConfig {
103    fn default() -> Self {
104        Self {
105            version: 1,
106            agent: AgentConfig::default(),
107            checks: vec![
108                "cargo build".to_string(),
109                "cargo +nightly fmt -- --check".to_string(),
110                "cargo clippy -- -D warnings".to_string(),
111            ],
112            prompts: PromptsConfig::default(),
113            git: GitConfig::default(),
114            review: ReviewConfig::default(),
115        }
116    }
117}
118
119impl Default for AgentConfig {
120    fn default() -> Self {
121        Self {
122            model: "claude-opus-4-6".to_string(),
123            max_budget_usd: 20.0,
124            max_retries: 3,
125            max_turns: 100,
126        }
127    }
128}
129
130impl Default for PromptsConfig {
131    fn default() -> Self {
132        Self {
133            extra_dirs: vec![".coda/prompts".to_string()],
134        }
135    }
136}
137
138impl Default for GitConfig {
139    fn default() -> Self {
140        Self {
141            auto_commit: true,
142            branch_prefix: "feature".to_string(),
143            base_branch: "auto".to_string(),
144        }
145    }
146}
147
148impl Default for ReviewConfig {
149    fn default() -> Self {
150        Self {
151            enabled: true,
152            max_review_rounds: 5,
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_should_create_default_config() {
163        let config = CodaConfig::default();
164        assert_eq!(config.version, 1);
165        assert_eq!(config.agent.max_budget_usd, 20.0);
166        assert_eq!(config.agent.max_retries, 3);
167        assert_eq!(config.checks.len(), 3);
168        assert!(config.git.auto_commit);
169        assert!(config.review.enabled);
170    }
171
172    #[test]
173    fn test_should_round_trip_yaml_serialization() {
174        let config = CodaConfig::default();
175        let yaml = serde_yaml::to_string(&config).unwrap();
176        let deserialized: CodaConfig = serde_yaml::from_str(&yaml).unwrap();
177        assert_eq!(deserialized.version, config.version);
178        assert_eq!(deserialized.agent.model, config.agent.model);
179        assert_eq!(deserialized.git.branch_prefix, config.git.branch_prefix);
180    }
181
182    #[test]
183    fn test_should_deserialize_custom_config() {
184        let yaml = r#"
185version: 2
186agent:
187  model: "claude-opus-4-20250514"
188  max_budget_usd: 50.0
189  max_retries: 5
190checks:
191  - "npm run build"
192  - "npm run lint"
193prompts:
194  extra_dirs:
195    - ".coda/custom-prompts"
196    - ".prompts"
197git:
198  auto_commit: false
199  branch_prefix: "dev"
200review:
201  enabled: false
202  max_review_rounds: 10
203"#;
204
205        let config: CodaConfig = serde_yaml::from_str(yaml).unwrap();
206        assert_eq!(config.version, 2);
207        assert_eq!(config.agent.model, "claude-opus-4-20250514");
208        assert!((config.agent.max_budget_usd - 50.0).abs() < f64::EPSILON);
209        assert_eq!(config.agent.max_retries, 5);
210        assert_eq!(config.checks.len(), 2);
211        assert_eq!(config.checks[0], "npm run build");
212        assert_eq!(config.prompts.extra_dirs.len(), 2);
213        assert!(!config.git.auto_commit);
214        assert_eq!(config.git.branch_prefix, "dev");
215        assert!(!config.review.enabled);
216        assert_eq!(config.review.max_review_rounds, 10);
217    }
218
219    #[test]
220    fn test_should_deserialize_partial_config_with_defaults() {
221        // Simulates a config.yml generated by the agent with missing/extra fields
222        let yaml = r#"
223version: 1
224agent:
225  model: "claude-sonnet-4-20250514"
226  permission_mode: "auto"
227  max_retries: 3
228prompts:
229  extra_dirs: []
230git:
231  auto_commit: true
232  branch_prefix: "feature"
233  commit_prefix: "feat"
234review:
235  enabled: true
236  checks:
237    - "cargo build"
238  max_review_rounds: 5
239"#;
240
241        let config: CodaConfig = serde_yaml::from_str(yaml).unwrap();
242        assert_eq!(config.version, 1);
243        // max_budget_usd missing → should use default 20.0
244        assert!((config.agent.max_budget_usd - 20.0).abs() < f64::EPSILON);
245        assert_eq!(config.agent.max_retries, 3);
246        // top-level checks missing → should use default
247        assert!(!config.checks.is_empty());
248        assert!(config.review.enabled);
249    }
250
251    #[test]
252    fn test_should_deserialize_minimal_config() {
253        let yaml = "version: 1\n";
254        let config: CodaConfig = serde_yaml::from_str(yaml).unwrap();
255        assert_eq!(config.version, 1);
256        assert_eq!(config.agent.model, "claude-opus-4-6");
257        assert!((config.agent.max_budget_usd - 20.0).abs() < f64::EPSILON);
258        assert!(config.git.auto_commit);
259    }
260}