Skip to main content

chant/config/
mod.rs

1//! Configuration management for chant projects.
2//!
3//! # Doc Audit
4//! - audited: 2026-01-25
5//! - docs: reference/config.md
6//! - ignore: false
7
8use anyhow::{Context, Result};
9use colored::Colorize;
10use serde::Deserialize;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use crate::provider::ProviderConfig;
15use crate::spec::split_frontmatter;
16
17pub mod defaults;
18pub mod providers;
19pub mod validation;
20
21pub use defaults::*;
22pub use providers::*;
23pub use validation::*;
24
25#[derive(Debug, Clone, Deserialize)]
26pub struct Config {
27    pub project: ProjectConfig,
28    #[serde(default)]
29    pub defaults: DefaultsConfig,
30    #[serde(default)]
31    pub providers: ProviderConfig,
32    #[serde(default)]
33    pub parallel: ParallelConfig,
34    #[serde(default)]
35    pub repos: Vec<RepoConfig>,
36    #[serde(default)]
37    pub enterprise: EnterpriseConfig,
38    #[serde(default)]
39    pub approval: ApprovalConfig,
40    #[serde(default)]
41    pub validation: OutputValidationConfig,
42    #[serde(default)]
43    pub site: SiteConfig,
44    #[serde(default)]
45    pub lint: LintConfig,
46    #[serde(default)]
47    pub watch: WatchConfig,
48}
49
50impl Config {
51    /// Load configuration with full merge semantics.
52    /// Merge order (later overrides earlier):
53    /// 1. Global config (~/.config/chant/config.md)
54    /// 2. Project config (.chant/config.md)
55    /// 3. Project agents config (.chant/agents.md) - only for parallel.agents
56    pub fn load() -> Result<Self> {
57        Self::load_merged_from(
58            global_config_path().as_deref(),
59            Path::new(".chant/config.md"),
60            Some(Path::new(".chant/agents.md")),
61        )
62    }
63
64    pub fn load_from(path: &Path) -> Result<Self> {
65        let content = fs::read_to_string(path)
66            .with_context(|| format!("Failed to read config from {}", path.display()))?;
67
68        Self::parse(&content)
69    }
70
71    pub fn parse(content: &str) -> Result<Self> {
72        // Extract YAML frontmatter using shared function
73        let (frontmatter, _body) = split_frontmatter(content);
74        let frontmatter = frontmatter.context("Failed to extract frontmatter from config")?;
75
76        let config: Config =
77            serde_yaml::from_str(&frontmatter).context("Failed to parse config frontmatter")?;
78
79        // Validate watch config
80        config.watch.validate()?;
81
82        Ok(config)
83    }
84
85    /// Load merged configuration from global and project configs.
86    /// Project config values override global config values.
87    pub fn load_merged() -> Result<Self> {
88        Self::load_merged_from(
89            global_config_path().as_deref(),
90            Path::new(".chant/config.md"),
91            Some(Path::new(".chant/agents.md")),
92        )
93    }
94
95    /// Load merged configuration from specified global, project, and agents config paths.
96    /// Merge order (later overrides earlier):
97    /// 1. Global config
98    /// 2. Project config
99    /// 3. Agents config (only for parallel.agents section)
100    pub fn load_merged_from(
101        global_path: Option<&Path>,
102        project_path: &Path,
103        agents_path: Option<&Path>,
104    ) -> Result<Self> {
105        // Load global config if it exists
106        let global_config = global_path
107            .filter(|p| p.exists())
108            .map(PartialConfig::load_from)
109            .transpose()?
110            .unwrap_or_default();
111
112        // Load project config as partial (required, but as partial for merging)
113        let project_config = PartialConfig::load_from(project_path)?;
114
115        // Load agents config if it exists (optional, gitignored)
116        let agents_config = agents_path
117            .filter(|p| p.exists())
118            .map(AgentsConfig::load_from)
119            .transpose()?;
120
121        // Merge: global < project < agents (for parallel.agents only)
122        let mut config = global_config.merge_with(project_config);
123
124        // Apply agents override if present
125        if let Some(agents) = agents_config {
126            if let Some(parallel) = agents.parallel {
127                if !parallel.agents.is_empty() {
128                    config.parallel.agents = parallel.agents;
129                }
130            }
131        }
132
133        // Warn if agents are configured but rotation strategy is "none"
134        if !config.parallel.agents.is_empty() && config.defaults.rotation_strategy == "none" {
135            eprintln!(
136                "{} parallel.agents configured but rotation_strategy is 'none' — agents will not be used. Set rotation_strategy: round-robin to enable.",
137                "Warning:".yellow()
138            );
139        }
140
141        Ok(config)
142    }
143}
144
145/// Returns the path to the global config file at ~/.config/chant/config.md
146pub fn global_config_path() -> Option<PathBuf> {
147    std::env::var("HOME")
148        .ok()
149        .map(|home| PathBuf::from(home).join(".config/chant/config.md"))
150}
151
152/// Agents-only config for project-specific agent overrides (.chant/agents.md)
153/// This file is gitignored and contains only the parallel.agents section
154#[derive(Debug, Deserialize, Default)]
155struct AgentsConfig {
156    pub parallel: Option<AgentsParallelConfig>,
157}
158
159/// Parallel config subset for agents.md - only contains agents list
160#[derive(Debug, Deserialize, Default)]
161struct AgentsParallelConfig {
162    #[serde(default)]
163    pub agents: Vec<AgentConfig>,
164}
165
166impl AgentsConfig {
167    fn load_from(path: &Path) -> Result<Self> {
168        let content = fs::read_to_string(path)
169            .with_context(|| format!("Failed to read agents config from {}", path.display()))?;
170
171        Self::parse(&content)
172    }
173
174    fn parse(content: &str) -> Result<Self> {
175        let (frontmatter, _body) = split_frontmatter(content);
176        let frontmatter =
177            frontmatter.context("Failed to extract frontmatter from agents config")?;
178
179        serde_yaml::from_str(&frontmatter).context("Failed to parse agents config frontmatter")
180    }
181}
182
183/// Partial config for merging - all fields optional
184#[derive(Debug, Deserialize, Default)]
185struct PartialConfig {
186    pub project: Option<PartialProjectConfig>,
187    pub defaults: Option<PartialDefaultsConfig>,
188    pub parallel: Option<ParallelConfig>,
189    pub repos: Option<Vec<RepoConfig>>,
190    pub enterprise: Option<EnterpriseConfig>,
191    pub approval: Option<ApprovalConfig>,
192    pub validation: Option<OutputValidationConfig>,
193    pub site: Option<SiteConfig>,
194    pub lint: Option<LintConfig>,
195    pub watch: Option<WatchConfig>,
196}
197
198#[derive(Debug, Deserialize, Default)]
199struct PartialProjectConfig {
200    pub name: Option<String>,
201    pub prefix: Option<String>,
202    pub silent: Option<bool>,
203}
204
205#[derive(Debug, Deserialize, Default)]
206struct PartialDefaultsConfig {
207    pub prompt: Option<String>,
208    pub branch_prefix: Option<String>,
209    pub model: Option<String>,
210    pub split_model: Option<String>,
211    pub main_branch: Option<String>,
212    pub provider: Option<crate::provider::ProviderType>,
213    pub rotation_strategy: Option<String>,
214    pub prompt_extensions: Option<Vec<String>>,
215}
216
217impl PartialConfig {
218    fn load_from(path: &Path) -> Result<Self> {
219        let content = fs::read_to_string(path)
220            .with_context(|| format!("Failed to read config from {}", path.display()))?;
221
222        Self::parse(&content)
223    }
224
225    fn parse(content: &str) -> Result<Self> {
226        let (frontmatter, _body) = split_frontmatter(content);
227        let frontmatter = frontmatter.context("Failed to extract frontmatter from config")?;
228
229        serde_yaml::from_str(&frontmatter).context("Failed to parse config frontmatter")
230    }
231
232    /// Merge this global config with a project config, returning the merged result.
233    /// Values from the project config take precedence over global.
234    fn merge_with(self, project: PartialConfig) -> Config {
235        let global_project = self.project.unwrap_or_default();
236        let global_defaults = self.defaults.unwrap_or_default();
237        let project_project = project.project.unwrap_or_default();
238        let project_defaults = project.defaults.unwrap_or_default();
239
240        Config {
241            project: ProjectConfig {
242                // Project name is required in project config
243                name: project_project.name.unwrap_or_default(),
244                // Project prefix overrides global prefix
245                prefix: project_project.prefix.or(global_project.prefix),
246                // Project silent overrides global silent
247                silent: project_project
248                    .silent
249                    .or(global_project.silent)
250                    .unwrap_or(false),
251            },
252            defaults: DefaultsConfig {
253                // Project value > global value > default
254                prompt: project_defaults
255                    .prompt
256                    .or(global_defaults.prompt)
257                    .unwrap_or_else(defaults::default_prompt),
258                branch_prefix: project_defaults
259                    .branch_prefix
260                    .or(global_defaults.branch_prefix)
261                    .unwrap_or_else(defaults::default_branch_prefix),
262                model: project_defaults.model.or(global_defaults.model),
263                split_model: project_defaults.split_model.or(global_defaults.split_model),
264                main_branch: project_defaults
265                    .main_branch
266                    .or(global_defaults.main_branch)
267                    .unwrap_or_else(defaults::default_main_branch),
268                provider: project_defaults
269                    .provider
270                    .or(global_defaults.provider)
271                    .unwrap_or_default(),
272                rotation_strategy: project_defaults
273                    .rotation_strategy
274                    .or(global_defaults.rotation_strategy)
275                    .unwrap_or_else(defaults::default_rotation_strategy),
276                prompt_extensions: project_defaults
277                    .prompt_extensions
278                    .or(global_defaults.prompt_extensions)
279                    .unwrap_or_default(),
280            },
281            providers: Default::default(),
282            // Parallel config: project overrides global, or use default
283            parallel: project.parallel.or(self.parallel).unwrap_or_default(),
284            // Repos: project overrides global, or use default
285            repos: project
286                .repos
287                .unwrap_or_else(|| self.repos.unwrap_or_default()),
288            // Enterprise config: project overrides global, or use default
289            enterprise: project.enterprise.or(self.enterprise).unwrap_or_default(),
290            // Approval config: project overrides global, or use default
291            approval: project.approval.or(self.approval).unwrap_or_default(),
292            // Validation config: project overrides global, or use default
293            validation: project.validation.or(self.validation).unwrap_or_default(),
294            // Site config: project overrides global, or use default
295            site: project.site.or(self.site).unwrap_or_default(),
296            // Lint config: project overrides global, or use default
297            lint: project.lint.or(self.lint).unwrap_or_default(),
298            // Watch config: project overrides global, or use default
299            watch: project.watch.or(self.watch).unwrap_or_default(),
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests;