Skip to main content

nmbrs_workload/
extends.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Workload `extends:` resolution and merging (SRD-72).
5//!
6//! Resolves single-parent workload composition: a child workload
7//! YAML may declare `extends: <relative-path>` at the top level
8//! to inherit from a parent. The parent is loaded (recursively,
9//! if it has its own `extends:`) and merged field-by-field before
10//! the resulting workload is fed to `parse::parse_workload`.
11//!
12//! The merge runs on parsed `serde_json::Value` trees, not on
13//! raw YAML text — every merge rule is structural (per-key
14//! merge, per-name replace, list union with dedup). The result
15//! is re-serialised back to YAML so the existing
16//! `parse_workload(&str, …)` entry point can consume it
17//! unchanged; template expansion then runs on the merged text
18//! with the caller's params, matching the SRD-72 rule that
19//! validation and templating run **once**, on the merged whole.
20
21use std::collections::HashSet;
22use std::path::{Path, PathBuf};
23
24use serde_json::Value as JVal;
25
26/// Where a workload in the `extends:` chain came from. SRD-85
27/// adds the bundled-catalog source: a bundled parent has no
28/// directory context, so its own `extends:` targets resolve
29/// through the catalog only.
30enum Source {
31    /// On-disk file (canonicalised lazily in the loader).
32    File(PathBuf),
33    /// Catalog entry — name + embedded text.
34    Bundled(&'static crate::catalog::BundledWorkload),
35}
36
37/// Load a workload YAML from disk, follow its `extends:` chain
38/// to completion, and return the merged YAML text ready for
39/// `parse::parse_workload`, plus any resolution warnings the
40/// chain produced (a target name matching multiple resources —
41/// see [`resolve_extends_target`]). Callers surface the warnings
42/// on their own channel; dropping them silently is a bug.
43///
44/// The returned text has every `extends:` directive stripped.
45pub 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
54/// SRD-85: load a bundled workload from the catalog, follow its
55/// `extends:` chain (catalog-resolved — a bundled workload has
56/// no directory context), and return the merged YAML text plus
57/// any resolution warnings.
58pub 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
69/// Recursive loader: parses the source, resolves any `extends:`,
70/// applies merge rules. `chain` is the parent-chain of sources
71/// already being loaded (canonical path or `bundled:<name>`
72/// keys), used for cycle detection.
73fn load_recursive(
74    src: Source,
75    chain: &mut Vec<String>,
76    warnings: &mut Vec<String>,
77) -> Result<JVal, String> {
78    // Resolve the source to (cycle key, display name, text,
79    // directory context for relative extends targets).
80    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        // Strip `extends:` from the child before merging so the
133        // merge fn doesn't need to special-case it.
134        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
147/// Resolve an `extends:` target per the SRD-85 nearest-first
148/// order — the logical filesystem location is always favored:
149///
150/// 1. A file relative to the including file's directory (when
151///    there is one).
152/// 2. For a bundled origin: the target inside the origin's
153///    namespace (`cql/vector_suite/full_cql_vector_sweep`
154///    extending `full_cql_vector.yaml` finds
155///    `cql/vector_suite/full_cql_vector`) — the sibling-by-
156///    filename idiom works identically on disk and in the
157///    catalog.
158/// 3. The target as a bare catalog name (extension stripped —
159///    files extend siblings by filename, catalog names carry
160///    none).
161///
162/// A target matching MORE THAN ONE of these is a warnable
163/// condition, not an error: the nearest candidate wins, and a
164/// warning naming every match is pushed for the caller to log —
165/// shadowing is allowed but never silent. A `./`-prefixed target
166/// that resolves at its pinned location (the local file, or the
167/// origin namespace for a bundled origin) is explicit — no
168/// warning. Prefer globally unique names to avoid the ambiguity
169/// altogether.
170fn 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    // A pinned target that resolves at its pinned location is
192    // unambiguous by declaration.
193    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    // Nearest-first candidate list.
204    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
245/// Extract `extends:` as a string. Returns `Ok(None)` if absent,
246/// `Err` if present but malformed (non-string, empty, or nested
247/// inside something other than the top-level mapping).
248fn 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; // referenced for clarity below
282    }
283    out.push_str(&format!("  → {repeat}  (cycle)\n"));
284    out
285}
286
287/// Merge a child workload onto an already-merged parent per the
288/// SRD-72 per-field rules.
289fn merge(parent: JVal, child: JVal) -> JVal {
290    // Both should be objects in practice (extract_extends_field
291    // already validated). Be defensive: a non-object child or
292    // parent falls back to whichever is an object.
293    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, // defensive — caller should have stripped
304            ("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
329/// Per-key merge: child wins on conflict, new keys added.
330fn 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
343/// Per-name merge: child entry replaces parent entry of same
344/// name (whole-entry replace). Same shape as `merge_per_key`
345/// but kept as a separate fn to make the intent explicit at
346/// call sites — the contract differs (whole-entry replace is
347/// stricter than per-key merge, even though the operation is
348/// identical at this layer).
349fn merge_per_name(parent: JVal, child: JVal) -> JVal {
350    merge_per_key(parent, child)
351}
352
353/// Bindings concat: parent's Polydat source emitted first, child's
354/// appended. Handles both the string form (the common case) and
355/// the legacy map form. Mixed forms (one string, one map) fall
356/// back to child-wins because there's no sensible concatenation.
357fn 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
373/// List union with first-occurrence ordering and dedup. Parent's
374/// entries come first, child's appended; duplicates suppressed.
375fn 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        // dedup by serialised form so non-string list entries
386        // (rare in status_metrics, but possible) still compare.
387        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
398/// Top-level `ops:` merge. If both forms are map-shaped, do a
399/// per-name merge (child entry replaces parent entry of same
400/// name). Otherwise (list form, mixed forms) the child wholly
401/// replaces the parent — positions are not stable identifiers
402/// so by-name override is impossible.
403fn 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}