1use super::config_store::ConfigOverride;
7use super::registry::{self, ConfigFieldDef, ConfigValueType};
8use serde::Serialize;
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize)]
13pub struct ResolvedField {
14 pub key: String,
15 pub value_type: String,
16 pub description: String,
17 pub dynamic: bool,
18 pub reloadable: bool,
19 pub default_value: String,
21 pub config_file_value: Option<String>,
23 pub override_value: Option<String>,
25 pub effective_value: String,
27 pub source: String,
29 #[serde(skip_serializing_if = "Vec::is_empty")]
31 pub choices: Vec<String>,
32}
33
34fn navigate_toml<'a>(root: &'a toml::Value, path: &str) -> Option<&'a toml::Value> {
36 let mut current = root;
37 for segment in path.split('.') {
38 current = current.as_table()?.get(segment)?;
39 }
40 Some(current)
41}
42
43fn toml_value_to_string(v: &toml::Value) -> Option<String> {
45 match v {
46 toml::Value::String(s) => Some(s.clone()),
47 toml::Value::Integer(i) => Some(i.to_string()),
48 toml::Value::Float(f) => Some(f.to_string()),
49 toml::Value::Boolean(b) => Some(b.to_string()),
50 _ => None, }
52}
53
54pub fn resolve_for_service(
59 service: &str,
60 toml_content: &str,
61 overrides: &[ConfigOverride],
62) -> Vec<ResolvedField> {
63 let fields = registry::fields_for_service(service);
64 let toml_tree: Option<toml::Value> = toml::from_str(toml_content).ok();
65 let override_map: HashMap<&str, &str> = overrides
66 .iter()
67 .map(|o| (o.key_path.as_str(), o.value.as_str()))
68 .collect();
69
70 fields
71 .into_iter()
72 .map(|field| resolve_field(field, &toml_tree, &override_map))
73 .collect()
74}
75
76pub fn resolve_all(toml_content: &str, overrides: &[ConfigOverride]) -> Vec<ResolvedField> {
78 let fields = registry::all_fields();
79 let toml_tree: Option<toml::Value> = toml::from_str(toml_content).ok();
80 let override_map: HashMap<&str, &str> = overrides
81 .iter()
82 .map(|o| (o.key_path.as_str(), o.value.as_str()))
83 .collect();
84
85 fields
86 .iter()
87 .map(|field| resolve_field(field, &toml_tree, &override_map))
88 .collect()
89}
90
91fn resolve_field(
92 field: &ConfigFieldDef,
93 toml_tree: &Option<toml::Value>,
94 override_map: &HashMap<&str, &str>,
95) -> ResolvedField {
96 let config_file_value = toml_tree
98 .as_ref()
99 .and_then(|tree| navigate_toml(tree, field.toml_path))
100 .and_then(toml_value_to_string);
101
102 let override_value = override_map.get(field.key).map(|v| v.to_string());
104
105 let (effective_value, source) = if let Some(ref ov) = override_value {
107 (ov.clone(), "override")
108 } else if let Some(ref cv) = config_file_value {
109 (cv.clone(), "config_file")
110 } else {
111 (field.default_value.to_string(), "default")
112 };
113
114 ResolvedField {
115 key: field.key.to_string(),
116 value_type: field.value_type.to_string(),
117 description: field.description.to_string(),
118 dynamic: field.dynamic,
119 reloadable: field.reloadable,
120 default_value: field.default_value.to_string(),
121 config_file_value,
122 override_value,
123 effective_value,
124 source: source.to_string(),
125 choices: field.choices.iter().map(|s| s.to_string()).collect(),
126 }
127}
128
129pub fn apply_overrides(
133 toml_content: &str,
134 overrides: &[ConfigOverride],
135) -> Result<super::ActrixConfig, String> {
136 let mut tree: toml::Value =
137 toml::from_str(toml_content).map_err(|e| format!("TOML parse error: {e}"))?;
138
139 for ov in overrides {
140 let Some(field) = registry::get_field(&ov.key_path) else {
142 continue;
143 };
144 if !field.dynamic {
145 continue;
146 }
147
148 set_toml_value(&mut tree, field.toml_path, &ov.value, field.value_type)
149 .map_err(|e| format!("Failed to apply override '{}': {e}", ov.key_path))?;
150 }
151
152 tree.try_into()
154 .map_err(|e| format!("Config deserialization error after applying overrides: {e}"))
155}
156
157fn set_toml_value(
159 root: &mut toml::Value,
160 path: &str,
161 value: &str,
162 value_type: ConfigValueType,
163) -> Result<(), String> {
164 let segments: Vec<&str> = path.split('.').collect();
165
166 let mut current = root;
168 for &segment in &segments[..segments.len() - 1] {
169 if !current.is_table() {
170 return Err(format!("Path segment '{segment}' parent is not a table"));
171 }
172 let table = current.as_table_mut().unwrap();
173 if !table.contains_key(segment) {
174 table.insert(segment.to_string(), toml::Value::Table(Default::default()));
175 }
176 current = table.get_mut(segment).unwrap();
177 }
178
179 let last = *segments.last().ok_or("Empty path")?;
180 let table = current
181 .as_table_mut()
182 .ok_or_else(|| format!("Parent of '{last}' is not a table"))?;
183
184 let typed_value = match value_type {
185 ConfigValueType::String
186 | ConfigValueType::Enum
187 | ConfigValueType::Range16
188 | ConfigValueType::Ip
189 | ConfigValueType::Fpath
190 | ConfigValueType::Domain
191 | ConfigValueType::UriPath => toml::Value::String(value.to_string()),
192 ConfigValueType::Bool => toml::Value::Boolean(
193 value
194 .parse()
195 .map_err(|_| format!("Cannot parse '{value}' as bool"))?,
196 ),
197 ConfigValueType::U8
198 | ConfigValueType::U16
199 | ConfigValueType::U32
200 | ConfigValueType::U64 => toml::Value::Integer(
201 value
202 .parse::<i64>()
203 .map_err(|_| format!("Cannot parse '{value}' as integer"))?,
204 ),
205 };
206
207 table.insert(last.to_string(), typed_value);
208 Ok(())
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn test_resolve_defaults_only() {
217 let fields = resolve_for_service("turn", "", &[]);
218 assert!(!fields.is_empty());
219
220 let realm = fields.iter().find(|f| f.key == "turn.realm").unwrap();
221 assert_eq!(realm.effective_value, "actrix.local");
222 assert_eq!(realm.source, "default");
223 assert!(realm.config_file_value.is_none());
224 assert!(realm.override_value.is_none());
225 }
226
227 #[test]
228 fn test_resolve_with_config_file() {
229 let toml = r#"
230[turn]
231realm = "production.example.com"
232relay_port_range = "49152-65535"
233"#;
234 let fields = resolve_for_service("turn", toml, &[]);
235 let realm = fields.iter().find(|f| f.key == "turn.realm").unwrap();
236 assert_eq!(realm.effective_value, "production.example.com");
237 assert_eq!(realm.source, "config_file");
238 assert_eq!(
239 realm.config_file_value.as_deref(),
240 Some("production.example.com")
241 );
242 }
243
244 #[test]
245 fn test_resolve_with_override() {
246 let toml = r#"
247[turn]
248realm = "production.example.com"
249"#;
250 let overrides = vec![ConfigOverride {
251 key_path: "turn.realm".to_string(),
252 value: "override.example.com".to_string(),
253 updated_at: "2024-01-01".to_string(),
254 updated_by: "admin".to_string(),
255 }];
256 let fields = resolve_for_service("turn", toml, &overrides);
257 let realm = fields.iter().find(|f| f.key == "turn.realm").unwrap();
258 assert_eq!(realm.effective_value, "override.example.com");
259 assert_eq!(realm.source, "override");
260 assert_eq!(
261 realm.config_file_value.as_deref(),
262 Some("production.example.com")
263 );
264 assert_eq!(
265 realm.override_value.as_deref(),
266 Some("override.example.com")
267 );
268 }
269
270 #[test]
271 fn test_apply_overrides() {
272 let toml = r#"
273name = "test"
274env = "dev"
275location_tag = "test"
276actrix_shared_key = "XDDYE8d+yMfdXcdWMrXprcUk2uzjnmoX6nCfFw1gGIg="
277sqlite_path = "database"
278
279[bind.ice]
280ip = "0.0.0.0"
281port = 3478
282advertised_ip = "127.0.0.1"
283advertised_port = 3478
284
285[turn]
286realm = "original.com"
287relay_port_range = "49152-65535"
288"#;
289 let overrides = vec![ConfigOverride {
290 key_path: "turn.realm".to_string(),
291 value: "overridden.com".to_string(),
292 updated_at: "2024-01-01".to_string(),
293 updated_by: "admin".to_string(),
294 }];
295
296 let config = apply_overrides(toml, &overrides).unwrap();
297 assert_eq!(config.turn.realm, "overridden.com");
298 }
299
300 #[test]
301 fn test_navigate_toml() {
302 let val: toml::Value = toml::from_str(
303 r#"
304[services.signaling.server.rate_limit.connection]
305enabled = true
306per_minute = 10
307"#,
308 )
309 .unwrap();
310
311 let enabled = navigate_toml(
312 &val,
313 "services.signaling.server.rate_limit.connection.enabled",
314 );
315 assert!(enabled.is_some());
316 assert_eq!(
317 toml_value_to_string(enabled.unwrap()),
318 Some("true".to_string())
319 );
320
321 let per_min = navigate_toml(
322 &val,
323 "services.signaling.server.rate_limit.connection.per_minute",
324 );
325 assert!(per_min.is_some());
326 assert_eq!(
327 toml_value_to_string(per_min.unwrap()),
328 Some("10".to_string())
329 );
330 }
331}