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