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