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//!
5//! Copyright (c) systemprompt.io — Business Source License 1.1.
6//! See <https://systemprompt.io> for licensing details.
7
8use 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            // Reaching the final pass while still mutating means a cycle or a
209            // reference chain deeper than MAX_PASSES — surface it rather than
210            // returning a config that still carries placeholders.
211            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}