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    #![allow(clippy::unwrap_used)]
145    use super::*;
146
147    #[test]
148    fn collect_defaults_named_params() {
149        let spec = ParamsSpec {
150            version: 1,
151            name: Some("dashboard_filters".to_string()),
152            params: vec![
153                ParamDef {
154                    id: "region".to_string(),
155                    param_type: "multiselect".to_string(),
156                    label: "Region".to_string(),
157                    options: Some(vec!["US".into(), "EU".into()]),
158                    default: Some(serde_json::json!(["US", "EU"])),
159                    placeholder: None,
160                    layout: None,
161                },
162                ParamDef {
163                    id: "min_revenue".to_string(),
164                    param_type: "number".to_string(),
165                    label: "Min Revenue".to_string(),
166                    options: None,
167                    default: Some(serde_json::json!(50000)),
168                    placeholder: None,
169                    layout: None,
170                },
171            ],
172        };
173
174        let defaults = collect_param_defaults(&[&spec]);
175        assert_eq!(defaults.get("dashboard_filters.region"), Some(&serde_json::json!(["US", "EU"])));
176        assert_eq!(defaults.get("dashboard_filters.min_revenue"), Some(&serde_json::json!(50000)));
177    }
178
179    #[test]
180    fn resolve_string_param() {
181        let mut params = ParamValues::new();
182        params.insert("top_n".to_string(), serde_json::json!(10));
183
184        let yaml = r#"limit: "$top_n""#;
185        let resolved = resolve_param_references(yaml, &params);
186        assert_eq!(resolved, "limit: 10");
187    }
188
189    #[test]
190    fn resolve_array_param() {
191        let mut params = ParamValues::new();
192        params.insert("dashboard_filters.region".to_string(), serde_json::json!(["US", "EU"]));
193
194        let yaml = r#"value: "$dashboard_filters.region""#;
195        let resolved = resolve_param_references(yaml, &params);
196        assert_eq!(resolved, r#"value: ["US", "EU"]"#);
197    }
198
199    #[test]
200    fn unresolved_param_stays() {
201        let params = ParamValues::new();
202        let yaml = r#"value: "$unknown.param""#;
203        let resolved = resolve_param_references(yaml, &params);
204        assert_eq!(resolved, r#"value: "$unknown.param""#);
205    }
206
207    #[test]
208    fn extract_refs() {
209        let yaml = r#"
210          value: "$dashboard_filters.region"
211          limit: "$top_n"
212        "#;
213        let refs = extract_param_references(yaml);
214        assert!(refs.contains(&"dashboard_filters.region".to_string()));
215        assert!(refs.contains(&"top_n".to_string()));
216    }
217}