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() && 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
145pub 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#[derive(Debug, Deserialize, Default)]
155struct AgentsConfig {
156 pub parallel: Option<AgentsParallelConfig>,
157}
158
159#[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#[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 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 name: project_project.name.unwrap_or_default(),
244 prefix: project_project.prefix.or(global_project.prefix),
246 silent: project_project
248 .silent
249 .or(global_project.silent)
250 .unwrap_or(false),
251 },
252 defaults: DefaultsConfig {
253 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: project.parallel.or(self.parallel).unwrap_or_default(),
284 repos: project
286 .repos
287 .unwrap_or_else(|| self.repos.unwrap_or_default()),
288 enterprise: project.enterprise.or(self.enterprise).unwrap_or_default(),
290 approval: project.approval.or(self.approval).unwrap_or_default(),
292 validation: project.validation.or(self.validation).unwrap_or_default(),
294 site: project.site.or(self.site).unwrap_or_default(),
296 lint: project.lint.or(self.lint).unwrap_or_default(),
298 watch: project.watch.or(self.watch).unwrap_or_default(),
300 }
301 }
302}
303
304#[cfg(test)]
305mod tests;