Skip to main content

floe_core/vars/
resolve.rs

1use std::collections::HashMap;
2
3use crate::{ConfigError, FloeResult};
4
5/// Maximum recursive expansion depth.  Prevents stack exhaustion from
6/// pathologically deep (non-cyclic) variable chains and acts as a second
7/// safety net alongside the explicit cycle check.
8const MAX_DEPTH: usize = 32;
9
10/// The three sources of variables, listed from **lowest** to **highest**
11/// precedence.  When the same key appears in multiple sources the value
12/// from the highest-precedence source wins.
13///
14/// ```text
15/// config variables  >  CLI overrides  >  profile variables
16/// ```
17pub struct VarSources<'a> {
18    /// Variables defined in the environment profile (`variables:` section).
19    /// Lowest precedence.
20    pub profile: &'a HashMap<String, String>,
21    /// Variables supplied via the CLI (`--var KEY=VALUE`).
22    /// Mid precedence.
23    pub cli: &'a HashMap<String, String>,
24    /// Variables defined in the main Floe config (`env.vars:`).
25    /// Highest precedence.
26    pub config: &'a HashMap<String, String>,
27}
28
29/// Merge variable sources with correct precedence and expand all `${VAR}`
30/// placeholders in variable *values*, including nested references.
31///
32/// # Resolution rules
33///
34/// 1. **Merge** – build a raw map applying the precedence chain:
35///    `config > cli > profile`.  Higher-precedence keys overwrite lower ones.
36/// 2. **Expand** – for every value, substitute `${KEY}` with the resolved
37///    value of `KEY` from the merged map, recursively.  A value may reference
38///    another variable whose value also contains `${…}` references.
39/// 3. **Fail-fast** – any placeholder that cannot be resolved (key not present
40///    in the merged map) or that forms a cycle produces an immediate,
41///    actionable error.
42///
43/// # Errors
44///
45/// Returns an error if:
46/// - A `${KEY}` reference in a variable value has no corresponding key in the
47///   merged map.
48/// - A cycle is detected (e.g., `A = ${B}`, `B = ${A}`).
49/// - An unclosed `${` or empty `${}` placeholder is found.
50pub fn resolve_vars(sources: VarSources<'_>) -> FloeResult<HashMap<String, String>> {
51    let raw = merge(sources);
52    expand_all(&raw)
53}
54
55// ---------------------------------------------------------------------------
56// Internal helpers
57// ---------------------------------------------------------------------------
58
59/// Build the merged raw map (no expansion yet).
60fn merge(sources: VarSources<'_>) -> HashMap<String, String> {
61    // Start from lowest precedence and let each higher layer overwrite.
62    let mut merged =
63        HashMap::with_capacity(sources.profile.len() + sources.cli.len() + sources.config.len());
64    merged.extend(sources.profile.iter().map(|(k, v)| (k.clone(), v.clone())));
65    merged.extend(sources.cli.iter().map(|(k, v)| (k.clone(), v.clone())));
66    merged.extend(sources.config.iter().map(|(k, v)| (k.clone(), v.clone())));
67    merged
68}
69
70/// Expand every value in `raw` by resolving `${VAR}` references.
71fn expand_all(raw: &HashMap<String, String>) -> FloeResult<HashMap<String, String>> {
72    let mut resolved: HashMap<String, String> = HashMap::with_capacity(raw.len());
73    for key in raw.keys() {
74        if !resolved.contains_key(key.as_str()) {
75            let mut in_progress: Vec<String> = Vec::new();
76            expand_key(key, raw, &mut resolved, &mut in_progress, 0)?;
77        }
78    }
79    Ok(resolved)
80}
81
82/// Recursively expand the value of `key`, caching the result in `resolved`.
83fn expand_key(
84    key: &str,
85    raw: &HashMap<String, String>,
86    resolved: &mut HashMap<String, String>,
87    in_progress: &mut Vec<String>,
88    depth: usize,
89) -> FloeResult<String> {
90    // Already expanded — return cached result.
91    if let Some(v) = resolved.get(key) {
92        return Ok(v.clone());
93    }
94
95    // Depth guard.
96    if depth > MAX_DEPTH {
97        return Err(Box::new(ConfigError(format!(
98            "variable expansion exceeded maximum depth ({MAX_DEPTH}); \
99             check for deeply nested or circular references near \"${{{key}}}\""
100        ))));
101    }
102
103    // Cycle detection.
104    if in_progress.iter().any(|k| k == key) {
105        let chain: Vec<&str> = in_progress.iter().map(|s| s.as_str()).collect();
106        return Err(Box::new(ConfigError(format!(
107            "circular variable reference detected: {} -> {}",
108            chain.join(" -> "),
109            key
110        ))));
111    }
112
113    let raw_value = raw.get(key).ok_or_else(|| {
114        Box::new(ConfigError(format!(
115            "variable \"${{{key}}}\" is referenced but not defined"
116        ))) as Box<dyn std::error::Error + Send + Sync>
117    })?;
118
119    in_progress.push(key.to_string());
120    let expanded = expand_value(raw_value, key, raw, resolved, in_progress, depth + 1)?;
121    in_progress.pop();
122
123    resolved.insert(key.to_string(), expanded.clone());
124    Ok(expanded)
125}
126
127/// Substitute every `${VAR}` occurrence in `value`, recursively resolving
128/// each referenced variable.
129fn expand_value(
130    value: &str,
131    owner_key: &str,
132    raw: &HashMap<String, String>,
133    resolved: &mut HashMap<String, String>,
134    in_progress: &mut Vec<String>,
135    depth: usize,
136) -> FloeResult<String> {
137    let mut result = String::with_capacity(value.len());
138    let mut rest = value;
139
140    while let Some(start) = rest.find("${") {
141        result.push_str(&rest[..start]);
142        rest = &rest[start + 2..];
143
144        let end = rest.find('}').ok_or_else(|| {
145            Box::new(ConfigError(format!(
146                "variable \"{owner_key}\": unclosed placeholder in value {value:?}"
147            ))) as Box<dyn std::error::Error + Send + Sync>
148        })?;
149
150        let ref_key = rest[..end].trim();
151        if ref_key.is_empty() {
152            return Err(Box::new(ConfigError(format!(
153                "variable \"{owner_key}\": empty placeholder ${{}}"
154            ))));
155        }
156
157        let ref_value = expand_key(ref_key, raw, resolved, in_progress, depth)?;
158        result.push_str(&ref_value);
159        rest = &rest[end + 1..];
160    }
161
162    result.push_str(rest);
163    Ok(result)
164}