systemprompt_loader/config_loader/
mod.rs1pub mod catalog;
25mod discovery;
26pub mod gateway;
27mod includes;
28mod merge;
29mod types;
30
31use std::collections::HashSet;
32use std::fs;
33use std::path::{Path, PathBuf};
34
35use systemprompt_manifest::profile::Profile;
36use systemprompt_manifest::services::ServicesConfig;
37
38use crate::error::{ConfigLoadError, ConfigLoadResult};
39
40use discovery::{discover_marketplaces, discover_plugins, discover_skills};
41use includes::resolve_includes_recursively;
42use merge::{resolve_skill_instruction_includes, resolve_system_prompt_includes};
43use types::IncludeResolveCtx;
44
45#[derive(Debug)]
46pub struct ConfigLoader {
47 base_path: PathBuf,
48 config_path: PathBuf,
49 port_offset: Option<u16>,
50}
51
52impl ConfigLoader {
53 #[must_use]
54 pub fn new(config_path: PathBuf) -> Self {
55 let base_path = config_path
56 .parent()
57 .unwrap_or_else(|| Path::new("."))
58 .to_path_buf();
59 Self {
60 base_path,
61 config_path,
62 port_offset: None,
63 }
64 }
65
66 #[must_use]
67 pub fn for_profile(profile: &Profile, services_root: &Path) -> Self {
68 Self::new(services_root.join("config").join("config.yaml")).with_profile(Some(profile))
69 }
70
71 #[must_use]
72 pub fn with_profile(mut self, profile: Option<&Profile>) -> Self {
73 self.port_offset = profile
74 .filter(|profile| !profile.services.is_identity())
75 .map(|profile| profile.services.port_offset);
76 self
77 }
78
79 pub fn load(self) -> ConfigLoadResult<ServicesConfig> {
80 self.run()
81 }
82
83 pub fn load_from_path(
84 path: &Path,
85 profile: Option<&Profile>,
86 ) -> ConfigLoadResult<ServicesConfig> {
87 Self::new(path.to_path_buf()).with_profile(profile).run()
88 }
89
90 pub fn load_from_content(
91 content: &str,
92 path: &Path,
93 profile: Option<&Profile>,
94 ) -> ConfigLoadResult<ServicesConfig> {
95 Self::new(path.to_path_buf())
96 .with_profile(profile)
97 .run_from_content(content)
98 }
99
100 pub fn validate_file(path: &Path, profile: Option<&Profile>) -> ConfigLoadResult<()> {
101 Self::load_from_path(path, profile).map(|_| ())
102 }
103
104 fn run(&self) -> ConfigLoadResult<ServicesConfig> {
105 let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
106 path: self.config_path.clone(),
107 source: e,
108 })?;
109 self.run_from_content(&content)
110 }
111
112 fn run_from_content(&self, content: &str) -> ConfigLoadResult<ServicesConfig> {
113 catalog::refuse_legacy(&self.config_path, content)?;
114 let mut merged: ServicesConfig =
115 serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
116 path: self.config_path.clone(),
117 source: e,
118 })?;
119 catalog::refuse_catalog_sections(&self.config_path, &merged)?;
120
121 let includes = std::mem::take(&mut merged.includes);
122
123 let mut visited: HashSet<PathBuf> = HashSet::new();
124 if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
125 visited.insert(canonical_root);
126 }
127 {
128 let mut ctx = IncludeResolveCtx {
129 visited: &mut visited,
130 merged: &mut merged,
131 chain: vec![self.config_path.clone()],
132 };
133 for include_path in &includes {
134 resolve_includes_recursively(
135 &self.base_path,
136 include_path,
137 &self.config_path,
138 &mut ctx,
139 )?;
140 }
141 }
142
143 let document = catalog::read_catalog_dir(&catalog::catalog_dir(&self.base_path))?;
144 catalog::install_catalog(&mut merged, document);
145
146 resolve_system_prompt_includes(&self.base_path, &mut merged)?;
147 resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
148 gateway::project_gateway(&mut merged);
149
150 discover_skills(&self.base_path, &mut merged)?;
151 discover_plugins(&self.base_path, &mut merged)?;
152 discover_marketplaces(&self.base_path, &mut merged)?;
153
154 if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
155 merged.settings.services_path = Some(val);
156 }
157 if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
158 merged.settings.skills_path = Some(val);
159 }
160 if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
161 merged.settings.config_path = Some(val);
162 }
163
164 if let Some(port_offset) = self.port_offset {
165 merged.apply_port_offset(port_offset)?;
166 }
167
168 merged.validate()?;
169
170 Ok(merged)
171 }
172
173 pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
174 #[derive(serde::Deserialize)]
175 struct IncludesOnly {
176 #[serde(default)]
177 includes: Vec<String>,
178 }
179
180 let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
181 path: self.config_path.clone(),
182 source: e,
183 })?;
184 let parsed: IncludesOnly =
185 serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
186 path: self.config_path.clone(),
187 source: e,
188 })?;
189 Ok(parsed.includes)
190 }
191
192 pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
193 self.get_includes()?
194 .into_iter()
195 .map(|include| {
196 let exists = self.base_path.join(&include).exists();
197 Ok((include, exists))
198 })
199 .collect()
200 }
201
202 #[must_use]
203 pub fn base_path(&self) -> &Path {
204 &self.base_path
205 }
206}