1use std::collections::HashSet;
22use std::path::{Path, PathBuf};
23
24use serde_json::Value as JVal;
25
26enum Source {
31 File(PathBuf),
33 Bundled(&'static crate::catalog::BundledWorkload),
35}
36
37pub fn load_and_merge(path: &Path) -> Result<(String, Vec<String>), String> {
46 let mut chain: Vec<String> = Vec::new();
47 let mut warnings: Vec<String> = Vec::new();
48 let merged_jval = load_recursive(Source::File(path.to_path_buf()), &mut chain, &mut warnings)?;
49 let text = serde_yaml::to_string(&merged_jval)
50 .map_err(|e| format!("re-serialising merged workload: {e}"))?;
51 Ok((text, warnings))
52}
53
54pub fn load_and_merge_bundled(
59 bundled: &'static crate::catalog::BundledWorkload,
60) -> Result<(String, Vec<String>), String> {
61 let mut chain: Vec<String> = Vec::new();
62 let mut warnings: Vec<String> = Vec::new();
63 let merged_jval = load_recursive(Source::Bundled(bundled), &mut chain, &mut warnings)?;
64 let text = serde_yaml::to_string(&merged_jval)
65 .map_err(|e| format!("re-serialising merged workload: {e}"))?;
66 Ok((text, warnings))
67}
68
69fn load_recursive(
74 src: Source,
75 chain: &mut Vec<String>,
76 warnings: &mut Vec<String>,
77) -> Result<JVal, String> {
78 let (key, display, text, origin_dir): (String, String, String, Option<PathBuf>) = match &src {
81 Source::File(path) => {
82 let canonical = path
83 .canonicalize()
84 .map_err(|e| format!("extends: target not found: {} ({e})", path.display()))?;
85 let text = std::fs::read_to_string(&canonical)
86 .map_err(|e| format!("read {}: {e}", canonical.display()))?;
87 let dir = canonical.parent().map(|p| p.to_path_buf());
88 (
89 canonical.display().to_string(),
90 canonical.display().to_string(),
91 text,
92 dir,
93 )
94 }
95 Source::Bundled(w) => (
96 format!("bundled:{}", w.name),
97 format!("bundled workload `{}`", w.name),
98 w.source.to_string(),
99 None,
100 ),
101 };
102
103 if let Some(idx) = chain.iter().position(|k| k == &key) {
104 return Err(format_cycle(chain, idx, &key));
105 }
106 chain.push(key);
107
108 let mut jval: JVal =
109 serde_yaml::from_str(&text).map_err(|e| format!("YAML parse error in {display}: {e}"))?;
110
111 let extends_target = extract_extends_field(&jval, &display)?;
112
113 let result = if let Some(extends_str) = extends_target {
114 let bundled_origin = match &src {
115 Source::Bundled(w) => Some(w.name),
116 Source::File(_) => None,
117 };
118 let parent_src = resolve_extends_target(
119 origin_dir.as_deref(),
120 bundled_origin,
121 &extends_str,
122 &display,
123 warnings,
124 )?;
125 let parent_display = match &parent_src {
126 Source::File(p) => p.display().to_string(),
127 Source::Bundled(w) => format!("bundled workload `{}`", w.name),
128 };
129 let parent_jval = load_recursive(parent_src, chain, warnings)
130 .map_err(|e| format!("while loading {display}'s parent {parent_display}: {e}"))?;
131
132 if let Some(obj) = jval.as_object_mut() {
135 obj.remove("extends");
136 }
137
138 merge(parent_jval, jval)
139 } else {
140 jval
141 };
142
143 chain.pop();
144 Ok(result)
145}
146
147fn resolve_extends_target(
171 origin_dir: Option<&Path>,
172 bundled_origin: Option<&str>,
173 target: &str,
174 child_display: &str,
175 warnings: &mut Vec<String>,
176) -> Result<Source, String> {
177 let pinned = target.starts_with("./") || target.starts_with("../");
178
179 let local: Option<PathBuf> = origin_dir.map(|d| d.join(target)).filter(|p| p.exists());
180
181 let stem = target
182 .strip_suffix(".yaml")
183 .or_else(|| target.strip_suffix(".yml"))
184 .unwrap_or(target);
185 let stem = stem.strip_prefix("./").unwrap_or(stem);
186 let ns_hit = bundled_origin
187 .and_then(|o| o.rsplit_once('/'))
188 .and_then(|(ns, _)| crate::catalog::lookup(&format!("{ns}/{stem}")));
189 let bare_hit = crate::catalog::lookup(stem).filter(|b| ns_hit.map(|n| n.name) != Some(b.name));
190
191 if pinned && local.is_some() {
194 return Ok(Source::File(local.unwrap()));
195 }
196 if pinned
197 && origin_dir.is_none()
198 && let Some(w) = ns_hit
199 {
200 return Ok(Source::Bundled(w));
201 }
202
203 let mut candidates: Vec<(String, Source)> = Vec::new();
205 if let Some(p) = local {
206 candidates.push((format!("local file {}", p.display()), Source::File(p)));
207 }
208 if let Some(w) = ns_hit {
209 candidates.push((format!("bundled workload `{}`", w.name), Source::Bundled(w)));
210 }
211 if let Some(w) = bare_hit {
212 candidates.push((format!("bundled workload `{}`", w.name), Source::Bundled(w)));
213 }
214
215 if candidates.len() > 1 {
216 let names: Vec<&str> = candidates.iter().map(|(n, _)| n.as_str()).collect();
217 warnings.push(format!(
218 "{child_display}: `extends: {target}` matches multiple resources — {} — \
219 using the nearest ({}). Same-named resources in multiple places \
220 invite confusion: prefer a unique name, or pin the intent with a \
221 `./` path / full catalog name.",
222 names.join(" AND "),
223 names[0],
224 ));
225 }
226
227 match candidates.into_iter().next() {
228 Some((_, src)) => Ok(src),
229 None => {
230 let local_hint = origin_dir
231 .map(|d| format!("{}", d.join(target).display()))
232 .unwrap_or_else(|| {
233 "<no directory context — bundled parents resolve targets \
234 through the catalog>"
235 .to_string()
236 });
237 Err(format!(
238 "{child_display}: `extends: {target}` not found — no file at \
239 {local_hint} and no bundled workload named `{stem}`"
240 ))
241 }
242 }
243}
244
245fn extract_extends_field(jval: &JVal, source: &str) -> Result<Option<String>, String> {
249 let Some(obj) = jval.as_object() else {
250 return Err(format!("{source} top level must be a YAML mapping"));
251 };
252 let Some(v) = obj.get("extends") else {
253 return Ok(None);
254 };
255 match v {
256 JVal::String(s) if !s.is_empty() => Ok(Some(s.clone())),
257 JVal::String(_) => Err(format!("{source}: `extends:` value is empty")),
258 _ => Err(format!(
259 "{source}: `extends:` must be a single scalar string, got {}",
260 describe_kind(v)
261 )),
262 }
263}
264
265fn describe_kind(v: &JVal) -> &'static str {
266 match v {
267 JVal::Null => "null",
268 JVal::Bool(_) => "bool",
269 JVal::Number(_) => "number",
270 JVal::String(_) => "string",
271 JVal::Array(_) => "list",
272 JVal::Object(_) => "mapping",
273 }
274}
275
276fn format_cycle(chain: &[String], cycle_start_idx: usize, repeat: &str) -> String {
277 let mut out = String::from("extends: cycle detected\n");
278 for (i, p) in chain.iter().enumerate() {
279 let arrow = if i == 0 { " " } else { " → " };
280 out.push_str(&format!("{arrow}{p}\n"));
281 let _ = cycle_start_idx; }
283 out.push_str(&format!(" → {repeat} (cycle)\n"));
284 out
285}
286
287fn merge(parent: JVal, child: JVal) -> JVal {
290 let Some(mut merged) = parent.as_object().cloned() else {
294 return child;
295 };
296 let Some(child_obj) = child.as_object() else {
297 return JVal::Object(merged);
298 };
299
300 for (key, child_val) in child_obj {
301 let parent_val = merged.remove(key);
302 let new_val = match (key.as_str(), parent_val) {
303 ("extends", _) => continue, ("description", _) => child_val.clone(),
305
306 ("params", Some(p)) => merge_per_key(p, child_val.clone()),
307 ("tags", Some(p)) => merge_per_key(p, child_val.clone()),
308
309 ("bindings", Some(p)) => concat_bindings(p, child_val.clone()),
310
311 ("status_metrics", Some(p)) => union_lists(p, child_val.clone()),
312
313 ("report", Some(p)) => merge_per_name(p, child_val.clone()),
314 ("scenarios", Some(p)) => merge_per_name(p, child_val.clone()),
315 ("phases", Some(p)) => merge_per_name(p, child_val.clone()),
316 ("blocks", Some(p)) => merge_per_name(p, child_val.clone()),
317 ("op_templates", Some(p)) => merge_per_name(p, child_val.clone()),
318
319 ("ops", Some(p)) => merge_ops(p, child_val.clone()),
320
321 (_, _) => child_val.clone(),
322 };
323 merged.insert(key.clone(), new_val);
324 }
325
326 JVal::Object(merged)
327}
328
329fn merge_per_key(parent: JVal, child: JVal) -> JVal {
331 let Some(mut p_map) = parent.as_object().cloned() else {
332 return child;
333 };
334 let Some(c_map) = child.as_object() else {
335 return JVal::Object(p_map);
336 };
337 for (k, v) in c_map {
338 p_map.insert(k.clone(), v.clone());
339 }
340 JVal::Object(p_map)
341}
342
343fn merge_per_name(parent: JVal, child: JVal) -> JVal {
350 merge_per_key(parent, child)
351}
352
353fn concat_bindings(parent: JVal, child: JVal) -> JVal {
358 match (&parent, &child) {
359 (JVal::String(p), JVal::String(c)) => {
360 let mut out = String::with_capacity(p.len() + c.len() + 1);
361 out.push_str(p);
362 if !p.ends_with('\n') {
363 out.push('\n');
364 }
365 out.push_str(c);
366 JVal::String(out)
367 }
368 (JVal::Object(_), JVal::Object(_)) => merge_per_key(parent, child),
369 _ => child,
370 }
371}
372
373fn union_lists(parent: JVal, child: JVal) -> JVal {
376 let Some(p_list) = parent.as_array().cloned() else {
377 return child;
378 };
379 let Some(c_list) = child.as_array() else {
380 return JVal::Array(p_list);
381 };
382 let mut seen: HashSet<String> = HashSet::new();
383 let mut out: Vec<JVal> = Vec::with_capacity(p_list.len() + c_list.len());
384 for v in p_list.into_iter().chain(c_list.iter().cloned()) {
385 let key = match &v {
388 JVal::String(s) => s.clone(),
389 other => other.to_string(),
390 };
391 if seen.insert(key) {
392 out.push(v);
393 }
394 }
395 JVal::Array(out)
396}
397
398fn merge_ops(parent: JVal, child: JVal) -> JVal {
404 match (&parent, &child) {
405 (JVal::Object(_), JVal::Object(_)) => merge_per_name(parent, child),
406 _ => child,
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 fn vstr(s: &str) -> JVal {
415 JVal::String(s.to_string())
416 }
417 fn arr(items: Vec<JVal>) -> JVal {
418 JVal::Array(items)
419 }
420
421 fn mp(pairs: &[(&str, JVal)]) -> JVal {
422 let mut m = serde_json::Map::new();
423 for (k, v) in pairs {
424 m.insert((*k).to_string(), v.clone());
425 }
426 JVal::Object(m)
427 }
428
429 #[test]
430 fn per_key_merge_child_wins() {
431 let p = mp(&[("a", vstr("p")), ("b", vstr("p"))]);
432 let c = mp(&[("b", vstr("c")), ("d", vstr("c"))]);
433 let merged = merge_per_key(p, c);
434 let obj = merged.as_object().unwrap();
435 assert_eq!(obj.get("a").unwrap(), &vstr("p"));
436 assert_eq!(obj.get("b").unwrap(), &vstr("c"));
437 assert_eq!(obj.get("d").unwrap(), &vstr("c"));
438 }
439
440 #[test]
441 fn concat_bindings_string_form() {
442 let merged = concat_bindings(vstr("a := 1"), vstr("b := 2"));
443 assert_eq!(merged, vstr("a := 1\nb := 2"));
444 }
445
446 #[test]
447 fn concat_bindings_preserves_trailing_newline() {
448 let merged = concat_bindings(vstr("a := 1\n"), vstr("b := 2"));
449 assert_eq!(merged, vstr("a := 1\nb := 2"));
450 }
451
452 #[test]
453 fn union_lists_dedup_preserves_first_occurrence() {
454 let merged = union_lists(
455 arr(vec![vstr("a"), vstr("b"), vstr("c")]),
456 arr(vec![vstr("b"), vstr("d")]),
457 );
458 assert_eq!(
459 merged,
460 arr(vec![vstr("a"), vstr("b"), vstr("c"), vstr("d")])
461 );
462 }
463
464 #[test]
465 fn merge_strips_extends() {
466 let parent = mp(&[("description", vstr("parent"))]);
467 let child = mp(&[
468 ("extends", vstr("./p.yaml")),
469 ("description", vstr("child")),
470 ]);
471 let merged = merge(parent, child);
472 let obj = merged.as_object().unwrap();
473 assert!(obj.get("extends").is_none());
474 assert_eq!(obj.get("description").unwrap(), &vstr("child"));
475 }
476
477 #[test]
478 fn merge_phases_per_name_replace() {
479 let parent = mp(&[(
480 "phases",
481 mp(&[
482 ("a", mp(&[("kind", vstr("p_a"))])),
483 ("b", mp(&[("kind", vstr("p_b"))])),
484 ]),
485 )]);
486 let child = mp(&[(
487 "phases",
488 mp(&[
489 ("b", mp(&[("kind", vstr("c_b"))])),
490 ("c", mp(&[("kind", vstr("c_c"))])),
491 ]),
492 )]);
493 let merged = merge(parent, child);
494 let phases = merged
495 .as_object()
496 .unwrap()
497 .get("phases")
498 .unwrap()
499 .as_object()
500 .unwrap();
501 assert_eq!(phases.len(), 3);
502 assert_eq!(phases.get("a").unwrap(), &mp(&[("kind", vstr("p_a"))]));
503 assert_eq!(phases.get("b").unwrap(), &mp(&[("kind", vstr("c_b"))]));
504 assert_eq!(phases.get("c").unwrap(), &mp(&[("kind", vstr("c_c"))]));
505 }
506}