1use crate::error::{CliError, CliResult};
11use crate::merge::merge_value;
12use serde_json::Value as JsonValue;
13use std::path::{Path, PathBuf};
14
15const MAX_COMPOSE_DEPTH: usize = 32;
17
18#[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
40fn 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
51fn cycle_key(path: &Path) -> PathBuf {
54 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
55}
56
57fn has_composition_markers(raw: &str) -> bool {
61 raw.contains("extends") || raw.contains("profiles") || raw.contains("!include")
62}
63
64pub 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 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 if let JsonValue::Object(map) = &mut merged {
79 map.remove("profiles");
80 map.remove("extends");
81 }
82 serialize_for(entry, &merged)
83}
84
85fn 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 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); acc
125 };
126
127 visited.pop();
128 Ok(result)
129}
130
131fn 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
157fn 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
168fn 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
195fn 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
217fn 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
232fn 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
285fn 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"); assert_eq!(v["pipeline"]["sink"]["config"]["path"], "base.jsonl"); 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 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 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}