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's services root to a YAML path, parses the root file, recursively
5//! resolves the `includes:` graph (rejecting cycles and duplicate definitions),
6//! reads the model catalog by convention from `services/ai/` ([`catalog`]),
7//! inlines `!include` references inside agent system prompts, skill
8//! instructions and gateway prompt overrides, projects the gateway spec to its
9//! resolved runtime form, and finally validates the merged configuration — the
10//! catalog resolved with every reference checked — before returning it to the
11//! caller.
12//!
13//! A pre-0.73 tree is refused with the `systemprompt admin config migrate`
14//! hint. A provider whose secret is absent is still loaded: a missing
15//! credential is health state the gateway reports, never a rewrite of the
16//! catalog.
17//!
18//! Every entry point is a fresh read; the process holds the parsed result in
19//! [`crate::ServicesState`].
20//!
21//! Copyright (c) systemprompt.io — Business Source License 1.1.
22//! See <https://systemprompt.io> for licensing details.
23
24pub 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}