Skip to main content

systemprompt_loader/
config_loader.rs

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}