systemprompt_loader/config_loader/
mod.rs1mod 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 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}