1use std::path::{Path, PathBuf};
25
26use serde_json::Value;
27
28pub 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 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
57pub 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 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 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 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 Ok(text.to_string())
114}
115
116pub 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
122fn 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
148fn expand_glob(pattern: &str, base_dir: &Path) -> Result<Vec<PathBuf>, String> {
155 let resolved = resolve_path(base_dir, pattern);
156 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 if resolved.is_file() {
173 return Ok(vec![resolved]);
174 }
175 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
187fn 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
221fn wildcard_match(pattern: &str, text: &str) -> bool {
223 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 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
249fn 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
259fn 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
267fn 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
283fn 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 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 }
308 out.insert(k.clone(), patch_val.clone());
309 }
310 Ok(Value::Object(out))
311 }
312 _ => Ok(patch.clone()),
313 }
314}
315
316fn 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 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}