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