Skip to main content

platform/config/
resolver.rs

1//! 三层配置解析器
2//!
3//! L0 默认值 → L1 配置文件 → L2 动态覆盖(API 写入)
4//! 最终有效值 = L2 ?? L1 ?? L0
5
6use super::config_store::ConfigOverride;
7use super::registry::{self, ConfigFieldDef, ConfigValueType};
8use serde::Serialize;
9use std::collections::HashMap;
10
11/// A resolved config field with all three layers visible.
12#[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    /// L0: always present (from registry default)
20    pub default_value: String,
21    /// L1: value from config.toml if explicitly set
22    pub config_file_value: Option<String>,
23    /// L2: dynamic override from SQLite
24    pub override_value: Option<String>,
25    /// Effective value: L2 ?? L1 ?? L0
26    pub effective_value: String,
27    /// Source of the effective value: "default", "config_file", or "override"
28    pub source: String,
29    /// Valid choices for enum fields (empty for other types)
30    #[serde(skip_serializing_if = "Vec::is_empty")]
31    pub choices: Vec<String>,
32}
33
34/// Navigate a `toml::Value` tree by dot-separated path.
35fn 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
43/// Extract a scalar `toml::Value` as a string representation.
44fn 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, // tables/arrays are not scalar config values
51    }
52}
53
54/// Resolve all config fields for a given service.
55///
56/// - `toml_content`: raw config.toml text (for L1 detection)
57/// - `overrides`: all L2 overrides (from `ConfigOverrideStore::list_all()`)
58pub 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
76/// Resolve all config fields across all services.
77pub 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    // L1: check if the field exists in the TOML tree
97    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    // L2: check override map
103    let override_value = override_map.get(field.key).map(|v| v.to_string());
104
105    // Effective value: L2 ?? L1 ?? L0
106    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
129/// Apply L2 overrides onto a TOML value tree, then deserialize to `ActrixConfig`.
130///
131/// This is used during reload to merge dynamic overrides into the config.
132pub 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        // Only apply if the field is registered and dynamic
141        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    // Deserialize back to ActrixConfig
153    tree.try_into()
154        .map_err(|e| format!("Config deserialization error after applying overrides: {e}"))
155}
156
157/// Set a value in a TOML tree by dot-separated path.
158fn 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    // Navigate/create intermediate tables
167    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}