systemprompt_loader/
config_loader.rs1use anyhow::{Context, Result};
2use std::collections::HashSet;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use systemprompt_models::services::{PartialServicesConfig, ServicesConfig};
7use systemprompt_models::AppPaths;
8
9use crate::ConfigWriter;
10
11#[derive(Debug, Clone, Copy)]
12pub struct ConfigLoader;
13
14#[derive(serde::Deserialize)]
15struct ConfigWithIncludes {
16 #[serde(default)]
17 includes: Vec<String>,
18 #[serde(flatten)]
19 config: ServicesConfig,
20}
21
22impl ConfigLoader {
23 pub fn load() -> Result<ServicesConfig> {
24 let paths = AppPaths::get().map_err(|e| anyhow::anyhow!("{}", e))?;
25 let path = paths.system().settings();
26 Self::load_from_path(path)
27 }
28
29 pub fn load_from_path(config_path: &Path) -> Result<ServicesConfig> {
30 let content = fs::read_to_string(config_path).with_context(|| {
31 format!("Failed to read services config: {}", config_path.display())
32 })?;
33
34 Self::load_from_content(&content, config_path)
35 }
36
37 pub fn load_from_content(content: &str, config_path: &Path) -> Result<ServicesConfig> {
38 let root_config: ConfigWithIncludes = serde_yaml::from_str(content)
39 .with_context(|| format!("Failed to parse config: {}", config_path.display()))?;
40
41 let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
42
43 let mut merged_config = root_config.config;
44
45 for include_path in &root_config.includes {
46 let full_path = config_dir.join(include_path);
47 let include_config = Self::load_include_file(&full_path)?;
48 Self::merge_include(&mut merged_config, include_config);
49 }
50
51 Self::discover_and_load_agents(
52 config_dir,
53 config_path,
54 &root_config.includes,
55 &mut merged_config,
56 )?;
57
58 merged_config.settings.apply_env_overrides();
59
60 merged_config
61 .validate()
62 .map_err(|e| anyhow::anyhow!("Services config validation failed: {}", e))?;
63
64 Ok(merged_config)
65 }
66
67 fn discover_and_load_agents(
68 config_dir: &Path,
69 config_path: &Path,
70 existing_includes: &[String],
71 merged_config: &mut ServicesConfig,
72 ) -> Result<()> {
73 let agents_dir = config_dir.join("../agents");
74
75 if !agents_dir.exists() {
76 return Ok(());
77 }
78
79 let included_files: HashSet<String> = existing_includes
80 .iter()
81 .filter_map(|inc| {
82 Path::new(inc)
83 .file_name()
84 .map(|f| f.to_string_lossy().to_string())
85 })
86 .collect();
87
88 let entries = fs::read_dir(&agents_dir).with_context(|| {
89 format!("Failed to read agents directory: {}", agents_dir.display())
90 })?;
91
92 for entry in entries {
93 let path = entry
94 .with_context(|| format!("Failed to read entry in: {}", agents_dir.display()))?
95 .path();
96
97 let is_yaml = path
98 .extension()
99 .is_some_and(|ext| ext == "yaml" || ext == "yml");
100
101 if !is_yaml {
102 continue;
103 }
104
105 let file_name = path
106 .file_name()
107 .map(|f| f.to_string_lossy().to_string())
108 .ok_or_else(|| anyhow::anyhow!("Invalid file path: {}", path.display()))?;
109
110 if included_files.contains(&file_name) {
111 continue;
112 }
113
114 let include_config = Self::load_include_file(&path)?;
115 Self::merge_include(merged_config, include_config);
116
117 let relative_path = format!("../agents/{}", file_name);
118 ConfigWriter::add_include(&relative_path, config_path).with_context(|| {
119 format!(
120 "Failed to add discovered agent to includes: {}",
121 relative_path
122 )
123 })?;
124 }
125
126 Ok(())
127 }
128
129 fn load_include_file(path: &PathBuf) -> Result<PartialServicesConfig> {
130 if !path.exists() {
131 anyhow::bail!(
132 "Include file not found: {}\nEither create the file or remove it from the \
133 includes list.",
134 path.display()
135 );
136 }
137
138 let content = fs::read_to_string(path)
139 .with_context(|| format!("Failed to read include: {}", path.display()))?;
140
141 serde_yaml::from_str(&content)
142 .with_context(|| format!("Failed to parse include: {}", path.display()))
143 }
144
145 fn merge_include(target: &mut ServicesConfig, partial: PartialServicesConfig) {
146 for (name, agent) in partial.agents {
147 target.agents.insert(name, agent);
148 }
149
150 for (name, mcp_server) in partial.mcp_servers {
151 target.mcp_servers.insert(name, mcp_server);
152 }
153
154 if partial.scheduler.is_some() {
155 target.scheduler = partial.scheduler;
156 }
157
158 if let Some(ai) = partial.ai {
159 target.ai = ai;
160 }
161
162 if let Some(web) = partial.web {
163 target.web = web;
164 }
165 }
166
167 pub fn validate_file(path: &Path) -> Result<()> {
168 let config = Self::load_from_path(path)?;
169 config
170 .validate()
171 .map_err(|e| anyhow::anyhow!("Config validation failed: {}", e))?;
172 Ok(())
173 }
174}