Skip to main content

systemprompt_loader/config_loader/
mod.rs

1//! Reads, parses, and merges the active services configuration.
2//!
3//! [`ConfigLoader`] is the only public entry point. It resolves the active
4//! profile (via [`systemprompt_config::ProfileBootstrap`]) to a YAML path,
5//! parses the root file, recursively resolves the `includes:` graph
6//! (rejecting cycles and duplicate definitions), inlines `!include`
7//! references inside agent system prompts and skill instructions, and
8//! finally validates the merged configuration before returning it to the
9//! caller.
10
11mod 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        merged.settings.apply_env_overrides();
113
114        merged
115            .validate()
116            .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
117
118        Ok(merged)
119    }
120
121    pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
122        #[derive(serde::Deserialize)]
123        struct IncludesOnly {
124            #[serde(default)]
125            includes: Vec<String>,
126        }
127
128        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
129            path: self.config_path.clone(),
130            source: e,
131        })?;
132        let parsed: IncludesOnly =
133            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
134                path: self.config_path.clone(),
135                source: e,
136            })?;
137        Ok(parsed.includes)
138    }
139
140    pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
141        self.get_includes()?
142            .into_iter()
143            .map(|include| {
144                let exists = self.base_path.join(&include).exists();
145                Ok((include, exists))
146            })
147            .collect()
148    }
149
150    #[must_use]
151    pub fn base_path(&self) -> &Path {
152        &self.base_path
153    }
154}
155
156/// Walks `<services>/marketplaces/*/config.yaml`, parses each as a
157/// `MarketplaceConfigFile`, and inserts into `merged.marketplaces`.
158///
159/// `base_path` is the parent of the root `config.yaml` (i.e.
160/// `<services>/config`), so the marketplaces directory is its sibling.
161fn discover_marketplaces(base_path: &Path, merged: &mut ServicesConfig) -> ConfigLoadResult<()> {
162    let Some(services_dir) = base_path.parent() else {
163        return Ok(());
164    };
165    let marketplaces_dir = services_dir.join("marketplaces");
166    if !marketplaces_dir.exists() {
167        return Ok(());
168    }
169
170    let entries = fs::read_dir(&marketplaces_dir).map_err(|e| ConfigLoadError::Io {
171        path: marketplaces_dir.clone(),
172        source: e,
173    })?;
174
175    for entry in entries {
176        let entry = entry.map_err(|e| ConfigLoadError::Io {
177            path: marketplaces_dir.clone(),
178            source: e,
179        })?;
180        let dir = entry.path();
181        if !dir.is_dir() {
182            continue;
183        }
184        let config_path = dir.join("config.yaml");
185        if !config_path.exists() {
186            continue;
187        }
188
189        let content = fs::read_to_string(&config_path).map_err(|e| ConfigLoadError::Io {
190            path: config_path.clone(),
191            source: e,
192        })?;
193        let file: MarketplaceConfigFile =
194            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
195                path: config_path.clone(),
196                source: e,
197            })?;
198
199        let id = file.marketplace.id.clone();
200        if merged.marketplaces.contains_key(&id) {
201            return Err(ConfigLoadError::DuplicateMarketplace(
202                id.as_str().to_owned(),
203            ));
204        }
205        merged.marketplaces.insert(id, file.marketplace);
206    }
207
208    Ok(())
209}