systemprompt_loader/config_loader/
mod.rs1mod 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}