1use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::interpolation::DocumentInterpolation;
5use crate::loader::{LoadedProject, ProjectInterpolation};
6use crate::model::{Located, ShortPort, ShortVolumeMount};
7use crate::source::{SourceId, SourceSpan};
8use crate::syntax::{MergeScalarKind, MergeSyntaxEntry, MergeSyntaxScalar, MergeSyntaxValue};
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 sensitive: bool,
86}
87
88impl MergedScalar {
89 #[must_use]
91 pub fn raw(&self) -> &str {
92 &self.raw
93 }
94
95 #[must_use]
97 pub fn value(&self) -> &str {
98 &self.value
99 }
100
101 #[must_use]
103 pub const fn kind(&self) -> MergedScalarKind {
104 self.kind
105 }
106
107 #[must_use]
109 pub const fn is_sensitive(&self) -> bool {
110 self.sensitive
111 }
112}
113
114impl fmt::Debug for MergedScalar {
115 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116 formatter
117 .debug_struct("MergedScalar")
118 .field("raw", &self.raw)
119 .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
120 .field("kind", &self.kind)
121 .field("sensitive", &self.sensitive)
122 .finish()
123 }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
128pub enum NullStyle {
129 Empty,
131 Explicit,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
137pub enum EntrySyntax {
138 Mapping,
140 ListKeyValue,
142 ListKeyOnly,
144}
145
146#[derive(Clone, PartialEq, Eq)]
148pub struct MergedEntry {
149 key: String,
150 key_sources: Vec<SourceSpan>,
151 key_sensitive: bool,
152 syntax: EntrySyntax,
153 value: MergedValue,
154}
155
156impl fmt::Debug for MergedEntry {
157 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
158 formatter
159 .debug_struct("MergedEntry")
160 .field("key", &if self.key_sensitive { "<redacted>" } else { &self.key })
161 .field("key_sources", &self.key_sources)
162 .field("key_sensitive", &self.key_sensitive)
163 .field("syntax", &self.syntax)
164 .field("value", &self.value)
165 .finish()
166 }
167}
168
169impl MergedEntry {
170 #[must_use]
172 pub fn key(&self) -> &str {
173 &self.key
174 }
175
176 #[must_use]
178 pub fn key_sources(&self) -> &[SourceSpan] {
179 &self.key_sources
180 }
181
182 #[must_use]
184 pub const fn is_key_sensitive(&self) -> bool {
185 self.key_sensitive
186 }
187
188 #[must_use]
190 pub const fn syntax(&self) -> EntrySyntax {
191 self.syntax
192 }
193
194 #[must_use]
196 pub const fn value(&self) -> &MergedValue {
197 &self.value
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum MergedValueKind {
204 Null(NullStyle),
206 Scalar(MergedScalar),
208 Mapping(Vec<MergedEntry>),
210 Sequence(Vec<MergedValue>),
212 Alias(String),
214 Tagged {
216 tag: String,
218 value: Box<MergedValue>,
220 },
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct MergedValue {
226 kind: MergedValueKind,
227 provenance: MergeProvenance,
228}
229
230impl MergedValue {
231 #[must_use]
233 pub const fn kind(&self) -> &MergedValueKind {
234 &self.kind
235 }
236
237 #[must_use]
239 pub const fn provenance(&self) -> &MergeProvenance {
240 &self.provenance
241 }
242
243 #[must_use]
245 pub const fn as_scalar(&self) -> Option<&MergedScalar> {
246 match &self.kind {
247 MergedValueKind::Scalar(value) => Some(value),
248 _ => None,
249 }
250 }
251
252 #[must_use]
254 pub fn as_mapping(&self) -> Option<&[MergedEntry]> {
255 match &self.kind {
256 MergedValueKind::Mapping(entries) => Some(entries),
257 _ => None,
258 }
259 }
260
261 #[must_use]
263 pub fn as_sequence(&self) -> Option<&[MergedValue]> {
264 match &self.kind {
265 MergedValueKind::Sequence(values) => Some(values),
266 _ => None,
267 }
268 }
269
270 #[must_use]
272 pub fn get(&self, key: &str) -> Option<&MergedValue> {
273 self.as_mapping()?
274 .iter()
275 .find(|entry| entry.key == key)
276 .map(|entry| &entry.value)
277 }
278
279 #[must_use]
281 pub fn is_sensitive(&self) -> bool {
282 match &self.kind {
283 MergedValueKind::Scalar(value) => value.sensitive,
284 MergedValueKind::Mapping(entries) => entries.iter().any(|entry| entry.value.is_sensitive()),
285 MergedValueKind::Sequence(values) => values.iter().any(Self::is_sensitive),
286 MergedValueKind::Tagged { value, .. } => value.is_sensitive(),
287 MergedValueKind::Null(_) | MergedValueKind::Alias(_) => false,
288 }
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct MergedProject {
295 root: MergedValue,
296 base_directory: PathBuf,
297 source_ids: Vec<SourceId>,
298}
299
300impl MergedProject {
301 #[must_use]
303 pub const fn root(&self) -> &MergedValue {
304 &self.root
305 }
306
307 #[must_use]
309 pub fn base_directory(&self) -> &Path {
310 &self.base_directory
311 }
312
313 #[must_use]
315 pub fn source_ids(&self) -> &[SourceId] {
316 &self.source_ids
317 }
318
319 #[must_use]
321 pub fn value(&self, path: &[&str]) -> Option<&MergedValue> {
322 path.iter().try_fold(&self.root, |value, key| value.get(key))
323 }
324}
325
326#[derive(Debug, Clone, PartialEq, Eq)]
328pub struct MergeResult {
329 project: Option<MergedProject>,
330 diagnostics: Vec<Diagnostic>,
331}
332
333impl MergeResult {
334 #[must_use]
336 pub const fn project(&self) -> Option<&MergedProject> {
337 self.project.as_ref()
338 }
339
340 #[must_use]
342 pub fn diagnostics(&self) -> &[Diagnostic] {
343 &self.diagnostics
344 }
345
346 #[must_use]
348 pub fn is_valid(&self) -> bool {
349 self.project.is_some()
350 && self
351 .diagnostics
352 .iter()
353 .all(|diagnostic| diagnostic.severity() != Severity::Error)
354 }
355}
356
357#[must_use]
362pub fn merge_project(project: &LoadedProject, interpolation: Option<&ProjectInterpolation>) -> MergeResult {
363 let mut diagnostics = project.diagnostics().to_vec();
364 if let Some(interpolation) = interpolation {
365 diagnostics.extend(interpolation.diagnostics().iter().cloned());
366 }
367
368 let mut root = None;
369 let mut source_ids = Vec::new();
370 for document in project.documents() {
371 let overlay = interpolation.and_then(|values| values.document(document.source_id()));
372 if interpolation.is_some() && overlay.is_none() {
373 diagnostics.push(
374 Diagnostic::new(
375 INTERPOLATION_PROJECT_MISMATCH,
376 Severity::Error,
377 "loaded document has no matching interpolation overlay",
378 )
379 .with_label(DiagnosticLabel::primary(
380 document.syntax().source_span(),
381 "missing per-file overlay",
382 )),
383 );
384 }
385
386 let Some(syntax_root) = document.syntax().merge_root() else {
387 continue;
388 };
389 if !matches!(syntax_root, MergeSyntaxValue::Mapping { .. }) {
390 diagnostics.push(
391 Diagnostic::new(
392 INVALID_DOCUMENT_ROOT,
393 Severity::Error,
394 "Compose merge inputs must have mapping roots",
395 )
396 .with_label(DiagnosticLabel::primary(
397 document.syntax().source_span(),
398 "document is not a mapping",
399 )),
400 );
401 continue;
402 }
403
404 let value = convert_value(syntax_root, overlay, &mut diagnostics);
405 source_ids.push(document.source_id());
406 root = Some(match root {
407 Some(current) => merge_value(current, value, &[], &mut diagnostics),
408 None => activate_tags(value, &[], &mut diagnostics),
409 });
410 }
411
412 if let Some(interpolation) = interpolation {
413 for overlay in interpolation.documents() {
414 if project.document(overlay.source_id()).is_none() {
415 diagnostics.push(Diagnostic::new(
416 INTERPOLATION_PROJECT_MISMATCH,
417 Severity::Error,
418 "interpolation overlay does not belong to the loaded project",
419 ));
420 }
421 }
422 }
423
424 MergeResult {
425 project: root.map(|root| MergedProject {
426 root,
427 base_directory: project.base_directory().to_path_buf(),
428 source_ids,
429 }),
430 diagnostics,
431 }
432}
433
434fn convert_value(
435 value: MergeSyntaxValue,
436 interpolation: Option<&DocumentInterpolation>,
437 diagnostics: &mut Vec<Diagnostic>,
438) -> MergedValue {
439 match value {
440 MergeSyntaxValue::Empty(span) => authored(MergedValueKind::Null(NullStyle::Empty), span),
441 MergeSyntaxValue::Scalar(value) if value.kind == MergeScalarKind::Null => {
442 authored(MergedValueKind::Null(NullStyle::Explicit), value.span)
443 }
444 MergeSyntaxValue::Scalar(value) => convert_scalar(value, interpolation),
445 MergeSyntaxValue::Mapping { entries, span } => {
446 let entries = entries
447 .into_iter()
448 .map(|entry| convert_entry(entry, interpolation, diagnostics))
449 .collect();
450 authored(MergedValueKind::Mapping(entries), span)
451 }
452 MergeSyntaxValue::Sequence { values, span } => {
453 let values = values
454 .into_iter()
455 .map(|value| convert_value(value, interpolation, diagnostics))
456 .collect();
457 authored(MergedValueKind::Sequence(values), span)
458 }
459 MergeSyntaxValue::Alias { name, span } => {
460 diagnostics.push(
461 Diagnostic::new(UNRESOLVED_ALIAS, Severity::Warning, "YAML alias could not be resolved")
462 .with_label(DiagnosticLabel::primary(span, "unresolved alias")),
463 );
464 authored(MergedValueKind::Alias(name), span)
465 }
466 MergeSyntaxValue::Tagged { tag, value, span } => {
467 let value = convert_value(*value, interpolation, diagnostics);
468 authored(
469 MergedValueKind::Tagged {
470 tag,
471 value: Box::new(value),
472 },
473 span,
474 )
475 }
476 }
477}
478
479fn convert_entry(
480 entry: MergeSyntaxEntry,
481 interpolation: Option<&DocumentInterpolation>,
482 diagnostics: &mut Vec<Diagnostic>,
483) -> MergedEntry {
484 MergedEntry {
485 key: entry.key.value,
486 key_sources: vec![entry.key.span],
487 key_sensitive: false,
488 syntax: EntrySyntax::Mapping,
489 value: convert_value(entry.value, interpolation, diagnostics),
490 }
491}
492
493fn convert_scalar(value: MergeSyntaxScalar, interpolation: Option<&DocumentInterpolation>) -> MergedValue {
494 let resolved = interpolation.and_then(|overlay| overlay.value(value.span));
495 let semantic = resolved.map_or_else(|| value.value.clone(), |result| result.resolved().to_owned());
496 let sensitive = resolved.is_some_and(crate::interpolation::InterpolationResult::is_sensitive);
497 let kind = match value.kind {
498 MergeScalarKind::String => MergedScalarKind::String,
499 MergeScalarKind::Boolean => MergedScalarKind::Boolean,
500 MergeScalarKind::Number => MergedScalarKind::Number,
501 MergeScalarKind::Null => unreachable!("null scalars are converted before convert_scalar"),
502 };
503 authored(
504 MergedValueKind::Scalar(MergedScalar {
505 raw: value.raw,
506 value: semantic,
507 kind,
508 sensitive,
509 }),
510 value.span,
511 )
512}
513
514fn authored(kind: MergedValueKind, span: SourceSpan) -> MergedValue {
515 MergedValue {
516 kind,
517 provenance: MergeProvenance {
518 operation: MergeOperation::Authored,
519 sources: vec![span],
520 },
521 }
522}
523
524fn activate_tags(mut value: MergedValue, path: &[String], diagnostics: &mut Vec<Diagnostic>) -> MergedValue {
525 value = match value.kind {
526 MergedValueKind::Tagged { tag, value: inner } if tag == "!reset" => reset_value(None, &inner, value.provenance),
527 MergedValueKind::Tagged { tag, value: inner } if tag == "!override" => {
528 override_value(None, *inner, value.provenance)
529 }
530 kind => MergedValue {
531 kind,
532 provenance: value.provenance,
533 },
534 };
535
536 match &mut value.kind {
537 MergedValueKind::Mapping(entries) => {
538 for entry in entries {
539 let mut child_path = path.to_vec();
540 child_path.push(entry.key.clone());
541 entry.value = activate_tags(entry.value.clone(), &child_path, diagnostics);
542 }
543 }
544 MergedValueKind::Sequence(values) => {
545 for item in values {
546 *item = activate_tags(item.clone(), path, diagnostics);
547 }
548 }
549 MergedValueKind::Tagged { value: inner, .. } => {
550 **inner = activate_tags((**inner).clone(), path, diagnostics);
551 }
552 MergedValueKind::Null(_) | MergedValueKind::Scalar(_) | MergedValueKind::Alias(_) => {}
553 }
554 let _ = diagnostics;
555 value
556}
557
558fn merge_value(
559 base: MergedValue,
560 incoming: MergedValue,
561 path: &[String],
562 diagnostics: &mut Vec<Diagnostic>,
563) -> MergedValue {
564 if let MergedValueKind::Tagged { tag, value } = incoming.kind {
565 if tag == "!reset" {
566 return reset_value(Some(&base), &value, incoming.provenance);
567 }
568 if tag == "!override" {
569 return override_value(Some(&base), *value, incoming.provenance);
570 }
571 return replace_value(
572 base,
573 MergedValue {
574 kind: MergedValueKind::Tagged { tag, value },
575 provenance: incoming.provenance,
576 },
577 MergeOperation::Replaced,
578 );
579 }
580
581 if is_shell_command(path) {
582 return replace_value(base, incoming, MergeOperation::Replaced);
583 }
584
585 if is_keyed_mapping(path) {
586 if let (Some(base), Some(incoming)) = (normalize_keyed(base.clone()), normalize_keyed(incoming.clone())) {
587 return merge_mappings(base, incoming, path, diagnostics);
588 }
589 }
590
591 match (&base.kind, &incoming.kind) {
592 (MergedValueKind::Mapping(_), MergedValueKind::Mapping(_)) => merge_mappings(base, incoming, path, diagnostics),
593 (MergedValueKind::Sequence(_), MergedValueKind::Sequence(_)) if unique_field(path).is_some() => {
594 merge_unique_sequences(base, incoming, path, diagnostics)
595 }
596 (MergedValueKind::Sequence(_), MergedValueKind::Sequence(_)) => append_sequences(base, incoming),
597 _ => replace_value(
598 base,
599 activate_tags(incoming, path, diagnostics),
600 MergeOperation::Replaced,
601 ),
602 }
603}
604
605fn merge_mappings(
606 base: MergedValue,
607 incoming: MergedValue,
608 path: &[String],
609 diagnostics: &mut Vec<Diagnostic>,
610) -> MergedValue {
611 let MergedValueKind::Mapping(mut base_entries) = base.kind else {
612 return base;
613 };
614 let MergedValueKind::Mapping(incoming_entries) = incoming.kind else {
615 return MergedValue {
616 kind: MergedValueKind::Mapping(base_entries),
617 provenance: base.provenance,
618 };
619 };
620
621 for incoming_entry in incoming_entries {
622 if let Some(index) = base_entries.iter().position(|entry| entry.key == incoming_entry.key) {
623 let mut child_path = path.to_vec();
624 child_path.push(incoming_entry.key.clone());
625 let existing = &mut base_entries[index];
626 existing.value = merge_value(existing.value.clone(), incoming_entry.value, &child_path, diagnostics);
627 extend_sources(&mut existing.key_sources, &incoming_entry.key_sources);
628 existing.key_sensitive |= incoming_entry.key_sensitive;
629 existing.syntax = incoming_entry.syntax;
630 } else {
631 let mut incoming_entry = incoming_entry;
632 incoming_entry.value = activate_tags(incoming_entry.value, path, diagnostics);
633 mark_added(&mut incoming_entry.value);
634 base_entries.push(incoming_entry);
635 }
636 }
637
638 MergedValue {
639 kind: MergedValueKind::Mapping(base_entries),
640 provenance: combined_provenance(base.provenance, &incoming.provenance, MergeOperation::Merged),
641 }
642}
643
644fn append_sequences(base: MergedValue, incoming: MergedValue) -> MergedValue {
645 let MergedValueKind::Sequence(mut base_values) = base.kind else {
646 return base;
647 };
648 let MergedValueKind::Sequence(mut incoming_values) = incoming.kind else {
649 return MergedValue {
650 kind: MergedValueKind::Sequence(base_values),
651 provenance: base.provenance,
652 };
653 };
654 for value in &mut incoming_values {
655 mark_added(value);
656 }
657 base_values.append(&mut incoming_values);
658 MergedValue {
659 kind: MergedValueKind::Sequence(base_values),
660 provenance: combined_provenance(base.provenance, &incoming.provenance, MergeOperation::Appended),
661 }
662}
663
664fn merge_unique_sequences(
665 base: MergedValue,
666 incoming: MergedValue,
667 path: &[String],
668 diagnostics: &mut Vec<Diagnostic>,
669) -> MergedValue {
670 let Some(field) = unique_field(path) else {
671 return append_sequences(base, incoming);
672 };
673 let MergedValueKind::Sequence(mut base_values) = base.kind else {
674 return base;
675 };
676 let MergedValueKind::Sequence(incoming_values) = incoming.kind else {
677 return MergedValue {
678 kind: MergedValueKind::Sequence(base_values),
679 provenance: base.provenance,
680 };
681 };
682
683 for mut incoming_value in incoming_values {
684 let key = unique_key(&incoming_value, field);
685 let existing = key.as_ref().and_then(|key| {
686 base_values
687 .iter()
688 .position(|value| unique_key(value, field).as_ref() == Some(key))
689 });
690 if let Some(index) = existing {
691 base_values[index] = merge_value(base_values[index].clone(), incoming_value, path, diagnostics);
692 } else {
693 incoming_value = activate_tags(incoming_value, path, diagnostics);
694 mark_added(&mut incoming_value);
695 base_values.push(incoming_value);
696 }
697 }
698
699 MergedValue {
700 kind: MergedValueKind::Sequence(base_values),
701 provenance: combined_provenance(base.provenance, &incoming.provenance, MergeOperation::Merged),
702 }
703}
704
705fn replace_value(base: MergedValue, mut incoming: MergedValue, operation: MergeOperation) -> MergedValue {
706 incoming.provenance = combined_provenance(base.provenance, &incoming.provenance, operation);
707 incoming
708}
709
710fn reset_value(base: Option<&MergedValue>, tagged: &MergedValue, tag: MergeProvenance) -> MergedValue {
711 let kind = match &tagged.kind {
712 MergedValueKind::Mapping(_) => MergedValueKind::Mapping(Vec::new()),
713 MergedValueKind::Sequence(_) => MergedValueKind::Sequence(Vec::new()),
714 MergedValueKind::Null(_) => match base.map(|value| &value.kind) {
715 Some(MergedValueKind::Mapping(_)) => MergedValueKind::Mapping(Vec::new()),
716 Some(MergedValueKind::Sequence(_)) => MergedValueKind::Sequence(Vec::new()),
717 _ => MergedValueKind::Null(NullStyle::Empty),
718 },
719 _ => MergedValueKind::Null(NullStyle::Empty),
720 };
721 let provenance = match base {
722 Some(base) => combined_provenance(base.provenance.clone(), &tag, MergeOperation::Reset),
723 None => MergeProvenance {
724 operation: MergeOperation::Reset,
725 sources: tag.sources,
726 },
727 };
728 MergedValue { kind, provenance }
729}
730
731fn override_value(base: Option<&MergedValue>, mut tagged: MergedValue, tag: MergeProvenance) -> MergedValue {
732 tagged.provenance = match base {
733 Some(base) => {
734 let prior = combined_provenance(base.provenance.clone(), &tag, MergeOperation::Override);
735 combined_provenance(prior, &tagged.provenance, MergeOperation::Override)
736 }
737 None => combined_provenance(tag, &tagged.provenance, MergeOperation::Override),
738 };
739 tagged
740}
741
742fn combined_provenance(
743 mut base: MergeProvenance,
744 incoming: &MergeProvenance,
745 operation: MergeOperation,
746) -> MergeProvenance {
747 extend_sources(&mut base.sources, &incoming.sources);
748 base.operation = operation;
749 base
750}
751
752fn extend_sources(target: &mut Vec<SourceSpan>, incoming: &[SourceSpan]) {
753 for span in incoming {
754 if !target.contains(span) {
755 target.push(*span);
756 }
757 }
758}
759
760fn mark_added(value: &mut MergedValue) {
761 if value.provenance.operation == MergeOperation::Authored {
762 value.provenance.operation = MergeOperation::Added;
763 }
764}
765
766fn is_shell_command(path: &[String]) -> bool {
767 matches!(path, [services, _, field] if services == "services" && (field == "command" || field == "entrypoint"))
768 || matches!(path, [services, _, healthcheck, test] if services == "services" && healthcheck == "healthcheck" && test == "test")
769}
770
771fn is_keyed_mapping(path: &[String]) -> bool {
772 matches!(path, [services, _, field] if services == "services" && (field == "environment" || field == "labels"))
773}
774
775fn normalize_keyed(value: MergedValue) -> Option<MergedValue> {
776 match value.kind {
777 MergedValueKind::Mapping(_) => Some(value),
778 MergedValueKind::Sequence(values) => {
779 let mut entries = Vec::with_capacity(values.len());
780 for value in values {
781 let scalar = value.as_scalar()?;
782 let (key, entry_value, syntax) = if let Some((key, entry_value)) = scalar.value.split_once('=') {
783 let scalar_value = MergedValue {
784 kind: MergedValueKind::Scalar(MergedScalar {
785 raw: entry_value.to_owned(),
786 value: entry_value.to_owned(),
787 kind: MergedScalarKind::String,
788 sensitive: scalar.sensitive,
789 }),
790 provenance: value.provenance.clone(),
791 };
792 (key.to_owned(), scalar_value, EntrySyntax::ListKeyValue)
793 } else {
794 let null = MergedValue {
795 kind: MergedValueKind::Null(NullStyle::Empty),
796 provenance: value.provenance.clone(),
797 };
798 (scalar.value.clone(), null, EntrySyntax::ListKeyOnly)
799 };
800 let key_source = value.provenance.effective_source()?;
801 entries.push(MergedEntry {
802 key,
803 key_sources: vec![key_source],
804 key_sensitive: scalar.sensitive,
805 syntax,
806 value: entry_value,
807 });
808 }
809 Some(MergedValue {
810 kind: MergedValueKind::Mapping(entries),
811 provenance: value.provenance,
812 })
813 }
814 _ => None,
815 }
816}
817
818#[derive(Debug, Clone, Copy, PartialEq, Eq)]
819enum UniqueField {
820 Volume,
821 Device,
822 Config,
823 Secret,
824 Port,
825}
826
827fn unique_field(path: &[String]) -> Option<UniqueField> {
828 let [services, _, field] = path else {
829 return None;
830 };
831 if services != "services" {
832 return None;
833 }
834 match field.as_str() {
835 "volumes" => Some(UniqueField::Volume),
836 "devices" => Some(UniqueField::Device),
837 "configs" => Some(UniqueField::Config),
838 "secrets" => Some(UniqueField::Secret),
839 "ports" => Some(UniqueField::Port),
840 _ => None,
841 }
842}
843
844#[derive(Debug, Clone, PartialEq, Eq)]
845enum UniqueKey {
846 Target(String),
847 Port {
848 ip: String,
849 target: String,
850 published: String,
851 protocol: String,
852 },
853}
854
855fn unique_key(value: &MergedValue, field: UniqueField) -> Option<UniqueKey> {
856 match field {
857 UniqueField::Volume | UniqueField::Device => target_key(value, true).map(UniqueKey::Target),
858 UniqueField::Config | UniqueField::Secret => target_key(value, false).map(UniqueKey::Target),
859 UniqueField::Port => port_key(value),
860 }
861}
862
863fn target_key(value: &MergedValue, colon_syntax: bool) -> Option<String> {
864 if let Some(scalar) = value.as_scalar() {
865 if colon_syntax {
866 let span = value.provenance.effective_source()?;
867 return ShortVolumeMount::new(Located::new(scalar.value.clone(), span))
868 .target()
869 .map(str::to_owned);
870 }
871 return Some(scalar.value.clone());
872 }
873 mapping_scalar(value, "target")
874 .or_else(|| (!colon_syntax).then(|| mapping_scalar(value, "source")).flatten())
875 .map(str::to_owned)
876}
877
878fn port_key(value: &MergedValue) -> Option<UniqueKey> {
879 if let Some(scalar) = value.as_scalar() {
880 let span = value.provenance.effective_source()?;
881 let port = ShortPort::parse(Located::new(scalar.value.clone(), span));
882 return Some(UniqueKey::Port {
883 ip: port.host_ip().unwrap_or_default().to_owned(),
884 target: port.target().to_owned(),
885 published: port.published().unwrap_or_default().to_owned(),
886 protocol: port.protocol().unwrap_or("tcp").to_owned(),
887 });
888 }
889 Some(UniqueKey::Port {
890 ip: mapping_scalar(value, "host_ip").unwrap_or_default().to_owned(),
891 target: mapping_scalar(value, "target")?.to_owned(),
892 published: mapping_scalar(value, "published").unwrap_or_default().to_owned(),
893 protocol: mapping_scalar(value, "protocol").unwrap_or("tcp").to_owned(),
894 })
895}
896
897fn mapping_scalar<'a>(value: &'a MergedValue, key: &str) -> Option<&'a str> {
898 value.get(key)?.as_scalar().map(MergedScalar::value)
899}