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> {
74 Self::for_active_profile()?.run_cached()
75 }
76
77 pub fn reload() -> ConfigLoadResult<ServicesConfig> {
85 Self::for_active_profile()?.run_uncached()
86 }
87
88 fn run_uncached(&self) -> ConfigLoadResult<ServicesConfig> {
89 let config = self.run()?;
90 let key = fs::canonicalize(&self.config_path).unwrap_or_else(|_| self.config_path.clone());
91 cache_store(key, &config);
92 Ok(config)
93 }
94
95 #[cfg(any(test, feature = "expose-internals"))]
98 pub fn load_cached_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
99 Self::new(path.to_path_buf()).run_cached()
100 }
101
102 #[cfg(any(test, feature = "expose-internals"))]
104 pub fn reload_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
105 Self::new(path.to_path_buf()).run_uncached()
106 }
107
108 fn run_cached(&self) -> ConfigLoadResult<ServicesConfig> {
109 let key = fs::canonicalize(&self.config_path).unwrap_or_else(|_| self.config_path.clone());
110
111 if let Some(cached) = cache_read(&key) {
112 return Ok(cached);
113 }
114
115 let config = self.run()?;
116 cache_store(key, &config);
117 Ok(config)
118 }
119
120 pub fn load_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
121 Self::new(path.to_path_buf()).run()
122 }
123
124 #[cfg(any(test, feature = "expose-internals"))]
125 pub fn load_from_content(content: &str, path: &Path) -> ConfigLoadResult<ServicesConfig> {
126 Self::new(path.to_path_buf()).run_from_content(content)
127 }
128
129 pub fn validate_file(path: &Path) -> ConfigLoadResult<()> {
130 Self::load_from_path(path).map(|_| ())
131 }
132
133 fn run(&self) -> ConfigLoadResult<ServicesConfig> {
134 let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
135 path: self.config_path.clone(),
136 source: e,
137 })?;
138 self.run_from_content(&content)
139 }
140
141 fn run_from_content(&self, content: &str) -> ConfigLoadResult<ServicesConfig> {
142 let mut merged: ServicesConfig =
143 serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
144 path: self.config_path.clone(),
145 source: e,
146 })?;
147
148 let includes = std::mem::take(&mut merged.includes);
149
150 let mut visited: HashSet<PathBuf> = HashSet::new();
151 if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
152 visited.insert(canonical_root);
153 }
154 {
155 let mut ctx = IncludeResolveCtx {
156 visited: &mut visited,
157 merged: &mut merged,
158 chain: vec![self.config_path.clone()],
159 };
160 for include_path in &includes {
161 resolve_includes_recursively(
162 &self.base_path,
163 include_path,
164 &self.config_path,
165 &mut ctx,
166 )?;
167 }
168 }
169
170 resolve_system_prompt_includes(&self.base_path, &mut merged)?;
171 resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
172
173 discover_skills(&self.base_path, &mut merged)?;
174 discover_plugins(&self.base_path, &mut merged)?;
175 discover_marketplaces(&self.base_path, &mut merged)?;
176
177 if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
178 merged.settings.services_path = Some(val);
179 }
180 if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
181 merged.settings.skills_path = Some(val);
182 }
183 if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
184 merged.settings.config_path = Some(val);
185 }
186
187 merged
188 .validate()
189 .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
190
191 Ok(merged)
192 }
193
194 pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
195 #[derive(serde::Deserialize)]
196 struct IncludesOnly {
197 #[serde(default)]
198 includes: Vec<String>,
199 }
200
201 let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
202 path: self.config_path.clone(),
203 source: e,
204 })?;
205 let parsed: IncludesOnly =
206 serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
207 path: self.config_path.clone(),
208 source: e,
209 })?;
210 Ok(parsed.includes)
211 }
212
213 pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
214 self.get_includes()?
215 .into_iter()
216 .map(|include| {
217 let exists = self.base_path.join(&include).exists();
218 Ok((include, exists))
219 })
220 .collect()
221 }
222
223 #[must_use]
224 pub fn base_path(&self) -> &Path {
225 &self.base_path
226 }
227}
228
229static CONFIG_CACHE: OnceLock<RwLock<HashMap<PathBuf, ServicesConfig>>> = OnceLock::new();
234
235fn config_cache() -> &'static RwLock<HashMap<PathBuf, ServicesConfig>> {
236 CONFIG_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
237}
238
239fn cache_read(key: &Path) -> Option<ServicesConfig> {
240 config_cache()
241 .read()
242 .unwrap_or_else(PoisonError::into_inner)
243 .get(key)
244 .cloned()
245}
246
247fn cache_store(key: PathBuf, config: &ServicesConfig) {
248 config_cache()
249 .write()
250 .unwrap_or_else(PoisonError::into_inner)
251 .insert(key, config.clone());
252}