dwara_core/config/
includes.rs

1//! Config includes and file-based profiles (CFG-01, #180).
2//!
3//! A config document may carry a top-level `includes:` key listing file
4//! paths or directory globs (relative to the config file's directory).
5//! [`resolve_includes`] reads each, recursively resolves includes in
6//! them, and merges the result into the base document with conflict
7//! detection: the same scalar key set in two documents is an error
8//! (forces the operator to be explicit), while collection keys
9//! (`listeners`, `routes`, `upstreams`, ...) from an include REPLACE
10//! the base's (an include is a full topology overlay, not a delta).
11//!
12//! File-based profiles: when `gateway.lifecycle.profiles.profiles_dir`
13//! is set, the selected profile's patch is loaded from
14//! `<profiles_dir>/<profile>.yaml` instead of an inline YAML string.
15//! [`apply_profile_overlay`] resolves the profile (from an explicit
16//! name or the `DWARA_PROFILE` env var) and merges the patch onto the
17//! base. This is backward compatible: inline `profile_overrides` still
18//! work when `profiles_dir` is absent.
19//!
20//! Both operate on raw YAML text BEFORE [`super::parse_gateway`], so
21//! the `includes:` / `profiles_dir` directives are consumed and never
22//! reach the strict `deny_unknown_fields` Gateway struct.
23
24use std::path::{Path, PathBuf};
25
26use serde_json::Value;
27
28/// Resolve `includes:` directives in a config document. The
29/// `includes:` top-level key lists file paths or directory globs
30/// (relative to `base_dir`). Each is read, recursively resolved, and
31/// merged into the base. The `includes:` key is removed from the
32/// result. Returns the merged YAML text.
33pub fn resolve_includes(text: &str, base_dir: &Path) -> Result<String, String> {
34    let mut doc: Value = serde_yaml_ng::from_str(text)
35        .map_err(|e| format!("includes: cannot parse config as YAML: {e}"))?;
36    let includes = extract_includes(&mut doc);
37    if includes.is_empty() {
38        // No includes: return the original text unchanged (avoid a
39        // round-trip that could reorder keys).
40        return Ok(text.to_string());
41    }
42    for pattern in includes {
43        let paths = expand_glob(&pattern, base_dir)?;
44        for path in paths {
45            let inc_dir = path.parent().unwrap_or(base_dir).to_path_buf();
46            let inc_text = std::fs::read_to_string(&path)
47                .map_err(|e| format!("includes: cannot read {}: {e}", path.display()))?;
48            let inc_resolved = resolve_includes(&inc_text, &inc_dir)?;
49            let inc_doc: Value = serde_yaml_ng::from_str(&inc_resolved)
50                .map_err(|e| format!("includes: cannot parse {}: {e}", path.display()))?;
51            doc = merge_with_conflict_detection(&doc, &inc_doc, &path.display().to_string())?;
52        }
53    }
54    serde_yaml_ng::to_string(&doc).map_err(|e| format!("includes: serialize failed: {e}"))
55}
56
57/// Apply the selected profile's overlay onto the base config. The
58/// profile is `profile` when given, else the `DWARA_PROFILE` env var.
59/// When `gateway.lifecycle.profiles.profiles_dir` is set, the patch is
60/// loaded from `<profiles_dir>/<profile>.yaml`; otherwise the inline
61/// `profile_overrides` map is used (backward compat). The merged YAML
62/// text is returned.
63pub fn apply_profile_overlay(
64    text: &str,
65    base_dir: &Path,
66    profile: Option<&str>,
67) -> Result<String, String> {
68    let profile = profile
69        .map(|s| s.to_string())
70        .or_else(|| std::env::var("DWARA_PROFILE").ok())
71        .filter(|s| !s.trim().is_empty());
72    let Some(profile) = profile else {
73        return Ok(text.to_string());
74    };
75    let doc: Value = serde_yaml_ng::from_str(text)
76        .map_err(|e| format!("profile: cannot parse config as YAML: {e}"))?;
77    // Navigate to gateway.lifecycle.profiles.
78    let profiles = doc
79        .get("gateway")
80        .and_then(|g| g.get("lifecycle"))
81        .and_then(|l| l.get("profiles"));
82    let Some(profiles) = profiles else {
83        return Ok(text.to_string());
84    };
85    // File-based profiles take precedence when profiles_dir is set.
86    if let Some(dir) = profiles.get("profiles_dir").and_then(Value::as_str) {
87        let profiles_dir = resolve_path(base_dir, dir);
88        let profile_file = profiles_dir.join(format!("{profile}.yaml"));
89        let patch_text = std::fs::read_to_string(&profile_file).map_err(|e| {
90            format!(
91                "profile: cannot read {} (profiles_dir={}, profile={profile}): {e}",
92                profile_file.display(),
93                profiles_dir.display()
94            )
95        })?;
96        let patch: Value = serde_yaml_ng::from_str(&patch_text)
97            .map_err(|e| format!("profile: cannot parse {}: {e}", profile_file.display()))?;
98        let merged = shallow_merge(&doc, &patch);
99        return serde_yaml_ng::to_string(&merged)
100            .map_err(|e| format!("profile: serialize failed: {e}"));
101    }
102    // Inline profile_overrides (backward compat).
103    if let Some(overrides) = profiles.get("profile_overrides").and_then(Value::as_object) {
104        if let Some(patch_yaml) = overrides.get(&profile).and_then(Value::as_str) {
105            let patch: Value = serde_yaml_ng::from_str(patch_yaml)
106                .map_err(|e| format!("profile: cannot parse inline patch for {profile}: {e}"))?;
107            let merged = shallow_merge(&doc, &patch);
108            return serde_yaml_ng::to_string(&merged)
109                .map_err(|e| format!("profile: serialize failed: {e}"));
110        }
111    }
112    // Profile selected but no patch found: return base unchanged.
113    Ok(text.to_string())
114}
115
116/// Full preprocessing: resolve includes, then apply the profile.
117pub fn preprocess(text: &str, base_dir: &Path, profile: Option<&str>) -> Result<String, String> {
118    let with_includes = resolve_includes(text, base_dir)?;
119    apply_profile_overlay(&with_includes, base_dir, profile)
120}
121
122// ---------------------------------------------------------------------------
123// Internals
124// ---------------------------------------------------------------------------
125
126/// Extract and remove the `includes:` key from a YAML document. Returns
127/// the list of include patterns (file paths or globs).
128fn extract_includes(doc: &mut Value) -> Vec<String> {
129    let Some(map) = doc.as_object_mut() else {
130        return Vec::new();
131    };
132    let Some(includes_val) = map.remove("includes") else {
133        return Vec::new();
134    };
135    match includes_val {
136        Value::String(s) => vec![s],
137        Value::Array(arr) => arr
138            .into_iter()
139            .filter_map(|v| match v {
140                Value::String(s) => Some(s),
141                _ => None,
142            })
143            .collect(),
144        _ => Vec::new(),
145    }
146}
147
148/// Expand a glob pattern relative to `base_dir`. Supports:
149/// - exact file paths (`configs/base.yaml`)
150/// - directory paths (`configs/` — all `*.yaml`/`*.yml` inside, sorted)
151/// - simple `*` globs (`configs/*.yaml`)
152///
153/// Returns sorted, deduplicated paths.
154fn expand_glob(pattern: &str, base_dir: &Path) -> Result<Vec<PathBuf>, String> {
155    let resolved = resolve_path(base_dir, pattern);
156    // Directory: read all yaml files inside.
157    if resolved.is_dir() {
158        let mut paths = Vec::new();
159        for entry in std::fs::read_dir(&resolved)
160            .map_err(|e| format!("includes: cannot read dir {}: {e}", resolved.display()))?
161        {
162            let entry = entry.map_err(|e| format!("includes: dir entry: {e}"))?;
163            let path = entry.path();
164            if path.is_file() && matches_ext(&path, "yaml", "yml") {
165                paths.push(path);
166            }
167        }
168        paths.sort();
169        return Ok(paths);
170    }
171    // Exact file.
172    if resolved.is_file() {
173        return Ok(vec![resolved]);
174    }
175    // Glob with '*': match against the parent directory's entries.
176    let pattern_str = pattern.replace('\\', "/");
177    if pattern_str.contains('*') {
178        return expand_wildcard(&pattern_str, base_dir);
179    }
180    Err(format!(
181        "includes: pattern '{}' resolved to '{}' which is not a file or directory",
182        pattern,
183        resolved.display()
184    ))
185}
186
187/// Expand a simple wildcard pattern (supports `*` for any non-separator
188/// chars and `**` for any chars including separators).
189fn expand_wildcard(pattern: &str, base_dir: &Path) -> Result<Vec<PathBuf>, String> {
190    let full = resolve_path(base_dir, pattern);
191    let parent = full.parent().unwrap_or(base_dir);
192    if !parent.is_dir() {
193        return Err(format!(
194            "includes: glob parent dir {} does not exist",
195            parent.display()
196        ));
197    }
198    let glob_suffix = full.file_name().and_then(|s| s.to_str()).unwrap_or("");
199    let mut paths = Vec::new();
200    for entry in std::fs::read_dir(parent)
201        .map_err(|e| format!("includes: cannot read glob dir {}: {e}", parent.display()))?
202    {
203        let entry = entry.map_err(|e| format!("includes: glob dir entry: {e}"))?;
204        let name = entry.file_name();
205        let name_str = name.to_string_lossy();
206        if wildcard_match(glob_suffix, &name_str)
207            && entry.path().is_file()
208            && matches_ext(&entry.path(), "yaml", "yml")
209        {
210            paths.push(entry.path());
211        }
212    }
213    paths.sort();
214    if paths.is_empty() {
215        Ok(Vec::new())
216    } else {
217        Ok(paths)
218    }
219}
220
221/// Match a glob pattern with `*` (any chars) against a string.
222fn wildcard_match(pattern: &str, text: &str) -> bool {
223    // Simple recursive wildcard match: '*' matches any sequence.
224    let p: Vec<char> = pattern.chars().collect();
225    let t: Vec<char> = text.chars().collect();
226    wildcard_match_inner(&p, 0, &t, 0)
227}
228
229fn wildcard_match_inner(p: &[char], pi: usize, t: &[char], ti: usize) -> bool {
230    if pi == p.len() {
231        return ti == t.len();
232    }
233    if p[pi] == '*' {
234        // '*' matches zero or more chars.
235        if wildcard_match_inner(p, pi + 1, t, ti) {
236            return true;
237        }
238        if ti < t.len() && wildcard_match_inner(p, pi, t, ti + 1) {
239            return true;
240        }
241        return false;
242    }
243    if ti < t.len() && p[pi] == t[ti] {
244        return wildcard_match_inner(p, pi + 1, t, ti + 1);
245    }
246    false
247}
248
249/// Resolve a path that may be relative to `base_dir` or absolute.
250fn resolve_path(base_dir: &Path, path: &str) -> PathBuf {
251    let p = Path::new(path);
252    if p.is_absolute() {
253        p.to_path_buf()
254    } else {
255        base_dir.join(p)
256    }
257}
258
259/// Check if a path has one of the given extensions.
260fn matches_ext(path: &Path, ext1: &str, ext2: &str) -> bool {
261    path.extension()
262        .and_then(|e| e.to_str())
263        .map(|e| e == ext1 || e == ext2)
264        .unwrap_or(false)
265}
266
267/// Shallow JSON merge: patch's top-level keys overwrite base's; keys
268/// only in the base are preserved. No conflict detection (the caller
269/// decides whether to use [`merge_with_conflict_detection`] instead).
270fn shallow_merge(base: &Value, patch: &Value) -> Value {
271    match (base, patch) {
272        (Value::Object(base_map), Value::Object(patch_map)) => {
273            let mut out = base_map.clone();
274            for (k, v) in patch_map {
275                out.insert(k.clone(), v.clone());
276            }
277            Value::Object(out)
278        }
279        _ => patch.clone(),
280    }
281}
282
283/// Merge two YAML documents with conflict detection: the same SCALAR
284/// key present in both is an error (forces explicitness). Collection
285/// keys (arrays/objects) from the patch REPLACE the base's (an include
286/// is a full overlay). `source` is the include file path for error
287/// context.
288fn merge_with_conflict_detection(
289    base: &Value,
290    patch: &Value,
291    source: &str,
292) -> Result<Value, String> {
293    match (base, patch) {
294        (Value::Object(base_map), Value::Object(patch_map)) => {
295            let mut out = base_map.clone();
296            for (k, patch_val) in patch_map {
297                if let Some(base_val) = out.get(k) {
298                    // Conflict on scalar keys (neither side is a collection).
299                    if !is_collection(base_val) && !is_collection(patch_val) {
300                        return Err(format!(
301                            "includes: conflict on key '{k}' (scalar set in both \
302                             base and {source}); move it to one file or make it a \
303                             collection"
304                        ));
305                    }
306                    // Collections: patch replaces base (full overlay).
307                }
308                out.insert(k.clone(), patch_val.clone());
309            }
310            Ok(Value::Object(out))
311        }
312        _ => Ok(patch.clone()),
313    }
314}
315
316/// Whether a JSON value is a collection (array or object).
317fn is_collection(v: &Value) -> bool {
318    matches!(v, Value::Array(_) | Value::Object(_))
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use std::io::Write as _;
325
326    fn tmpdir() -> tempfile::TempDir {
327        tempfile::tempdir().expect("tempdir")
328    }
329
330    fn write(dir: &Path, name: &str, content: &str) -> PathBuf {
331        let path = dir.join(name);
332        let mut f = std::fs::File::create(&path).expect("create");
333        f.write_all(content.as_bytes()).expect("write");
334        path
335    }
336
337    #[test]
338    fn resolve_includes_no_includes() {
339        let dir = tmpdir();
340        let text = "listeners: []\nroutes: []\n";
341        let out = resolve_includes(text, dir.path()).unwrap();
342        assert_eq!(out, text);
343    }
344
345    #[test]
346    fn resolve_includes_single_file() {
347        let dir = tmpdir();
348        write(
349            dir.path(),
350            "base.yaml",
351            "listeners:\n  - name: main\n    port: 8080\n",
352        );
353        write(
354            dir.path(),
355            "routes.yaml",
356            "routes:\n  - name: api\n    path: /api\n",
357        );
358        let text = "includes: [routes.yaml]\nlisteners:\n  - name: main\n    port: 8080\n";
359        let out = resolve_includes(text, dir.path()).unwrap();
360        assert!(out.contains("routes:"));
361        assert!(out.contains("listeners:"));
362        assert!(!out.contains("includes:"));
363    }
364
365    #[test]
366    fn resolve_includes_directory() {
367        let dir = tmpdir();
368        std::fs::create_dir(dir.path().join("conf.d")).unwrap();
369        write(
370            dir.path(),
371            "conf.d/a.yaml",
372            "routes:\n  - name: a\n    path: /a\n",
373        );
374        write(dir.path(), "conf.d/b.yaml", "upstreams:\n  - name: u1\n");
375        let text = "includes: [conf.d]\nlisteners: []\n";
376        let out = resolve_includes(text, dir.path()).unwrap();
377        assert!(out.contains("routes:"));
378        assert!(out.contains("upstreams:"));
379    }
380
381    #[test]
382    fn resolve_includes_glob() {
383        let dir = tmpdir();
384        std::fs::create_dir(dir.path().join("conf.d")).unwrap();
385        write(dir.path(), "conf.d/a.yaml", "routes:\n  - name: a\n");
386        write(dir.path(), "conf.d/b.yaml", "upstreams:\n  - name: u1\n");
387        write(dir.path(), "conf.d/readme.txt", "ignore me\n");
388        let text = "includes: [\"conf.d/*.yaml\"]\nlisteners: []\n";
389        let out = resolve_includes(text, dir.path()).unwrap();
390        assert!(out.contains("routes:"));
391        assert!(out.contains("upstreams:"));
392    }
393
394    #[test]
395    fn resolve_includes_conflict_detected() {
396        let dir = tmpdir();
397        write(dir.path(), "base.yaml", "version: 1\n");
398        write(dir.path(), "inc.yaml", "version: 2\n");
399        let text = "includes: [inc.yaml]\nversion: 1\n";
400        let err = resolve_includes(text, dir.path()).unwrap_err();
401        assert!(err.contains("conflict"));
402        assert!(err.contains("version"));
403    }
404
405    #[test]
406    fn resolve_includes_recursive() {
407        let dir = tmpdir();
408        std::fs::create_dir(dir.path().join("sub")).unwrap();
409        write(dir.path(), "sub/c.yaml", "consumers: []\n");
410        write(dir.path(), "b.yaml", "includes: [sub/c.yaml]\nroutes: []\n");
411        let text = "includes: [b.yaml]\nlisteners: []\n";
412        let out = resolve_includes(text, dir.path()).unwrap();
413        assert!(out.contains("consumers:"));
414        assert!(out.contains("routes:"));
415    }
416
417    #[test]
418    fn resolve_includes_collection_replaces_not_conflict() {
419        let dir = tmpdir();
420        write(dir.path(), "inc.yaml", "routes:\n  - name: r2\n");
421        let text = "includes: [inc.yaml]\nroutes:\n  - name: r1\n";
422        let out = resolve_includes(text, dir.path()).unwrap();
423        // Collection keys replace, not conflict.
424        assert!(out.contains("r2"));
425    }
426
427    #[test]
428    fn apply_profile_overlay_no_profiles_block() {
429        let dir = tmpdir();
430        let text = "listeners: []\n";
431        let out = apply_profile_overlay(text, dir.path(), Some("prod")).unwrap();
432        assert_eq!(out, text);
433    }
434
435    #[test]
436    fn apply_profile_overlay_file_based() {
437        let dir = tmpdir();
438        std::fs::create_dir(dir.path().join("profiles")).unwrap();
439        write(
440            dir.path(),
441            "profiles/prod.yaml",
442            "routes:\n  - name: prod-route\n",
443        );
444        let text =
445            "gateway:\n  lifecycle:\n    profiles:\n      profiles_dir: profiles\nroutes: []\n";
446        let out = apply_profile_overlay(text, dir.path(), Some("prod")).unwrap();
447        assert!(out.contains("prod-route"));
448    }
449
450    #[test]
451    fn apply_profile_overlay_inline() {
452        let dir = tmpdir();
453        let text = "gateway:\n  lifecycle:\n    profiles:\n    base_config: |\n      routes: []\n    profile_overrides:\n      prod: |\n        routes:\n          - name: prod-route\n";
454        let out = apply_profile_overlay(text, dir.path(), Some("prod")).unwrap();
455        assert!(out.contains("prod-route"));
456    }
457
458    #[test]
459    fn apply_profile_overlay_none_selected() {
460        let dir = tmpdir();
461        let text = "listeners: []\n";
462        let out = apply_profile_overlay(text, dir.path(), None).unwrap();
463        assert_eq!(out, text);
464    }
465
466    #[test]
467    fn preprocess_combines() {
468        let dir = tmpdir();
469        std::fs::create_dir(dir.path().join("profiles")).unwrap();
470        write(
471            dir.path(),
472            "profiles/prod.yaml",
473            "routes:\n  - name: prod-route\n",
474        );
475        write(dir.path(), "inc.yaml", "upstreams: []\n");
476        let text =
477            "includes: [inc.yaml]\ngateway:\n  lifecycle:\n    profiles:\n      profiles_dir: profiles\nroutes: []\n";
478        let out = preprocess(text, dir.path(), Some("prod")).unwrap();
479        assert!(out.contains("upstreams:"));
480        assert!(out.contains("prod-route"));
481    }
482
483    #[test]
484    fn wildcard_match_basic() {
485        assert!(wildcard_match("*.yaml", "a.yaml"));
486        assert!(wildcard_match("*.yaml", "config.yaml"));
487        assert!(!wildcard_match("*.yaml", "a.txt"));
488        assert!(wildcard_match("*", "anything"));
489        assert!(wildcard_match("a*", "abc"));
490        assert!(!wildcard_match("a*", "bcd"));
491    }
492}