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::{MarketplaceConfigFile, ServicesConfig};
21
22use crate::error::{ConfigLoadError, ConfigLoadResult};
23
24use includes::resolve_includes_recursively;
25use merge::{resolve_skill_instruction_includes, resolve_system_prompt_includes};
26use types::IncludeResolveCtx;
27
28#[derive(Debug)]
29pub struct ConfigLoader {
30 base_path: PathBuf,
31 config_path: PathBuf,
32}
33
34impl ConfigLoader {
35 #[must_use]
36 pub fn new(config_path: PathBuf) -> Self {
37 let base_path = config_path
38 .parent()
39 .unwrap_or_else(|| Path::new("."))
40 .to_path_buf();
41 Self {
42 base_path,
43 config_path,
44 }
45 }
46
47 pub fn for_active_profile() -> ConfigLoadResult<Self> {
48 let profile = ProfileBootstrap::get()?;
49 let config_path = PathBuf::from(profile.paths.config());
50 Ok(Self::new(config_path))
51 }
52
53 pub fn load() -> ConfigLoadResult<ServicesConfig> {
54 Self::for_active_profile()?.run()
55 }
56
57 pub fn load_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
58 Self::new(path.to_path_buf()).run()
59 }
60
61 #[cfg(any(test, feature = "expose-internals"))]
62 pub fn load_from_content(content: &str, path: &Path) -> ConfigLoadResult<ServicesConfig> {
63 Self::new(path.to_path_buf()).run_from_content(content)
64 }
65
66 pub fn validate_file(path: &Path) -> ConfigLoadResult<()> {
67 Self::load_from_path(path).map(|_| ())
68 }
69
70 fn run(&self) -> ConfigLoadResult<ServicesConfig> {
71 let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
72 path: self.config_path.clone(),
73 source: e,
74 })?;
75 self.run_from_content(&content)
76 }
77
78 fn run_from_content(&self, content: &str) -> ConfigLoadResult<ServicesConfig> {
79 let mut merged: ServicesConfig =
80 serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
81 path: self.config_path.clone(),
82 source: e,
83 })?;
84
85 let includes = std::mem::take(&mut merged.includes);
86
87 let mut visited: HashSet<PathBuf> = HashSet::new();
88 if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
89 visited.insert(canonical_root);
90 }
91 {
92 let mut ctx = IncludeResolveCtx {
93 visited: &mut visited,
94 merged: &mut merged,
95 chain: vec![self.config_path.clone()],
96 };
97 for include_path in &includes {
98 resolve_includes_recursively(
99 &self.base_path,
100 include_path,
101 &self.config_path,
102 &mut ctx,
103 )?;
104 }
105 }
106
107 resolve_system_prompt_includes(&self.base_path, &mut merged)?;
108 resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
109
110 discover_marketplaces(&self.base_path, &mut merged)?;
111
112 if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
113 merged.settings.services_path = Some(val);
114 }
115 if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
116 merged.settings.skills_path = Some(val);
117 }
118 if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
119 merged.settings.config_path = Some(val);
120 }
121
122 merged
123 .validate()
124 .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
125
126 Ok(merged)
127 }
128
129 pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
130 #[derive(serde::Deserialize)]
131 struct IncludesOnly {
132 #[serde(default)]
133 includes: Vec<String>,
134 }
135
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 let parsed: IncludesOnly =
141 serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
142 path: self.config_path.clone(),
143 source: e,
144 })?;
145 Ok(parsed.includes)
146 }
147
148 pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
149 self.get_includes()?
150 .into_iter()
151 .map(|include| {
152 let exists = self.base_path.join(&include).exists();
153 Ok((include, exists))
154 })
155 .collect()
156 }
157
158 #[must_use]
159 pub fn base_path(&self) -> &Path {
160 &self.base_path
161 }
162}
163
164fn discover_marketplaces(base_path: &Path, merged: &mut ServicesConfig) -> ConfigLoadResult<()> {
170 let Some(services_dir) = base_path.parent() else {
171 return Ok(());
172 };
173 let marketplaces_dir = services_dir.join("marketplaces");
174 if !marketplaces_dir.exists() {
175 return Ok(());
176 }
177
178 let entries = fs::read_dir(&marketplaces_dir).map_err(|e| ConfigLoadError::Io {
179 path: marketplaces_dir.clone(),
180 source: e,
181 })?;
182
183 for entry in entries {
184 let entry = entry.map_err(|e| ConfigLoadError::Io {
185 path: marketplaces_dir.clone(),
186 source: e,
187 })?;
188 let dir = entry.path();
189 if !dir.is_dir() {
190 continue;
191 }
192 let config_path = dir.join("config.yaml");
193 if !config_path.exists() {
194 continue;
195 }
196
197 let content = fs::read_to_string(&config_path).map_err(|e| ConfigLoadError::Io {
198 path: config_path.clone(),
199 source: e,
200 })?;
201 let file: MarketplaceConfigFile =
202 serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
203 path: config_path.clone(),
204 source: e,
205 })?;
206
207 let id = file.marketplace.id.clone();
208 if merged.marketplaces.contains_key(&id) {
209 return Err(ConfigLoadError::DuplicateMarketplace(
210 id.as_str().to_owned(),
211 ));
212 }
213 merged.marketplaces.insert(id, file.marketplace);
214 }
215
216 Ok(())
217}