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//!
11//! [`ConfigLoader::load`] — the active-profile entry point — memoises its
12//! result for the lifetime of the process. The explicit
13//! [`ConfigLoader::load_from_path`] and [`ConfigLoader::validate_file`] forms
14//! are one-shot reads of a caller-supplied file and are never cached.
15//!
16//! Copyright (c) systemprompt.io — Business Source License 1.1.
17//! See <https://systemprompt.io> for licensing details.
18
19mod discovery;
20mod includes;
21mod merge;
22mod types;
23
24use std::collections::{HashMap, HashSet};
25use std::fs;
26use std::path::{Path, PathBuf};
27use std::sync::{OnceLock, PoisonError, RwLock};
28
29use systemprompt_config::ProfileBootstrap;
30use systemprompt_models::services::ServicesConfig;
31
32use crate::error::{ConfigLoadError, ConfigLoadResult};
33
34use discovery::{discover_marketplaces, discover_plugins, discover_skills};
35use includes::resolve_includes_recursively;
36use merge::{resolve_skill_instruction_includes, resolve_system_prompt_includes};
37use types::IncludeResolveCtx;
38
39#[derive(Debug)]
40pub struct ConfigLoader {
41    base_path: PathBuf,
42    config_path: PathBuf,
43}
44
45impl ConfigLoader {
46    #[must_use]
47    pub fn new(config_path: PathBuf) -> Self {
48        let base_path = config_path
49            .parent()
50            .unwrap_or_else(|| Path::new("."))
51            .to_path_buf();
52        Self {
53            base_path,
54            config_path,
55        }
56    }
57
58    pub fn for_active_profile() -> ConfigLoadResult<Self> {
59        let profile = ProfileBootstrap::get()?;
60        let config_path = PathBuf::from(profile.paths.config());
61        Ok(Self::new(config_path))
62    }
63
64    /// Loads the active profile's services configuration.
65    ///
66    /// The result is cached for the lifetime of the process, keyed by the
67    /// resolved configuration path. Boot alone calls this dozens of times —
68    /// startup validation, the scheduler, the agent registry, and every MCP
69    /// deployment accessor — and each uncached call re-read the YAML, re-walked
70    /// the skills/plugins/marketplaces trees, and re-validated the whole file.
71    /// Configuration is fixed for the life of a deployment, so an edit requires
72    /// a restart to take effect.
73    pub fn load() -> ConfigLoadResult<ServicesConfig> {
74        Self::for_active_profile()?.run_cached()
75    }
76
77    /// Re-reads and re-validates the active profile's services configuration,
78    /// bypassing the [`Self::load`] cache and refreshing it with the result.
79    ///
80    /// Required by any command that validates its own write: the agent
81    /// create/edit/delete commands each load the configuration to resolve their
82    /// target before writing, so a cached [`Self::load`] afterwards would
83    /// return the pre-write state and the validation would silently pass.
84    pub fn reload() -> ConfigLoadResult<ServicesConfig> {
85        Self::for_active_profile()?.run_uncached()
86    }
87
88    fn run_uncached(&self) -> ConfigLoadResult<ServicesConfig> {
89        let config = self.run()?;
90        let key = fs::canonicalize(&self.config_path).unwrap_or_else(|_| self.config_path.clone());
91        cache_store(key, &config);
92        Ok(config)
93    }
94
95    /// [`Self::load`] against an explicit path, sharing the same process-wide
96    /// cache.
97    #[cfg(any(test, feature = "expose-internals"))]
98    pub fn load_cached_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
99        Self::new(path.to_path_buf()).run_cached()
100    }
101
102    /// [`Self::reload`] against an explicit path.
103    #[cfg(any(test, feature = "expose-internals"))]
104    pub fn reload_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
105        Self::new(path.to_path_buf()).run_uncached()
106    }
107
108    fn run_cached(&self) -> ConfigLoadResult<ServicesConfig> {
109        let key = fs::canonicalize(&self.config_path).unwrap_or_else(|_| self.config_path.clone());
110
111        if let Some(cached) = cache_read(&key) {
112            return Ok(cached);
113        }
114
115        let config = self.run()?;
116        cache_store(key, &config);
117        Ok(config)
118    }
119
120    pub fn load_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
121        Self::new(path.to_path_buf()).run()
122    }
123
124    #[cfg(any(test, feature = "expose-internals"))]
125    pub fn load_from_content(content: &str, path: &Path) -> ConfigLoadResult<ServicesConfig> {
126        Self::new(path.to_path_buf()).run_from_content(content)
127    }
128
129    pub fn validate_file(path: &Path) -> ConfigLoadResult<()> {
130        Self::load_from_path(path).map(|_| ())
131    }
132
133    fn run(&self) -> ConfigLoadResult<ServicesConfig> {
134        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
135            path: self.config_path.clone(),
136            source: e,
137        })?;
138        self.run_from_content(&content)
139    }
140
141    fn run_from_content(&self, content: &str) -> ConfigLoadResult<ServicesConfig> {
142        let mut merged: ServicesConfig =
143            serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
144                path: self.config_path.clone(),
145                source: e,
146            })?;
147
148        let includes = std::mem::take(&mut merged.includes);
149
150        let mut visited: HashSet<PathBuf> = HashSet::new();
151        if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
152            visited.insert(canonical_root);
153        }
154        {
155            let mut ctx = IncludeResolveCtx {
156                visited: &mut visited,
157                merged: &mut merged,
158                chain: vec![self.config_path.clone()],
159            };
160            for include_path in &includes {
161                resolve_includes_recursively(
162                    &self.base_path,
163                    include_path,
164                    &self.config_path,
165                    &mut ctx,
166                )?;
167            }
168        }
169
170        resolve_system_prompt_includes(&self.base_path, &mut merged)?;
171        resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
172
173        discover_skills(&self.base_path, &mut merged)?;
174        discover_plugins(&self.base_path, &mut merged)?;
175        discover_marketplaces(&self.base_path, &mut merged)?;
176
177        if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
178            merged.settings.services_path = Some(val);
179        }
180        if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
181            merged.settings.skills_path = Some(val);
182        }
183        if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
184            merged.settings.config_path = Some(val);
185        }
186
187        merged
188            .validate()
189            .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
190
191        Ok(merged)
192    }
193
194    pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
195        #[derive(serde::Deserialize)]
196        struct IncludesOnly {
197            #[serde(default)]
198            includes: Vec<String>,
199        }
200
201        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
202            path: self.config_path.clone(),
203            source: e,
204        })?;
205        let parsed: IncludesOnly =
206            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
207                path: self.config_path.clone(),
208                source: e,
209            })?;
210        Ok(parsed.includes)
211    }
212
213    pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
214        self.get_includes()?
215            .into_iter()
216            .map(|include| {
217                let exists = self.base_path.join(&include).exists();
218                Ok((include, exists))
219            })
220            .collect()
221    }
222
223    #[must_use]
224    pub fn base_path(&self) -> &Path {
225        &self.base_path
226    }
227}
228
229// Why: keyed by path rather than a bare `OnceLock` so that a process which
230// resolves more than one configuration — the CLI acting on an explicit path, a
231// test case pointed at its own temporary profile — cannot be served another
232// path's entry.
233static CONFIG_CACHE: OnceLock<RwLock<HashMap<PathBuf, ServicesConfig>>> = OnceLock::new();
234
235fn config_cache() -> &'static RwLock<HashMap<PathBuf, ServicesConfig>> {
236    CONFIG_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
237}
238
239fn cache_read(key: &Path) -> Option<ServicesConfig> {
240    config_cache()
241        .read()
242        .unwrap_or_else(PoisonError::into_inner)
243        .get(key)
244        .cloned()
245}
246
247fn cache_store(key: PathBuf, config: &ServicesConfig) {
248    config_cache()
249        .write()
250        .unwrap_or_else(PoisonError::into_inner)
251        .insert(key, config.clone());
252}