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, 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
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    plain: bool,
86    presentation: ScalarPresentation,
87    strict_yaml_string: bool,
88    sensitive: bool,
89}
90
91impl MergedScalar {
92    /// Returns the exact authored scalar spelling.
93    #[must_use]
94    pub fn raw(&self) -> &str {
95        &self.raw
96    }
97
98    /// Returns the semantic value after optional interpolation.
99    #[must_use]
100    pub fn value(&self) -> &str {
101        &self.value
102    }
103
104    /// Returns the scalar category.
105    #[must_use]
106    pub const fn kind(&self) -> MergedScalarKind {
107        self.kind
108    }
109
110    /// Returns whether the scalar was a YAML string rather than a timestamp or regex style.
111    #[must_use]
112    pub const fn is_strict_yaml_string(&self) -> bool {
113        self.strict_yaml_string
114    }
115
116    /// Returns the authored presentation retained before optional interpolation.
117    #[must_use]
118    pub const fn presentation(&self) -> ScalarPresentation {
119        self.presentation
120    }
121
122    /// Reports whether the scalar used YAML plain style before interpolation.
123    #[must_use]
124    pub(crate) const fn is_plain_style(&self) -> bool {
125        self.plain
126    }
127
128    /// Reports whether interpolation inserted sensitive content.
129    #[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/// Whether a null-like value was explicit or represented by an empty YAML mapping value.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152pub enum NullStyle {
153    /// A key was authored without a value.
154    Empty,
155    /// A YAML null scalar such as `null` or `~` was authored.
156    Explicit,
157}
158
159/// The authored form that contributed one semantic mapping entry.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
161pub enum EntrySyntax {
162    /// A normal YAML mapping entry.
163    Mapping,
164    /// A `KEY=VALUE` scalar from a Compose list form.
165    ListKeyValue,
166    /// A key-only scalar from a Compose list form.
167    ListKeyOnly,
168}
169
170/// One mapping entry in a merged semantic view.
171#[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    /// Returns the semantic scalar key, or retained source spelling for an unsupported key.
199    ///
200    /// Check [`Self::is_strict_yaml_string`] before interpreting this as a string key. Unsupported
201    /// keys and authored string keys with identical spelling remain separate entries.
202    #[must_use]
203    pub fn key(&self) -> &str {
204        &self.key
205    }
206
207    /// Returns every authored key location that participated in this entry.
208    #[must_use]
209    pub fn key_sources(&self) -> &[SourceSpan] {
210        &self.key_sources
211    }
212
213    /// Reports whether a sensitive interpolated scalar produced this semantic key.
214    #[must_use]
215    pub const fn is_key_sensitive(&self) -> bool {
216        self.key_sensitive
217    }
218
219    /// Reports whether the effective key was authored as a strict YAML string.
220    #[must_use]
221    pub const fn is_strict_yaml_string(&self) -> bool {
222        self.strict_yaml_string
223    }
224
225    /// Returns the most recent authored syntax form for this entry.
226    #[must_use]
227    pub const fn syntax(&self) -> EntrySyntax {
228        self.syntax
229    }
230
231    /// Returns the complete list scalar when a keyed mapping entry came from list syntax.
232    #[must_use]
233    pub const fn raw_list_item(&self) -> Option<&MergedScalar> {
234        self.raw_list_item.as_ref()
235    }
236
237    /// Returns the complete decoded list scalar with its latest authored item span and taint.
238    ///
239    /// Use [`Self::raw_list_item`] for the original presentation, rather than the synthetic
240    /// normalized value's presentation. This is the current semantic item: an interpolation
241    /// overlay may already have resolved it. Authored inspection requires merging without an
242    /// interpolation overlay (`merge_project(..., None)`). No raw YAML decoding is performed here.
243    #[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    /// Returns the merged entry value.
255    #[must_use]
256    pub const fn value(&self) -> &MergedValue {
257        &self.value
258    }
259}
260
261/// A ComposeLens-owned semantic value that does not expose the YAML parser dependency.
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub enum MergedValueKind {
264    /// An empty or explicit null value.
265    Null(NullStyle),
266    /// A scalar with authored and optionally interpolated forms.
267    Scalar(MergedScalar),
268    /// An ordered semantic mapping.
269    Mapping(Vec<MergedEntry>),
270    /// An ordered sequence.
271    Sequence(Vec<MergedValue>),
272    /// An unresolved YAML alias retained for diagnostics and recovery.
273    Alias(String),
274    /// A non-Compose YAML tag retained without interpretation.
275    Tagged {
276        /// The authored tag name.
277        tag: String,
278        /// The tagged value.
279        value: Box<MergedValue>,
280    },
281}
282
283/// One value in the merged semantic project.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct MergedValue {
286    kind: MergedValueKind,
287    provenance: MergeProvenance,
288}
289
290impl MergedValue {
291    /// Returns the semantic value kind.
292    #[must_use]
293    pub const fn kind(&self) -> &MergedValueKind {
294        &self.kind
295    }
296
297    /// Returns merge provenance.
298    #[must_use]
299    pub const fn provenance(&self) -> &MergeProvenance {
300        &self.provenance
301    }
302
303    /// Returns a scalar value when this node is a scalar.
304    #[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    /// Returns mapping entries when this node is a mapping.
313    #[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    /// Returns sequence items when this node is a sequence.
322    #[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    /// Finds a semantic mapping value by key.
331    #[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    /// Reports whether this node or one of its descendants contains interpolated sensitive data.
340    #[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/// A merged Compose project and its retained project origin.
355#[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    /// Returns the merged root mapping.
364    #[must_use]
365    pub const fn root(&self) -> &MergedValue {
366        &self.root
367    }
368
369    /// Returns the project directory inherited from the first loaded document.
370    #[must_use]
371    pub fn base_directory(&self) -> &Path {
372        &self.base_directory
373    }
374
375    /// Returns source documents in merge order.
376    #[must_use]
377    pub fn source_ids(&self) -> &[SourceId] {
378        &self.source_ids
379    }
380
381    /// Traverses mapping keys from the root.
382    #[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/// A recoverable multi-file merge result.
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct MergeResult {
391    project: Option<MergedProject>,
392    diagnostics: Vec<Diagnostic>,
393}
394
395impl MergeResult {
396    /// Returns the merged project when at least one document has a mapping root.
397    #[must_use]
398    pub const fn project(&self) -> Option<&MergedProject> {
399        self.project.as_ref()
400    }
401
402    /// Returns upstream and merge diagnostics in processing order.
403    #[must_use]
404    pub fn diagnostics(&self) -> &[Diagnostic] {
405        &self.diagnostics
406    }
407
408    /// Reports whether processing produced a project and no error diagnostics.
409    #[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/// Merges loaded documents in order, optionally applying matching per-file interpolation overlays.
420///
421/// Passing `None` deliberately merges authored values without interpolation. Passing an overlay
422/// applies each document's substitutions before its values participate in the merge.
423#[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}