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