Skip to main content

compose_lens/merge/
mod.rs

1//! Provenance-preserving Compose multi-file merge behavior.
2
3use 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
12/// A loaded document is missing its matching per-file interpolation overlay.
13pub const INTERPOLATION_PROJECT_MISMATCH: DiagnosticCode =
14    DiagnosticCode::new("compose.merge.interpolation-project-mismatch");
15
16/// A document root cannot participate in a Compose project merge.
17pub const INVALID_DOCUMENT_ROOT: DiagnosticCode = DiagnosticCode::new("compose.merge.invalid-document-root");
18
19/// A YAML alias could not be resolved within its source document.
20pub const UNRESOLVED_ALIAS: DiagnosticCode = DiagnosticCode::new("compose.merge.unresolved-alias");
21
22/// How a merged value was produced.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum MergeOperation {
25    /// The value comes directly from the first document that defined it.
26    Authored,
27    /// A later document added a previously absent field or unique item.
28    Added,
29    /// A later scalar or incompatible value form replaced the earlier value.
30    Replaced,
31    /// Mappings or field-specific unique entries were combined.
32    Merged,
33    /// An ordinary sequence from a later document was appended.
34    Appended,
35    /// Compose's `!reset` tag cleared the earlier value.
36    Reset,
37    /// Compose's `!override` tag replaced the earlier value without normal merge behavior.
38    Override,
39}
40
41/// Source evidence and the last operation applied to one merged value.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct MergeProvenance {
44    operation: MergeOperation,
45    sources: Vec<SourceSpan>,
46}
47
48impl MergeProvenance {
49    /// Returns the operation that produced the current value.
50    #[must_use]
51    pub const fn operation(&self) -> MergeOperation {
52        self.operation
53    }
54
55    /// Returns contributing source spans in processing order.
56    #[must_use]
57    pub fn sources(&self) -> &[SourceSpan] {
58        &self.sources
59    }
60
61    /// Returns the most recent contributing span.
62    #[must_use]
63    pub fn effective_source(&self) -> Option<SourceSpan> {
64        self.sources.last().copied()
65    }
66}
67
68/// The semantic scalar category retained by the merge view.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub enum MergedScalarKind {
71    /// A YAML string, including quoted numeric or boolean spelling.
72    String,
73    /// A YAML boolean.
74    Boolean,
75    /// A YAML integer or floating-point value retained as text.
76    Number,
77}
78
79/// One scalar after optional per-file interpolation.
80#[derive(Clone, PartialEq, Eq)]
81pub struct MergedScalar {
82    raw: String,
83    value: String,
84    kind: MergedScalarKind,
85    sensitive: bool,
86}
87
88impl MergedScalar {
89    /// Returns the exact authored scalar spelling.
90    #[must_use]
91    pub fn raw(&self) -> &str {
92        &self.raw
93    }
94
95    /// Returns the semantic value after optional interpolation.
96    #[must_use]
97    pub fn value(&self) -> &str {
98        &self.value
99    }
100
101    /// Returns the scalar category.
102    #[must_use]
103    pub const fn kind(&self) -> MergedScalarKind {
104        self.kind
105    }
106
107    /// Reports whether interpolation inserted sensitive content.
108    #[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/// Whether a null-like value was explicit or represented by an empty YAML mapping value.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
128pub enum NullStyle {
129    /// A key was authored without a value.
130    Empty,
131    /// A YAML null scalar such as `null` or `~` was authored.
132    Explicit,
133}
134
135/// The authored form that contributed one semantic mapping entry.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
137pub enum EntrySyntax {
138    /// A normal YAML mapping entry.
139    Mapping,
140    /// A `KEY=VALUE` scalar from a Compose list form.
141    ListKeyValue,
142    /// A key-only scalar from a Compose list form.
143    ListKeyOnly,
144}
145
146/// One mapping entry in a merged semantic view.
147#[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    /// Returns the semantic mapping key.
171    #[must_use]
172    pub fn key(&self) -> &str {
173        &self.key
174    }
175
176    /// Returns every authored key location that participated in this entry.
177    #[must_use]
178    pub fn key_sources(&self) -> &[SourceSpan] {
179        &self.key_sources
180    }
181
182    /// Reports whether a sensitive interpolated scalar produced this semantic key.
183    #[must_use]
184    pub const fn is_key_sensitive(&self) -> bool {
185        self.key_sensitive
186    }
187
188    /// Returns the most recent authored syntax form for this entry.
189    #[must_use]
190    pub const fn syntax(&self) -> EntrySyntax {
191        self.syntax
192    }
193
194    /// Returns the merged entry value.
195    #[must_use]
196    pub const fn value(&self) -> &MergedValue {
197        &self.value
198    }
199}
200
201/// A ComposeLens-owned semantic value that does not expose the YAML parser dependency.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum MergedValueKind {
204    /// An empty or explicit null value.
205    Null(NullStyle),
206    /// A scalar with authored and optionally interpolated forms.
207    Scalar(MergedScalar),
208    /// An ordered semantic mapping.
209    Mapping(Vec<MergedEntry>),
210    /// An ordered sequence.
211    Sequence(Vec<MergedValue>),
212    /// An unresolved YAML alias retained for diagnostics and recovery.
213    Alias(String),
214    /// A non-Compose YAML tag retained without interpretation.
215    Tagged {
216        /// The authored tag name.
217        tag: String,
218        /// The tagged value.
219        value: Box<MergedValue>,
220    },
221}
222
223/// One value in the merged semantic project.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct MergedValue {
226    kind: MergedValueKind,
227    provenance: MergeProvenance,
228}
229
230impl MergedValue {
231    /// Returns the semantic value kind.
232    #[must_use]
233    pub const fn kind(&self) -> &MergedValueKind {
234        &self.kind
235    }
236
237    /// Returns merge provenance.
238    #[must_use]
239    pub const fn provenance(&self) -> &MergeProvenance {
240        &self.provenance
241    }
242
243    /// Returns a scalar value when this node is a scalar.
244    #[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    /// Returns mapping entries when this node is a mapping.
253    #[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    /// Returns sequence items when this node is a sequence.
262    #[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    /// Finds a semantic mapping value by key.
271    #[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    /// Reports whether this node or one of its descendants contains interpolated sensitive data.
280    #[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/// A merged Compose project and its retained project origin.
293#[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    /// Returns the merged root mapping.
302    #[must_use]
303    pub const fn root(&self) -> &MergedValue {
304        &self.root
305    }
306
307    /// Returns the project directory inherited from the first loaded document.
308    #[must_use]
309    pub fn base_directory(&self) -> &Path {
310        &self.base_directory
311    }
312
313    /// Returns source documents in merge order.
314    #[must_use]
315    pub fn source_ids(&self) -> &[SourceId] {
316        &self.source_ids
317    }
318
319    /// Traverses mapping keys from the root.
320    #[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/// A recoverable multi-file merge result.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub struct MergeResult {
329    project: Option<MergedProject>,
330    diagnostics: Vec<Diagnostic>,
331}
332
333impl MergeResult {
334    /// Returns the merged project when at least one document has a mapping root.
335    #[must_use]
336    pub const fn project(&self) -> Option<&MergedProject> {
337        self.project.as_ref()
338    }
339
340    /// Returns upstream and merge diagnostics in processing order.
341    #[must_use]
342    pub fn diagnostics(&self) -> &[Diagnostic] {
343        &self.diagnostics
344    }
345
346    /// Reports whether processing produced a project and no error diagnostics.
347    #[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/// Merges loaded documents in order, optionally applying matching per-file interpolation overlays.
358///
359/// Passing `None` deliberately merges authored values without interpolation. Passing an overlay
360/// applies each document's substitutions before its values participate in the merge.
361#[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}