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, skill instructions and gateway
8//! prompt overrides, projects the gateway spec to its resolved runtime form,
9//! and finally validates the merged configuration — provider registry and
10//! gateway references included — before returning it to the caller.
11//!
12//! [`ConfigLoader::load`] — the active-profile entry point — memoises its
13//! result for the lifetime of the process. The explicit
14//! [`ConfigLoader::load_from_path`] and [`ConfigLoader::validate_file`] forms
15//! are one-shot reads of a caller-supplied file and are never cached.
16//!
17//! Copyright (c) systemprompt.io — Business Source License 1.1.
18//! See <https://systemprompt.io> for licensing details.
19
20mod discovery;
21pub mod gateway;
22mod includes;
23mod merge;
24mod types;
25
26use std::collections::{HashMap, HashSet};
27use std::fs;
28use std::path::{Path, PathBuf};
29use std::sync::{OnceLock, PoisonError, RwLock};
30
31use systemprompt_config::ProfileBootstrap;
32use systemprompt_models::services::ServicesConfig;
33
34use crate::error::{ConfigLoadError, ConfigLoadResult};
35
36use discovery::{discover_marketplaces, discover_plugins, discover_skills};
37use includes::resolve_includes_recursively;
38use merge::{resolve_skill_instruction_includes, resolve_system_prompt_includes};
39use types::IncludeResolveCtx;
40
41#[derive(Debug)]
42pub struct ConfigLoader {
43    base_path: PathBuf,
44    config_path: PathBuf,
45}
46
47impl ConfigLoader {
48    #[must_use]
49    pub fn new(config_path: PathBuf) -> Self {
50        let base_path = config_path
51            .parent()
52            .unwrap_or_else(|| Path::new("."))
53            .to_path_buf();
54        Self {
55            base_path,
56            config_path,
57        }
58    }
59
60    pub fn for_active_profile() -> ConfigLoadResult<Self> {
61        let profile = ProfileBootstrap::get()?;
62        let config_path = PathBuf::from(profile.paths.config());
63        Ok(Self::new(config_path))
64    }
65
66    pub fn load() -> ConfigLoadResult<ServicesConfig> {
67        Self::for_active_profile()?.run_cached()
68    }
69
70    pub fn reload() -> ConfigLoadResult<ServicesConfig> {
71        Self::for_active_profile()?.run_uncached()
72    }
73
74    fn run_uncached(&self) -> ConfigLoadResult<ServicesConfig> {
75        let config = self.run()?;
76        let key = fs::canonicalize(&self.config_path).unwrap_or_else(|_| self.config_path.clone());
77        cache_store(key, &config);
78        Ok(config)
79    }
80
81    #[cfg(any(test, feature = "expose-internals"))]
82    pub fn load_cached_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
83        Self::new(path.to_path_buf()).run_cached()
84    }
85
86    #[cfg(any(test, feature = "expose-internals"))]
87    pub fn reload_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
88        Self::new(path.to_path_buf()).run_uncached()
89    }
90
91    fn run_cached(&self) -> ConfigLoadResult<ServicesConfig> {
92        let key = self.cache_key();
93
94        if let Some(cached) = cache_read(&key) {
95            return Ok(cached);
96        }
97
98        let config = self.run()?;
99        cache_store(key, &config);
100        Ok(config)
101    }
102
103    // Why: canonicalising the file itself makes the cache key depend on the file
104    // still existing — `fs::canonicalize` fails once it is removed and the key
105    // silently falls back to the uncanonicalised path, missing the entry at the
106    // exact moment the cache is meant to cover for the missing file. Only the
107    // directory is resolved, which survives the file's removal and still folds
108    // away symlinked roots (on macOS `/var` vs `/private/var`).
109    fn cache_key(&self) -> PathBuf {
110        let Some(parent) = self.config_path.parent() else {
111            return self.config_path.clone();
112        };
113        let Some(name) = self.config_path.file_name() else {
114            return self.config_path.clone();
115        };
116        fs::canonicalize(parent).map_or_else(|_| self.config_path.clone(), |dir| dir.join(name))
117    }
118
119    pub fn load_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
120        Self::new(path.to_path_buf()).run()
121    }
122
123    #[cfg(any(test, feature = "expose-internals"))]
124    pub fn load_from_content(content: &str, path: &Path) -> ConfigLoadResult<ServicesConfig> {
125        Self::new(path.to_path_buf()).run_from_content(content)
126    }
127
128    pub fn validate_file(path: &Path) -> ConfigLoadResult<()> {
129        Self::load_from_path(path).map(|_| ())
130    }
131
132    fn run(&self) -> ConfigLoadResult<ServicesConfig> {
133        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
134            path: self.config_path.clone(),
135            source: e,
136        })?;
137        self.run_from_content(&content)
138    }
139
140    fn run_from_content(&self, content: &str) -> ConfigLoadResult<ServicesConfig> {
141        let mut merged: ServicesConfig =
142            serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
143                path: self.config_path.clone(),
144                source: e,
145            })?;
146
147        let includes = std::mem::take(&mut merged.includes);
148
149        let mut visited: HashSet<PathBuf> = HashSet::new();
150        if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
151            visited.insert(canonical_root);
152        }
153        {
154            let mut ctx = IncludeResolveCtx {
155                visited: &mut visited,
156                merged: &mut merged,
157                chain: vec![self.config_path.clone()],
158            };
159            for include_path in &includes {
160                resolve_includes_recursively(
161                    &self.base_path,
162                    include_path,
163                    &self.config_path,
164                    &mut ctx,
165                )?;
166            }
167        }
168
169        resolve_system_prompt_includes(&self.base_path, &mut merged)?;
170        resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
171        gateway::resolve_file_gateway_includes(&self.base_path, &mut merged)?;
172        gateway::project_gateway(&mut merged);
173
174        discover_skills(&self.base_path, &mut merged)?;
175        discover_plugins(&self.base_path, &mut merged)?;
176        discover_marketplaces(&self.base_path, &mut merged)?;
177
178        if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
179            merged.settings.services_path = Some(val);
180        }
181        if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
182            merged.settings.skills_path = Some(val);
183        }
184        if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
185            merged.settings.config_path = Some(val);
186        }
187
188        if let Ok(profile) = ProfileBootstrap::get()
189            && !profile.services.is_identity()
190        {
191            merged
192                .apply_port_offset(profile.services.port_offset)
193                .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
194        }
195
196        merged
197            .validate()
198            .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
199
200        Ok(merged)
201    }
202
203    pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
204        #[derive(serde::Deserialize)]
205        struct IncludesOnly {
206            #[serde(default)]
207            includes: Vec<String>,
208        }
209
210        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
211            path: self.config_path.clone(),
212            source: e,
213        })?;
214        let parsed: IncludesOnly =
215            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
216                path: self.config_path.clone(),
217                source: e,
218            })?;
219        Ok(parsed.includes)
220    }
221
222    pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
223        self.get_includes()?
224            .into_iter()
225            .map(|include| {
226                let exists = self.base_path.join(&include).exists();
227                Ok((include, exists))
228            })
229            .collect()
230    }
231
232    #[must_use]
233    pub fn base_path(&self) -> &Path {
234        &self.base_path
235    }
236}
237
238static CONFIG_CACHE: OnceLock<RwLock<HashMap<PathBuf, ServicesConfig>>> = OnceLock::new();
239
240fn config_cache() -> &'static RwLock<HashMap<PathBuf, ServicesConfig>> {
241    CONFIG_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
242}
243
244fn cache_read(key: &Path) -> Option<ServicesConfig> {
245    config_cache()
246        .read()
247        .unwrap_or_else(PoisonError::into_inner)
248        .get(key)
249        .cloned()
250}
251
252fn cache_store(key: PathBuf, config: &ServicesConfig) {
253    config_cache()
254        .write()
255        .unwrap_or_else(PoisonError::into_inner)
256        .insert(key, config.clone());
257}