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