Skip to main content

systemprompt_loader/config_loader/
mod.rs

1//! Reads, parses, and merges the active services configuration.
2//!
3//! [`ConfigLoader`] is the only public entry point. It resolves the active
4//! profile (via [`systemprompt_config::ProfileBootstrap`]) to a YAML path,
5//! parses the root file, recursively resolves the `includes:` graph
6//! (rejecting cycles and duplicate definitions), inlines `!include`
7//! references inside agent system prompts and skill instructions, and
8//! finally validates the merged configuration before returning it to the
9//! caller.
10
11mod includes;
12mod merge;
13mod types;
14
15use std::collections::HashSet;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use systemprompt_config::ProfileBootstrap;
20use systemprompt_models::services::{
21    MarketplaceConfigFile, PluginComponentRef, PluginConfigFile, ServicesConfig, SkillConfig,
22};
23use systemprompt_models::{DiskSkillConfig, SKILL_CONFIG_FILENAME};
24
25use crate::error::{ConfigLoadError, ConfigLoadResult};
26
27use includes::resolve_includes_recursively;
28use merge::{
29    resolve_skill_instruction_includes, resolve_system_prompt_includes,
30    warn_on_authored_card_skills,
31};
32use types::IncludeResolveCtx;
33
34#[derive(Debug)]
35pub struct ConfigLoader {
36    base_path: PathBuf,
37    config_path: PathBuf,
38}
39
40impl ConfigLoader {
41    #[must_use]
42    pub fn new(config_path: PathBuf) -> Self {
43        let base_path = config_path
44            .parent()
45            .unwrap_or_else(|| Path::new("."))
46            .to_path_buf();
47        Self {
48            base_path,
49            config_path,
50        }
51    }
52
53    pub fn for_active_profile() -> ConfigLoadResult<Self> {
54        let profile = ProfileBootstrap::get()?;
55        let config_path = PathBuf::from(profile.paths.config());
56        Ok(Self::new(config_path))
57    }
58
59    pub fn load() -> ConfigLoadResult<ServicesConfig> {
60        Self::for_active_profile()?.run()
61    }
62
63    pub fn load_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
64        Self::new(path.to_path_buf()).run()
65    }
66
67    #[cfg(any(test, feature = "expose-internals"))]
68    pub fn load_from_content(content: &str, path: &Path) -> ConfigLoadResult<ServicesConfig> {
69        Self::new(path.to_path_buf()).run_from_content(content)
70    }
71
72    pub fn validate_file(path: &Path) -> ConfigLoadResult<()> {
73        Self::load_from_path(path).map(|_| ())
74    }
75
76    fn run(&self) -> ConfigLoadResult<ServicesConfig> {
77        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
78            path: self.config_path.clone(),
79            source: e,
80        })?;
81        self.run_from_content(&content)
82    }
83
84    fn run_from_content(&self, content: &str) -> ConfigLoadResult<ServicesConfig> {
85        let mut merged: ServicesConfig =
86            serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
87                path: self.config_path.clone(),
88                source: e,
89            })?;
90
91        let includes = std::mem::take(&mut merged.includes);
92
93        let mut visited: HashSet<PathBuf> = HashSet::new();
94        if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
95            visited.insert(canonical_root);
96        }
97        {
98            let mut ctx = IncludeResolveCtx {
99                visited: &mut visited,
100                merged: &mut merged,
101                chain: vec![self.config_path.clone()],
102            };
103            for include_path in &includes {
104                resolve_includes_recursively(
105                    &self.base_path,
106                    include_path,
107                    &self.config_path,
108                    &mut ctx,
109                )?;
110            }
111        }
112
113        resolve_system_prompt_includes(&self.base_path, &mut merged)?;
114        resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
115        warn_on_authored_card_skills(&merged);
116
117        discover_skills(&self.base_path, &mut merged)?;
118        discover_plugins(&self.base_path, &mut merged)?;
119        discover_marketplaces(&self.base_path, &mut merged)?;
120
121        if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
122            merged.settings.services_path = Some(val);
123        }
124        if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
125            merged.settings.skills_path = Some(val);
126        }
127        if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
128            merged.settings.config_path = Some(val);
129        }
130
131        merged
132            .validate()
133            .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
134
135        Ok(merged)
136    }
137
138    pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
139        #[derive(serde::Deserialize)]
140        struct IncludesOnly {
141            #[serde(default)]
142            includes: Vec<String>,
143        }
144
145        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
146            path: self.config_path.clone(),
147            source: e,
148        })?;
149        let parsed: IncludesOnly =
150            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
151                path: self.config_path.clone(),
152                source: e,
153            })?;
154        Ok(parsed.includes)
155    }
156
157    pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
158        self.get_includes()?
159            .into_iter()
160            .map(|include| {
161                let exists = self.base_path.join(&include).exists();
162                Ok((include, exists))
163            })
164            .collect()
165    }
166
167    #[must_use]
168    pub fn base_path(&self) -> &Path {
169        &self.base_path
170    }
171}
172
173/// Walks `<services>/marketplaces/*/config.yaml`, parses each as a
174/// `MarketplaceConfigFile`, and inserts into `merged.marketplaces`.
175///
176/// `base_path` is the parent of the root `config.yaml` (i.e.
177/// `<services>/config`), so the marketplaces directory is its sibling.
178/// Walks `<services>/skills/<id>/config.yaml`, parses each as
179/// [`DiskSkillConfig`], and inserts a corresponding [`SkillConfig`] into
180/// `merged.skills.skills` so that marketplace/plugin `skills.include`
181/// references can be resolved at validation time.
182fn discover_skills(base_path: &Path, merged: &mut ServicesConfig) -> ConfigLoadResult<()> {
183    let Some(services_dir) = base_path.parent() else {
184        return Ok(());
185    };
186    let skills_dir = services_dir.join("skills");
187    if !skills_dir.exists() {
188        return Ok(());
189    }
190
191    let entries = fs::read_dir(&skills_dir).map_err(|e| ConfigLoadError::Io {
192        path: skills_dir.clone(),
193        source: e,
194    })?;
195
196    for entry in entries {
197        let entry = entry.map_err(|e| ConfigLoadError::Io {
198            path: skills_dir.clone(),
199            source: e,
200        })?;
201        let dir = entry.path();
202        if !dir.is_dir() {
203            continue;
204        }
205        let config_path = dir.join(SKILL_CONFIG_FILENAME);
206        if !config_path.exists() {
207            continue;
208        }
209
210        let content = fs::read_to_string(&config_path).map_err(|e| ConfigLoadError::Io {
211            path: config_path.clone(),
212            source: e,
213        })?;
214        let disk: DiskSkillConfig =
215            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
216                path: config_path.clone(),
217                source: e,
218            })?;
219
220        let key = disk.id.as_str().to_owned();
221        if merged.skills.skills.contains_key(&key) {
222            continue;
223        }
224        merged.skills.skills.insert(
225            key,
226            SkillConfig {
227                id: disk.id,
228                name: disk.name,
229                description: disk.description,
230                enabled: disk.enabled,
231                tags: disk.tags,
232                instructions: None,
233                assigned_agents: PluginComponentRef::default(),
234                mcp_servers: PluginComponentRef::default(),
235                model_config: None,
236            },
237        );
238    }
239
240    Ok(())
241}
242
243/// Walks `<services>/plugins/<id>/config.yaml`, parses each as
244/// [`PluginConfigFile`], and inserts the contained [`PluginConfig`] into
245/// `merged.plugins` so that marketplace `plugins.include` references and
246/// plugin-binding validation resolve at load time.
247fn discover_plugins(base_path: &Path, merged: &mut ServicesConfig) -> ConfigLoadResult<()> {
248    let Some(services_dir) = base_path.parent() else {
249        return Ok(());
250    };
251    let plugins_dir = services_dir.join("plugins");
252    if !plugins_dir.exists() {
253        return Ok(());
254    }
255
256    let entries = fs::read_dir(&plugins_dir).map_err(|e| ConfigLoadError::Io {
257        path: plugins_dir.clone(),
258        source: e,
259    })?;
260
261    for entry in entries {
262        let entry = entry.map_err(|e| ConfigLoadError::Io {
263            path: plugins_dir.clone(),
264            source: e,
265        })?;
266        let dir = entry.path();
267        if !dir.is_dir() {
268            continue;
269        }
270        let config_path = dir.join("config.yaml");
271        if !config_path.exists() {
272            continue;
273        }
274
275        let content = fs::read_to_string(&config_path).map_err(|e| ConfigLoadError::Io {
276            path: config_path.clone(),
277            source: e,
278        })?;
279        let file: PluginConfigFile =
280            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
281                path: config_path.clone(),
282                source: e,
283            })?;
284
285        let id = file.plugin.id.as_str().to_owned();
286        if merged.plugins.contains_key(&id) {
287            continue;
288        }
289        merged.plugins.insert(id, file.plugin);
290    }
291
292    Ok(())
293}
294
295fn discover_marketplaces(base_path: &Path, merged: &mut ServicesConfig) -> ConfigLoadResult<()> {
296    let Some(services_dir) = base_path.parent() else {
297        return Ok(());
298    };
299    let marketplaces_dir = services_dir.join("marketplaces");
300    if !marketplaces_dir.exists() {
301        return Ok(());
302    }
303
304    let entries = fs::read_dir(&marketplaces_dir).map_err(|e| ConfigLoadError::Io {
305        path: marketplaces_dir.clone(),
306        source: e,
307    })?;
308
309    for entry in entries {
310        let entry = entry.map_err(|e| ConfigLoadError::Io {
311            path: marketplaces_dir.clone(),
312            source: e,
313        })?;
314        let dir = entry.path();
315        if !dir.is_dir() {
316            continue;
317        }
318        let config_path = dir.join("config.yaml");
319        if !config_path.exists() {
320            continue;
321        }
322
323        let content = fs::read_to_string(&config_path).map_err(|e| ConfigLoadError::Io {
324            path: config_path.clone(),
325            source: e,
326        })?;
327        let file: MarketplaceConfigFile =
328            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
329                path: config_path.clone(),
330                source: e,
331            })?;
332
333        let id = file.marketplace.id.clone();
334        if merged.marketplaces.contains_key(&id) {
335            return Err(ConfigLoadError::DuplicateMarketplace(
336                id.as_str().to_owned(),
337            ));
338        }
339        merged.marketplaces.insert(id, file.marketplace);
340    }
341
342    Ok(())
343}