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    pub fn load() -> ConfigLoadResult<ServicesConfig> {
65        Self::for_active_profile()?.run_cached()
66    }
67
68    pub fn reload() -> ConfigLoadResult<ServicesConfig> {
69        Self::for_active_profile()?.run_uncached()
70    }
71
72    fn run_uncached(&self) -> ConfigLoadResult<ServicesConfig> {
73        let config = self.run()?;
74        let key = fs::canonicalize(&self.config_path).unwrap_or_else(|_| self.config_path.clone());
75        cache_store(key, &config);
76        Ok(config)
77    }
78
79    #[cfg(any(test, feature = "expose-internals"))]
80    pub fn load_cached_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
81        Self::new(path.to_path_buf()).run_cached()
82    }
83
84    #[cfg(any(test, feature = "expose-internals"))]
85    pub fn reload_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
86        Self::new(path.to_path_buf()).run_uncached()
87    }
88
89    fn run_cached(&self) -> ConfigLoadResult<ServicesConfig> {
90        let key = self.cache_key();
91
92        if let Some(cached) = cache_read(&key) {
93            return Ok(cached);
94        }
95
96        let config = self.run()?;
97        cache_store(key, &config);
98        Ok(config)
99    }
100
101    // Why: canonicalising the file itself makes the cache key depend on the file
102    // still existing — `fs::canonicalize` fails once it is removed and the key
103    // silently falls back to the uncanonicalised path, missing the entry at the
104    // exact moment the cache is meant to cover for the missing file. Only the
105    // directory is resolved, which survives the file's removal and still folds
106    // away symlinked roots (on macOS `/var` vs `/private/var`).
107    fn cache_key(&self) -> PathBuf {
108        let Some(parent) = self.config_path.parent() else {
109            return self.config_path.clone();
110        };
111        let Some(name) = self.config_path.file_name() else {
112            return self.config_path.clone();
113        };
114        fs::canonicalize(parent).map_or_else(|_| self.config_path.clone(), |dir| dir.join(name))
115    }
116
117    pub fn load_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
118        Self::new(path.to_path_buf()).run()
119    }
120
121    #[cfg(any(test, feature = "expose-internals"))]
122    pub fn load_from_content(content: &str, path: &Path) -> ConfigLoadResult<ServicesConfig> {
123        Self::new(path.to_path_buf()).run_from_content(content)
124    }
125
126    pub fn validate_file(path: &Path) -> ConfigLoadResult<()> {
127        Self::load_from_path(path).map(|_| ())
128    }
129
130    fn run(&self) -> ConfigLoadResult<ServicesConfig> {
131        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
132            path: self.config_path.clone(),
133            source: e,
134        })?;
135        self.run_from_content(&content)
136    }
137
138    fn run_from_content(&self, content: &str) -> ConfigLoadResult<ServicesConfig> {
139        let mut merged: ServicesConfig =
140            serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
141                path: self.config_path.clone(),
142                source: e,
143            })?;
144
145        let includes = std::mem::take(&mut merged.includes);
146
147        let mut visited: HashSet<PathBuf> = HashSet::new();
148        if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
149            visited.insert(canonical_root);
150        }
151        {
152            let mut ctx = IncludeResolveCtx {
153                visited: &mut visited,
154                merged: &mut merged,
155                chain: vec![self.config_path.clone()],
156            };
157            for include_path in &includes {
158                resolve_includes_recursively(
159                    &self.base_path,
160                    include_path,
161                    &self.config_path,
162                    &mut ctx,
163                )?;
164            }
165        }
166
167        resolve_system_prompt_includes(&self.base_path, &mut merged)?;
168        resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
169
170        discover_skills(&self.base_path, &mut merged)?;
171        discover_plugins(&self.base_path, &mut merged)?;
172        discover_marketplaces(&self.base_path, &mut merged)?;
173
174        if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
175            merged.settings.services_path = Some(val);
176        }
177        if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
178            merged.settings.skills_path = Some(val);
179        }
180        if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
181            merged.settings.config_path = Some(val);
182        }
183
184        if let Ok(profile) = ProfileBootstrap::get()
185            && !profile.services.is_identity()
186        {
187            merged
188                .apply_port_offset(profile.services.port_offset)
189                .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
190        }
191
192        merged
193            .validate()
194            .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
195
196        Ok(merged)
197    }
198
199    pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
200        #[derive(serde::Deserialize)]
201        struct IncludesOnly {
202            #[serde(default)]
203            includes: Vec<String>,
204        }
205
206        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
207            path: self.config_path.clone(),
208            source: e,
209        })?;
210        let parsed: IncludesOnly =
211            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
212                path: self.config_path.clone(),
213                source: e,
214            })?;
215        Ok(parsed.includes)
216    }
217
218    pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
219        self.get_includes()?
220            .into_iter()
221            .map(|include| {
222                let exists = self.base_path.join(&include).exists();
223                Ok((include, exists))
224            })
225            .collect()
226    }
227
228    #[must_use]
229    pub fn base_path(&self) -> &Path {
230        &self.base_path
231    }
232}
233
234static CONFIG_CACHE: OnceLock<RwLock<HashMap<PathBuf, ServicesConfig>>> = OnceLock::new();
235
236fn config_cache() -> &'static RwLock<HashMap<PathBuf, ServicesConfig>> {
237    CONFIG_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
238}
239
240fn cache_read(key: &Path) -> Option<ServicesConfig> {
241    config_cache()
242        .read()
243        .unwrap_or_else(PoisonError::into_inner)
244        .get(key)
245        .cloned()
246}
247
248fn cache_store(key: PathBuf, config: &ServicesConfig) {
249    config_cache()
250        .write()
251        .unwrap_or_else(PoisonError::into_inner)
252        .insert(key, config.clone());
253}