Skip to main content

compose_lens/
project.rs

1//! Source-aware native values from a merged and optionally profile-selected Compose project.
2
3use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::merge::{
5    EntrySyntax, MergeProvenance, MergedEntry, MergedProject, MergedScalarKind, MergedValue, MergedValueKind,
6};
7use crate::model::{
8    ANNOTATIONS_DUPLICATE_NAME, ANNOTATIONS_EMPTY_NAME, ANNOTATIONS_EXPECTED_STRING, ANNOTATIONS_KEY_ONLY, BindOptions,
9    BooleanValue, CAP_ADD_DUPLICATE_ITEM, CAP_DROP_DUPLICATE_ITEM, CapabilityAddItem, CapabilityDropItem, Command,
10    ComposeScalar, ConfigDefinition, DEVICE_EXPECTED_FORM, DEVICE_EXPECTED_STRING, DNS_EXPECTED_FORM,
11    DNS_EXPECTED_STRING, DNS_OPT_DUPLICATE_ITEM, DNS_OPT_EXPECTED_SEQUENCE, DNS_OPT_EXPECTED_STRING,
12    DNS_SEARCH_DUPLICATE_ITEM, DNS_SEARCH_EXPECTED_FORM, DNS_SEARCH_EXPECTED_STRING, DependencyCondition,
13    EXPOSE_DUPLICATE_ITEM, EXPOSE_EXPECTED_SCALAR, EXPOSE_EXPECTED_SEQUENCE, EXPOSE_INVALID_ITEM,
14    EXPOSE_PROVIDER_DEPENDENT, Entrypoint, EnvironmentFileFormat, EnvironmentFileFormatKind, ExposeItemKind,
15    ExposeScalarKind, HealthcheckDuration, HealthcheckRetries, HealthcheckTest, HealthcheckTestKind, HostAddress,
16    Hostname, HostnameKind, ImageReference, Ipam, IpamConfig, KeyValueEntry, Labels, LimitValue, Located, LongPort,
17    LongVolumeMount, MEM_LIMIT_AMBIGUOUS_ZERO, MEM_LIMIT_EXPECTED_VALUE, MEM_LIMIT_PROVIDER_DEPENDENT_STRING,
18    MEM_LIMIT_SCHEMA_NUMBER, MemLimit, MemLimitKind, MemLimitScalarKind, MountType, NetworkDefinition,
19    PIDS_LIMIT_AMBIGUOUS_ZERO, PidsLimit, PidsLimitKind, Port, PullPolicy, RestartPolicy,
20    SECURITY_OPT_APPARMOR_CONFLICT, SECURITY_OPT_APPARMOR_NEAR_MISS, SECURITY_OPT_EMPTY_ITEM,
21    SECURITY_OPT_EXPECTED_SEQUENCE, SECURITY_OPT_EXPECTED_STRING, SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT,
22    SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS, SECURITY_OPT_SECCOMP_CONFLICT, SECURITY_OPT_SECCOMP_NEAR_MISS,
23    SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT, SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS,
24    SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT, SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS,
25    SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT, SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS,
26    SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT, SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS,
27    SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT, SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS, SHM_SIZE_AMBIGUOUS_ZERO,
28    SHM_SIZE_EXPECTED_VALUE, SHM_SIZE_PROVIDER_DEPENDENT_NUMBER, SHM_SIZE_PROVIDER_DEPENDENT_STRING,
29    SYSCTLS_DUPLICATE_ITEM, SYSCTLS_EMPTY_KEY, SYSCTLS_EXPECTED_FORM, SYSCTLS_EXPECTED_SCALAR, SYSCTLS_EXPECTED_STRING,
30    SecretDefinition, SecurityOptionCandidateCounts, SecurityOptionKind, SelinuxRelabel, ServiceNetwork,
31    ServiceNetworks, ShmSize, ShmSizeKind, ShmSizeScalarKind, ShortDevice, ShortExtraHost, ShortPort, ShortVolumeMount,
32    StopGracePeriod, TMPFS_EXPECTED_FORM, TMPFS_EXPECTED_STRING, TMPFS_PROVIDER_DEPENDENT, TmpfsItem, TmpfsItemKind,
33    ULIMIT_INVALID_NAME, ULIMIT_INVALID_VALUE, ULIMIT_MISSING_RANGE_MEMBER, UserNamespaceMode, UserSpec,
34    VolumeDefinition, VolumeMount, classify_expose_item, classify_security_option, security_path_option_diagnostic,
35    valid_ulimit_name,
36};
37use crate::profiles::ProfileSelection;
38use crate::resolution::{SELECTION_PROJECT_MISMATCH, service_in_scope};
39use crate::source::{SourceId, SourceSpan};
40use std::collections::{BTreeMap, BTreeSet};
41use std::fmt;
42use std::path::{Path, PathBuf};
43
44/// A value in the merged project has an unexpected mapping, sequence, scalar, or null form.
45pub const PROJECT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.project.expected-form");
46
47/// A required field is absent from a merged native value.
48pub const PROJECT_MISSING_FIELD: DiagnosticCode = DiagnosticCode::new("compose.project.missing-field");
49
50/// A scalar cannot be represented by the requested native value type.
51pub const PROJECT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.project.invalid-value");
52
53/// A typed value together with every source span that contributed to it during merging.
54#[derive(Clone, PartialEq, Eq)]
55pub struct ProjectValue<T> {
56    value: T,
57    provenance: MergeProvenance,
58    sensitive: bool,
59}
60
61impl<T: fmt::Debug> fmt::Debug for ProjectValue<T> {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        let mut debug = formatter.debug_struct("ProjectValue");
64        if self.sensitive {
65            debug.field("value", &"<redacted>");
66        } else {
67            debug.field("value", &self.value);
68        }
69        debug
70            .field("provenance", &self.provenance)
71            .field("sensitive", &self.sensitive)
72            .finish()
73    }
74}
75
76impl<T> ProjectValue<T> {
77    fn new(value: T, source: &MergedValue) -> Self {
78        Self {
79            value,
80            provenance: source.provenance().clone(),
81            sensitive: source.is_sensitive(),
82        }
83    }
84
85    /// Returns the typed effective value.
86    #[must_use]
87    pub const fn value(&self) -> &T {
88        &self.value
89    }
90
91    /// Returns the merge operation and contributing spans in processing order.
92    #[must_use]
93    pub const fn provenance(&self) -> &MergeProvenance {
94        &self.provenance
95    }
96
97    /// Returns the most recent source contributing to this value.
98    #[must_use]
99    pub fn effective_source(&self) -> Option<SourceSpan> {
100        self.provenance.effective_source()
101    }
102
103    /// Reports whether this value contains sensitive interpolation output.
104    #[must_use]
105    pub const fn is_sensitive(&self) -> bool {
106        self.sensitive
107    }
108
109    /// Removes the provenance wrapper and returns the typed value.
110    #[must_use]
111    pub fn into_value(self) -> T {
112        self.value
113    }
114}
115
116/// A merged mapping key and every location at which that key was authored.
117#[derive(Clone, PartialEq, Eq)]
118pub struct ProjectKey {
119    value: String,
120    sources: Vec<SourceSpan>,
121    sensitive: bool,
122}
123
124impl fmt::Debug for ProjectKey {
125    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126        formatter
127            .debug_struct("ProjectKey")
128            .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
129            .field("sources", &self.sources)
130            .field("sensitive", &self.sensitive)
131            .finish()
132    }
133}
134
135impl ProjectKey {
136    fn from_entry(entry: &MergedEntry) -> Self {
137        Self {
138            value: entry.key().to_owned(),
139            sources: entry.key_sources().to_vec(),
140            sensitive: entry.is_key_sensitive(),
141        }
142    }
143
144    fn from_value(value: String, source: &MergedValue) -> Self {
145        Self {
146            value,
147            sources: source.provenance().sources().to_vec(),
148            sensitive: source.is_sensitive(),
149        }
150    }
151
152    /// Returns the semantic key text.
153    #[must_use]
154    pub fn value(&self) -> &str {
155        &self.value
156    }
157
158    /// Returns authored key locations in merge order.
159    #[must_use]
160    pub fn sources(&self) -> &[SourceSpan] {
161        &self.sources
162    }
163
164    /// Returns the effective key location.
165    #[must_use]
166    pub fn effective_source(&self) -> Option<SourceSpan> {
167        self.sources.last().copied()
168    }
169
170    /// Reports whether interpolation inserted sensitive content into this semantic key.
171    #[must_use]
172    pub const fn is_sensitive(&self) -> bool {
173        self.sensitive
174    }
175}
176
177/// One effective service dependency with source-aware long-form options.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct ProjectServiceDependency {
180    service: ProjectKey,
181    condition: Option<ProjectValue<DependencyCondition>>,
182    restart: Option<ProjectValue<BooleanValue>>,
183    required: Option<ProjectValue<BooleanValue>>,
184    unmodeled_fields: Vec<ProjectFieldReference>,
185}
186
187impl ProjectServiceDependency {
188    /// Returns the referenced service name and all contributing name locations.
189    #[must_use]
190    pub const fn service(&self) -> &ProjectKey {
191        &self.service
192    }
193
194    /// Returns the explicitly authored readiness condition.
195    #[must_use]
196    pub const fn condition(&self) -> Option<&ProjectValue<DependencyCondition>> {
197        self.condition.as_ref()
198    }
199
200    /// Returns whether Compose-controlled dependency updates restart this service.
201    #[must_use]
202    pub const fn restart(&self) -> Option<&ProjectValue<BooleanValue>> {
203        self.restart.as_ref()
204    }
205
206    /// Returns whether the dependency is required.
207    #[must_use]
208    pub const fn required(&self) -> Option<&ProjectValue<BooleanValue>> {
209        self.required.as_ref()
210    }
211
212    /// Returns retained long-form fields outside the typed dependency boundary.
213    #[must_use]
214    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
215        &self.unmodeled_fields
216    }
217}
218
219/// Effective service dependencies with the short or long Compose form retained.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum ProjectDependsOn {
222    /// A sequence of service names using Compose defaults.
223    Short(Vec<ProjectValue<ProjectServiceDependency>>),
224    /// A mapping of service names to dependency options.
225    Long(Vec<ProjectValue<ProjectServiceDependency>>),
226}
227
228impl ProjectDependsOn {
229    /// Returns dependencies in effective merge order.
230    #[must_use]
231    pub fn services(&self) -> &[ProjectValue<ProjectServiceDependency>] {
232        match self {
233            Self::Short(services) | Self::Long(services) => services,
234        }
235    }
236
237    /// Reports whether the effective field uses long mapping syntax.
238    #[must_use]
239    pub const fn is_long(&self) -> bool {
240        matches!(self, Self::Long(_))
241    }
242}
243
244/// A field retained by the merged tree but outside the first native project-view boundary.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct ProjectFieldReference {
247    path: Vec<String>,
248    key: ProjectKey,
249    provenance: MergeProvenance,
250    extension: bool,
251    sensitive: bool,
252}
253
254impl ProjectFieldReference {
255    /// Returns the semantic path including the field name.
256    #[must_use]
257    pub fn path(&self) -> &[String] {
258        &self.path
259    }
260
261    /// Returns the retained mapping key and all of its source locations.
262    #[must_use]
263    pub const fn key(&self) -> &ProjectKey {
264        &self.key
265    }
266
267    /// Returns the field value's complete merge provenance.
268    #[must_use]
269    pub const fn provenance(&self) -> &MergeProvenance {
270        &self.provenance
271    }
272
273    /// Reports whether the field name starts with `x-`.
274    #[must_use]
275    pub const fn is_extension(&self) -> bool {
276        self.extension
277    }
278
279    /// Reports whether the retained value contains sensitive interpolation output.
280    #[must_use]
281    pub const fn is_sensitive(&self) -> bool {
282        self.sensitive
283    }
284}
285
286/// One effective environment variable after field-specific multi-file merging.
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct ProjectEnvironmentEntry {
289    name: ProjectKey,
290    value: ProjectValue<ComposeScalar>,
291    syntax: EntrySyntax,
292}
293
294impl ProjectEnvironmentEntry {
295    /// Returns the variable name and its contributing key spans.
296    #[must_use]
297    pub const fn name(&self) -> &ProjectKey {
298        &self.name
299    }
300
301    /// Returns the effective scalar, including a distinct host-environment null value.
302    #[must_use]
303    pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
304        &self.value
305    }
306
307    /// Returns the most recent mapping or list syntax contributing this entry.
308    #[must_use]
309    pub const fn syntax(&self) -> EntrySyntax {
310        self.syntax
311    }
312}
313
314/// A normalized-by-key environment view that retains each entry's authored syntax form.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct ProjectEnvironment {
317    entries: Vec<ProjectEnvironmentEntry>,
318}
319
320/// One effective service environment-file entry with syntax and item provenance retained.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum ProjectEnvironmentFile {
323    /// Scalar path syntax.
324    Short(String),
325    /// Mapping syntax with field-level provenance.
326    Long(Box<ProjectLongEnvironmentFile>),
327}
328
329/// Effective long-syntax service environment-file options.
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct ProjectLongEnvironmentFile {
332    path: Option<ProjectValue<String>>,
333    required: Option<ProjectValue<BooleanValue>>,
334    format: Option<ProjectValue<EnvironmentFileFormat>>,
335    unmodeled_fields: Vec<ProjectFieldReference>,
336}
337
338impl ProjectLongEnvironmentFile {
339    /// Returns the required environment-file path.
340    #[must_use]
341    pub const fn path(&self) -> Option<&ProjectValue<String>> {
342        self.path.as_ref()
343    }
344
345    /// Returns the explicit required-file choice; absence means Compose's default `true`.
346    #[must_use]
347    pub const fn required(&self) -> Option<&ProjectValue<BooleanValue>> {
348        self.required.as_ref()
349    }
350
351    /// Returns the explicit parser format; absence means Compose's default format.
352    #[must_use]
353    pub const fn format(&self) -> Option<&ProjectValue<EnvironmentFileFormat>> {
354        self.format.as_ref()
355    }
356
357    /// Returns retained long-form fields outside the typed project-view boundary.
358    #[must_use]
359    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
360        &self.unmodeled_fields
361    }
362}
363
364/// One effective service metadata label after field-specific multi-file merging.
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct ProjectLabelEntry {
367    name: ProjectKey,
368    value: ProjectValue<ComposeScalar>,
369    syntax: EntrySyntax,
370}
371
372impl ProjectLabelEntry {
373    /// Returns the label name and its contributing key spans.
374    #[must_use]
375    pub const fn name(&self) -> &ProjectKey {
376        &self.name
377    }
378
379    /// Returns the effective label scalar.
380    ///
381    /// A key-only list entry has an explicit empty-string value while retaining
382    /// [`EntrySyntax::ListKeyOnly`] as its authored form.
383    #[must_use]
384    pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
385        &self.value
386    }
387
388    /// Returns the most recent mapping or list syntax contributing this entry.
389    #[must_use]
390    pub const fn syntax(&self) -> EntrySyntax {
391        self.syntax
392    }
393}
394
395/// A normalized-by-key service-label view retaining each entry's effective syntax.
396#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct ProjectLabels {
398    entries: Vec<ProjectLabelEntry>,
399}
400
401/// One raw/effective annotation scalar without erasing authored spelling.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct ProjectAnnotationScalar {
404    authored: String,
405    effective: ComposeScalar,
406}
407
408impl ProjectAnnotationScalar {
409    /// Returns the exact authored scalar spelling retained by the merge layer.
410    #[must_use]
411    pub fn authored(&self) -> &str {
412        &self.authored
413    }
414
415    /// Returns the effective scalar after optional per-file interpolation.
416    #[must_use]
417    pub const fn effective(&self) -> &ComposeScalar {
418        &self.effective
419    }
420}
421
422/// One effective service annotation after keyed merge and duplicate replacement.
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct ProjectAnnotationEntry {
425    name: ProjectKey,
426    value: Option<ProjectValue<ProjectAnnotationScalar>>,
427    raw_list_item: Option<ProjectValue<ProjectAnnotationScalar>>,
428    syntax: EntrySyntax,
429    contributors: Vec<MergeProvenance>,
430}
431
432impl ProjectAnnotationEntry {
433    /// Returns the effective annotation name and every contributing key/item location.
434    #[must_use]
435    pub const fn name(&self) -> &ProjectKey {
436        &self.name
437    }
438
439    /// Returns the explicit effective annotation value.
440    ///
441    /// A key-only list item returns `None`; it is diagnosed and never coerced to an empty string.
442    #[must_use]
443    pub const fn value(&self) -> Option<&ProjectValue<ProjectAnnotationScalar>> {
444        self.value.as_ref()
445    }
446
447    /// Returns the complete raw list scalar when list syntax supplied the effective entry.
448    #[must_use]
449    pub const fn raw_list_item(&self) -> Option<&ProjectValue<ProjectAnnotationScalar>> {
450        self.raw_list_item.as_ref()
451    }
452
453    /// Returns the most recent mapping or list syntax contributing this entry.
454    #[must_use]
455    pub const fn syntax(&self) -> EntrySyntax {
456        self.syntax
457    }
458
459    /// Returns every replaced contributor's merge provenance in authored order.
460    #[must_use]
461    pub fn contributors(&self) -> &[MergeProvenance] {
462        &self.contributors
463    }
464}
465
466/// Effective service annotations keyed by semantic name with authored evidence retained.
467#[derive(Debug, Clone, PartialEq, Eq)]
468pub struct ProjectAnnotations {
469    entries: Vec<ProjectAnnotationEntry>,
470}
471
472impl ProjectAnnotations {
473    /// Returns effective annotations in first-key order.
474    #[must_use]
475    pub fn entries(&self) -> &[ProjectAnnotationEntry] {
476        &self.entries
477    }
478
479    /// Finds an effective annotation by name.
480    #[must_use]
481    pub fn get(&self, name: &str) -> Option<&ProjectAnnotationEntry> {
482        self.entries.iter().find(|entry| entry.name.value == name)
483    }
484}
485
486impl ProjectLabels {
487    /// Returns labels in effective merge order.
488    #[must_use]
489    pub fn entries(&self) -> &[ProjectLabelEntry] {
490        &self.entries
491    }
492
493    /// Finds an effective label by name.
494    #[must_use]
495    pub fn get(&self, name: &str) -> Option<&ProjectLabelEntry> {
496        self.entries.iter().find(|entry| entry.name.value == name)
497    }
498}
499
500/// One effective hostname-to-address mapping after field-specific project merging.
501#[derive(Debug, Clone, PartialEq, Eq)]
502pub struct ProjectExtraHost {
503    hostname: ProjectKey,
504    address: ProjectValue<HostAddress>,
505    syntax: EntrySyntax,
506}
507
508impl ProjectExtraHost {
509    /// Returns the hostname and every contributing source location.
510    #[must_use]
511    pub const fn hostname(&self) -> &ProjectKey {
512        &self.hostname
513    }
514
515    /// Returns the raw-preserving IP address or implementation token.
516    #[must_use]
517    pub const fn address(&self) -> &ProjectValue<HostAddress> {
518        &self.address
519    }
520
521    /// Returns the most recent mapping or list syntax contributing this entry.
522    #[must_use]
523    pub const fn syntax(&self) -> EntrySyntax {
524        self.syntax
525    }
526}
527
528/// Ordered effective `extra_hosts` entries with field and item provenance.
529#[derive(Debug, Clone, PartialEq, Eq)]
530pub struct ProjectExtraHosts {
531    entries: Vec<ProjectExtraHost>,
532}
533
534impl ProjectExtraHosts {
535    /// Returns host mappings in effective merge order.
536    #[must_use]
537    pub fn entries(&self) -> &[ProjectExtraHost] {
538        &self.entries
539    }
540}
541
542impl ProjectEnvironment {
543    /// Returns environment variables in effective merge order.
544    #[must_use]
545    pub fn entries(&self) -> &[ProjectEnvironmentEntry] {
546        &self.entries
547    }
548
549    /// Finds an effective environment variable by name.
550    #[must_use]
551    pub fn get(&self, name: &str) -> Option<&ProjectEnvironmentEntry> {
552        self.entries.iter().find(|entry| entry.name.value == name)
553    }
554}
555
556/// One effective service health check with field-level merge provenance.
557#[derive(Debug, Clone, PartialEq, Eq)]
558pub struct ProjectHealthcheck {
559    test: Option<ProjectValue<HealthcheckTest>>,
560    interval: Option<ProjectValue<HealthcheckDuration>>,
561    timeout: Option<ProjectValue<HealthcheckDuration>>,
562    retries: Option<ProjectValue<HealthcheckRetries>>,
563    start_period: Option<ProjectValue<HealthcheckDuration>>,
564    start_interval: Option<ProjectValue<HealthcheckDuration>>,
565    disable: Option<ProjectValue<BooleanValue>>,
566    unmodeled_fields: Vec<ProjectFieldReference>,
567}
568
569/// Effective long-form service config or secret grant with field-level merge provenance.
570#[derive(Debug, Clone, PartialEq, Eq)]
571pub struct ProjectLongGrant {
572    source: Option<ProjectValue<String>>,
573    target: Option<ProjectValue<String>>,
574    uid: Option<ProjectValue<String>>,
575    gid: Option<ProjectValue<String>>,
576    mode: Option<ProjectValue<String>>,
577    unmodeled_fields: Vec<ProjectFieldReference>,
578}
579
580impl ProjectLongGrant {
581    /// Returns the referenced top-level resource name.
582    #[must_use]
583    pub const fn source(&self) -> Option<&ProjectValue<String>> {
584        self.source.as_ref()
585    }
586
587    /// Returns the requested container path or name.
588    #[must_use]
589    pub const fn target(&self) -> Option<&ProjectValue<String>> {
590        self.target.as_ref()
591    }
592
593    /// Returns the requested container user-ID spelling.
594    #[must_use]
595    pub const fn uid(&self) -> Option<&ProjectValue<String>> {
596        self.uid.as_ref()
597    }
598
599    /// Returns the requested container group-ID spelling.
600    #[must_use]
601    pub const fn gid(&self) -> Option<&ProjectValue<String>> {
602        self.gid.as_ref()
603    }
604
605    /// Returns the requested permission-mode spelling.
606    #[must_use]
607    pub const fn mode(&self) -> Option<&ProjectValue<String>> {
608        self.mode.as_ref()
609    }
610
611    /// Returns retained long-form fields outside the typed project-view boundary.
612    #[must_use]
613    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
614        &self.unmodeled_fields
615    }
616}
617
618/// One effective service config or secret grant with its Compose syntax form retained.
619#[derive(Debug, Clone, PartialEq, Eq)]
620pub enum ProjectGrant {
621    /// Resource-name short syntax.
622    Short(String),
623    /// Mapping-based long syntax.
624    Long(Box<ProjectLongGrant>),
625}
626
627/// Effective long-form service device with nested merge provenance retained.
628#[derive(Debug, Clone, PartialEq, Eq)]
629pub struct ProjectLongDevice {
630    source: Option<ProjectValue<String>>,
631    target: Option<ProjectValue<String>>,
632    permissions: Option<ProjectValue<String>>,
633    extension_fields: Vec<ProjectFieldReference>,
634    unknown_fields: Vec<ProjectFieldReference>,
635}
636
637impl ProjectLongDevice {
638    /// Returns the required raw source when it was valid and present.
639    #[must_use]
640    pub const fn source(&self) -> Option<&ProjectValue<String>> {
641        self.source.as_ref()
642    }
643
644    /// Returns the optional raw target without path interpretation.
645    #[must_use]
646    pub const fn target(&self) -> Option<&ProjectValue<String>> {
647        self.target.as_ref()
648    }
649
650    /// Returns the optional raw permissions string without validating runtime meaning.
651    #[must_use]
652    pub const fn permissions(&self) -> Option<&ProjectValue<String>> {
653        self.permissions.as_ref()
654    }
655
656    /// Returns retained `x-` options with their complete source evidence.
657    #[must_use]
658    pub fn extension_fields(&self) -> &[ProjectFieldReference] {
659        &self.extension_fields
660    }
661
662    /// Returns unrecognized long-form options with their complete source evidence.
663    #[must_use]
664    pub fn unknown_fields(&self) -> &[ProjectFieldReference] {
665        &self.unknown_fields
666    }
667}
668
669/// One effective service device with short and long syntax kept distinct.
670#[derive(Debug, Clone, PartialEq, Eq)]
671#[non_exhaustive]
672pub enum ProjectDevice {
673    /// A raw short scalar, including path, CDI, deferred, and opaque spellings.
674    Short(ShortDevice),
675    /// A mapping-form device whose nested values retain their own provenance.
676    Long(ProjectLongDevice),
677}
678
679impl ProjectHealthcheck {
680    /// Returns the effective health command without collapsing scalar and list forms.
681    #[must_use]
682    pub const fn test(&self) -> Option<&ProjectValue<HealthcheckTest>> {
683        self.test.as_ref()
684    }
685
686    /// Returns the effective regular-check interval.
687    #[must_use]
688    pub const fn interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
689        self.interval.as_ref()
690    }
691
692    /// Returns the effective per-check timeout.
693    #[must_use]
694    pub const fn timeout(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
695        self.timeout.as_ref()
696    }
697
698    /// Returns the effective unhealthy retry count.
699    #[must_use]
700    pub const fn retries(&self) -> Option<&ProjectValue<HealthcheckRetries>> {
701        self.retries.as_ref()
702    }
703
704    /// Returns the effective startup grace period.
705    #[must_use]
706    pub const fn start_period(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
707        self.start_period.as_ref()
708    }
709
710    /// Returns the effective interval used during the startup grace period.
711    #[must_use]
712    pub const fn start_interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
713        self.start_interval.as_ref()
714    }
715
716    /// Returns whether the image health check is explicitly disabled.
717    #[must_use]
718    pub const fn disable(&self) -> Option<&ProjectValue<BooleanValue>> {
719        self.disable.as_ref()
720    }
721
722    /// Reports whether the effective definition explicitly disables health checks.
723    #[must_use]
724    pub fn is_disabled(&self) -> bool {
725        matches!(
726            self.disable.as_ref().map(ProjectValue::value),
727            Some(BooleanValue::Literal(true))
728        ) || matches!(
729            self.test.as_ref().and_then(|test| test.value().kind()),
730            Some(HealthcheckTestKind::None)
731        )
732    }
733
734    /// Returns retained health-check fields outside the typed project-view boundary.
735    #[must_use]
736    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
737        &self.unmodeled_fields
738    }
739}
740
741/// Effective service-level `tmpfs` syntax with per-item merge provenance retained.
742#[derive(Debug, Clone, PartialEq, Eq)]
743#[non_exhaustive]
744pub enum ProjectTmpfs {
745    /// One effective scalar declaration.
746    Scalar(ProjectValue<TmpfsItem>),
747    /// One effective list, including an explicit empty or reset list.
748    List(Vec<ProjectValue<TmpfsItem>>),
749}
750
751/// Effective service `dns` syntax with collection and per-item merge provenance retained.
752#[derive(Debug, Clone, PartialEq, Eq)]
753#[non_exhaustive]
754pub enum ProjectDns {
755    /// One effective raw scalar server string.
756    Scalar(ProjectValue<String>),
757    /// One effective ordered list, including an explicit empty or reset list.
758    List(Vec<ProjectValue<String>>),
759}
760
761/// Effective service `dns_search` syntax with collection and per-item merge provenance retained.
762#[derive(Debug, Clone, PartialEq, Eq)]
763#[non_exhaustive]
764pub enum ProjectDnsSearch {
765    /// One effective raw scalar search-domain string.
766    Scalar(ProjectValue<String>),
767    /// One effective ordered list, including an explicit empty or reset list.
768    List(Vec<ProjectValue<String>>),
769}
770
771/// One effective service `expose` scalar with authored spelling and YAML kind retained.
772#[derive(Debug, Clone, PartialEq, Eq)]
773pub struct ProjectExposeItem {
774    authored: String,
775    value: String,
776    scalar_kind: ExposeScalarKind,
777    kind: ExposeItemKind,
778}
779
780/// One effective raw service security option with authored and interpolated spelling retained.
781#[derive(Debug, Clone, PartialEq, Eq)]
782pub struct ProjectSecurityOptionItem {
783    authored: String,
784    value: String,
785    scalar_kind: MergedScalarKind,
786    kind: SecurityOptionKind,
787}
788
789impl ProjectSecurityOptionItem {
790    /// Returns the exact scalar spelling before optional interpolation.
791    #[must_use]
792    pub fn authored(&self) -> &str {
793        &self.authored
794    }
795
796    /// Returns the effective scalar spelling after optional interpolation.
797    #[must_use]
798    pub fn value(&self) -> &str {
799        &self.value
800    }
801
802    /// Returns the retained YAML scalar category.
803    #[must_use]
804    pub const fn scalar_kind(&self) -> MergedScalarKind {
805        self.scalar_kind
806    }
807
808    /// Returns the narrow classification of the effective spelling.
809    #[must_use]
810    pub const fn kind(&self) -> &SecurityOptionKind {
811        &self.kind
812    }
813}
814
815impl ProjectExposeItem {
816    /// Returns the exact scalar spelling before optional interpolation.
817    #[must_use]
818    pub fn authored(&self) -> &str {
819        &self.authored
820    }
821
822    /// Returns the exact effective scalar spelling after optional interpolation.
823    #[must_use]
824    pub fn value(&self) -> &str {
825        &self.value
826    }
827
828    /// Returns whether the YAML scalar was authored as a string or number.
829    #[must_use]
830    pub const fn scalar_kind(&self) -> ExposeScalarKind {
831        self.scalar_kind
832    }
833
834    /// Returns the conservative classification of the effective spelling.
835    #[must_use]
836    pub const fn kind(&self) -> &ExposeItemKind {
837        &self.kind
838    }
839}
840
841/// One effective ulimit scalar with authored spelling and YAML scalar kind retained.
842#[derive(Debug, Clone, PartialEq, Eq)]
843pub struct ProjectUlimitScalar {
844    authored: String,
845    value: LimitValue,
846    kind: MergedScalarKind,
847}
848
849impl ProjectUlimitScalar {
850    /// Returns the exact authored scalar spelling before optional interpolation.
851    #[must_use]
852    pub fn authored(&self) -> &str {
853        &self.authored
854    }
855
856    /// Returns the classified effective spelling after optional interpolation.
857    #[must_use]
858    pub const fn value(&self) -> &LimitValue {
859        &self.value
860    }
861
862    /// Returns whether the authored YAML scalar was a string or number.
863    #[must_use]
864    pub const fn kind(&self) -> MergedScalarKind {
865        self.kind
866    }
867}
868
869/// Effective long-syntax ulimit members with independent merge provenance.
870#[derive(Debug, Clone, PartialEq, Eq)]
871pub struct ProjectUlimitRange {
872    soft: Option<ProjectValue<ProjectUlimitScalar>>,
873    hard: Option<ProjectValue<ProjectUlimitScalar>>,
874    unmodeled_fields: Vec<ProjectFieldReference>,
875}
876
877impl ProjectUlimitRange {
878    /// Returns the effective soft limit, or `None` when the required member was omitted or malformed.
879    #[must_use]
880    pub const fn soft(&self) -> Option<&ProjectValue<ProjectUlimitScalar>> {
881        self.soft.as_ref()
882    }
883
884    /// Returns the effective hard limit, or `None` when the required member was omitted or malformed.
885    #[must_use]
886    pub const fn hard(&self) -> Option<&ProjectValue<ProjectUlimitScalar>> {
887        self.hard.as_ref()
888    }
889
890    /// Returns retained range fields outside the `soft` and `hard` boundary.
891    #[must_use]
892    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
893        &self.unmodeled_fields
894    }
895}
896
897/// The effective single or soft/hard form of one named ulimit.
898#[derive(Debug, Clone, PartialEq, Eq)]
899#[non_exhaustive]
900pub enum ProjectUlimitValue {
901    /// One scalar applies to both the soft and hard limit.
902    Single(ProjectValue<ProjectUlimitScalar>),
903    /// Soft and hard members remain independently source-aware.
904    Range(ProjectUlimitRange),
905}
906
907/// One ordered effective named ulimit.
908#[derive(Debug, Clone, PartialEq, Eq)]
909pub struct ProjectUlimit {
910    name: ProjectKey,
911    value: ProjectUlimitValue,
912}
913
914impl ProjectUlimit {
915    /// Returns the lowercase limit name and every authored key location.
916    #[must_use]
917    pub const fn name(&self) -> &ProjectKey {
918        &self.name
919    }
920
921    /// Returns the effective single or soft/hard form.
922    #[must_use]
923    pub const fn value(&self) -> &ProjectUlimitValue {
924        &self.value
925    }
926}
927
928/// Effective service `ulimits`, including an explicitly empty or reset mapping.
929#[derive(Debug, Clone, PartialEq, Eq)]
930pub struct ProjectUlimits {
931    entries: Vec<ProjectValue<ProjectUlimit>>,
932}
933
934impl ProjectUlimits {
935    /// Returns named limits in effective mapping order.
936    #[must_use]
937    pub fn entries(&self) -> &[ProjectValue<ProjectUlimit>] {
938        &self.entries
939    }
940
941    /// Reports whether the effective mapping is explicitly empty.
942    #[must_use]
943    pub fn is_empty(&self) -> bool {
944        self.entries.is_empty()
945    }
946}
947
948/// One effective mapping-form service sysctl with key and scalar-value provenance.
949#[derive(Debug, Clone, PartialEq, Eq)]
950pub struct ProjectSysctl {
951    name: ProjectKey,
952    value: ProjectValue<ComposeScalar>,
953}
954
955impl ProjectSysctl {
956    /// Returns the exact sysctl name and every authored key location.
957    #[must_use]
958    pub const fn name(&self) -> &ProjectKey {
959        &self.name
960    }
961
962    /// Returns the exact scalar kind and spelling with complete merge provenance.
963    #[must_use]
964    pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
965        &self.value
966    }
967}
968
969/// Effective service `sysctls` with mapping/list form and per-entry provenance retained.
970#[derive(Debug, Clone, PartialEq, Eq)]
971#[non_exhaustive]
972pub enum ProjectSysctls {
973    /// Ordered mapping entries merged by exact key.
974    Map(Vec<ProjectValue<ProjectSysctl>>),
975    /// Ordered list items appended without implicit deduplication.
976    List(Vec<ProjectValue<String>>),
977}
978
979/// One selected service with the native fields needed by the first conversion boundary.
980#[derive(Debug, Clone, PartialEq, Eq)]
981pub struct ProjectService {
982    name: ProjectKey,
983    provenance: MergeProvenance,
984    hostname: Option<ProjectValue<Hostname>>,
985    container_name: Option<ProjectValue<String>>,
986    image: Option<ProjectValue<ImageReference>>,
987    entrypoint: Option<ProjectValue<Entrypoint>>,
988    command: Option<ProjectValue<Command>>,
989    init: Option<ProjectValue<BooleanValue>>,
990    environment: Option<ProjectValue<ProjectEnvironment>>,
991    environment_files: Option<ProjectValue<Vec<ProjectValue<ProjectEnvironmentFile>>>>,
992    labels: Option<ProjectValue<ProjectLabels>>,
993    annotations: Option<ProjectValue<ProjectAnnotations>>,
994    extra_hosts: Option<ProjectValue<ProjectExtraHosts>>,
995    user: Option<ProjectValue<UserSpec>>,
996    userns_mode: Option<ProjectValue<UserNamespaceMode>>,
997    group_add: Option<ProjectValue<Vec<ProjectValue<String>>>>,
998    cap_add: Option<ProjectValue<Vec<ProjectValue<CapabilityAddItem>>>>,
999    cap_drop: Option<ProjectValue<Vec<ProjectValue<CapabilityDropItem>>>>,
1000    devices: Option<ProjectValue<Vec<ProjectValue<ProjectDevice>>>>,
1001    dns: Option<ProjectValue<ProjectDns>>,
1002    dns_options: Option<ProjectValue<Vec<ProjectValue<String>>>>,
1003    dns_search: Option<ProjectValue<ProjectDnsSearch>>,
1004    expose: Option<ProjectValue<Vec<ProjectValue<ProjectExposeItem>>>>,
1005    security_options: Option<ProjectValue<Vec<ProjectValue<ProjectSecurityOptionItem>>>>,
1006    working_dir: Option<ProjectValue<String>>,
1007    read_only: Option<ProjectValue<BooleanValue>>,
1008    pids_limit: Option<ProjectValue<PidsLimit>>,
1009    shm_size: Option<ProjectValue<ShmSize>>,
1010    mem_limit: Option<ProjectValue<MemLimit>>,
1011    tmpfs: Option<ProjectValue<ProjectTmpfs>>,
1012    sysctls: Option<ProjectValue<ProjectSysctls>>,
1013    ulimits: Option<ProjectValue<ProjectUlimits>>,
1014    pull_policy: Option<ProjectValue<PullPolicy>>,
1015    restart: Option<ProjectValue<RestartPolicy>>,
1016    stop_signal: Option<ProjectValue<String>>,
1017    stop_grace_period: Option<ProjectValue<StopGracePeriod>>,
1018    healthcheck: Option<ProjectValue<ProjectHealthcheck>>,
1019    depends_on: Option<ProjectValue<ProjectDependsOn>>,
1020    ports: Option<ProjectValue<Vec<ProjectValue<Port>>>>,
1021    volumes: Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>>,
1022    configs: Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>,
1023    secrets: Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>,
1024    networks: Option<ProjectValue<ServiceNetworks>>,
1025    profiles: Option<ProjectValue<Vec<ProjectValue<String>>>>,
1026    unmodeled_fields: Vec<ProjectFieldReference>,
1027}
1028
1029impl ProjectService {
1030    fn from_entry(entry: &MergedEntry) -> Self {
1031        Self {
1032            name: ProjectKey::from_entry(entry),
1033            provenance: entry.value().provenance().clone(),
1034            hostname: None,
1035            container_name: None,
1036            image: None,
1037            entrypoint: None,
1038            command: None,
1039            init: None,
1040            environment: None,
1041            environment_files: None,
1042            labels: None,
1043            annotations: None,
1044            extra_hosts: None,
1045            user: None,
1046            userns_mode: None,
1047            group_add: None,
1048            cap_add: None,
1049            cap_drop: None,
1050            devices: None,
1051            dns: None,
1052            dns_options: None,
1053            dns_search: None,
1054            expose: None,
1055            security_options: None,
1056            working_dir: None,
1057            read_only: None,
1058            pids_limit: None,
1059            shm_size: None,
1060            mem_limit: None,
1061            tmpfs: None,
1062            sysctls: None,
1063            ulimits: None,
1064            pull_policy: None,
1065            restart: None,
1066            stop_signal: None,
1067            stop_grace_period: None,
1068            healthcheck: None,
1069            depends_on: None,
1070            ports: None,
1071            volumes: None,
1072            configs: None,
1073            secrets: None,
1074            networks: None,
1075            profiles: None,
1076            unmodeled_fields: Vec::new(),
1077        }
1078    }
1079
1080    /// Returns the service name and all contributing key spans.
1081    #[must_use]
1082    pub const fn name(&self) -> &ProjectKey {
1083        &self.name
1084    }
1085
1086    /// Returns provenance for the complete effective service mapping.
1087    #[must_use]
1088    pub const fn provenance(&self) -> &MergeProvenance {
1089        &self.provenance
1090    }
1091
1092    /// Returns the effective raw-preserving service hostname.
1093    #[must_use]
1094    pub const fn hostname(&self) -> Option<&ProjectValue<Hostname>> {
1095        self.hostname.as_ref()
1096    }
1097
1098    /// Returns the effective explicit runtime container name.
1099    #[must_use]
1100    pub const fn container_name(&self) -> Option<&ProjectValue<String>> {
1101        self.container_name.as_ref()
1102    }
1103
1104    /// Returns the effective image reference.
1105    #[must_use]
1106    pub const fn image(&self) -> Option<&ProjectValue<ImageReference>> {
1107        self.image.as_ref()
1108    }
1109
1110    /// Returns the effective entrypoint without normalizing scalar and list forms.
1111    #[must_use]
1112    pub const fn entrypoint(&self) -> Option<&ProjectValue<Entrypoint>> {
1113        self.entrypoint.as_ref()
1114    }
1115
1116    /// Returns the effective command without normalizing scalar and list forms.
1117    #[must_use]
1118    pub const fn command(&self) -> Option<&ProjectValue<Command>> {
1119        self.command.as_ref()
1120    }
1121
1122    /// Returns the effective platform-specific init-process choice.
1123    #[must_use]
1124    pub const fn init(&self) -> Option<&ProjectValue<BooleanValue>> {
1125        self.init.as_ref()
1126    }
1127
1128    /// Returns environment entries normalized by key with per-entry syntax retained.
1129    #[must_use]
1130    pub const fn environment(&self) -> Option<&ProjectValue<ProjectEnvironment>> {
1131        self.environment.as_ref()
1132    }
1133
1134    /// Returns effective service environment files in merge order with per-item provenance.
1135    #[must_use]
1136    pub const fn environment_files(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectEnvironmentFile>>>> {
1137        self.environment_files.as_ref()
1138    }
1139
1140    /// Returns effective service labels normalized by key with entry syntax retained.
1141    #[must_use]
1142    pub const fn labels(&self) -> Option<&ProjectValue<ProjectLabels>> {
1143        self.labels.as_ref()
1144    }
1145
1146    /// Returns effective service annotations keyed by name with ambiguous key-only entries retained.
1147    #[must_use]
1148    pub const fn annotations(&self) -> Option<&ProjectValue<ProjectAnnotations>> {
1149        self.annotations.as_ref()
1150    }
1151
1152    /// Returns effective service host mappings with per-entry provenance and syntax.
1153    #[must_use]
1154    pub const fn extra_hosts(&self) -> Option<&ProjectValue<ProjectExtraHosts>> {
1155        self.extra_hosts.as_ref()
1156    }
1157
1158    /// Returns the effective container user and optional group spelling.
1159    #[must_use]
1160    pub const fn user(&self) -> Option<&ProjectValue<UserSpec>> {
1161        self.user.as_ref()
1162    }
1163
1164    /// Returns the effective user-namespace mode.
1165    #[must_use]
1166    pub const fn userns_mode(&self) -> Option<&ProjectValue<UserNamespaceMode>> {
1167        self.userns_mode.as_ref()
1168    }
1169
1170    /// Returns supplementary groups in effective merge order.
1171    #[must_use]
1172    pub const fn group_add(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
1173        self.group_add.as_ref()
1174    }
1175
1176    /// Returns the effective capability-add sequence with full field and per-item provenance.
1177    ///
1178    /// `None` means the field was omitted; `Some` with an empty vector means it was explicitly
1179    /// configured empty or reset.
1180    #[must_use]
1181    pub const fn cap_add(&self) -> Option<&ProjectValue<Vec<ProjectValue<CapabilityAddItem>>>> {
1182        self.cap_add.as_ref()
1183    }
1184
1185    /// Returns the effective capability-drop sequence with full field and per-item provenance.
1186    ///
1187    /// `None` means the field was omitted; `Some` with an empty vector means it was explicitly
1188    /// configured empty or reset.
1189    #[must_use]
1190    pub const fn cap_drop(&self) -> Option<&ProjectValue<Vec<ProjectValue<CapabilityDropItem>>>> {
1191        self.cap_drop.as_ref()
1192    }
1193
1194    /// Returns effective ordered mixed short/long service devices with complete provenance.
1195    ///
1196    /// `None` means omission; `Some` with an empty vector means an explicit empty sequence or reset.
1197    #[must_use]
1198    pub const fn devices(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectDevice>>>> {
1199        self.devices.as_ref()
1200    }
1201
1202    /// Returns effective raw service DNS servers with source form and provenance.
1203    #[must_use]
1204    pub const fn dns(&self) -> Option<&ProjectValue<ProjectDns>> {
1205        self.dns.as_ref()
1206    }
1207
1208    /// Returns the effective ordered service DNS resolver options.
1209    ///
1210    /// Omission remains `None`; an explicitly empty or reset sequence remains `Some` with no
1211    /// items. Exact duplicate items are retained and diagnosed.
1212    #[must_use]
1213    pub const fn dns_options(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
1214        self.dns_options.as_ref()
1215    }
1216
1217    /// Returns effective raw DNS search domains with source form and provenance.
1218    #[must_use]
1219    pub const fn dns_search(&self) -> Option<&ProjectValue<ProjectDnsSearch>> {
1220        self.dns_search.as_ref()
1221    }
1222
1223    /// Returns the effective ordered service `expose` sequence.
1224    ///
1225    /// Omission remains `None`; an explicitly empty or reset sequence remains `Some` with no
1226    /// items. Exact scalar identity includes both value text and YAML string/number kind.
1227    #[must_use]
1228    pub const fn expose(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectExposeItem>>>> {
1229        self.expose.as_ref()
1230    }
1231
1232    /// Returns the effective ordered raw service security options.
1233    ///
1234    /// Omission remains `None`; an explicitly empty or reset sequence remains `Some` with no
1235    /// items. Duplicates remain ordered evidence.
1236    #[must_use]
1237    pub const fn security_options(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectSecurityOptionItem>>>> {
1238        self.security_options.as_ref()
1239    }
1240
1241    /// Returns the effective container working-directory override.
1242    #[must_use]
1243    pub const fn working_dir(&self) -> Option<&ProjectValue<String>> {
1244        self.working_dir.as_ref()
1245    }
1246
1247    /// Returns the effective read-only root-filesystem choice.
1248    #[must_use]
1249    pub const fn read_only(&self) -> Option<&ProjectValue<BooleanValue>> {
1250        self.read_only.as_ref()
1251    }
1252
1253    /// Returns the effective raw-preserving service PID limit.
1254    #[must_use]
1255    pub const fn pids_limit(&self) -> Option<&ProjectValue<PidsLimit>> {
1256        self.pids_limit.as_ref()
1257    }
1258
1259    /// Returns the effective raw-preserving service shared-memory size.
1260    #[must_use]
1261    pub const fn shm_size(&self) -> Option<&ProjectValue<ShmSize>> {
1262        self.shm_size.as_ref()
1263    }
1264
1265    /// Returns the effective raw-preserving service memory limit.
1266    #[must_use]
1267    pub const fn mem_limit(&self) -> Option<&ProjectValue<MemLimit>> {
1268        self.mem_limit.as_ref()
1269    }
1270
1271    /// Returns effective service-level temporary filesystems with source form and provenance.
1272    #[must_use]
1273    pub const fn tmpfs(&self) -> Option<&ProjectValue<ProjectTmpfs>> {
1274        self.tmpfs.as_ref()
1275    }
1276
1277    /// Returns effective service sysctls with source form and per-entry provenance.
1278    #[must_use]
1279    pub const fn sysctls(&self) -> Option<&ProjectValue<ProjectSysctls>> {
1280        self.sysctls.as_ref()
1281    }
1282
1283    /// Returns effective ordered service limits with nested and field-level merge provenance.
1284    ///
1285    /// `None` means the field was omitted; an empty mapping remains present and can carry reset or
1286    /// override provenance.
1287    #[must_use]
1288    pub const fn ulimits(&self) -> Option<&ProjectValue<ProjectUlimits>> {
1289        self.ulimits.as_ref()
1290    }
1291
1292    /// Returns the effective raw-preserving service image pull policy.
1293    #[must_use]
1294    pub const fn pull_policy(&self) -> Option<&ProjectValue<PullPolicy>> {
1295        self.pull_policy.as_ref()
1296    }
1297
1298    /// Returns the effective service-level container restart policy.
1299    #[must_use]
1300    pub const fn restart(&self) -> Option<&ProjectValue<RestartPolicy>> {
1301        self.restart.as_ref()
1302    }
1303
1304    /// Returns the effective explicitly authored service stop signal.
1305    #[must_use]
1306    pub const fn stop_signal(&self) -> Option<&ProjectValue<String>> {
1307        self.stop_signal.as_ref()
1308    }
1309
1310    /// Returns the effective raw-preserving service stop grace period.
1311    #[must_use]
1312    pub const fn stop_grace_period(&self) -> Option<&ProjectValue<StopGracePeriod>> {
1313        self.stop_grace_period.as_ref()
1314    }
1315
1316    /// Returns the effective health check with per-field merge provenance.
1317    #[must_use]
1318    pub const fn healthcheck(&self) -> Option<&ProjectValue<ProjectHealthcheck>> {
1319        self.healthcheck.as_ref()
1320    }
1321
1322    /// Returns effective service dependencies with authored form and field-level provenance.
1323    #[must_use]
1324    pub const fn depends_on(&self) -> Option<&ProjectValue<ProjectDependsOn>> {
1325        self.depends_on.as_ref()
1326    }
1327
1328    /// Returns the effective port collection and per-item provenance.
1329    #[must_use]
1330    pub const fn ports(&self) -> Option<&ProjectValue<Vec<ProjectValue<Port>>>> {
1331        self.ports.as_ref()
1332    }
1333
1334    /// Returns the effective volume-mount collection and per-item provenance.
1335    #[must_use]
1336    pub const fn volumes(&self) -> Option<&ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
1337        self.volumes.as_ref()
1338    }
1339
1340    /// Returns effective service config grants with syntax and field-level provenance retained.
1341    #[must_use]
1342    pub const fn configs(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
1343        self.configs.as_ref()
1344    }
1345
1346    /// Returns effective service secret grants with syntax and field-level provenance retained.
1347    #[must_use]
1348    pub const fn secrets(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
1349        self.secrets.as_ref()
1350    }
1351
1352    /// Returns effective network attachments with short and long forms retained.
1353    #[must_use]
1354    pub const fn networks(&self) -> Option<&ProjectValue<ServiceNetworks>> {
1355        self.networks.as_ref()
1356    }
1357
1358    /// Returns effective profile names and their individual provenance.
1359    #[must_use]
1360    pub const fn profiles(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
1361        self.profiles.as_ref()
1362    }
1363
1364    /// Returns fields retained outside this initial native project-view boundary.
1365    #[must_use]
1366    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
1367        &self.unmodeled_fields
1368    }
1369}
1370
1371/// One named top-level resource with key and definition provenance kept separately.
1372#[derive(Debug, Clone, PartialEq, Eq)]
1373pub struct ProjectResource<T> {
1374    name: ProjectKey,
1375    definition: ProjectValue<T>,
1376}
1377
1378impl<T> ProjectResource<T> {
1379    /// Returns the model name and all authored key locations.
1380    #[must_use]
1381    pub const fn name(&self) -> &ProjectKey {
1382        &self.name
1383    }
1384
1385    /// Returns the native effective definition and its merge provenance.
1386    #[must_use]
1387    pub const fn definition(&self) -> &ProjectValue<T> {
1388        &self.definition
1389    }
1390}
1391
1392/// The native consumer view of one merged and optionally profile-selected Compose project.
1393#[derive(Debug, Clone, PartialEq, Eq)]
1394pub struct ProjectView {
1395    source_ids: Vec<SourceId>,
1396    base_directory: PathBuf,
1397    provenance: MergeProvenance,
1398    name: Option<ProjectValue<String>>,
1399    services: Vec<ProjectService>,
1400    networks: Vec<ProjectResource<NetworkDefinition>>,
1401    volumes: Vec<ProjectResource<VolumeDefinition>>,
1402    configs: Vec<ProjectResource<ConfigDefinition>>,
1403    secrets: Vec<ProjectResource<SecretDefinition>>,
1404    unmodeled_fields: Vec<ProjectFieldReference>,
1405}
1406
1407impl ProjectView {
1408    /// Returns source documents in merge order.
1409    #[must_use]
1410    pub fn source_ids(&self) -> &[SourceId] {
1411        &self.source_ids
1412    }
1413
1414    /// Returns the project directory inherited from the first loaded document.
1415    #[must_use]
1416    pub fn base_directory(&self) -> &Path {
1417        &self.base_directory
1418    }
1419
1420    /// Returns provenance for the complete merged root.
1421    #[must_use]
1422    pub const fn provenance(&self) -> &MergeProvenance {
1423        &self.provenance
1424    }
1425
1426    /// Returns the effective explicit project name.
1427    #[must_use]
1428    pub const fn name(&self) -> Option<&ProjectValue<String>> {
1429        self.name.as_ref()
1430    }
1431
1432    /// Returns profile-active services in merged order.
1433    #[must_use]
1434    pub fn services(&self) -> &[ProjectService] {
1435        &self.services
1436    }
1437
1438    /// Finds one profile-active service.
1439    #[must_use]
1440    pub fn service(&self, name: &str) -> Option<&ProjectService> {
1441        self.services.iter().find(|service| service.name.value == name)
1442    }
1443
1444    /// Returns effective top-level network definitions.
1445    #[must_use]
1446    pub fn networks(&self) -> &[ProjectResource<NetworkDefinition>] {
1447        &self.networks
1448    }
1449
1450    /// Returns effective top-level volume definitions.
1451    #[must_use]
1452    pub fn volumes(&self) -> &[ProjectResource<VolumeDefinition>] {
1453        &self.volumes
1454    }
1455
1456    /// Returns effective top-level config definitions.
1457    #[must_use]
1458    pub fn configs(&self) -> &[ProjectResource<ConfigDefinition>] {
1459        &self.configs
1460    }
1461
1462    /// Returns effective top-level secret definitions.
1463    #[must_use]
1464    pub fn secrets(&self) -> &[ProjectResource<SecretDefinition>] {
1465        &self.secrets
1466    }
1467
1468    /// Returns root fields retained outside this initial native project-view boundary.
1469    #[must_use]
1470    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
1471        &self.unmodeled_fields
1472    }
1473}
1474
1475/// Recoverable result of building a typed merged project view.
1476#[derive(Debug, Clone, PartialEq, Eq)]
1477pub struct ProjectViewResult {
1478    view: Option<ProjectView>,
1479    diagnostics: Vec<Diagnostic>,
1480}
1481
1482impl ProjectViewResult {
1483    /// Returns the typed view when the profile selection belongs to the project.
1484    #[must_use]
1485    pub const fn view(&self) -> Option<&ProjectView> {
1486        self.view.as_ref()
1487    }
1488
1489    /// Returns project-view diagnostics in traversal order.
1490    #[must_use]
1491    pub fn diagnostics(&self) -> &[Diagnostic] {
1492        &self.diagnostics
1493    }
1494
1495    /// Reports whether a view exists and contains no error diagnostics.
1496    #[must_use]
1497    pub fn is_valid(&self) -> bool {
1498        self.view.is_some()
1499            && self
1500                .diagnostics
1501                .iter()
1502                .all(|diagnostic| diagnostic.severity() != Severity::Error)
1503    }
1504
1505    /// Separates the view and diagnostics.
1506    #[must_use]
1507    pub fn into_parts(self) -> (Option<ProjectView>, Vec<Diagnostic>) {
1508        (self.view, self.diagnostics)
1509    }
1510}
1511
1512/// Builds native values directly from a merged project without canonical rendering or reparsing.
1513///
1514/// A matching selection filters inactive services. Omitting it includes every service. The
1515/// operation performs no file, environment, provider, or runtime access.
1516#[must_use]
1517pub fn build_project_view(project: &MergedProject, selection: Option<&ProfileSelection>) -> ProjectViewResult {
1518    if selection.is_some_and(|selection| !selection.belongs_to(project)) {
1519        return ProjectViewResult {
1520            view: None,
1521            diagnostics: vec![Diagnostic::new(
1522                SELECTION_PROJECT_MISMATCH,
1523                Severity::Error,
1524                "profile selection does not belong to the merged project",
1525            )],
1526        };
1527    }
1528
1529    Builder::new(project, selection).build()
1530}
1531
1532struct Builder<'a> {
1533    project: &'a MergedProject,
1534    selection: Option<&'a ProfileSelection>,
1535    diagnostics: Vec<Diagnostic>,
1536    root_unmodeled: Vec<ProjectFieldReference>,
1537    pending_unmodeled: Vec<ProjectFieldReference>,
1538}
1539
1540impl<'a> Builder<'a> {
1541    const fn new(project: &'a MergedProject, selection: Option<&'a ProfileSelection>) -> Self {
1542        Self {
1543            project,
1544            selection,
1545            diagnostics: Vec::new(),
1546            root_unmodeled: Vec::new(),
1547            pending_unmodeled: Vec::new(),
1548        }
1549    }
1550
1551    fn build(mut self) -> ProjectViewResult {
1552        let root = self.project.root();
1553        let entries = root.as_mapping().unwrap_or_default();
1554        let mut name = None;
1555        let mut services = Vec::new();
1556        let mut networks = Vec::new();
1557        let mut volumes = Vec::new();
1558        let mut configs = Vec::new();
1559        let mut secrets = Vec::new();
1560
1561        for entry in entries {
1562            match entry.key() {
1563                "name" => name = self.project_string(entry.value(), "project name"),
1564                "services" => services = self.services(entry.value()),
1565                "networks" => networks = self.network_definitions(entry.value()),
1566                "volumes" => volumes = self.volume_definitions(entry.value()),
1567                "configs" => configs = self.config_definitions(entry.value()),
1568                "secrets" => secrets = self.secret_definitions(entry.value()),
1569                _ => self.record_root_unmodeled(&[], entry),
1570            }
1571        }
1572
1573        ProjectViewResult {
1574            view: Some(ProjectView {
1575                source_ids: self.project.source_ids().to_vec(),
1576                base_directory: self.project.base_directory().to_path_buf(),
1577                provenance: root.provenance().clone(),
1578                name,
1579                services,
1580                networks,
1581                volumes,
1582                configs,
1583                secrets,
1584                unmodeled_fields: self.root_unmodeled,
1585            }),
1586            diagnostics: self.diagnostics,
1587        }
1588    }
1589
1590    fn services(&mut self, value: &MergedValue) -> Vec<ProjectService> {
1591        let Some(entries) = self.mapping(value, "services must be a mapping") else {
1592            return Vec::new();
1593        };
1594        let selection = self.selection;
1595        let mut services = Vec::new();
1596        for entry in entries {
1597            if service_in_scope(selection, entry.key()) {
1598                services.extend(self.service(entry));
1599            }
1600        }
1601        services
1602    }
1603
1604    fn service(&mut self, entry: &MergedEntry) -> Option<ProjectService> {
1605        let pending_start = self.pending_unmodeled.len();
1606        let value = entry.value();
1607        let fields = self.mapping(value, "service definition must be a mapping")?;
1608        let mut service = ProjectService::from_entry(entry);
1609        let path = ["services".to_owned(), entry.key().to_owned()];
1610
1611        for field in fields {
1612            match field.key() {
1613                "hostname" => service.hostname = self.hostname(field.value()),
1614                "container_name" => {
1615                    service.container_name = self.project_string(field.value(), "service container name");
1616                }
1617                "image" => {
1618                    service.image = self
1619                        .project_string(field.value(), "service image")
1620                        .map(|value| ProjectValue {
1621                            value: ImageReference::parse(value.value),
1622                            provenance: value.provenance,
1623                            sensitive: value.sensitive,
1624                        });
1625                }
1626                "entrypoint" => service.entrypoint = self.entrypoint(field.value()),
1627                "command" => service.command = self.command(field.value()),
1628                "init" => {
1629                    service.init = self
1630                        .located_boolean(field.value(), "service init must be a boolean")
1631                        .map(|value| ProjectValue::new(value.into_value(), field.value()));
1632                }
1633                "environment" => service.environment = self.environment(field.value()),
1634                "env_file" => service.environment_files = self.environment_files(field.value(), &path),
1635                "labels" => service.labels = self.service_labels(field.value()),
1636                "annotations" => service.annotations = self.service_annotations(field.value()),
1637                "extra_hosts" => service.extra_hosts = self.extra_hosts(field.value()),
1638                "user" => service.user = self.user(field.value()),
1639                "userns_mode" => service.userns_mode = self.userns_mode(field.value()),
1640                "group_add" => {
1641                    service.group_add = self.string_collection(field.value(), "group_add must be a sequence");
1642                }
1643                "cap_add" => service.cap_add = self.capability_add(field.value()),
1644                "cap_drop" => service.cap_drop = self.capability_drop(field.value()),
1645                "devices" => service.devices = self.devices(field.value(), &path),
1646                "dns" => service.dns = self.dns(field.value()),
1647                "dns_opt" => service.dns_options = self.dns_options(field.value()),
1648                "dns_search" => service.dns_search = self.dns_search(field.value()),
1649                "expose" => service.expose = self.expose(field.value()),
1650                "security_opt" => service.security_options = self.security_options(field.value()),
1651                "working_dir" => service.working_dir = self.project_string(field.value(), "service working directory"),
1652                "read_only" => {
1653                    service.read_only = self
1654                        .located_boolean(field.value(), "service read_only must be a boolean")
1655                        .map(|value| ProjectValue::new(value.into_value(), field.value()));
1656                }
1657                "pids_limit" => service.pids_limit = self.pids_limit(field.value()),
1658                "shm_size" => service.shm_size = self.shm_size(field.value()),
1659                "mem_limit" => service.mem_limit = self.mem_limit(field.value()),
1660                "tmpfs" => service.tmpfs = self.tmpfs(field.value()),
1661                "sysctls" => service.sysctls = self.sysctls(field.value()),
1662                "ulimits" => service.ulimits = self.ulimits(field.value(), &path),
1663                "pull_policy" => service.pull_policy = self.pull_policy(field.value()),
1664                "restart" => service.restart = self.restart_policy(field.value()),
1665                "stop_signal" => {
1666                    service.stop_signal = self.project_string(field.value(), "service stop signal");
1667                }
1668                "stop_grace_period" => {
1669                    service.stop_grace_period = self.stop_grace_period(field.value());
1670                }
1671                "healthcheck" => service.healthcheck = self.healthcheck(field.value(), &path),
1672                "depends_on" => service.depends_on = self.depends_on(field.value(), &path),
1673                "ports" => service.ports = self.ports(field.value(), &path),
1674                "volumes" => service.volumes = self.volumes(field.value(), &path),
1675                "configs" => service.configs = self.grants(field.value(), &path, "config"),
1676                "secrets" => service.secrets = self.grants(field.value(), &path, "secret"),
1677                "networks" => service.networks = self.service_networks(field.value(), &path),
1678                "profiles" => service.profiles = self.string_collection(field.value(), "profiles must be a sequence"),
1679                _ => service.unmodeled_fields.push(field_reference(&path, field)),
1680            }
1681        }
1682        service
1683            .unmodeled_fields
1684            .extend(self.pending_unmodeled.drain(pending_start..));
1685        Some(service)
1686    }
1687
1688    fn hostname(&mut self, value: &MergedValue) -> Option<ProjectValue<Hostname>> {
1689        let scalar = match value.kind() {
1690            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
1691            _ => {
1692                self.expected(value, "hostname must be a YAML string scalar");
1693                return None;
1694            }
1695        };
1696        let hostname = Hostname::parse(Located::new(scalar.value().to_owned(), effective_span(value)));
1697        if hostname.kind() == &HostnameKind::Invalid {
1698            self.invalid(
1699                effective_span(value),
1700                "hostname must be an ASCII RFC-1123 name of 1 to 253 characters with dot-separated labels of 1 to 63 alphanumeric or hyphen characters; each label must start and end alphanumeric",
1701            );
1702        }
1703        Some(ProjectValue::new(hostname, value))
1704    }
1705
1706    fn restart_policy(&mut self, value: &MergedValue) -> Option<ProjectValue<RestartPolicy>> {
1707        let policy = RestartPolicy::parse(self.located_string(value, "restart must be a non-null scalar")?);
1708        if !policy.is_valid() {
1709            self.invalid(
1710                effective_span(value),
1711                "restart must be `no`, `always`, `on-failure[:max-retries]`, or `unless-stopped`",
1712            );
1713        }
1714        Some(ProjectValue::new(policy, value))
1715    }
1716
1717    fn pids_limit(&mut self, value: &MergedValue) -> Option<ProjectValue<PidsLimit>> {
1718        let scalar = match value.kind() {
1719            MergedValueKind::Scalar(scalar) if scalar.kind() != MergedScalarKind::Boolean => scalar,
1720            _ => {
1721                self.expected(value, "pids_limit must be a number or string scalar");
1722                return None;
1723            }
1724        };
1725        let limit = PidsLimit::parse(Located::new(scalar.value().to_owned(), effective_span(value)));
1726        match limit.kind() {
1727            PidsLimitKind::Zero => self.diagnostics.push(
1728                Diagnostic::new(
1729                    PIDS_LIMIT_AMBIGUOUS_ZERO,
1730                    Severity::Warning,
1731                    "pids_limit zero is preserved as an ambiguous and unportable native state",
1732                )
1733                .with_label(DiagnosticLabel::primary(
1734                    effective_span(value),
1735                    "ambiguous zero PID limit",
1736                )),
1737            ),
1738            PidsLimitKind::Other => self.invalid(
1739                effective_span(value),
1740                "pids_limit must be `-1`, a positive integral decimal, or interpolation",
1741            ),
1742            _ => {}
1743        }
1744        Some(ProjectValue::new(limit, value))
1745    }
1746
1747    fn shm_size(&mut self, value: &MergedValue) -> Option<ProjectValue<ShmSize>> {
1748        let scalar = match value.kind() {
1749            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::Number => {
1750                (scalar, ShmSizeScalarKind::Number)
1751            }
1752            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
1753                (scalar, ShmSizeScalarKind::String)
1754            }
1755            _ => {
1756                self.diagnostics.push(
1757                    Diagnostic::new(
1758                        SHM_SIZE_EXPECTED_VALUE,
1759                        Severity::Error,
1760                        "shm_size must be a YAML number or string scalar",
1761                    )
1762                    .with_label(DiagnosticLabel::primary(
1763                        effective_span(value),
1764                        "unexpected shared-memory-size form",
1765                    )),
1766                );
1767                return None;
1768            }
1769        };
1770        let size = ShmSize::parse(
1771            Located::new(scalar.0.value().to_owned(), effective_span(value)),
1772            scalar.1,
1773        );
1774        let (code, message, label, note) = match size.kind() {
1775            ShmSizeKind::Zero { .. } => (
1776                SHM_SIZE_AMBIGUOUS_ZERO,
1777                "shm_size zero is preserved because Compose does not define its semantics",
1778                "ambiguous zero shared-memory size",
1779                "choose a positive size with an explicit documented lowercase unit",
1780            ),
1781            ShmSizeKind::ProviderDependentNumber => (
1782                SHM_SIZE_PROVIDER_DEPENDENT_NUMBER,
1783                "numeric shm_size is schema-accepted but lacks a documented explicit unit",
1784                "provider-dependent numeric shared-memory size",
1785                "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for portable intent",
1786            ),
1787            ShmSizeKind::ProviderDependentString => (
1788                SHM_SIZE_PROVIDER_DEPENDENT_STRING,
1789                "string shm_size is schema-accepted but falls outside the documented lowercase suffix family",
1790                "provider-dependent string shared-memory size",
1791                "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
1792            ),
1793            ShmSizeKind::Documented { .. } | ShmSizeKind::Expression => {
1794                return Some(ProjectValue::new(size, value));
1795            }
1796        };
1797        self.diagnostics.push(
1798            Diagnostic::new(code, Severity::Warning, message)
1799                .with_label(DiagnosticLabel::primary(effective_span(value), label))
1800                .with_note(note),
1801        );
1802        Some(ProjectValue::new(size, value))
1803    }
1804
1805    fn mem_limit(&mut self, value: &MergedValue) -> Option<ProjectValue<MemLimit>> {
1806        let scalar = match value.kind() {
1807            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::Number => {
1808                (scalar, MemLimitScalarKind::Number)
1809            }
1810            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
1811                (scalar, MemLimitScalarKind::String)
1812            }
1813            _ => {
1814                self.diagnostics.push(
1815                    Diagnostic::new(
1816                        MEM_LIMIT_EXPECTED_VALUE,
1817                        Severity::Error,
1818                        "mem_limit must be a YAML number or string scalar",
1819                    )
1820                    .with_label(DiagnosticLabel::primary(
1821                        effective_span(value),
1822                        "unexpected memory-limit form",
1823                    )),
1824                );
1825                return None;
1826            }
1827        };
1828        let limit = MemLimit::parse(
1829            Located::new(scalar.0.value().to_owned(), effective_span(value)),
1830            scalar.1,
1831        );
1832        let (code, message, label, note) = match limit.kind() {
1833            MemLimitKind::Zero { .. } => (
1834                MEM_LIMIT_AMBIGUOUS_ZERO,
1835                "mem_limit zero is preserved without inferring portable runtime behavior",
1836                "ambiguous zero memory limit",
1837                "choose a positive size with an explicit documented lowercase unit",
1838            ),
1839            MemLimitKind::SchemaNumber => (
1840                MEM_LIMIT_SCHEMA_NUMBER,
1841                "numeric mem_limit is schema-accepted but lacks a documented explicit unit",
1842                "schema-only numeric memory limit",
1843                "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for explicit intent",
1844            ),
1845            MemLimitKind::ProviderDependentString => (
1846                MEM_LIMIT_PROVIDER_DEPENDENT_STRING,
1847                "string mem_limit is schema-accepted but falls outside the documented lowercase suffix family",
1848                "provider-dependent string memory limit",
1849                "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
1850            ),
1851            MemLimitKind::Documented { .. } | MemLimitKind::Expression => {
1852                return Some(ProjectValue::new(limit, value));
1853            }
1854        };
1855        self.diagnostics.push(
1856            Diagnostic::new(code, Severity::Warning, message)
1857                .with_label(DiagnosticLabel::primary(effective_span(value), label))
1858                .with_note(note),
1859        );
1860        Some(ProjectValue::new(limit, value))
1861    }
1862
1863    fn dns(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectDns>> {
1864        let form = match value.kind() {
1865            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
1866                ProjectDns::Scalar(ProjectValue::new(scalar.value().to_owned(), value))
1867            }
1868            MergedValueKind::Sequence(values) => {
1869                let mut items = Vec::new();
1870                for item_value in values {
1871                    let MergedValueKind::Scalar(scalar) = item_value.kind() else {
1872                        self.diagnostics.push(
1873                            Diagnostic::new(
1874                                DNS_EXPECTED_STRING,
1875                                Severity::Error,
1876                                "dns entries must be string scalars",
1877                            )
1878                            .with_label(DiagnosticLabel::primary(
1879                                effective_span(item_value),
1880                                "unexpected DNS server list item",
1881                            )),
1882                        );
1883                        continue;
1884                    };
1885                    if scalar.kind() != MergedScalarKind::String {
1886                        self.diagnostics.push(
1887                            Diagnostic::new(
1888                                DNS_EXPECTED_STRING,
1889                                Severity::Error,
1890                                "dns entries must be string scalars",
1891                            )
1892                            .with_label(DiagnosticLabel::primary(
1893                                effective_span(item_value),
1894                                "unexpected DNS server list item",
1895                            )),
1896                        );
1897                        continue;
1898                    }
1899                    items.push(ProjectValue::new(scalar.value().to_owned(), item_value));
1900                }
1901                ProjectDns::List(items)
1902            }
1903            _ => {
1904                self.diagnostics.push(
1905                    Diagnostic::new(
1906                        DNS_EXPECTED_FORM,
1907                        Severity::Error,
1908                        "dns must be a string scalar or a sequence of string scalars",
1909                    )
1910                    .with_label(DiagnosticLabel::primary(
1911                        effective_span(value),
1912                        "unexpected service DNS form",
1913                    )),
1914                );
1915                return None;
1916            }
1917        };
1918        Some(ProjectValue::new(form, value))
1919    }
1920
1921    fn dns_options(&mut self, value: &MergedValue) -> Option<ProjectValue<Vec<ProjectValue<String>>>> {
1922        let MergedValueKind::Sequence(values) = value.kind() else {
1923            self.diagnostics.push(
1924                Diagnostic::new(
1925                    DNS_OPT_EXPECTED_SEQUENCE,
1926                    Severity::Error,
1927                    "dns_opt must be a sequence of string scalars",
1928                )
1929                .with_label(DiagnosticLabel::primary(
1930                    effective_span(value),
1931                    "unexpected service DNS option form",
1932                )),
1933            );
1934            return None;
1935        };
1936        let mut items = Vec::new();
1937        let mut seen = BTreeSet::new();
1938        for item_value in values {
1939            let MergedValueKind::Scalar(scalar) = item_value.kind() else {
1940                self.diagnostics.push(
1941                    Diagnostic::new(
1942                        DNS_OPT_EXPECTED_STRING,
1943                        Severity::Error,
1944                        "dns_opt entries must be string scalars",
1945                    )
1946                    .with_label(DiagnosticLabel::primary(
1947                        effective_span(item_value),
1948                        "unexpected DNS option list item",
1949                    )),
1950                );
1951                continue;
1952            };
1953            if scalar.kind() != MergedScalarKind::String {
1954                self.diagnostics.push(
1955                    Diagnostic::new(
1956                        DNS_OPT_EXPECTED_STRING,
1957                        Severity::Error,
1958                        "dns_opt entries must be string scalars",
1959                    )
1960                    .with_label(DiagnosticLabel::primary(
1961                        effective_span(item_value),
1962                        "unexpected DNS option list item",
1963                    )),
1964                );
1965                continue;
1966            }
1967            if !seen.insert(scalar.value().to_owned()) {
1968                self.diagnostics.push(
1969                    Diagnostic::new(
1970                        DNS_OPT_DUPLICATE_ITEM,
1971                        Severity::Warning,
1972                        "dns_opt entries must be unique exact strings",
1973                    )
1974                    .with_label(DiagnosticLabel::primary(
1975                        effective_span(item_value),
1976                        "duplicate DNS option retained",
1977                    )),
1978                );
1979            }
1980            items.push(ProjectValue::new(scalar.value().to_owned(), item_value));
1981        }
1982        Some(ProjectValue::new(items, value))
1983    }
1984
1985    fn dns_search(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectDnsSearch>> {
1986        let form = match value.kind() {
1987            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
1988                ProjectDnsSearch::Scalar(ProjectValue::new(scalar.value().to_owned(), value))
1989            }
1990            MergedValueKind::Sequence(values) => {
1991                let mut items = Vec::new();
1992                let mut seen = BTreeSet::new();
1993                for item_value in values {
1994                    let MergedValueKind::Scalar(scalar) = item_value.kind() else {
1995                        self.diagnostics.push(
1996                            Diagnostic::new(
1997                                DNS_SEARCH_EXPECTED_STRING,
1998                                Severity::Error,
1999                                "dns_search entries must be string scalars",
2000                            )
2001                            .with_label(DiagnosticLabel::primary(
2002                                effective_span(item_value),
2003                                "unexpected DNS search-domain list item",
2004                            )),
2005                        );
2006                        continue;
2007                    };
2008                    if scalar.kind() != MergedScalarKind::String {
2009                        self.diagnostics.push(
2010                            Diagnostic::new(
2011                                DNS_SEARCH_EXPECTED_STRING,
2012                                Severity::Error,
2013                                "dns_search entries must be string scalars",
2014                            )
2015                            .with_label(DiagnosticLabel::primary(
2016                                effective_span(item_value),
2017                                "unexpected DNS search-domain list item",
2018                            )),
2019                        );
2020                        continue;
2021                    }
2022                    if !seen.insert(scalar.value().to_owned()) {
2023                        self.diagnostics.push(
2024                            Diagnostic::new(
2025                                DNS_SEARCH_DUPLICATE_ITEM,
2026                                Severity::Warning,
2027                                "dns_search schema entries are unique, but duplicate merge behavior is ambiguous",
2028                            )
2029                            .with_label(DiagnosticLabel::primary(
2030                                effective_span(item_value),
2031                                "duplicate DNS search domain retained",
2032                            )),
2033                        );
2034                    }
2035                    items.push(ProjectValue::new(scalar.value().to_owned(), item_value));
2036                }
2037                ProjectDnsSearch::List(items)
2038            }
2039            _ => {
2040                self.diagnostics.push(
2041                    Diagnostic::new(
2042                        DNS_SEARCH_EXPECTED_FORM,
2043                        Severity::Error,
2044                        "dns_search must be a string scalar or a sequence of string scalars",
2045                    )
2046                    .with_label(DiagnosticLabel::primary(
2047                        effective_span(value),
2048                        "unexpected service DNS search-domain form",
2049                    )),
2050                );
2051                return None;
2052            }
2053        };
2054        Some(ProjectValue::new(form, value))
2055    }
2056
2057    fn expose(&mut self, value: &MergedValue) -> Option<ProjectValue<Vec<ProjectValue<ProjectExposeItem>>>> {
2058        let MergedValueKind::Sequence(values) = value.kind() else {
2059            self.diagnostics.push(
2060                Diagnostic::new(
2061                    EXPOSE_EXPECTED_SEQUENCE,
2062                    Severity::Error,
2063                    "expose must be a sequence of string or number scalars",
2064                )
2065                .with_label(DiagnosticLabel::primary(
2066                    effective_span(value),
2067                    "unexpected service expose form",
2068                )),
2069            );
2070            return None;
2071        };
2072        let mut items = Vec::new();
2073        let mut seen = Vec::new();
2074        for item_value in values {
2075            let MergedValueKind::Scalar(scalar) = item_value.kind() else {
2076                self.diagnostics.push(
2077                    Diagnostic::new(
2078                        EXPOSE_EXPECTED_SCALAR,
2079                        Severity::Error,
2080                        "expose entries must be string or number scalars",
2081                    )
2082                    .with_label(DiagnosticLabel::primary(
2083                        effective_span(item_value),
2084                        "unexpected exposed-port item",
2085                    )),
2086                );
2087                continue;
2088            };
2089            let scalar_kind = match scalar.kind() {
2090                MergedScalarKind::String => ExposeScalarKind::String,
2091                MergedScalarKind::Number => ExposeScalarKind::Number,
2092                MergedScalarKind::Boolean => {
2093                    self.diagnostics.push(
2094                        Diagnostic::new(
2095                            EXPOSE_EXPECTED_SCALAR,
2096                            Severity::Error,
2097                            "expose entries must be string or number scalars",
2098                        )
2099                        .with_label(DiagnosticLabel::primary(
2100                            effective_span(item_value),
2101                            "unexpected exposed-port item",
2102                        )),
2103                    );
2104                    continue;
2105                }
2106            };
2107            if seen.contains(&(scalar_kind, scalar.value().to_owned())) {
2108                self.diagnostics.push(
2109                    Diagnostic::new(
2110                        EXPOSE_DUPLICATE_ITEM,
2111                        Severity::Warning,
2112                        "expose entries must be unique by exact scalar identity",
2113                    )
2114                    .with_label(DiagnosticLabel::primary(
2115                        effective_span(item_value),
2116                        "duplicate exposed-port item retained",
2117                    )),
2118                );
2119            } else {
2120                seen.push((scalar_kind, scalar.value().to_owned()));
2121            }
2122            let kind = classify_expose_item(scalar.value(), scalar_kind);
2123            self.diagnose_expose_item(&kind, item_value);
2124            items.push(ProjectValue::new(
2125                ProjectExposeItem {
2126                    authored: scalar.raw().to_owned(),
2127                    value: scalar.value().to_owned(),
2128                    scalar_kind,
2129                    kind,
2130                },
2131                item_value,
2132            ));
2133        }
2134        Some(ProjectValue::new(items, value))
2135    }
2136
2137    fn diagnose_expose_item(&mut self, kind: &ExposeItemKind, value: &MergedValue) {
2138        let (code, severity, message, label) = match kind {
2139            ExposeItemKind::Documented { .. } | ExposeItemKind::Expression => return,
2140            ExposeItemKind::Sctp { .. } | ExposeItemKind::UnknownProtocol { .. } => (
2141                EXPOSE_PROVIDER_DEPENDENT,
2142                Severity::Warning,
2143                "expose protocol is outside the documented portable `tcp` and `udp` set",
2144                "provider-dependent exposed-port protocol retained",
2145            ),
2146            ExposeItemKind::Malformed => (
2147                EXPOSE_INVALID_ITEM,
2148                Severity::Error,
2149                "expose item must be a decimal port or range with an optional protocol",
2150                "malformed exposed-port item retained",
2151            ),
2152        };
2153        self.diagnostics.push(
2154            Diagnostic::new(code, severity, message).with_label(DiagnosticLabel::primary(effective_span(value), label)),
2155        );
2156    }
2157
2158    fn security_options(
2159        &mut self,
2160        value: &MergedValue,
2161    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectSecurityOptionItem>>>> {
2162        let MergedValueKind::Sequence(values) = value.kind() else {
2163            self.diagnostics.push(
2164                Diagnostic::new(
2165                    SECURITY_OPT_EXPECTED_SEQUENCE,
2166                    Severity::Error,
2167                    "security_opt must be a sequence of string scalars",
2168                )
2169                .with_label(DiagnosticLabel::primary(
2170                    effective_span(value),
2171                    "unexpected service security-option form",
2172                )),
2173            );
2174            return None;
2175        };
2176        let mut items = Vec::new();
2177        let mut candidates = SecurityOptionCandidateCounts::default();
2178        for item_value in values {
2179            let MergedValueKind::Scalar(scalar) = item_value.kind() else {
2180                self.diagnostics.push(
2181                    Diagnostic::new(
2182                        SECURITY_OPT_EXPECTED_STRING,
2183                        Severity::Error,
2184                        "security_opt entries must be string scalars",
2185                    )
2186                    .with_label(DiagnosticLabel::primary(
2187                        effective_span(item_value),
2188                        "unexpected security-option item retained in source evidence",
2189                    )),
2190                );
2191                continue;
2192            };
2193            if scalar.kind() != MergedScalarKind::String {
2194                self.diagnostics.push(
2195                    Diagnostic::new(
2196                        SECURITY_OPT_EXPECTED_STRING,
2197                        Severity::Error,
2198                        "security_opt entries must be string scalars",
2199                    )
2200                    .with_label(DiagnosticLabel::primary(
2201                        effective_span(item_value),
2202                        "unexpected security-option scalar kind retained in source evidence",
2203                    )),
2204                );
2205                continue;
2206            }
2207            let kind = classify_security_option(scalar.value());
2208            self.diagnose_security_option_item(&kind, effective_span(item_value), &mut candidates);
2209            items.push(ProjectValue::new(
2210                ProjectSecurityOptionItem {
2211                    authored: scalar.raw().to_owned(),
2212                    value: scalar.value().to_owned(),
2213                    scalar_kind: scalar.kind(),
2214                    kind,
2215                },
2216                item_value,
2217            ));
2218        }
2219        Some(ProjectValue::new(items, value))
2220    }
2221
2222    fn diagnose_security_option_item(
2223        &mut self,
2224        kind: &SecurityOptionKind,
2225        span: SourceSpan,
2226        candidates: &mut SecurityOptionCandidateCounts,
2227    ) {
2228        let diagnostic = match kind {
2229            SecurityOptionKind::AppArmor { .. } => {
2230                candidates.apparmor += 1;
2231                (candidates.apparmor > 1).then(|| {
2232                    Diagnostic::new(
2233                        SECURITY_OPT_APPARMOR_CONFLICT,
2234                        Severity::Warning,
2235                        "multiple AppArmor candidates are retained; a consumer must resolve the conflict explicitly",
2236                    )
2237                    .with_label(DiagnosticLabel::primary(
2238                        span,
2239                        "additional effective AppArmor candidate retained",
2240                    ))
2241                })
2242            }
2243            SecurityOptionKind::AppArmorNearMiss => Some(
2244                Diagnostic::new(
2245                    SECURITY_OPT_APPARMOR_NEAR_MISS,
2246                    Severity::Warning,
2247                    "AppArmor candidates require exact lowercase `apparmor=<profile>` spelling without whitespace",
2248                )
2249                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2250            ),
2251            SecurityOptionKind::Seccomp { .. } => {
2252                candidates.seccomp += 1;
2253                (candidates.seccomp > 1).then(|| {
2254                    Diagnostic::new(
2255                        SECURITY_OPT_SECCOMP_CONFLICT,
2256                        Severity::Warning,
2257                        "multiple seccomp candidates are retained; a consumer must resolve the conflict explicitly",
2258                    )
2259                    .with_label(DiagnosticLabel::primary(
2260                        span,
2261                        "additional effective seccomp candidate retained",
2262                    ))
2263                })
2264            }
2265            SecurityOptionKind::SeccompNearMiss => Some(
2266                Diagnostic::new(
2267                    SECURITY_OPT_SECCOMP_NEAR_MISS,
2268                    Severity::Warning,
2269                    "seccomp candidates require exact lowercase `seccomp=<profile>` spelling without whitespace",
2270                )
2271                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2272            ),
2273            SecurityOptionKind::NoNewPrivileges { .. } => {
2274                candidates.no_new_privileges += 1;
2275                (candidates.no_new_privileges > 1).then(|| {
2276                    Diagnostic::new(
2277                        SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT,
2278                        Severity::Warning,
2279                        "multiple no-new-privileges candidates are retained; a consumer must resolve the conflict explicitly",
2280                    )
2281                    .with_label(DiagnosticLabel::primary(
2282                        span,
2283                        "additional effective no-new-privileges candidate retained",
2284                    ))
2285                })
2286            }
2287            SecurityOptionKind::NoNewPrivilegesNearMiss => Some(
2288                Diagnostic::new(
2289                    SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS,
2290                    Severity::Warning,
2291                    "no-new-privileges candidates require exact lowercase `no-new-privileges:true` or `no-new-privileges:false` spelling without whitespace",
2292                )
2293                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2294            ),
2295            SecurityOptionKind::Mask { .. }
2296            | SecurityOptionKind::MaskNearMiss
2297            | SecurityOptionKind::Unmask { .. }
2298            | SecurityOptionKind::UnmaskNearMiss => security_path_option_diagnostic(kind, span),
2299            SecurityOptionKind::SecurityLabelDisable { .. }
2300            | SecurityOptionKind::SecurityLabelDisableNearMiss
2301            | SecurityOptionKind::SecurityLabelFileType { .. }
2302            | SecurityOptionKind::SecurityLabelFileTypeNearMiss
2303            | SecurityOptionKind::SecurityLabelLevel { .. }
2304            | SecurityOptionKind::SecurityLabelLevelNearMiss
2305            | SecurityOptionKind::SecurityLabelNested { .. }
2306            | SecurityOptionKind::SecurityLabelNestedNearMiss
2307            | SecurityOptionKind::SecurityLabelType { .. }
2308            | SecurityOptionKind::SecurityLabelTypeNearMiss => {
2309                effective_security_label_diagnostic(kind, span, candidates)
2310            }
2311            SecurityOptionKind::Empty => Some(
2312                Diagnostic::new(
2313                    SECURITY_OPT_EMPTY_ITEM,
2314                    Severity::Error,
2315                    "security_opt entries must not be empty strings",
2316                )
2317                .with_label(DiagnosticLabel::primary(span, "empty security option retained")),
2318            ),
2319            SecurityOptionKind::Expression | SecurityOptionKind::Other => None,
2320        };
2321        if let Some(diagnostic) = diagnostic {
2322            self.diagnostics.push(diagnostic);
2323        }
2324    }
2325
2326    fn tmpfs(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectTmpfs>> {
2327        let form = match value.kind() {
2328            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
2329                let item = self.tmpfs_item(value, scalar.value());
2330                ProjectTmpfs::Scalar(ProjectValue::new(item, value))
2331            }
2332            MergedValueKind::Sequence(values) => {
2333                let mut items = Vec::new();
2334                for item_value in values {
2335                    let MergedValueKind::Scalar(scalar) = item_value.kind() else {
2336                        self.diagnostics.push(
2337                            Diagnostic::new(
2338                                TMPFS_EXPECTED_STRING,
2339                                Severity::Error,
2340                                "tmpfs entries must be string scalars",
2341                            )
2342                            .with_label(DiagnosticLabel::primary(
2343                                effective_span(item_value),
2344                                "unexpected temporary-filesystem list item",
2345                            )),
2346                        );
2347                        continue;
2348                    };
2349                    if scalar.kind() != MergedScalarKind::String {
2350                        self.diagnostics.push(
2351                            Diagnostic::new(
2352                                TMPFS_EXPECTED_STRING,
2353                                Severity::Error,
2354                                "tmpfs entries must be string scalars",
2355                            )
2356                            .with_label(DiagnosticLabel::primary(
2357                                effective_span(item_value),
2358                                "unexpected temporary-filesystem list item",
2359                            )),
2360                        );
2361                        continue;
2362                    }
2363                    let item = self.tmpfs_item(item_value, scalar.value());
2364                    items.push(ProjectValue::new(item, item_value));
2365                }
2366                ProjectTmpfs::List(items)
2367            }
2368            _ => {
2369                self.diagnostics.push(
2370                    Diagnostic::new(
2371                        TMPFS_EXPECTED_FORM,
2372                        Severity::Error,
2373                        "tmpfs must be a string scalar or a sequence of string scalars",
2374                    )
2375                    .with_label(DiagnosticLabel::primary(
2376                        effective_span(value),
2377                        "unexpected service-level temporary-filesystem form",
2378                    )),
2379                );
2380                return None;
2381            }
2382        };
2383        Some(ProjectValue::new(form, value))
2384    }
2385
2386    fn tmpfs_item(&mut self, source: &MergedValue, raw: &str) -> TmpfsItem {
2387        let item = TmpfsItem::parse(Located::new(raw.to_owned(), effective_span(source)));
2388        if item.kind() == TmpfsItemKind::ProviderDependent {
2389            self.diagnostics.push(
2390                Diagnostic::new(
2391                    TMPFS_PROVIDER_DEPENDENT,
2392                    Severity::Warning,
2393                    "tmpfs item is malformed or uses provider- or target-specific options",
2394                )
2395                .with_label(DiagnosticLabel::primary(
2396                    effective_span(source),
2397                    "provider-dependent temporary-filesystem item",
2398                ))
2399                .with_note("use a non-empty path with only non-empty `mode`, `uid`, or `gid` assignments for documented portable syntax"),
2400            );
2401        }
2402        item
2403    }
2404
2405    fn sysctls(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectSysctls>> {
2406        let form = match value.kind() {
2407            MergedValueKind::Mapping(entries) => ProjectSysctls::Map(self.sysctls_map(entries)),
2408            MergedValueKind::Sequence(items) => ProjectSysctls::List(self.sysctls_list(items)),
2409            _ => {
2410                self.diagnostics.push(
2411                    Diagnostic::new(
2412                        SYSCTLS_EXPECTED_FORM,
2413                        Severity::Error,
2414                        "sysctls must be a mapping or a sequence of string scalars",
2415                    )
2416                    .with_label(DiagnosticLabel::primary(
2417                        effective_span(value),
2418                        "unexpected service sysctls form",
2419                    )),
2420                );
2421                return None;
2422            }
2423        };
2424        Some(ProjectValue::new(form, value))
2425    }
2426
2427    fn sysctls_map(&mut self, entries: &[MergedEntry]) -> Vec<ProjectValue<ProjectSysctl>> {
2428        let mut sysctls = Vec::new();
2429        for entry in entries {
2430            if entry.key().is_empty() {
2431                self.diagnostics.push(
2432                    Diagnostic::new(
2433                        SYSCTLS_EMPTY_KEY,
2434                        Severity::Error,
2435                        "sysctls mapping keys must not be empty",
2436                    )
2437                    .with_label(DiagnosticLabel::primary(entry_span(entry), "empty sysctl name")),
2438                );
2439                continue;
2440            }
2441            let Some(scalar) = self.sysctl_scalar(entry.value()) else {
2442                continue;
2443            };
2444            let sysctl = ProjectSysctl {
2445                name: ProjectKey::from_entry(entry),
2446                value: ProjectValue::new(scalar, entry.value()),
2447            };
2448            sysctls.push(ProjectValue::new(sysctl, entry.value()));
2449        }
2450        sysctls
2451    }
2452
2453    fn sysctl_scalar(&mut self, value: &MergedValue) -> Option<ComposeScalar> {
2454        match value.kind() {
2455            MergedValueKind::Null(_) => Some(ComposeScalar::Null),
2456            MergedValueKind::Scalar(scalar) => Some(match scalar.kind() {
2457                MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
2458                MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
2459                MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
2460            }),
2461            _ => {
2462                self.diagnostics.push(
2463                    Diagnostic::new(
2464                        SYSCTLS_EXPECTED_SCALAR,
2465                        Severity::Error,
2466                        "sysctls mapping values must be scalar strings, numbers, booleans, or null",
2467                    )
2468                    .with_label(DiagnosticLabel::primary(
2469                        effective_span(value),
2470                        "non-scalar sysctl value",
2471                    )),
2472                );
2473                None
2474            }
2475        }
2476    }
2477
2478    fn sysctls_list(&mut self, items: &[MergedValue]) -> Vec<ProjectValue<String>> {
2479        let mut sysctls = Vec::new();
2480        let mut seen = BTreeMap::new();
2481        for item in items {
2482            let MergedValueKind::Scalar(scalar) = item.kind() else {
2483                self.invalid_sysctl_list_item(item);
2484                continue;
2485            };
2486            if scalar.kind() != MergedScalarKind::String {
2487                self.invalid_sysctl_list_item(item);
2488                continue;
2489            }
2490            let span = effective_span(item);
2491            if let Some(first) = seen.get(scalar.value()) {
2492                self.diagnostics.push(
2493                    Diagnostic::new(
2494                        SYSCTLS_DUPLICATE_ITEM,
2495                        Severity::Error,
2496                        "effective sysctls list entries must be unique exact strings",
2497                    )
2498                    .with_label(DiagnosticLabel::primary(span, "duplicate sysctl string"))
2499                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
2500                );
2501            } else {
2502                seen.insert(scalar.value().to_owned(), span);
2503            }
2504            sysctls.push(ProjectValue::new(scalar.value().to_owned(), item));
2505        }
2506        sysctls
2507    }
2508
2509    fn invalid_sysctl_list_item(&mut self, value: &MergedValue) {
2510        self.diagnostics.push(
2511            Diagnostic::new(
2512                SYSCTLS_EXPECTED_STRING,
2513                Severity::Error,
2514                "sysctls list entries must be YAML string scalars",
2515            )
2516            .with_label(DiagnosticLabel::primary(
2517                effective_span(value),
2518                "non-string sysctl list item",
2519            )),
2520        );
2521    }
2522
2523    fn ulimits(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<ProjectUlimits>> {
2524        let Some(entries) = value.as_mapping() else {
2525            self.expected(value, "ulimits must be a mapping");
2526            return None;
2527        };
2528        let mut limits = Vec::new();
2529        let mut path = service_path.to_vec();
2530        path.push("ulimits".to_owned());
2531        for entry in entries {
2532            if !valid_ulimit_name(entry.key()) {
2533                self.diagnostics.push(
2534                    Diagnostic::new(
2535                        ULIMIT_INVALID_NAME,
2536                        Severity::Error,
2537                        "ulimit names must contain only lowercase ASCII letters",
2538                    )
2539                    .with_label(DiagnosticLabel::primary(entry_span(entry), "invalid ulimit name")),
2540                );
2541                self.record_pending_unmodeled(&path, entry);
2542                continue;
2543            }
2544            let Some(limit) = self.ulimit(entry, &path) else {
2545                self.record_pending_unmodeled(&path, entry);
2546                continue;
2547            };
2548            limits.push(ProjectValue::new(limit, entry.value()));
2549        }
2550        Some(ProjectValue::new(ProjectUlimits { entries: limits }, value))
2551    }
2552
2553    fn ulimit(&mut self, entry: &MergedEntry, parent_path: &[String]) -> Option<ProjectUlimit> {
2554        let value = match entry.value().kind() {
2555            MergedValueKind::Scalar(_) => ProjectUlimitValue::Single(
2556                self.ulimit_scalar(entry.value())
2557                    .map(|scalar| ProjectValue::new(scalar, entry.value()))?,
2558            ),
2559            MergedValueKind::Mapping(fields) => {
2560                let mut soft = None;
2561                let mut hard = None;
2562                let mut unmodeled_fields = Vec::new();
2563                let mut range_path = parent_path.to_vec();
2564                range_path.push(entry.key().to_owned());
2565                for field in fields {
2566                    match field.key() {
2567                        "soft" => {
2568                            soft = self
2569                                .ulimit_scalar(field.value())
2570                                .map(|scalar| ProjectValue::new(scalar, field.value()));
2571                        }
2572                        "hard" => {
2573                            hard = self
2574                                .ulimit_scalar(field.value())
2575                                .map(|scalar| ProjectValue::new(scalar, field.value()));
2576                        }
2577                        _ => unmodeled_fields.push(field_reference(&range_path, field)),
2578                    }
2579                }
2580                if soft.is_none() {
2581                    self.diagnostics.push(
2582                        Diagnostic::new(
2583                            ULIMIT_MISSING_RANGE_MEMBER,
2584                            Severity::Error,
2585                            "ulimit range is missing required `soft`",
2586                        )
2587                        .with_label(DiagnosticLabel::primary(
2588                            effective_span(entry.value()),
2589                            "missing soft limit",
2590                        )),
2591                    );
2592                }
2593                if hard.is_none() {
2594                    self.diagnostics.push(
2595                        Diagnostic::new(
2596                            ULIMIT_MISSING_RANGE_MEMBER,
2597                            Severity::Error,
2598                            "ulimit range is missing required `hard`",
2599                        )
2600                        .with_label(DiagnosticLabel::primary(
2601                            effective_span(entry.value()),
2602                            "missing hard limit",
2603                        )),
2604                    );
2605                }
2606                ProjectUlimitValue::Range(ProjectUlimitRange {
2607                    soft,
2608                    hard,
2609                    unmodeled_fields,
2610                })
2611            }
2612            _ => {
2613                self.expected(
2614                    entry.value(),
2615                    "ulimit must be a number/string scalar or a soft/hard mapping",
2616                );
2617                return None;
2618            }
2619        };
2620        Some(ProjectUlimit {
2621            name: ProjectKey::from_entry(entry),
2622            value,
2623        })
2624    }
2625
2626    fn ulimit_scalar(&mut self, value: &MergedValue) -> Option<ProjectUlimitScalar> {
2627        let Some(scalar) = value.as_scalar() else {
2628            self.expected(value, "ulimit values must be number or string scalars");
2629            return None;
2630        };
2631        if !matches!(scalar.kind(), MergedScalarKind::String | MergedScalarKind::Number) {
2632            self.diagnostics.push(
2633                Diagnostic::new(
2634                    ULIMIT_INVALID_VALUE,
2635                    Severity::Error,
2636                    "ulimit values must be number or string scalars",
2637                )
2638                .with_label(DiagnosticLabel::primary(
2639                    effective_span(value),
2640                    "invalid ulimit scalar kind",
2641                )),
2642            );
2643            return None;
2644        }
2645        let parsed = LimitValue::parse(scalar.value().to_owned());
2646        if !parsed.is_valid() {
2647            self.diagnostics.push(
2648                Diagnostic::new(
2649                    ULIMIT_INVALID_VALUE,
2650                    Severity::Error,
2651                    "ulimit must be -1, a non-negative integer, or an interpolation expression",
2652                )
2653                .with_label(DiagnosticLabel::primary(effective_span(value), "invalid ulimit value")),
2654            );
2655        }
2656        Some(ProjectUlimitScalar {
2657            authored: scalar.raw().to_owned(),
2658            value: parsed,
2659            kind: scalar.kind(),
2660        })
2661    }
2662
2663    fn pull_policy(&mut self, value: &MergedValue) -> Option<ProjectValue<PullPolicy>> {
2664        let policy = PullPolicy::parse(self.located_string(value, "pull_policy must be a non-null scalar")?);
2665        if !policy.is_recognized() {
2666            self.invalid(
2667                effective_span(value),
2668                "pull_policy must be a documented Compose policy, the retained `if_not_present` alias, schema-only `refresh`, an `every_` interval matching integer `w`, `d`, `h`, `m`, and `s` components, or interpolation",
2669            );
2670        }
2671        Some(ProjectValue::new(policy, value))
2672    }
2673
2674    fn stop_grace_period(&mut self, value: &MergedValue) -> Option<ProjectValue<StopGracePeriod>> {
2675        let scalar = self.scalar(value, "stop_grace_period must be a non-null scalar")?;
2676        let period = StopGracePeriod::parse(scalar.value().to_owned());
2677        if !period.is_valid() {
2678            self.invalid(
2679                effective_span(value),
2680                "stop_grace_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
2681            );
2682        }
2683        Some(ProjectValue::new(period, value))
2684    }
2685
2686    fn command(&mut self, value: &MergedValue) -> Option<ProjectValue<Command>> {
2687        let span = effective_span(value);
2688        let command = match value.kind() {
2689            MergedValueKind::Null(_) => Command::Null(span),
2690            MergedValueKind::Scalar(scalar) => Command::String(Located::new(scalar.value().to_owned(), span)),
2691            MergedValueKind::Sequence(values) => {
2692                let mut arguments = Vec::new();
2693                for value in values {
2694                    arguments.push(self.located_string(value, "command list item must be a scalar")?);
2695                }
2696                Command::List {
2697                    span,
2698                    values: arguments,
2699                }
2700            }
2701            _ => {
2702                self.expected(value, "command must be null, a scalar, or a sequence");
2703                return None;
2704            }
2705        };
2706        Some(ProjectValue::new(command, value))
2707    }
2708
2709    fn entrypoint(&mut self, value: &MergedValue) -> Option<ProjectValue<Entrypoint>> {
2710        let span = effective_span(value);
2711        let entrypoint = match value.kind() {
2712            MergedValueKind::Null(_) => Entrypoint::Null(span),
2713            MergedValueKind::Scalar(scalar) => Entrypoint::String(Located::new(scalar.value().to_owned(), span)),
2714            MergedValueKind::Sequence(values) => {
2715                let mut arguments = Vec::new();
2716                for value in values {
2717                    arguments.push(self.located_string(value, "entrypoint list item must be a scalar")?);
2718                }
2719                Entrypoint::List {
2720                    span,
2721                    values: arguments,
2722                }
2723            }
2724            _ => {
2725                self.expected(value, "entrypoint must be null, a scalar, or a sequence");
2726                return None;
2727            }
2728        };
2729        Some(ProjectValue::new(entrypoint, value))
2730    }
2731
2732    fn user(&mut self, value: &MergedValue) -> Option<ProjectValue<UserSpec>> {
2733        let raw = self.project_string(value, "service user")?;
2734        Some(ProjectValue {
2735            value: UserSpec::parse(Located::new(raw.value, effective_span(value))),
2736            provenance: raw.provenance,
2737            sensitive: raw.sensitive,
2738        })
2739    }
2740
2741    fn userns_mode(&mut self, value: &MergedValue) -> Option<ProjectValue<UserNamespaceMode>> {
2742        let raw = self.project_string(value, "service user namespace mode")?;
2743        Some(ProjectValue {
2744            value: UserNamespaceMode::parse(Located::new(raw.value, effective_span(value))),
2745            provenance: raw.provenance,
2746            sensitive: raw.sensitive,
2747        })
2748    }
2749
2750    fn environment(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectEnvironment>> {
2751        let mut entries = Vec::new();
2752        match value.kind() {
2753            MergedValueKind::Mapping(values) => {
2754                for entry in values {
2755                    let scalar = self.compose_scalar(entry.value(), "environment value must be a scalar or null")?;
2756                    entries.push(ProjectEnvironmentEntry {
2757                        name: ProjectKey::from_entry(entry),
2758                        value: ProjectValue::new(scalar, entry.value()),
2759                        syntax: entry.syntax(),
2760                    });
2761                }
2762            }
2763            MergedValueKind::Sequence(values) => {
2764                for item in values {
2765                    let raw = self.located_string(item, "environment list item must be a scalar")?;
2766                    let (name, scalar, syntax) = raw.value().split_once('=').map_or_else(
2767                        || (raw.value().clone(), ComposeScalar::Null, EntrySyntax::ListKeyOnly),
2768                        |(name, value)| {
2769                            (
2770                                name.to_owned(),
2771                                ComposeScalar::String(value.to_owned()),
2772                                EntrySyntax::ListKeyValue,
2773                            )
2774                        },
2775                    );
2776                    entries.push(ProjectEnvironmentEntry {
2777                        name: ProjectKey {
2778                            value: name,
2779                            sources: item.provenance().sources().to_vec(),
2780                            sensitive: item.is_sensitive(),
2781                        },
2782                        value: ProjectValue::new(scalar, item),
2783                        syntax,
2784                    });
2785                }
2786            }
2787            _ => {
2788                self.expected(value, "environment must be a mapping or sequence");
2789                return None;
2790            }
2791        }
2792        Some(ProjectValue::new(ProjectEnvironment { entries }, value))
2793    }
2794
2795    fn environment_files(
2796        &mut self,
2797        value: &MergedValue,
2798        service_path: &[String],
2799    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectEnvironmentFile>>>> {
2800        let values = match value.kind() {
2801            MergedValueKind::Scalar(_) => std::slice::from_ref(value),
2802            MergedValueKind::Sequence(values) => values,
2803            _ => {
2804                self.expected(
2805                    value,
2806                    "env_file must be a scalar path or sequence of short/long entries",
2807                );
2808                return None;
2809            }
2810        };
2811        let mut environment_files = Vec::new();
2812        for (index, item) in values.iter().enumerate() {
2813            let mut path = service_path.to_vec();
2814            path.push("env_file".to_owned());
2815            path.push(index.to_string());
2816            let environment_file = match item.kind() {
2817                MergedValueKind::Scalar(scalar) => ProjectEnvironmentFile::Short(scalar.value().to_owned()),
2818                MergedValueKind::Mapping(fields) => {
2819                    ProjectEnvironmentFile::Long(Box::new(self.long_environment_file(item, fields, &path)))
2820                }
2821                _ => {
2822                    self.expected(
2823                        item,
2824                        "env_file item must use scalar short syntax or mapping long syntax",
2825                    );
2826                    continue;
2827                }
2828            };
2829            environment_files.push(ProjectValue::new(environment_file, item));
2830        }
2831        Some(ProjectValue::new(environment_files, value))
2832    }
2833
2834    fn long_environment_file(
2835        &mut self,
2836        value: &MergedValue,
2837        fields: &[MergedEntry],
2838        path: &[String],
2839    ) -> ProjectLongEnvironmentFile {
2840        let mut environment_file = ProjectLongEnvironmentFile {
2841            path: None,
2842            required: None,
2843            format: None,
2844            unmodeled_fields: Vec::new(),
2845        };
2846        for field in fields {
2847            match field.key() {
2848                "path" => {
2849                    environment_file.path = self.project_string(field.value(), "environment-file path");
2850                }
2851                "required" => {
2852                    environment_file.required = self
2853                        .located_boolean(field.value(), "environment-file required option must be a boolean")
2854                        .map(|value| ProjectValue::new(value.into_value(), field.value()));
2855                }
2856                "format" => {
2857                    environment_file.format = self.environment_file_format(field.value());
2858                }
2859                _ => environment_file.unmodeled_fields.push(field_reference(path, field)),
2860            }
2861        }
2862        if environment_file.path.is_none() {
2863            self.missing(value, "long-syntax environment file is missing `path`");
2864        }
2865        environment_file
2866    }
2867
2868    fn environment_file_format(&mut self, value: &MergedValue) -> Option<ProjectValue<EnvironmentFileFormat>> {
2869        let raw = self.project_string(value, "environment-file format")?;
2870        let format = EnvironmentFileFormat::parse(Located::new(raw.value, effective_span(value)));
2871        if matches!(format.kind(), EnvironmentFileFormatKind::Other) {
2872            self.invalid(
2873                effective_span(value),
2874                "environment-file format must be `raw` or interpolation",
2875            );
2876        }
2877        Some(ProjectValue {
2878            value: format,
2879            provenance: raw.provenance,
2880            sensitive: raw.sensitive,
2881        })
2882    }
2883
2884    fn service_labels(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectLabels>> {
2885        let mut entries = Vec::new();
2886        match value.kind() {
2887            MergedValueKind::Mapping(values) => {
2888                for entry in values {
2889                    let scalar = if entry.syntax() == EntrySyntax::ListKeyOnly {
2890                        ComposeScalar::String(String::new())
2891                    } else {
2892                        self.compose_scalar(entry.value(), "label value must be a scalar or null")?
2893                    };
2894                    entries.push(ProjectLabelEntry {
2895                        name: ProjectKey::from_entry(entry),
2896                        value: ProjectValue::new(scalar, entry.value()),
2897                        syntax: entry.syntax(),
2898                    });
2899                }
2900            }
2901            MergedValueKind::Sequence(values) => {
2902                for item in values {
2903                    let raw = self.located_string(item, "label list item must be a scalar")?;
2904                    let (name, value, syntax) = raw.value().split_once('=').map_or_else(
2905                        || (raw.value().clone(), String::new(), EntrySyntax::ListKeyOnly),
2906                        |(name, value)| (name.to_owned(), value.to_owned(), EntrySyntax::ListKeyValue),
2907                    );
2908                    entries.push(ProjectLabelEntry {
2909                        name: ProjectKey::from_value(name, item),
2910                        value: ProjectValue::new(ComposeScalar::String(value), item),
2911                        syntax,
2912                    });
2913                }
2914            }
2915            _ => {
2916                self.expected(value, "labels must be a mapping or sequence");
2917                return None;
2918            }
2919        }
2920        Some(ProjectValue::new(ProjectLabels { entries }, value))
2921    }
2922
2923    fn service_annotations(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectAnnotations>> {
2924        let entries = match value.kind() {
2925            MergedValueKind::Mapping(values) => self.annotation_mapping(values),
2926            MergedValueKind::Sequence(values) => self.annotation_sequence(values),
2927            _ => {
2928                self.expected(value, "annotations must be a mapping or sequence");
2929                return None;
2930            }
2931        };
2932        Some(ProjectValue::new(ProjectAnnotations { entries }, value))
2933    }
2934
2935    fn annotation_mapping(&mut self, values: &[MergedEntry]) -> Vec<ProjectAnnotationEntry> {
2936        let mut entries = Vec::new();
2937        for entry in values {
2938            if entry.key().is_empty() {
2939                self.annotation_finding(
2940                    ANNOTATIONS_EMPTY_NAME,
2941                    Severity::Error,
2942                    entry.value(),
2943                    "service annotation name must not be empty",
2944                    "empty annotation name",
2945                );
2946            }
2947            if entry
2948                .raw_list_item()
2949                .is_some_and(|raw| raw.kind() != MergedScalarKind::String)
2950            {
2951                self.annotation_finding(
2952                    ANNOTATIONS_EXPECTED_STRING,
2953                    Severity::Error,
2954                    entry.value(),
2955                    "annotation list entries must be string scalars",
2956                    "non-string annotation item retained",
2957                );
2958            }
2959            let raw_list_item = entry.raw_list_item().map(|raw| ProjectValue {
2960                value: ProjectAnnotationScalar {
2961                    authored: raw.raw().to_owned(),
2962                    effective: compose_scalar_from_merged(raw),
2963                },
2964                provenance: entry.value().provenance().clone(),
2965                sensitive: raw.is_sensitive(),
2966            });
2967            let value = self.annotation_mapping_value(entry);
2968            let candidate = ProjectAnnotationEntry {
2969                name: ProjectKey::from_entry(entry),
2970                value,
2971                raw_list_item,
2972                syntax: entry.syntax(),
2973                contributors: vec![entry.value().provenance().clone()],
2974            };
2975            self.upsert_annotation(&mut entries, candidate, entry.value());
2976        }
2977        entries
2978    }
2979
2980    fn annotation_mapping_value(&mut self, entry: &MergedEntry) -> Option<ProjectValue<ProjectAnnotationScalar>> {
2981        if entry.syntax() == EntrySyntax::ListKeyOnly {
2982            self.annotation_finding(
2983                ANNOTATIONS_KEY_ONLY,
2984                Severity::Warning,
2985                entry.value(),
2986                "key-only service annotation has no explicit value",
2987                "ambiguous key-only annotation",
2988            );
2989            return None;
2990        }
2991        self.annotation_scalar(entry.value()).map(|scalar| ProjectValue {
2992            value: scalar,
2993            provenance: entry.value().provenance().clone(),
2994            sensitive: entry.value().is_sensitive(),
2995        })
2996    }
2997
2998    fn annotation_sequence(&mut self, values: &[MergedValue]) -> Vec<ProjectAnnotationEntry> {
2999        let mut entries = Vec::new();
3000        for item in values {
3001            let Some(candidate) = self.annotation_list_item(item) else {
3002                continue;
3003            };
3004            self.upsert_annotation(&mut entries, candidate, item);
3005        }
3006        entries
3007    }
3008
3009    fn annotation_list_item(&mut self, item: &MergedValue) -> Option<ProjectAnnotationEntry> {
3010        let Some(scalar) = item.as_scalar() else {
3011            self.invalid_annotation_list_item(item);
3012            return None;
3013        };
3014        if scalar.kind() != MergedScalarKind::String {
3015            self.invalid_annotation_list_item(item);
3016            return None;
3017        }
3018        let raw_list_item = Some(ProjectValue {
3019            value: ProjectAnnotationScalar {
3020                authored: scalar.raw().to_owned(),
3021                effective: ComposeScalar::String(scalar.value().to_owned()),
3022            },
3023            provenance: item.provenance().clone(),
3024            sensitive: scalar.is_sensitive(),
3025        });
3026        let (name, value, syntax) = if let Some((name, value)) = scalar.value().split_once('=') {
3027            (
3028                name.to_owned(),
3029                Some(ProjectValue {
3030                    value: ProjectAnnotationScalar {
3031                        authored: scalar.raw().to_owned(),
3032                        effective: ComposeScalar::String(value.to_owned()),
3033                    },
3034                    provenance: item.provenance().clone(),
3035                    sensitive: scalar.is_sensitive(),
3036                }),
3037                EntrySyntax::ListKeyValue,
3038            )
3039        } else {
3040            self.annotation_finding(
3041                ANNOTATIONS_KEY_ONLY,
3042                Severity::Warning,
3043                item,
3044                "key-only service annotation has no explicit value",
3045                "ambiguous key-only annotation",
3046            );
3047            (scalar.value().to_owned(), None, EntrySyntax::ListKeyOnly)
3048        };
3049        if name.is_empty() {
3050            self.annotation_finding(
3051                ANNOTATIONS_EMPTY_NAME,
3052                Severity::Error,
3053                item,
3054                "service annotation name must not be empty",
3055                "empty annotation name",
3056            );
3057        }
3058        Some(ProjectAnnotationEntry {
3059            name: ProjectKey::from_value(name, item),
3060            value,
3061            raw_list_item,
3062            syntax,
3063            contributors: vec![item.provenance().clone()],
3064        })
3065    }
3066
3067    fn invalid_annotation_list_item(&mut self, item: &MergedValue) {
3068        self.annotation_finding(
3069            ANNOTATIONS_EXPECTED_STRING,
3070            Severity::Error,
3071            item,
3072            "annotation list entries must be string scalars",
3073            "non-string annotation item retained in merged source",
3074        );
3075    }
3076
3077    fn annotation_scalar(&mut self, value: &MergedValue) -> Option<ProjectAnnotationScalar> {
3078        let effective = self.compose_scalar(
3079            value,
3080            "annotation mapping values must be scalar strings, numbers, booleans, or null",
3081        )?;
3082        let authored = match value.kind() {
3083            MergedValueKind::Null(crate::merge::NullStyle::Empty) => String::new(),
3084            MergedValueKind::Null(crate::merge::NullStyle::Explicit) => "null".to_owned(),
3085            MergedValueKind::Scalar(scalar) => scalar.raw().to_owned(),
3086            _ => unreachable!("compose_scalar accepted only scalar or null"),
3087        };
3088        Some(ProjectAnnotationScalar { authored, effective })
3089    }
3090
3091    fn upsert_annotation(
3092        &mut self,
3093        entries: &mut Vec<ProjectAnnotationEntry>,
3094        mut candidate: ProjectAnnotationEntry,
3095        source: &MergedValue,
3096    ) {
3097        if let Some(existing) = entries
3098            .iter_mut()
3099            .find(|entry| entry.name.value == candidate.name.value)
3100        {
3101            self.annotation_finding(
3102                ANNOTATIONS_DUPLICATE_NAME,
3103                Severity::Error,
3104                source,
3105                "service annotation names must be unique",
3106                "later annotation value replaces earlier effective value",
3107            );
3108            for span in candidate.name.sources.drain(..) {
3109                if !existing.name.sources.contains(&span) {
3110                    existing.name.sources.push(span);
3111                }
3112            }
3113            existing.name.sensitive |= candidate.name.sensitive;
3114            existing.contributors.append(&mut candidate.contributors);
3115            existing.value = candidate.value;
3116            existing.raw_list_item = candidate.raw_list_item;
3117            existing.syntax = candidate.syntax;
3118        } else {
3119            entries.push(candidate);
3120        }
3121    }
3122
3123    fn annotation_finding(
3124        &mut self,
3125        code: DiagnosticCode,
3126        severity: Severity,
3127        value: &MergedValue,
3128        message: &'static str,
3129        label: &'static str,
3130    ) {
3131        self.diagnostics.push(
3132            Diagnostic::new(code, severity, message).with_label(DiagnosticLabel::primary(effective_span(value), label)),
3133        );
3134    }
3135
3136    fn healthcheck(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ProjectValue<ProjectHealthcheck>> {
3137        let fields = self.mapping(value, "healthcheck must be a mapping")?;
3138        let mut healthcheck = ProjectHealthcheck {
3139            test: None,
3140            interval: None,
3141            timeout: None,
3142            retries: None,
3143            start_period: None,
3144            start_interval: None,
3145            disable: None,
3146            unmodeled_fields: Vec::new(),
3147        };
3148        let mut path = parent_path.to_vec();
3149        path.push("healthcheck".to_owned());
3150        for field in fields {
3151            match field.key() {
3152                "test" => healthcheck.test = self.healthcheck_test(field.value()),
3153                "interval" => {
3154                    healthcheck.interval =
3155                        self.healthcheck_duration(field.value(), "healthcheck interval must be a scalar");
3156                }
3157                "timeout" => {
3158                    healthcheck.timeout =
3159                        self.healthcheck_duration(field.value(), "healthcheck timeout must be a scalar");
3160                }
3161                "retries" => healthcheck.retries = self.healthcheck_retries(field.value()),
3162                "start_period" => {
3163                    healthcheck.start_period =
3164                        self.healthcheck_duration(field.value(), "healthcheck start_period must be a scalar");
3165                }
3166                "start_interval" => {
3167                    healthcheck.start_interval =
3168                        self.healthcheck_duration(field.value(), "healthcheck start_interval must be a scalar");
3169                }
3170                "disable" => {
3171                    healthcheck.disable = self
3172                        .located_boolean(field.value(), "healthcheck disable must be a boolean")
3173                        .map(|value| ProjectValue::new(value.into_value(), field.value()));
3174                }
3175                _ => healthcheck.unmodeled_fields.push(field_reference(&path, field)),
3176            }
3177        }
3178        Some(ProjectValue::new(healthcheck, value))
3179    }
3180
3181    fn depends_on(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ProjectValue<ProjectDependsOn>> {
3182        let dependencies = match value.kind() {
3183            MergedValueKind::Sequence(values) => {
3184                let mut dependencies = Vec::new();
3185                for value in values {
3186                    let Some(service) = self.project_string(value, "dependency service name") else {
3187                        continue;
3188                    };
3189                    let dependency = ProjectServiceDependency {
3190                        service: ProjectKey::from_value(service.value, value),
3191                        condition: None,
3192                        restart: None,
3193                        required: None,
3194                        unmodeled_fields: Vec::new(),
3195                    };
3196                    dependencies.push(ProjectValue::new(dependency, value));
3197                }
3198                ProjectDependsOn::Short(dependencies)
3199            }
3200            MergedValueKind::Mapping(entries) => {
3201                let mut dependencies = Vec::new();
3202                let mut path = parent_path.to_vec();
3203                path.push("depends_on".to_owned());
3204                for entry in entries {
3205                    let mut dependency = ProjectServiceDependency {
3206                        service: ProjectKey::from_entry(entry),
3207                        condition: None,
3208                        restart: None,
3209                        required: None,
3210                        unmodeled_fields: Vec::new(),
3211                    };
3212                    let fields = match entry.value().kind() {
3213                        MergedValueKind::Null(_) => &[][..],
3214                        MergedValueKind::Mapping(fields) => fields.as_slice(),
3215                        _ => {
3216                            self.expected(entry.value(), "long dependency options must be a mapping or null");
3217                            continue;
3218                        }
3219                    };
3220                    let mut dependency_path = path.clone();
3221                    dependency_path.push(entry.key().to_owned());
3222                    for field in fields {
3223                        match field.key() {
3224                            "condition" => {
3225                                let Some(condition) = self.project_string(field.value(), "dependency condition") else {
3226                                    continue;
3227                                };
3228                                let parsed = DependencyCondition::parse(condition.value);
3229                                if !parsed.is_known() {
3230                                    self.invalid(
3231                                        effective_span(field.value()),
3232                                        "dependency condition is not defined by Compose",
3233                                    );
3234                                }
3235                                dependency.condition = Some(ProjectValue {
3236                                    value: parsed,
3237                                    provenance: condition.provenance,
3238                                    sensitive: condition.sensitive,
3239                                });
3240                            }
3241                            "restart" => {
3242                                dependency.restart = self
3243                                    .located_boolean(field.value(), "dependency restart must be a boolean")
3244                                    .map(|value| ProjectValue::new(value.into_value(), field.value()));
3245                            }
3246                            "required" => {
3247                                dependency.required = self
3248                                    .located_boolean(field.value(), "dependency required must be a boolean")
3249                                    .map(|value| ProjectValue::new(value.into_value(), field.value()));
3250                            }
3251                            _ => dependency
3252                                .unmodeled_fields
3253                                .push(field_reference(&dependency_path, field)),
3254                        }
3255                    }
3256                    dependencies.push(ProjectValue::new(dependency, entry.value()));
3257                }
3258                ProjectDependsOn::Long(dependencies)
3259            }
3260            _ => {
3261                self.expected(value, "depends_on must be a sequence or mapping");
3262                return None;
3263            }
3264        };
3265        Some(ProjectValue::new(dependencies, value))
3266    }
3267
3268    fn healthcheck_test(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckTest>> {
3269        let span = effective_span(value);
3270        let test = match value.kind() {
3271            MergedValueKind::Scalar(scalar) => HealthcheckTest::String(Located::new(scalar.value().to_owned(), span)),
3272            MergedValueKind::Sequence(values) => {
3273                let mut items = Vec::new();
3274                for value in values {
3275                    items.push(self.located_string(value, "healthcheck test item must be a scalar")?);
3276                }
3277                let kind = items.first().map(|item| HealthcheckTestKind::parse(item.value()));
3278                HealthcheckTest::List {
3279                    span,
3280                    kind,
3281                    values: items,
3282                }
3283            }
3284            _ => {
3285                self.expected(value, "healthcheck test must be a scalar or sequence");
3286                return None;
3287            }
3288        };
3289        Some(ProjectValue::new(test, value))
3290    }
3291
3292    fn healthcheck_duration(
3293        &mut self,
3294        value: &MergedValue,
3295        message: &str,
3296    ) -> Option<ProjectValue<HealthcheckDuration>> {
3297        let scalar = self.scalar(value, message)?;
3298        Some(ProjectValue::new(
3299            HealthcheckDuration::parse(scalar.value().to_owned()),
3300            value,
3301        ))
3302    }
3303
3304    fn healthcheck_retries(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckRetries>> {
3305        let scalar = self.scalar(value, "healthcheck retries must be a scalar")?;
3306        Some(ProjectValue::new(
3307            HealthcheckRetries::parse(scalar.value().to_owned()),
3308            value,
3309        ))
3310    }
3311
3312    fn extra_hosts(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectExtraHosts>> {
3313        let mut entries = Vec::new();
3314        match value.kind() {
3315            MergedValueKind::Mapping(values) => {
3316                for entry in values {
3317                    let scalar = self.scalar(entry.value(), "extra_hosts address must be a scalar")?;
3318                    entries.push(ProjectExtraHost {
3319                        hostname: ProjectKey::from_entry(entry),
3320                        address: ProjectValue::new(HostAddress::parse(scalar.value().to_owned()), entry.value()),
3321                        syntax: EntrySyntax::Mapping,
3322                    });
3323                }
3324            }
3325            MergedValueKind::Sequence(values) => {
3326                for item in values {
3327                    let raw = self.located_string(item, "extra_hosts list item must be a scalar")?;
3328                    let parsed = ShortExtraHost::parse(raw);
3329                    let (Some(hostname), Some(address)) = (parsed.hostname(), parsed.address()) else {
3330                        self.invalid(
3331                            effective_span(item),
3332                            "extra_hosts entry must contain a hostname and address",
3333                        );
3334                        continue;
3335                    };
3336                    entries.push(ProjectExtraHost {
3337                        hostname: ProjectKey {
3338                            value: hostname.to_owned(),
3339                            sources: item.provenance().sources().to_vec(),
3340                            sensitive: item.is_sensitive(),
3341                        },
3342                        address: ProjectValue::new(address.clone(), item),
3343                        syntax: EntrySyntax::ListKeyValue,
3344                    });
3345                }
3346            }
3347            _ => {
3348                self.expected(value, "extra_hosts must be a mapping or sequence");
3349                return None;
3350            }
3351        }
3352        Some(ProjectValue::new(ProjectExtraHosts { entries }, value))
3353    }
3354
3355    fn project_string(&mut self, value: &MergedValue, description: &str) -> Option<ProjectValue<String>> {
3356        let scalar = self.scalar(value, &format!("{description} must be a non-null scalar"))?;
3357        Some(ProjectValue::new(scalar.value().to_owned(), value))
3358    }
3359
3360    fn string_collection(
3361        &mut self,
3362        value: &MergedValue,
3363        message: &str,
3364    ) -> Option<ProjectValue<Vec<ProjectValue<String>>>> {
3365        let Some(values) = value.as_sequence() else {
3366            self.expected(value, message);
3367            return None;
3368        };
3369        let mut strings = Vec::new();
3370        for value in values {
3371            let scalar = self.scalar(value, "sequence item must be a non-null scalar")?;
3372            strings.push(ProjectValue::new(scalar.value().to_owned(), value));
3373        }
3374        Some(ProjectValue::new(strings, value))
3375    }
3376
3377    fn capability_drop(&mut self, value: &MergedValue) -> Option<ProjectValue<Vec<ProjectValue<CapabilityDropItem>>>> {
3378        let Some(values) = value.as_sequence() else {
3379            self.expected(value, "cap_drop must be a sequence of string scalars");
3380            return None;
3381        };
3382        let mut items = Vec::new();
3383        let mut seen = BTreeMap::new();
3384        for item in values {
3385            let scalar = match item.kind() {
3386                MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
3387                _ => {
3388                    self.expected(item, "cap_drop entries must be string scalars");
3389                    continue;
3390                }
3391            };
3392            let span = effective_span(item);
3393            if let Some(first) = seen.get(scalar.value()) {
3394                self.diagnostics.push(
3395                    Diagnostic::new(
3396                        CAP_DROP_DUPLICATE_ITEM,
3397                        Severity::Error,
3398                        "cap_drop entries must be unique exact strings",
3399                    )
3400                    .with_label(DiagnosticLabel::primary(span, "duplicate capability string"))
3401                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
3402                );
3403            } else {
3404                seen.insert(scalar.value().to_owned(), span);
3405            }
3406            let typed = CapabilityDropItem::new(Located::new(scalar.value().to_owned(), span));
3407            items.push(ProjectValue::new(typed, item));
3408        }
3409        Some(ProjectValue::new(items, value))
3410    }
3411
3412    fn capability_add(&mut self, value: &MergedValue) -> Option<ProjectValue<Vec<ProjectValue<CapabilityAddItem>>>> {
3413        let Some(values) = value.as_sequence() else {
3414            self.expected(value, "cap_add must be a sequence of string scalars");
3415            return None;
3416        };
3417        let mut items = Vec::new();
3418        let mut seen = BTreeMap::new();
3419        for item in values {
3420            let scalar = match item.kind() {
3421                MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
3422                _ => {
3423                    self.expected(item, "cap_add entries must be string scalars");
3424                    continue;
3425                }
3426            };
3427            let span = effective_span(item);
3428            if let Some(first) = seen.get(scalar.value()) {
3429                self.diagnostics.push(
3430                    Diagnostic::new(
3431                        CAP_ADD_DUPLICATE_ITEM,
3432                        Severity::Error,
3433                        "cap_add entries must be unique exact strings",
3434                    )
3435                    .with_label(DiagnosticLabel::primary(span, "duplicate capability string"))
3436                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
3437                );
3438            } else {
3439                seen.insert(scalar.value().to_owned(), span);
3440            }
3441            let typed = CapabilityAddItem::new(Located::new(scalar.value().to_owned(), span));
3442            items.push(ProjectValue::new(typed, item));
3443        }
3444        Some(ProjectValue::new(items, value))
3445    }
3446
3447    fn devices(
3448        &mut self,
3449        value: &MergedValue,
3450        service_path: &[String],
3451    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectDevice>>>> {
3452        let Some(values) = value.as_sequence() else {
3453            self.expected(value, "service devices must be a sequence");
3454            return None;
3455        };
3456        let mut devices = Vec::new();
3457        for (index, item) in values.iter().enumerate() {
3458            let mut path = service_path.to_vec();
3459            path.push("devices".to_owned());
3460            path.push(index.to_string());
3461            let device = match item.kind() {
3462                MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => ProjectDevice::Short(
3463                    ShortDevice::new(Located::new(scalar.value().to_owned(), effective_span(item))),
3464                ),
3465                MergedValueKind::Mapping(fields) => ProjectDevice::Long(self.long_device(item, fields, &path)),
3466                _ => {
3467                    self.diagnostics.push(
3468                        Diagnostic::new(
3469                            DEVICE_EXPECTED_FORM,
3470                            Severity::Error,
3471                            "service device must use string short syntax or mapping long syntax",
3472                        )
3473                        .with_label(DiagnosticLabel::primary(
3474                            effective_span(item),
3475                            "unsupported device form",
3476                        )),
3477                    );
3478                    continue;
3479                }
3480            };
3481            devices.push(ProjectValue::new(device, item));
3482        }
3483        Some(ProjectValue::new(devices, value))
3484    }
3485
3486    fn long_device(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> ProjectLongDevice {
3487        let mut device = ProjectLongDevice {
3488            source: None,
3489            target: None,
3490            permissions: None,
3491            extension_fields: Vec::new(),
3492            unknown_fields: Vec::new(),
3493        };
3494        for field in fields {
3495            let parsed = match field.key() {
3496                "source" | "target" | "permissions" => self.device_string(field.value(), field.key()),
3497                name if name.starts_with("x-") => {
3498                    device.extension_fields.push(field_reference(path, field));
3499                    continue;
3500                }
3501                _ => {
3502                    device.unknown_fields.push(field_reference(path, field));
3503                    continue;
3504                }
3505            };
3506            match field.key() {
3507                "source" => device.source = parsed,
3508                "target" => device.target = parsed,
3509                "permissions" => device.permissions = parsed,
3510                _ => unreachable!("unrecognized device fields continue before assignment"),
3511            }
3512        }
3513        if device.source.is_none() {
3514            self.missing(value, "long-syntax device is missing required string `source`");
3515        }
3516        device
3517    }
3518
3519    fn device_string(&mut self, value: &MergedValue, member: &str) -> Option<ProjectValue<String>> {
3520        let scalar = match value.kind() {
3521            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
3522            _ => {
3523                self.diagnostics.push(
3524                    Diagnostic::new(
3525                        DEVICE_EXPECTED_STRING,
3526                        Severity::Error,
3527                        format!("device {member} must be a string scalar"),
3528                    )
3529                    .with_label(DiagnosticLabel::primary(
3530                        effective_span(value),
3531                        "unexpected long-device member form",
3532                    )),
3533                );
3534                return None;
3535            }
3536        };
3537        Some(ProjectValue::new(scalar.value().to_owned(), value))
3538    }
3539
3540    fn scalar<'value>(
3541        &mut self,
3542        value: &'value MergedValue,
3543        message: &str,
3544    ) -> Option<&'value crate::merge::MergedScalar> {
3545        let Some(scalar) = value.as_scalar() else {
3546            self.expected(value, message);
3547            return None;
3548        };
3549        Some(scalar)
3550    }
3551
3552    fn located_string(&mut self, value: &MergedValue, message: &str) -> Option<Located<String>> {
3553        let scalar = self.scalar(value, message)?;
3554        Some(Located::new(scalar.value().to_owned(), effective_span(value)))
3555    }
3556
3557    fn compose_scalar(&mut self, value: &MergedValue, message: &str) -> Option<ComposeScalar> {
3558        match value.kind() {
3559            MergedValueKind::Null(_) => Some(ComposeScalar::Null),
3560            MergedValueKind::Scalar(scalar) => Some(match scalar.kind() {
3561                MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
3562                MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
3563                MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
3564            }),
3565            _ => {
3566                self.expected(value, message);
3567                None
3568            }
3569        }
3570    }
3571
3572    fn mapping<'value>(&mut self, value: &'value MergedValue, message: &str) -> Option<&'value [MergedEntry]> {
3573        let Some(entries) = value.as_mapping() else {
3574            self.expected(value, message);
3575            return None;
3576        };
3577        Some(entries)
3578    }
3579
3580    fn expected(&mut self, value: &MergedValue, message: &str) {
3581        self.diagnostics.push(
3582            Diagnostic::new(PROJECT_EXPECTED_FORM, Severity::Error, message).with_label(DiagnosticLabel::primary(
3583                effective_span(value),
3584                "unexpected merged value form",
3585            )),
3586        );
3587    }
3588
3589    fn missing(&mut self, value: &MergedValue, message: &str) {
3590        self.diagnostics.push(
3591            Diagnostic::new(PROJECT_MISSING_FIELD, Severity::Error, message).with_label(DiagnosticLabel::primary(
3592                effective_span(value),
3593                "required field is missing",
3594            )),
3595        );
3596    }
3597
3598    fn invalid(&mut self, span: SourceSpan, message: &str) {
3599        self.diagnostics.push(
3600            Diagnostic::new(PROJECT_INVALID_VALUE, Severity::Error, message)
3601                .with_label(DiagnosticLabel::primary(span, "invalid native value")),
3602        );
3603    }
3604
3605    fn record_root_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
3606        self.root_unmodeled.push(field_reference(path, entry));
3607    }
3608
3609    fn record_pending_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
3610        self.pending_unmodeled.push(field_reference(path, entry));
3611    }
3612}
3613
3614fn effective_security_label_diagnostic(
3615    kind: &SecurityOptionKind,
3616    span: SourceSpan,
3617    candidates: &mut SecurityOptionCandidateCounts,
3618) -> Option<Diagnostic> {
3619    match kind {
3620        SecurityOptionKind::SecurityLabelDisable { .. } | SecurityOptionKind::SecurityLabelDisableNearMiss => {
3621            security_label_disable_diagnostic(kind, span, &mut candidates.security_label_disable)
3622        }
3623        SecurityOptionKind::SecurityLabelFileType { .. } | SecurityOptionKind::SecurityLabelFileTypeNearMiss => {
3624            security_label_filetype_diagnostic(kind, span, &mut candidates.security_label_filetype)
3625        }
3626        SecurityOptionKind::SecurityLabelLevel { .. } | SecurityOptionKind::SecurityLabelLevelNearMiss => {
3627            security_label_level_diagnostic(kind, span, &mut candidates.security_label_level)
3628        }
3629        SecurityOptionKind::SecurityLabelNested { .. } | SecurityOptionKind::SecurityLabelNestedNearMiss => {
3630            security_label_nested_diagnostic(kind, span, &mut candidates.security_label_nested)
3631        }
3632        SecurityOptionKind::SecurityLabelType { .. } | SecurityOptionKind::SecurityLabelTypeNearMiss => {
3633            security_label_type_diagnostic(kind, span, &mut candidates.security_label_type)
3634        }
3635        _ => None,
3636    }
3637}
3638
3639fn security_label_disable_diagnostic(
3640    kind: &SecurityOptionKind,
3641    span: SourceSpan,
3642    candidates: &mut usize,
3643) -> Option<Diagnostic> {
3644    match kind {
3645        SecurityOptionKind::SecurityLabelDisable { .. } => {
3646            *candidates += 1;
3647            (*candidates > 1).then(|| {
3648                Diagnostic::new(
3649                    SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT,
3650                    Severity::Warning,
3651                    "multiple SELinux label-disable candidates are retained; a consumer must resolve the conflict explicitly",
3652                )
3653                .with_label(DiagnosticLabel::primary(
3654                    span,
3655                    "additional effective SELinux label-disable candidate retained",
3656                ))
3657            })
3658        }
3659        SecurityOptionKind::SecurityLabelDisableNearMiss => Some(
3660            Diagnostic::new(
3661                SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS,
3662                Severity::Warning,
3663                "SELinux label-disable candidates require exact lowercase `label:disable` spelling without whitespace",
3664            )
3665            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3666        ),
3667        _ => None,
3668    }
3669}
3670
3671fn security_label_filetype_diagnostic(
3672    kind: &SecurityOptionKind,
3673    span: SourceSpan,
3674    candidates: &mut usize,
3675) -> Option<Diagnostic> {
3676    match kind {
3677        SecurityOptionKind::SecurityLabelFileType { .. } => {
3678            *candidates += 1;
3679            (*candidates > 1).then(|| {
3680                Diagnostic::new(
3681                    SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT,
3682                    Severity::Warning,
3683                    "multiple SELinux label-filetype candidates are retained; a consumer must resolve the conflict explicitly",
3684                )
3685                .with_label(DiagnosticLabel::primary(
3686                    span,
3687                    "additional effective SELinux label-filetype candidate retained",
3688                ))
3689            })
3690        }
3691        SecurityOptionKind::SecurityLabelFileTypeNearMiss => Some(
3692            Diagnostic::new(
3693                SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS,
3694                Severity::Warning,
3695                "SELinux label-filetype candidates require exact lowercase `label:filetype:<type>` spelling without whitespace",
3696            )
3697            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3698        ),
3699        _ => None,
3700    }
3701}
3702
3703fn security_label_level_diagnostic(
3704    kind: &SecurityOptionKind,
3705    span: SourceSpan,
3706    candidates: &mut usize,
3707) -> Option<Diagnostic> {
3708    match kind {
3709        SecurityOptionKind::SecurityLabelLevel { .. } => {
3710            *candidates += 1;
3711            (*candidates > 1).then(|| {
3712                Diagnostic::new(
3713                    SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT,
3714                    Severity::Warning,
3715                    "multiple SELinux label-level candidates are retained; a consumer must resolve the conflict explicitly",
3716                )
3717                .with_label(DiagnosticLabel::primary(
3718                    span,
3719                    "additional effective SELinux label-level candidate retained",
3720                ))
3721            })
3722        }
3723        SecurityOptionKind::SecurityLabelLevelNearMiss => Some(
3724            Diagnostic::new(
3725                SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS,
3726                Severity::Warning,
3727                "SELinux label-level candidates require exact lowercase `label:level:<level>` spelling without whitespace",
3728            )
3729            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3730        ),
3731        _ => None,
3732    }
3733}
3734
3735fn security_label_nested_diagnostic(
3736    kind: &SecurityOptionKind,
3737    span: SourceSpan,
3738    candidates: &mut usize,
3739) -> Option<Diagnostic> {
3740    match kind {
3741        SecurityOptionKind::SecurityLabelNested { .. } => {
3742            *candidates += 1;
3743            (*candidates > 1).then(|| {
3744                Diagnostic::new(
3745                    SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT,
3746                    Severity::Warning,
3747                    "multiple SELinux label-nested candidates are retained; a consumer must resolve the conflict explicitly",
3748                )
3749                .with_label(DiagnosticLabel::primary(
3750                    span,
3751                    "additional effective SELinux label-nested candidate retained",
3752                ))
3753            })
3754        }
3755        SecurityOptionKind::SecurityLabelNestedNearMiss => Some(
3756            Diagnostic::new(
3757                SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS,
3758                Severity::Warning,
3759                "SELinux label-nested candidates require exact lowercase `label:nested` spelling without whitespace",
3760            )
3761            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3762        ),
3763        _ => None,
3764    }
3765}
3766
3767fn security_label_type_diagnostic(
3768    kind: &SecurityOptionKind,
3769    span: SourceSpan,
3770    candidates: &mut usize,
3771) -> Option<Diagnostic> {
3772    match kind {
3773        SecurityOptionKind::SecurityLabelType { .. } => {
3774            *candidates += 1;
3775            (*candidates > 1).then(|| {
3776                Diagnostic::new(
3777                    SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT,
3778                    Severity::Warning,
3779                    "multiple SELinux label-type candidates are retained; a consumer must resolve the conflict explicitly",
3780                )
3781                .with_label(DiagnosticLabel::primary(
3782                    span,
3783                    "additional effective SELinux label-type candidate retained",
3784                ))
3785            })
3786        }
3787        SecurityOptionKind::SecurityLabelTypeNearMiss => Some(
3788            Diagnostic::new(
3789                SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS,
3790                Severity::Warning,
3791                "SELinux label-type candidates require exact lowercase `label:type:<type>` spelling with one non-empty whitespace-free type",
3792            )
3793            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3794        ),
3795        _ => None,
3796    }
3797}
3798
3799impl Builder<'_> {
3800    fn ports(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<Vec<ProjectValue<Port>>>> {
3801        let Some(values) = value.as_sequence() else {
3802            self.expected(value, "service ports must be a sequence");
3803            return None;
3804        };
3805        let mut ports = Vec::new();
3806        for (index, item) in values.iter().enumerate() {
3807            let mut path = service_path.to_vec();
3808            path.push("ports".to_owned());
3809            path.push(index.to_string());
3810            let port = match item.kind() {
3811                MergedValueKind::Scalar(scalar) => Port::Short(ShortPort::parse(Located::new(
3812                    scalar.value().to_owned(),
3813                    effective_span(item),
3814                ))),
3815                MergedValueKind::Mapping(fields) => Port::Long(Box::new(self.long_port(item, fields, &path))),
3816                _ => {
3817                    self.expected(item, "service port must use scalar short syntax or mapping long syntax");
3818                    continue;
3819                }
3820            };
3821            ports.push(ProjectValue::new(port, item));
3822        }
3823        Some(ProjectValue::new(ports, value))
3824    }
3825
3826    fn long_port(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongPort {
3827        let mut port = LongPort::new(effective_span(value));
3828        let mut has_target = false;
3829        for field in fields {
3830            match field.key() {
3831                "target" => {
3832                    if let Some(value) = self.located_string(field.value(), "port target must be a scalar") {
3833                        port.set_target(value);
3834                        has_target = true;
3835                    }
3836                }
3837                "published" => self
3838                    .located_string(field.value(), "published port must be a scalar")
3839                    .into_iter()
3840                    .for_each(|value| port.set_published(value)),
3841                "host_ip" => self
3842                    .located_string(field.value(), "port host_ip must be a scalar")
3843                    .into_iter()
3844                    .for_each(|value| port.set_host_ip(value)),
3845                "protocol" => self
3846                    .located_string(field.value(), "port protocol must be a scalar")
3847                    .into_iter()
3848                    .for_each(|value| port.set_protocol(value)),
3849                "app_protocol" => self
3850                    .located_string(field.value(), "port app_protocol must be a scalar")
3851                    .into_iter()
3852                    .for_each(|value| port.set_app_protocol(value)),
3853                "mode" => self
3854                    .located_string(field.value(), "port mode must be a scalar")
3855                    .into_iter()
3856                    .for_each(|value| port.set_mode(value)),
3857                "name" => self
3858                    .located_string(field.value(), "port name must be a scalar")
3859                    .into_iter()
3860                    .for_each(|value| port.set_name(value)),
3861                _ => self.record_pending_unmodeled(path, field),
3862            }
3863        }
3864        if !has_target {
3865            self.missing(value, "long-syntax port is missing `target`");
3866        }
3867        port
3868    }
3869
3870    fn volumes(
3871        &mut self,
3872        value: &MergedValue,
3873        service_path: &[String],
3874    ) -> Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
3875        let Some(values) = value.as_sequence() else {
3876            self.expected(value, "service volumes must be a sequence");
3877            return None;
3878        };
3879        let mut mounts = Vec::new();
3880        for (index, item) in values.iter().enumerate() {
3881            let mut path = service_path.to_vec();
3882            path.push("volumes".to_owned());
3883            path.push(index.to_string());
3884            let mount = match item.kind() {
3885                MergedValueKind::Scalar(scalar) => VolumeMount::Short(ShortVolumeMount::new(Located::new(
3886                    scalar.value().to_owned(),
3887                    effective_span(item),
3888                ))),
3889                MergedValueKind::Mapping(fields) => VolumeMount::Long(Box::new(self.long_volume(item, fields, &path))),
3890                _ => {
3891                    self.expected(
3892                        item,
3893                        "service volume must use scalar short syntax or mapping long syntax",
3894                    );
3895                    continue;
3896                }
3897            };
3898            mounts.push(ProjectValue::new(mount, item));
3899        }
3900        Some(ProjectValue::new(mounts, value))
3901    }
3902
3903    fn long_volume(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongVolumeMount {
3904        let mut mount = LongVolumeMount::new(effective_span(value));
3905        let mut has_type = false;
3906        let mut has_target = false;
3907        for field in fields {
3908            match field.key() {
3909                "type" => {
3910                    if let Some(value) = self.located_string(field.value(), "volume type must be a scalar") {
3911                        mount.set_mount_type(Located::new(MountType::from_text(value.value().clone()), value.span()));
3912                        has_type = true;
3913                    }
3914                }
3915                "source" => self
3916                    .located_string(field.value(), "volume source must be a scalar")
3917                    .into_iter()
3918                    .for_each(|value| mount.set_source(value)),
3919                "target" => {
3920                    if let Some(value) = self.located_string(field.value(), "volume target must be a scalar") {
3921                        mount.set_target(value);
3922                        has_target = true;
3923                    }
3924                }
3925                "read_only" => self
3926                    .located_boolean(field.value(), "volume read_only must be a boolean")
3927                    .into_iter()
3928                    .for_each(|value| mount.set_read_only(value)),
3929                "bind" => self
3930                    .bind_options(field.value(), path)
3931                    .into_iter()
3932                    .for_each(|value| mount.set_bind(value)),
3933                _ => self.record_pending_unmodeled(path, field),
3934            }
3935        }
3936        if !has_type {
3937            self.missing(value, "long-syntax volume is missing `type`");
3938        }
3939        if !has_target {
3940            self.missing(value, "long-syntax volume is missing `target`");
3941        }
3942        mount
3943    }
3944
3945    fn grants(
3946        &mut self,
3947        value: &MergedValue,
3948        service_path: &[String],
3949        kind: &str,
3950    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
3951        let Some(values) = value.as_sequence() else {
3952            self.expected(value, &format!("service {kind}s must be a sequence"));
3953            return None;
3954        };
3955        let mut grants = Vec::new();
3956        for (index, item) in values.iter().enumerate() {
3957            let mut path = service_path.to_vec();
3958            path.push(format!("{kind}s"));
3959            path.push(index.to_string());
3960            let grant = match item.kind() {
3961                MergedValueKind::Scalar(scalar) => ProjectGrant::Short(scalar.value().to_owned()),
3962                MergedValueKind::Mapping(fields) => {
3963                    ProjectGrant::Long(Box::new(self.long_grant(item, fields, &path, kind)))
3964                }
3965                _ => {
3966                    self.expected(
3967                        item,
3968                        &format!("service {kind} must use scalar short syntax or mapping long syntax"),
3969                    );
3970                    continue;
3971                }
3972            };
3973            grants.push(ProjectValue::new(grant, item));
3974        }
3975        Some(ProjectValue::new(grants, value))
3976    }
3977
3978    fn long_grant(
3979        &mut self,
3980        value: &MergedValue,
3981        fields: &[MergedEntry],
3982        path: &[String],
3983        kind: &str,
3984    ) -> ProjectLongGrant {
3985        let mut grant = ProjectLongGrant {
3986            source: None,
3987            target: None,
3988            uid: None,
3989            gid: None,
3990            mode: None,
3991            unmodeled_fields: Vec::new(),
3992        };
3993        for field in fields {
3994            let parsed = match field.key() {
3995                "source" => self.project_string(field.value(), &format!("{kind} source")),
3996                "target" => self.project_string(field.value(), &format!("{kind} target")),
3997                "uid" => self.project_string(field.value(), &format!("{kind} uid")),
3998                "gid" => self.project_string(field.value(), &format!("{kind} gid")),
3999                "mode" => self.project_string(field.value(), &format!("{kind} mode")),
4000                _ => {
4001                    grant.unmodeled_fields.push(field_reference(path, field));
4002                    continue;
4003                }
4004            };
4005            match field.key() {
4006                "source" => grant.source = parsed,
4007                "target" => grant.target = parsed,
4008                "uid" => grant.uid = parsed,
4009                "gid" => grant.gid = parsed,
4010                "mode" => grant.mode = parsed,
4011                _ => unreachable!("unrecognized grant fields continue before assignment"),
4012            }
4013        }
4014        if grant.source.is_none() {
4015            self.missing(value, &format!("long-syntax {kind} is missing `source`"));
4016        }
4017        grant
4018    }
4019
4020    fn bind_options(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<BindOptions> {
4021        let fields = self.mapping(value, "volume bind options must be a mapping")?;
4022        let mut bind = BindOptions::new(effective_span(value));
4023        let mut path = parent_path.to_vec();
4024        path.push("bind".to_owned());
4025        for field in fields {
4026            match field.key() {
4027                "propagation" => self
4028                    .located_string(field.value(), "bind propagation must be a scalar")
4029                    .into_iter()
4030                    .for_each(|value| bind.set_propagation(value)),
4031                "create_host_path" => self
4032                    .located_boolean(field.value(), "bind create_host_path must be a boolean")
4033                    .into_iter()
4034                    .for_each(|value| bind.set_create_host_path(value)),
4035                "selinux" => {
4036                    if let Some(value) = self.located_string(field.value(), "bind SELinux mode must be a scalar") {
4037                        let mode = match value.value().as_str() {
4038                            "z" => Some(SelinuxRelabel::Shared),
4039                            "Z" => Some(SelinuxRelabel::Private),
4040                            _ => None,
4041                        };
4042                        if let Some(mode) = mode {
4043                            bind.set_selinux(Located::new(mode, value.span()));
4044                        } else {
4045                            self.invalid(value.span(), "bind SELinux mode must be `z` or `Z`");
4046                        }
4047                    }
4048                }
4049                _ => self.record_pending_unmodeled(&path, field),
4050            }
4051        }
4052        Some(bind)
4053    }
4054
4055    fn service_networks(
4056        &mut self,
4057        value: &MergedValue,
4058        service_path: &[String],
4059    ) -> Option<ProjectValue<ServiceNetworks>> {
4060        let span = effective_span(value);
4061        let networks = match value.kind() {
4062            MergedValueKind::Sequence(values) => {
4063                let mut names = Vec::new();
4064                for value in values {
4065                    names.push(self.located_string(value, "service network name must be a scalar")?);
4066                }
4067                ServiceNetworks::Short { span, names }
4068            }
4069            MergedValueKind::Mapping(entries) => {
4070                let mut networks = Vec::new();
4071                for entry in entries {
4072                    let mut path = service_path.to_vec();
4073                    path.push("networks".to_owned());
4074                    path.push(entry.key().to_owned());
4075                    networks.push(self.service_network(entry, &path)?);
4076                }
4077                ServiceNetworks::Long { span, networks }
4078            }
4079            _ => {
4080                self.expected(value, "service networks must be a sequence or mapping");
4081                return None;
4082            }
4083        };
4084        Some(ProjectValue::new(networks, value))
4085    }
4086
4087    fn service_network(&mut self, entry: &MergedEntry, path: &[String]) -> Option<ServiceNetwork> {
4088        let value = entry.value();
4089        let span = effective_span(value);
4090        let mut network = ServiceNetwork::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
4091        let fields = match value.kind() {
4092            MergedValueKind::Null(_) => return Some(network),
4093            MergedValueKind::Mapping(fields) => fields,
4094            _ => {
4095                self.expected(value, "service network attachment must be a mapping or null");
4096                return None;
4097            }
4098        };
4099        for field in fields {
4100            match field.key() {
4101                "aliases" => self
4102                    .located_string_sequence(field.value(), "network aliases must be a sequence")
4103                    .into_iter()
4104                    .for_each(|value| network.set_aliases(value)),
4105                "interface_name" => self
4106                    .located_string(field.value(), "network interface_name must be a scalar")
4107                    .into_iter()
4108                    .for_each(|value| network.set_interface_name(value)),
4109                "ipv4_address" => self
4110                    .located_string(field.value(), "network ipv4_address must be a scalar")
4111                    .into_iter()
4112                    .for_each(|value| network.set_ipv4_address(value)),
4113                "ipv6_address" => self
4114                    .located_string(field.value(), "network ipv6_address must be a scalar")
4115                    .into_iter()
4116                    .for_each(|value| network.set_ipv6_address(value)),
4117                "link_local_ips" => self
4118                    .located_string_sequence(field.value(), "link_local_ips must be a sequence")
4119                    .into_iter()
4120                    .for_each(|value| network.set_link_local_ips(value)),
4121                "mac_address" => self
4122                    .located_string(field.value(), "network mac_address must be a scalar")
4123                    .into_iter()
4124                    .for_each(|value| network.set_mac_address(value)),
4125                "driver_opts" => self
4126                    .key_value_mapping(field.value(), "network driver_opts must be a mapping")
4127                    .into_iter()
4128                    .for_each(|value| network.set_driver_opts(value)),
4129                "gw_priority" => self
4130                    .located_string(field.value(), "network gw_priority must be a scalar")
4131                    .into_iter()
4132                    .for_each(|value| network.set_gw_priority(value)),
4133                "priority" => self
4134                    .located_string(field.value(), "network priority must be a scalar")
4135                    .into_iter()
4136                    .for_each(|value| network.set_priority(value)),
4137                _ => self.record_pending_unmodeled(path, field),
4138            }
4139        }
4140        Some(network)
4141    }
4142
4143    fn located_boolean(&mut self, value: &MergedValue, message: &str) -> Option<Located<BooleanValue>> {
4144        let scalar = self.scalar(value, message)?;
4145        let boolean = if scalar.kind() == MergedScalarKind::Boolean {
4146            BooleanValue::Literal(scalar.value().eq_ignore_ascii_case("true"))
4147        } else if scalar.value().contains('$') {
4148            BooleanValue::Expression(scalar.value().to_owned())
4149        } else {
4150            self.invalid(effective_span(value), message);
4151            return None;
4152        };
4153        Some(Located::new(boolean, effective_span(value)))
4154    }
4155
4156    fn located_string_sequence(&mut self, value: &MergedValue, message: &str) -> Option<Vec<Located<String>>> {
4157        let Some(values) = value.as_sequence() else {
4158            self.expected(value, message);
4159            return None;
4160        };
4161        let mut strings = Vec::new();
4162        for value in values {
4163            strings.push(self.located_string(value, "sequence item must be a scalar")?);
4164        }
4165        Some(strings)
4166    }
4167
4168    fn key_value_mapping(&mut self, value: &MergedValue, message: &str) -> Option<Vec<KeyValueEntry>> {
4169        let Some(entries) = value.as_mapping() else {
4170            self.expected(value, message);
4171            return None;
4172        };
4173        let mut values = Vec::new();
4174        for entry in entries {
4175            let scalar = self.compose_scalar(entry.value(), "mapping value must be a scalar or null")?;
4176            let value_span = effective_span(entry.value());
4177            values.push(KeyValueEntry::new(
4178                Located::new(entry.key().to_owned(), entry_span(entry)),
4179                Located::new(scalar, value_span),
4180                value_span,
4181            ));
4182        }
4183        Some(values)
4184    }
4185
4186    fn network_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<NetworkDefinition>> {
4187        let Some(entries) = self.mapping(value, "top-level networks must be a mapping") else {
4188            return Vec::new();
4189        };
4190        entries
4191            .iter()
4192            .filter_map(|entry| {
4193                let definition = self.network_definition(entry)?;
4194                Some(ProjectResource {
4195                    name: ProjectKey::from_entry(entry),
4196                    definition: ProjectValue::new(definition, entry.value()),
4197                })
4198            })
4199            .collect()
4200    }
4201
4202    fn network_definition(&mut self, entry: &MergedEntry) -> Option<NetworkDefinition> {
4203        let value = entry.value();
4204        let span = effective_span(value);
4205        let mut network = NetworkDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
4206        let fields = match value.kind() {
4207            MergedValueKind::Null(_) => return Some(network),
4208            MergedValueKind::Mapping(fields) => fields,
4209            _ => {
4210                self.expected(value, "network definition must be a mapping or null");
4211                return None;
4212            }
4213        };
4214        let path = ["networks".to_owned(), entry.key().to_owned()];
4215        for field in fields {
4216            match field.key() {
4217                "driver" => self
4218                    .located_string(field.value(), "network driver must be a scalar")
4219                    .into_iter()
4220                    .for_each(|value| network.set_driver(value)),
4221                "driver_opts" => self
4222                    .key_value_mapping(field.value(), "network driver_opts must be a mapping")
4223                    .into_iter()
4224                    .for_each(|value| network.set_driver_opts(value)),
4225                "attachable" => self
4226                    .located_boolean(field.value(), "network attachable must be a boolean")
4227                    .into_iter()
4228                    .for_each(|value| network.set_attachable(value)),
4229                "enable_ipv4" => self
4230                    .located_boolean(field.value(), "network enable_ipv4 must be a boolean")
4231                    .into_iter()
4232                    .for_each(|value| network.set_enable_ipv4(value)),
4233                "enable_ipv6" => self
4234                    .located_boolean(field.value(), "network enable_ipv6 must be a boolean")
4235                    .into_iter()
4236                    .for_each(|value| network.set_enable_ipv6(value)),
4237                "external" => self
4238                    .located_boolean(field.value(), "network external must be a boolean")
4239                    .into_iter()
4240                    .for_each(|value| network.set_external(value)),
4241                "internal" => self
4242                    .located_boolean(field.value(), "network internal must be a boolean")
4243                    .into_iter()
4244                    .for_each(|value| network.set_internal(value)),
4245                "ipam" => self
4246                    .ipam(field.value(), &path)
4247                    .into_iter()
4248                    .for_each(|value| network.set_ipam(value)),
4249                "labels" => self
4250                    .labels(field.value())
4251                    .into_iter()
4252                    .for_each(|value| network.set_labels(value)),
4253                "name" => self
4254                    .located_string(field.value(), "network custom name must be a scalar")
4255                    .into_iter()
4256                    .for_each(|value| network.set_custom_name(value)),
4257                _ => self.record_root_unmodeled(&path, field),
4258            }
4259        }
4260        Some(network)
4261    }
4262
4263    fn ipam(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Ipam> {
4264        let fields = self.mapping(value, "network IPAM must be a mapping")?;
4265        let mut ipam = Ipam::new(effective_span(value));
4266        let mut path = parent_path.to_vec();
4267        path.push("ipam".to_owned());
4268        for field in fields {
4269            match field.key() {
4270                "driver" => self
4271                    .located_string(field.value(), "IPAM driver must be a scalar")
4272                    .into_iter()
4273                    .for_each(|value| ipam.set_driver(value)),
4274                "config" => self
4275                    .ipam_configs(field.value(), &path)
4276                    .into_iter()
4277                    .for_each(|value| ipam.set_config(value)),
4278                "options" => self
4279                    .key_value_mapping(field.value(), "IPAM options must be a mapping")
4280                    .into_iter()
4281                    .for_each(|value| ipam.set_options(value)),
4282                _ => self.record_root_unmodeled(&path, field),
4283            }
4284        }
4285        Some(ipam)
4286    }
4287
4288    fn ipam_configs(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Vec<IpamConfig>> {
4289        let Some(values) = value.as_sequence() else {
4290            self.expected(value, "IPAM config must be a sequence");
4291            return None;
4292        };
4293        let mut configs = Vec::new();
4294        for (index, value) in values.iter().enumerate() {
4295            let Some(fields) = value.as_mapping() else {
4296                self.expected(value, "IPAM config entry must be a mapping");
4297                continue;
4298            };
4299            let mut config = IpamConfig::new(effective_span(value));
4300            let mut path = parent_path.to_vec();
4301            path.push("config".to_owned());
4302            path.push(index.to_string());
4303            for field in fields {
4304                match field.key() {
4305                    "subnet" => self
4306                        .located_string(field.value(), "IPAM subnet must be a scalar")
4307                        .into_iter()
4308                        .for_each(|value| config.set_subnet(value)),
4309                    "ip_range" => self
4310                        .located_string(field.value(), "IPAM ip_range must be a scalar")
4311                        .into_iter()
4312                        .for_each(|value| config.set_ip_range(value)),
4313                    "gateway" => self
4314                        .located_string(field.value(), "IPAM gateway must be a scalar")
4315                        .into_iter()
4316                        .for_each(|value| config.set_gateway(value)),
4317                    "aux_addresses" => self
4318                        .key_value_mapping(field.value(), "IPAM aux_addresses must be a mapping")
4319                        .into_iter()
4320                        .for_each(|value| config.set_aux_addresses(value)),
4321                    _ => self.record_root_unmodeled(&path, field),
4322                }
4323            }
4324            configs.push(config);
4325        }
4326        Some(configs)
4327    }
4328
4329    fn volume_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<VolumeDefinition>> {
4330        let Some(entries) = self.mapping(value, "top-level volumes must be a mapping") else {
4331            return Vec::new();
4332        };
4333        entries
4334            .iter()
4335            .filter_map(|entry| {
4336                let definition = self.volume_definition(entry)?;
4337                Some(ProjectResource {
4338                    name: ProjectKey::from_entry(entry),
4339                    definition: ProjectValue::new(definition, entry.value()),
4340                })
4341            })
4342            .collect()
4343    }
4344
4345    fn volume_definition(&mut self, entry: &MergedEntry) -> Option<VolumeDefinition> {
4346        let value = entry.value();
4347        let span = effective_span(value);
4348        let mut volume = VolumeDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
4349        let fields = match value.kind() {
4350            MergedValueKind::Null(_) => return Some(volume),
4351            MergedValueKind::Mapping(fields) => fields,
4352            _ => {
4353                self.expected(value, "volume definition must be a mapping or null");
4354                return None;
4355            }
4356        };
4357        let path = ["volumes".to_owned(), entry.key().to_owned()];
4358        for field in fields {
4359            match field.key() {
4360                "driver" => self
4361                    .located_string(field.value(), "volume driver must be a scalar")
4362                    .into_iter()
4363                    .for_each(|value| volume.set_driver(value)),
4364                "driver_opts" => self
4365                    .key_value_mapping(field.value(), "volume driver_opts must be a mapping")
4366                    .into_iter()
4367                    .for_each(|value| volume.set_driver_opts(value)),
4368                "external" => self
4369                    .located_boolean(field.value(), "volume external must be a boolean")
4370                    .into_iter()
4371                    .for_each(|value| volume.set_external(value)),
4372                "labels" => self
4373                    .labels(field.value())
4374                    .into_iter()
4375                    .for_each(|value| volume.set_labels(value)),
4376                "name" => self
4377                    .located_string(field.value(), "volume custom name must be a scalar")
4378                    .into_iter()
4379                    .for_each(|value| volume.set_custom_name(value)),
4380                _ => self.record_root_unmodeled(&path, field),
4381            }
4382        }
4383        Some(volume)
4384    }
4385
4386    fn config_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<ConfigDefinition>> {
4387        let Some(entries) = self.mapping(value, "top-level configs must be a mapping") else {
4388            return Vec::new();
4389        };
4390        entries
4391            .iter()
4392            .filter_map(|entry| {
4393                let definition = self.config_definition(entry)?;
4394                Some(ProjectResource {
4395                    name: ProjectKey::from_entry(entry),
4396                    definition: ProjectValue::new(definition, entry.value()),
4397                })
4398            })
4399            .collect()
4400    }
4401
4402    fn config_definition(&mut self, entry: &MergedEntry) -> Option<ConfigDefinition> {
4403        let value = entry.value();
4404        let span = effective_span(value);
4405        let mut config = ConfigDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
4406        let fields = match value.kind() {
4407            MergedValueKind::Null(_) => return Some(config),
4408            MergedValueKind::Mapping(fields) => fields,
4409            _ => {
4410                self.expected(value, "config definition must be a mapping or null");
4411                return None;
4412            }
4413        };
4414        let path = ["configs".to_owned(), entry.key().to_owned()];
4415        for field in fields {
4416            match field.key() {
4417                "file" => self
4418                    .located_string(field.value(), "config file must be a scalar")
4419                    .into_iter()
4420                    .for_each(|value| config.set_file(value)),
4421                "environment" => self
4422                    .located_string(field.value(), "config environment must be a scalar")
4423                    .into_iter()
4424                    .for_each(|value| config.set_environment(value)),
4425                "content" => self
4426                    .located_string(field.value(), "config content must be a scalar")
4427                    .into_iter()
4428                    .for_each(|value| config.set_content(value)),
4429                "external" => self
4430                    .located_boolean(field.value(), "config external must be a boolean")
4431                    .into_iter()
4432                    .for_each(|value| config.set_external(value)),
4433                "name" => self
4434                    .located_string(field.value(), "config custom name must be a scalar")
4435                    .into_iter()
4436                    .for_each(|value| config.set_custom_name(value)),
4437                _ => self.record_root_unmodeled(&path, field),
4438            }
4439        }
4440        Some(config)
4441    }
4442
4443    fn secret_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<SecretDefinition>> {
4444        let Some(entries) = self.mapping(value, "top-level secrets must be a mapping") else {
4445            return Vec::new();
4446        };
4447        entries
4448            .iter()
4449            .filter_map(|entry| {
4450                let definition = self.secret_definition(entry)?;
4451                Some(ProjectResource {
4452                    name: ProjectKey::from_entry(entry),
4453                    definition: ProjectValue::new(definition, entry.value()),
4454                })
4455            })
4456            .collect()
4457    }
4458
4459    fn secret_definition(&mut self, entry: &MergedEntry) -> Option<SecretDefinition> {
4460        let value = entry.value();
4461        let span = effective_span(value);
4462        let mut secret = SecretDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
4463        let fields = match value.kind() {
4464            MergedValueKind::Null(_) => return Some(secret),
4465            MergedValueKind::Mapping(fields) => fields,
4466            _ => {
4467                self.expected(value, "secret definition must be a mapping or null");
4468                return None;
4469            }
4470        };
4471        let path = ["secrets".to_owned(), entry.key().to_owned()];
4472        for field in fields {
4473            match field.key() {
4474                "file" => self
4475                    .located_string(field.value(), "secret file must be a scalar")
4476                    .into_iter()
4477                    .for_each(|value| secret.set_file(value)),
4478                "environment" => self
4479                    .located_string(field.value(), "secret environment must be a scalar")
4480                    .into_iter()
4481                    .for_each(|value| secret.set_environment(value)),
4482                "external" => self
4483                    .located_boolean(field.value(), "secret external must be a boolean")
4484                    .into_iter()
4485                    .for_each(|value| secret.set_external(value)),
4486                "name" => self
4487                    .located_string(field.value(), "secret custom name must be a scalar")
4488                    .into_iter()
4489                    .for_each(|value| secret.set_custom_name(value)),
4490                _ => self.record_root_unmodeled(&path, field),
4491            }
4492        }
4493        Some(secret)
4494    }
4495
4496    fn labels(&mut self, value: &MergedValue) -> Option<Labels> {
4497        let span = effective_span(value);
4498        match value.kind() {
4499            MergedValueKind::Sequence(_) => self
4500                .located_string_sequence(value, "labels must be a scalar sequence")
4501                .map(|values| Labels::List { span, values }),
4502            MergedValueKind::Mapping(_) => self
4503                .key_value_mapping(value, "labels must be a scalar mapping")
4504                .map(|entries| Labels::Map { span, entries }),
4505            _ => {
4506                self.expected(value, "labels must be a sequence or mapping");
4507                None
4508            }
4509        }
4510    }
4511}
4512
4513fn compose_scalar_from_merged(scalar: &crate::merge::MergedScalar) -> ComposeScalar {
4514    match scalar.kind() {
4515        MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
4516        MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
4517        MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
4518    }
4519}
4520
4521fn field_reference(path: &[String], entry: &MergedEntry) -> ProjectFieldReference {
4522    let mut complete_path = path.to_vec();
4523    complete_path.push(entry.key().to_owned());
4524    ProjectFieldReference {
4525        path: complete_path,
4526        key: ProjectKey::from_entry(entry),
4527        provenance: entry.value().provenance().clone(),
4528        extension: entry.key().starts_with("x-"),
4529        sensitive: entry.value().is_sensitive(),
4530    }
4531}
4532
4533fn effective_span(value: &MergedValue) -> SourceSpan {
4534    value
4535        .provenance()
4536        .effective_source()
4537        .or_else(|| value.provenance().sources().first().copied())
4538        .unwrap_or_else(|| SourceSpan::from_valid_offsets(SourceId::new(0), 0, 0))
4539}
4540
4541fn entry_span(entry: &MergedEntry) -> SourceSpan {
4542    entry
4543        .key_sources()
4544        .last()
4545        .copied()
4546        .or_else(|| entry.key_sources().first().copied())
4547        .unwrap_or_else(|| effective_span(entry.value()))
4548}