systemprompt_loader/config_loader/
mod.rs1mod discovery;
30pub mod gateway;
31mod includes;
32mod merge;
33mod types;
34
35use std::collections::{HashMap, HashSet};
36use std::fs;
37use std::path::{Path, PathBuf};
38use std::sync::{OnceLock, PoisonError, RwLock};
39
40use systemprompt_config::ProfileBootstrap;
41use systemprompt_models::services::{ApiSurface, ServicesConfig};
42
43use crate::error::{ConfigLoadError, ConfigLoadResult};
44
45use discovery::{discover_marketplaces, discover_plugins, discover_skills};
46use includes::resolve_includes_recursively;
47use merge::{resolve_skill_instruction_includes, resolve_system_prompt_includes};
48use types::IncludeResolveCtx;
49
50#[derive(Debug)]
51pub struct ConfigLoader {
52 base_path: PathBuf,
53 config_path: PathBuf,
54}
55
56impl ConfigLoader {
57 #[must_use]
58 pub fn new(config_path: PathBuf) -> Self {
59 let base_path = config_path
60 .parent()
61 .unwrap_or_else(|| Path::new("."))
62 .to_path_buf();
63 Self {
64 base_path,
65 config_path,
66 }
67 }
68
69 pub fn for_active_profile() -> ConfigLoadResult<Self> {
70 let profile = ProfileBootstrap::get()?;
71 let config_path = PathBuf::from(profile.paths.config());
72 Ok(Self::new(config_path))
73 }
74
75 pub fn load() -> ConfigLoadResult<ServicesConfig> {
76 Self::for_active_profile()?.run_cached()
77 }
78
79 pub fn reload() -> ConfigLoadResult<ServicesConfig> {
80 Self::for_active_profile()?.run_uncached()
81 }
82
83 fn run_uncached(&self) -> ConfigLoadResult<ServicesConfig> {
84 let config = self.run()?;
85 let key = fs::canonicalize(&self.config_path).unwrap_or_else(|_| self.config_path.clone());
86 cache_store(key, &config);
87 Ok(config)
88 }
89
90 #[cfg(any(test, feature = "expose-internals"))]
91 pub fn load_cached_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
92 Self::new(path.to_path_buf()).run_cached()
93 }
94
95 #[cfg(any(test, feature = "expose-internals"))]
96 pub fn reload_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
97 Self::new(path.to_path_buf()).run_uncached()
98 }
99
100 fn run_cached(&self) -> ConfigLoadResult<ServicesConfig> {
101 let key = self.cache_key();
102
103 if let Some(cached) = cache_read(&key) {
104 return Ok(cached);
105 }
106
107 let config = self.run()?;
108 cache_store(key, &config);
109 Ok(config)
110 }
111
112 fn cache_key(&self) -> PathBuf {
119 let Some(parent) = self.config_path.parent() else {
120 return self.config_path.clone();
121 };
122 let Some(name) = self.config_path.file_name() else {
123 return self.config_path.clone();
124 };
125 fs::canonicalize(parent).map_or_else(|_| self.config_path.clone(), |dir| dir.join(name))
126 }
127
128 pub fn load_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
129 Self::new(path.to_path_buf()).run()
130 }
131
132 #[cfg(any(test, feature = "expose-internals"))]
133 pub fn load_from_content(content: &str, path: &Path) -> ConfigLoadResult<ServicesConfig> {
134 Self::new(path.to_path_buf()).run_from_content(content)
135 }
136
137 pub fn validate_file(path: &Path) -> ConfigLoadResult<()> {
138 Self::load_from_path(path).map(|_| ())
139 }
140
141 fn run(&self) -> ConfigLoadResult<ServicesConfig> {
142 let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
143 path: self.config_path.clone(),
144 source: e,
145 })?;
146 self.run_from_content(&content)
147 }
148
149 fn run_from_content(&self, content: &str) -> ConfigLoadResult<ServicesConfig> {
150 let mut merged: ServicesConfig =
151 serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
152 path: self.config_path.clone(),
153 source: e,
154 })?;
155
156 let includes = std::mem::take(&mut merged.includes);
157
158 let mut visited: HashSet<PathBuf> = HashSet::new();
159 if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
160 visited.insert(canonical_root);
161 }
162 {
163 let mut ctx = IncludeResolveCtx {
164 visited: &mut visited,
165 merged: &mut merged,
166 chain: vec![self.config_path.clone()],
167 };
168 for include_path in &includes {
169 resolve_includes_recursively(
170 &self.base_path,
171 include_path,
172 &self.config_path,
173 &mut ctx,
174 )?;
175 }
176 }
177
178 resolve_system_prompt_includes(&self.base_path, &mut merged)?;
179 resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
180 gateway::resolve_file_gateway_includes(&self.base_path, &mut merged)?;
181 gateway::project_gateway(&mut merged);
182
183 discover_skills(&self.base_path, &mut merged)?;
184 discover_plugins(&self.base_path, &mut merged)?;
185 discover_marketplaces(&self.base_path, &mut merged)?;
186
187 if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
188 merged.settings.services_path = Some(val);
189 }
190 if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
191 merged.settings.skills_path = Some(val);
192 }
193 if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
194 merged.settings.config_path = Some(val);
195 }
196
197 if let Ok(profile) = ProfileBootstrap::get()
198 && !profile.services.is_identity()
199 {
200 merged
201 .apply_port_offset(profile.services.port_offset)
202 .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
203 }
204
205 demote_providers_without_credentials(&mut merged);
206
207 merged
208 .validate()
209 .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
210
211 Ok(merged)
212 }
213
214 pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
215 #[derive(serde::Deserialize)]
216 struct IncludesOnly {
217 #[serde(default)]
218 includes: Vec<String>,
219 }
220
221 let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
222 path: self.config_path.clone(),
223 source: e,
224 })?;
225 let parsed: IncludesOnly =
226 serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
227 path: self.config_path.clone(),
228 source: e,
229 })?;
230 Ok(parsed.includes)
231 }
232
233 pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
234 self.get_includes()?
235 .into_iter()
236 .map(|include| {
237 let exists = self.base_path.join(&include).exists();
238 Ok((include, exists))
239 })
240 .collect()
241 }
242
243 #[must_use]
244 pub fn base_path(&self) -> &Path {
245 &self.base_path
246 }
247}
248
249static CONFIG_CACHE: OnceLock<RwLock<HashMap<PathBuf, ServicesConfig>>> = OnceLock::new();
250
251fn config_cache() -> &'static RwLock<HashMap<PathBuf, ServicesConfig>> {
252 CONFIG_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
253}
254
255fn cache_read(key: &Path) -> Option<ServicesConfig> {
256 config_cache()
257 .read()
258 .unwrap_or_else(PoisonError::into_inner)
259 .get(key)
260 .cloned()
261}
262
263fn cache_store(key: PathBuf, config: &ServicesConfig) {
264 config_cache()
265 .write()
266 .unwrap_or_else(PoisonError::into_inner)
267 .insert(key, config.clone());
268}
269
270fn demote_providers_without_credentials(config: &mut ServicesConfig) {
271 let Ok(secrets) = systemprompt_config::SecretsBootstrap::get() else {
272 return;
273 };
274
275 for provider in &mut config.providers.providers {
276 if !provider.surface.is_advertised() {
277 continue;
278 }
279 if secrets.get(provider.api_key_secret.as_str()).is_some() {
280 continue;
281 }
282 tracing::warn!(
286 provider = %provider.name.as_str(),
287 "provider has no credential in the secret store; its models will not be advertised"
288 );
289 provider.surface = ApiSurface::Backend;
290 }
291}