Skip to main content

systemprompt_config/services/
service.rs

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