1use 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 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 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 config.watch.validate()?;
81
82 Ok(config)
83 }
84
85 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 pub fn load_merged_from(
101 global_path: Option<&Path>,
102 project_path: &Path,
103 agents_path: Option<&Path>,
104 ) -> Result<Self> {
105 let global_config = global_path
107 .filter(|p| p.exists())
108 .map(PartialConfig::load_from)
109 .transpose()?
110 .unwrap_or_default();
111
112 let project_config = PartialConfig::load_from(project_path)?;
114
115 let agents_config = agents_path
117 .filter(|p| p.exists())
118 .map(AgentsConfig::load_from)
119 .transpose()?;
120
121 let mut config = global_config.merge_with(project_config);
123
124 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 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
147pub 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#[derive(Debug, Deserialize, Default)]
157struct AgentsConfig {
158 pub parallel: Option<AgentsParallelConfig>,
159}
160
161#[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#[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 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 name: project_project.name.unwrap_or_default(),
246 prefix: project_project.prefix.or(global_project.prefix),
248 silent: project_project
250 .silent
251 .or(global_project.silent)
252 .unwrap_or(false),
253 },
254 defaults: DefaultsConfig {
255 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: project.parallel.or(self.parallel).unwrap_or_default(),
286 repos: project
288 .repos
289 .unwrap_or_else(|| self.repos.unwrap_or_default()),
290 enterprise: project.enterprise.or(self.enterprise).unwrap_or_default(),
292 approval: project.approval.or(self.approval).unwrap_or_default(),
294 validation: project.validation.or(self.validation).unwrap_or_default(),
296 site: project.site.or(self.site).unwrap_or_default(),
298 lint: project.lint.or(self.lint).unwrap_or_default(),
300 watch: project.watch.or(self.watch).unwrap_or_default(),
302 }
303 }
304}
305
306#[cfg(test)]
307mod tests;