systemprompt_config/services/
service.rs1use 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 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}