1use std::path::{Path, PathBuf};
25
26use serde_yaml::{Mapping, Value};
27
28pub fn deep_merge(base: Value, over: Value) -> Value {
36 match (base, over) {
37 (Value::Mapping(base_map), Value::Mapping(over_map)) => {
38 Value::Mapping(merge_mapping(base_map, over_map))
39 }
40 (_, over) => over,
42 }
43}
44
45fn merge_mapping(mut base: Mapping, over: Mapping) -> Mapping {
46 for (k, v) in over {
47 if matches!(v, Value::Null) {
48 base.remove(&k);
49 continue;
50 }
51 match base.remove(&k) {
52 Some(existing) => {
53 base.insert(k, deep_merge(existing, v));
54 }
55 None => {
56 base.insert(k, v);
57 }
58 }
59 }
60 base
61}
62
63pub fn merge_layers<I>(layers: I) -> Value
66where
67 I: IntoIterator<Item = Value>,
68{
69 layers
70 .into_iter()
71 .reduce(deep_merge)
72 .unwrap_or(Value::Null)
73}
74
75const EXTENSIONS: &[&str] = &["yml", "yaml", "json"];
80
81pub fn find_env_file(base_config: &Path, env: &str) -> Option<PathBuf> {
87 let dir = base_config.parent()?;
88 for ext in EXTENSIONS {
89 let candidate = dir.join(format!("fdl.{env}.{ext}"));
90 if candidate.is_file() {
91 return Some(candidate);
92 }
93 }
94 None
95}
96
97pub fn list_envs(base_config: &Path) -> Vec<String> {
103 let Some(dir) = base_config.parent() else {
104 return Vec::new();
105 };
106 let entries = match std::fs::read_dir(dir) {
107 Ok(r) => r,
108 Err(_) => return Vec::new(),
109 };
110 let mut envs = std::collections::BTreeSet::new();
111 for entry in entries.flatten() {
112 let name = entry.file_name();
113 let Some(name_str) = name.to_str() else {
114 continue;
115 };
116 let Some(stripped) = name_str.strip_prefix("fdl.") else {
117 continue;
118 };
119 let Some((env, ext)) = stripped.rsplit_once('.') else {
121 continue;
122 };
123 if env.is_empty() || !EXTENSIONS.contains(&ext) {
124 continue;
125 }
126 envs.insert(env.to_string());
127 }
128 envs.into_iter().collect()
129}
130
131#[derive(Debug, Clone)]
149pub enum AnnotatedNode {
150 Leaf { value: Value, source: usize },
153 Map { entries: Vec<(Value, AnnotatedNode)> },
157}
158
159impl AnnotatedNode {
160 pub fn to_value(&self) -> Value {
163 match self {
164 AnnotatedNode::Leaf { value, .. } => value.clone(),
165 AnnotatedNode::Map { entries } => {
166 let mut m = Mapping::new();
167 for (k, v) in entries {
168 m.insert(k.clone(), v.to_value());
169 }
170 Value::Mapping(m)
171 }
172 }
173 }
174}
175
176pub fn merge_layers_annotated(layers: &[Value]) -> AnnotatedNode {
180 if layers.is_empty() {
181 return AnnotatedNode::Leaf {
182 value: Value::Null,
183 source: 0,
184 };
185 }
186
187 let mut result = to_annotated(&layers[0], 0);
188 for (i, layer) in layers.iter().enumerate().skip(1) {
189 result = deep_merge_annotated(result, layer, i);
190 }
191 result
192}
193
194fn to_annotated(v: &Value, source: usize) -> AnnotatedNode {
196 match v {
197 Value::Mapping(m) => {
198 let entries = m
199 .iter()
200 .map(|(k, v)| (k.clone(), to_annotated(v, source)))
201 .collect();
202 AnnotatedNode::Map { entries }
203 }
204 other => AnnotatedNode::Leaf {
205 value: other.clone(),
206 source,
207 },
208 }
209}
210
211fn deep_merge_annotated(
215 base: AnnotatedNode,
216 over: &Value,
217 over_source: usize,
218) -> AnnotatedNode {
219 match (base, over) {
220 (AnnotatedNode::Map { mut entries }, Value::Mapping(over_map)) => {
221 for (k, v) in over_map {
222 if matches!(v, Value::Null) {
223 entries.retain(|(ek, _)| ek != k);
224 continue;
225 }
226 let pos = entries.iter().position(|(ek, _)| ek == k);
227 match pos {
228 Some(p) => {
229 let (_, existing) = entries.remove(p);
232 let merged = deep_merge_annotated(existing, v, over_source);
233 entries.push((k.clone(), merged));
234 }
235 None => {
236 entries.push((k.clone(), to_annotated(v, over_source)));
237 }
238 }
239 }
240 AnnotatedNode::Map { entries }
241 }
242 (_, over) => to_annotated(over, over_source),
244 }
245}
246
247pub fn render_annotated_yaml(node: &AnnotatedNode, source_labels: &[String]) -> String {
257 let mut raw = String::new();
261 render_node(node, 0, source_labels, &mut raw);
262 align_comments(&raw)
263}
264
265const INLINE_SEQ_LIMIT: usize = 80;
268
269fn render_node(node: &AnnotatedNode, indent: usize, labels: &[String], out: &mut String) {
270 match node {
271 AnnotatedNode::Leaf { value, source } => {
272 let tag = label(labels, *source);
274 emit_line(out, indent, &format_scalar(value), Some(&tag));
275 }
276 AnnotatedNode::Map { entries } => {
277 for (k, child) in entries {
278 let key = format_key(k);
279 match child {
280 AnnotatedNode::Leaf { value, source } => {
281 let tag = label(labels, *source);
282 render_leaf_entry(&key, value, &tag, indent, out);
283 }
284 AnnotatedNode::Map { .. } => {
285 emit_header(out, indent, &format!("{key}:"));
288 render_node(child, indent + 2, labels, out);
289 }
290 }
291 }
292 }
293 }
294}
295
296fn render_leaf_entry(key: &str, value: &Value, tag: &str, indent: usize, out: &mut String) {
297 match value {
298 Value::Sequence(items) if items.iter().all(is_inline_scalar) => {
299 let inline = format!(
300 "{key}: [{}]",
301 items
302 .iter()
303 .map(format_scalar)
304 .collect::<Vec<_>>()
305 .join(", ")
306 );
307 if indent + inline.len() <= INLINE_SEQ_LIMIT {
308 emit_line(out, indent, &inline, Some(tag));
309 } else {
310 emit_line(out, indent, &format!("{key}:"), Some(tag));
311 for item in items {
312 emit_header(out, indent + 2, &format!("- {}", format_scalar(item)));
313 }
314 }
315 }
316 Value::Sequence(items) => {
317 emit_line(out, indent, &format!("{key}:"), Some(tag));
318 for item in items {
319 match item {
320 Value::Mapping(m) => {
321 let mut it = m.iter();
324 if let Some((first_k, first_v)) = it.next() {
325 let first_key = format_key(first_k);
326 emit_header(
327 out,
328 indent + 2,
329 &format!("- {first_key}: {}", format_scalar(first_v)),
330 );
331 for (k, v) in it {
332 emit_header(
333 out,
334 indent + 4,
335 &format!("{}: {}", format_key(k), format_scalar(v)),
336 );
337 }
338 }
339 }
340 other => {
341 emit_header(out, indent + 2, &format!("- {}", format_scalar(other)));
342 }
343 }
344 }
345 }
346 other => {
347 emit_line(out, indent, &format!("{key}: {}", format_scalar(other)), Some(tag));
348 }
349 }
350}
351
352fn emit_line(out: &mut String, indent: usize, body: &str, tag: Option<&str>) {
356 for _ in 0..indent {
357 out.push(' ');
358 }
359 out.push_str(body);
360 if let Some(t) = tag {
361 out.push('\0');
362 out.push_str(t);
363 }
364 out.push('\n');
365}
366
367fn emit_header(out: &mut String, indent: usize, body: &str) {
370 for _ in 0..indent {
371 out.push(' ');
372 }
373 out.push_str(body);
374 out.push('\n');
375}
376
377fn align_comments(raw: &str) -> String {
381 let lines: Vec<&str> = raw.lines().collect();
382 let mut max_body = 0;
383 for line in &lines {
384 if let Some(idx) = line.find('\0') {
385 max_body = max_body.max(idx);
386 }
387 }
388 let col = max_body.max(12) + 2;
391
392 let mut out = String::with_capacity(raw.len() + lines.len() * 4);
393 for line in &lines {
394 match line.find('\0') {
395 Some(idx) => {
396 let (body, rest) = line.split_at(idx);
397 let tag = &rest[1..]; out.push_str(body);
399 for _ in body.chars().count()..col {
400 out.push(' ');
401 }
402 out.push_str("# ");
403 out.push_str(tag);
404 }
405 None => out.push_str(line),
406 }
407 out.push('\n');
408 }
409 out
410}
411
412fn label(labels: &[String], source: usize) -> String {
413 labels
414 .get(source)
415 .cloned()
416 .unwrap_or_else(|| format!("layer[{source}]"))
417}
418
419fn is_inline_scalar(v: &Value) -> bool {
420 matches!(
421 v,
422 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
423 )
424}
425
426fn format_scalar(v: &Value) -> String {
431 match v {
432 Value::Null => "null".to_string(),
433 Value::Bool(b) => b.to_string(),
434 Value::Number(n) => n.to_string(),
435 Value::String(s) => format_string(s),
436 Value::Sequence(_) | Value::Mapping(_) => {
437 serde_yaml::to_string(v).unwrap_or_default().trim().to_string()
439 }
440 Value::Tagged(t) => serde_yaml::to_string(&**t)
441 .unwrap_or_default()
442 .trim()
443 .to_string(),
444 }
445}
446
447fn format_key(k: &Value) -> String {
448 match k {
449 Value::String(s) => {
450 if is_plain_key(s) {
452 s.clone()
453 } else {
454 format_string(s)
455 }
456 }
457 other => format_scalar(other),
458 }
459}
460
461fn is_plain_key(s: &str) -> bool {
462 !s.is_empty()
463 && s.chars()
464 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
465}
466
467fn format_string(s: &str) -> String {
468 let needs_quote = s.is_empty()
471 || s.contains(':')
472 || s.contains('#')
473 || s.contains('\n')
474 || s.contains('"')
475 || s.starts_with(|c: char| c.is_whitespace() || "!&*>|%@`[]{},-?".contains(c))
476 || matches!(s, "true" | "false" | "null" | "yes" | "no" | "~")
477 || s.parse::<f64>().is_ok();
478 if needs_quote {
479 let escaped = s
481 .replace('\\', "\\\\")
482 .replace('"', "\\\"")
483 .replace('\n', "\\n")
484 .replace('\t', "\\t");
485 format!("\"{escaped}\"")
486 } else {
487 s.to_string()
488 }
489}
490
491pub fn load_value(path: &Path) -> Result<Value, String> {
494 let content = std::fs::read_to_string(path)
495 .map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
496 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
497 match ext {
498 "json" => serde_json::from_str::<Value>(&content)
499 .map_err(|e| format!("{}: {}", path.display(), e)),
500 _ => serde_yaml::from_str::<Value>(&content)
501 .map_err(|e| format!("{}: {}", path.display(), e)),
502 }
503}
504
505const INHERIT_KEY: &str = "inherit-from";
515
516pub fn resolve_chain(path: &Path) -> Result<Vec<(PathBuf, Value)>, String> {
525 let mut stack: Vec<PathBuf> = Vec::new();
526 let mut out: Vec<(PathBuf, Value)> = Vec::new();
527 resolve_chain_inner(path, &mut stack, &mut out)?;
528 Ok(out)
529}
530
531fn resolve_chain_inner(
532 path: &Path,
533 stack: &mut Vec<PathBuf>,
534 out: &mut Vec<(PathBuf, Value)>,
535) -> Result<(), String> {
536 let canonical = path.canonicalize().map_err(|e| {
537 format!(
538 "cannot resolve inherit-from target `{}`: {e}",
539 path.display()
540 )
541 })?;
542
543 if stack.contains(&canonical) {
544 let mut chain: Vec<String> = stack.iter().map(|p| p.display().to_string()).collect();
545 chain.push(canonical.display().to_string());
546 return Err(format!("inherit-from cycle detected: {}", chain.join(" -> ")));
547 }
548
549 stack.push(canonical.clone());
550
551 let mut value = load_value(path)?;
552 let parent = extract_inherit_from(&mut value, path)?;
553
554 if let Some(parent_rel) = parent {
555 let parent_abs = if Path::new(&parent_rel).is_absolute() {
556 PathBuf::from(&parent_rel)
557 } else {
558 canonical
559 .parent()
560 .unwrap_or_else(|| Path::new("."))
561 .join(&parent_rel)
562 };
563 resolve_chain_inner(&parent_abs, stack, out)?;
564 }
565
566 stack.pop();
567 out.push((canonical, value));
568 Ok(())
569}
570
571fn extract_inherit_from(value: &mut Value, path: &Path) -> Result<Option<String>, String> {
575 let Value::Mapping(m) = value else {
576 return Ok(None);
577 };
578 let key = Value::String(INHERIT_KEY.to_string());
579 match m.remove(&key) {
580 None | Some(Value::Null) => Ok(None),
581 Some(Value::String(s)) if s.is_empty() => Err(format!(
582 "{INHERIT_KEY} in {} must be a non-empty path",
583 path.display()
584 )),
585 Some(Value::String(s)) => Ok(Some(s)),
586 Some(other) => Err(format!(
587 "{INHERIT_KEY} in {} must be a string path, got {}",
588 path.display(),
589 type_name(&other)
590 )),
591 }
592}
593
594fn type_name(v: &Value) -> &'static str {
595 match v {
596 Value::Null => "null",
597 Value::Bool(_) => "bool",
598 Value::Number(_) => "number",
599 Value::String(_) => "string",
600 Value::Sequence(_) => "sequence",
601 Value::Mapping(_) => "mapping",
602 Value::Tagged(_) => "tagged",
603 }
604}
605
606#[cfg(test)]
609mod tests {
610 use super::*;
611 use std::collections::BTreeMap;
612
613 fn yaml(s: &str) -> Value {
614 serde_yaml::from_str(s).expect("test fixture must parse")
615 }
616
617 fn p(xs: &[&str]) -> Vec<String> {
620 xs.iter().map(|s| s.to_string()).collect()
621 }
622
623 #[test]
624 fn scalar_over_scalar_replaces() {
625 let base = yaml("42");
626 let over = yaml("99");
627 assert_eq!(deep_merge(base, over), yaml("99"));
628 }
629
630 #[test]
631 fn map_keys_deep_merge() {
632 let base = yaml(
633 r"
634 a: 1
635 nested:
636 x: one
637 y: two
638 ",
639 );
640 let over = yaml(
641 r"
642 nested:
643 y: TWO
644 z: three
645 b: 2
646 ",
647 );
648 let expected = yaml(
649 r"
650 a: 1
651 b: 2
652 nested:
653 x: one
654 y: TWO
655 z: three
656 ",
657 );
658 assert_eq!(deep_merge(base, over), expected);
659 }
660
661 #[test]
662 fn lists_replace_not_append() {
663 let base = yaml(
664 r"
665 items: [a, b, c]
666 ",
667 );
668 let over = yaml(
669 r"
670 items: [x, y]
671 ",
672 );
673 let expected = yaml(
674 r"
675 items: [x, y]
676 ",
677 );
678 assert_eq!(deep_merge(base, over), expected);
679 }
680
681 #[test]
682 fn null_in_overlay_deletes_key() {
683 let base = yaml(
684 r"
685 ddp:
686 policy: cadence
687 anchor: 3
688 training:
689 epochs: 10
690 ",
691 );
692 let over = yaml(
693 r"
694 ddp: ~
695 training:
696 epochs: 20
697 ",
698 );
699 let expected = yaml(
701 r"
702 training:
703 epochs: 20
704 ",
705 );
706 assert_eq!(deep_merge(base, over), expected);
707 }
708
709 #[test]
710 fn null_leaf_removes_single_key() {
711 let base = yaml(
712 r"
713 ddp:
714 policy: cadence
715 anchor: 3
716 ",
717 );
718 let over = yaml(
719 r"
720 ddp:
721 anchor: ~
722 ",
723 );
724 let expected = yaml(
725 r"
726 ddp:
727 policy: cadence
728 ",
729 );
730 assert_eq!(deep_merge(base, over), expected);
731 }
732
733 #[test]
734 fn overlay_adds_new_top_level_key() {
735 let base = yaml("a: 1");
736 let over = yaml("b: 2");
737 let expected = yaml(
738 r"
739 a: 1
740 b: 2
741 ",
742 );
743 assert_eq!(deep_merge(base, over), expected);
744 }
745
746 #[test]
747 fn merge_chain_three_layers() {
748 let l1 = yaml("a: 1\nb: 1");
749 let l2 = yaml("b: 2\nc: 2");
750 let l3 = yaml("c: 3");
751 let got = merge_layers(vec![l1, l2, l3]);
752 let expected = yaml(
753 r"
754 a: 1
755 b: 2
756 c: 3
757 ",
758 );
759 assert_eq!(got, expected);
760 }
761
762 #[test]
763 fn type_change_overlay_replaces_wholesale() {
764 let base = yaml(
765 r"
766 ddp:
767 policy: cadence
768 ",
769 );
770 let over = yaml(
771 r"
772 ddp: solo-0
773 ",
774 );
775 let expected = yaml(
776 r"
777 ddp: solo-0
778 ",
779 );
780 assert_eq!(deep_merge(base, over), expected);
781 }
782
783 #[test]
784 fn type_change_scalar_base_mapping_overlay_replaces() {
785 let base = yaml(
789 r"
790 ddp: solo-0
791 ",
792 );
793 let over = yaml(
794 r"
795 ddp:
796 policy: cadence
797 anchor: 3
798 ",
799 );
800 let expected = yaml(
801 r"
802 ddp:
803 policy: cadence
804 anchor: 3
805 ",
806 );
807 assert_eq!(deep_merge(base, over), expected);
808 }
809
810 #[test]
811 fn list_envs_discovers_sibling_overlays() {
812 let tmp = tempdir();
813 std::fs::write(tmp.path().join("fdl.yml"), "description: base").unwrap();
814 std::fs::write(tmp.path().join("fdl.ci.yml"), "description: ci").unwrap();
815 std::fs::write(tmp.path().join("fdl.cloud.yaml"), "description: cloud").unwrap();
816 std::fs::write(tmp.path().join("fdl.prod.json"), "{}").unwrap();
817 std::fs::write(tmp.path().join("fdl.yml.example"), "").unwrap();
819 std::fs::write(tmp.path().join("other.ci.yml"), "").unwrap();
820 std::fs::write(tmp.path().join("fdl.yml.bak"), "").unwrap();
821
822 let envs = list_envs(&tmp.path().join("fdl.yml"));
823 assert_eq!(envs, vec!["ci".to_string(), "cloud".into(), "prod".into()]);
824 }
825
826 #[test]
827 fn find_env_file_respects_extension_precedence() {
828 let tmp = tempdir();
829 std::fs::write(tmp.path().join("fdl.yml"), "").unwrap();
830 std::fs::write(tmp.path().join("fdl.ci.yml"), "# yml wins").unwrap();
831 std::fs::write(tmp.path().join("fdl.ci.yaml"), "# yaml loses").unwrap();
832
833 let got = find_env_file(&tmp.path().join("fdl.yml"), "ci").unwrap();
834 assert_eq!(got.file_name().unwrap().to_str(), Some("fdl.ci.yml"));
835 }
836
837 #[test]
838 fn find_env_file_missing_returns_none() {
839 let tmp = tempdir();
840 std::fs::write(tmp.path().join("fdl.yml"), "").unwrap();
841 assert!(find_env_file(&tmp.path().join("fdl.yml"), "nope").is_none());
842 }
843
844 fn leaves(node: &AnnotatedNode) -> Vec<(Vec<String>, usize)> {
850 fn walk(node: &AnnotatedNode, path: &mut Vec<String>, out: &mut Vec<(Vec<String>, usize)>) {
851 match node {
852 AnnotatedNode::Leaf { source, .. } => out.push((path.clone(), *source)),
853 AnnotatedNode::Map { entries } => {
854 for (k, v) in entries {
855 let key = match k {
856 Value::String(s) => s.clone(),
857 other => format!("{other:?}"),
858 };
859 path.push(key);
860 walk(v, path, out);
861 path.pop();
862 }
863 }
864 }
865 }
866 let mut out = Vec::new();
867 walk(node, &mut Vec::new(), &mut out);
868 out
869 }
870
871 #[test]
872 fn annotated_single_layer_tags_every_leaf_with_zero() {
873 let layers = vec![yaml("ddp:\n policy: cadence\n anchor: 3\ntraining:\n epochs: 10\n")];
874 let node = merge_layers_annotated(&layers);
875 for (path, src) in leaves(&node) {
876 assert_eq!(src, 0, "{path:?} should be tagged with layer 0");
877 }
878 }
879
880 #[test]
881 fn annotated_overlay_replaces_key_source() {
882 let layers = vec![
883 yaml("ddp:\n policy: cadence\n anchor: 3\n"),
884 yaml("ddp:\n anchor: 5\n"),
885 ];
886 let node = merge_layers_annotated(&layers);
887 let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
888 assert_eq!(by_path[&p(&["ddp", "policy"])], 0);
889 assert_eq!(by_path[&p(&["ddp", "anchor"])], 1);
890 }
891
892 #[test]
893 fn annotated_added_key_tagged_with_overlay() {
894 let layers = vec![
895 yaml("ddp:\n policy: cadence\n"),
896 yaml("training:\n epochs: 20\n"),
897 ];
898 let node = merge_layers_annotated(&layers);
899 let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
900 assert_eq!(by_path[&p(&["training", "epochs"])], 1);
901 }
902
903 #[test]
904 fn annotated_null_deletes_key_and_removes_leaf() {
905 let layers = vec![
906 yaml("ddp:\n policy: cadence\n anchor: 3\n"),
907 yaml("ddp:\n anchor: ~\n"),
908 ];
909 let node = merge_layers_annotated(&layers);
910 let paths: Vec<Vec<String>> = leaves(&node).into_iter().map(|(path, _)| path).collect();
911 assert!(paths.contains(&p(&["ddp", "policy"])));
912 assert!(!paths.iter().any(|path| path == &p(&["ddp", "anchor"])));
913 }
914
915 #[test]
916 fn annotated_type_change_resets_source_to_overlay() {
917 let layers = vec![
920 yaml("ddp:\n policy: cadence\n"),
921 yaml("ddp: solo-0\n"),
922 ];
923 let node = merge_layers_annotated(&layers);
924 let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
925 assert_eq!(by_path[&p(&["ddp"])], 1);
926 assert!(!by_path.contains_key(&p(&["ddp", "policy"])));
927 }
928
929 #[test]
930 fn annotated_list_replaced_wholesale_tagged_with_setter() {
931 let layers = vec![
934 yaml("regions: [eu-west]\n"),
935 yaml("regions: [us-east, ap-south]\n"),
936 ];
937 let node = merge_layers_annotated(&layers);
938 let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
939 assert_eq!(by_path[&p(&["regions"])], 1);
940 }
941
942 #[test]
943 fn annotated_three_layer_chain() {
944 let layers = vec![
945 yaml("a: 1\nb: 1\nc: 1\n"),
946 yaml("b: 2\nc: 2\n"),
947 yaml("c: 3\n"),
948 ];
949 let node = merge_layers_annotated(&layers);
950 let by_path: BTreeMap<Vec<String>, usize> = leaves(&node).into_iter().collect();
951 assert_eq!(by_path[&p(&["a"])], 0);
952 assert_eq!(by_path[&p(&["b"])], 1);
953 assert_eq!(by_path[&p(&["c"])], 2);
954 }
955
956 #[test]
957 fn annotated_to_value_matches_deep_merge() {
958 let l1 = yaml("ddp:\n policy: cadence\n anchor: 3\ntraining:\n epochs: 10\n");
959 let l2 = yaml("ddp:\n anchor: 5\ntraining:\n seed: 42\n");
960 let annotated = merge_layers_annotated(&[l1.clone(), l2.clone()]);
961 let plain = deep_merge(l1, l2);
962 assert_eq!(annotated.to_value(), plain);
963 }
964
965 fn labels(xs: &[&str]) -> Vec<String> {
968 xs.iter().map(|s| s.to_string()).collect()
969 }
970
971 #[test]
972 fn render_tags_every_leaf_with_filename() {
973 let layers = vec![yaml("ddp:\n policy: cadence\n anchor: 3\n")];
974 let node = merge_layers_annotated(&layers);
975 let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
976 for line in out.lines() {
977 if line.contains(':') && !line.trim_end().ends_with(':') {
978 assert!(line.contains("# fdl.yml"), "missing tag on: `{line}`");
979 }
980 }
981 }
982
983 #[test]
984 fn render_tags_overlay_keys_with_overlay_filename() {
985 let layers = vec![
986 yaml("ddp:\n policy: cadence\n anchor: 3\n"),
987 yaml("ddp:\n anchor: 5\n"),
988 ];
989 let node = merge_layers_annotated(&layers);
990 let out = render_annotated_yaml(&node, &labels(&["fdl.yml", "fdl.ci.yml"]));
991 let policy_line = out.lines().find(|l| l.contains("policy:")).unwrap();
993 assert!(policy_line.contains("# fdl.yml") && !policy_line.contains("# fdl.ci.yml"));
994 let anchor_line = out.lines().find(|l| l.contains("anchor:")).unwrap();
996 assert!(anchor_line.contains("# fdl.ci.yml"));
997 }
998
999 #[test]
1000 fn render_aligns_comment_column() {
1001 let layers = vec![yaml("a: 1\nbb: 22\nccc: 333\n")];
1002 let node = merge_layers_annotated(&layers);
1003 let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
1004 let cols: Vec<usize> = out
1006 .lines()
1007 .filter_map(|l| l.find('#'))
1008 .collect();
1009 assert!(cols.len() >= 3);
1010 let first = cols[0];
1011 assert!(cols.iter().all(|c| *c == first), "mismatched columns: {cols:?}");
1012 }
1013
1014 #[test]
1015 fn render_inline_short_scalar_list() {
1016 let layers = vec![yaml("ratios: [1.5, 1.0]\n")];
1018 let node = merge_layers_annotated(&layers);
1019 let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
1020 assert!(out.contains("ratios: [1.5, 1.0]"), "got:\n{out}");
1021 assert!(out.lines().next().unwrap().contains("# fdl.yml"));
1022 }
1023
1024 #[test]
1025 fn render_deleted_key_absent_from_output() {
1026 let layers = vec![
1027 yaml("ddp:\n policy: cadence\n anchor: 3\n"),
1028 yaml("ddp:\n anchor: ~\n"),
1029 ];
1030 let node = merge_layers_annotated(&layers);
1031 let out = render_annotated_yaml(&node, &labels(&["fdl.yml", "fdl.ci.yml"]));
1032 assert!(!out.contains("anchor"), "deleted key leaked: {out}");
1033 assert!(out.contains("policy"));
1034 }
1035
1036 #[test]
1037 fn render_header_lines_have_no_comment() {
1038 let layers = vec![yaml("ddp:\n policy: cadence\n")];
1041 let node = merge_layers_annotated(&layers);
1042 let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
1043 let header = out.lines().find(|l| l.trim() == "ddp:").unwrap();
1044 assert!(!header.contains('#'));
1045 }
1046
1047 #[test]
1048 fn render_quotes_ambiguous_strings() {
1049 let layers = vec![yaml("flag: \"true\"\n")];
1052 let node = merge_layers_annotated(&layers);
1053 let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
1054 assert!(out.contains("flag: \"true\""), "got:\n{out}");
1055 }
1056
1057 #[test]
1058 fn render_long_scalar_list_drops_to_block_form() {
1059 let long: Vec<String> = (0..30).map(|i| format!("item-number-{i}")).collect();
1060 let yaml_src = format!("items: [{}]\n", long.join(", "));
1061 let layers = vec![yaml(&yaml_src)];
1062 let node = merge_layers_annotated(&layers);
1063 let out = render_annotated_yaml(&node, &labels(&["fdl.yml"]));
1064 assert!(out.contains("items: "), "expected header line with tag");
1065 assert!(out.contains("- item-number-0"));
1066 }
1067
1068 fn canon(p: &Path) -> PathBuf {
1073 p.canonicalize().expect("canonicalize fixture path")
1074 }
1075
1076 #[test]
1077 fn resolve_chain_single_file_no_inherit() {
1078 let tmp = tempdir();
1079 let f = tmp.path().join("fdl.yml");
1080 std::fs::write(&f, "description: test\nddp:\n policy: cadence\n").unwrap();
1081 let chain = resolve_chain(&f).unwrap();
1082 assert_eq!(chain.len(), 1);
1083 assert_eq!(chain[0].0, canon(&f));
1084 }
1085
1086 #[test]
1087 fn resolve_chain_strips_inherit_from_key() {
1088 let tmp = tempdir();
1089 let parent = tmp.path().join("fdl.yml");
1090 let child = tmp.path().join("fdl.ci.yml");
1091 std::fs::write(&parent, "a: 1\n").unwrap();
1092 std::fs::write(&child, "inherit-from: fdl.yml\nb: 2\n").unwrap();
1093 let chain = resolve_chain(&child).unwrap();
1094 assert_eq!(chain.len(), 2);
1095 assert_eq!(chain[0].0, canon(&parent));
1097 assert_eq!(chain[1].0, canon(&child));
1098 for (_, v) in &chain {
1100 if let Value::Mapping(m) = v {
1101 assert!(!m.contains_key(Value::String("inherit-from".to_string())));
1102 }
1103 }
1104 }
1105
1106 #[test]
1107 fn resolve_chain_three_level_ordering() {
1108 let tmp = tempdir();
1110 let a = tmp.path().join("a.yml");
1111 let b = tmp.path().join("b.yml");
1112 let c = tmp.path().join("c.yml");
1113 std::fs::write(&a, "x: from-a\n").unwrap();
1114 std::fs::write(&b, "inherit-from: a.yml\ny: from-b\n").unwrap();
1115 std::fs::write(&c, "inherit-from: b.yml\nz: from-c\n").unwrap();
1116 let chain = resolve_chain(&c).unwrap();
1117 let paths: Vec<PathBuf> = chain.iter().map(|(p, _)| p.clone()).collect();
1118 assert_eq!(paths, vec![canon(&a), canon(&b), canon(&c)]);
1119 }
1120
1121 #[test]
1122 fn resolve_chain_relative_paths_resolve_from_declaring_file() {
1123 let tmp = tempdir();
1125 let base = tmp.path().join("base.yml");
1126 let nested_dir = tmp.path().join("nested");
1127 std::fs::create_dir_all(&nested_dir).unwrap();
1128 let child = nested_dir.join("child.yml");
1129 std::fs::write(&base, "shared: true\n").unwrap();
1130 std::fs::write(&child, "inherit-from: ../base.yml\nlocal: true\n").unwrap();
1131 let chain = resolve_chain(&child).unwrap();
1132 assert_eq!(chain.len(), 2);
1133 assert_eq!(chain[0].0, canon(&base));
1134 assert_eq!(chain[1].0, canon(&child));
1135 }
1136
1137 #[test]
1138 fn resolve_chain_absolute_path_works() {
1139 let tmp = tempdir();
1140 let parent = tmp.path().join("parent.yml");
1141 let child = tmp.path().join("child.yml");
1142 std::fs::write(&parent, "a: 1\n").unwrap();
1143 let abs = canon(&parent);
1145 std::fs::write(
1146 &child,
1147 format!("inherit-from: {}\nb: 2\n", abs.display()),
1148 )
1149 .unwrap();
1150 let chain = resolve_chain(&child).unwrap();
1151 assert_eq!(chain.len(), 2);
1152 assert_eq!(chain[0].0, canon(&parent));
1153 }
1154
1155 #[test]
1156 fn resolve_chain_self_inheritance_errors() {
1157 let tmp = tempdir();
1158 let f = tmp.path().join("fdl.yml");
1159 std::fs::write(&f, "inherit-from: fdl.yml\nx: 1\n").unwrap();
1160 let err = resolve_chain(&f).unwrap_err();
1161 assert!(err.contains("cycle"), "got: {err}");
1162 assert!(err.matches("fdl.yml").count() >= 2, "got: {err}");
1164 }
1165
1166 #[test]
1167 fn resolve_chain_two_file_cycle_errors() {
1168 let tmp = tempdir();
1170 let a = tmp.path().join("a.yml");
1171 let b = tmp.path().join("b.yml");
1172 std::fs::write(&a, "inherit-from: b.yml\nx: 1\n").unwrap();
1173 std::fs::write(&b, "inherit-from: a.yml\ny: 2\n").unwrap();
1174 let err = resolve_chain(&a).unwrap_err();
1175 assert!(err.contains("cycle"), "got: {err}");
1176 assert!(err.contains("a.yml"));
1177 assert!(err.contains("b.yml"));
1178 }
1179
1180 #[test]
1181 fn resolve_chain_missing_parent_errors() {
1182 let tmp = tempdir();
1183 let f = tmp.path().join("fdl.yml");
1184 std::fs::write(&f, "inherit-from: missing.yml\nx: 1\n").unwrap();
1185 let err = resolve_chain(&f).unwrap_err();
1186 assert!(
1187 err.contains("cannot resolve inherit-from target"),
1188 "got: {err}"
1189 );
1190 assert!(err.contains("missing.yml"), "got: {err}");
1191 }
1192
1193 #[test]
1194 fn resolve_chain_non_string_inherit_errors() {
1195 let tmp = tempdir();
1196 let f = tmp.path().join("fdl.yml");
1197 std::fs::write(&f, "inherit-from: 42\nx: 1\n").unwrap();
1198 let err = resolve_chain(&f).unwrap_err();
1199 assert!(err.contains("must be a string path"), "got: {err}");
1200 assert!(err.contains("got number"), "got: {err}");
1201 }
1202
1203 #[test]
1204 fn resolve_chain_empty_string_inherit_errors() {
1205 let tmp = tempdir();
1206 let f = tmp.path().join("fdl.yml");
1207 std::fs::write(&f, "inherit-from: \"\"\nx: 1\n").unwrap();
1208 let err = resolve_chain(&f).unwrap_err();
1209 assert!(err.contains("non-empty"), "got: {err}");
1210 }
1211
1212 #[test]
1213 fn resolve_chain_null_inherit_ignored() {
1214 let tmp = tempdir();
1216 let f = tmp.path().join("fdl.yml");
1217 std::fs::write(&f, "inherit-from: ~\nx: 1\n").unwrap();
1218 let chain = resolve_chain(&f).unwrap();
1219 assert_eq!(chain.len(), 1);
1220 }
1221
1222 fn tempdir() -> TempDir {
1224 TempDir::new()
1225 }
1226
1227 struct TempDir(PathBuf);
1228
1229 impl TempDir {
1230 fn new() -> Self {
1231 let base = std::env::temp_dir();
1232 let unique = format!(
1233 "flodl-overlay-{}-{}",
1234 std::process::id(),
1235 std::time::SystemTime::now()
1236 .duration_since(std::time::UNIX_EPOCH)
1237 .map(|d| d.as_nanos())
1238 .unwrap_or(0)
1239 );
1240 let dir = base.join(unique);
1241 std::fs::create_dir_all(&dir).expect("tempdir creation");
1242 Self(dir)
1243 }
1244 fn path(&self) -> &Path {
1245 &self.0
1246 }
1247 }
1248
1249 impl Drop for TempDir {
1250 fn drop(&mut self) {
1251 let _ = std::fs::remove_dir_all(&self.0);
1252 }
1253 }
1254}