Skip to main content

chartml_core/
params.rs

1//! Parameter resolution for ChartML.
2//!
3//! Resolves `$blockname.param_id` and `$param_id` references in YAML specs
4//! by substituting them with actual parameter values or defaults.
5//! Matches the JS `parameterResolver.js` implementation.
6
7use std::collections::HashMap;
8use crate::spec::{ParamsSpec, ParamDef};
9
10/// Collected parameter values — flat map from "blockname.param_id" or "param_id" to value.
11pub type ParamValues = HashMap<String, serde_json::Value>;
12
13/// Collect default parameter values from params component definitions.
14/// Named params (with a block name): stored as "blockname.param_id" → default
15/// Chart-level params (no block name): stored as "param_id" → default
16pub fn collect_param_defaults(params_specs: &[&ParamsSpec]) -> ParamValues {
17    let mut values = ParamValues::new();
18
19    for spec in params_specs {
20        for param in &spec.params {
21            let key = if let Some(ref name) = spec.name {
22                format!("{}.{}", name, param.id)
23            } else {
24                param.id.clone()
25            };
26
27            if let Some(ref default) = param.default {
28                values.insert(key, default.clone());
29            }
30        }
31    }
32
33    values
34}
35
36/// Resolve parameter references in a YAML string.
37///
38/// Replaces `"$identifier"` and `"$identifier.path"` with their values.
39/// Works on the raw YAML string before parsing, matching the JS approach
40/// of JSON.stringify → regex replace → JSON.parse.
41///
42/// If a reference is not found in `param_values`, it is left unchanged
43/// (the filter/transform will see the raw `$...` string).
44pub fn resolve_param_references(yaml: &str, param_values: &ParamValues) -> String {
45    let mut result = yaml.to_string();
46
47    // Sort keys by length descending so longer paths are replaced first
48    // (prevents "$dashboard_filters.region" matching "$dashboard_filters" first)
49    let mut keys: Vec<&String> = param_values.keys().collect();
50    keys.sort_by_key(|b| std::cmp::Reverse(b.len()));
51
52    for key in keys {
53        let value = &param_values[key];
54        let pattern = format!("\"${}\"", key);
55
56        if result.contains(&pattern) {
57            let replacement = value_to_yaml(value);
58            result = result.replace(&pattern, &replacement);
59        }
60
61        // Also handle unquoted references (e.g., value: $top_n without quotes)
62        let _unquoted_pattern = format!("${}",  key);
63        // Only replace if it's a standalone reference (not inside a longer string)
64        // This is trickier — skip for now, quoted refs cover the spec
65    }
66
67    result
68}
69
70/// Convert a serde_json::Value to its YAML representation for substitution.
71fn value_to_yaml(value: &serde_json::Value) -> String {
72    match value {
73        serde_json::Value::String(s) => format!("\"{}\"", s),
74        serde_json::Value::Number(n) => n.to_string(),
75        serde_json::Value::Bool(b) => b.to_string(),
76        serde_json::Value::Null => "null".to_string(),
77        serde_json::Value::Array(arr) => {
78            let items: Vec<String> = arr.iter().map(value_to_yaml).collect();
79            format!("[{}]", items.join(", "))
80        }
81        serde_json::Value::Object(_obj) => {
82            // For objects, use JSON format which YAML also accepts
83            serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
84        }
85    }
86}
87
88/// Extract chart-level param defaults from a YAML string by attempting
89/// to parse just the params section. This handles the case where params
90/// are defined inside the chart spec itself (not as a separate component).
91///
92/// Scans for inline `params:` blocks and extracts id/default pairs.
93pub fn extract_inline_param_defaults(yaml: &str) -> ParamValues {
94    let mut values = ParamValues::new();
95
96    // Try to parse the YAML as a mapping and look for a "params" key
97    // that contains an array of param definitions
98    if let Ok(serde_yaml::Value::Mapping(map)) = serde_yaml::from_str::<serde_yaml::Value>(yaml) {
99        if let Some(params_val) = map.get(serde_yaml::Value::String("params".into())) {
100            if let Ok(params) = serde_yaml::from_value::<Vec<ParamDef>>(params_val.clone()) {
101                for param in &params {
102                    if let Some(ref default) = param.default {
103                        values.insert(param.id.clone(), default.clone());
104                    }
105                }
106            }
107        }
108    }
109
110    values
111}
112
113/// Extract the set of parameter references from a YAML string.
114/// Returns paths like "dashboard_filters.region" or "top_n".
115pub fn extract_param_references(yaml: &str) -> Vec<String> {
116    let mut refs = Vec::new();
117    // Match "$identifier" or "$identifier.path" in the YAML
118    let mut i = 0;
119    let bytes = yaml.as_bytes();
120    while i < bytes.len() {
121        if bytes[i] == b'$' && i > 0 && (bytes[i - 1] == b'"' || bytes[i - 1] == b' ' || bytes[i - 1] == b':') {
122            let start = i + 1;
123            let mut end = start;
124            while end < bytes.len()
125                && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_' || bytes[end] == b'.')
126            {
127                end += 1;
128            }
129            if end > start {
130                refs.push(yaml[start..end].to_string());
131            }
132            i = end;
133        } else {
134            i += 1;
135        }
136    }
137    refs.sort();
138    refs.dedup();
139    refs
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn collect_defaults_named_params() {
148        let spec = ParamsSpec {
149            version: 1,
150            name: Some("dashboard_filters".to_string()),
151            params: vec![
152                ParamDef {
153                    id: "region".to_string(),
154                    param_type: "multiselect".to_string(),
155                    label: "Region".to_string(),
156                    options: Some(vec!["US".into(), "EU".into()]),
157                    default: Some(serde_json::json!(["US", "EU"])),
158                    placeholder: None,
159                    layout: None,
160                },
161                ParamDef {
162                    id: "min_revenue".to_string(),
163                    param_type: "number".to_string(),
164                    label: "Min Revenue".to_string(),
165                    options: None,
166                    default: Some(serde_json::json!(50000)),
167                    placeholder: None,
168                    layout: None,
169                },
170            ],
171        };
172
173        let defaults = collect_param_defaults(&[&spec]);
174        assert_eq!(defaults.get("dashboard_filters.region"), Some(&serde_json::json!(["US", "EU"])));
175        assert_eq!(defaults.get("dashboard_filters.min_revenue"), Some(&serde_json::json!(50000)));
176    }
177
178    #[test]
179    fn resolve_string_param() {
180        let mut params = ParamValues::new();
181        params.insert("top_n".to_string(), serde_json::json!(10));
182
183        let yaml = r#"limit: "$top_n""#;
184        let resolved = resolve_param_references(yaml, &params);
185        assert_eq!(resolved, "limit: 10");
186    }
187
188    #[test]
189    fn resolve_array_param() {
190        let mut params = ParamValues::new();
191        params.insert("dashboard_filters.region".to_string(), serde_json::json!(["US", "EU"]));
192
193        let yaml = r#"value: "$dashboard_filters.region""#;
194        let resolved = resolve_param_references(yaml, &params);
195        assert_eq!(resolved, r#"value: ["US", "EU"]"#);
196    }
197
198    #[test]
199    fn unresolved_param_stays() {
200        let params = ParamValues::new();
201        let yaml = r#"value: "$unknown.param""#;
202        let resolved = resolve_param_references(yaml, &params);
203        assert_eq!(resolved, r#"value: "$unknown.param""#);
204    }
205
206    #[test]
207    fn extract_refs() {
208        let yaml = r#"
209          value: "$dashboard_filters.region"
210          limit: "$top_n"
211        "#;
212        let refs = extract_param_references(yaml);
213        assert!(refs.contains(&"dashboard_filters.region".to_string()));
214        assert!(refs.contains(&"top_n".to_string()));
215    }
216}