Skip to main content

systemprompt_loader/
config_loader.rs

1use 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}