systemprompt_config/services/
service.rs1use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use systemprompt_logging::CliService;
13use systemprompt_models::{contains_placeholder, interpolate, read_env_optional};
14
15use super::types::{DeployEnvironment, EnvironmentConfig};
16use super::writer::ConfigWriter;
17use crate::error::{ConfigError, ConfigResult};
18
19#[derive(Debug)]
20pub struct ConfigService {
21 project_root: PathBuf,
22 environments_dir: PathBuf,
23 writer: ConfigWriter,
24}
25
26impl ConfigService {
27 #[must_use]
28 pub fn new(project_root: PathBuf) -> Self {
29 let environments_dir = project_root.join("infrastructure/environments");
30 let writer = ConfigWriter::new(project_root.clone());
31 Self {
32 project_root,
33 environments_dir,
34 writer,
35 }
36 }
37
38 pub fn generate_config(
39 &self,
40 environment: DeployEnvironment,
41 ) -> ConfigResult<EnvironmentConfig> {
42 CliService::info(&format!(
43 "Building configuration for environment: {}",
44 environment.as_str()
45 ));
46
47 let base_config_path = self.environments_dir.join("base.yaml");
48 let env_config_path = self
49 .environments_dir
50 .join(environment.as_str())
51 .join("config.yaml");
52
53 if !base_config_path.exists() {
54 return Err(ConfigError::EnvironmentConfigMissing {
55 path: base_config_path,
56 });
57 }
58
59 if !env_config_path.exists() {
60 return Err(ConfigError::EnvironmentConfigMissing {
61 path: env_config_path,
62 });
63 }
64
65 let secrets = self.load_secrets()?;
66
67 CliService::success(&format!(
68 " Parsing base config: {}",
69 base_config_path.display()
70 ));
71 let base_vars = Self::yaml_to_flat_map(&base_config_path)?;
72
73 CliService::success(&format!(
74 " Parsing environment config: {}",
75 env_config_path.display()
76 ));
77 let env_vars = Self::yaml_to_flat_map(&env_config_path)?;
78
79 let merged = Self::merge_configs(base_vars, env_vars);
80
81 let resolved = Self::resolve_variables(merged, &secrets)?;
82
83 CliService::success(" Configuration generated successfully");
84
85 Ok(EnvironmentConfig {
86 environment,
87 variables: resolved,
88 })
89 }
90
91 fn load_secrets(&self) -> ConfigResult<HashMap<String, String>> {
92 let secrets_file = self.project_root.join(".env.secrets");
93 let mut secrets = HashMap::new();
94
95 if secrets_file.exists() {
96 CliService::info(&format!(
97 " Loading secrets from: {}",
98 secrets_file.display()
99 ));
100 let content = fs::read_to_string(&secrets_file)?;
101
102 for line in content.lines() {
103 let line = line.trim();
104 if line.is_empty() || line.starts_with('#') {
105 continue;
106 }
107
108 if let Some((key, value)) = line.split_once('=') {
109 secrets.insert(
110 key.trim().to_owned(),
111 value.trim().trim_matches('"').to_owned(),
112 );
113 }
114 }
115
116 CliService::success(" Secrets loaded");
117 } else {
118 CliService::warning(" No .env.secrets file found");
119 }
120
121 Ok(secrets)
122 }
123
124 fn yaml_to_flat_map(yaml_path: &Path) -> ConfigResult<HashMap<String, String>> {
125 let content = fs::read_to_string(yaml_path)?;
126 let yaml: serde_yaml::Value = serde_yaml::from_str(&content)?;
127
128 let mut flat_map = HashMap::new();
129 Self::flatten_yaml(&yaml, String::new(), &mut flat_map);
130
131 Ok(flat_map)
132 }
133
134 fn flatten_yaml(
135 value: &serde_yaml::Value,
136 prefix: String,
137 result: &mut HashMap<String, String>,
138 ) {
139 match value {
140 serde_yaml::Value::Mapping(map) => {
141 for (k, v) in map {
142 if let Some(key_str) = k.as_str() {
143 let new_prefix = if prefix.is_empty() {
144 key_str.to_uppercase()
145 } else {
146 format!("{}_{}", prefix, key_str.to_uppercase())
147 };
148 Self::flatten_yaml(v, new_prefix, result);
149 }
150 }
151 },
152 serde_yaml::Value::Sequence(_) => {
153 tracing::warn!(key = %prefix, "YAML sequences are not supported in config flattening - skipping");
154 },
155 _ => {
156 if let Some(str_val) = value.as_str() {
157 result.insert(prefix, str_val.to_owned());
158 } else if let Some(num_val) = value.as_i64() {
159 result.insert(prefix, num_val.to_string());
160 } else if let Some(bool_val) = value.as_bool() {
161 result.insert(prefix, bool_val.to_string());
162 } else if let Some(float_val) = value.as_f64() {
163 result.insert(prefix, float_val.to_string());
164 }
165 },
166 }
167 }
168
169 fn merge_configs(
170 base: HashMap<String, String>,
171 env: HashMap<String, String>,
172 ) -> HashMap<String, String> {
173 let mut merged = base;
174 for (k, v) in env {
175 merged.insert(k, v);
176 }
177 merged
178 }
179
180 fn resolve_variables(
181 mut vars: HashMap<String, String>,
182 secrets: &HashMap<String, String>,
183 ) -> ConfigResult<HashMap<String, String>> {
184 const MAX_PASSES: usize = 5;
185
186 for current_pass in 0..MAX_PASSES {
187 let mut changes_made = false;
188
189 for (key, value) in vars.clone() {
190 let resolved = interpolate(&value, &|name| {
191 secrets
192 .get(name)
193 .cloned()
194 .or_else(|| read_env_optional(name))
195 .or_else(|| vars.get(name).cloned())
196 });
197
198 if resolved != value {
199 vars.insert(key, resolved);
200 changes_made = true;
201 }
202 }
203
204 if !changes_made {
205 break;
206 }
207
208 if current_pass == MAX_PASSES - 1 {
212 let unresolved: Vec<_> = vars
213 .iter()
214 .filter(|(_, v)| contains_placeholder(v))
215 .map(|(k, v)| format!("{k} = {v}"))
216 .collect();
217
218 if !unresolved.is_empty() {
219 return Err(ConfigError::UnresolvedVariables {
220 passes: MAX_PASSES,
221 unresolved: unresolved.join("\n"),
222 });
223 }
224 }
225 }
226
227 Ok(vars)
228 }
229
230 pub fn write_env_file(config: &EnvironmentConfig, output_path: &Path) -> ConfigResult<()> {
231 ConfigWriter::write_env_file(config, output_path)
232 }
233
234 pub fn write_web_env_file(&self, config: &EnvironmentConfig) -> ConfigResult<()> {
235 self.writer.write_web_env_file(config)
236 }
237}