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