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//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14mod discovery;
15mod includes;
16mod merge;
17mod types;
18
19use std::collections::HashSet;
20use std::fs;
21use std::path::{Path, PathBuf};
22
23use systemprompt_config::ProfileBootstrap;
24use systemprompt_models::services::ServicesConfig;
25
26use crate::error::{ConfigLoadError, ConfigLoadResult};
27
28use discovery::{discover_marketplaces, discover_plugins, discover_skills};
29use includes::resolve_includes_recursively;
30use merge::{
31    resolve_skill_instruction_includes, resolve_system_prompt_includes,
32    warn_on_authored_card_skills,
33};
34use types::IncludeResolveCtx;
35
36#[derive(Debug)]
37pub struct ConfigLoader {
38    base_path: PathBuf,
39    config_path: PathBuf,
40}
41
42impl ConfigLoader {
43    #[must_use]
44    pub fn new(config_path: PathBuf) -> Self {
45        let base_path = config_path
46            .parent()
47            .unwrap_or_else(|| Path::new("."))
48            .to_path_buf();
49        Self {
50            base_path,
51            config_path,
52        }
53    }
54
55    pub fn for_active_profile() -> ConfigLoadResult<Self> {
56        let profile = ProfileBootstrap::get()?;
57        let config_path = PathBuf::from(profile.paths.config());
58        Ok(Self::new(config_path))
59    }
60
61    pub fn load() -> ConfigLoadResult<ServicesConfig> {
62        Self::for_active_profile()?.run()
63    }
64
65    pub fn load_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
66        Self::new(path.to_path_buf()).run()
67    }
68
69    #[cfg(any(test, feature = "expose-internals"))]
70    pub fn load_from_content(content: &str, path: &Path) -> ConfigLoadResult<ServicesConfig> {
71        Self::new(path.to_path_buf()).run_from_content(content)
72    }
73
74    pub fn validate_file(path: &Path) -> ConfigLoadResult<()> {
75        Self::load_from_path(path).map(|_| ())
76    }
77
78    fn run(&self) -> ConfigLoadResult<ServicesConfig> {
79        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
80            path: self.config_path.clone(),
81            source: e,
82        })?;
83        self.run_from_content(&content)
84    }
85
86    fn run_from_content(&self, content: &str) -> ConfigLoadResult<ServicesConfig> {
87        let mut merged: ServicesConfig =
88            serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
89                path: self.config_path.clone(),
90                source: e,
91            })?;
92
93        let includes = std::mem::take(&mut merged.includes);
94
95        let mut visited: HashSet<PathBuf> = HashSet::new();
96        if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
97            visited.insert(canonical_root);
98        }
99        {
100            let mut ctx = IncludeResolveCtx {
101                visited: &mut visited,
102                merged: &mut merged,
103                chain: vec![self.config_path.clone()],
104            };
105            for include_path in &includes {
106                resolve_includes_recursively(
107                    &self.base_path,
108                    include_path,
109                    &self.config_path,
110                    &mut ctx,
111                )?;
112            }
113        }
114
115        resolve_system_prompt_includes(&self.base_path, &mut merged)?;
116        resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
117        warn_on_authored_card_skills(&merged);
118
119        discover_skills(&self.base_path, &mut merged)?;
120        discover_plugins(&self.base_path, &mut merged)?;
121        discover_marketplaces(&self.base_path, &mut merged)?;
122
123        if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
124            merged.settings.services_path = Some(val);
125        }
126        if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
127            merged.settings.skills_path = Some(val);
128        }
129        if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
130            merged.settings.config_path = Some(val);
131        }
132
133        merged
134            .validate()
135            .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
136
137        Ok(merged)
138    }
139
140    pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
141        #[derive(serde::Deserialize)]
142        struct IncludesOnly {
143            #[serde(default)]
144            includes: Vec<String>,
145        }
146
147        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
148            path: self.config_path.clone(),
149            source: e,
150        })?;
151        let parsed: IncludesOnly =
152            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
153                path: self.config_path.clone(),
154                source: e,
155            })?;
156        Ok(parsed.includes)
157    }
158
159    pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
160        self.get_includes()?
161            .into_iter()
162            .map(|include| {
163                let exists = self.base_path.join(&include).exists();
164                Ok((include, exists))
165            })
166            .collect()
167    }
168
169    #[must_use]
170    pub fn base_path(&self) -> &Path {
171        &self.base_path
172    }
173}