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()
135            && config.defaults.rotation_strategy == defaults::RotationStrategy::None
136        {
137            eprintln!(
138                "{} parallel.agents configured but rotation_strategy is 'none' — agents will not be used. Set rotation_strategy: round_robin to enable.",
139                "Warning:".yellow()
140            );
141        }
142
143        Ok(config)
144    }
145}
146
147/// Returns the path to the global config file at ~/.config/chant/config.md
148pub fn global_config_path() -> Option<PathBuf> {
149    std::env::var("HOME")
150        .ok()
151        .map(|home| PathBuf::from(home).join(".config/chant/config.md"))
152}
153
154/// Agents-only config for project-specific agent overrides (.chant/agents.md)
155/// This file is gitignored and contains only the parallel.agents section
156#[derive(Debug, Deserialize, Default)]
157struct AgentsConfig {
158    pub parallel: Option<AgentsParallelConfig>,
159}
160
161/// Parallel config subset for agents.md - only contains agents list
162#[derive(Debug, Deserialize, Default)]
163struct AgentsParallelConfig {
164    #[serde(default)]
165    pub agents: Vec<AgentConfig>,
166}
167
168impl AgentsConfig {
169    fn load_from(path: &Path) -> Result<Self> {
170        let content = fs::read_to_string(path)
171            .with_context(|| format!("Failed to read agents config from {}", path.display()))?;
172
173        Self::parse(&content)
174    }
175
176    fn parse(content: &str) -> Result<Self> {
177        let (frontmatter, _body) = split_frontmatter(content);
178        let frontmatter =
179            frontmatter.context("Failed to extract frontmatter from agents config")?;
180
181        serde_yaml::from_str(&frontmatter).context("Failed to parse agents config frontmatter")
182    }
183}
184
185/// Partial config for merging - all fields optional
186#[derive(Debug, Deserialize, Default)]
187struct PartialConfig {
188    pub project: Option<PartialProjectConfig>,
189    pub defaults: Option<PartialDefaultsConfig>,
190    pub parallel: Option<ParallelConfig>,
191    pub repos: Option<Vec<RepoConfig>>,
192    pub enterprise: Option<EnterpriseConfig>,
193    pub approval: Option<ApprovalConfig>,
194    pub validation: Option<OutputValidationConfig>,
195    pub site: Option<SiteConfig>,
196    pub lint: Option<LintConfig>,
197    pub watch: Option<WatchConfig>,
198}
199
200#[derive(Debug, Deserialize, Default)]
201struct PartialProjectConfig {
202    pub name: Option<String>,
203    pub prefix: Option<String>,
204    pub silent: Option<bool>,
205}
206
207#[derive(Debug, Deserialize, Default)]
208struct PartialDefaultsConfig {
209    pub prompt: Option<String>,
210    pub branch_prefix: Option<String>,
211    pub model: Option<String>,
212    pub split_model: Option<String>,
213    pub main_branch: Option<String>,
214    pub provider: Option<crate::provider::ProviderType>,
215    pub rotation_strategy: Option<defaults::RotationStrategy>,
216    pub prompt_extensions: Option<Vec<String>>,
217}
218
219impl PartialConfig {
220    fn load_from(path: &Path) -> Result<Self> {
221        let content = fs::read_to_string(path)
222            .with_context(|| format!("Failed to read config from {}", path.display()))?;
223
224        Self::parse(&content)
225    }
226
227    fn parse(content: &str) -> Result<Self> {
228        let (frontmatter, _body) = split_frontmatter(content);
229        let frontmatter = frontmatter.context("Failed to extract frontmatter from config")?;
230
231        serde_yaml::from_str(&frontmatter).context("Failed to parse config frontmatter")
232    }
233
234    /// Merge this global config with a project config, returning the merged result.
235    /// Values from the project config take precedence over global.
236    fn merge_with(self, project: PartialConfig) -> Config {
237        let global_project = self.project.unwrap_or_default();
238        let global_defaults = self.defaults.unwrap_or_default();
239        let project_project = project.project.unwrap_or_default();
240        let project_defaults = project.defaults.unwrap_or_default();
241
242        Config {
243            project: ProjectConfig {
244                // Project name is required in project config
245                name: project_project.name.unwrap_or_default(),
246                // Project prefix overrides global prefix
247                prefix: project_project.prefix.or(global_project.prefix),
248                // Project silent overrides global silent
249                silent: project_project
250                    .silent
251                    .or(global_project.silent)
252                    .unwrap_or(false),
253            },
254            defaults: DefaultsConfig {
255                // Project value > global value > default
256                prompt: project_defaults
257                    .prompt
258                    .or(global_defaults.prompt)
259                    .unwrap_or_else(defaults::default_prompt),
260                branch_prefix: project_defaults
261                    .branch_prefix
262                    .or(global_defaults.branch_prefix)
263                    .unwrap_or_else(defaults::default_branch_prefix),
264                model: project_defaults.model.or(global_defaults.model),
265                split_model: project_defaults.split_model.or(global_defaults.split_model),
266                main_branch: project_defaults
267                    .main_branch
268                    .or(global_defaults.main_branch)
269                    .unwrap_or_else(defaults::default_main_branch),
270                provider: project_defaults
271                    .provider
272                    .or(global_defaults.provider)
273                    .unwrap_or_default(),
274                rotation_strategy: project_defaults
275                    .rotation_strategy
276                    .or(global_defaults.rotation_strategy)
277                    .unwrap_or_else(defaults::default_rotation_strategy_enum),
278                prompt_extensions: project_defaults
279                    .prompt_extensions
280                    .or(global_defaults.prompt_extensions)
281                    .unwrap_or_default(),
282            },
283            providers: Default::default(),
284            // Parallel config: project overrides global, or use default
285            parallel: project.parallel.or(self.parallel).unwrap_or_default(),
286            // Repos: project overrides global, or use default
287            repos: project
288                .repos
289                .unwrap_or_else(|| self.repos.unwrap_or_default()),
290            // Enterprise config: project overrides global, or use default
291            enterprise: project.enterprise.or(self.enterprise).unwrap_or_default(),
292            // Approval config: project overrides global, or use default
293            approval: project.approval.or(self.approval).unwrap_or_default(),
294            // Validation config: project overrides global, or use default
295            validation: project.validation.or(self.validation).unwrap_or_default(),
296            // Site config: project overrides global, or use default
297            site: project.site.or(self.site).unwrap_or_default(),
298            // Lint config: project overrides global, or use default
299            lint: project.lint.or(self.lint).unwrap_or_default(),
300            // Watch config: project overrides global, or use default
301            watch: project.watch.or(self.watch).unwrap_or_default(),
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests;