systemprompt_config/services/
manager.rs1use super::types::{DeployEnvironment, EnvironmentConfig};
2use super::writer::ConfigWriter;
3use anyhow::{Result, anyhow};
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 let secrets = 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, &secrets)?;
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<HashMap<String, String>> {
81 let secrets_file = self.project_root.join(".env.secrets");
82 let mut secrets = HashMap::new();
83
84 if secrets_file.exists() {
85 CliService::info(&format!(
86 " Loading secrets from: {}",
87 secrets_file.display()
88 ));
89 let content = fs::read_to_string(&secrets_file)?;
90
91 for line in content.lines() {
92 let line = line.trim();
93 if line.is_empty() || line.starts_with('#') {
94 continue;
95 }
96
97 if let Some((key, value)) = line.split_once('=') {
98 secrets.insert(
99 key.trim().to_string(),
100 value.trim().trim_matches('"').to_string(),
101 );
102 }
103 }
104
105 CliService::success(" Secrets loaded");
106 } else {
107 CliService::warning(" No .env.secrets file found");
108 }
109
110 Ok(secrets)
111 }
112
113 fn yaml_to_flat_map(yaml_path: &Path) -> Result<HashMap<String, String>> {
114 let content = fs::read_to_string(yaml_path)?;
115 let yaml: serde_yaml::Value = serde_yaml::from_str(&content)?;
116
117 let mut flat_map = HashMap::new();
118 Self::flatten_yaml(&yaml, String::new(), &mut flat_map);
119
120 Ok(flat_map)
121 }
122
123 fn flatten_yaml(
124 value: &serde_yaml::Value,
125 prefix: String,
126 result: &mut HashMap<String, String>,
127 ) {
128 match value {
129 serde_yaml::Value::Mapping(map) => {
130 for (k, v) in map {
131 if let Some(key_str) = k.as_str() {
132 let new_prefix = if prefix.is_empty() {
133 key_str.to_uppercase()
134 } else {
135 format!("{}_{}", prefix, key_str.to_uppercase())
136 };
137 Self::flatten_yaml(v, new_prefix, result);
138 }
139 }
140 },
141 serde_yaml::Value::Sequence(_) => {
142 tracing::warn!(key = %prefix, "YAML sequences are not supported in config flattening - skipping");
143 },
144 _ => {
145 if let Some(str_val) = value.as_str() {
146 result.insert(prefix, str_val.to_string());
147 } else if let Some(num_val) = value.as_i64() {
148 result.insert(prefix, num_val.to_string());
149 } else if let Some(bool_val) = value.as_bool() {
150 result.insert(prefix, bool_val.to_string());
151 } else if let Some(float_val) = value.as_f64() {
152 result.insert(prefix, float_val.to_string());
153 }
154 },
155 }
156 }
157
158 fn merge_configs(
159 base: HashMap<String, String>,
160 env: HashMap<String, String>,
161 ) -> HashMap<String, String> {
162 let mut merged = base;
163 for (k, v) in env {
164 merged.insert(k, v);
165 }
166 merged
167 }
168
169 fn resolve_variables(
170 mut vars: HashMap<String, String>,
171 secrets: &HashMap<String, String>,
172 ) -> Result<HashMap<String, String>> {
173 let var_regex = Regex::new(r"\$\{([^}:]+)(?::-(.*?))?\}")?;
174 let max_passes = 5;
175
176 for current_pass in 0..max_passes {
177 let mut changes_made = false;
178
179 for (key, value) in vars.clone() {
180 if var_regex.is_match(&value) {
181 let resolved = Self::resolve_value(&value, &vars, secrets, &var_regex)?;
182
183 if resolved != value {
184 vars.insert(key, resolved);
185 changes_made = true;
186 }
187 }
188 }
189
190 if !changes_made {
191 break;
192 }
193
194 if current_pass == max_passes - 1 && changes_made {
195 let unresolved: Vec<_> = vars
196 .iter()
197 .filter(|(_, v)| var_regex.is_match(v))
198 .map(|(k, v)| format!("{k} = {v}"))
199 .collect();
200
201 if !unresolved.is_empty() {
202 return Err(anyhow!(
203 "Failed to resolve after {} passes:\n{}",
204 max_passes,
205 unresolved.join("\n")
206 ));
207 }
208 }
209 }
210
211 Ok(vars)
212 }
213
214 fn resolve_value(
215 value: &str,
216 vars: &HashMap<String, String>,
217 secrets: &HashMap<String, String>,
218 var_regex: &Regex,
219 ) -> Result<String> {
220 let mut result = value.to_string();
221
222 for cap in var_regex.captures_iter(value) {
223 let full_match = cap
224 .get(0)
225 .ok_or_else(|| anyhow!("Regex capture group 0 missing"))?
226 .as_str();
227 let var_name = cap
228 .get(1)
229 .ok_or_else(|| anyhow!("Regex capture group 1 missing"))?
230 .as_str();
231 let default_value = cap.get(2).map(|m| m.as_str());
232
233 let replacement = secrets
234 .get(var_name)
235 .cloned()
236 .or_else(|| std::env::var(var_name).ok())
237 .or_else(|| vars.get(var_name).cloned())
238 .unwrap_or_else(|| {
239 default_value.map_or_else(|| full_match.to_string(), ToString::to_string)
240 });
241
242 result = result.replace(full_match, &replacement);
243 }
244
245 Ok(result)
246 }
247
248 pub fn write_env_file(config: &EnvironmentConfig, output_path: &Path) -> Result<()> {
249 ConfigWriter::write_env_file(config, output_path)
250 }
251
252 pub fn write_web_env_file(&self, config: &EnvironmentConfig) -> Result<()> {
253 self.writer.write_web_env_file(config)
254 }
255}