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 discovery;
12mod includes;
13mod merge;
14mod types;
15
16use std::collections::HashSet;
17use std::fs;
18use std::path::{Path, PathBuf};
19
20use systemprompt_config::ProfileBootstrap;
21use systemprompt_models::services::ServicesConfig;
22
23use crate::error::{ConfigLoadError, ConfigLoadResult};
24
25use discovery::{discover_marketplaces, discover_plugins, discover_skills};
26use includes::resolve_includes_recursively;
27use merge::{
28    resolve_skill_instruction_includes, resolve_system_prompt_includes,
29    warn_on_authored_card_skills,
30};
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        warn_on_authored_card_skills(&merged);
115
116        discover_skills(&self.base_path, &mut merged)?;
117        discover_plugins(&self.base_path, &mut merged)?;
118        discover_marketplaces(&self.base_path, &mut merged)?;
119
120        if let Ok(val) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
121            merged.settings.services_path = Some(val);
122        }
123        if let Ok(val) = std::env::var("SYSTEMPROMPT_SKILLS_PATH") {
124            merged.settings.skills_path = Some(val);
125        }
126        if let Ok(val) = std::env::var("SYSTEMPROMPT_CONFIG_PATH") {
127            merged.settings.config_path = Some(val);
128        }
129
130        merged
131            .validate()
132            .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
133
134        Ok(merged)
135    }
136
137    pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
138        #[derive(serde::Deserialize)]
139        struct IncludesOnly {
140            #[serde(default)]
141            includes: Vec<String>,
142        }
143
144        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
145            path: self.config_path.clone(),
146            source: e,
147        })?;
148        let parsed: IncludesOnly =
149            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
150                path: self.config_path.clone(),
151                source: e,
152            })?;
153        Ok(parsed.includes)
154    }
155
156    pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
157        self.get_includes()?
158            .into_iter()
159            .map(|include| {
160                let exists = self.base_path.join(&include).exists();
161                Ok((include, exists))
162            })
163            .collect()
164    }
165
166    #[must_use]
167    pub fn base_path(&self) -> &Path {
168        &self.base_path
169    }
170}