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::{AiConfig, 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, RootConfig};
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 from_env() -> ConfigLoadResult<Self> {
48        let profile = ProfileBootstrap::get()
49            .map_err(|e| ConfigLoadError::ProfileBootstrap(e.to_string()))?;
50        let config_path = PathBuf::from(profile.paths.config());
51        Ok(Self::new(config_path))
52    }
53
54    pub fn load() -> ConfigLoadResult<ServicesConfig> {
55        Self::from_env()?.run()
56    }
57
58    pub fn load_from_path(path: &Path) -> ConfigLoadResult<ServicesConfig> {
59        Self::new(path.to_path_buf()).run()
60    }
61
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 root: RootConfig =
80            serde_yaml::from_str(content).map_err(|e| ConfigLoadError::Yaml {
81                path: self.config_path.clone(),
82                source: e,
83            })?;
84
85        let mut merged = ServicesConfig {
86            agents: root.agents,
87            mcp_servers: root.mcp_servers,
88            settings: root.settings,
89            scheduler: root.scheduler,
90            ai: root.ai.unwrap_or_else(AiConfig::default),
91            web: root.web,
92            plugins: root.plugins,
93            skills: root.skills,
94            content: root.content,
95        };
96
97        let mut visited: HashSet<PathBuf> = HashSet::new();
98        if let Ok(canonical_root) = fs::canonicalize(&self.config_path) {
99            visited.insert(canonical_root);
100        }
101        {
102            let mut ctx = IncludeResolveCtx {
103                visited: &mut visited,
104                merged: &mut merged,
105                chain: vec![self.config_path.clone()],
106            };
107            for include_path in &root.includes {
108                resolve_includes_recursively(
109                    &self.base_path,
110                    include_path,
111                    &self.config_path,
112                    &mut ctx,
113                )?;
114            }
115        }
116
117        resolve_system_prompt_includes(&self.base_path, &mut merged)?;
118        resolve_skill_instruction_includes(&self.base_path, &mut merged)?;
119
120        merged.settings.apply_env_overrides();
121
122        merged
123            .validate()
124            .map_err(|e| ConfigLoadError::Validation(e.to_string()))?;
125
126        Ok(merged)
127    }
128
129    pub fn get_includes(&self) -> ConfigLoadResult<Vec<String>> {
130        #[derive(serde::Deserialize)]
131        struct IncludesOnly {
132            #[serde(default)]
133            includes: Vec<String>,
134        }
135
136        let content = fs::read_to_string(&self.config_path).map_err(|e| ConfigLoadError::Io {
137            path: self.config_path.clone(),
138            source: e,
139        })?;
140        let parsed: IncludesOnly =
141            serde_yaml::from_str(&content).map_err(|e| ConfigLoadError::Yaml {
142                path: self.config_path.clone(),
143                source: e,
144            })?;
145        Ok(parsed.includes)
146    }
147
148    pub fn list_all_includes(&self) -> ConfigLoadResult<Vec<(String, bool)>> {
149        self.get_includes()?
150            .into_iter()
151            .map(|include| {
152                let exists = self.base_path.join(&include).exists();
153                Ok((include, exists))
154            })
155            .collect()
156    }
157
158    #[must_use]
159    pub fn base_path(&self) -> &Path {
160        &self.base_path
161    }
162}