Skip to main content

systemprompt_config/services/
manager.rs

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