Skip to main content

rest_e2e_mcp/variable/
resolver.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::types::{ResolvedVars, VarSource, VarTrace};
5
6/// 4層の変数ソースを統合して解決する。
7///
8/// 優先順位(高→低):
9/// 1. runtime(run の variables パラメータ)
10/// 2. suite_vars(YAMLファイル内の variables)
11/// 3. dotenv(.env ファイル)
12/// 4. OS環境変数
13pub fn resolve_variables(
14    runtime_vars: &HashMap<String, String>,
15    suite_vars: &HashMap<String, String>,
16    env_file: Option<&Path>,
17) -> ResolvedVars {
18    // .env読み込み
19    let dotenv_vars = load_dotenv(env_file);
20
21    // 全変数名を収集(BTreeSetで重複除去 + ソート済み)
22    let mut name_set = std::collections::BTreeSet::new();
23    name_set.extend(runtime_vars.keys().cloned());
24    name_set.extend(suite_vars.keys().cloned());
25    name_set.extend(dotenv_vars.keys().cloned());
26    // OS環境変数は明示的に列挙しない(他のソースで参照された名前のみ追加)
27    let all_names: Vec<String> = name_set.into_iter().collect();
28
29    let mut vars = HashMap::new();
30    let mut traces = Vec::new();
31
32    for name in &all_names {
33        let mut found_in: Vec<(VarSource, String)> = Vec::new();
34
35        // 低い優先順位から積む
36        if let Ok(val) = std::env::var(name) {
37            found_in.push((VarSource::OsEnv, val));
38        }
39        if let Some(val) = dotenv_vars.get(name) {
40            found_in.push((VarSource::DotEnv, val.clone()));
41        }
42        if let Some(val) = suite_vars.get(name) {
43            found_in.push((VarSource::SuiteFile, val.clone()));
44        }
45        if let Some(val) = runtime_vars.get(name) {
46            found_in.push((VarSource::Runtime, val.clone()));
47        }
48
49        if let Some((source, value)) = found_in.last() {
50            vars.insert(name.clone(), value.clone());
51            traces.push(VarTrace {
52                name: name.clone(),
53                value: value.clone(),
54                source: source.clone(),
55                found_in: found_in.clone(),
56            });
57        }
58    }
59
60    ResolvedVars { vars, traces }
61}
62
63/// 変数展開の結果。
64pub struct SubstituteResult {
65    /// 展開後のテキスト。
66    pub text: String,
67    /// 未解決の変数名リスト。
68    pub unresolved: Vec<String>,
69}
70
71/// 文字列中の変数参照を展開し、未解決変数も返す(ネスト変数を再帰的に解決)。
72pub fn substitute_with_check(input: &str, vars: &HashMap<String, String>) -> SubstituteResult {
73    const MAX_DEPTH: usize = 10;
74    let mut result = input.to_string();
75    for _ in 0..MAX_DEPTH {
76        let next = substitute_inner(&result, vars, None);
77        if next == result {
78            break;
79        }
80        result = next;
81    }
82    // 安定状態から未解決変数を収集
83    let mut unresolved = Vec::new();
84    let _ = substitute_inner(&result, vars, Some(&mut unresolved));
85    unresolved.sort();
86    unresolved.dedup();
87    SubstituteResult {
88        text: result,
89        unresolved,
90    }
91}
92
93/// 文字列中の変数参照を展開する(ネスト変数を再帰的に解決)。
94///
95/// 対応構文:
96/// - `{{VARIABLE_NAME}}` — テンプレート変数
97/// - `${ENV_VAR}` — ブレース付き変数参照
98/// - `${ENV_VAR:default}` — デフォルト値付き(ブレース構文のみ)
99/// - `$ENV_VAR` — 変数直接参照(単語境界で区切り、デフォルト値なし)
100pub fn substitute(input: &str, vars: &HashMap<String, String>) -> String {
101    const MAX_DEPTH: usize = 10;
102    let mut result = input.to_string();
103    for _ in 0..MAX_DEPTH {
104        let next = substitute_inner(&result, vars, None);
105        if next == result {
106            break;
107        }
108        result = next;
109    }
110    result
111}
112
113fn substitute_inner(
114    input: &str,
115    vars: &HashMap<String, String>,
116    mut unresolved_out: Option<&mut Vec<String>>,
117) -> String {
118    let mut result = String::with_capacity(input.len());
119    let chars: Vec<char> = input.chars().collect();
120    let mut i = 0;
121
122    while i < chars.len() {
123        // {{VAR}} 構文
124        if i + 1 < chars.len()
125            && chars[i] == '{'
126            && chars[i + 1] == '{'
127            && let Some(end) = find_closing_braces(&chars, i + 2)
128        {
129            let var_name: String = chars[i + 2..end].iter().collect();
130            let var_name = var_name.trim();
131            if let Some(val) = vars.get(var_name) {
132                result.push_str(val);
133            } else {
134                // 未解決の変数はそのまま残す
135                let original: String = chars[i..end + 2].iter().collect();
136                result.push_str(&original);
137                if let Some(ref mut out) = unresolved_out {
138                    out.push(var_name.to_string());
139                }
140            }
141            i = end + 2;
142            continue;
143        }
144
145        // ${VAR} or ${VAR:default} 構文
146        if i + 1 < chars.len()
147            && chars[i] == '$'
148            && chars[i + 1] == '{'
149            && let Some(end) = find_closing_brace(&chars, i + 2)
150        {
151            let inner: String = chars[i + 2..end].iter().collect();
152            let inner = inner.trim();
153
154            // ${VAR:default} 構文
155            if let Some((var_name, default_val)) = inner.split_once(':') {
156                let var_name = var_name.trim();
157                let default_val = default_val.trim();
158                if let Some(val) = vars.get(var_name) {
159                    result.push_str(val);
160                } else {
161                    result.push_str(default_val);
162                }
163            } else if let Some(val) = vars.get(inner) {
164                result.push_str(val);
165            } else {
166                let original: String = chars[i..end + 1].iter().collect();
167                result.push_str(&original);
168                if let Some(ref mut out) = unresolved_out {
169                    out.push(inner.to_string());
170                }
171            }
172            i = end + 1;
173            continue;
174        }
175
176        // $VAR 構文(bare dollar、デフォルト値なし)
177        if chars[i] == '$'
178            && i + 1 < chars.len()
179            && (chars[i + 1].is_ascii_alphabetic() || chars[i + 1] == '_')
180        {
181            let start = i + 1;
182            let mut end = start;
183            while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') {
184                end += 1;
185            }
186            let var_name: String = chars[start..end].iter().collect();
187
188            if let Some(val) = vars.get(&var_name) {
189                result.push_str(val);
190            } else {
191                result.push('$');
192                result.push_str(&var_name);
193                if let Some(ref mut out) = unresolved_out {
194                    out.push(var_name.clone());
195                }
196            }
197            i = end;
198            continue;
199        }
200
201        result.push(chars[i]);
202        i += 1;
203    }
204
205    result
206}
207
208/// .envファイルを読み込む。
209fn load_dotenv(path: Option<&Path>) -> HashMap<String, String> {
210    let mut map = HashMap::new();
211    if let Some(path) = path
212        && path.exists()
213        && let Ok(iter) = dotenvy::from_path_iter(path)
214    {
215        for (key, val) in iter.flatten() {
216            map.insert(key, val);
217        }
218    }
219    map
220}
221
222fn find_closing_braces(chars: &[char], start: usize) -> Option<usize> {
223    let mut i = start;
224    while i + 1 < chars.len() {
225        if chars[i] == '}' && chars[i + 1] == '}' {
226            return Some(i);
227        }
228        i += 1;
229    }
230    None
231}
232
233fn find_closing_brace(chars: &[char], start: usize) -> Option<usize> {
234    let mut i = start;
235    while i < chars.len() {
236        if chars[i] == '}' {
237            return Some(i);
238        }
239        i += 1;
240    }
241    None
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn make_vars(pairs: &[(&str, &str)]) -> HashMap<String, String> {
249        pairs
250            .iter()
251            .map(|(k, v)| (k.to_string(), v.to_string()))
252            .collect()
253    }
254
255    #[test]
256    fn substitute_double_braces() {
257        let vars = make_vars(&[("BASE_URL", "https://example.com")]);
258        assert_eq!(
259            substitute("{{BASE_URL}}/api", &vars),
260            "https://example.com/api"
261        );
262    }
263
264    #[test]
265    fn substitute_dollar_brace() {
266        let vars = make_vars(&[("API_KEY", "secret")]);
267        assert_eq!(substitute("Bearer ${API_KEY}", &vars), "Bearer secret");
268    }
269
270    #[test]
271    fn substitute_dollar_bare() {
272        let vars = make_vars(&[("HOST", "localhost")]);
273        assert_eq!(
274            substitute("http://$HOST:8080", &vars),
275            "http://localhost:8080"
276        );
277    }
278
279    #[test]
280    fn substitute_default_value_brace() {
281        let vars: HashMap<String, String> = HashMap::new();
282        assert_eq!(substitute("${MISSING:fallback}", &vars), "fallback");
283    }
284
285    #[test]
286    fn substitute_default_not_used_when_present_brace() {
287        let vars = make_vars(&[("PORT", "9090")]);
288        assert_eq!(substitute("${PORT:8080}", &vars), "9090");
289    }
290
291    #[test]
292    fn substitute_bare_dollar_no_default() {
293        // bare $VAR は :default を消費しない(URL port との衝突回避)
294        let vars = make_vars(&[("PORT", "9090")]);
295        assert_eq!(substitute("$PORT:8080", &vars), "9090:8080");
296    }
297
298    #[test]
299    fn substitute_unresolved_preserved() {
300        let vars: HashMap<String, String> = HashMap::new();
301        assert_eq!(substitute("{{UNKNOWN}}", &vars), "{{UNKNOWN}}");
302    }
303
304    #[test]
305    fn resolve_priority() {
306        // runtime > suite > dotenv > OS
307        let runtime = make_vars(&[("KEY", "from_runtime")]);
308        let suite = make_vars(&[("KEY", "from_suite"), ("OTHER", "suite_val")]);
309
310        let resolved = resolve_variables(&runtime, &suite, None);
311        assert_eq!(resolved.vars.get("KEY").unwrap(), "from_runtime");
312        assert_eq!(resolved.vars.get("OTHER").unwrap(), "suite_val");
313
314        // トレース確認
315        let key_trace = resolved.traces.iter().find(|t| t.name == "KEY").unwrap();
316        assert_eq!(key_trace.source, VarSource::Runtime);
317        assert!(key_trace.found_in.len() >= 2);
318    }
319
320    #[test]
321    fn substitute_with_check_detects_unresolved_double_braces() {
322        let vars = make_vars(&[("FOO", "abc")]);
323        let result = substitute_with_check("{{FOO}}/{{BAR}}/{{BAZ}}", &vars);
324        assert_eq!(result.text, "abc/{{BAR}}/{{BAZ}}");
325        assert_eq!(result.unresolved, vec!["BAR", "BAZ"]);
326    }
327
328    #[test]
329    fn substitute_with_check_detects_unresolved_dollar_brace() {
330        let vars: HashMap<String, String> = HashMap::new();
331        let result = substitute_with_check("${MISSING}", &vars);
332        assert_eq!(result.text, "${MISSING}");
333        assert_eq!(result.unresolved, vec!["MISSING"]);
334    }
335
336    #[test]
337    fn substitute_with_check_detects_unresolved_bare_dollar() {
338        let vars: HashMap<String, String> = HashMap::new();
339        let result = substitute_with_check("$BARE_VAR", &vars);
340        assert_eq!(result.text, "$BARE_VAR");
341        assert_eq!(result.unresolved, vec!["BARE_VAR"]);
342    }
343
344    #[test]
345    fn substitute_with_check_no_unresolved() {
346        let vars = make_vars(&[("HOST", "localhost"), ("PORT", "8080")]);
347        let result = substitute_with_check("{{HOST}}:${PORT}", &vars);
348        assert_eq!(result.text, "localhost:8080");
349        assert!(result.unresolved.is_empty());
350    }
351
352    #[test]
353    fn substitute_with_check_deduplicates() {
354        let vars: HashMap<String, String> = HashMap::new();
355        let result = substitute_with_check("{{X}}/{{X}}/{{X}}", &vars);
356        assert_eq!(result.unresolved, vec!["X"]);
357    }
358
359    #[test]
360    fn substitute_with_check_default_value_not_unresolved() {
361        let vars: HashMap<String, String> = HashMap::new();
362        let result = substitute_with_check("${MISSING:fallback}", &vars);
363        assert_eq!(result.text, "fallback");
364        assert!(result.unresolved.is_empty());
365    }
366
367    #[test]
368    fn substitute_nested_variables() {
369        let vars = make_vars(&[
370            ("ASSET_ID", "{{ASSET_ID_GROUP}}"),
371            ("ASSET_ID_GROUP", "abc123"),
372        ]);
373        assert_eq!(substitute("{{ASSET_ID}}", &vars), "abc123");
374    }
375
376    #[test]
377    fn substitute_nested_three_levels() {
378        let vars = make_vars(&[("A", "{{B}}"), ("B", "{{C}}"), ("C", "final_value")]);
379        assert_eq!(substitute("{{A}}", &vars), "final_value");
380    }
381
382    #[test]
383    fn substitute_nested_circular_stops() {
384        let vars = make_vars(&[("A", "{{B}}"), ("B", "{{A}}")]);
385        // 無限ループせず停止すること
386        let result = substitute("{{A}}", &vars);
387        assert!(result.contains("{{"));
388    }
389
390    #[test]
391    fn substitute_with_check_nested_unresolved() {
392        let vars = make_vars(&[("ASSET_ID", "{{MISSING}}")]);
393        let result = substitute_with_check("{{ASSET_ID}}", &vars);
394        assert_eq!(result.text, "{{MISSING}}");
395        assert_eq!(result.unresolved, vec!["MISSING"]);
396    }
397
398    #[test]
399    fn substitute_nested_mixed_syntax() {
400        let vars = make_vars(&[("A", "$B"), ("B", "resolved")]);
401        assert_eq!(substitute("{{A}}", &vars), "resolved");
402    }
403}