systemprompt_loader/config_loader/
mod.rs1mod includes;
12mod merge;
13mod types;
14
15use std::collections::HashSet;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use systemprompt_config::ProfileBootstrap;
20use systemprompt_models::services::{
21 MarketplaceConfigFile, PluginComponentRef, PluginConfigFile, ServicesConfig, SkillConfig,
22};
23use systemprompt_models::{DiskSkillConfig, SKILL_CONFIG_FILENAME};
24
25use crate::error::{ConfigLoadError, ConfigLoadResult};
26
27use includes::resolve_includes_recursively;
28use merge::{
29 resolve_skill_instruction_includes, resolve_system_prompt_includes,
30 warn_on_authored_card_skills,
31};
32use types::IncludeResolveCtx;
33
34#[derive(Debug)]
35pub struct ConfigLoader {
36 base_path: PathBuf,
37 config_path: PathBuf,
38}
39
40impl ConfigLoader {
41 #[must_use]
42 pub fn new(config_path: PathBuf) -> Self {
43 let base_path = config_path
44 .parent()
45 .unwrap_or_else(|| Path::new("."))
46 .to_path_buf();
47 Self {
48 base_path,
49 config_path,
50 }
51 }
52
53 pub fn for_active_profile() -> ConfigLoadResult<Self> {
54 let profile = ProfileBootstrap::get()?;
55 let config_path = PathBuf::from(profile.paths.config());
56 Ok(Self::new(config_path))
57 }
58
59 pub fn load() -> ConfigLoadResult<ServicesConfig> {
60 Self::for_active_profile()?.run()
61 }
62
63 pub fn load_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
64 Self::new(path.to_path_buf()).run()
65 }
66
67 #[cfg(any(test, feature = "expose-internals"))]
68 pub fn load_from_content(content: &str, path: &Path) -> ConfigLoadResult<ServicesConfig> {
69 Self::new(path.to_path_buf()).run_from_content(content)
70 }
71
72 pub fn validate_file(path: &Path) -> ConfigLoadResult<()> {
73 Self::load_from_path(path).map(|_| ())
74 }
75
76 fn run(&self) -> ConfigLoadResult<ServicesConfig> {
77 let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
78 path: self.config_path.clone(),
79 source: e,
80 })?;
81 self.run_from_content(&content)
82 }
83
84 fn run_from_content(&self, content: &str) -> ConfigLoadResult<ServicesConfig> {
85 let mut merged: ServicesConfig =
86 serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
87 path: self.config_path.clone(),
88 source: e,
89 })?;
90
91 let includes = std::mem::take(&mut merged.includes);
92
93 let mut visited: HashSet<PathBuf> = HashSet::new();
94 if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
95 visited.insert(canonical_root);
96 }
97 {
98 let mut ctx = IncludeResolveCtx {
99 visited: &mut visited,
100 merged: &mut merged,
101 chain: vec![self.config_path.clone()],
102 };
103 for include_path in &includes {
104 resolve_includes_recursively(
105 &self.base_path,
106 include_path,
107 &self.config_path,
108 &mut ctx,
109 )?;
110 }
111 }
112
113 resolve_system_prompt_includes(&self.base_path, &mut merged)?;
114 resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
115 warn_on_authored_card_skills(&merged);
116
117 discover_skills(&self.base_path, &mut merged)?;
118 discover_plugins(&self.base_path, &mut merged)?;
119 discover_marketplaces(&self.base_path, &mut merged)?;
120
121 if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
122 merged.settings.services_path = Some(val);
123 }
124 if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
125 merged.settings.skills_path = Some(val);
126 }
127 if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
128 merged.settings.config_path = Some(val);
129 }
130
131 merged
132 .validate()
133 .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
134
135 Ok(merged)
136 }
137
138 pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
139 #[derive(serde::Deserialize)]
140 struct IncludesOnly {
141 #[serde(default)]
142 includes: Vec<String>,
143 }
144
145 let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
146 path: self.config_path.clone(),
147 source: e,
148 })?;
149 let parsed: IncludesOnly =
150 serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
151 path: self.config_path.clone(),
152 source: e,
153 })?;
154 Ok(parsed.includes)
155 }
156
157 pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
158 self.get_includes()?
159 .into_iter()
160 .map(|include| {
161 let exists = self.base_path.join(&include).exists();
162 Ok((include, exists))
163 })
164 .collect()
165 }
166
167 #[must_use]
168 pub fn base_path(&self) -> &Path {
169 &self.base_path
170 }
171}
172
173fn discover_skills(base_path: &Path, merged: &mut ServicesConfig) -> ConfigLoadResult<()> {
183 let Some(services_dir) = base_path.parent() else {
184 return Ok(());
185 };
186 let skills_dir = services_dir.join("skills");
187 if !skills_dir.exists() {
188 return Ok(());
189 }
190
191 let entries = fs::read_dir(&skills_dir).map_err(|e| ConfigLoadError::Io {
192 path: skills_dir.clone(),
193 source: e,
194 })?;
195
196 for entry in entries {
197 let entry = entry.map_err(|e| ConfigLoadError::Io {
198 path: skills_dir.clone(),
199 source: e,
200 })?;
201 let dir = entry.path();
202 if !dir.is_dir() {
203 continue;
204 }
205 let config_path = dir.join(SKILL_CONFIG_FILENAME);
206 if !config_path.exists() {
207 continue;
208 }
209
210 let content = fs::read_to_string(&config_path).map_err(|e| ConfigLoadError::Io {
211 path: config_path.clone(),
212 source: e,
213 })?;
214 let disk: DiskSkillConfig =
215 serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
216 path: config_path.clone(),
217 source: e,
218 })?;
219
220 let key = disk.id.as_str().to_owned();
221 if merged.skills.skills.contains_key(&key) {
222 continue;
223 }
224 merged.skills.skills.insert(
225 key,
226 SkillConfig {
227 id: disk.id,
228 name: disk.name,
229 description: disk.description,
230 enabled: disk.enabled,
231 tags: disk.tags,
232 instructions: None,
233 assigned_agents: PluginComponentRef::default(),
234 mcp_servers: PluginComponentRef::default(),
235 model_config: None,
236 },
237 );
238 }
239
240 Ok(())
241}
242
243fn discover_plugins(base_path: &Path, merged: &mut ServicesConfig) -> ConfigLoadResult<()> {
248 let Some(services_dir) = base_path.parent() else {
249 return Ok(());
250 };
251 let plugins_dir = services_dir.join("plugins");
252 if !plugins_dir.exists() {
253 return Ok(());
254 }
255
256 let entries = fs::read_dir(&plugins_dir).map_err(|e| ConfigLoadError::Io {
257 path: plugins_dir.clone(),
258 source: e,
259 })?;
260
261 for entry in entries {
262 let entry = entry.map_err(|e| ConfigLoadError::Io {
263 path: plugins_dir.clone(),
264 source: e,
265 })?;
266 let dir = entry.path();
267 if !dir.is_dir() {
268 continue;
269 }
270 let config_path = dir.join("config.yaml");
271 if !config_path.exists() {
272 continue;
273 }
274
275 let content = fs::read_to_string(&config_path).map_err(|e| ConfigLoadError::Io {
276 path: config_path.clone(),
277 source: e,
278 })?;
279 let file: PluginConfigFile =
280 serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
281 path: config_path.clone(),
282 source: e,
283 })?;
284
285 let id = file.plugin.id.as_str().to_owned();
286 if merged.plugins.contains_key(&id) {
287 continue;
288 }
289 merged.plugins.insert(id, file.plugin);
290 }
291
292 Ok(())
293}
294
295fn discover_marketplaces(base_path: &Path, merged: &mut ServicesConfig) -> ConfigLoadResult<()> {
296 let Some(services_dir) = base_path.parent() else {
297 return Ok(());
298 };
299 let marketplaces_dir = services_dir.join("marketplaces");
300 if !marketplaces_dir.exists() {
301 return Ok(());
302 }
303
304 let entries = fs::read_dir(&marketplaces_dir).map_err(|e| ConfigLoadError::Io {
305 path: marketplaces_dir.clone(),
306 source: e,
307 })?;
308
309 for entry in entries {
310 let entry = entry.map_err(|e| ConfigLoadError::Io {
311 path: marketplaces_dir.clone(),
312 source: e,
313 })?;
314 let dir = entry.path();
315 if !dir.is_dir() {
316 continue;
317 }
318 let config_path = dir.join("config.yaml");
319 if !config_path.exists() {
320 continue;
321 }
322
323 let content = fs::read_to_string(&config_path).map_err(|e| ConfigLoadError::Io {
324 path: config_path.clone(),
325 source: e,
326 })?;
327 let file: MarketplaceConfigFile =
328 serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
329 path: config_path.clone(),
330 source: e,
331 })?;
332
333 let id = file.marketplace.id.clone();
334 if merged.marketplaces.contains_key(&id) {
335 return Err(ConfigLoadError::DuplicateMarketplace(
336 id.as_str().to_owned(),
337 ));
338 }
339 merged.marketplaces.insert(id, file.marketplace);
340 }
341
342 Ok(())
343}