1use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::interpolation::{DocumentInterpolation, InterpolationInput};
5use crate::loader::{LoadedProject, ProjectInterpolation};
6use crate::model::{Located, ShortPort, ShortVolumeMount};
7use crate::source::{SourceId, SourceSpan};
8use crate::syntax::{MergeScalarKind, MergeSyntaxEntry, MergeSyntaxScalar, MergeSyntaxValue, ScalarPresentation};
9use std::fmt;
10use std::path::{Path, PathBuf};
11
12pub const INTERPOLATION_PROJECT_MISMATCH: DiagnosticCode =
14 DiagnosticCode::new("compose.merge.interpolation-project-mismatch");
15
16pub const INVALID_DOCUMENT_ROOT: DiagnosticCode = DiagnosticCode::new("compose.merge.invalid-document-root");
18
19pub const UNRESOLVED_ALIAS: DiagnosticCode = DiagnosticCode::new("compose.merge.unresolved-alias");
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum MergeOperation {
25 Authored,
27 Added,
29 Replaced,
31 Merged,
33 Appended,
35 Reset,
37 Override,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct MergeProvenance {
44 operation: MergeOperation,
45 sources: Vec<SourceSpan>,
46}
47
48impl MergeProvenance {
49 #[must_use]
51 pub const fn operation(&self) -> MergeOperation {
52 self.operation
53 }
54
55 #[must_use]
57 pub fn sources(&self) -> &[SourceSpan] {
58 &self.sources
59 }
60
61 #[must_use]
63 pub fn effective_source(&self) -> Option<SourceSpan> {
64 self.sources.last().copied()
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub enum MergedScalarKind {
71 String,
73 Boolean,
75 Number,
77}
78
79#[derive(Clone, PartialEq, Eq)]
81pub struct MergedScalar {
82 raw: String,
83 value: String,
84 kind: MergedScalarKind,
85 plain: bool,
86 presentation: ScalarPresentation,
87 strict_yaml_string: bool,
88 sensitive: bool,
89}
90
91impl MergedScalar {
92 #[must_use]
94 pub fn raw(&self) -> &str {
95 &self.raw
96 }
97
98 #[must_use]
100 pub fn value(&self) -> &str {
101 &self.value
102 }
103
104 #[must_use]
106 pub const fn kind(&self) -> MergedScalarKind {
107 self.kind
108 }
109
110 #[must_use]
112 pub const fn is_strict_yaml_string(&self) -> bool {
113 self.strict_yaml_string
114 }
115
116 #[must_use]
118 pub const fn presentation(&self) -> ScalarPresentation {
119 self.presentation
120 }
121
122 #[must_use]
124 pub(crate) const fn is_plain_style(&self) -> bool {
125 self.plain
126 }
127
128 #[must_use]
130 pub const fn is_sensitive(&self) -> bool {
131 self.sensitive
132 }
133}
134
135impl fmt::Debug for MergedScalar {
136 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137 formatter
138 .debug_struct("MergedScalar")
139 .field("raw", &self.raw)
140 .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
141 .field("kind", &self.kind)
142 .field("plain", &self.plain)
143 .field("presentation", &self.presentation)
144 .field("strict_yaml_string", &self.strict_yaml_string)
145 .field("sensitive", &self.sensitive)
146 .finish()
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152pub enum NullStyle {
153 Empty,
155 Explicit,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
161pub enum EntrySyntax {
162 Mapping,
164 ListKeyValue,
166 ListKeyOnly,
168}
169
170#[derive(Clone, PartialEq, Eq)]
172pub struct MergedEntry {
173 key: String,
174 key_sources: Vec<SourceSpan>,
175 key_sensitive: bool,
176 strict_yaml_string: bool,
177 syntax: EntrySyntax,
178 raw_list_item: Option<MergedScalar>,
179 value: MergedValue,
180}
181
182impl fmt::Debug for MergedEntry {
183 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184 formatter
185 .debug_struct("MergedEntry")
186 .field("key", &if self.key_sensitive { "<redacted>" } else { &self.key })
187 .field("key_sources", &self.key_sources)
188 .field("key_sensitive", &self.key_sensitive)
189 .field("strict_yaml_string", &self.strict_yaml_string)
190 .field("syntax", &self.syntax)
191 .field("raw_list_item", &self.raw_list_item)
192 .field("value", &self.value)
193 .finish()
194 }
195}
196
197impl MergedEntry {
198 #[must_use]
203 pub fn key(&self) -> &str {
204 &self.key
205 }
206
207 #[must_use]
209 pub fn key_sources(&self) -> &[SourceSpan] {
210 &self.key_sources
211 }
212
213 #[must_use]
215 pub const fn is_key_sensitive(&self) -> bool {
216 self.key_sensitive
217 }
218
219 #[must_use]
221 pub const fn is_strict_yaml_string(&self) -> bool {
222 self.strict_yaml_string
223 }
224
225 #[must_use]
227 pub const fn syntax(&self) -> EntrySyntax {
228 self.syntax
229 }
230
231 #[must_use]
233 pub const fn raw_list_item(&self) -> Option<&MergedScalar> {
234 self.raw_list_item.as_ref()
235 }
236
237 #[must_use]
244 pub fn raw_list_item_input(&self) -> Option<InterpolationInput<'_>> {
245 let scalar = self.raw_list_item.as_ref()?;
246 let input = InterpolationInput::new(scalar.value(), *self.key_sources.last()?);
247 Some(if scalar.is_sensitive() {
248 input.sensitive()
249 } else {
250 input
251 })
252 }
253
254 #[must_use]
256 pub const fn value(&self) -> &MergedValue {
257 &self.value
258 }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
263pub enum MergedValueKind {
264 Null(NullStyle),
266 Scalar(MergedScalar),
268 Mapping(Vec<MergedEntry>),
270 Sequence(Vec<MergedValue>),
272 Alias(String),
274 Tagged {
276 tag: String,
278 value: Box<MergedValue>,
280 },
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct MergedValue {
286 kind: MergedValueKind,
287 provenance: MergeProvenance,
288}
289
290impl MergedValue {
291 #[must_use]
293 pub const fn kind(&self) -> &MergedValueKind {
294 &self.kind
295 }
296
297 #[must_use]
299 pub const fn provenance(&self) -> &MergeProvenance {
300 &self.provenance
301 }
302
303 #[must_use]
305 pub const fn as_scalar(&self) -> Option<&MergedScalar> {
306 match &self.kind {
307 MergedValueKind::Scalar(value) => Some(value),
308 _ => None,
309 }
310 }
311
312 #[must_use]
314 pub fn as_mapping(&self) -> Option<&[MergedEntry]> {
315 match &self.kind {
316 MergedValueKind::Mapping(entries) => Some(entries),
317 _ => None,
318 }
319 }
320
321 #[must_use]
323 pub fn as_sequence(&self) -> Option<&[MergedValue]> {
324 match &self.kind {
325 MergedValueKind::Sequence(values) => Some(values),
326 _ => None,
327 }
328 }
329
330 #[must_use]
332 pub fn get(&self, key: &str) -> Option<&MergedValue> {
333 self.as_mapping()?
334 .iter()
335 .find(|entry| entry.key == key)
336 .map(|entry| &entry.value)
337 }
338
339 #[must_use]
341 pub fn is_sensitive(&self) -> bool {
342 match &self.kind {
343 MergedValueKind::Scalar(value) => value.sensitive,
344 MergedValueKind::Mapping(entries) => entries
345 .iter()
346 .any(|entry| entry.key_sensitive || entry.value.is_sensitive()),
347 MergedValueKind::Sequence(values) => values.iter().any(Self::is_sensitive),
348 MergedValueKind::Tagged { value, .. } => value.is_sensitive(),
349 MergedValueKind::Null(_) | MergedValueKind::Alias(_) => false,
350 }
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct MergedProject {
357 root: MergedValue,
358 base_directory: PathBuf,
359 source_ids: Vec<SourceId>,
360}
361
362impl MergedProject {
363 #[must_use]
365 pub const fn root(&self) -> &MergedValue {
366 &self.root
367 }
368
369 #[must_use]
371 pub fn base_directory(&self) -> &Path {
372 &self.base_directory
373 }
374
375 #[must_use]
377 pub fn source_ids(&self) -> &[SourceId] {
378 &self.source_ids
379 }
380
381 #[must_use]
383 pub fn value(&self, path: &[&str]) -> Option<&MergedValue> {
384 path.iter().try_fold(&self.root, |value, key| value.get(key))
385 }
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct MergeResult {
391 project: Option<MergedProject>,
392 diagnostics: Vec<Diagnostic>,
393}
394
395impl MergeResult {
396 #[must_use]
398 pub const fn project(&self) -> Option<&MergedProject> {
399 self.project.as_ref()
400 }
401
402 #[must_use]
404 pub fn diagnostics(&self) -> &[Diagnostic] {
405 &self.diagnostics
406 }
407
408 #[must_use]
410 pub fn is_valid(&self) -> bool {
411 self.project.is_some()
412 && self
413 .diagnostics
414 .iter()
415 .all(|diagnostic| diagnostic.severity() != Severity::Error)
416 }
417}
418
419#[must_use]
424pub fn merge_project(project: &LoadedProject, interpolation: Option<&ProjectInterpolation>) -> MergeResult {
425 let mut diagnostics = project.diagnostics().to_vec();
426 if let Some(interpolation) = interpolation {
427 diagnostics.extend(interpolation.diagnostics().iter().cloned());
428 }
429
430 let mut root = None;
431 let mut source_ids = Vec::new();
432 for document in project.documents() {
433 let overlay = interpolation.and_then(|values| values.document(document.source_id()));
434 if interpolation.is_some() && overlay.is_none() {
435 diagnostics.push(
436 Diagnostic::new(
437 INTERPOLATION_PROJECT_MISMATCH,
438 Severity::Error,
439 "loaded document has no matching interpolation overlay",
440 )
441 .with_label(DiagnosticLabel::primary(
442 document.syntax().source_span(),
443 "missing per-file overlay",
444 )),
445 );
446 }
447
448 let Some(syntax_root) = document.syntax().merge_root() else {
449 continue;
450 };
451 if !matches!(syntax_root, MergeSyntaxValue::Mapping { .. }) {
452 diagnostics.push(
453 Diagnostic::new(
454 INVALID_DOCUMENT_ROOT,
455 Severity::Error,
456 "Compose merge inputs must have mapping roots",
457 )
458 .with_label(DiagnosticLabel::primary(
459 document.syntax().source_span(),
460 "document is not a mapping",
461 )),
462 );
463 continue;
464 }
465
466 let value = convert_value(syntax_root, overlay, &mut diagnostics);
467 source_ids.push(document.source_id());
468 root = Some(match root {
469 Some(current) => merge_value(current, value, &[], &mut diagnostics),
470 None => activate_tags(value, &[], &mut diagnostics),
471 });
472 }
473
474 if let Some(interpolation) = interpolation {
475 for overlay in interpolation.documents() {
476 if project.document(overlay.source_id()).is_none() {
477 diagnostics.push(Diagnostic::new(
478 INTERPOLATION_PROJECT_MISMATCH,
479 Severity::Error,
480 "interpolation overlay does not belong to the loaded project",
481 ));
482 }
483 }
484 }
485
486 MergeResult {
487 project: root.map(|root| MergedProject {
488 root,
489 base_directory: project.base_directory().to_path_buf(),
490 source_ids,
491 }),
492 diagnostics,
493 }
494}
495
496fn convert_value(
497 value: MergeSyntaxValue,
498 interpolation: Option<&DocumentInterpolation>,
499 diagnostics: &mut Vec<Diagnostic>,
500) -> MergedValue {
501 match value {
502 MergeSyntaxValue::Empty(span) => authored(MergedValueKind::Null(NullStyle::Empty), span),
503 MergeSyntaxValue::Scalar(value) if value.kind == MergeScalarKind::Null => {
504 authored(MergedValueKind::Null(NullStyle::Explicit), value.span)
505 }
506 MergeSyntaxValue::Scalar(value) => convert_scalar(value, interpolation),
507 MergeSyntaxValue::Mapping { entries, span } => {
508 let entries = entries
509 .into_iter()
510 .map(|entry| convert_entry(entry, interpolation, diagnostics))
511 .collect();
512 authored(MergedValueKind::Mapping(entries), span)
513 }
514 MergeSyntaxValue::Sequence { values, span } => {
515 let values = values
516 .into_iter()
517 .map(|value| convert_value(value, interpolation, diagnostics))
518 .collect();
519 authored(MergedValueKind::Sequence(values), span)
520 }
521 MergeSyntaxValue::Alias { name, span } => {
522 diagnostics.push(
523 Diagnostic::new(UNRESOLVED_ALIAS, Severity::Warning, "YAML alias could not be resolved")
524 .with_label(DiagnosticLabel::primary(span, "unresolved alias")),
525 );
526 authored(MergedValueKind::Alias(name), span)
527 }
528 MergeSyntaxValue::Tagged { tag, value, span } => {
529 let value = convert_value(*value, interpolation, diagnostics);
530 authored(
531 MergedValueKind::Tagged {
532 tag,
533 value: Box::new(value),
534 },
535 span,
536 )
537 }
538 }
539}
540
541fn convert_entry(
542 entry: MergeSyntaxEntry,
543 interpolation: Option<&DocumentInterpolation>,
544 diagnostics: &mut Vec<Diagnostic>,
545) -> MergedEntry {
546 MergedEntry {
547 key: entry.key.value,
548 key_sources: vec![entry.key.span],
549 key_sensitive: false,
550 strict_yaml_string: entry.key.strict_yaml_string,
551 syntax: EntrySyntax::Mapping,
552 raw_list_item: None,
553 value: convert_value(entry.value, interpolation, diagnostics),
554 }
555}
556
557fn convert_scalar(value: MergeSyntaxScalar, interpolation: Option<&DocumentInterpolation>) -> MergedValue {
558 let resolved = interpolation.and_then(|overlay| overlay.value(value.span));
559 let semantic = resolved.map_or_else(|| value.value.clone(), |result| result.resolved().to_owned());
560 let sensitive = resolved.is_some_and(crate::interpolation::InterpolationResult::is_sensitive);
561 let kind = match value.kind {
562 MergeScalarKind::String => MergedScalarKind::String,
563 MergeScalarKind::Boolean => MergedScalarKind::Boolean,
564 MergeScalarKind::Number => MergedScalarKind::Number,
565 MergeScalarKind::Null => unreachable!("null scalars are converted before convert_scalar"),
566 };
567 authored(
568 MergedValueKind::Scalar(MergedScalar {
569 raw: value.raw,
570 value: semantic,
571 kind,
572 plain: value.plain,
573 presentation: value.presentation,
574 strict_yaml_string: value.strict_yaml_string,
575 sensitive,
576 }),
577 value.span,
578 )
579}
580
581fn authored(kind: MergedValueKind, span: SourceSpan) -> MergedValue {
582 MergedValue {
583 kind,
584 provenance: MergeProvenance {
585 operation: MergeOperation::Authored,
586 sources: vec![span],
587 },
588 }
589}
590
591fn activate_tags(mut value: MergedValue, path: &[String], diagnostics: &mut Vec<Diagnostic>) -> MergedValue {
592 value = match value.kind {
593 MergedValueKind::Tagged { tag, value: inner } if tag == "!reset" => reset_value(None, &inner, value.provenance),
594 MergedValueKind::Tagged { tag, value: inner } if tag == "!override" => {
595 override_value(None, *inner, value.provenance)
596 }
597 kind => MergedValue {
598 kind,
599 provenance: value.provenance,
600 },
601 };
602
603 match &mut value.kind {
604 MergedValueKind::Mapping(entries) => {
605 for entry in entries {
606 let mut child_path = path.to_vec();
607 child_path.push(entry.key.clone());
608 entry.value = activate_tags(entry.value.clone(), &child_path, diagnostics);
609 }
610 }
611 MergedValueKind::Sequence(values) => {
612 for item in values {
613 *item = activate_tags(item.clone(), path, diagnostics);
614 }
615 }
616 MergedValueKind::Tagged { value: inner, .. } => {
617 **inner = activate_tags((**inner).clone(), path, diagnostics);
618 }
619 MergedValueKind::Null(_) | MergedValueKind::Scalar(_) | MergedValueKind::Alias(_) => {}
620 }
621 let _ = diagnostics;
622 value
623}
624
625fn merge_value(
626 base: MergedValue,
627 incoming: MergedValue,
628 path: &[String],
629 diagnostics: &mut Vec<Diagnostic>,
630) -> MergedValue {
631 if let MergedValueKind::Tagged { tag, value } = incoming.kind {
632 if tag == "!reset" {
633 return reset_value(Some(&base), &value, incoming.provenance);
634 }
635 if tag == "!override" {
636 return override_value(Some(&base), *value, incoming.provenance);
637 }
638 return replace_value(
639 base,
640 MergedValue {
641 kind: MergedValueKind::Tagged { tag, value },
642 provenance: incoming.provenance,
643 },
644 MergeOperation::Replaced,
645 );
646 }
647
648 if is_shell_command(path) {
649 return replace_value(base, incoming, MergeOperation::Replaced);
650 }
651
652 if is_whole_sequence_replacement(path) {
653 return replace_value(
654 base,
655 activate_tags(incoming, path, diagnostics),
656 MergeOperation::Replaced,
657 );
658 }
659
660 if is_keyed_mapping(path) {
661 let normalize = if is_annotations(path) {
662 normalize_annotations
663 } else {
664 normalize_keyed
665 };
666 if let (Some(base), Some(incoming)) = (normalize(base.clone()), normalize(incoming.clone())) {
667 return merge_mappings(base, incoming, path, diagnostics);
668 }
669 }
670
671 match (&base.kind, &incoming.kind) {
672 (MergedValueKind::Mapping(_), MergedValueKind::Mapping(_)) => merge_mappings(base, incoming, path, diagnostics),
673 (MergedValueKind::Sequence(_), MergedValueKind::Sequence(_)) if unique_field(path).is_some() => {
674 merge_unique_sequences(base, incoming, path, diagnostics)
675 }
676 (MergedValueKind::Sequence(_), MergedValueKind::Sequence(_)) => append_sequences(base, incoming),
677 _ => replace_value(
678 base,
679 activate_tags(incoming, path, diagnostics),
680 MergeOperation::Replaced,
681 ),
682 }
683}
684
685fn merge_mappings(
686 base: MergedValue,
687 incoming: MergedValue,
688 path: &[String],
689 diagnostics: &mut Vec<Diagnostic>,
690) -> MergedValue {
691 let MergedValueKind::Mapping(mut base_entries) = base.kind else {
692 return base;
693 };
694 let MergedValueKind::Mapping(incoming_entries) = incoming.kind else {
695 return MergedValue {
696 kind: MergedValueKind::Mapping(base_entries),
697 provenance: base.provenance,
698 };
699 };
700
701 for incoming_entry in incoming_entries {
702 if let Some(index) = base_entries.iter().position(|entry| {
703 entry.key == incoming_entry.key && entry.strict_yaml_string == incoming_entry.strict_yaml_string
704 }) {
705 let mut child_path = path.to_vec();
706 child_path.push(incoming_entry.key.clone());
707 let existing = &mut base_entries[index];
708 existing.value = merge_value(existing.value.clone(), incoming_entry.value, &child_path, diagnostics);
709 extend_sources(&mut existing.key_sources, &incoming_entry.key_sources);
710 existing.key_sensitive |= incoming_entry.key_sensitive;
711 existing.strict_yaml_string = incoming_entry.strict_yaml_string;
712 existing.syntax = incoming_entry.syntax;
713 existing.raw_list_item = incoming_entry.raw_list_item;
714 } else {
715 let mut incoming_entry = incoming_entry;
716 incoming_entry.value = activate_tags(incoming_entry.value, path, diagnostics);
717 mark_added(&mut incoming_entry.value);
718 base_entries.push(incoming_entry);
719 }
720 }
721
722 MergedValue {
723 kind: MergedValueKind::Mapping(base_entries),
724 provenance: combined_provenance(base.provenance, &incoming.provenance, MergeOperation::Merged),
725 }
726}
727
728fn append_sequences(base: MergedValue, incoming: MergedValue) -> MergedValue {
729 let MergedValueKind::Sequence(mut base_values) = base.kind else {
730 return base;
731 };
732 let MergedValueKind::Sequence(mut incoming_values) = incoming.kind else {
733 return MergedValue {
734 kind: MergedValueKind::Sequence(base_values),
735 provenance: base.provenance,
736 };
737 };
738 for value in &mut incoming_values {
739 mark_added(value);
740 }
741 base_values.append(&mut incoming_values);
742 MergedValue {
743 kind: MergedValueKind::Sequence(base_values),
744 provenance: combined_provenance(base.provenance, &incoming.provenance, MergeOperation::Appended),
745 }
746}
747
748fn merge_unique_sequences(
749 base: MergedValue,
750 incoming: MergedValue,
751 path: &[String],
752 diagnostics: &mut Vec<Diagnostic>,
753) -> MergedValue {
754 let Some(field) = unique_field(path) else {
755 return append_sequences(base, incoming);
756 };
757 if matches!(field, UniqueField::ExactStringScalar | UniqueField::ExactScalar) {
758 return merge_exact_scalar_sequences(base, incoming, field == UniqueField::ExactScalar);
759 }
760 let MergedValueKind::Sequence(mut base_values) = base.kind else {
761 return base;
762 };
763 let MergedValueKind::Sequence(incoming_values) = incoming.kind else {
764 return MergedValue {
765 kind: MergedValueKind::Sequence(base_values),
766 provenance: base.provenance,
767 };
768 };
769
770 for mut incoming_value in incoming_values {
771 let key = unique_key(&incoming_value, field);
772 let existing = key.as_ref().and_then(|key| {
773 base_values
774 .iter()
775 .position(|value| unique_key(value, field).as_ref() == Some(key))
776 });
777 if let Some(index) = existing {
778 base_values[index] = merge_value(base_values[index].clone(), incoming_value, path, diagnostics);
779 } else {
780 incoming_value = activate_tags(incoming_value, path, diagnostics);
781 mark_added(&mut incoming_value);
782 base_values.push(incoming_value);
783 }
784 }
785
786 MergedValue {
787 kind: MergedValueKind::Sequence(base_values),
788 provenance: combined_provenance(base.provenance, &incoming.provenance, MergeOperation::Merged),
789 }
790}
791
792fn merge_exact_scalar_sequences(
793 base: MergedValue,
794 incoming: MergedValue,
795 include_all_scalar_kinds: bool,
796) -> MergedValue {
797 let MergedValueKind::Sequence(mut values) = base.kind else {
798 return base;
799 };
800 let MergedValueKind::Sequence(mut incoming_values) = incoming.kind else {
801 return MergedValue {
802 kind: MergedValueKind::Sequence(values),
803 provenance: base.provenance,
804 };
805 };
806 for value in &mut incoming_values {
807 mark_added(value);
808 }
809 values.append(&mut incoming_values);
810
811 let mut deduplicated: Vec<MergedValue> = Vec::with_capacity(values.len());
812 for value in values {
813 let exact = value.as_scalar().and_then(|scalar| {
814 (include_all_scalar_kinds || scalar.kind == MergedScalarKind::String)
815 .then(|| (scalar.kind, scalar.value.clone()))
816 });
817 let existing = exact.as_ref().and_then(|(kind, exact)| {
818 deduplicated.iter().position(|candidate| {
819 candidate
820 .as_scalar()
821 .is_some_and(|scalar| scalar.kind == *kind && scalar.value == *exact)
822 })
823 });
824 if let Some(index) = existing {
825 let prior = &deduplicated[index];
826 let sensitive = prior.is_sensitive() || value.is_sensitive();
827 let provenance = combined_provenance(prior.provenance.clone(), &value.provenance, MergeOperation::Merged);
828 let mut retained = prior.clone();
829 retained.provenance = provenance;
830 if let MergedValueKind::Scalar(scalar) = &mut retained.kind {
831 scalar.sensitive = sensitive;
832 }
833 deduplicated[index] = retained;
834 } else {
835 deduplicated.push(value);
836 }
837 }
838
839 MergedValue {
840 kind: MergedValueKind::Sequence(deduplicated),
841 provenance: combined_provenance(base.provenance, &incoming.provenance, MergeOperation::Merged),
842 }
843}
844
845fn replace_value(base: MergedValue, mut incoming: MergedValue, operation: MergeOperation) -> MergedValue {
846 incoming.provenance = combined_provenance(base.provenance, &incoming.provenance, operation);
847 incoming
848}
849
850fn reset_value(base: Option<&MergedValue>, tagged: &MergedValue, tag: MergeProvenance) -> MergedValue {
851 let kind = match &tagged.kind {
852 MergedValueKind::Mapping(_) => MergedValueKind::Mapping(Vec::new()),
853 MergedValueKind::Sequence(_) => MergedValueKind::Sequence(Vec::new()),
854 MergedValueKind::Null(_) => match base.map(|value| &value.kind) {
855 Some(MergedValueKind::Mapping(_)) => MergedValueKind::Mapping(Vec::new()),
856 Some(MergedValueKind::Sequence(_)) => MergedValueKind::Sequence(Vec::new()),
857 _ => MergedValueKind::Null(NullStyle::Empty),
858 },
859 _ => MergedValueKind::Null(NullStyle::Empty),
860 };
861 let provenance = match base {
862 Some(base) => combined_provenance(base.provenance.clone(), &tag, MergeOperation::Reset),
863 None => MergeProvenance {
864 operation: MergeOperation::Reset,
865 sources: tag.sources,
866 },
867 };
868 MergedValue { kind, provenance }
869}
870
871fn override_value(base: Option<&MergedValue>, mut tagged: MergedValue, tag: MergeProvenance) -> MergedValue {
872 tagged.provenance = match base {
873 Some(base) => {
874 let prior = combined_provenance(base.provenance.clone(), &tag, MergeOperation::Override);
875 combined_provenance(prior, &tagged.provenance, MergeOperation::Override)
876 }
877 None => combined_provenance(tag, &tagged.provenance, MergeOperation::Override),
878 };
879 tagged
880}
881
882fn combined_provenance(
883 mut base: MergeProvenance,
884 incoming: &MergeProvenance,
885 operation: MergeOperation,
886) -> MergeProvenance {
887 extend_sources(&mut base.sources, &incoming.sources);
888 base.operation = operation;
889 base
890}
891
892fn extend_sources(target: &mut Vec<SourceSpan>, incoming: &[SourceSpan]) {
893 for span in incoming {
894 if !target.contains(span) {
895 target.push(*span);
896 }
897 }
898}
899
900fn mark_added(value: &mut MergedValue) {
901 if value.provenance.operation == MergeOperation::Authored {
902 value.provenance.operation = MergeOperation::Added;
903 }
904}
905
906fn is_shell_command(path: &[String]) -> bool {
907 matches!(path, [services, _, field] if services == "services" && (field == "command" || field == "entrypoint"))
908 || matches!(path, [services, _, healthcheck, test] if services == "services" && healthcheck == "healthcheck" && test == "test")
909}
910
911fn is_whole_sequence_replacement(path: &[String]) -> bool {
912 matches!(path, [services, _, field] if services == "services" && field == "dns_opt")
913}
914
915fn is_keyed_mapping(path: &[String]) -> bool {
916 matches!(path, [services, _, field] if services == "services" && (field == "environment" || field == "labels" || field == "annotations"))
917}
918
919fn is_annotations(path: &[String]) -> bool {
920 matches!(path, [services, _, field] if services == "services" && field == "annotations")
921}
922
923fn normalize_annotations(value: MergedValue) -> Option<MergedValue> {
924 if let MergedValueKind::Sequence(values) = &value.kind {
925 for item in values {
926 let scalar = item.as_scalar()?;
927 if scalar.kind != MergedScalarKind::String {
928 return None;
929 }
930 let (name, _) = scalar.value.split_once('=')?;
931 if name.is_empty() {
932 return None;
933 }
934 }
935 }
936 normalize_keyed(value)
937}
938
939fn normalize_keyed(value: MergedValue) -> Option<MergedValue> {
940 match value.kind {
941 MergedValueKind::Mapping(_) => Some(value),
942 MergedValueKind::Sequence(values) => {
943 let mut entries = Vec::with_capacity(values.len());
944 for value in values {
945 let scalar = value.as_scalar()?;
946 let (key, entry_value, syntax) = if let Some((key, entry_value)) = scalar.value.split_once('=') {
947 let scalar_value = MergedValue {
948 kind: MergedValueKind::Scalar(MergedScalar {
949 raw: entry_value.to_owned(),
950 value: entry_value.to_owned(),
951 kind: MergedScalarKind::String,
952 plain: true,
953 presentation: ScalarPresentation::Plain,
954 strict_yaml_string: true,
955 sensitive: scalar.sensitive,
956 }),
957 provenance: value.provenance.clone(),
958 };
959 (key.to_owned(), scalar_value, EntrySyntax::ListKeyValue)
960 } else {
961 let null = MergedValue {
962 kind: MergedValueKind::Null(NullStyle::Empty),
963 provenance: value.provenance.clone(),
964 };
965 (scalar.value.clone(), null, EntrySyntax::ListKeyOnly)
966 };
967 let key_source = value.provenance.effective_source()?;
968 entries.push(MergedEntry {
969 key,
970 key_sources: vec![key_source],
971 key_sensitive: scalar.sensitive,
972 strict_yaml_string: true,
973 syntax,
974 raw_list_item: Some(scalar.clone()),
975 value: entry_value,
976 });
977 }
978 Some(MergedValue {
979 kind: MergedValueKind::Mapping(entries),
980 provenance: value.provenance,
981 })
982 }
983 _ => None,
984 }
985}
986
987#[derive(Debug, Clone, Copy, PartialEq, Eq)]
988enum UniqueField {
989 ExactStringScalar,
990 ExactScalar,
991 Volume,
992 Device,
993 Config,
994 Secret,
995 Port,
996}
997
998fn unique_field(path: &[String]) -> Option<UniqueField> {
999 let [services, _, field] = path else {
1000 return None;
1001 };
1002 if services != "services" {
1003 return None;
1004 }
1005 match field.as_str() {
1006 "volumes" => Some(UniqueField::Volume),
1007 "devices" => Some(UniqueField::Device),
1008 "configs" => Some(UniqueField::Config),
1009 "secrets" => Some(UniqueField::Secret),
1010 "ports" => Some(UniqueField::Port),
1011 "cap_add" | "cap_drop" => Some(UniqueField::ExactStringScalar),
1012 "expose" => Some(UniqueField::ExactScalar),
1013 _ => None,
1014 }
1015}
1016
1017#[derive(Debug, Clone, PartialEq, Eq)]
1018enum UniqueKey {
1019 Scalar(MergedScalarKind, String),
1020 Target(String),
1021 Port {
1022 ip: String,
1023 target: String,
1024 published: String,
1025 protocol: String,
1026 },
1027}
1028
1029fn unique_key(value: &MergedValue, field: UniqueField) -> Option<UniqueKey> {
1030 match field {
1031 UniqueField::ExactStringScalar => value.as_scalar().and_then(|scalar| {
1032 (scalar.kind == MergedScalarKind::String).then(|| UniqueKey::Target(scalar.value.clone()))
1033 }),
1034 UniqueField::ExactScalar => value
1035 .as_scalar()
1036 .map(|scalar| UniqueKey::Scalar(scalar.kind, scalar.value.clone())),
1037 UniqueField::Volume | UniqueField::Device => target_key(value, true).map(UniqueKey::Target),
1038 UniqueField::Config | UniqueField::Secret => target_key(value, false).map(UniqueKey::Target),
1039 UniqueField::Port => port_key(value),
1040 }
1041}
1042
1043fn target_key(value: &MergedValue, colon_syntax: bool) -> Option<String> {
1044 if let Some(scalar) = value.as_scalar() {
1045 if colon_syntax {
1046 let span = value.provenance.effective_source()?;
1047 return ShortVolumeMount::new(Located::new(scalar.value.clone(), span))
1048 .target()
1049 .map(str::to_owned);
1050 }
1051 return Some(scalar.value.clone());
1052 }
1053 mapping_scalar(value, "target")
1054 .or_else(|| (!colon_syntax).then(|| mapping_scalar(value, "source")).flatten())
1055 .map(str::to_owned)
1056}
1057
1058fn port_key(value: &MergedValue) -> Option<UniqueKey> {
1059 if let Some(scalar) = value.as_scalar() {
1060 let span = value.provenance.effective_source()?;
1061 let port = ShortPort::parse(Located::new(scalar.value.clone(), span));
1062 return Some(UniqueKey::Port {
1063 ip: port.host_ip().unwrap_or_default().to_owned(),
1064 target: port.target().to_owned(),
1065 published: port.published().unwrap_or_default().to_owned(),
1066 protocol: port.protocol().unwrap_or("tcp").to_owned(),
1067 });
1068 }
1069 Some(UniqueKey::Port {
1070 ip: mapping_scalar(value, "host_ip").unwrap_or_default().to_owned(),
1071 target: mapping_scalar(value, "target")?.to_owned(),
1072 published: mapping_scalar(value, "published").unwrap_or_default().to_owned(),
1073 protocol: mapping_scalar(value, "protocol").unwrap_or("tcp").to_owned(),
1074 })
1075}
1076
1077fn mapping_scalar<'a>(value: &'a MergedValue, key: &str) -> Option<&'a str> {
1078 value.get(key)?.as_scalar().map(MergedScalar::value)
1079}