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