1use anyhow::{Context, Result};
2use std::collections::{HashMap, HashSet};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use systemprompt_models::AppPaths;
7use systemprompt_models::mcp::Deployment;
8use systemprompt_models::services::{
9 AgentConfig, AiConfig, ContentConfig, IncludableString, PartialServicesConfig, PluginConfig,
10 SchedulerConfig, ServicesConfig, Settings as ServicesSettings, SkillsConfig, WebConfig,
11};
12
13#[derive(Debug)]
14pub struct ConfigLoader {
15 base_path: PathBuf,
16 config_path: PathBuf,
17}
18
19#[derive(serde::Deserialize, Default)]
20#[serde(deny_unknown_fields)]
21struct RootConfig {
22 #[serde(default)]
23 includes: Vec<String>,
24 #[serde(default)]
25 agents: HashMap<String, AgentConfig>,
26 #[serde(default)]
27 mcp_servers: HashMap<String, Deployment>,
28 #[serde(default)]
29 settings: ServicesSettings,
30 #[serde(default)]
31 scheduler: Option<SchedulerConfig>,
32 #[serde(default)]
33 ai: Option<AiConfig>,
34 #[serde(default)]
35 web: Option<WebConfig>,
36 #[serde(default)]
37 plugins: HashMap<String, PluginConfig>,
38 #[serde(default)]
39 skills: SkillsConfig,
40 #[serde(default)]
41 content: ContentConfig,
42}
43
44#[derive(serde::Deserialize, Default)]
45#[serde(deny_unknown_fields)]
46struct PartialServicesFile {
47 #[serde(default)]
48 includes: Vec<String>,
49 #[serde(default)]
50 agents: HashMap<String, AgentConfig>,
51 #[serde(default)]
52 mcp_servers: HashMap<String, Deployment>,
53 #[serde(default)]
54 scheduler: Option<SchedulerConfig>,
55 #[serde(default)]
56 ai: Option<AiConfig>,
57 #[serde(default)]
58 web: Option<WebConfig>,
59 #[serde(default)]
60 plugins: HashMap<String, PluginConfig>,
61 #[serde(default)]
62 skills: SkillsConfig,
63 #[serde(default)]
64 content: ContentConfig,
65}
66
67impl PartialServicesFile {
68 fn into_partial_config(self) -> PartialServicesConfig {
69 PartialServicesConfig {
70 agents: self.agents,
71 mcp_servers: self.mcp_servers,
72 scheduler: self.scheduler,
73 ai: self.ai,
74 web: self.web,
75 plugins: self.plugins,
76 skills: self.skills,
77 content: self.content,
78 }
79 }
80}
81
82struct IncludeResolveCtx<'a> {
83 visited: &'a mut HashSet<PathBuf>,
84 merged: &'a mut ServicesConfig,
85 chain: Vec<PathBuf>,
86}
87
88impl ConfigLoader {
89 pub fn new(config_path: PathBuf) -> Self {
90 let base_path = config_path
91 .parent()
92 .unwrap_or_else(|| Path::new("."))
93 .to_path_buf();
94 Self {
95 base_path,
96 config_path,
97 }
98 }
99
100 pub fn from_env() -> Result<Self> {
101 let paths = AppPaths::get().map_err(|e| anyhow::anyhow!("{}", e))?;
102 let config_path = paths.system().settings().to_path_buf();
103 Ok(Self::new(config_path))
104 }
105
106 pub fn load() -> Result<ServicesConfig> {
107 Self::from_env()?.run()
108 }
109
110 pub fn load_from_path(path: &Path) -> Result<ServicesConfig> {
111 Self::new(path.to_path_buf()).run()
112 }
113
114 pub fn load_from_content(content: &str, path: &Path) -> Result<ServicesConfig> {
115 Self::new(path.to_path_buf()).run_from_content(content)
116 }
117
118 pub fn validate_file(path: &Path) -> Result<()> {
119 let _ = Self::load_from_path(path)?;
120 Ok(())
121 }
122
123 fn run(&self) -> Result<ServicesConfig> {
124 let content = fs::read_to_string(&self.config_path)
125 .with_context(|| format!("Failed to read config: {}", self.config_path.display()))?;
126 self.run_from_content(&content)
127 }
128
129 fn run_from_content(&self, content: &str) -> Result<ServicesConfig> {
130 let root: RootConfig = serde_yaml::from_str(content)
131 .with_context(|| format!("Failed to parse config: {}", self.config_path.display()))?;
132
133 let mut merged = ServicesConfig {
134 agents: root.agents,
135 mcp_servers: root.mcp_servers,
136 settings: root.settings,
137 scheduler: root.scheduler,
138 ai: root.ai.unwrap_or_else(AiConfig::default),
139 web: root.web,
140 plugins: root.plugins,
141 skills: root.skills,
142 content: root.content,
143 };
144
145 let mut visited: HashSet<PathBuf> = HashSet::new();
146 if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
147 visited.insert(canonical_root);
148 }
149 {
150 let mut ctx = IncludeResolveCtx {
151 visited: &mut visited,
152 merged: &mut merged,
153 chain: vec![self.config_path.clone()],
154 };
155 for include_path in &root.includes {
156 self.resolve_includes_recursively(include_path, &self.config_path, &mut ctx)?;
157 }
158 }
159
160 self.resolve_system_prompt_includes(&mut merged)?;
161 self.resolve_skill_instruction_includes(&mut merged)?;
162
163 merged.settings.apply_env_overrides();
164
165 merged
166 .validate()
167 .map_err(|e| anyhow::anyhow!("Services config validation failed: {}", e))?;
168
169 Ok(merged)
170 }
171
172 fn resolve_includes_recursively(
173 &self,
174 include_path: &str,
175 referrer: &Path,
176 ctx: &mut IncludeResolveCtx<'_>,
177 ) -> Result<()> {
178 let referrer_dir = referrer.parent().unwrap_or(&self.base_path);
179 let full_path = referrer_dir.join(include_path);
180
181 if !full_path.exists() {
182 anyhow::bail!(
183 "Include file not found: {}\nReferenced in: {}\nEither create the file or remove \
184 it from the includes list.",
185 full_path.display(),
186 referrer.display()
187 );
188 }
189
190 let canonical = fs::canonicalize(&full_path).with_context(|| {
191 format!(
192 "while loading include {} referenced from {}",
193 full_path.display(),
194 referrer.display()
195 )
196 })?;
197
198 if ctx.visited.contains(&canonical) {
199 let mut chain: Vec<String> =
200 ctx.chain.iter().map(|p| p.display().to_string()).collect();
201 chain.push(canonical.display().to_string());
202 anyhow::bail!("Include cycle detected: {}", chain.join(" -> "));
203 }
204 ctx.visited.insert(canonical.clone());
205
206 let content = fs::read_to_string(&canonical).with_context(|| {
207 format!(
208 "while loading include {} referenced from {}",
209 canonical.display(),
210 referrer.display()
211 )
212 })?;
213
214 let partial_file: PartialServicesFile =
215 serde_yaml::from_str(&content).with_context(|| {
216 format!(
217 "while loading include {} referenced from {}",
218 canonical.display(),
219 referrer.display()
220 )
221 })?;
222
223 ctx.chain.push(canonical.clone());
224 for nested in &partial_file.includes {
225 self.resolve_includes_recursively(nested, &canonical, ctx)?;
226 }
227 ctx.chain.pop();
228
229 let file_dir = canonical.parent().unwrap_or(&self.base_path).to_path_buf();
230 let mut partial = partial_file.into_partial_config();
231 Self::resolve_partial_includes(&mut partial, &file_dir)?;
232 Self::merge_partial(ctx.merged, partial)?;
233
234 Ok(())
235 }
236
237 fn resolve_partial_includes(
238 partial: &mut PartialServicesConfig,
239 base_dir: &Path,
240 ) -> Result<()> {
241 for (name, agent) in &mut partial.agents {
242 if let Some(ref system_prompt) = agent.metadata.system_prompt {
243 if let Some(include_path) = system_prompt.strip_prefix("!include ") {
244 let full_path = base_dir.join(include_path.trim());
245 let resolved = fs::read_to_string(&full_path).with_context(|| {
246 format!(
247 "Failed to resolve system_prompt include for agent '{name}': {}",
248 full_path.display()
249 )
250 })?;
251 agent.metadata.system_prompt = Some(resolved);
252 }
253 }
254 }
255
256 for (key, skill) in &mut partial.skills.skills {
257 let Some(instructions) = skill.instructions.as_ref() else {
258 continue;
259 };
260 if let IncludableString::Include { path } = instructions {
261 let full_path = base_dir.join(path.trim());
262 let resolved = fs::read_to_string(&full_path).with_context(|| {
263 format!(
264 "Failed to resolve instructions include for skill '{key}': {}",
265 full_path.display()
266 )
267 })?;
268 skill.instructions = Some(IncludableString::Inline(resolved));
269 }
270 }
271
272 Ok(())
273 }
274
275 fn merge_partial(target: &mut ServicesConfig, partial: PartialServicesConfig) -> Result<()> {
276 for (name, agent) in partial.agents {
277 if target.agents.contains_key(&name) {
278 anyhow::bail!("Duplicate agent definition: {name}");
279 }
280 target.agents.insert(name, agent);
281 }
282
283 for (name, mcp) in partial.mcp_servers {
284 if target.mcp_servers.contains_key(&name) {
285 anyhow::bail!("Duplicate MCP server definition: {name}");
286 }
287 target.mcp_servers.insert(name, mcp);
288 }
289
290 if partial.scheduler.is_some() && target.scheduler.is_none() {
291 target.scheduler = partial.scheduler;
292 }
293
294 if let Some(ai) = partial.ai {
295 if target.ai.providers.is_empty() && !ai.providers.is_empty() {
296 target.ai = ai;
297 } else {
298 for (name, provider) in ai.providers {
299 target.ai.providers.insert(name, provider);
300 }
301 }
302 }
303
304 if partial.web.is_some() {
305 target.web = partial.web;
306 }
307
308 for (name, plugin) in partial.plugins {
309 if target.plugins.contains_key(&name) {
310 anyhow::bail!("Duplicate plugin definition: {name}");
311 }
312 target.plugins.insert(name, plugin);
313 }
314
315 Self::merge_skills(target, partial.skills)?;
316 Self::merge_content(&mut target.content, partial.content)?;
317
318 Ok(())
319 }
320
321 fn merge_skills(target: &mut ServicesConfig, partial: SkillsConfig) -> Result<()> {
322 if partial.auto_discover {
323 target.skills.auto_discover = true;
324 }
325 if partial.skills_path.is_some() {
326 target.skills.skills_path = partial.skills_path;
327 }
328 for (id, skill) in partial.skills {
329 if target.skills.skills.contains_key(&id) {
330 anyhow::bail!("Duplicate skill definition: {id}");
331 }
332 target.skills.skills.insert(id, skill);
333 }
334 Ok(())
335 }
336
337 fn merge_content(target: &mut ContentConfig, partial: ContentConfig) -> Result<()> {
338 for (name, source) in partial.sources {
339 if target.sources.contains_key(&name) {
340 anyhow::bail!("Duplicate content source definition: {name}");
341 }
342 target.sources.insert(name, source);
343 }
344
345 for (name, source) in partial.raw.content_sources {
346 if target.raw.content_sources.contains_key(&name) {
347 anyhow::bail!("Duplicate content source definition: {name}");
348 }
349 target.raw.content_sources.insert(name, source);
350 }
351
352 for (name, category) in partial.raw.categories {
353 target.raw.categories.entry(name).or_insert(category);
354 }
355
356 if !partial.raw.metadata.default_author.is_empty() {
357 target.raw.metadata = partial.raw.metadata;
358 }
359
360 Ok(())
361 }
362
363 fn resolve_system_prompt_includes(&self, config: &mut ServicesConfig) -> Result<()> {
364 for (name, agent) in &mut config.agents {
365 if let Some(ref system_prompt) = agent.metadata.system_prompt {
366 if let Some(include_path) = system_prompt.strip_prefix("!include ") {
367 let full_path = self.base_path.join(include_path.trim());
368 let resolved = fs::read_to_string(&full_path).with_context(|| {
369 format!(
370 "Failed to resolve system_prompt include for agent '{name}': {}",
371 full_path.display()
372 )
373 })?;
374 agent.metadata.system_prompt = Some(resolved);
375 }
376 }
377 }
378
379 Ok(())
380 }
381
382 fn resolve_skill_instruction_includes(&self, config: &mut ServicesConfig) -> Result<()> {
383 for (key, skill) in &mut config.skills.skills {
384 let Some(instructions) = skill.instructions.as_ref() else {
385 continue;
386 };
387 if let IncludableString::Include { path } = instructions {
388 let full_path = self.base_path.join(path.trim());
389 let resolved = fs::read_to_string(&full_path).with_context(|| {
390 format!(
391 "Failed to resolve instructions include for skill '{key}': {}",
392 full_path.display()
393 )
394 })?;
395 skill.instructions = Some(IncludableString::Inline(resolved));
396 }
397 }
398 Ok(())
399 }
400
401 pub fn get_includes(&self) -> Result<Vec<String>> {
402 #[derive(serde::Deserialize)]
403 struct IncludesOnly {
404 #[serde(default)]
405 includes: Vec<String>,
406 }
407
408 let content = fs::read_to_string(&self.config_path)?;
409 let parsed: IncludesOnly = serde_yaml::from_str(&content)?;
410 Ok(parsed.includes)
411 }
412
413 pub fn list_all_includes(&self) -> Result<Vec<(String, bool)>> {
414 self.get_includes()?
415 .into_iter()
416 .map(|include| {
417 let exists = self.base_path.join(&include).exists();
418 Ok((include, exists))
419 })
420 .collect()
421 }
422
423 pub fn base_path(&self) -> &Path {
424 &self.base_path
425 }
426}