1use 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 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 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 config.watch.validate()?;
80
81 Ok(config)
82 }
83
84 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 pub fn load_merged_from(
100 global_path: Option<&Path>,
101 project_path: &Path,
102 agents_path: Option<&Path>,
103 ) -> Result<Self> {
104 let global_config = global_path
106 .filter(|p| p.exists())
107 .map(PartialConfig::load_from)
108 .transpose()?
109 .unwrap_or_default();
110
111 let project_config = PartialConfig::load_from(project_path)?;
113
114 let agents_config = agents_path
116 .filter(|p| p.exists())
117 .map(AgentsConfig::load_from)
118 .transpose()?;
119
120 let mut config = global_config.merge_with(project_config);
122
123 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
136pub 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#[derive(Debug, Deserialize, Default)]
146struct AgentsConfig {
147 pub parallel: Option<AgentsParallelConfig>,
148}
149
150#[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#[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 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 name: project_project.name.unwrap_or_default(),
235 prefix: project_project.prefix.or(global_project.prefix),
237 silent: project_project
239 .silent
240 .or(global_project.silent)
241 .unwrap_or(false),
242 },
243 defaults: DefaultsConfig {
244 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: project.parallel.or(self.parallel).unwrap_or_default(),
275 repos: project
277 .repos
278 .unwrap_or_else(|| self.repos.unwrap_or_default()),
279 enterprise: project.enterprise.or(self.enterprise).unwrap_or_default(),
281 approval: project.approval.or(self.approval).unwrap_or_default(),
283 validation: project.validation.or(self.validation).unwrap_or_default(),
285 site: project.site.or(self.site).unwrap_or_default(),
287 lint: project.lint.or(self.lint).unwrap_or_default(),
289 watch: project.watch.or(self.watch).unwrap_or_default(),
291 }
292 }
293}
294
295#[cfg(test)]
296mod tests;