Skip to main content

faucet_cli/
compose.rs

1//! Config composition front pre-pass: `extends` (base inheritance), `profiles`
2//! (named overlays selected via `--profile`/`FAUCET_PROFILE`), and `!include`
3//! (YAML fragment substitution). Runs BEFORE `${...}` interpolation and the
4//! secrets pass, reusing [`crate::merge::merge_value`].
5//!
6//! [`compose`] returns the merged document as **text** (in the entry file's
7//! format) so the unchanged `interpolate → from_text` pipeline runs verbatim.
8//! When no composition is in play it returns the raw file text byte-identical.
9
10use crate::error::{CliError, CliResult};
11use crate::merge::merge_value;
12use serde_json::Value as JsonValue;
13use std::path::{Path, PathBuf};
14
15/// Hard cap on extends/!include nesting depth (loop backstop).
16const MAX_COMPOSE_DEPTH: usize = 32;
17
18/// File format, chosen by extension.
19#[derive(Clone, Copy)]
20enum Format {
21    Yaml,
22    Json,
23}
24
25fn format_of(path: &Path) -> CliResult<Format> {
26    match path
27        .extension()
28        .and_then(|e| e.to_str())
29        .map(str::to_ascii_lowercase)
30        .as_deref()
31    {
32        Some("yaml" | "yml") => Ok(Format::Yaml),
33        Some("json") => Ok(Format::Json),
34        _ => Err(CliError::UnknownExtension {
35            path: path.to_path_buf(),
36        }),
37    }
38}
39
40/// Resolve a (possibly relative) `extends`/`!include` target against the
41/// directory of the file that referenced it.
42fn resolve_rel(dir: &Path, rel: &str) -> PathBuf {
43    let p = Path::new(rel);
44    if p.is_absolute() {
45        p.to_path_buf()
46    } else {
47        dir.join(p)
48    }
49}
50
51/// Canonicalize for cycle detection; fall back to the raw path if the file
52/// cannot be canonicalized (callers verify existence before recursing).
53fn cycle_key(path: &Path) -> PathBuf {
54    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
55}
56
57/// Cheap substring pre-check: are there any composition markers in the text?
58/// A false positive only forces the (still-correct) merge path; never a false
59/// negative for real directives, so the fast path stays safe.
60fn has_composition_markers(raw: &str) -> bool {
61    raw.contains("extends") || raw.contains("profiles") || raw.contains("!include")
62}
63
64/// Public entry: returns text ready to feed into `interpolate` + `from_text`.
65pub fn compose(entry: &Path, profile: Option<&str>) -> CliResult<String> {
66    let raw = std::fs::read_to_string(entry).map_err(|source| CliError::ReadConfig {
67        path: entry.to_path_buf(),
68        source,
69    })?;
70    // Fast path: no directives + no profile selected → byte-identical passthrough.
71    if profile.is_none() && !has_composition_markers(&raw) {
72        return Ok(raw);
73    }
74    let mut visited: Vec<PathBuf> = Vec::new();
75    let mut merged = compose_document(entry, &mut visited, 0)?;
76    apply_profile(&mut merged, profile)?;
77    // Strip composition metadata so PipelineConfig (deny_unknown_fields) never sees it.
78    if let JsonValue::Object(map) = &mut merged {
79        map.remove("profiles");
80        map.remove("extends");
81    }
82    serialize_for(entry, &merged)
83}
84
85/// Load a document, then resolve its `extends` chain. Returns the deep-merged
86/// `serde_json::Value` (profiles preserved; extends stripped). Used for the
87/// entry file and every `extends` target.
88fn compose_document(path: &Path, visited: &mut Vec<PathBuf>, depth: usize) -> CliResult<JsonValue> {
89    if depth > MAX_COMPOSE_DEPTH {
90        return Err(CliError::CompositionDepthExceeded {
91            max: MAX_COMPOSE_DEPTH,
92        });
93    }
94    let key = cycle_key(path);
95    if visited.contains(&key) {
96        let mut chain: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
97        chain.push(key.display().to_string());
98        return Err(CliError::CompositionCycle { chain });
99    }
100    visited.push(key);
101
102    let mut doc = load_value(path, visited, depth)?;
103    let bases = take_extends(&mut doc, path)?;
104    let result = if bases.is_empty() {
105        doc
106    } else {
107        let dir = path.parent().unwrap_or_else(|| Path::new("."));
108        let mut acc = JsonValue::Object(serde_json::Map::new());
109        for base_rel in bases {
110            let base_path = resolve_rel(dir, &base_rel);
111            if !base_path.exists() {
112                // No mid-fn pop: any Err aborts the whole traversal and `visited`
113                // (a `compose`-local) is discarded, so the stack is only meaningful
114                // on the Ok path.
115                return Err(CliError::IncludeNotFound {
116                    path: base_path,
117                    referenced_by: path.to_path_buf(),
118                });
119            }
120            let base_doc = compose_document(&base_path, visited, depth + 1)?;
121            merge_value(&mut acc, base_doc);
122        }
123        merge_value(&mut acc, doc); // child wins over its bases
124        acc
125    };
126
127    visited.pop();
128    Ok(result)
129}
130
131/// Parse one file into a `serde_json::Value`, resolving any `!include` tags
132/// (YAML only) before conversion. Shares the caller's `visited`/`depth` cycle
133/// stack so a mixed `extends`+`!include` loop is caught as a `CompositionCycle`
134/// rather than terminating with a confusing leftover-key error.
135fn load_value(path: &Path, visited: &mut Vec<PathBuf>, depth: usize) -> CliResult<JsonValue> {
136    let text = std::fs::read_to_string(path).map_err(|source| CliError::ReadConfig {
137        path: path.to_path_buf(),
138        source,
139    })?;
140    match format_of(path)? {
141        Format::Yaml => {
142            let mut yv: serde_yaml::Value =
143                serde_yaml::from_str(&text).map_err(|e| CliError::ParseConfig {
144                    path: path.to_path_buf(),
145                    message: e.to_string(),
146                })?;
147            resolve_includes(&mut yv, path, visited, depth)?;
148            yaml_to_json(yv, path)
149        }
150        Format::Json => serde_json::from_str(&text).map_err(|e| CliError::ParseConfig {
151            path: path.to_path_buf(),
152            message: e.to_string(),
153        }),
154    }
155}
156
157/// Convert a (fully include-resolved) YAML value to JSON. Non-string map keys
158/// or leftover unsupported tags surface as a parse error against `path`.
159fn yaml_to_json(yv: serde_yaml::Value, path: &Path) -> CliResult<JsonValue> {
160    serde_json::to_value(yv).map_err(|e| CliError::ParseConfig {
161        path: path.to_path_buf(),
162        message: format!(
163            "could not convert YAML to JSON (non-string keys or unsupported tag?): {e}"
164        ),
165    })
166}
167
168/// Remove and return the top-level `extends` entry as a list of path strings.
169fn take_extends(doc: &mut JsonValue, path: &Path) -> CliResult<Vec<String>> {
170    let JsonValue::Object(map) = doc else {
171        return Ok(Vec::new());
172    };
173    let Some(ext) = map.remove("extends") else {
174        return Ok(Vec::new());
175    };
176    match ext {
177        JsonValue::String(s) => Ok(vec![s]),
178        JsonValue::Array(arr) => arr
179            .into_iter()
180            .map(|v| match v {
181                JsonValue::String(s) => Ok(s),
182                other => Err(CliError::Config(format!(
183                    "`extends` list entries must be strings, got {other} in '{}'",
184                    path.display()
185                ))),
186            })
187            .collect(),
188        other => Err(CliError::Config(format!(
189            "`extends` must be a string or list of strings, got {other} in '{}'",
190            path.display()
191        ))),
192    }
193}
194
195/// Deep-merge `profiles[name]` over `merged` when a profile is selected.
196fn apply_profile(merged: &mut JsonValue, profile: Option<&str>) -> CliResult<()> {
197    let Some(name) = profile else {
198        return Ok(());
199    };
200    let profiles = merged.get("profiles").and_then(|p| p.as_object());
201    let known: Vec<String> = profiles
202        .map(|m| m.keys().cloned().collect())
203        .unwrap_or_default();
204    let overlay = profiles.and_then(|m| m.get(name)).cloned();
205    match overlay {
206        Some(ov) => {
207            merge_value(merged, ov);
208            Ok(())
209        }
210        None => Err(CliError::UnknownProfile {
211            name: name.to_string(),
212            known,
213        }),
214    }
215}
216
217/// Serialize the merged document back to the entry file's format.
218///
219/// The output is canonicalized, not a faithful re-render: `serde_json`'s map is
220/// a `BTreeMap`, so keys come out sorted and comments/formatting are dropped.
221/// That's fine — the result is re-parsed by `from_text` downstream, and the fast
222/// path keeps non-composed configs byte-identical.
223fn serialize_for(entry: &Path, merged: &JsonValue) -> CliResult<String> {
224    match format_of(entry)? {
225        Format::Yaml => serde_yaml::to_string(merged)
226            .map_err(|e| CliError::Internal(format!("re-serialize composed config to YAML: {e}"))),
227        Format::Json => serde_json::to_string_pretty(merged)
228            .map_err(|e| CliError::Internal(format!("re-serialize composed config to JSON: {e}"))),
229    }
230}
231
232/// Resolve `!include <path>` tags throughout a YAML value in place. Each tag's
233/// payload must be a string path, resolved relative to `including`'s directory.
234/// Recurses into the included fragment (which may itself contain `!include`s).
235fn resolve_includes(
236    yv: &mut serde_yaml::Value,
237    including: &Path,
238    visited: &mut Vec<PathBuf>,
239    depth: usize,
240) -> CliResult<()> {
241    use serde_yaml::Value as Y;
242    match yv {
243        Y::Tagged(tagged) => {
244            if tagged.tag == "include" {
245                let Y::String(rel) = &tagged.value else {
246                    return Err(CliError::BadInclude {
247                        path: including.to_path_buf(),
248                        reason: "`!include` payload must be a string path".into(),
249                    });
250                };
251                let dir = including.parent().unwrap_or_else(|| Path::new("."));
252                let target = resolve_rel(dir, rel);
253                if !target.exists() {
254                    return Err(CliError::IncludeNotFound {
255                        path: target,
256                        referenced_by: including.to_path_buf(),
257                    });
258                }
259                *yv = load_fragment(&target, visited, depth + 1)?;
260            } else {
261                return Err(CliError::BadInclude {
262                    path: including.to_path_buf(),
263                    reason: format!(
264                        "unsupported YAML tag '{}' (only `!include` is supported)",
265                        tagged.tag
266                    ),
267                });
268            }
269        }
270        Y::Mapping(map) => {
271            for v in map.values_mut() {
272                resolve_includes(v, including, visited, depth)?;
273            }
274        }
275        Y::Sequence(seq) => {
276            for v in seq.iter_mut() {
277                resolve_includes(v, including, visited, depth)?;
278            }
279        }
280        _ => {}
281    }
282    Ok(())
283}
284
285/// Load an `!include` target into a YAML value, resolving its own nested
286/// includes. Fragments are raw nodes — `extends`/`profiles` are NOT processed.
287/// A fragment's own top-level `extends`/`profiles` keys are therefore passed
288/// through as literal data (not stripped, not followed); if such a fragment is
289/// spliced where `PipelineConfig` is parsed, the leftover key surfaces as a
290/// `deny_unknown_fields` error downstream.
291fn load_fragment(
292    path: &Path,
293    visited: &mut Vec<PathBuf>,
294    depth: usize,
295) -> CliResult<serde_yaml::Value> {
296    if depth > MAX_COMPOSE_DEPTH {
297        return Err(CliError::CompositionDepthExceeded {
298            max: MAX_COMPOSE_DEPTH,
299        });
300    }
301    let key = cycle_key(path);
302    if visited.contains(&key) {
303        let mut chain: Vec<String> = visited.iter().map(|p| p.display().to_string()).collect();
304        chain.push(key.display().to_string());
305        return Err(CliError::CompositionCycle { chain });
306    }
307    visited.push(key);
308
309    let text = std::fs::read_to_string(path).map_err(|source| CliError::ReadConfig {
310        path: path.to_path_buf(),
311        source,
312    })?;
313    let mut yv: serde_yaml::Value = match format_of(path)? {
314        Format::Yaml => serde_yaml::from_str(&text).map_err(|e| CliError::ParseConfig {
315            path: path.to_path_buf(),
316            message: e.to_string(),
317        })?,
318        Format::Json => {
319            let jv: JsonValue = serde_json::from_str(&text).map_err(|e| CliError::ParseConfig {
320                path: path.to_path_buf(),
321                message: e.to_string(),
322            })?;
323            serde_yaml::to_value(jv).map_err(|e| CliError::ParseConfig {
324                path: path.to_path_buf(),
325                message: format!("could not convert JSON fragment to YAML: {e}"),
326            })?
327        }
328    };
329    resolve_includes(&mut yv, path, visited, depth)?;
330    visited.pop();
331    Ok(yv)
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use std::io::Write;
338
339    fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
340        let p = dir.join(name);
341        let mut f = std::fs::File::create(&p).unwrap();
342        f.write_all(body.as_bytes()).unwrap();
343        p
344    }
345
346    #[test]
347    fn fast_path_returns_raw_text_unchanged() {
348        let dir = tempfile::tempdir().unwrap();
349        let body = "version: 1\npipeline:\n  source: { type: csv, config: { path: x.csv } }\n  sink: { type: jsonl, config: { path: o.jsonl } }\n";
350        let p = write(dir.path(), "p.yaml", body);
351        let out = compose(&p, None).unwrap();
352        assert_eq!(
353            out, body,
354            "no directives + no profile must be byte-identical"
355        );
356    }
357
358    #[test]
359    fn single_extends_child_wins_and_base_keys_survive() {
360        let dir = tempfile::tempdir().unwrap();
361        write(
362            dir.path(),
363            "base.yaml",
364            "version: 1\npipeline:\n  source: { type: csv, config: { path: BASE.csv } }\n  sink: { type: jsonl, config: { path: base.jsonl } }\n",
365        );
366        let app = write(
367            dir.path(),
368            "app.yaml",
369            "extends: ./base.yaml\npipeline:\n  source: { config: { path: APP.csv } }\n",
370        );
371        let text = compose(&app, None).unwrap();
372        let v: JsonValue = serde_yaml::from_str(&text).unwrap();
373        assert_eq!(v["pipeline"]["source"]["config"]["path"], "APP.csv"); // child wins
374        assert_eq!(v["pipeline"]["sink"]["config"]["path"], "base.jsonl"); // base survives
375        assert!(v.get("extends").is_none(), "extends must be stripped");
376    }
377
378    #[test]
379    fn extends_list_merges_left_to_right() {
380        let dir = tempfile::tempdir().unwrap();
381        write(
382            dir.path(),
383            "a.yaml",
384            "version: 1\npipeline: { transforms: [] }\nshared: { a: 1, both: \"from-a\" }\n",
385        );
386        write(dir.path(), "b.yaml", "shared: { b: 2, both: \"from-b\" }\n");
387        let app = write(
388            dir.path(),
389            "app.yaml",
390            "extends: [./a.yaml, ./b.yaml]\nshared: { c: 3 }\n",
391        );
392        let v: JsonValue = serde_yaml::from_str(&compose(&app, None).unwrap()).unwrap();
393        assert_eq!(v["shared"]["a"], 1);
394        assert_eq!(v["shared"]["b"], 2);
395        assert_eq!(v["shared"]["c"], 3);
396        assert_eq!(
397            v["shared"]["both"], "from-b",
398            "later base wins over earlier"
399        );
400    }
401
402    #[test]
403    fn profile_overlay_beats_extended_base() {
404        let dir = tempfile::tempdir().unwrap();
405        write(
406            dir.path(),
407            "base.yaml",
408            "version: 1\npipeline:\n  sink: { type: jsonl, config: { path: base.jsonl } }\n",
409        );
410        let app = write(
411            dir.path(),
412            "app.yaml",
413            "extends: ./base.yaml\nprofiles:\n  prod:\n    pipeline:\n      sink: { config: { path: prod.jsonl } }\n",
414        );
415        let v: JsonValue = serde_yaml::from_str(&compose(&app, Some("prod")).unwrap()).unwrap();
416        assert_eq!(v["pipeline"]["sink"]["config"]["path"], "prod.jsonl");
417        assert!(v.get("profiles").is_none(), "profiles must be stripped");
418    }
419
420    #[test]
421    fn profiles_stripped_when_no_profile_selected() {
422        let dir = tempfile::tempdir().unwrap();
423        let p = write(
424            dir.path(),
425            "p.yaml",
426            "version: 1\npipeline:\n  sink: { type: jsonl, config: { path: base.jsonl } }\nprofiles:\n  prod: { pipeline: { sink: { config: { path: prod.jsonl } } } }\n",
427        );
428        let v: JsonValue = serde_yaml::from_str(&compose(&p, None).unwrap()).unwrap();
429        assert_eq!(v["pipeline"]["sink"]["config"]["path"], "base.jsonl");
430        assert!(v.get("profiles").is_none());
431    }
432
433    #[test]
434    fn unknown_profile_errors_with_known_list() {
435        let dir = tempfile::tempdir().unwrap();
436        let p = write(
437            dir.path(),
438            "p.yaml",
439            "version: 1\nprofiles:\n  dev: {}\n  prod: {}\npipeline: {}\n",
440        );
441        match compose(&p, Some("staging")).unwrap_err() {
442            CliError::UnknownProfile { name, known } => {
443                assert_eq!(name, "staging");
444                assert!(known.contains(&"dev".to_string()) && known.contains(&"prod".to_string()));
445            }
446            other => panic!("expected UnknownProfile, got {other:?}"),
447        }
448    }
449
450    #[test]
451    fn missing_extends_base_errors() {
452        let dir = tempfile::tempdir().unwrap();
453        let app = write(dir.path(), "app.yaml", "extends: ./nope.yaml\nversion: 1\n");
454        assert!(matches!(
455            compose(&app, None).unwrap_err(),
456            CliError::IncludeNotFound { .. }
457        ));
458    }
459
460    #[test]
461    fn extends_cycle_errors() {
462        let dir = tempfile::tempdir().unwrap();
463        write(dir.path(), "a.yaml", "extends: ./b.yaml\nversion: 1\n");
464        write(dir.path(), "b.yaml", "extends: ./a.yaml\nversion: 1\n");
465        let a = dir.path().join("a.yaml");
466        assert!(matches!(
467            compose(&a, None).unwrap_err(),
468            CliError::CompositionCycle { .. }
469        ));
470    }
471
472    #[test]
473    fn diamond_extends_does_not_false_trigger_cycle() {
474        // app extends [b, c]; both b and c extend d. d is reached twice on
475        // different branches — that is NOT a cycle.
476        let dir = tempfile::tempdir().unwrap();
477        write(
478            dir.path(),
479            "d.yaml",
480            "version: 1\nshared: { from_d: true }\n",
481        );
482        write(
483            dir.path(),
484            "b.yaml",
485            "extends: ./d.yaml\nshared: { from_b: true }\n",
486        );
487        write(
488            dir.path(),
489            "c.yaml",
490            "extends: ./d.yaml\nshared: { from_c: true }\n",
491        );
492        let app = write(
493            dir.path(),
494            "app.yaml",
495            "extends: [./b.yaml, ./c.yaml]\nshared: { from_app: true }\n",
496        );
497        let v: JsonValue = serde_yaml::from_str(&compose(&app, None).unwrap()).unwrap();
498        assert_eq!(v["shared"]["from_d"], true);
499        assert_eq!(v["shared"]["from_b"], true);
500        assert_eq!(v["shared"]["from_c"], true);
501        assert_eq!(v["shared"]["from_app"], true);
502    }
503
504    #[test]
505    fn missing_base_after_successful_base_errors() {
506        let dir = tempfile::tempdir().unwrap();
507        write(dir.path(), "good.yaml", "version: 1\n");
508        let app = write(
509            dir.path(),
510            "app.yaml",
511            "extends: [./good.yaml, ./nope.yaml]\n",
512        );
513        assert!(matches!(
514            compose(&app, None).unwrap_err(),
515            CliError::IncludeNotFound { .. }
516        ));
517    }
518
519    #[test]
520    fn self_extends_is_a_cycle() {
521        let dir = tempfile::tempdir().unwrap();
522        let a = write(dir.path(), "a.yaml", "extends: ./a.yaml\nversion: 1\n");
523        assert!(matches!(
524            compose(&a, None).unwrap_err(),
525            CliError::CompositionCycle { .. }
526        ));
527    }
528
529    #[test]
530    fn yaml_can_extend_json_base() {
531        let dir = tempfile::tempdir().unwrap();
532        write(
533            dir.path(),
534            "base.json",
535            "{ \"version\": 1, \"pipeline\": { \"sink\": { \"type\": \"jsonl\", \"config\": { \"path\": \"base.jsonl\" } } } }",
536        );
537        let app = write(
538            dir.path(),
539            "app.yaml",
540            "extends: ./base.json\npipeline:\n  source: { type: csv, config: { path: a.csv } }\n",
541        );
542        let v: JsonValue = serde_yaml::from_str(&compose(&app, None).unwrap()).unwrap();
543        assert_eq!(v["pipeline"]["sink"]["config"]["path"], "base.jsonl");
544        assert_eq!(v["pipeline"]["source"]["config"]["path"], "a.csv");
545    }
546
547    #[test]
548    fn include_substitutes_at_nested_map_position() {
549        let dir = tempfile::tempdir().unwrap();
550        write(
551            dir.path(),
552            "auth.yaml",
553            "type: bearer\nconfig: { token: T }\n",
554        );
555        let app = write(
556            dir.path(),
557            "app.yaml",
558            "version: 1\npipeline:\n  source:\n    type: rest\n    config: { base_url: https://x }\n    auth: !include ./auth.yaml\n",
559        );
560        let v: JsonValue = serde_yaml::from_str(&compose(&app, None).unwrap()).unwrap();
561        assert_eq!(v["pipeline"]["source"]["auth"]["type"], "bearer");
562        assert_eq!(v["pipeline"]["source"]["auth"]["config"]["token"], "T");
563    }
564
565    #[test]
566    fn include_substitutes_a_sequence_fragment() {
567        let dir = tempfile::tempdir().unwrap();
568        write(
569            dir.path(),
570            "tx.yaml",
571            "- { type: flatten }\n- { type: redact, config: { fields: [ssn] } }\n",
572        );
573        let app = write(
574            dir.path(),
575            "app.yaml",
576            "version: 1\npipeline:\n  source: { type: csv, config: { path: x.csv } }\n  sink: { type: jsonl, config: { path: o.jsonl } }\n  transforms: !include ./tx.yaml\n",
577        );
578        let v: JsonValue = serde_yaml::from_str(&compose(&app, None).unwrap()).unwrap();
579        assert_eq!(v["pipeline"]["transforms"][0]["type"], "flatten");
580        assert_eq!(v["pipeline"]["transforms"][1]["type"], "redact");
581    }
582
583    #[test]
584    fn include_combined_with_extends() {
585        let dir = tempfile::tempdir().unwrap();
586        write(
587            dir.path(),
588            "base.yaml",
589            "version: 1\npipeline:\n  sink: { type: jsonl, config: { path: base.jsonl } }\n",
590        );
591        write(
592            dir.path(),
593            "src.yaml",
594            "type: csv\nconfig: { path: from-include.csv }\n",
595        );
596        let app = write(
597            dir.path(),
598            "app.yaml",
599            "extends: ./base.yaml\npipeline:\n  source: !include ./src.yaml\n",
600        );
601        let v: JsonValue = serde_yaml::from_str(&compose(&app, None).unwrap()).unwrap();
602        assert_eq!(
603            v["pipeline"]["source"]["config"]["path"],
604            "from-include.csv"
605        );
606        assert_eq!(v["pipeline"]["sink"]["config"]["path"], "base.jsonl");
607    }
608
609    #[test]
610    fn include_non_string_payload_errors() {
611        let dir = tempfile::tempdir().unwrap();
612        let app = write(
613            dir.path(),
614            "app.yaml",
615            "version: 1\nbad: !include { not: a-path }\n",
616        );
617        assert!(matches!(
618            compose(&app, None).unwrap_err(),
619            CliError::BadInclude { .. }
620        ));
621    }
622
623    #[test]
624    fn include_missing_file_errors() {
625        let dir = tempfile::tempdir().unwrap();
626        let app = write(
627            dir.path(),
628            "app.yaml",
629            "version: 1\nx: !include ./nope.yaml\n",
630        );
631        assert!(matches!(
632            compose(&app, None).unwrap_err(),
633            CliError::IncludeNotFound { .. }
634        ));
635    }
636
637    #[test]
638    fn include_cycle_errors() {
639        let dir = tempfile::tempdir().unwrap();
640        write(dir.path(), "a.yaml", "x: !include ./b.yaml\n");
641        write(dir.path(), "b.yaml", "y: !include ./a.yaml\n");
642        let a = dir.path().join("a.yaml");
643        assert!(matches!(
644            compose(&a, None).unwrap_err(),
645            CliError::CompositionCycle { .. }
646        ));
647    }
648
649    #[test]
650    fn cross_mechanism_extends_include_cycle_errors() {
651        // `a extends b`, `b !includes a` — the extends and include stacks are
652        // shared, so the loop surfaces as a CompositionCycle (not a confusing
653        // leftover-key parse error or an infinite loop).
654        let dir = tempfile::tempdir().unwrap();
655        write(dir.path(), "a.yaml", "extends: ./b.yaml\nversion: 1\n");
656        write(dir.path(), "b.yaml", "x: !include ./a.yaml\n");
657        let a = dir.path().join("a.yaml");
658        assert!(matches!(
659            compose(&a, None).unwrap_err(),
660            CliError::CompositionCycle { .. }
661        ));
662    }
663}