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