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