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