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,
9    BUILD_DOCKERFILE_INLINE_CONFLICT, BUILD_NO_CACHE_FILTER_DUPLICATE_ITEM, BindOptions, BlkioScalar, BooleanValue,
10    BuildNoCache, BuildProvenance, BuildSbom, CAP_ADD_DUPLICATE_ITEM, CAP_DROP_DUPLICATE_ITEM, CPU_COUNT_NEGATIVE,
11    CPU_PERCENT_OUT_OF_RANGE, CPU_RT_RUNTIME_INVALID, CapabilityAddItem, CapabilityDropItem, CgroupNamespace, Command,
12    ComposeScalar, ConfigDefinition, CpuCount, CpuPercent, CpuPeriod, CpuQuota, CpuRtPeriod, CpuRtRuntime, Cpus,
13    DEPLOY_ENDPOINT_MODE_PORTABILITY, DEPLOY_MODE_PORTABILITY, DEPLOY_RESERVATION_DEVICE_ALLOCATION_SELECTOR_CONFLICT,
14    DEPLOY_RESERVATION_DEVICE_CAPABILITY_DUPLICATE_ITEM, DEPLOY_RESERVATION_DEVICE_OPTIONS_DUPLICATE_ITEM,
15    DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY, DEPLOY_ROLLBACK_CONFIG_ORDER_PORTABILITY,
16    DEPLOY_UPDATE_CONFIG_ORDER_PORTABILITY, DEVELOP_WATCH_EXEC_MISSING_COMMAND, DEVELOP_WATCH_INVALID_ACTION,
17    DEVELOP_WATCH_MISSING_ACTION, DEVELOP_WATCH_MISSING_EXEC, DEVELOP_WATCH_MISSING_PATH, DEVELOP_WATCH_MISSING_TARGET,
18    DEVICE_EXPECTED_FORM, DEVICE_EXPECTED_STRING, DNS_EXPECTED_FORM, DNS_EXPECTED_STRING, DNS_OPT_DUPLICATE_ITEM,
19    DNS_OPT_EXPECTED_SEQUENCE, DNS_OPT_EXPECTED_STRING, DNS_SEARCH_DUPLICATE_ITEM, DNS_SEARCH_EXPECTED_FORM,
20    DNS_SEARCH_EXPECTED_STRING, DependencyCondition, DeployDiscreteResourceValue, DeployEndpointMode, DeployMode,
21    DeployPlacementMaxReplicasPerNode, DeployReplicas, DeployReservationDeviceCount, DeployResourceCpus,
22    DeployResourceMemory, DeployResourcePids, DeployRestartCondition, DeployRestartDuration, DeployRestartMaxAttempts,
23    DeployRollbackMaxFailureRatio, DeployRollbackOrder, DeployRollbackParallelism, DeployUpdateMaxFailureRatio,
24    DeployUpdateOrder, DeployUpdateParallelism, Develop, DevelopWatch, DevelopWatchExec, EXPOSE_DUPLICATE_ITEM,
25    EXPOSE_EXPECTED_SCALAR, EXPOSE_EXPECTED_SEQUENCE, EXPOSE_INVALID_ITEM, EXPOSE_PROVIDER_DEPENDENT,
26    EXTENDS_MISSING_SERVICE, Entrypoint, Environment, EnvironmentFileFormat, EnvironmentFileFormatKind,
27    EnvironmentListEntry, EnvironmentMapEntry, ExposeItemKind, ExposeScalarKind, ExternalNameMapping, FieldReference,
28    GPU_MISSING_CAPABILITIES, GpuDevice, GpuOptions, Gpus, HealthcheckDuration, HealthcheckRetries, HealthcheckTest,
29    HealthcheckTestKind, HostAddress, Hostname, HostnameKind, ImageMountOptions, ImageReference, IncludeItem,
30    IncludeLong, Includes, Ipam, IpamConfig, IpcMode, KeyValueEntry, LOGGING_DRIVER_EXPECTED_STRING,
31    LOGGING_EXPECTED_MAPPING, LOGGING_OPTION_EMPTY_KEY, LOGGING_OPTION_EXPECTED_SCALAR,
32    LOGGING_OPTIONS_EXPECTED_MAPPING, Labels, LimitValue, Located, LongPort, LongVolumeMount, MEM_LIMIT_AMBIGUOUS_ZERO,
33    MEM_LIMIT_EXPECTED_VALUE, MEM_LIMIT_PROVIDER_DEPENDENT_STRING, MEM_LIMIT_SCHEMA_NUMBER,
34    MEMSWAP_LIMIT_EXPECTED_VALUE, MEMSWAP_LIMIT_INVALID, MemLimit, MemLimitKind, MemLimitScalarKind, MemswapLimit,
35    MemswapLimitKind, MemswapLimitScalarKind, ModelDefinition, ModelDefinitions, MountType, NetworkDefinition,
36    NetworkMode, PIDS_LIMIT_AMBIGUOUS_ZERO, POST_START_MISSING_COMMAND, PRE_STOP_MISSING_COMMAND,
37    PROVIDER_MISSING_TYPE, PidMode, PidsLimit, PidsLimitKind, Port, PullPolicy, ResourceExternal, RestartPolicy,
38    SECURITY_OPT_APPARMOR_CONFLICT, SECURITY_OPT_APPARMOR_NEAR_MISS, SECURITY_OPT_EMPTY_ITEM,
39    SECURITY_OPT_EXPECTED_SEQUENCE, SECURITY_OPT_EXPECTED_STRING, SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT,
40    SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS, SECURITY_OPT_SECCOMP_CONFLICT, SECURITY_OPT_SECCOMP_NEAR_MISS,
41    SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT, SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS,
42    SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT, SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS,
43    SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT, SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS,
44    SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT, SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS,
45    SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT, SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS, SHM_SIZE_AMBIGUOUS_ZERO,
46    SHM_SIZE_EXPECTED_VALUE, SHM_SIZE_PROVIDER_DEPENDENT_NUMBER, SHM_SIZE_PROVIDER_DEPENDENT_STRING,
47    SYSCTLS_DUPLICATE_ITEM, SYSCTLS_EMPTY_KEY, SYSCTLS_EXPECTED_FORM, SYSCTLS_EXPECTED_SCALAR, SYSCTLS_EXPECTED_STRING,
48    SecretDefinition, SecurityOptionCandidateCounts, SecurityOptionKind, SelinuxRelabel, ServiceInteger,
49    ServiceModelBinding, ServiceModels, ServiceNetwork, ServiceNetworks, ShmSize, ShmSizeKind, ShmSizeScalarKind,
50    ShortDevice, ShortExtraHost, ShortPort, ShortVolumeMount, StopGracePeriod, TMPFS_EXPECTED_FORM,
51    TMPFS_EXPECTED_STRING, TMPFS_PROVIDER_DEPENDENT, TmpfsItem, TmpfsItemKind, TmpfsMountOptions, ULIMIT_INVALID_NAME,
52    ULIMIT_INVALID_VALUE, ULIMIT_MISSING_RANGE_MEMBER, UserNamespaceMode, UserSpec,
53    VOLUME_EXTERNAL_DRIVER_CONFIGURATION, VOLUME_EXTERNAL_LABELS_CONFIGURATION, VolumeDefinition, VolumeMount,
54    VolumeMountOptions, VolumesFrom, classify_expose_item, classify_security_option, security_path_option_diagnostic,
55    valid_ulimit_name,
56};
57use crate::profiles::ProfileSelection;
58use crate::resolution::{SELECTION_PROJECT_MISMATCH, service_in_scope};
59use crate::source::{SourceId, SourceSpan};
60use std::collections::{BTreeMap, BTreeSet};
61use std::fmt;
62use std::path::{Path, PathBuf};
63use std::sync::Arc;
64
65/// A value in the merged project has an unexpected mapping, sequence, scalar, or null form.
66pub const PROJECT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.project.expected-form");
67
68/// A required field is absent from a merged native value.
69pub const PROJECT_MISSING_FIELD: DiagnosticCode = DiagnosticCode::new("compose.project.missing-field");
70
71/// A scalar cannot be represented by the requested native value type.
72pub const PROJECT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.project.invalid-value");
73/// A malformed effective item from a sequence requiring YAML string scalars.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct ProjectInvalidServiceStringItem {
76    span: SourceSpan,
77}
78
79type ProjectStringCollection = Option<ProjectValue<Vec<ProjectValue<String>>>>;
80type ProjectVolumesFromCollection = Option<ProjectValue<Vec<ProjectValue<VolumesFrom>>>>;
81type ProjectInvalidServiceStringItems = Vec<ProjectInvalidServiceStringItem>;
82impl ProjectInvalidServiceStringItem {
83    /// Returns the malformed item's effective source span.
84    #[must_use]
85    pub const fn span(self) -> SourceSpan {
86        self.span
87    }
88}
89/// Service CPU allocation conflicts with its deploy CPU limit spelling.
90pub const SERVICE_CPUS_DEPLOY_LIMIT_CONFLICT: DiagnosticCode =
91    DiagnosticCode::new("compose.service.cpus.deploy-limit-conflict");
92/// Service memory reservation coexists with a deploy reservation.
93pub const SERVICE_MEMORY_RESERVATION_DEPLOY_CONFLICT: DiagnosticCode =
94    DiagnosticCode::new("compose.service.mem-reservation.deploy-reservation-conflict");
95/// A memory-plus-swap limit requires an explicit service memory limit.
96pub const SERVICE_MEMSWAP_REQUIRES_MEMORY_LIMIT: DiagnosticCode =
97    DiagnosticCode::new("compose.service.memswap-limit.requires-mem-limit");
98/// A positive memory-plus-swap limit is below a comparable service memory limit.
99pub const SERVICE_MEMSWAP_BELOW_MEMORY_LIMIT: DiagnosticCode =
100    DiagnosticCode::new("compose.service.memswap-limit.below-mem-limit");
101/// Service network mode and explicit networks are mutually exclusive.
102pub const SERVICE_NETWORK_MODE_NETWORKS_CONFLICT: DiagnosticCode =
103    DiagnosticCode::new("compose.service.network-mode.networks-conflict");
104/// Service scale conflicts with the deploy replica spelling.
105pub const SERVICE_SCALE_DEPLOY_REPLICAS_CONFLICT: DiagnosticCode =
106    DiagnosticCode::new("compose.service.scale.deploy-replicas-conflict");
107/// A scalar GPU selector coexists with deploy device reservations and needs caller reconciliation.
108pub const SERVICE_GPUS_DEPLOY_DEVICES_CONFLICT: DiagnosticCode =
109    DiagnosticCode::new("compose.service.gpus.deploy-devices-conflict");
110
111/// A typed value together with every source span that contributed to it during merging.
112#[derive(Clone, PartialEq, Eq)]
113pub struct ProjectValue<T> {
114    value: T,
115    provenance: MergeProvenance,
116    sensitive: bool,
117}
118
119impl<T: fmt::Debug> fmt::Debug for ProjectValue<T> {
120    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
121        let mut debug = formatter.debug_struct("ProjectValue");
122        if self.sensitive {
123            debug.field("value", &"<redacted>");
124        } else {
125            debug.field("value", &self.value);
126        }
127        debug
128            .field("provenance", &self.provenance)
129            .field("sensitive", &self.sensitive)
130            .finish()
131    }
132}
133
134impl<T> ProjectValue<T> {
135    fn new(value: T, source: &MergedValue) -> Self {
136        Self {
137            value,
138            provenance: source.provenance().clone(),
139            sensitive: source.is_sensitive(),
140        }
141    }
142
143    fn new_sensitive(value: T, source: &MergedValue) -> Self {
144        Self {
145            value,
146            provenance: source.provenance().clone(),
147            sensitive: true,
148        }
149    }
150
151    /// Returns the typed effective value.
152    /// Returns effective no-cache filter form with collection provenance.
153    /// Returns effective update parallelism with provenance.
154    #[must_use]
155    pub const fn value(&self) -> &T {
156        &self.value
157    }
158
159    /// Returns the merge operation and contributing spans in processing order.
160    /// Returns effective update delay with provenance.
161    #[must_use]
162    pub const fn provenance(&self) -> &MergeProvenance {
163        &self.provenance
164    }
165
166    /// Returns the most recent source contributing to this value.
167    /// Returns effective update monitor with provenance.
168    #[must_use]
169    pub fn effective_source(&self) -> Option<SourceSpan> {
170        self.provenance.effective_source()
171    }
172
173    /// Reports whether this value contains sensitive interpolation output.
174    /// Returns effective update failure action with provenance.
175    #[must_use]
176    pub const fn is_sensitive(&self) -> bool {
177        self.sensitive
178    }
179
180    /// Removes the provenance wrapper and returns the typed value.
181    /// Returns effective maximum failure ratio with provenance.
182    #[must_use]
183    pub fn into_value(self) -> T {
184        self.value
185    }
186}
187
188/// A merged mapping key and every location at which that key was authored.
189#[derive(Clone, PartialEq, Eq)]
190pub struct ProjectKey {
191    value: String,
192    sources: Vec<SourceSpan>,
193    sensitive: bool,
194}
195
196impl fmt::Debug for ProjectKey {
197    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
198        formatter
199            .debug_struct("ProjectKey")
200            .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
201            .field("sources", &self.sources)
202            .field("sensitive", &self.sensitive)
203            .finish()
204    }
205}
206
207impl ProjectKey {
208    fn from_entry(entry: &MergedEntry) -> Self {
209        Self {
210            value: entry.key().to_owned(),
211            sources: entry.key_sources().to_vec(),
212            sensitive: entry.is_key_sensitive(),
213        }
214    }
215
216    fn from_sensitive_entry(entry: &MergedEntry) -> Self {
217        Self {
218            value: entry.key().to_owned(),
219            sources: entry.key_sources().to_vec(),
220            sensitive: true,
221        }
222    }
223
224    fn from_value(value: String, source: &MergedValue) -> Self {
225        Self {
226            value,
227            sources: source.provenance().sources().to_vec(),
228            sensitive: source.is_sensitive(),
229        }
230    }
231
232    /// Returns the semantic key text.
233    /// Returns effective update order with provenance.
234    #[must_use]
235    pub fn value(&self) -> &str {
236        &self.value
237    }
238
239    /// Returns authored key locations in merge order.
240    /// Returns malformed and unknown retained update members.
241    #[must_use]
242    pub fn sources(&self) -> &[SourceSpan] {
243        &self.sources
244    }
245
246    /// Returns the effective key location.
247    #[must_use]
248    pub fn effective_source(&self) -> Option<SourceSpan> {
249        self.sources.last().copied()
250    }
251
252    /// Reports whether interpolation inserted sensitive content into this semantic key.
253    #[must_use]
254    pub const fn is_sensitive(&self) -> bool {
255        self.sensitive
256    }
257}
258
259/// One effective service dependency with source-aware long-form options.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct ProjectServiceDependency {
262    service: ProjectKey,
263    condition: Option<ProjectValue<DependencyCondition>>,
264    restart: Option<ProjectValue<BooleanValue>>,
265    required: Option<ProjectValue<BooleanValue>>,
266    unmodeled_fields: Vec<ProjectFieldReference>,
267}
268
269impl ProjectServiceDependency {
270    /// Returns the referenced service name and all contributing name locations.
271    #[must_use]
272    pub const fn service(&self) -> &ProjectKey {
273        &self.service
274    }
275
276    /// Returns the explicitly authored readiness condition.
277    #[must_use]
278    pub const fn condition(&self) -> Option<&ProjectValue<DependencyCondition>> {
279        self.condition.as_ref()
280    }
281
282    /// Returns whether Compose-controlled dependency updates restart this service.
283    #[must_use]
284    pub const fn restart(&self) -> Option<&ProjectValue<BooleanValue>> {
285        self.restart.as_ref()
286    }
287
288    /// Returns whether the dependency is required.
289    #[must_use]
290    pub const fn required(&self) -> Option<&ProjectValue<BooleanValue>> {
291        self.required.as_ref()
292    }
293
294    /// Returns retained long-form fields outside the typed dependency boundary.
295    #[must_use]
296    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
297        &self.unmodeled_fields
298    }
299}
300
301/// Effective service dependencies with the short or long Compose form retained.
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub enum ProjectDependsOn {
304    /// A sequence of service names using Compose defaults.
305    Short(Vec<ProjectValue<ProjectServiceDependency>>),
306    /// A mapping of service names to dependency options.
307    Long(Vec<ProjectValue<ProjectServiceDependency>>),
308}
309
310impl ProjectDependsOn {
311    /// Returns dependencies in effective merge order.
312    #[must_use]
313    pub fn services(&self) -> &[ProjectValue<ProjectServiceDependency>] {
314        match self {
315            Self::Short(services) | Self::Long(services) => services,
316        }
317    }
318
319    /// Reports whether the effective field uses long mapping syntax.
320    #[must_use]
321    pub const fn is_long(&self) -> bool {
322        matches!(self, Self::Long(_))
323    }
324}
325
326/// A field retained by the merged tree but outside the first native project-view boundary.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub struct ProjectFieldReference {
329    path: Vec<String>,
330    key: ProjectKey,
331    provenance: MergeProvenance,
332    extension: bool,
333    sensitive: bool,
334}
335
336impl ProjectFieldReference {
337    /// Returns the semantic path including the field name.
338    #[must_use]
339    pub fn path(&self) -> &[String] {
340        &self.path
341    }
342
343    /// Returns the retained mapping key and all of its source locations.
344    #[must_use]
345    pub const fn key(&self) -> &ProjectKey {
346        &self.key
347    }
348
349    /// Returns the field value's complete merge provenance.
350    #[must_use]
351    pub const fn provenance(&self) -> &MergeProvenance {
352        &self.provenance
353    }
354
355    /// Reports whether the field name starts with `x-`.
356    #[must_use]
357    pub const fn is_extension(&self) -> bool {
358        self.extension
359    }
360
361    /// Reports whether the retained value contains sensitive interpolation output.
362    #[must_use]
363    pub const fn is_sensitive(&self) -> bool {
364        self.sensitive
365    }
366}
367
368/// One effective environment variable after field-specific multi-file merging.
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct ProjectEnvironmentEntry {
371    name: ProjectKey,
372    value: ProjectValue<ComposeScalar>,
373    syntax: EntrySyntax,
374}
375
376impl ProjectEnvironmentEntry {
377    /// Returns the variable name and its contributing key spans.
378    #[must_use]
379    pub const fn name(&self) -> &ProjectKey {
380        &self.name
381    }
382
383    /// Returns the effective scalar, including a distinct host-environment null value.
384    #[must_use]
385    pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
386        &self.value
387    }
388
389    /// Returns the most recent mapping or list syntax contributing this entry.
390    #[must_use]
391    pub const fn syntax(&self) -> EntrySyntax {
392        self.syntax
393    }
394}
395
396/// A normalized-by-key environment view that retains each entry's authored syntax form.
397#[derive(Debug, Clone, PartialEq, Eq)]
398pub struct ProjectEnvironment {
399    entries: Vec<ProjectEnvironmentEntry>,
400}
401
402/// One effective service environment-file entry with syntax and item provenance retained.
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub enum ProjectEnvironmentFile {
405    /// Scalar path syntax.
406    Short(String),
407    /// Mapping syntax with field-level provenance.
408    Long(Box<ProjectLongEnvironmentFile>),
409}
410
411/// Effective long-syntax service environment-file options.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct ProjectLongEnvironmentFile {
414    path: Option<ProjectValue<String>>,
415    required: Option<ProjectValue<BooleanValue>>,
416    format: Option<ProjectValue<EnvironmentFileFormat>>,
417    unmodeled_fields: Vec<ProjectFieldReference>,
418}
419
420impl ProjectLongEnvironmentFile {
421    /// Returns the required environment-file path.
422    #[must_use]
423    pub const fn path(&self) -> Option<&ProjectValue<String>> {
424        self.path.as_ref()
425    }
426
427    /// Returns the explicit required-file choice; absence means Compose's default `true`.
428    #[must_use]
429    pub const fn required(&self) -> Option<&ProjectValue<BooleanValue>> {
430        self.required.as_ref()
431    }
432
433    /// Returns the explicit parser format; absence means Compose's default format.
434    #[must_use]
435    pub const fn format(&self) -> Option<&ProjectValue<EnvironmentFileFormat>> {
436        self.format.as_ref()
437    }
438
439    /// Returns retained long-form fields outside the typed project-view boundary.
440    #[must_use]
441    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
442        &self.unmodeled_fields
443    }
444}
445
446/// One effective service or deployment metadata label after field-specific multi-file merging.
447#[derive(Debug, Clone, PartialEq, Eq)]
448pub struct ProjectLabelEntry {
449    name: ProjectKey,
450    value: ProjectValue<ComposeScalar>,
451    syntax: EntrySyntax,
452}
453
454impl ProjectLabelEntry {
455    /// Returns the label name and its contributing key spans.
456    #[must_use]
457    pub const fn name(&self) -> &ProjectKey {
458        &self.name
459    }
460
461    /// Returns the effective label scalar.
462    ///
463    /// A key-only list entry has an explicit empty-string value while retaining
464    /// [`EntrySyntax::ListKeyOnly`] as its authored form.
465    #[must_use]
466    pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
467        &self.value
468    }
469
470    /// Returns the most recent mapping or list syntax contributing this entry.
471    #[must_use]
472    pub const fn syntax(&self) -> EntrySyntax {
473        self.syntax
474    }
475}
476
477/// An effective service or deployment label view retaining collection and entry syntax.
478#[derive(Debug, Clone, PartialEq, Eq)]
479pub struct ProjectLabels {
480    form: ProjectLabelsForm,
481    entries: Vec<ProjectLabelEntry>,
482}
483
484/// The effective syntax form of a service or deployment label collection.
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486#[non_exhaustive]
487pub enum ProjectLabelsForm {
488    /// Mapping syntax with scalar or null values.
489    Map,
490    /// Ordered scalar list syntax.
491    List,
492}
493
494/// Effective build labels retaining their mapping or list syntax.
495///
496/// Mapping entries use the same key/value representation as service labels. List entries remain
497/// raw strings so order, duplicates, and bare-label spelling are not normalized away.
498#[derive(Debug, Clone, PartialEq, Eq)]
499#[non_exhaustive]
500pub enum ProjectBuildLabels {
501    /// Mapping syntax with keyed label entries.
502    Map(Vec<ProjectLabelEntry>),
503    /// List syntax with raw ordered label strings.
504    List(Vec<ProjectValue<String>>),
505}
506
507/// One effective mapping-form build argument with key and scalar provenance retained.
508#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct ProjectBuildArgEntry {
510    name: ProjectKey,
511    value: ProjectValue<ComposeScalar>,
512}
513
514impl ProjectBuildArgEntry {
515    /// Returns the argument name and every contributing key location.
516    #[must_use]
517    pub const fn name(&self) -> &ProjectKey {
518        &self.name
519    }
520
521    /// Returns the effective string, number, boolean, or null value with merge provenance.
522    #[must_use]
523    pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
524        &self.value
525    }
526}
527
528/// Effective build arguments retaining their mapping or list syntax.
529///
530/// Mapping entries merge by exact argument name. List entries remain raw ordered strings, so
531/// duplicates and bare argument names are not normalized or resolved from the environment.
532#[derive(Debug, Clone, PartialEq, Eq)]
533#[non_exhaustive]
534pub enum ProjectBuildArgs {
535    /// Mapping syntax with keyed argument entries.
536    Map(Vec<ProjectBuildArgEntry>),
537    /// List syntax with raw ordered argument strings.
538    List(Vec<ProjectValue<String>>),
539}
540
541/// One effective mapping-form additional build context with key and scalar provenance retained.
542#[derive(Debug, Clone, PartialEq, Eq)]
543pub struct ProjectBuildAdditionalContextEntry {
544    name: ProjectKey,
545    value: ProjectValue<ComposeScalar>,
546}
547
548impl ProjectBuildAdditionalContextEntry {
549    /// Returns the context name and every contributing key location.
550    #[must_use]
551    pub const fn name(&self) -> &ProjectKey {
552        &self.name
553    }
554
555    /// Returns the effective scalar context value with merge provenance.
556    #[must_use]
557    pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
558        &self.value
559    }
560}
561
562/// Effective additional build contexts retaining their mapping or list syntax.
563///
564/// List entries remain raw ordered strings, including duplicates and `NAME=VALUE` spelling.
565/// Mapping entries merge by exact key. Neither form interprets context names, paths, URLs,
566/// images, service schemes, or builder behavior.
567#[derive(Debug, Clone, PartialEq, Eq)]
568#[non_exhaustive]
569pub enum ProjectBuildAdditionalContexts {
570    /// Mapping syntax with keyed scalar entries.
571    Map(Vec<ProjectBuildAdditionalContextEntry>),
572    /// List syntax with raw ordered entries.
573    List(Vec<ProjectValue<String>>),
574}
575
576/// One effective mapping-form build host entry with complete key and address provenance.
577#[derive(Debug, Clone, PartialEq, Eq)]
578pub struct ProjectBuildExtraHostEntry {
579    hostname: ProjectKey,
580    addresses: ProjectBuildExtraHostAddresses,
581}
582
583impl ProjectBuildExtraHostEntry {
584    /// Returns the raw hostname key and every contributing key span.
585    #[must_use]
586    pub const fn hostname(&self) -> &ProjectKey {
587        &self.hostname
588    }
589
590    /// Returns the scalar or ordered-list address form with nested provenance.
591    #[must_use]
592    pub const fn addresses(&self) -> &ProjectBuildExtraHostAddresses {
593        &self.addresses
594    }
595}
596
597/// Effective build-host addresses for one mapping key.
598#[derive(Debug, Clone, PartialEq, Eq)]
599#[non_exhaustive]
600pub enum ProjectBuildExtraHostAddresses {
601    /// One raw string address.
602    Scalar(ProjectValue<String>),
603    /// Ordered raw string addresses.
604    List(Vec<ProjectValue<String>>),
605}
606
607impl ProjectBuildExtraHostAddresses {
608    /// Returns the scalar address when that effective form is present.
609    #[must_use]
610    pub const fn as_scalar(&self) -> Option<&ProjectValue<String>> {
611        match self {
612            Self::Scalar(value) => Some(value),
613            Self::List(_) => None,
614        }
615    }
616
617    /// Returns the ordered address list when that effective form is present.
618    #[must_use]
619    pub fn as_list(&self) -> Option<&[ProjectValue<String>]> {
620        let Self::List(values) = self else {
621            return None;
622        };
623        Some(values)
624    }
625}
626
627/// Effective build-time host mappings, intentionally distinct from service [`ProjectExtraHosts`].
628///
629/// List entries remain raw ordered strings, including duplicates. Mapping entries retain raw
630/// hostname keys and scalar/list address syntax without applying service-host parsing.
631#[derive(Debug, Clone, PartialEq, Eq)]
632#[non_exhaustive]
633pub enum ProjectBuildExtraHosts {
634    /// Ordered raw string entries.
635    List(Vec<ProjectValue<String>>),
636    /// Ordered hostname keys with scalar or list addresses.
637    Map(Vec<ProjectBuildExtraHostEntry>),
638}
639
640impl ProjectBuildExtraHosts {
641    /// Returns ordered raw entries when the effective form is a list.
642    #[must_use]
643    pub fn as_list(&self) -> Option<&[ProjectValue<String>]> {
644        let Self::List(entries) = self else {
645            return None;
646        };
647        Some(entries)
648    }
649
650    /// Returns ordered hostname entries when the effective form is a mapping.
651    #[must_use]
652    pub fn as_map(&self) -> Option<&[ProjectBuildExtraHostEntry]> {
653        let Self::Map(entries) = self else {
654            return None;
655        };
656        Some(entries)
657    }
658}
659
660/// One effective mapping-form `BuildKit` SSH grant with sensitive key and value provenance.
661#[derive(Clone, PartialEq, Eq)]
662pub struct ProjectBuildSshEntry {
663    name: ProjectKey,
664    value: ProjectValue<ComposeScalar>,
665}
666
667impl fmt::Debug for ProjectBuildSshEntry {
668    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
669        formatter
670            .debug_struct("ProjectBuildSshEntry")
671            .field("name", &"<redacted>")
672            .field("value", &"<redacted>")
673            .finish()
674    }
675}
676
677impl ProjectBuildSshEntry {
678    /// Explicitly returns the raw SSH grant name and its key provenance.
679    #[must_use]
680    pub const fn name(&self) -> &ProjectKey {
681        &self.name
682    }
683
684    /// Explicitly returns the raw SSH grant scalar and its merge provenance.
685    #[must_use]
686    pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
687        &self.value
688    }
689}
690
691/// Effective sensitive `BuildKit` SSH grants retaining mapping or list syntax.
692#[derive(Clone, PartialEq, Eq)]
693#[non_exhaustive]
694pub enum ProjectBuildSsh {
695    /// Mapping syntax with sensitive keyed scalar entries.
696    Map(Vec<ProjectBuildSshEntry>),
697    /// List syntax with sensitive raw ordered grant strings.
698    List(Vec<ProjectValue<String>>),
699}
700
701impl fmt::Debug for ProjectBuildSsh {
702    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
703        let form = match self {
704            Self::Map(_) => "Map",
705            Self::List(_) => "List",
706        };
707        formatter
708            .debug_struct("ProjectBuildSsh")
709            .field("form", &form)
710            .field("entries", &"<redacted>")
711            .finish()
712    }
713}
714
715impl ProjectBuildSsh {
716    /// Explicitly returns raw mapping-form SSH grants.
717    #[must_use]
718    pub fn as_map(&self) -> Option<&[ProjectBuildSshEntry]> {
719        let Self::Map(entries) = self else {
720            return None;
721        };
722        Some(entries)
723    }
724
725    /// Explicitly returns raw list-form SSH grants.
726    #[must_use]
727    pub fn as_list(&self) -> Option<&[ProjectValue<String>]> {
728        let Self::List(entries) = self else {
729            return None;
730        };
731        Some(entries)
732    }
733}
734
735impl ProjectBuildAdditionalContexts {
736    /// Returns mapping entries when the effective additional contexts use mapping syntax.
737    #[must_use]
738    pub fn as_map(&self) -> Option<&[ProjectBuildAdditionalContextEntry]> {
739        let Self::Map(entries) = self else {
740            return None;
741        };
742        Some(entries)
743    }
744
745    /// Returns raw ordered entries when the effective additional contexts use list syntax.
746    #[must_use]
747    pub fn as_list(&self) -> Option<&[ProjectValue<String>]> {
748        let Self::List(entries) = self else {
749            return None;
750        };
751        Some(entries)
752    }
753}
754
755impl ProjectBuildArgs {
756    /// Returns mapping entries when the effective build arguments use mapping syntax.
757    #[must_use]
758    pub fn as_map(&self) -> Option<&[ProjectBuildArgEntry]> {
759        let Self::Map(entries) = self else {
760            return None;
761        };
762        Some(entries)
763    }
764
765    /// Returns raw ordered entries when the effective build arguments use list syntax.
766    #[must_use]
767    pub fn as_list(&self) -> Option<&[ProjectValue<String>]> {
768        let Self::List(entries) = self else {
769            return None;
770        };
771        Some(entries)
772    }
773}
774
775impl ProjectBuildLabels {
776    /// Returns mapping entries when the effective build labels use mapping syntax.
777    #[must_use]
778    pub fn as_map(&self) -> Option<&[ProjectLabelEntry]> {
779        let Self::Map(entries) = self else {
780            return None;
781        };
782        Some(entries)
783    }
784
785    /// Returns raw ordered entries when the effective build labels use list syntax.
786    #[must_use]
787    pub fn as_list(&self) -> Option<&[ProjectValue<String>]> {
788        let Self::List(entries) = self else {
789            return None;
790        };
791        Some(entries)
792    }
793}
794
795/// One raw/effective annotation scalar without erasing authored spelling.
796#[derive(Debug, Clone, PartialEq, Eq)]
797pub struct ProjectAnnotationScalar {
798    authored: String,
799    effective: ComposeScalar,
800}
801
802impl ProjectAnnotationScalar {
803    /// Returns the exact authored scalar spelling retained by the merge layer.
804    #[must_use]
805    pub fn authored(&self) -> &str {
806        &self.authored
807    }
808
809    /// Returns the effective scalar after optional per-file interpolation.
810    #[must_use]
811    pub const fn effective(&self) -> &ComposeScalar {
812        &self.effective
813    }
814}
815
816/// One effective service annotation after keyed merge and duplicate replacement.
817#[derive(Debug, Clone, PartialEq, Eq)]
818pub struct ProjectAnnotationEntry {
819    name: ProjectKey,
820    value: Option<ProjectValue<ProjectAnnotationScalar>>,
821    raw_list_item: Option<ProjectValue<ProjectAnnotationScalar>>,
822    syntax: EntrySyntax,
823    contributors: Vec<MergeProvenance>,
824}
825
826impl ProjectAnnotationEntry {
827    /// Returns the effective annotation name and every contributing key/item location.
828    #[must_use]
829    pub const fn name(&self) -> &ProjectKey {
830        &self.name
831    }
832
833    /// Returns the explicit effective annotation value.
834    ///
835    /// A key-only list item returns `None`; it is diagnosed and never coerced to an empty string.
836    #[must_use]
837    pub const fn value(&self) -> Option<&ProjectValue<ProjectAnnotationScalar>> {
838        self.value.as_ref()
839    }
840
841    /// Returns the complete raw list scalar when list syntax supplied the effective entry.
842    #[must_use]
843    pub const fn raw_list_item(&self) -> Option<&ProjectValue<ProjectAnnotationScalar>> {
844        self.raw_list_item.as_ref()
845    }
846
847    /// Returns the most recent mapping or list syntax contributing this entry.
848    #[must_use]
849    pub const fn syntax(&self) -> EntrySyntax {
850        self.syntax
851    }
852
853    /// Returns every replaced contributor's merge provenance in authored order.
854    #[must_use]
855    pub fn contributors(&self) -> &[MergeProvenance] {
856        &self.contributors
857    }
858}
859
860/// Effective service annotations keyed by semantic name with authored evidence retained.
861#[derive(Debug, Clone, PartialEq, Eq)]
862pub struct ProjectAnnotations {
863    entries: Vec<ProjectAnnotationEntry>,
864}
865
866impl ProjectAnnotations {
867    /// Returns effective annotations in first-key order.
868    #[must_use]
869    pub fn entries(&self) -> &[ProjectAnnotationEntry] {
870        &self.entries
871    }
872
873    /// Finds an effective annotation by name.
874    #[must_use]
875    pub fn get(&self, name: &str) -> Option<&ProjectAnnotationEntry> {
876        self.entries.iter().find(|entry| entry.name.value == name)
877    }
878}
879
880impl ProjectLabels {
881    /// Returns whether the effective collection uses mapping or list syntax.
882    #[must_use]
883    pub const fn form(&self) -> ProjectLabelsForm {
884        self.form
885    }
886
887    /// Returns labels in effective merge order.
888    #[must_use]
889    pub fn entries(&self) -> &[ProjectLabelEntry] {
890        &self.entries
891    }
892
893    /// Finds an effective label by name.
894    #[must_use]
895    pub fn get(&self, name: &str) -> Option<&ProjectLabelEntry> {
896        self.entries.iter().find(|entry| entry.name.value == name)
897    }
898}
899
900/// One effective hostname-to-address mapping after field-specific project merging.
901#[derive(Debug, Clone, PartialEq, Eq)]
902pub struct ProjectExtraHost {
903    hostname: ProjectKey,
904    address: ProjectValue<HostAddress>,
905    syntax: EntrySyntax,
906}
907
908impl ProjectExtraHost {
909    /// Returns the hostname and every contributing source location.
910    #[must_use]
911    pub const fn hostname(&self) -> &ProjectKey {
912        &self.hostname
913    }
914
915    /// Returns the raw-preserving IP address or implementation token.
916    #[must_use]
917    pub const fn address(&self) -> &ProjectValue<HostAddress> {
918        &self.address
919    }
920
921    /// Returns the most recent mapping or list syntax contributing this entry.
922    #[must_use]
923    pub const fn syntax(&self) -> EntrySyntax {
924        self.syntax
925    }
926}
927
928/// Ordered effective `extra_hosts` entries with field and item provenance.
929#[derive(Debug, Clone, PartialEq, Eq)]
930pub struct ProjectExtraHosts {
931    entries: Vec<ProjectExtraHost>,
932}
933
934impl ProjectExtraHosts {
935    /// Returns host mappings in effective merge order.
936    #[must_use]
937    pub fn entries(&self) -> &[ProjectExtraHost] {
938        &self.entries
939    }
940}
941
942impl ProjectEnvironment {
943    /// Returns environment variables in effective merge order.
944    #[must_use]
945    pub fn entries(&self) -> &[ProjectEnvironmentEntry] {
946        &self.entries
947    }
948
949    /// Finds an effective environment variable by name.
950    #[must_use]
951    pub fn get(&self, name: &str) -> Option<&ProjectEnvironmentEntry> {
952        self.entries.iter().find(|entry| entry.name.value == name)
953    }
954}
955
956/// One effective service health check with field-level merge provenance.
957#[derive(Debug, Clone, PartialEq, Eq)]
958pub struct ProjectHealthcheck {
959    test: Option<ProjectValue<HealthcheckTest>>,
960    interval: Option<ProjectValue<HealthcheckDuration>>,
961    timeout: Option<ProjectValue<HealthcheckDuration>>,
962    retries: Option<ProjectValue<HealthcheckRetries>>,
963    start_period: Option<ProjectValue<HealthcheckDuration>>,
964    start_interval: Option<ProjectValue<HealthcheckDuration>>,
965    disable: Option<ProjectValue<BooleanValue>>,
966    unmodeled_fields: Vec<ProjectFieldReference>,
967}
968
969/// Effective long-form service config or secret grant with field-level merge provenance.
970#[derive(Debug, Clone, PartialEq, Eq)]
971pub struct ProjectLongGrant {
972    source: Option<ProjectValue<String>>,
973    target: Option<ProjectValue<String>>,
974    uid: Option<ProjectValue<String>>,
975    gid: Option<ProjectValue<String>>,
976    mode: Option<ProjectValue<String>>,
977    unmodeled_fields: Vec<ProjectFieldReference>,
978}
979
980impl ProjectLongGrant {
981    /// Returns the referenced top-level resource name.
982    #[must_use]
983    pub const fn source(&self) -> Option<&ProjectValue<String>> {
984        self.source.as_ref()
985    }
986
987    /// Returns the requested container path or name.
988    #[must_use]
989    pub const fn target(&self) -> Option<&ProjectValue<String>> {
990        self.target.as_ref()
991    }
992
993    /// Returns the requested container user-ID spelling.
994    #[must_use]
995    pub const fn uid(&self) -> Option<&ProjectValue<String>> {
996        self.uid.as_ref()
997    }
998
999    /// Returns the requested container group-ID spelling.
1000    #[must_use]
1001    pub const fn gid(&self) -> Option<&ProjectValue<String>> {
1002        self.gid.as_ref()
1003    }
1004
1005    /// Returns the requested permission-mode spelling.
1006    #[must_use]
1007    pub const fn mode(&self) -> Option<&ProjectValue<String>> {
1008        self.mode.as_ref()
1009    }
1010
1011    /// Returns retained long-form fields outside the typed project-view boundary.
1012    #[must_use]
1013    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
1014        &self.unmodeled_fields
1015    }
1016}
1017
1018/// One effective service config or secret grant with its Compose syntax form retained.
1019#[derive(Debug, Clone, PartialEq, Eq)]
1020pub enum ProjectGrant {
1021    /// Resource-name short syntax.
1022    Short(String),
1023    /// Mapping-based long syntax.
1024    Long(Box<ProjectLongGrant>),
1025}
1026
1027/// Effective long-form service device with nested merge provenance retained.
1028#[derive(Debug, Clone, PartialEq, Eq)]
1029pub struct ProjectLongDevice {
1030    source: Option<ProjectValue<String>>,
1031    target: Option<ProjectValue<String>>,
1032    permissions: Option<ProjectValue<String>>,
1033    extension_fields: Vec<ProjectFieldReference>,
1034    unknown_fields: Vec<ProjectFieldReference>,
1035}
1036
1037impl ProjectLongDevice {
1038    /// Returns the required raw source when it was valid and present.
1039    #[must_use]
1040    pub const fn source(&self) -> Option<&ProjectValue<String>> {
1041        self.source.as_ref()
1042    }
1043
1044    /// Returns the optional raw target without path interpretation.
1045    #[must_use]
1046    pub const fn target(&self) -> Option<&ProjectValue<String>> {
1047        self.target.as_ref()
1048    }
1049
1050    /// Returns the optional raw permissions string without validating runtime meaning.
1051    #[must_use]
1052    pub const fn permissions(&self) -> Option<&ProjectValue<String>> {
1053        self.permissions.as_ref()
1054    }
1055
1056    /// Returns retained `x-` options with their complete source evidence.
1057    #[must_use]
1058    pub fn extension_fields(&self) -> &[ProjectFieldReference] {
1059        &self.extension_fields
1060    }
1061
1062    /// Returns unrecognized long-form options with their complete source evidence.
1063    #[must_use]
1064    pub fn unknown_fields(&self) -> &[ProjectFieldReference] {
1065        &self.unknown_fields
1066    }
1067}
1068
1069/// One effective service device with short and long syntax kept distinct.
1070#[derive(Debug, Clone, PartialEq, Eq)]
1071#[non_exhaustive]
1072pub enum ProjectDevice {
1073    /// A raw short scalar, including path, CDI, deferred, and opaque spellings.
1074    Short(ShortDevice),
1075    /// A mapping-form device whose nested values retain their own provenance.
1076    Long(ProjectLongDevice),
1077}
1078
1079impl ProjectHealthcheck {
1080    /// Returns the effective health command without collapsing scalar and list forms.
1081    #[must_use]
1082    pub const fn test(&self) -> Option<&ProjectValue<HealthcheckTest>> {
1083        self.test.as_ref()
1084    }
1085
1086    /// Returns the effective regular-check interval.
1087    #[must_use]
1088    pub const fn interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
1089        self.interval.as_ref()
1090    }
1091
1092    /// Returns the effective per-check timeout.
1093    #[must_use]
1094    pub const fn timeout(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
1095        self.timeout.as_ref()
1096    }
1097
1098    /// Returns the effective unhealthy retry count.
1099    #[must_use]
1100    pub const fn retries(&self) -> Option<&ProjectValue<HealthcheckRetries>> {
1101        self.retries.as_ref()
1102    }
1103
1104    /// Returns the effective startup grace period.
1105    #[must_use]
1106    pub const fn start_period(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
1107        self.start_period.as_ref()
1108    }
1109
1110    /// Returns the effective interval used during the startup grace period.
1111    #[must_use]
1112    pub const fn start_interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
1113        self.start_interval.as_ref()
1114    }
1115
1116    /// Returns whether the image health check is explicitly disabled.
1117    #[must_use]
1118    pub const fn disable(&self) -> Option<&ProjectValue<BooleanValue>> {
1119        self.disable.as_ref()
1120    }
1121
1122    /// Reports whether the effective definition explicitly disables health checks.
1123    #[must_use]
1124    pub fn is_disabled(&self) -> bool {
1125        matches!(
1126            self.disable.as_ref().map(ProjectValue::value),
1127            Some(BooleanValue::Literal(true))
1128        ) || matches!(
1129            self.test.as_ref().and_then(|test| test.value().kind()),
1130            Some(HealthcheckTestKind::None)
1131        )
1132    }
1133
1134    /// Returns retained health-check fields outside the typed project-view boundary.
1135    #[must_use]
1136    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
1137        &self.unmodeled_fields
1138    }
1139}
1140
1141/// Effective service-level `tmpfs` syntax with per-item merge provenance retained.
1142#[derive(Debug, Clone, PartialEq, Eq)]
1143#[non_exhaustive]
1144pub enum ProjectTmpfs {
1145    /// One effective scalar declaration.
1146    Scalar(ProjectValue<TmpfsItem>),
1147    /// One effective list, including an explicit empty or reset list.
1148    List(Vec<ProjectValue<TmpfsItem>>),
1149}
1150
1151/// Effective service `dns` syntax with collection and per-item merge provenance retained.
1152#[derive(Debug, Clone, PartialEq, Eq)]
1153#[non_exhaustive]
1154pub enum ProjectDns {
1155    /// One effective raw scalar server string.
1156    Scalar(ProjectValue<String>),
1157    /// One effective ordered list, including an explicit empty or reset list.
1158    List(Vec<ProjectValue<String>>),
1159}
1160
1161/// Effective service `dns_search` syntax with collection and per-item merge provenance retained.
1162#[derive(Debug, Clone, PartialEq, Eq)]
1163#[non_exhaustive]
1164pub enum ProjectDnsSearch {
1165    /// One effective raw scalar search-domain string.
1166    Scalar(ProjectValue<String>),
1167    /// One effective ordered list, including an explicit empty or reset list.
1168    List(Vec<ProjectValue<String>>),
1169}
1170
1171/// Effective service `label_file` syntax with collection and per-item merge provenance retained.
1172#[derive(Debug, Clone, PartialEq, Eq)]
1173#[non_exhaustive]
1174pub enum ProjectLabelFiles {
1175    /// One effective raw label-file path scalar.
1176    Scalar(ProjectValue<String>),
1177    /// One effective ordered list, including an explicit empty or reset list.
1178    List(Vec<ProjectValue<String>>),
1179}
1180
1181/// Effective malformed `label_file` list-item evidence.
1182#[derive(Debug, Clone, PartialEq, Eq)]
1183pub struct ProjectLabelFileEvidence {
1184    reference: ProjectFieldReference,
1185}
1186
1187impl ProjectLabelFileEvidence {
1188    fn new(reference: ProjectFieldReference) -> Self {
1189        Self { reference }
1190    }
1191
1192    /// Returns the source-aware malformed item reference.
1193    #[must_use]
1194    pub const fn reference(&self) -> &ProjectFieldReference {
1195        &self.reference
1196    }
1197}
1198
1199/// Effective service `label_file` values with malformed list-item evidence retained.
1200#[derive(Debug, Clone, PartialEq, Eq)]
1201pub struct ProjectServiceLabelFiles {
1202    form: ProjectLabelFiles,
1203    unmodeled_items: Vec<ProjectLabelFileEvidence>,
1204}
1205
1206impl ProjectServiceLabelFiles {
1207    fn new(form: ProjectLabelFiles, unmodeled_items: Vec<ProjectLabelFileEvidence>) -> Self {
1208        Self { form, unmodeled_items }
1209    }
1210
1211    /// Returns the effective scalar or ordered-list syntax form.
1212    #[must_use]
1213    pub const fn form(&self) -> &ProjectLabelFiles {
1214        &self.form
1215    }
1216
1217    /// Returns malformed list items retained as source-aware evidence.
1218    #[must_use]
1219    pub fn unmodeled_items(&self) -> &[ProjectLabelFileEvidence] {
1220        &self.unmodeled_items
1221    }
1222}
1223
1224/// One effective service `expose` scalar with authored spelling and YAML kind retained.
1225#[derive(Debug, Clone, PartialEq, Eq)]
1226pub struct ProjectExposeItem {
1227    authored: String,
1228    value: String,
1229    scalar_kind: ExposeScalarKind,
1230    kind: ExposeItemKind,
1231}
1232
1233/// One effective raw service security option with authored and interpolated spelling retained.
1234#[derive(Debug, Clone, PartialEq, Eq)]
1235pub struct ProjectSecurityOptionItem {
1236    authored: String,
1237    value: String,
1238    scalar_kind: MergedScalarKind,
1239    kind: SecurityOptionKind,
1240}
1241
1242impl ProjectSecurityOptionItem {
1243    /// Returns the exact scalar spelling before optional interpolation.
1244    #[must_use]
1245    pub fn authored(&self) -> &str {
1246        &self.authored
1247    }
1248
1249    /// Returns the effective scalar spelling after optional interpolation.
1250    #[must_use]
1251    pub fn value(&self) -> &str {
1252        &self.value
1253    }
1254
1255    /// Returns the retained YAML scalar category.
1256    #[must_use]
1257    pub const fn scalar_kind(&self) -> MergedScalarKind {
1258        self.scalar_kind
1259    }
1260
1261    /// Returns the narrow classification of the effective spelling.
1262    #[must_use]
1263    pub const fn kind(&self) -> &SecurityOptionKind {
1264        &self.kind
1265    }
1266}
1267
1268impl ProjectExposeItem {
1269    /// Returns the exact scalar spelling before optional interpolation.
1270    #[must_use]
1271    pub fn authored(&self) -> &str {
1272        &self.authored
1273    }
1274
1275    /// Returns the exact effective scalar spelling after optional interpolation.
1276    #[must_use]
1277    pub fn value(&self) -> &str {
1278        &self.value
1279    }
1280
1281    /// Returns whether the YAML scalar was authored as a string or number.
1282    #[must_use]
1283    pub const fn scalar_kind(&self) -> ExposeScalarKind {
1284        self.scalar_kind
1285    }
1286
1287    /// Returns the conservative classification of the effective spelling.
1288    #[must_use]
1289    pub const fn kind(&self) -> &ExposeItemKind {
1290        &self.kind
1291    }
1292}
1293
1294/// One effective ulimit scalar with authored spelling and YAML scalar kind retained.
1295#[derive(Debug, Clone, PartialEq, Eq)]
1296pub struct ProjectUlimitScalar {
1297    authored: String,
1298    value: LimitValue,
1299    kind: MergedScalarKind,
1300}
1301
1302impl ProjectUlimitScalar {
1303    /// Returns the exact authored scalar spelling before optional interpolation.
1304    #[must_use]
1305    pub fn authored(&self) -> &str {
1306        &self.authored
1307    }
1308
1309    /// Returns the classified effective spelling after optional interpolation.
1310    #[must_use]
1311    pub const fn value(&self) -> &LimitValue {
1312        &self.value
1313    }
1314
1315    /// Returns whether the authored YAML scalar was a string or number.
1316    #[must_use]
1317    pub const fn kind(&self) -> MergedScalarKind {
1318        self.kind
1319    }
1320}
1321
1322/// Effective long-syntax ulimit members with independent merge provenance.
1323#[derive(Debug, Clone, PartialEq, Eq)]
1324pub struct ProjectUlimitRange {
1325    soft: Option<ProjectValue<ProjectUlimitScalar>>,
1326    hard: Option<ProjectValue<ProjectUlimitScalar>>,
1327    unmodeled_fields: Vec<ProjectFieldReference>,
1328}
1329
1330impl ProjectUlimitRange {
1331    /// Returns the effective soft limit, or `None` when the required member was omitted or malformed.
1332    #[must_use]
1333    pub const fn soft(&self) -> Option<&ProjectValue<ProjectUlimitScalar>> {
1334        self.soft.as_ref()
1335    }
1336
1337    /// Returns the effective hard limit, or `None` when the required member was omitted or malformed.
1338    #[must_use]
1339    pub const fn hard(&self) -> Option<&ProjectValue<ProjectUlimitScalar>> {
1340        self.hard.as_ref()
1341    }
1342
1343    /// Returns retained range fields outside the `soft` and `hard` boundary.
1344    #[must_use]
1345    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
1346        &self.unmodeled_fields
1347    }
1348}
1349
1350/// The effective single or soft/hard form of one named ulimit.
1351#[derive(Debug, Clone, PartialEq, Eq)]
1352#[non_exhaustive]
1353pub enum ProjectUlimitValue {
1354    /// One scalar applies to both the soft and hard limit.
1355    Single(ProjectValue<ProjectUlimitScalar>),
1356    /// Soft and hard members remain independently source-aware.
1357    Range(ProjectUlimitRange),
1358}
1359
1360/// One ordered effective named ulimit.
1361#[derive(Debug, Clone, PartialEq, Eq)]
1362pub struct ProjectUlimit {
1363    name: ProjectKey,
1364    value: ProjectUlimitValue,
1365}
1366
1367impl ProjectUlimit {
1368    /// Returns the lowercase limit name and every authored key location.
1369    #[must_use]
1370    pub const fn name(&self) -> &ProjectKey {
1371        &self.name
1372    }
1373
1374    /// Returns the effective single or soft/hard form.
1375    #[must_use]
1376    pub const fn value(&self) -> &ProjectUlimitValue {
1377        &self.value
1378    }
1379}
1380
1381/// Effective service `ulimits`, including an explicitly empty or reset mapping.
1382#[derive(Debug, Clone, PartialEq, Eq)]
1383pub struct ProjectUlimits {
1384    entries: Vec<ProjectValue<ProjectUlimit>>,
1385}
1386
1387impl ProjectUlimits {
1388    /// Returns named limits in effective mapping order.
1389    #[must_use]
1390    pub fn entries(&self) -> &[ProjectValue<ProjectUlimit>] {
1391        &self.entries
1392    }
1393
1394    /// Reports whether the effective mapping is explicitly empty.
1395    #[must_use]
1396    pub fn is_empty(&self) -> bool {
1397        self.entries.is_empty()
1398    }
1399}
1400
1401/// One effective mapping-form service sysctl with key and scalar-value provenance.
1402#[derive(Debug, Clone, PartialEq, Eq)]
1403pub struct ProjectSysctl {
1404    name: ProjectKey,
1405    value: ProjectValue<ComposeScalar>,
1406}
1407
1408impl ProjectSysctl {
1409    /// Returns the exact sysctl name and every authored key location.
1410    #[must_use]
1411    pub const fn name(&self) -> &ProjectKey {
1412        &self.name
1413    }
1414
1415    /// Returns the exact scalar kind and spelling with complete merge provenance.
1416    #[must_use]
1417    pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
1418        &self.value
1419    }
1420}
1421
1422/// Effective service `sysctls` with mapping/list form and per-entry provenance retained.
1423#[derive(Debug, Clone, PartialEq, Eq)]
1424#[non_exhaustive]
1425pub enum ProjectSysctls {
1426    /// Ordered mapping entries merged by exact key.
1427    Map(Vec<ProjectValue<ProjectSysctl>>),
1428    /// Ordered list items appended without implicit deduplication.
1429    List(Vec<ProjectValue<String>>),
1430}
1431
1432/// One effective logging-option scalar with authored and interpolated spelling retained.
1433#[derive(Debug, Clone, PartialEq, Eq)]
1434#[non_exhaustive]
1435pub enum ProjectLoggingOptionValue {
1436    /// A YAML string scalar before and after optional per-file interpolation.
1437    String {
1438        /// Exact authored string before interpolation.
1439        authored: String,
1440        /// Effective string after interpolation.
1441        value: String,
1442    },
1443    /// A YAML number scalar with exact spelling retained.
1444    Number(String),
1445    /// An explicit or empty YAML null.
1446    Null,
1447}
1448
1449/// One ordered effective logging option with complete key and value provenance.
1450#[derive(Debug, Clone, PartialEq, Eq)]
1451pub struct ProjectLoggingOption {
1452    name: ProjectKey,
1453    value: ProjectValue<ProjectLoggingOptionValue>,
1454}
1455
1456impl ProjectLoggingOption {
1457    /// Returns the non-empty option name and every authored key location.
1458    #[must_use]
1459    pub const fn name(&self) -> &ProjectKey {
1460        &self.name
1461    }
1462
1463    /// Returns the exact string, number, or null option value with merge provenance.
1464    #[must_use]
1465    pub const fn value(&self) -> &ProjectValue<ProjectLoggingOptionValue> {
1466        &self.value
1467    }
1468}
1469
1470/// Effective ordered logging options, including an explicitly empty or reset mapping.
1471#[derive(Debug, Clone, PartialEq, Eq)]
1472pub struct ProjectLoggingOptions {
1473    entries: Vec<ProjectValue<ProjectLoggingOption>>,
1474    unmodeled_entries: Vec<ProjectFieldReference>,
1475}
1476
1477impl ProjectLoggingOptions {
1478    /// Returns valid option entries in effective mapping order.
1479    #[must_use]
1480    pub fn entries(&self) -> &[ProjectValue<ProjectLoggingOption>] {
1481        &self.entries
1482    }
1483
1484    /// Returns malformed entries retained outside the typed string/number/null boundary.
1485    #[must_use]
1486    pub fn unmodeled_entries(&self) -> &[ProjectFieldReference] {
1487        &self.unmodeled_entries
1488    }
1489
1490    /// Reports whether the effective options mapping has no valid or malformed entries.
1491    #[must_use]
1492    pub fn is_empty(&self) -> bool {
1493        self.entries.is_empty() && self.unmodeled_entries.is_empty()
1494    }
1495}
1496
1497/// Effective service logging configuration with nested merge provenance retained.
1498#[derive(Debug, Clone, PartialEq, Eq)]
1499pub struct ProjectLogging {
1500    driver: Option<ProjectValue<String>>,
1501    options: Option<ProjectValue<ProjectLoggingOptions>>,
1502    unmodeled_fields: Vec<ProjectFieldReference>,
1503}
1504
1505/// An effective Compose build declaration retaining scalar and mapping syntax.
1506///
1507/// Only `context`, `args`, `cache_from`, `cache_to`, `dockerfile`, `dockerfile_inline`, `entitlements`, `extra_hosts`, `target`, `network`, `isolation`, `platforms`, `no_cache`,
1508/// `sbom`, `pull`, `shm_size`, `tags`, `labels`, `secrets`, and `ulimits` are promoted to native values in this slice.
1509/// Every other build subfield stays source-addressable as unmodeled evidence.
1510#[derive(Debug, Clone, PartialEq, Eq)]
1511#[non_exhaustive]
1512pub enum ProjectBuild {
1513    /// A short-syntax scalar build context.
1514    Context(ProjectValue<String>),
1515    /// A long-syntax build declaration.
1516    Definition(ProjectBuildDefinition),
1517}
1518/// Effective `build.no_cache_filter` scalar or list form.
1519#[derive(Debug, Clone, PartialEq, Eq)]
1520#[non_exhaustive]
1521pub enum ProjectBuildNoCacheFilter {
1522    /// One exact scalar stage name.
1523    Scalar(ProjectValue<String>),
1524    /// Ordered exact stage names.
1525    List(Vec<ProjectValue<String>>),
1526}
1527
1528/// Private grouping keeps the public Build declaration compact while retaining independent values.
1529#[derive(Debug, Clone, PartialEq, Eq)]
1530struct ProjectBuildCacheOptions {
1531    cache_from: Option<ProjectValue<Vec<ProjectValue<String>>>>,
1532    cache_to: Option<ProjectValue<Vec<ProjectValue<String>>>>,
1533    no_cache: Option<ProjectValue<BuildNoCache>>,
1534    pull: Option<ProjectValue<BooleanValue>>,
1535}
1536
1537/// An effective long-syntax build declaration.
1538#[derive(Debug, Clone, PartialEq, Eq)]
1539pub struct ProjectBuildDefinition {
1540    additional_contexts: Option<Box<ProjectValue<ProjectBuildAdditionalContexts>>>,
1541    context: Option<Box<ProjectValue<String>>>,
1542    args: Option<Box<ProjectValue<ProjectBuildArgs>>>,
1543    entitlements: Option<Box<ProjectValue<Vec<ProjectValue<String>>>>>,
1544    extra_hosts: Option<Box<ProjectValue<ProjectBuildExtraHosts>>>,
1545    dockerfile: Option<Box<ProjectValue<String>>>,
1546    dockerfile_inline: Option<Box<ProjectValue<String>>>,
1547    target: Option<Box<ProjectValue<String>>>,
1548    network: Option<Box<ProjectValue<String>>>,
1549    isolation: Option<Box<ProjectValue<String>>>,
1550    platforms: Option<Box<ProjectValue<Vec<ProjectValue<String>>>>>,
1551    cache_options: Option<Box<ProjectBuildCacheOptions>>,
1552    no_cache_filter: Option<Box<ProjectValue<ProjectBuildNoCacheFilter>>>,
1553    privileged: Option<Box<ProjectValue<BooleanValue>>>,
1554    sbom: Option<Box<ProjectValue<BuildSbom>>>,
1555    provenance: Option<Box<ProjectValue<BuildProvenance>>>,
1556    shm_size: Option<Box<ProjectValue<ShmSize>>>,
1557    tags: Option<Box<ProjectValue<Vec<ProjectValue<String>>>>>,
1558    labels: Option<Box<ProjectValue<ProjectBuildLabels>>>,
1559    secrets: Option<Box<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>>,
1560    ssh: Option<Box<ProjectValue<ProjectBuildSsh>>>,
1561    ulimits: Option<Box<ProjectValue<ProjectUlimits>>>,
1562    unmodeled_fields: Arc<Vec<ProjectFieldReference>>,
1563}
1564
1565impl ProjectBuildDefinition {
1566    /// Returns effective additional build contexts without normalizing mapping and list syntax.
1567    ///
1568    /// The collection, list items, mapping keys, and mapping values each retain merge provenance
1569    /// and interpolation sensitivity. No builder or reference interpretation is performed.
1570    /// Returns effective no-cache filter form with collection provenance.
1571    #[must_use]
1572    pub fn additional_contexts(&self) -> Option<&ProjectValue<ProjectBuildAdditionalContexts>> {
1573        self.additional_contexts.as_deref()
1574    }
1575
1576    /// Returns the effective long-syntax build context when it is a scalar.
1577    #[must_use]
1578    pub const fn context(&self) -> Option<&ProjectValue<String>> {
1579        match &self.context {
1580            Some(context) => Some(context),
1581            None => None,
1582        }
1583    }
1584
1585    /// Returns effective build arguments without resolving list entries from an environment.
1586    ///
1587    /// Mapping entries retain exact-key replacement provenance and scalar kind. List entries
1588    /// retain append/reset/override provenance, ordering, duplicates, and bare forms.
1589    #[must_use]
1590    pub fn args(&self) -> Option<&ProjectValue<ProjectBuildArgs>> {
1591        self.args.as_deref()
1592    }
1593
1594    /// Returns effective build entitlements in append order with per-item provenance.
1595    ///
1596    /// Explicit empty and reset sequences remain distinct from omission. Entries are opaque raw
1597    /// strings: this view retains duplicates and does not infer allowlists, privilege state,
1598    /// BuildKit/platform support, execution, or runtime effect.
1599    #[must_use]
1600    pub fn entitlements(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
1601        self.entitlements.as_deref()
1602    }
1603
1604    /// Returns effective build-time host mappings without applying service-host parsing.
1605    ///
1606    /// Generic project merging appends list entries, recursively merges mapping keys and nested
1607    /// address lists, and replaces mixed forms. Collection, key, scalar, and nested-list-item
1608    /// provenance plus per-file interpolation sensitivity remain available. This view performs no
1609    /// address normalization or validation, DNS/host access, build generation, or conversion.
1610    #[must_use]
1611    pub fn extra_hosts(&self) -> Option<&ProjectValue<ProjectBuildExtraHosts>> {
1612        self.extra_hosts.as_deref()
1613    }
1614
1615    /// Returns the effective long-syntax Dockerfile when it is a non-empty scalar.
1616    #[must_use]
1617    pub const fn dockerfile(&self) -> Option<&ProjectValue<String>> {
1618        match &self.dockerfile {
1619            Some(dockerfile) => Some(dockerfile),
1620            None => None,
1621        }
1622    }
1623
1624    /// Returns the effective inline Dockerfile as an exact string scalar.
1625    ///
1626    /// Empty and multiline content remains distinct from omission. `ComposeLens` does not parse
1627    /// Containerfile syntax, resolve paths or contexts, scan content for secrets, build images,
1628    /// or infer Docker, `BuildKit`, or runtime behavior.
1629    #[must_use]
1630    pub const fn dockerfile_inline(&self) -> Option<&ProjectValue<String>> {
1631        match &self.dockerfile_inline {
1632            Some(dockerfile_inline) => Some(dockerfile_inline),
1633            None => None,
1634        }
1635    }
1636
1637    /// Returns the effective long-syntax build target as an opaque scalar.
1638    ///
1639    /// An empty scalar remains an authored target; this view does not infer stage-name grammar.
1640    #[must_use]
1641    pub const fn target(&self) -> Option<&ProjectValue<String>> {
1642        match &self.target {
1643            Some(target) => Some(target),
1644            None => None,
1645        }
1646    }
1647
1648    /// Returns the effective long-syntax build network as an opaque scalar.
1649    ///
1650    /// An empty scalar remains authored; this view does not infer network names, defaults, or
1651    /// runtime behavior.
1652    #[must_use]
1653    pub fn network(&self) -> Option<&ProjectValue<String>> {
1654        self.network.as_deref()
1655    }
1656
1657    /// Returns the effective long-syntax build isolation as an opaque YAML string.
1658    ///
1659    /// The scalar is interpolated per file before merging and retains complete replacement,
1660    /// reset, or override provenance and sensitivity. This view does not validate isolation
1661    /// modes, platforms, defaults, privileges, or `BUILDAH_ISOLATION` behavior, and does not
1662    /// represent service-level `isolation`.
1663    #[must_use]
1664    pub fn isolation(&self) -> Option<&ProjectValue<String>> {
1665        self.isolation.as_deref()
1666    }
1667
1668    /// Returns effective build platforms in append order with per-item provenance.
1669    ///
1670    /// An explicit empty or reset sequence remains distinct from omission. Platforms are raw
1671    /// scalars; this view does not parse OCI platform grammar or validate availability.
1672    #[must_use]
1673    pub fn platforms(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
1674        self.platforms.as_deref()
1675    }
1676
1677    /// Returns effective external build-cache sources in append order with per-item provenance.
1678    ///
1679    /// An explicit empty or reset sequence remains distinct from omission. Entries are opaque
1680    /// strings: this view preserves duplicates and does not interpret cache type, reference,
1681    /// source, path, image, credentials, or builder behavior.
1682    #[must_use]
1683    pub fn cache_from(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
1684        self.cache_options
1685            .as_ref()
1686            .and_then(|options| options.cache_from.as_ref())
1687    }
1688
1689    /// Returns effective external build-cache destinations in append order with per-item provenance.
1690    ///
1691    /// An explicit empty or reset sequence remains distinct from omission. Entries are opaque
1692    /// strings: this view preserves duplicates and does not interpret cache type, reference,
1693    /// destination, path, image, credentials, or builder behavior.
1694    #[must_use]
1695    pub fn cache_to(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
1696        self.cache_options
1697            .as_ref()
1698            .and_then(|options| options.cache_to.as_ref())
1699    }
1700
1701    /// Returns the effective build cache-disable choice with YAML scalar type retained.
1702    ///
1703    /// String values, including empty and interpolation-shaped values, remain strings after the
1704    /// explicit per-file interpolation stage and are never coerced to booleans. Omission and a
1705    /// reset null imply no default, builder, or cache behavior.
1706    #[must_use]
1707    pub fn no_cache(&self) -> Option<&ProjectValue<BuildNoCache>> {
1708        self.cache_options
1709            .as_ref()
1710            .and_then(|options| options.no_cache.as_ref())
1711    }
1712    /// Returns effective no-cache filter form with collection provenance.
1713    #[must_use]
1714    pub fn no_cache_filter(&self) -> Option<&ProjectValue<ProjectBuildNoCacheFilter>> {
1715        self.no_cache_filter.as_deref()
1716    }
1717    /// Returns the explicit build privileged boolean or deferred expression.
1718    #[must_use]
1719    pub fn privileged(&self) -> Option<&ProjectValue<BooleanValue>> {
1720        self.privileged.as_deref()
1721    }
1722
1723    /// Returns the effective build SBOM choice with YAML scalar type retained.
1724    ///
1725    /// Strings, including empty, generator-shaped, and interpolation-derived values, remain
1726    /// strings. The value retains scalar replacement/reset/override provenance and sensitivity;
1727    /// this view neither parses generator data nor generates an SBOM or infers builder behavior.
1728    #[must_use]
1729    pub fn sbom(&self) -> Option<&ProjectValue<BuildSbom>> {
1730        self.sbom.as_deref()
1731    }
1732    /// Returns effective Build provenance as a boolean or opaque string scalar.
1733    #[must_use]
1734    pub fn provenance(&self) -> Option<&ProjectValue<BuildProvenance>> {
1735        self.provenance.as_deref()
1736    }
1737
1738    /// Returns the effective build-image pull choice with complete scalar merge provenance.
1739    ///
1740    /// Literal booleans and deferred expressions remain distinct. Omission or a reset null does
1741    /// not imply a default, and this view neither resolves expressions nor infers build execution
1742    /// behavior.
1743    #[must_use]
1744    pub fn pull(&self) -> Option<&ProjectValue<BooleanValue>> {
1745        self.cache_options.as_ref().and_then(|options| options.pull.as_ref())
1746    }
1747
1748    /// Returns the effective raw-preserving build-container shared-memory size.
1749    ///
1750    /// The value retains YAML number/string spelling, documented lowercase-unit classification,
1751    /// lexical zero, deferred expressions, scalar replacement/reset/override provenance, and
1752    /// interpolation sensitivity. This view does not infer builder defaults, allocations, host
1753    /// settings, or runtime behavior.
1754    #[must_use]
1755    pub fn shm_size(&self) -> Option<&ProjectValue<ShmSize>> {
1756        self.shm_size.as_deref()
1757    }
1758
1759    /// Returns effective additional build tags in append order with per-item provenance.
1760    ///
1761    /// An explicit empty or reset sequence remains distinct from omission. Tags are opaque
1762    /// scalars; this view does not apply image-reference grammar or duplicate handling.
1763    #[must_use]
1764    pub fn tags(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
1765        self.tags.as_deref()
1766    }
1767
1768    /// Returns effective build labels without normalizing mapping and list syntax.
1769    ///
1770    /// Mapping entries retain key/value provenance after per-key replacement. List entries retain
1771    /// append/reset/override provenance, order, duplicates, and bare-label spelling.
1772    #[must_use]
1773    pub fn labels(&self) -> Option<&ProjectValue<ProjectBuildLabels>> {
1774        self.labels.as_deref()
1775    }
1776
1777    /// Returns effective build secret grants in append order with item and nested-field provenance.
1778    ///
1779    /// Short resource-name and long mapping syntax remain distinct. An explicit empty or reset
1780    /// sequence remains distinct from omission; this view does not resolve top-level secrets,
1781    /// read secret values, or infer build execution behavior.
1782    #[must_use]
1783    pub fn secrets(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
1784        self.secrets.as_deref()
1785    }
1786
1787    /// Returns effective sensitive `BuildKit` SSH grants without normalizing their form.
1788    ///
1789    /// Collection, item, mapping key, and mapping value provenance are retained. Every value is
1790    /// sensitive even without interpolation; callers must use the explicit form accessors to
1791    /// inspect raw grants. No identifier, path, PEM, socket, agent, mount, or runtime behavior is
1792    /// parsed or accessed.
1793    #[must_use]
1794    pub fn ssh(&self) -> Option<&ProjectValue<ProjectBuildSsh>> {
1795        self.ssh.as_deref()
1796    }
1797
1798    /// Returns effective build-container resource limits with nested merge provenance.
1799    ///
1800    /// Single and soft/hard forms retain exact scalar spelling, YAML string/number identity,
1801    /// interpolation sensitivity, explicit empty/reset/override mappings, and independent range
1802    /// members. This view injects no defaults, normalizes neither `-1` nor names, and does not
1803    /// validate host limits or infer builder or runtime behavior.
1804    #[must_use]
1805    pub fn ulimits(&self) -> Option<&ProjectValue<ProjectUlimits>> {
1806        self.ulimits.as_deref()
1807    }
1808
1809    /// Returns all non-build-context/args/Dockerfile/entitlements/extra_hosts/target/network/isolation/platforms/no_cache/sbom/pull/shm_size/tags/labels/secrets/ulimits,
1810    /// extension, unknown, and malformed known fields.
1811    #[must_use]
1812    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
1813        self.unmodeled_fields.as_slice()
1814    }
1815
1816    fn push_unmodeled(&mut self, field: ProjectFieldReference) {
1817        Arc::make_mut(&mut self.unmodeled_fields).push(field);
1818    }
1819
1820    fn cache_options(&mut self) -> &mut ProjectBuildCacheOptions {
1821        self.cache_options.get_or_insert_with(|| {
1822            Box::new(ProjectBuildCacheOptions {
1823                cache_from: None,
1824                cache_to: None,
1825                no_cache: None,
1826                pull: None,
1827            })
1828        })
1829    }
1830}
1831
1832impl ProjectLogging {
1833    /// Returns the uninterpreted effective YAML string driver.
1834    #[must_use]
1835    pub const fn driver(&self) -> Option<&ProjectValue<String>> {
1836        self.driver.as_ref()
1837    }
1838
1839    /// Returns the ordered options mapping, including an explicitly empty one.
1840    #[must_use]
1841    pub const fn options(&self) -> Option<&ProjectValue<ProjectLoggingOptions>> {
1842        self.options.as_ref()
1843    }
1844
1845    /// Returns extensions, unknown fields, and malformed known siblings retained in the mapping.
1846    #[must_use]
1847    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
1848        &self.unmodeled_fields
1849    }
1850}
1851
1852/// The effective deploy mapping, retaining malformed, extension, and future-unknown evidence.
1853#[derive(Debug, Clone, PartialEq, Eq)]
1854pub struct ProjectDeploy {
1855    endpoint_mode: Option<ProjectValue<DeployEndpointMode>>,
1856    labels: Option<ProjectValue<ProjectLabels>>,
1857    mode: Option<ProjectValue<DeployMode>>,
1858    placement: Option<ProjectValue<ProjectDeployPlacement>>,
1859    replicas: Option<ProjectValue<DeployReplicas>>,
1860    resources: Option<ProjectValue<ProjectDeployResources>>,
1861    restart_policy: Option<ProjectValue<ProjectDeployRestartPolicy>>,
1862    rollback_config: Option<ProjectValue<ProjectDeployRollbackConfig>>,
1863    update_config: Option<ProjectValue<ProjectDeployUpdateConfig>>,
1864    unmodeled_fields: Vec<ProjectFieldReference>,
1865}
1866
1867impl ProjectDeploy {
1868    /// Returns the effective service-discovery endpoint mode.
1869    #[must_use]
1870    pub const fn endpoint_mode(&self) -> Option<&ProjectValue<DeployEndpointMode>> {
1871        self.endpoint_mode.as_ref()
1872    }
1873
1874    /// Returns effective deployment labels, distinct from service container labels.
1875    #[must_use]
1876    pub const fn labels(&self) -> Option<&ProjectValue<ProjectLabels>> {
1877        self.labels.as_ref()
1878    }
1879
1880    /// Returns the effective deployment mode.
1881    #[must_use]
1882    pub const fn mode(&self) -> Option<&ProjectValue<DeployMode>> {
1883        self.mode.as_ref()
1884    }
1885
1886    /// Returns the effective deployment placement without scheduling interpretation.
1887    #[must_use]
1888    pub const fn placement(&self) -> Option<&ProjectValue<ProjectDeployPlacement>> {
1889        self.placement.as_ref()
1890    }
1891
1892    /// Returns the effective replica-count spelling and YAML scalar category.
1893    #[must_use]
1894    pub const fn replicas(&self) -> Option<&ProjectValue<DeployReplicas>> {
1895        self.replicas.as_ref()
1896    }
1897
1898    /// Returns effective deploy resources without resource-policy interpretation.
1899    #[must_use]
1900    pub const fn resources(&self) -> Option<&ProjectValue<ProjectDeployResources>> {
1901        self.resources.as_ref()
1902    }
1903
1904    /// Returns the effective deploy restart policy, separate from service `restart`.
1905    #[must_use]
1906    pub const fn restart_policy(&self) -> Option<&ProjectValue<ProjectDeployRestartPolicy>> {
1907        self.restart_policy.as_ref()
1908    }
1909    /// Returns the effective rollback configuration without rollout interpretation.
1910    #[must_use]
1911    pub const fn rollback_config(&self) -> Option<&ProjectValue<ProjectDeployRollbackConfig>> {
1912        self.rollback_config.as_ref()
1913    }
1914    /// Returns the effective rolling-update configuration without rollout interpretation.
1915    #[must_use]
1916    pub const fn update_config(&self) -> Option<&ProjectValue<ProjectDeployUpdateConfig>> {
1917        self.update_config.as_ref()
1918    }
1919
1920    /// Returns malformed, extension, and future-unknown deploy-field evidence.
1921    #[must_use]
1922    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
1923        &self.unmodeled_fields
1924    }
1925}
1926
1927/// Effective deploy resources with nested provenance.
1928#[derive(Debug, Clone, PartialEq, Eq)]
1929pub struct ProjectDeployResources {
1930    limits: Option<ProjectValue<ProjectDeployResourceLimits>>,
1931    reservations: Option<ProjectValue<ProjectDeployResourceReservations>>,
1932    unmodeled_fields: Vec<ProjectFieldReference>,
1933}
1934
1935impl ProjectDeployResources {
1936    /// Returns effective deploy resource limits without defaults or runtime interpretation.
1937    #[must_use]
1938    pub const fn limits(&self) -> Option<&ProjectValue<ProjectDeployResourceLimits>> {
1939        self.limits.as_ref()
1940    }
1941
1942    /// Returns effective resource reservations without scheduling interpretation.
1943    #[must_use]
1944    pub const fn reservations(&self) -> Option<&ProjectValue<ProjectDeployResourceReservations>> {
1945        self.reservations.as_ref()
1946    }
1947
1948    /// Returns extensions, unknown fields, and malformed children retained as evidence.
1949    #[must_use]
1950    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
1951        &self.unmodeled_fields
1952    }
1953}
1954
1955/// Effective deploy resource reservations with nested provenance.
1956#[derive(Debug, Clone, PartialEq, Eq)]
1957pub struct ProjectDeployResourceReservations {
1958    cpus: Option<ProjectValue<DeployResourceCpus>>,
1959    memory: Option<ProjectValue<DeployResourceMemory>>,
1960    generic_resources: Option<ProjectValue<Vec<ProjectValue<ProjectDeployGenericResource>>>>,
1961    devices: Option<ProjectValue<Vec<ProjectValue<ProjectDeployReservationDevice>>>>,
1962    unmodeled_fields: Vec<ProjectFieldReference>,
1963}
1964
1965impl ProjectDeployResourceReservations {
1966    /// Returns the effective deploy resource reservation CPU scalar spelling and category.
1967    #[must_use]
1968    pub const fn cpus(&self) -> Option<&ProjectValue<DeployResourceCpus>> {
1969        self.cpus.as_ref()
1970    }
1971
1972    /// Returns the effective deploy resource reservation memory value and its provenance.
1973    #[must_use]
1974    pub const fn memory(&self) -> Option<&ProjectValue<DeployResourceMemory>> {
1975        self.memory.as_ref()
1976    }
1977
1978    /// Returns effective ordered generic-resource reservations, including an explicit empty list.
1979    #[must_use]
1980    pub const fn generic_resources(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectDeployGenericResource>>>> {
1981        self.generic_resources.as_ref()
1982    }
1983
1984    /// Returns effective reservation devices, including an explicit empty list.
1985    #[must_use]
1986    pub const fn devices(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectDeployReservationDevice>>>> {
1987        self.devices.as_ref()
1988    }
1989
1990    /// Returns extensions, unknown fields, and malformed children retained as evidence.
1991    #[must_use]
1992    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
1993        &self.unmodeled_fields
1994    }
1995}
1996
1997/// One effective resource-reservation device with nested provenance.
1998#[derive(Debug, Clone, PartialEq, Eq)]
1999pub struct ProjectDeployReservationDevice {
2000    form: ProjectDeployReservationDeviceForm,
2001    capabilities: Option<ProjectValue<Vec<ProjectValue<ProjectDeployReservationDeviceCapability>>>>,
2002    driver: Option<ProjectValue<String>>,
2003    count: Option<ProjectValue<DeployReservationDeviceCount>>,
2004    device_ids: Option<ProjectValue<Vec<ProjectValue<ProjectDeployReservationDeviceId>>>>,
2005    options: Option<ProjectValue<ProjectDeployReservationDeviceOptions>>,
2006    unmodeled_fields: Vec<ProjectFieldReference>,
2007}
2008
2009impl ProjectDeployReservationDevice {
2010    /// Returns whether this effective item is a mapping or retained unmodeled sequence entry.
2011    #[must_use]
2012    pub const fn form(&self) -> ProjectDeployReservationDeviceForm {
2013        self.form
2014    }
2015
2016    /// Returns the required effective capabilities collection when its form was valid.
2017    #[must_use]
2018    pub const fn capabilities(
2019        &self,
2020    ) -> Option<&ProjectValue<Vec<ProjectValue<ProjectDeployReservationDeviceCapability>>>> {
2021        self.capabilities.as_ref()
2022    }
2023
2024    /// Returns the optional effective raw device driver and its provenance.
2025    #[must_use]
2026    pub const fn driver(&self) -> Option<&ProjectValue<String>> {
2027        self.driver.as_ref()
2028    }
2029
2030    /// Returns the optional raw effective device allocation count and its provenance.
2031    #[must_use]
2032    pub const fn count(&self) -> Option<&ProjectValue<DeployReservationDeviceCount>> {
2033        self.count.as_ref()
2034    }
2035
2036    /// Returns optional effective ordered device allocation IDs, including an explicit empty list.
2037    #[must_use]
2038    pub const fn device_ids(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectDeployReservationDeviceId>>>> {
2039        self.device_ids.as_ref()
2040    }
2041
2042    /// Returns optional effective schema-shaped device options with full provenance.
2043    #[must_use]
2044    pub const fn options(&self) -> Option<&ProjectValue<ProjectDeployReservationDeviceOptions>> {
2045        self.options.as_ref()
2046    }
2047
2048    /// Returns extensions, unknown fields, and malformed members retained as evidence.
2049    #[must_use]
2050    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2051        &self.unmodeled_fields
2052    }
2053}
2054
2055/// Effective resource-reservation device item shape retained without coercion.
2056#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2057#[non_exhaustive]
2058pub enum ProjectDeployReservationDeviceForm {
2059    /// A mapping-form item.
2060    Mapping,
2061    /// A non-mapping sequence item retained as evidence.
2062    Unmodeled,
2063}
2064
2065/// One effective resource-reservation device allocation ID with item provenance.
2066#[derive(Debug, Clone, PartialEq, Eq)]
2067pub struct ProjectDeployReservationDeviceId {
2068    form: ProjectDeployReservationDeviceIdForm,
2069    value: Option<ProjectValue<String>>,
2070}
2071
2072impl ProjectDeployReservationDeviceId {
2073    /// Returns whether the item was a YAML string or retained unmodeled value.
2074    #[must_use]
2075    pub const fn form(&self) -> ProjectDeployReservationDeviceIdForm {
2076        self.form
2077    }
2078
2079    /// Returns the exact effective string when the item had string form.
2080    #[must_use]
2081    pub const fn value(&self) -> Option<&ProjectValue<String>> {
2082        self.value.as_ref()
2083    }
2084}
2085
2086/// Effective resource-reservation device allocation-ID item shape retained without coercion.
2087#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2088#[non_exhaustive]
2089pub enum ProjectDeployReservationDeviceIdForm {
2090    /// A YAML string item.
2091    String,
2092    /// A non-string sequence item retained as evidence.
2093    Unmodeled,
2094}
2095
2096/// Effective schema-shaped resource-reservation device options.
2097#[derive(Debug, Clone, PartialEq, Eq)]
2098#[non_exhaustive]
2099pub enum ProjectDeployReservationDeviceOptions {
2100    /// Ordered mapping entries plus malformed fields retained as evidence.
2101    Map {
2102        /// Valid map entries with key and value provenance.
2103        entries: Vec<ProjectValue<ProjectDeployReservationDeviceOption>>,
2104        /// Malformed or duplicate map fields.
2105        unmodeled_entries: Vec<ProjectFieldReference>,
2106    },
2107    /// Ordered list entries, including malformed items.
2108    List(Vec<ProjectValue<ProjectDeployReservationDeviceOptionItem>>),
2109}
2110
2111impl ProjectDeployReservationDeviceOptions {
2112    /// Returns valid mapping entries when the effective form was a mapping.
2113    #[must_use]
2114    pub fn as_map(&self) -> Option<&[ProjectValue<ProjectDeployReservationDeviceOption>]> {
2115        let Self::Map { entries, .. } = self else { return None };
2116        Some(entries)
2117    }
2118    /// Returns malformed or duplicate mapping fields when the effective form was a mapping.
2119    #[must_use]
2120    pub fn unmodeled_entries(&self) -> Option<&[ProjectFieldReference]> {
2121        let Self::Map { unmodeled_entries, .. } = self else {
2122            return None;
2123        };
2124        Some(unmodeled_entries)
2125    }
2126    /// Returns ordered list items when the effective form was a sequence.
2127    #[must_use]
2128    pub fn as_list(&self) -> Option<&[ProjectValue<ProjectDeployReservationDeviceOptionItem>]> {
2129        let Self::List(items) = self else { return None };
2130        Some(items)
2131    }
2132}
2133
2134/// One effective device-options mapping entry with nested provenance.
2135#[derive(Debug, Clone, PartialEq, Eq)]
2136pub struct ProjectDeployReservationDeviceOption {
2137    name: ProjectKey,
2138    value: ProjectValue<ComposeScalar>,
2139}
2140impl ProjectDeployReservationDeviceOption {
2141    /// Returns the strict non-empty option key and its provenance.
2142    #[must_use]
2143    pub const fn name(&self) -> &ProjectKey {
2144        &self.name
2145    }
2146    /// Returns the scalar option value and its provenance.
2147    #[must_use]
2148    pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
2149        &self.value
2150    }
2151}
2152
2153/// One effective device-options list item retained without splitting or coercion.
2154#[derive(Debug, Clone, PartialEq, Eq)]
2155pub struct ProjectDeployReservationDeviceOptionItem {
2156    form: ProjectDeployReservationDeviceOptionItemForm,
2157    value: Option<ProjectValue<String>>,
2158}
2159impl ProjectDeployReservationDeviceOptionItem {
2160    /// Returns the effective item form.
2161    #[must_use]
2162    pub const fn form(&self) -> ProjectDeployReservationDeviceOptionItemForm {
2163        self.form
2164    }
2165    /// Returns the exact list string when the item was valid.
2166    #[must_use]
2167    pub const fn value(&self) -> Option<&ProjectValue<String>> {
2168        self.value.as_ref()
2169    }
2170}
2171/// Effective device-options list item form.
2172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2173#[non_exhaustive]
2174pub enum ProjectDeployReservationDeviceOptionItemForm {
2175    /// A strict YAML string list item.
2176    String,
2177    /// A non-string list item retained as evidence.
2178    Unmodeled,
2179}
2180
2181/// One effective resource-reservation device capability with item provenance.
2182#[derive(Debug, Clone, PartialEq, Eq)]
2183pub struct ProjectDeployReservationDeviceCapability {
2184    form: ProjectDeployReservationDeviceCapabilityForm,
2185    value: Option<ProjectValue<String>>,
2186}
2187
2188impl ProjectDeployReservationDeviceCapability {
2189    /// Returns whether the item was a YAML string or retained unmodeled value.
2190    #[must_use]
2191    pub const fn form(&self) -> ProjectDeployReservationDeviceCapabilityForm {
2192        self.form
2193    }
2194
2195    /// Returns the exact effective string when the item had string form.
2196    #[must_use]
2197    pub const fn value(&self) -> Option<&ProjectValue<String>> {
2198        self.value.as_ref()
2199    }
2200}
2201
2202/// Effective resource-reservation capability item shape retained without coercion.
2203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2204#[non_exhaustive]
2205pub enum ProjectDeployReservationDeviceCapabilityForm {
2206    /// A YAML string item.
2207    String,
2208    /// A non-string sequence item retained as evidence.
2209    Unmodeled,
2210}
2211
2212/// One effective generic-resource reservation with nested provenance.
2213#[derive(Debug, Clone, PartialEq, Eq)]
2214pub struct ProjectDeployGenericResource {
2215    form: ProjectDeployGenericResourceForm,
2216    discrete_resource_spec: Option<ProjectValue<ProjectDeployDiscreteResourceSpec>>,
2217    unmodeled_fields: Vec<ProjectFieldReference>,
2218}
2219impl ProjectDeployGenericResource {
2220    /// Returns whether this effective item is a mapping or retained unmodeled sequence entry.
2221    #[must_use]
2222    pub const fn form(&self) -> ProjectDeployGenericResourceForm {
2223        self.form
2224    }
2225    /// Returns the optional effective discrete-resource specification.
2226    #[must_use]
2227    pub const fn discrete_resource_spec(&self) -> Option<&ProjectValue<ProjectDeployDiscreteResourceSpec>> {
2228        self.discrete_resource_spec.as_ref()
2229    }
2230    /// Returns extensions, unknown fields, and malformed members retained as evidence.
2231    #[must_use]
2232    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2233        &self.unmodeled_fields
2234    }
2235}
2236
2237/// Effective generic-resource item shape retained without coercion.
2238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2239#[non_exhaustive]
2240pub enum ProjectDeployGenericResourceForm {
2241    /// A mapping-form item.
2242    Mapping,
2243    /// A non-mapping sequence item retained as evidence.
2244    Unmodeled,
2245}
2246
2247/// Effective discrete generic-resource specification with member provenance.
2248#[derive(Debug, Clone, PartialEq, Eq)]
2249pub struct ProjectDeployDiscreteResourceSpec {
2250    kind: Option<ProjectValue<String>>,
2251    value: Option<ProjectValue<DeployDiscreteResourceValue>>,
2252    unmodeled_fields: Vec<ProjectFieldReference>,
2253}
2254impl ProjectDeployDiscreteResourceSpec {
2255    /// Returns the optional effective kind scalar with provenance.
2256    #[must_use]
2257    pub const fn kind(&self) -> Option<&ProjectValue<String>> {
2258        self.kind.as_ref()
2259    }
2260    /// Returns the optional effective value scalar with provenance.
2261    #[must_use]
2262    pub const fn value(&self) -> Option<&ProjectValue<DeployDiscreteResourceValue>> {
2263        self.value.as_ref()
2264    }
2265    /// Returns extensions, unknown fields, and malformed members retained as evidence.
2266    #[must_use]
2267    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2268        &self.unmodeled_fields
2269    }
2270}
2271
2272/// Effective deploy resource limits with nested provenance.
2273#[derive(Debug, Clone, PartialEq, Eq)]
2274pub struct ProjectDeployResourceLimits {
2275    cpus: Option<ProjectValue<DeployResourceCpus>>,
2276    memory: Option<ProjectValue<DeployResourceMemory>>,
2277    pids: Option<ProjectValue<DeployResourcePids>>,
2278    unmodeled_fields: Vec<ProjectFieldReference>,
2279}
2280
2281impl ProjectDeployResourceLimits {
2282    /// Returns the effective deploy resource CPU scalar spelling and category.
2283    #[must_use]
2284    pub const fn cpus(&self) -> Option<&ProjectValue<DeployResourceCpus>> {
2285        self.cpus.as_ref()
2286    }
2287
2288    /// Returns the effective deploy resource memory value and its provenance.
2289    #[must_use]
2290    pub const fn memory(&self) -> Option<&ProjectValue<DeployResourceMemory>> {
2291        self.memory.as_ref()
2292    }
2293
2294    /// Returns the effective deploy resource PID scalar spelling and category.
2295    #[must_use]
2296    pub const fn pids(&self) -> Option<&ProjectValue<DeployResourcePids>> {
2297        self.pids.as_ref()
2298    }
2299
2300    /// Returns extensions, unknown fields, and malformed children retained as evidence.
2301    #[must_use]
2302    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2303        &self.unmodeled_fields
2304    }
2305}
2306
2307/// Effective deploy restart-policy members with independent provenance.
2308#[derive(Debug, Clone, PartialEq, Eq)]
2309pub struct ProjectDeployRestartPolicy {
2310    condition: Option<ProjectValue<DeployRestartCondition>>,
2311    delay: Option<ProjectValue<DeployRestartDuration>>,
2312    max_attempts: Option<ProjectValue<DeployRestartMaxAttempts>>,
2313    window: Option<ProjectValue<DeployRestartDuration>>,
2314    unmodeled_fields: Vec<ProjectFieldReference>,
2315}
2316
2317/// Effective deploy rollback members with independent provenance.
2318#[derive(Debug, Clone, PartialEq, Eq)]
2319pub struct ProjectDeployRollbackConfig {
2320    parallelism: Option<ProjectValue<DeployRollbackParallelism>>,
2321    delay: Option<ProjectValue<String>>,
2322    monitor: Option<ProjectValue<String>>,
2323    failure_action: Option<ProjectValue<String>>,
2324    max_failure_ratio: Option<ProjectValue<DeployRollbackMaxFailureRatio>>,
2325    order: Option<ProjectValue<DeployRollbackOrder>>,
2326    unmodeled_fields: Vec<ProjectFieldReference>,
2327}
2328impl ProjectDeployRollbackConfig {
2329    /// Returns effective rollback parallelism with provenance.
2330    #[must_use]
2331    pub const fn parallelism(&self) -> Option<&ProjectValue<DeployRollbackParallelism>> {
2332        self.parallelism.as_ref()
2333    }
2334    /// Returns effective rollback delay with provenance.
2335    #[must_use]
2336    pub const fn delay(&self) -> Option<&ProjectValue<String>> {
2337        self.delay.as_ref()
2338    }
2339    /// Returns effective rollback monitor with provenance.
2340    #[must_use]
2341    pub const fn monitor(&self) -> Option<&ProjectValue<String>> {
2342        self.monitor.as_ref()
2343    }
2344    /// Returns effective rollback failure action with provenance.
2345    #[must_use]
2346    pub const fn failure_action(&self) -> Option<&ProjectValue<String>> {
2347        self.failure_action.as_ref()
2348    }
2349    /// Returns effective rollback maximum failure ratio with provenance.
2350    #[must_use]
2351    pub const fn max_failure_ratio(&self) -> Option<&ProjectValue<DeployRollbackMaxFailureRatio>> {
2352        self.max_failure_ratio.as_ref()
2353    }
2354    /// Returns effective rollback order with provenance.
2355    #[must_use]
2356    pub const fn order(&self) -> Option<&ProjectValue<DeployRollbackOrder>> {
2357        self.order.as_ref()
2358    }
2359    /// Returns malformed and unknown retained rollback members.
2360    #[must_use]
2361    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2362        &self.unmodeled_fields
2363    }
2364}
2365
2366/// Effective deploy rolling-update members with independent provenance.
2367#[derive(Debug, Clone, PartialEq, Eq)]
2368pub struct ProjectDeployUpdateConfig {
2369    parallelism: Option<ProjectValue<DeployUpdateParallelism>>,
2370    delay: Option<ProjectValue<String>>,
2371    monitor: Option<ProjectValue<String>>,
2372    failure_action: Option<ProjectValue<String>>,
2373    max_failure_ratio: Option<ProjectValue<DeployUpdateMaxFailureRatio>>,
2374    order: Option<ProjectValue<DeployUpdateOrder>>,
2375    unmodeled_fields: Vec<ProjectFieldReference>,
2376}
2377impl ProjectDeployUpdateConfig {
2378    /// Returns effective update parallelism with provenance.
2379    #[must_use]
2380    pub const fn parallelism(&self) -> Option<&ProjectValue<DeployUpdateParallelism>> {
2381        self.parallelism.as_ref()
2382    }
2383    /// Returns effective update delay with provenance.
2384    #[must_use]
2385    pub const fn delay(&self) -> Option<&ProjectValue<String>> {
2386        self.delay.as_ref()
2387    }
2388    /// Returns effective update monitor with provenance.
2389    #[must_use]
2390    pub const fn monitor(&self) -> Option<&ProjectValue<String>> {
2391        self.monitor.as_ref()
2392    }
2393    /// Returns effective update failure action with provenance.
2394    #[must_use]
2395    pub const fn failure_action(&self) -> Option<&ProjectValue<String>> {
2396        self.failure_action.as_ref()
2397    }
2398    /// Returns effective maximum failure ratio with provenance.
2399    #[must_use]
2400    pub const fn max_failure_ratio(&self) -> Option<&ProjectValue<DeployUpdateMaxFailureRatio>> {
2401        self.max_failure_ratio.as_ref()
2402    }
2403    /// Returns effective update order with provenance.
2404    #[must_use]
2405    pub const fn order(&self) -> Option<&ProjectValue<DeployUpdateOrder>> {
2406        self.order.as_ref()
2407    }
2408    /// Returns malformed and unknown retained update members.
2409    #[must_use]
2410    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2411        &self.unmodeled_fields
2412    }
2413}
2414
2415impl ProjectDeployRestartPolicy {
2416    /// Returns the effective restart condition.
2417    #[must_use]
2418    pub const fn condition(&self) -> Option<&ProjectValue<DeployRestartCondition>> {
2419        self.condition.as_ref()
2420    }
2421    /// Returns the effective raw delay spelling.
2422    #[must_use]
2423    pub const fn delay(&self) -> Option<&ProjectValue<DeployRestartDuration>> {
2424        self.delay.as_ref()
2425    }
2426    /// Returns the effective maximum-attempts scalar.
2427    #[must_use]
2428    pub const fn max_attempts(&self) -> Option<&ProjectValue<DeployRestartMaxAttempts>> {
2429        self.max_attempts.as_ref()
2430    }
2431    /// Returns the effective raw window spelling.
2432    #[must_use]
2433    pub const fn window(&self) -> Option<&ProjectValue<DeployRestartDuration>> {
2434        self.window.as_ref()
2435    }
2436    /// Returns malformed and unknown retained members.
2437    #[must_use]
2438    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2439        &self.unmodeled_fields
2440    }
2441}
2442
2443/// Effective deploy placement with item and nested-member provenance.
2444#[derive(Debug, Clone, PartialEq, Eq)]
2445pub struct ProjectDeployPlacement {
2446    constraints: Option<ProjectValue<Vec<ProjectValue<String>>>>,
2447    preferences: Option<ProjectValue<Vec<ProjectValue<ProjectDeployPlacementPreference>>>>,
2448    max_replicas_per_node: Option<ProjectValue<DeployPlacementMaxReplicasPerNode>>,
2449    unmodeled_fields: Vec<ProjectFieldReference>,
2450}
2451
2452impl ProjectDeployPlacement {
2453    /// Returns ordered raw constraints, including duplicates and empty strings.
2454    #[must_use]
2455    pub const fn constraints(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
2456        self.constraints.as_ref()
2457    }
2458
2459    /// Returns ordered placement preferences, including explicit empty mappings.
2460    #[must_use]
2461    pub const fn preferences(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectDeployPlacementPreference>>>> {
2462        self.preferences.as_ref()
2463    }
2464
2465    /// Returns the effective max-replicas-per-node scalar spelling and category.
2466    #[must_use]
2467    pub const fn max_replicas_per_node(&self) -> Option<&ProjectValue<DeployPlacementMaxReplicasPerNode>> {
2468        self.max_replicas_per_node.as_ref()
2469    }
2470
2471    /// Returns extensions, unknown fields, and malformed children retained as evidence.
2472    #[must_use]
2473    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2474        &self.unmodeled_fields
2475    }
2476}
2477
2478/// One effective placement preference mapping with nested provenance.
2479#[derive(Debug, Clone, PartialEq, Eq)]
2480pub struct ProjectDeployPlacementPreference {
2481    spread: Option<ProjectValue<String>>,
2482    unmodeled_fields: Vec<ProjectFieldReference>,
2483}
2484
2485impl ProjectDeployPlacementPreference {
2486    /// Returns the raw effective spread expression without scheduling interpretation.
2487    #[must_use]
2488    pub const fn spread(&self) -> Option<&ProjectValue<String>> {
2489        self.spread.as_ref()
2490    }
2491
2492    /// Returns extensions, unknown fields, and malformed members retained as evidence.
2493    #[must_use]
2494    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2495        &self.unmodeled_fields
2496    }
2497}
2498
2499/// Effective service credential-spec members with independent provenance.
2500#[derive(Debug, Clone, PartialEq, Eq)]
2501pub struct ProjectCredentialSpec {
2502    config: Option<ProjectValue<String>>,
2503    file: Option<ProjectValue<String>>,
2504    registry: Option<ProjectValue<String>>,
2505    unmodeled_fields: Vec<ProjectFieldReference>,
2506}
2507
2508impl ProjectCredentialSpec {
2509    /// Returns the effective config reference without resolving top-level configs.
2510    #[must_use]
2511    pub const fn config(&self) -> Option<&ProjectValue<String>> {
2512        self.config.as_ref()
2513    }
2514
2515    /// Returns the effective file reference without accessing the filesystem.
2516    #[must_use]
2517    pub const fn file(&self) -> Option<&ProjectValue<String>> {
2518        self.file.as_ref()
2519    }
2520
2521    /// Returns the effective registry reference without registry or account access.
2522    #[must_use]
2523    pub const fn registry(&self) -> Option<&ProjectValue<String>> {
2524        self.registry.as_ref()
2525    }
2526
2527    /// Returns malformed, extension, and future-unknown members retained as evidence.
2528    #[must_use]
2529    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2530        &self.unmodeled_fields
2531    }
2532}
2533
2534/// An effective raw service `extends` directive without referenced-service resolution.
2535#[derive(Debug, Clone, PartialEq, Eq)]
2536#[non_exhaustive]
2537pub enum ProjectExtends {
2538    /// A short service reference with field provenance.
2539    Short(ProjectValue<String>),
2540    /// A long reference mapping with member provenance.
2541    Long(ProjectExtendsReference),
2542}
2543
2544/// Effective long service extends reference members.
2545#[derive(Debug, Clone, PartialEq, Eq)]
2546pub struct ProjectExtendsReference {
2547    service: Option<ProjectValue<String>>,
2548    file: Option<ProjectValue<String>>,
2549    unmodeled_fields: Vec<ProjectFieldReference>,
2550}
2551
2552impl ProjectExtendsReference {
2553    /// Returns the effective referenced service without resolution.
2554    #[must_use]
2555    pub const fn service(&self) -> Option<&ProjectValue<String>> {
2556        self.service.as_ref()
2557    }
2558
2559    /// Returns the effective referenced file without path access.
2560    #[must_use]
2561    pub const fn file(&self) -> Option<&ProjectValue<String>> {
2562        self.file.as_ref()
2563    }
2564
2565    /// Returns malformed, extension, and future-unknown members retained as evidence.
2566    #[must_use]
2567    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2568        &self.unmodeled_fields
2569    }
2570}
2571
2572/// One effective provider option value with scalar category or ordered sequence preserved.
2573#[derive(Debug, Clone, PartialEq, Eq)]
2574#[non_exhaustive]
2575pub enum ProjectProviderOptionValue {
2576    /// One YAML string, number, or boolean scalar.
2577    Scalar(ComposeScalar),
2578    /// Ordered scalar or malformed sequence items with per-item provenance.
2579    Sequence(Vec<ProjectValue<ProjectProviderOptionItem>>),
2580}
2581
2582/// One effective provider-option sequence item.
2583#[derive(Debug, Clone, PartialEq, Eq)]
2584#[non_exhaustive]
2585pub enum ProjectProviderOptionItem {
2586    /// A YAML string, number, or boolean scalar.
2587    Scalar(ComposeScalar),
2588    /// A malformed item retained with item provenance and sensitivity.
2589    Unmodeled,
2590}
2591
2592/// One effective provider option with complete key and value provenance.
2593#[derive(Debug, Clone, PartialEq, Eq)]
2594pub struct ProjectProviderOption {
2595    name: ProjectKey,
2596    value: ProjectValue<ProjectProviderOptionValue>,
2597}
2598
2599impl ProjectProviderOption {
2600    /// Returns the non-empty option name and every authored key location.
2601    #[must_use]
2602    pub const fn name(&self) -> &ProjectKey {
2603        &self.name
2604    }
2605
2606    /// Returns the scalar category or ordered sequence with complete merge provenance.
2607    #[must_use]
2608    pub const fn value(&self) -> &ProjectValue<ProjectProviderOptionValue> {
2609        &self.value
2610    }
2611}
2612
2613/// Effective ordered provider options, including an explicitly empty or reset mapping.
2614#[derive(Debug, Clone, PartialEq, Eq)]
2615pub struct ProjectProviderOptions {
2616    entries: Vec<ProjectValue<ProjectProviderOption>>,
2617    unmodeled_entries: Vec<ProjectFieldReference>,
2618}
2619
2620impl ProjectProviderOptions {
2621    /// Returns valid option entries in effective mapping order.
2622    #[must_use]
2623    pub fn entries(&self) -> &[ProjectValue<ProjectProviderOption>] {
2624        &self.entries
2625    }
2626
2627    /// Returns malformed, duplicate, or empty-key entries retained as evidence.
2628    #[must_use]
2629    pub fn unmodeled_entries(&self) -> &[ProjectFieldReference] {
2630        &self.unmodeled_entries
2631    }
2632
2633    /// Reports whether the effective options mapping has no valid or malformed entries.
2634    #[must_use]
2635    pub fn is_empty(&self) -> bool {
2636        self.entries.is_empty() && self.unmodeled_entries.is_empty()
2637    }
2638}
2639
2640/// An effective service provider mapping without execution or discovery.
2641#[derive(Debug, Clone, PartialEq, Eq)]
2642pub struct ProjectProvider {
2643    type_: Option<ProjectValue<String>>,
2644    options: Option<ProjectValue<ProjectProviderOptions>>,
2645    unmodeled_fields: Vec<ProjectFieldReference>,
2646}
2647
2648impl ProjectProvider {
2649    /// Returns the required strict YAML-string provider type when validly effective.
2650    #[must_use]
2651    pub const fn type_(&self) -> Option<&ProjectValue<String>> {
2652        self.type_.as_ref()
2653    }
2654
2655    /// Returns the ordered provider options mapping, including an explicitly empty one.
2656    #[must_use]
2657    pub const fn options(&self) -> Option<&ProjectValue<ProjectProviderOptions>> {
2658        self.options.as_ref()
2659    }
2660
2661    /// Returns malformed, extension, and unknown provider members retained as evidence.
2662    #[must_use]
2663    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2664        &self.unmodeled_fields
2665    }
2666}
2667
2668/// One effective post-start hook with independently retained member provenance.
2669#[derive(Debug, Clone, PartialEq, Eq)]
2670pub struct ProjectServiceHook {
2671    command: Option<ProjectValue<Command>>,
2672    environment: Option<ProjectValue<ProjectEnvironment>>,
2673    privileged: Option<ProjectValue<BooleanValue>>,
2674    user: Option<ProjectValue<String>>,
2675    working_dir: Option<ProjectValue<String>>,
2676    unmodeled_fields: Vec<ProjectFieldReference>,
2677}
2678
2679impl ProjectServiceHook {
2680    /// Returns the required command without shell or argv interpretation.
2681    #[must_use]
2682    pub const fn command(&self) -> Option<&ProjectValue<Command>> {
2683        self.command.as_ref()
2684    }
2685
2686    /// Returns the local environment without combining it with service environment values.
2687    #[must_use]
2688    pub const fn environment(&self) -> Option<&ProjectValue<ProjectEnvironment>> {
2689        self.environment.as_ref()
2690    }
2691
2692    /// Returns the explicit hook privilege choice.
2693    #[must_use]
2694    pub const fn privileged(&self) -> Option<&ProjectValue<BooleanValue>> {
2695        self.privileged.as_ref()
2696    }
2697
2698    /// Returns the strict YAML-string hook user without default resolution.
2699    #[must_use]
2700    pub const fn user(&self) -> Option<&ProjectValue<String>> {
2701        self.user.as_ref()
2702    }
2703
2704    /// Returns the strict YAML-string hook working directory without path resolution.
2705    #[must_use]
2706    pub const fn working_dir(&self) -> Option<&ProjectValue<String>> {
2707        self.working_dir.as_ref()
2708    }
2709
2710    /// Returns malformed, duplicate, extension, and unknown hook members as evidence.
2711    #[must_use]
2712    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2713        &self.unmodeled_fields
2714    }
2715}
2716
2717/// One effective `post_start` sequence item.
2718#[derive(Debug, Clone, PartialEq, Eq)]
2719#[non_exhaustive]
2720pub enum ProjectPostStartHook {
2721    /// A valid or partially recovered hook mapping.
2722    Hook(Box<ProjectServiceHook>),
2723    /// A malformed non-mapping item retained with per-item provenance.
2724    Unmodeled,
2725}
2726
2727/// One effective `pre_stop` sequence item.
2728#[derive(Debug, Clone, PartialEq, Eq)]
2729#[non_exhaustive]
2730pub enum ProjectPreStopHook {
2731    /// A valid or partially recovered hook mapping.
2732    Hook(Box<ProjectServiceHook>),
2733    /// A malformed non-mapping item retained with per-item provenance.
2734    Unmodeled,
2735}
2736
2737/// One effective pre-start hook with independently retained member provenance.
2738#[derive(Debug, Clone, PartialEq, Eq)]
2739pub struct ProjectPreStartServiceHook {
2740    command: Option<ProjectValue<Command>>,
2741    image: Option<ProjectValue<String>>,
2742    environment: Option<ProjectValue<ProjectEnvironment>>,
2743    privileged: Option<ProjectValue<BooleanValue>>,
2744    per_replica: Option<ProjectValue<BooleanValue>>,
2745    user: Option<ProjectValue<String>>,
2746    working_dir: Option<ProjectValue<String>>,
2747    unmodeled_fields: Vec<ProjectFieldReference>,
2748}
2749
2750impl ProjectPreStartServiceHook {
2751    /// Returns the optional command without shell or argv interpretation.
2752    #[must_use]
2753    pub const fn command(&self) -> Option<&ProjectValue<Command>> {
2754        self.command.as_ref()
2755    }
2756
2757    /// Returns the strict raw hook image without image-reference interpretation.
2758    #[must_use]
2759    pub const fn image(&self) -> Option<&ProjectValue<String>> {
2760        self.image.as_ref()
2761    }
2762
2763    /// Returns the local environment without combining it with service environment values.
2764    #[must_use]
2765    pub const fn environment(&self) -> Option<&ProjectValue<ProjectEnvironment>> {
2766        self.environment.as_ref()
2767    }
2768
2769    /// Returns the explicit hook privilege choice.
2770    #[must_use]
2771    pub const fn privileged(&self) -> Option<&ProjectValue<BooleanValue>> {
2772        self.privileged.as_ref()
2773    }
2774
2775    /// Returns the explicit per-replica choice without injecting a default.
2776    #[must_use]
2777    pub const fn per_replica(&self) -> Option<&ProjectValue<BooleanValue>> {
2778        self.per_replica.as_ref()
2779    }
2780
2781    /// Returns the strict YAML-string hook user without default resolution.
2782    #[must_use]
2783    pub const fn user(&self) -> Option<&ProjectValue<String>> {
2784        self.user.as_ref()
2785    }
2786
2787    /// Returns the strict YAML-string hook working directory without path resolution.
2788    #[must_use]
2789    pub const fn working_dir(&self) -> Option<&ProjectValue<String>> {
2790        self.working_dir.as_ref()
2791    }
2792
2793    /// Returns malformed, duplicate, extension, and unknown hook members as evidence.
2794    #[must_use]
2795    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2796        &self.unmodeled_fields
2797    }
2798}
2799
2800/// One effective `pre_start` sequence item.
2801#[derive(Debug, Clone, PartialEq, Eq)]
2802#[non_exhaustive]
2803pub enum ProjectPreStartHook {
2804    /// A valid or partially recovered hook mapping.
2805    Hook(Box<ProjectPreStartServiceHook>),
2806    /// A malformed non-mapping item retained with per-item provenance.
2807    Unmodeled,
2808}
2809
2810/// Effective nested evidence for one service-volume mount.
2811///
2812/// The collection returned by [`ProjectService::volume_mount_options`] keeps the same order as
2813/// [`ProjectService::volumes`]. It supplements, rather than replaces, the stable native mount API.
2814#[derive(Debug, Clone, PartialEq, Eq)]
2815pub struct ProjectVolumeMountOptions {
2816    consistency: Option<ProjectValue<String>>,
2817    bind: Option<ProjectBindMountOptions>,
2818    image: Option<ProjectImageMountOptions>,
2819    tmpfs: Option<ProjectTmpfsMountOptions>,
2820    volume: Option<ProjectNamedVolumeMountOptions>,
2821    unmodeled_fields: Vec<ProjectFieldReference>,
2822}
2823
2824impl ProjectVolumeMountOptions {
2825    fn new() -> Self {
2826        Self {
2827            consistency: None,
2828            bind: None,
2829            image: None,
2830            tmpfs: None,
2831            volume: None,
2832            unmodeled_fields: Vec::new(),
2833        }
2834    }
2835    /// Returns the effective mount-consistency token and its contributors.
2836    #[must_use]
2837    pub const fn consistency(&self) -> Option<&ProjectValue<String>> {
2838        self.consistency.as_ref()
2839    }
2840    /// Returns effective bind-option evidence.
2841    #[must_use]
2842    pub const fn bind(&self) -> Option<&ProjectBindMountOptions> {
2843        self.bind.as_ref()
2844    }
2845    /// Returns effective image-mount option evidence.
2846    #[must_use]
2847    pub const fn image(&self) -> Option<&ProjectImageMountOptions> {
2848        self.image.as_ref()
2849    }
2850    /// Returns effective tmpfs-mount option evidence.
2851    #[must_use]
2852    pub const fn tmpfs(&self) -> Option<&ProjectTmpfsMountOptions> {
2853        self.tmpfs.as_ref()
2854    }
2855    /// Returns effective named-volume option evidence.
2856    #[must_use]
2857    pub const fn volume(&self) -> Option<&ProjectNamedVolumeMountOptions> {
2858        self.volume.as_ref()
2859    }
2860    /// Returns malformed or unrecognized long-mount fields with effective provenance.
2861    #[must_use]
2862    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
2863        &self.unmodeled_fields
2864    }
2865}
2866
2867/// Effective bind-option evidence for one long mount.
2868#[derive(Debug, Clone, PartialEq, Eq)]
2869pub struct ProjectBindMountOptions {
2870    recursive: Option<ProjectValue<String>>,
2871    extension_fields: Vec<ProjectFieldReference>,
2872    unknown_fields: Vec<ProjectFieldReference>,
2873}
2874impl ProjectBindMountOptions {
2875    /// Returns the effective recursive bind token and its contributors.
2876    #[must_use]
2877    pub const fn recursive(&self) -> Option<&ProjectValue<String>> {
2878        self.recursive.as_ref()
2879    }
2880    /// Returns retained effective `x-` bind-option fields.
2881    #[must_use]
2882    pub fn extension_fields(&self) -> &[ProjectFieldReference] {
2883        &self.extension_fields
2884    }
2885    /// Returns malformed or unrecognized effective bind-option fields.
2886    #[must_use]
2887    pub fn unknown_fields(&self) -> &[ProjectFieldReference] {
2888        &self.unknown_fields
2889    }
2890}
2891
2892/// Effective image-mount option evidence for one long mount.
2893#[derive(Debug, Clone, PartialEq, Eq)]
2894pub struct ProjectImageMountOptions {
2895    subpath: Option<ProjectValue<String>>,
2896    extension_fields: Vec<ProjectFieldReference>,
2897    unknown_fields: Vec<ProjectFieldReference>,
2898}
2899impl ProjectImageMountOptions {
2900    /// Returns the effective image subpath and its contributors.
2901    #[must_use]
2902    pub const fn subpath(&self) -> Option<&ProjectValue<String>> {
2903        self.subpath.as_ref()
2904    }
2905    /// Returns retained effective `x-` image-option fields.
2906    #[must_use]
2907    pub fn extension_fields(&self) -> &[ProjectFieldReference] {
2908        &self.extension_fields
2909    }
2910    /// Returns malformed or unrecognized effective image-option fields.
2911    #[must_use]
2912    pub fn unknown_fields(&self) -> &[ProjectFieldReference] {
2913        &self.unknown_fields
2914    }
2915}
2916
2917/// Effective tmpfs-mount option evidence for one long mount.
2918#[derive(Debug, Clone, PartialEq, Eq)]
2919pub struct ProjectTmpfsMountOptions {
2920    size: Option<ProjectValue<ComposeScalar>>,
2921    mode: Option<ProjectValue<ComposeScalar>>,
2922    extension_fields: Vec<ProjectFieldReference>,
2923    unknown_fields: Vec<ProjectFieldReference>,
2924}
2925impl ProjectTmpfsMountOptions {
2926    /// Returns the effective raw tmpfs size scalar and its contributors.
2927    #[must_use]
2928    pub const fn size(&self) -> Option<&ProjectValue<ComposeScalar>> {
2929        self.size.as_ref()
2930    }
2931    /// Returns the effective raw tmpfs mode scalar and its contributors.
2932    #[must_use]
2933    pub const fn mode(&self) -> Option<&ProjectValue<ComposeScalar>> {
2934        self.mode.as_ref()
2935    }
2936    /// Returns retained effective `x-` tmpfs-option fields.
2937    #[must_use]
2938    pub fn extension_fields(&self) -> &[ProjectFieldReference] {
2939        &self.extension_fields
2940    }
2941    /// Returns malformed or unrecognized effective tmpfs-option fields.
2942    #[must_use]
2943    pub fn unknown_fields(&self) -> &[ProjectFieldReference] {
2944        &self.unknown_fields
2945    }
2946}
2947
2948/// Effective named-volume option evidence for one long mount.
2949#[derive(Debug, Clone, PartialEq, Eq)]
2950pub struct ProjectNamedVolumeMountOptions {
2951    nocopy: Option<ProjectValue<BooleanValue>>,
2952    subpath: Option<ProjectValue<String>>,
2953    labels: Option<ProjectValue<Labels>>,
2954    extension_fields: Vec<ProjectFieldReference>,
2955    unknown_fields: Vec<ProjectFieldReference>,
2956}
2957impl ProjectNamedVolumeMountOptions {
2958    /// Returns the effective copy-prevention choice and its contributors.
2959    #[must_use]
2960    pub const fn nocopy(&self) -> Option<&ProjectValue<BooleanValue>> {
2961        self.nocopy.as_ref()
2962    }
2963    /// Returns the effective named-volume subpath and its contributors.
2964    #[must_use]
2965    pub const fn subpath(&self) -> Option<&ProjectValue<String>> {
2966        self.subpath.as_ref()
2967    }
2968    /// Returns effective named-volume labels in their list/map form with provenance.
2969    #[must_use]
2970    pub const fn labels(&self) -> Option<&ProjectValue<Labels>> {
2971        self.labels.as_ref()
2972    }
2973    /// Returns retained effective `x-` named-volume-option fields.
2974    #[must_use]
2975    pub fn extension_fields(&self) -> &[ProjectFieldReference] {
2976        &self.extension_fields
2977    }
2978    /// Returns malformed or unrecognized effective named-volume-option fields.
2979    #[must_use]
2980    pub fn unknown_fields(&self) -> &[ProjectFieldReference] {
2981        &self.unknown_fields
2982    }
2983}
2984
2985#[derive(Debug, Clone, PartialEq, Eq, Default)]
2986struct ProjectVolumeViews {
2987    volumes: Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>>,
2988    options: Option<ProjectValue<Vec<ProjectValue<ProjectVolumeMountOptions>>>>,
2989}
2990
2991/// One selected service with the native fields needed by the first conversion boundary.
2992#[derive(Debug, Clone, PartialEq, Eq)]
2993pub struct ProjectService {
2994    name: ProjectKey,
2995    provenance: MergeProvenance,
2996    hostname: Option<ProjectValue<Hostname>>,
2997    domainname: Option<ProjectValue<String>>,
2998    container_name: Option<ProjectValue<String>>,
2999    image: Option<ProjectValue<ImageReference>>,
3000    platform: Option<ProjectValue<String>>,
3001    isolation: Option<ProjectValue<String>>,
3002    mac_address: Option<ProjectValue<String>>,
3003    uts: Option<ProjectValue<String>>,
3004    entrypoint: Option<ProjectValue<Entrypoint>>,
3005    command: Option<ProjectValue<Command>>,
3006    credential_spec: Option<ProjectValue<ProjectCredentialSpec>>,
3007    extends: Option<ProjectValue<ProjectExtends>>,
3008    provider: Option<ProjectValue<ProjectProvider>>,
3009    post_start: Option<ProjectValue<Vec<ProjectValue<ProjectPostStartHook>>>>,
3010    pre_stop: Option<ProjectValue<Vec<ProjectValue<ProjectPreStopHook>>>>,
3011    pre_start: Option<ProjectValue<Vec<ProjectValue<ProjectPreStartHook>>>>,
3012    blkio_config: Option<ProjectValue<ProjectBlkioConfig>>,
3013    cgroup: Option<ProjectValue<CgroupNamespace>>,
3014    cgroup_parent: Option<ProjectValue<String>>,
3015    attach: Option<ProjectValue<BooleanValue>>,
3016    init: Option<ProjectValue<BooleanValue>>,
3017    stdin_open: Option<ProjectValue<BooleanValue>>,
3018    tty: Option<ProjectValue<BooleanValue>>,
3019    privileged: Option<ProjectValue<BooleanValue>>,
3020    use_api_socket: Option<ProjectValue<BooleanValue>>,
3021    environment: Option<ProjectValue<ProjectEnvironment>>,
3022    environment_files: Option<ProjectValue<Vec<ProjectValue<ProjectEnvironmentFile>>>>,
3023    label_files: Option<ProjectValue<ProjectServiceLabelFiles>>,
3024    labels: Option<ProjectValue<ProjectLabels>>,
3025    annotations: Option<ProjectValue<ProjectAnnotations>>,
3026    extra_hosts: Option<ProjectValue<ProjectExtraHosts>>,
3027    external_links: Option<ProjectValue<Vec<ProjectValue<String>>>>,
3028    links: Option<ProjectValue<Vec<ProjectValue<String>>>>,
3029    storage_opt: Option<ProjectValue<Labels>>,
3030    models: Option<ProjectValue<ServiceModels>>,
3031    gpus: Option<ProjectValue<Gpus>>,
3032    develop: Option<ProjectValue<Develop>>,
3033    user: Option<ProjectValue<UserSpec>>,
3034    userns_mode: Option<ProjectValue<UserNamespaceMode>>,
3035    group_add: Option<ProjectValue<Vec<ProjectValue<String>>>>,
3036    cap_add: Option<ProjectValue<Vec<ProjectValue<CapabilityAddItem>>>>,
3037    cap_drop: Option<ProjectValue<Vec<ProjectValue<CapabilityDropItem>>>>,
3038    devices: Option<ProjectValue<Vec<ProjectValue<ProjectDevice>>>>,
3039    dns: Option<ProjectValue<ProjectDns>>,
3040    dns_options: Option<ProjectValue<Vec<ProjectValue<String>>>>,
3041    dns_search: Option<ProjectValue<ProjectDnsSearch>>,
3042    expose: Option<ProjectValue<Vec<ProjectValue<ProjectExposeItem>>>>,
3043    security_options: Option<ProjectValue<Vec<ProjectValue<ProjectSecurityOptionItem>>>>,
3044    working_dir: Option<ProjectValue<String>>,
3045    read_only: Option<ProjectValue<BooleanValue>>,
3046    pids_limit: Option<ProjectValue<PidsLimit>>,
3047    cpu_count: Option<ProjectValue<CpuCount>>,
3048    cpu_percent: Option<ProjectValue<CpuPercent>>,
3049    cpu_period: Option<ProjectValue<CpuPeriod>>,
3050    cpu_quota: Option<ProjectValue<CpuQuota>>,
3051    cpu_rt_period: Option<ProjectValue<CpuRtPeriod>>,
3052    cpu_rt_runtime: Option<ProjectValue<CpuRtRuntime>>,
3053    cpu_shares: Option<ProjectValue<ServiceInteger>>,
3054    cpus: Option<ProjectValue<Cpus>>,
3055    cpuset: Option<ProjectValue<String>>,
3056    device_cgroup_rules: Option<ProjectValue<Vec<ProjectValue<String>>>>,
3057    invalid_device_cgroup_rules: Vec<ProjectInvalidServiceStringItem>,
3058    ipc: Option<ProjectValue<IpcMode>>,
3059    mem_reservation: Option<ProjectValue<MemLimit>>,
3060    mem_swappiness: Option<ProjectValue<ServiceInteger>>,
3061    memswap_limit: Option<ProjectValue<MemswapLimit>>,
3062    network_mode: Option<ProjectValue<NetworkMode>>,
3063    oom_kill_disable: Option<ProjectValue<BooleanValue>>,
3064    oom_score_adj: Option<ProjectValue<ServiceInteger>>,
3065    pid: Option<ProjectValue<PidMode>>,
3066    scale: Option<ProjectValue<ServiceInteger>>,
3067    volumes_from: Option<ProjectValue<Vec<ProjectValue<VolumesFrom>>>>,
3068    invalid_volumes_from: Vec<ProjectInvalidServiceStringItem>,
3069    shm_size: Option<ProjectValue<ShmSize>>,
3070    mem_limit: Option<ProjectValue<MemLimit>>,
3071    tmpfs: Option<ProjectValue<ProjectTmpfs>>,
3072    sysctls: Option<ProjectValue<ProjectSysctls>>,
3073    logging: Option<ProjectValue<ProjectLogging>>,
3074    ulimits: Option<ProjectValue<ProjectUlimits>>,
3075    pull_policy: Option<ProjectValue<PullPolicy>>,
3076    pull_refresh_after: Option<ProjectValue<String>>,
3077    restart: Option<ProjectValue<RestartPolicy>>,
3078    runtime: Option<ProjectValue<String>>,
3079    stop_signal: Option<ProjectValue<String>>,
3080    stop_grace_period: Option<ProjectValue<StopGracePeriod>>,
3081    healthcheck: Option<ProjectValue<ProjectHealthcheck>>,
3082    build: Option<ProjectValue<ProjectBuild>>,
3083    deploy: Option<ProjectValue<ProjectDeploy>>,
3084    depends_on: Option<ProjectValue<ProjectDependsOn>>,
3085    ports: Option<ProjectValue<Vec<ProjectValue<Port>>>>,
3086    volume_views: ProjectVolumeViews,
3087    configs: Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>,
3088    secrets: Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>,
3089    networks: Option<ProjectValue<ServiceNetworks>>,
3090    profiles: Option<ProjectValue<Vec<ProjectValue<String>>>>,
3091    unmodeled_fields: Vec<ProjectFieldReference>,
3092}
3093
3094impl ProjectService {
3095    fn from_entry(entry: &MergedEntry) -> Self {
3096        Self {
3097            name: ProjectKey::from_entry(entry),
3098            provenance: entry.value().provenance().clone(),
3099            hostname: None,
3100            domainname: None,
3101            container_name: None,
3102            image: None,
3103            platform: None,
3104            isolation: None,
3105            mac_address: None,
3106            uts: None,
3107            entrypoint: None,
3108            command: None,
3109            credential_spec: None,
3110            extends: None,
3111            provider: None,
3112            post_start: None,
3113            pre_stop: None,
3114            pre_start: None,
3115            blkio_config: None,
3116            cgroup: None,
3117            cgroup_parent: None,
3118            attach: None,
3119            init: None,
3120            stdin_open: None,
3121            tty: None,
3122            privileged: None,
3123            use_api_socket: None,
3124            environment: None,
3125            environment_files: None,
3126            label_files: None,
3127            labels: None,
3128            annotations: None,
3129            extra_hosts: None,
3130            external_links: None,
3131            links: None,
3132            storage_opt: None,
3133            models: None,
3134            gpus: None,
3135            develop: None,
3136            user: None,
3137            userns_mode: None,
3138            group_add: None,
3139            cap_add: None,
3140            cap_drop: None,
3141            devices: None,
3142            dns: None,
3143            dns_options: None,
3144            dns_search: None,
3145            expose: None,
3146            security_options: None,
3147            working_dir: None,
3148            read_only: None,
3149            pids_limit: None,
3150            cpu_count: None,
3151            cpu_percent: None,
3152            cpu_period: None,
3153            cpu_quota: None,
3154            cpu_rt_period: None,
3155            cpu_rt_runtime: None,
3156            cpu_shares: None,
3157            cpus: None,
3158            cpuset: None,
3159            device_cgroup_rules: None,
3160            invalid_device_cgroup_rules: Vec::new(),
3161            ipc: None,
3162            mem_reservation: None,
3163            mem_swappiness: None,
3164            memswap_limit: None,
3165            network_mode: None,
3166            oom_kill_disable: None,
3167            oom_score_adj: None,
3168            pid: None,
3169            scale: None,
3170            volumes_from: None,
3171            invalid_volumes_from: Vec::new(),
3172            shm_size: None,
3173            mem_limit: None,
3174            tmpfs: None,
3175            sysctls: None,
3176            logging: None,
3177            ulimits: None,
3178            pull_policy: None,
3179            pull_refresh_after: None,
3180            restart: None,
3181            runtime: None,
3182            stop_signal: None,
3183            stop_grace_period: None,
3184            healthcheck: None,
3185            build: None,
3186            deploy: None,
3187            depends_on: None,
3188            ports: None,
3189            volume_views: ProjectVolumeViews::default(),
3190            configs: None,
3191            secrets: None,
3192            networks: None,
3193            profiles: None,
3194            unmodeled_fields: Vec::new(),
3195        }
3196    }
3197
3198    /// Returns the service name and all contributing key spans.
3199    #[must_use]
3200    pub const fn name(&self) -> &ProjectKey {
3201        &self.name
3202    }
3203
3204    /// Returns provenance for the complete effective service mapping.
3205    #[must_use]
3206    pub const fn provenance(&self) -> &MergeProvenance {
3207        &self.provenance
3208    }
3209
3210    /// Returns the effective raw-preserving service hostname.
3211    #[must_use]
3212    pub const fn hostname(&self) -> Option<&ProjectValue<Hostname>> {
3213        self.hostname.as_ref()
3214    }
3215
3216    /// Returns the effective raw service domain name without DNS or runtime interpretation.
3217    #[must_use]
3218    pub const fn domainname(&self) -> Option<&ProjectValue<String>> {
3219        self.domainname.as_ref()
3220    }
3221
3222    /// Returns the effective explicit runtime container name.
3223    #[must_use]
3224    pub const fn container_name(&self) -> Option<&ProjectValue<String>> {
3225        self.container_name.as_ref()
3226    }
3227
3228    /// Returns the effective image reference.
3229    #[must_use]
3230    pub const fn image(&self) -> Option<&ProjectValue<ImageReference>> {
3231        self.image.as_ref()
3232    }
3233
3234    /// Returns the strict raw effective service platform string without OCI interpretation.
3235    #[must_use]
3236    pub const fn platform(&self) -> Option<&ProjectValue<String>> {
3237        self.platform.as_ref()
3238    }
3239
3240    /// Returns the effective raw service isolation spelling.
3241    #[must_use]
3242    pub const fn isolation(&self) -> Option<&ProjectValue<String>> {
3243        self.isolation.as_ref()
3244    }
3245
3246    /// Returns the effective raw service MAC-address spelling.
3247    #[must_use]
3248    pub const fn mac_address(&self) -> Option<&ProjectValue<String>> {
3249        self.mac_address.as_ref()
3250    }
3251
3252    /// Returns the effective raw service UTS-mode spelling.
3253    #[must_use]
3254    pub const fn uts(&self) -> Option<&ProjectValue<String>> {
3255        self.uts.as_ref()
3256    }
3257
3258    /// Returns the effective entrypoint without normalizing scalar and list forms.
3259    #[must_use]
3260    pub const fn entrypoint(&self) -> Option<&ProjectValue<Entrypoint>> {
3261        self.entrypoint.as_ref()
3262    }
3263
3264    /// Returns the effective command without normalizing scalar and list forms.
3265    #[must_use]
3266    pub const fn command(&self) -> Option<&ProjectValue<Command>> {
3267        self.command.as_ref()
3268    }
3269
3270    /// Returns the effective credential-spec mapping without resolving its references.
3271    #[must_use]
3272    pub const fn credential_spec(&self) -> Option<&ProjectValue<ProjectCredentialSpec>> {
3273        self.credential_spec.as_ref()
3274    }
3275
3276    /// Returns the effective raw extends directive without reference resolution.
3277    #[must_use]
3278    pub const fn extends(&self) -> Option<&ProjectValue<ProjectExtends>> {
3279        self.extends.as_ref()
3280    }
3281
3282    /// Returns effective provider configuration without execution or discovery.
3283    #[must_use]
3284    pub const fn provider(&self) -> Option<&ProjectValue<ProjectProvider>> {
3285        self.provider.as_ref()
3286    }
3287
3288    /// Returns effective post-start hooks in merge order without lifecycle execution or scheduling.
3289    #[must_use]
3290    pub const fn post_start(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectPostStartHook>>>> {
3291        self.post_start.as_ref()
3292    }
3293
3294    /// Returns effective pre-stop hooks in merge order without lifecycle execution or scheduling.
3295    #[must_use]
3296    pub const fn pre_stop(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectPreStopHook>>>> {
3297        self.pre_stop.as_ref()
3298    }
3299
3300    /// Returns effective pre-start hooks in merge order without lifecycle execution or scheduling.
3301    #[must_use]
3302    pub const fn pre_start(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectPreStartHook>>>> {
3303        self.pre_start.as_ref()
3304    }
3305
3306    /// Returns effective block-I/O configuration without controller or runtime interpretation.
3307    #[must_use]
3308    pub const fn blkio_config(&self) -> Option<&ProjectValue<ProjectBlkioConfig>> {
3309        self.blkio_config.as_ref()
3310    }
3311
3312    /// Returns the effective cgroup namespace without controller or runtime interpretation.
3313    #[must_use]
3314    pub const fn cgroup(&self) -> Option<&ProjectValue<CgroupNamespace>> {
3315        self.cgroup.as_ref()
3316    }
3317
3318    /// Returns the effective raw cgroup parent string without path or runtime interpretation.
3319    #[must_use]
3320    pub const fn cgroup_parent(&self) -> Option<&ProjectValue<String>> {
3321        self.cgroup_parent.as_ref()
3322    }
3323
3324    /// Returns the strict raw service runtime string without runtime interpretation.
3325    #[must_use]
3326    pub const fn runtime(&self) -> Option<&ProjectValue<String>> {
3327        self.runtime.as_ref()
3328    }
3329
3330    /// Returns the strict raw effective pull-refresh interval without refresh interpretation.
3331    #[must_use]
3332    pub const fn pull_refresh_after(&self) -> Option<&ProjectValue<String>> {
3333        self.pull_refresh_after.as_ref()
3334    }
3335
3336    /// Returns the effective attach choice without runtime interpretation.
3337    #[must_use]
3338    pub const fn attach(&self) -> Option<&ProjectValue<BooleanValue>> {
3339        self.attach.as_ref()
3340    }
3341
3342    /// Returns the effective platform-specific init-process choice.
3343    #[must_use]
3344    pub const fn init(&self) -> Option<&ProjectValue<BooleanValue>> {
3345        self.init.as_ref()
3346    }
3347
3348    /// Returns whether Compose should keep standard input open for the effective service.
3349    #[must_use]
3350    pub const fn stdin_open(&self) -> Option<&ProjectValue<BooleanValue>> {
3351        self.stdin_open.as_ref()
3352    }
3353
3354    /// Returns whether Compose should allocate a terminal for the effective service.
3355    #[must_use]
3356    pub const fn tty(&self) -> Option<&ProjectValue<BooleanValue>> {
3357        self.tty.as_ref()
3358    }
3359
3360    /// Returns the effective privileged choice for the service.
3361    #[must_use]
3362    pub const fn privileged(&self) -> Option<&ProjectValue<BooleanValue>> {
3363        self.privileged.as_ref()
3364    }
3365
3366    /// Returns the effective API-socket choice without inspecting a local socket or environment.
3367    #[must_use]
3368    pub const fn use_api_socket(&self) -> Option<&ProjectValue<BooleanValue>> {
3369        self.use_api_socket.as_ref()
3370    }
3371
3372    /// Returns environment entries normalized by key with per-entry syntax retained.
3373    #[must_use]
3374    pub const fn environment(&self) -> Option<&ProjectValue<ProjectEnvironment>> {
3375        self.environment.as_ref()
3376    }
3377
3378    /// Returns effective service environment files in merge order with per-item provenance.
3379    #[must_use]
3380    pub const fn environment_files(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectEnvironmentFile>>>> {
3381        self.environment_files.as_ref()
3382    }
3383
3384    /// Returns effective scalar or ordered-list label-file paths. No file contents are read.
3385    #[must_use]
3386    pub const fn label_files(&self) -> Option<&ProjectValue<ProjectServiceLabelFiles>> {
3387        self.label_files.as_ref()
3388    }
3389
3390    /// Returns effective service labels normalized by key with entry syntax retained.
3391    #[must_use]
3392    pub const fn labels(&self) -> Option<&ProjectValue<ProjectLabels>> {
3393        self.labels.as_ref()
3394    }
3395
3396    /// Returns effective service annotations keyed by name with ambiguous key-only entries retained.
3397    #[must_use]
3398    pub const fn annotations(&self) -> Option<&ProjectValue<ProjectAnnotations>> {
3399        self.annotations.as_ref()
3400    }
3401
3402    /// Returns effective service host mappings with per-entry provenance and syntax.
3403    #[must_use]
3404    pub const fn extra_hosts(&self) -> Option<&ProjectValue<ProjectExtraHosts>> {
3405        self.extra_hosts.as_ref()
3406    }
3407
3408    /// Returns effective external links without resolving external services.
3409    #[must_use]
3410    pub const fn external_links(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
3411        self.external_links.as_ref()
3412    }
3413
3414    /// Returns effective legacy links without injecting dependency behavior.
3415    #[must_use]
3416    pub const fn links(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
3417        self.links.as_ref()
3418    }
3419
3420    /// Returns effective storage options without storage-driver interpretation.
3421    #[must_use]
3422    pub const fn storage_opt(&self) -> Option<&ProjectValue<Labels>> {
3423        self.storage_opt.as_ref()
3424    }
3425
3426    /// Returns effective raw model bindings without model-provider resolution.
3427    #[must_use]
3428    pub const fn models(&self) -> Option<&ProjectValue<ServiceModels>> {
3429        self.models.as_ref()
3430    }
3431
3432    /// Returns the effective raw scalar GPU selector.
3433    #[must_use]
3434    pub const fn gpus(&self) -> Option<&ProjectValue<Gpus>> {
3435        self.gpus.as_ref()
3436    }
3437
3438    /// Returns source provenance for the effective `develop` declaration.
3439    #[must_use]
3440    pub const fn develop(&self) -> Option<&ProjectValue<Develop>> {
3441        self.develop.as_ref()
3442    }
3443
3444    /// Returns the effective container user and optional group spelling.
3445    #[must_use]
3446    pub const fn user(&self) -> Option<&ProjectValue<UserSpec>> {
3447        self.user.as_ref()
3448    }
3449
3450    /// Returns the effective user-namespace mode.
3451    #[must_use]
3452    pub const fn userns_mode(&self) -> Option<&ProjectValue<UserNamespaceMode>> {
3453        self.userns_mode.as_ref()
3454    }
3455
3456    /// Returns supplementary groups in effective merge order.
3457    #[must_use]
3458    pub const fn group_add(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
3459        self.group_add.as_ref()
3460    }
3461
3462    /// Returns the effective capability-add sequence with full field and per-item provenance.
3463    ///
3464    /// `None` means the field was omitted; `Some` with an empty vector means it was explicitly
3465    /// configured empty or reset.
3466    #[must_use]
3467    pub const fn cap_add(&self) -> Option<&ProjectValue<Vec<ProjectValue<CapabilityAddItem>>>> {
3468        self.cap_add.as_ref()
3469    }
3470
3471    /// Returns the effective capability-drop sequence with full field and per-item provenance.
3472    ///
3473    /// `None` means the field was omitted; `Some` with an empty vector means it was explicitly
3474    /// configured empty or reset.
3475    #[must_use]
3476    pub const fn cap_drop(&self) -> Option<&ProjectValue<Vec<ProjectValue<CapabilityDropItem>>>> {
3477        self.cap_drop.as_ref()
3478    }
3479
3480    /// Returns effective ordered mixed short/long service devices with complete provenance.
3481    ///
3482    /// `None` means omission; `Some` with an empty vector means an explicit empty sequence or reset.
3483    #[must_use]
3484    pub const fn devices(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectDevice>>>> {
3485        self.devices.as_ref()
3486    }
3487
3488    /// Returns effective raw service DNS servers with source form and provenance.
3489    #[must_use]
3490    pub const fn dns(&self) -> Option<&ProjectValue<ProjectDns>> {
3491        self.dns.as_ref()
3492    }
3493
3494    /// Returns the effective ordered service DNS resolver options.
3495    ///
3496    /// Omission remains `None`; an explicitly empty or reset sequence remains `Some` with no
3497    /// items. Exact duplicate items are retained and diagnosed.
3498    #[must_use]
3499    pub const fn dns_options(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
3500        self.dns_options.as_ref()
3501    }
3502
3503    /// Returns effective raw DNS search domains with source form and provenance.
3504    #[must_use]
3505    pub const fn dns_search(&self) -> Option<&ProjectValue<ProjectDnsSearch>> {
3506        self.dns_search.as_ref()
3507    }
3508
3509    /// Returns the effective ordered service `expose` sequence.
3510    ///
3511    /// Omission remains `None`; an explicitly empty or reset sequence remains `Some` with no
3512    /// items. Exact scalar identity includes both value text and YAML string/number kind.
3513    #[must_use]
3514    pub const fn expose(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectExposeItem>>>> {
3515        self.expose.as_ref()
3516    }
3517
3518    /// Returns the effective ordered raw service security options.
3519    ///
3520    /// Omission remains `None`; an explicitly empty or reset sequence remains `Some` with no
3521    /// items. Duplicates remain ordered evidence.
3522    #[must_use]
3523    pub const fn security_options(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectSecurityOptionItem>>>> {
3524        self.security_options.as_ref()
3525    }
3526
3527    /// Returns the effective container working-directory override.
3528    #[must_use]
3529    pub const fn working_dir(&self) -> Option<&ProjectValue<String>> {
3530        self.working_dir.as_ref()
3531    }
3532
3533    /// Returns the effective read-only root-filesystem choice.
3534    #[must_use]
3535    pub const fn read_only(&self) -> Option<&ProjectValue<BooleanValue>> {
3536        self.read_only.as_ref()
3537    }
3538
3539    /// Returns the effective raw-preserving service PID limit.
3540    #[must_use]
3541    pub const fn pids_limit(&self) -> Option<&ProjectValue<PidsLimit>> {
3542        self.pids_limit.as_ref()
3543    }
3544
3545    /// Returns the effective CPU-count scalar without quota or runtime interpretation.
3546    #[must_use]
3547    pub const fn cpu_count(&self) -> Option<&ProjectValue<CpuCount>> {
3548        self.cpu_count.as_ref()
3549    }
3550
3551    /// Returns the effective CPU-percentage scalar without CPU or runtime interpretation.
3552    #[must_use]
3553    pub const fn cpu_percent(&self) -> Option<&ProjectValue<CpuPercent>> {
3554        self.cpu_percent.as_ref()
3555    }
3556
3557    /// Returns the effective CPU-period scalar without CPU or runtime interpretation.
3558    #[must_use]
3559    pub const fn cpu_period(&self) -> Option<&ProjectValue<CpuPeriod>> {
3560        self.cpu_period.as_ref()
3561    }
3562
3563    /// Returns the effective CPU-quota scalar without CPU or runtime interpretation.
3564    #[must_use]
3565    pub const fn cpu_quota(&self) -> Option<&ProjectValue<CpuQuota>> {
3566        self.cpu_quota.as_ref()
3567    }
3568
3569    /// Returns the effective real-time CPU-period scalar without CPU or runtime interpretation.
3570    #[must_use]
3571    pub const fn cpu_rt_period(&self) -> Option<&ProjectValue<CpuRtPeriod>> {
3572        self.cpu_rt_period.as_ref()
3573    }
3574    /// Returns the effective real-time CPU-runtime spelling.
3575    #[must_use]
3576    pub const fn cpu_rt_runtime(&self) -> Option<&ProjectValue<CpuRtRuntime>> {
3577        self.cpu_rt_runtime.as_ref()
3578    }
3579    /// Returns effective relative CPU shares.
3580    #[must_use]
3581    pub const fn cpu_shares(&self) -> Option<&ProjectValue<ServiceInteger>> {
3582        self.cpu_shares.as_ref()
3583    }
3584    /// Returns effective raw decimal CPU allocation.
3585    #[must_use]
3586    pub const fn cpus(&self) -> Option<&ProjectValue<Cpus>> {
3587        self.cpus.as_ref()
3588    }
3589    /// Returns effective raw CPU-set spelling.
3590    #[must_use]
3591    pub const fn cpuset(&self) -> Option<&ProjectValue<String>> {
3592        self.cpuset.as_ref()
3593    }
3594    /// Returns ordered effective device-cgroup rules, including duplicates.
3595    #[must_use]
3596    pub const fn device_cgroup_rules(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
3597        self.device_cgroup_rules.as_ref()
3598    }
3599    /// Returns malformed device-cgroup rule items retained with effective spans.
3600    #[must_use]
3601    pub fn invalid_device_cgroup_rules(&self) -> &[ProjectInvalidServiceStringItem] {
3602        &self.invalid_device_cgroup_rules
3603    }
3604    /// Returns the effective IPC mode.
3605    #[must_use]
3606    pub const fn ipc(&self) -> Option<&ProjectValue<IpcMode>> {
3607        self.ipc.as_ref()
3608    }
3609    /// Returns the effective raw memory reservation.
3610    #[must_use]
3611    pub const fn mem_reservation(&self) -> Option<&ProjectValue<MemLimit>> {
3612        self.mem_reservation.as_ref()
3613    }
3614    /// Returns effective memory swappiness.
3615    #[must_use]
3616    pub const fn mem_swappiness(&self) -> Option<&ProjectValue<ServiceInteger>> {
3617        self.mem_swappiness.as_ref()
3618    }
3619    /// Returns effective raw memory-plus-swap limit.
3620    #[must_use]
3621    pub const fn memswap_limit(&self) -> Option<&ProjectValue<MemswapLimit>> {
3622        self.memswap_limit.as_ref()
3623    }
3624    /// Returns the effective network mode.
3625    #[must_use]
3626    pub const fn network_mode(&self) -> Option<&ProjectValue<NetworkMode>> {
3627        self.network_mode.as_ref()
3628    }
3629    /// Returns the effective OOM-kill setting.
3630    #[must_use]
3631    pub const fn oom_kill_disable(&self) -> Option<&ProjectValue<BooleanValue>> {
3632        self.oom_kill_disable.as_ref()
3633    }
3634    /// Returns effective OOM-score adjustment.
3635    #[must_use]
3636    pub const fn oom_score_adj(&self) -> Option<&ProjectValue<ServiceInteger>> {
3637        self.oom_score_adj.as_ref()
3638    }
3639    /// Returns the effective PID mode.
3640    #[must_use]
3641    pub const fn pid(&self) -> Option<&ProjectValue<PidMode>> {
3642        self.pid.as_ref()
3643    }
3644    /// Returns effective scale spelling.
3645    #[must_use]
3646    pub const fn scale(&self) -> Option<&ProjectValue<ServiceInteger>> {
3647        self.scale.as_ref()
3648    }
3649    /// Returns ordered effective `volumes_from` references.
3650    #[must_use]
3651    pub const fn volumes_from(&self) -> Option<&ProjectValue<Vec<ProjectValue<VolumesFrom>>>> {
3652        self.volumes_from.as_ref()
3653    }
3654    /// Returns malformed `volumes_from` items retained with effective spans.
3655    #[must_use]
3656    pub fn invalid_volumes_from(&self) -> &[ProjectInvalidServiceStringItem] {
3657        &self.invalid_volumes_from
3658    }
3659
3660    /// Returns the effective raw-preserving service shared-memory size.
3661    #[must_use]
3662    pub const fn shm_size(&self) -> Option<&ProjectValue<ShmSize>> {
3663        self.shm_size.as_ref()
3664    }
3665
3666    /// Returns the effective raw-preserving service memory limit.
3667    #[must_use]
3668    pub const fn mem_limit(&self) -> Option<&ProjectValue<MemLimit>> {
3669        self.mem_limit.as_ref()
3670    }
3671
3672    /// Returns effective service-level temporary filesystems with source form and provenance.
3673    #[must_use]
3674    pub const fn tmpfs(&self) -> Option<&ProjectValue<ProjectTmpfs>> {
3675        self.tmpfs.as_ref()
3676    }
3677
3678    /// Returns effective service sysctls with source form and per-entry provenance.
3679    #[must_use]
3680    pub const fn sysctls(&self) -> Option<&ProjectValue<ProjectSysctls>> {
3681        self.sysctls.as_ref()
3682    }
3683
3684    /// Returns effective service logging configuration with nested provenance and recovery data.
3685    #[must_use]
3686    pub const fn logging(&self) -> Option<&ProjectValue<ProjectLogging>> {
3687        self.logging.as_ref()
3688    }
3689
3690    /// Returns effective ordered service limits with nested and field-level merge provenance.
3691    ///
3692    /// `None` means the field was omitted; an empty mapping remains present and can carry reset or
3693    /// override provenance.
3694    #[must_use]
3695    pub const fn ulimits(&self) -> Option<&ProjectValue<ProjectUlimits>> {
3696        self.ulimits.as_ref()
3697    }
3698
3699    /// Returns the effective raw-preserving service image pull policy.
3700    #[must_use]
3701    pub const fn pull_policy(&self) -> Option<&ProjectValue<PullPolicy>> {
3702        self.pull_policy.as_ref()
3703    }
3704
3705    /// Returns the effective service-level container restart policy.
3706    #[must_use]
3707    pub const fn restart(&self) -> Option<&ProjectValue<RestartPolicy>> {
3708        self.restart.as_ref()
3709    }
3710
3711    /// Returns the effective explicitly authored service stop signal.
3712    #[must_use]
3713    pub const fn stop_signal(&self) -> Option<&ProjectValue<String>> {
3714        self.stop_signal.as_ref()
3715    }
3716
3717    /// Returns the effective raw-preserving service stop grace period.
3718    #[must_use]
3719    pub const fn stop_grace_period(&self) -> Option<&ProjectValue<StopGracePeriod>> {
3720        self.stop_grace_period.as_ref()
3721    }
3722
3723    /// Returns the effective health check with per-field merge provenance.
3724    #[must_use]
3725    pub const fn healthcheck(&self) -> Option<&ProjectValue<ProjectHealthcheck>> {
3726        self.healthcheck.as_ref()
3727    }
3728
3729    /// Returns the effective build declaration with scalar and mapping context forms retained.
3730    ///
3731    /// Only build context, arguments, Dockerfile, target, network, platforms, no-cache, pull,
3732    /// tags, labels, secrets, and ulimits are natively
3733    /// modeled. All sibling build fields remain available as source-addressable unmodeled references.
3734    #[must_use]
3735    pub const fn build(&self) -> Option<&ProjectValue<ProjectBuild>> {
3736        self.build.as_ref()
3737    }
3738
3739    /// Returns the effective deploy mapping without applying deployment semantics.
3740    #[must_use]
3741    pub const fn deploy(&self) -> Option<&ProjectValue<ProjectDeploy>> {
3742        self.deploy.as_ref()
3743    }
3744
3745    /// Returns effective service dependencies with authored form and field-level provenance.
3746    #[must_use]
3747    pub const fn depends_on(&self) -> Option<&ProjectValue<ProjectDependsOn>> {
3748        self.depends_on.as_ref()
3749    }
3750
3751    /// Returns the effective port collection and per-item provenance.
3752    #[must_use]
3753    pub const fn ports(&self) -> Option<&ProjectValue<Vec<ProjectValue<Port>>>> {
3754        self.ports.as_ref()
3755    }
3756
3757    /// Returns the effective volume-mount collection and per-item provenance.
3758    #[must_use]
3759    pub const fn volumes(&self) -> Option<&ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
3760        self.volume_views.volumes.as_ref()
3761    }
3762
3763    /// Returns effective long-mount option blocks with nested contributor provenance and retained evidence.
3764    ///
3765    /// This companion view does not replace [`Self::volumes`]: the latter remains the stable
3766    /// syntax-preserving mount API. Short mounts have an empty option view at their matching index.
3767    #[must_use]
3768    pub const fn volume_mount_options(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectVolumeMountOptions>>>> {
3769        self.volume_views.options.as_ref()
3770    }
3771
3772    /// Returns effective service config grants with syntax and field-level provenance retained.
3773    #[must_use]
3774    pub const fn configs(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
3775        self.configs.as_ref()
3776    }
3777
3778    /// Returns effective service secret grants with syntax and field-level provenance retained.
3779    #[must_use]
3780    pub const fn secrets(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
3781        self.secrets.as_ref()
3782    }
3783
3784    /// Returns effective network attachments with short and long forms retained.
3785    #[must_use]
3786    pub const fn networks(&self) -> Option<&ProjectValue<ServiceNetworks>> {
3787        self.networks.as_ref()
3788    }
3789
3790    /// Returns effective profile names and their individual provenance.
3791    #[must_use]
3792    pub const fn profiles(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
3793        self.profiles.as_ref()
3794    }
3795
3796    /// Returns fields retained outside this initial native project-view boundary.
3797    #[must_use]
3798    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
3799        &self.unmodeled_fields
3800    }
3801}
3802
3803/// Effective block-I/O configuration with nested merge provenance.
3804#[derive(Debug, Clone, PartialEq, Eq)]
3805pub struct ProjectBlkioConfig {
3806    weight: Option<ProjectValue<BlkioScalar>>,
3807    device_read_bps: Option<ProjectValue<Vec<ProjectValue<ProjectBlkioDeviceRate>>>>,
3808    device_read_iops: Option<ProjectValue<Vec<ProjectValue<ProjectBlkioDeviceRate>>>>,
3809    device_write_bps: Option<ProjectValue<Vec<ProjectValue<ProjectBlkioDeviceRate>>>>,
3810    device_write_iops: Option<ProjectValue<Vec<ProjectValue<ProjectBlkioDeviceRate>>>>,
3811    weight_device: Option<ProjectValue<Vec<ProjectValue<ProjectBlkioWeightDevice>>>>,
3812    unmodeled_fields: Vec<ProjectFieldReference>,
3813}
3814impl ProjectBlkioConfig {
3815    /// Returns the optional effective raw overall weight and its provenance.
3816    #[must_use]
3817    pub const fn weight(&self) -> Option<&ProjectValue<BlkioScalar>> {
3818        self.weight.as_ref()
3819    }
3820    /// Returns effective ordered read-byte-rate entries, including an explicit empty sequence.
3821    #[must_use]
3822    pub const fn device_read_bps(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectBlkioDeviceRate>>>> {
3823        self.device_read_bps.as_ref()
3824    }
3825    /// Returns effective ordered read-I/O-rate entries, including an explicit empty sequence.
3826    #[must_use]
3827    pub const fn device_read_iops(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectBlkioDeviceRate>>>> {
3828        self.device_read_iops.as_ref()
3829    }
3830    /// Returns effective ordered write-byte-rate entries, including an explicit empty sequence.
3831    #[must_use]
3832    pub const fn device_write_bps(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectBlkioDeviceRate>>>> {
3833        self.device_write_bps.as_ref()
3834    }
3835    /// Returns effective ordered write-I/O-rate entries, including an explicit empty sequence.
3836    #[must_use]
3837    pub const fn device_write_iops(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectBlkioDeviceRate>>>> {
3838        self.device_write_iops.as_ref()
3839    }
3840    /// Returns effective ordered device-weight entries, including an explicit empty sequence.
3841    #[must_use]
3842    pub const fn weight_device(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectBlkioWeightDevice>>>> {
3843        self.weight_device.as_ref()
3844    }
3845    /// Returns extensions, unknown fields, and malformed members retained as evidence.
3846    #[must_use]
3847    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
3848        &self.unmodeled_fields
3849    }
3850}
3851
3852/// One effective block-I/O rate item.
3853#[derive(Debug, Clone, PartialEq, Eq)]
3854pub struct ProjectBlkioDeviceRate {
3855    form: ProjectBlkioDeviceRateForm,
3856    path: Option<ProjectValue<String>>,
3857    rate: Option<ProjectValue<BlkioScalar>>,
3858    unmodeled_fields: Vec<ProjectFieldReference>,
3859}
3860impl ProjectBlkioDeviceRate {
3861    /// Returns whether this effective entry is a mapping or retained unmodeled sequence item.
3862    #[must_use]
3863    pub const fn form(&self) -> ProjectBlkioDeviceRateForm {
3864        self.form
3865    }
3866    /// Returns the effective raw device path and its provenance.
3867    #[must_use]
3868    pub const fn path(&self) -> Option<&ProjectValue<String>> {
3869        self.path.as_ref()
3870    }
3871    /// Returns the effective raw integer-or-string rate and its provenance.
3872    #[must_use]
3873    pub const fn rate(&self) -> Option<&ProjectValue<BlkioScalar>> {
3874        self.rate.as_ref()
3875    }
3876    /// Returns extensions, unknown fields, and malformed members retained as evidence.
3877    #[must_use]
3878    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
3879        &self.unmodeled_fields
3880    }
3881}
3882
3883/// Effective block-I/O device-rate entry shape retained without coercion.
3884#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3885#[non_exhaustive]
3886pub enum ProjectBlkioDeviceRateForm {
3887    /// A mapping-form entry.
3888    Mapping,
3889    /// A non-mapping sequence entry retained as evidence.
3890    Unmodeled,
3891}
3892
3893/// One effective block-I/O device-weight item.
3894#[derive(Debug, Clone, PartialEq, Eq)]
3895pub struct ProjectBlkioWeightDevice {
3896    form: ProjectBlkioWeightDeviceForm,
3897    path: Option<ProjectValue<String>>,
3898    weight: Option<ProjectValue<BlkioScalar>>,
3899    unmodeled_fields: Vec<ProjectFieldReference>,
3900}
3901impl ProjectBlkioWeightDevice {
3902    /// Returns whether this effective entry is a mapping or retained unmodeled sequence item.
3903    #[must_use]
3904    pub const fn form(&self) -> ProjectBlkioWeightDeviceForm {
3905        self.form
3906    }
3907    /// Returns the effective raw device path and its provenance.
3908    #[must_use]
3909    pub const fn path(&self) -> Option<&ProjectValue<String>> {
3910        self.path.as_ref()
3911    }
3912    /// Returns the effective raw integer-or-string weight and its provenance.
3913    #[must_use]
3914    pub const fn weight(&self) -> Option<&ProjectValue<BlkioScalar>> {
3915        self.weight.as_ref()
3916    }
3917    /// Returns extensions, unknown fields, and malformed members retained as evidence.
3918    #[must_use]
3919    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
3920        &self.unmodeled_fields
3921    }
3922}
3923
3924/// Effective block-I/O device-weight entry shape retained without coercion.
3925#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3926#[non_exhaustive]
3927pub enum ProjectBlkioWeightDeviceForm {
3928    /// A mapping-form entry.
3929    Mapping,
3930    /// A non-mapping sequence entry retained as evidence.
3931    Unmodeled,
3932}
3933
3934/// One named top-level resource with key and definition provenance kept separately.
3935#[derive(Debug, Clone, PartialEq, Eq)]
3936pub struct ProjectResource<T> {
3937    name: ProjectKey,
3938    definition: ProjectValue<T>,
3939}
3940
3941impl<T> ProjectResource<T> {
3942    /// Returns the model name and all authored key locations.
3943    #[must_use]
3944    pub const fn name(&self) -> &ProjectKey {
3945        &self.name
3946    }
3947
3948    /// Returns the native effective definition and its merge provenance.
3949    #[must_use]
3950    pub const fn definition(&self) -> &ProjectValue<T> {
3951        &self.definition
3952    }
3953}
3954
3955/// The native consumer view of one merged and optionally profile-selected Compose project.
3956#[derive(Debug, Clone, PartialEq, Eq)]
3957pub struct ProjectView {
3958    source_ids: Vec<SourceId>,
3959    base_directory: PathBuf,
3960    provenance: MergeProvenance,
3961    name: Option<ProjectValue<String>>,
3962    version: Option<ProjectValue<String>>,
3963    include: Option<ProjectValue<Includes>>,
3964    models: Option<ProjectValue<ModelDefinitions>>,
3965    services: Vec<ProjectService>,
3966    networks: Vec<ProjectResource<NetworkDefinition>>,
3967    volumes: Vec<ProjectResource<VolumeDefinition>>,
3968    configs: Vec<ProjectResource<ConfigDefinition>>,
3969    secrets: Vec<ProjectResource<SecretDefinition>>,
3970    unmodeled_fields: Vec<ProjectFieldReference>,
3971}
3972
3973impl ProjectView {
3974    /// Returns source documents in merge order.
3975    #[must_use]
3976    pub fn source_ids(&self) -> &[SourceId] {
3977        &self.source_ids
3978    }
3979
3980    /// Returns the project directory inherited from the first loaded document.
3981    #[must_use]
3982    pub fn base_directory(&self) -> &Path {
3983        &self.base_directory
3984    }
3985
3986    /// Returns provenance for the complete merged root.
3987    #[must_use]
3988    pub const fn provenance(&self) -> &MergeProvenance {
3989        &self.provenance
3990    }
3991
3992    /// Returns the effective explicit project name.
3993    #[must_use]
3994    pub const fn name(&self) -> Option<&ProjectValue<String>> {
3995        self.name.as_ref()
3996    }
3997
3998    /// Returns the effective obsolete version spelling without selecting provider behavior.
3999    #[must_use]
4000    pub const fn version(&self) -> Option<&ProjectValue<String>> {
4001        self.version.as_ref()
4002    }
4003
4004    /// Returns simple effective include paths in merge order; no files are loaded.
4005    #[must_use]
4006    pub const fn include(&self) -> Option<&ProjectValue<Includes>> {
4007        self.include.as_ref()
4008    }
4009
4010    /// Returns effective model definitions without loading a provider or context.
4011    #[must_use]
4012    pub const fn models(&self) -> Option<&ProjectValue<ModelDefinitions>> {
4013        self.models.as_ref()
4014    }
4015
4016    /// Returns profile-active services in merged order.
4017    #[must_use]
4018    pub fn services(&self) -> &[ProjectService] {
4019        &self.services
4020    }
4021
4022    /// Finds one profile-active service.
4023    #[must_use]
4024    pub fn service(&self, name: &str) -> Option<&ProjectService> {
4025        self.services.iter().find(|service| service.name.value == name)
4026    }
4027
4028    /// Returns effective top-level network definitions.
4029    #[must_use]
4030    pub fn networks(&self) -> &[ProjectResource<NetworkDefinition>] {
4031        &self.networks
4032    }
4033
4034    /// Returns effective top-level volume definitions.
4035    #[must_use]
4036    pub fn volumes(&self) -> &[ProjectResource<VolumeDefinition>] {
4037        &self.volumes
4038    }
4039
4040    /// Returns effective top-level config definitions.
4041    #[must_use]
4042    pub fn configs(&self) -> &[ProjectResource<ConfigDefinition>] {
4043        &self.configs
4044    }
4045
4046    /// Returns effective top-level secret definitions.
4047    #[must_use]
4048    pub fn secrets(&self) -> &[ProjectResource<SecretDefinition>] {
4049        &self.secrets
4050    }
4051
4052    /// Returns root fields retained outside this initial native project-view boundary.
4053    #[must_use]
4054    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
4055        &self.unmodeled_fields
4056    }
4057}
4058
4059/// Recoverable result of building a typed merged project view.
4060#[derive(Debug, Clone, PartialEq, Eq)]
4061pub struct ProjectViewResult {
4062    view: Option<ProjectView>,
4063    diagnostics: Vec<Diagnostic>,
4064}
4065
4066impl ProjectViewResult {
4067    /// Returns the typed view when the profile selection belongs to the project.
4068    #[must_use]
4069    pub const fn view(&self) -> Option<&ProjectView> {
4070        self.view.as_ref()
4071    }
4072
4073    /// Returns project-view diagnostics in traversal order.
4074    #[must_use]
4075    pub fn diagnostics(&self) -> &[Diagnostic] {
4076        &self.diagnostics
4077    }
4078
4079    /// Reports whether a view exists and contains no error diagnostics.
4080    #[must_use]
4081    pub fn is_valid(&self) -> bool {
4082        self.view.is_some()
4083            && self
4084                .diagnostics
4085                .iter()
4086                .all(|diagnostic| diagnostic.severity() != Severity::Error)
4087    }
4088
4089    /// Separates the view and diagnostics.
4090    #[must_use]
4091    pub fn into_parts(self) -> (Option<ProjectView>, Vec<Diagnostic>) {
4092        (self.view, self.diagnostics)
4093    }
4094}
4095
4096/// Builds native values directly from a merged project without canonical rendering or reparsing.
4097///
4098/// A matching selection filters inactive services. Omitting it includes every service. The
4099/// operation performs no file, environment, provider, or runtime access.
4100#[must_use]
4101pub fn build_project_view(project: &MergedProject, selection: Option<&ProfileSelection>) -> ProjectViewResult {
4102    if selection.is_some_and(|selection| !selection.belongs_to(project)) {
4103        return ProjectViewResult {
4104            view: None,
4105            diagnostics: vec![Diagnostic::new(
4106                SELECTION_PROJECT_MISMATCH,
4107                Severity::Error,
4108                "profile selection does not belong to the merged project",
4109            )],
4110        };
4111    }
4112
4113    Builder::new(project, selection).build()
4114}
4115
4116struct Builder<'a> {
4117    project: &'a MergedProject,
4118    selection: Option<&'a ProfileSelection>,
4119    diagnostics: Vec<Diagnostic>,
4120    root_unmodeled: Vec<ProjectFieldReference>,
4121    pending_unmodeled: Vec<ProjectFieldReference>,
4122}
4123
4124impl<'a> Builder<'a> {
4125    const fn new(project: &'a MergedProject, selection: Option<&'a ProfileSelection>) -> Self {
4126        Self {
4127            project,
4128            selection,
4129            diagnostics: Vec::new(),
4130            root_unmodeled: Vec::new(),
4131            pending_unmodeled: Vec::new(),
4132        }
4133    }
4134
4135    fn build(mut self) -> ProjectViewResult {
4136        let root = self.project.root();
4137        let entries = root.as_mapping().unwrap_or_default();
4138        let mut name = None;
4139        let mut version = None;
4140        let mut include = None;
4141        let mut models = None;
4142        let mut services = Vec::new();
4143        let mut networks = Vec::new();
4144        let mut volumes = Vec::new();
4145        let mut configs = Vec::new();
4146        let mut secrets = Vec::new();
4147
4148        for entry in entries {
4149            match entry.key() {
4150                "name" => name = self.project_string(entry.value(), "project name"),
4151                "version" => version = self.project_string(entry.value(), "Compose version"),
4152                "include" => {
4153                    include = self.includes(entry);
4154                    if include.is_none() {
4155                        self.record_root_unmodeled(&[], entry);
4156                    }
4157                }
4158                "models" => {
4159                    models = self.model_definitions(entry.value());
4160                    if models.is_none() {
4161                        self.record_root_unmodeled(&[], entry);
4162                    }
4163                }
4164                "services" => services = self.services(entry.value()),
4165                "networks" => networks = self.network_definitions(entry.value()),
4166                "volumes" => volumes = self.volume_definitions(entry.value()),
4167                "configs" => configs = self.config_definitions(entry.value()),
4168                "secrets" => secrets = self.secret_definitions(entry.value()),
4169                _ => self.record_root_unmodeled(&[], entry),
4170            }
4171        }
4172
4173        ProjectViewResult {
4174            view: Some(ProjectView {
4175                source_ids: self.project.source_ids().to_vec(),
4176                base_directory: self.project.base_directory().to_path_buf(),
4177                provenance: root.provenance().clone(),
4178                name,
4179                version,
4180                include,
4181                models,
4182                services,
4183                networks,
4184                volumes,
4185                configs,
4186                secrets,
4187                unmodeled_fields: self.root_unmodeled,
4188            }),
4189            diagnostics: self.diagnostics,
4190        }
4191    }
4192
4193    fn services(&mut self, value: &MergedValue) -> Vec<ProjectService> {
4194        let Some(entries) = self.mapping(value, "services must be a mapping") else {
4195            return Vec::new();
4196        };
4197        let selection = self.selection;
4198        let mut services = Vec::new();
4199        for entry in entries {
4200            if service_in_scope(selection, entry.key()) {
4201                services.extend(self.service(entry));
4202            }
4203        }
4204        services
4205    }
4206
4207    fn service(&mut self, entry: &MergedEntry) -> Option<ProjectService> {
4208        let pending_start = self.pending_unmodeled.len();
4209        let fields = self.mapping(entry.value(), "service definition must be a mapping")?;
4210        let mut service = ProjectService::from_entry(entry);
4211        let path = ["services".to_owned(), entry.key().to_owned()];
4212        for field in fields {
4213            if self.set_extra_service_field(&mut service, field, &path) {
4214                continue;
4215            }
4216            match field.key() {
4217                "hostname" => service.hostname = self.hostname(field.value()),
4218                "container_name" => {
4219                    service.container_name = self.project_string(field.value(), "service container name");
4220                }
4221                "image" => {
4222                    service.image = self
4223                        .project_string(field.value(), "service image")
4224                        .map(|value| ProjectValue {
4225                            value: ImageReference::parse(value.value),
4226                            provenance: value.provenance,
4227                            sensitive: value.sensitive,
4228                        });
4229                }
4230                "platform" => self.set_platform(&mut service, field, &path),
4231                "entrypoint" => service.entrypoint = self.entrypoint(field.value()),
4232                "command" => service.command = self.command(field.value()),
4233                "credential_spec" => self.set_credential_spec(&mut service, field, &path),
4234                "extends" => self.set_extends(&mut service, field, &path),
4235                "provider" => self.set_provider(&mut service, field, &path),
4236                "post_start" => self.set_post_start(&mut service, field, &path),
4237                "pre_stop" => self.set_pre_stop(&mut service, field, &path),
4238                "pre_start" => self.set_pre_start(&mut service, field, &path),
4239                "blkio_config" => self.set_blkio_config(&mut service, field, &path),
4240                "cgroup" | "cgroup_parent" => self.set_cgroup(&mut service, field, &path),
4241                "attach" | "init" | "stdin_open" | "tty" | "privileged" => {
4242                    self.set_service_boolean(&mut service, field, &path);
4243                }
4244                "environment" => service.environment = self.environment(field.value()),
4245                "env_file" => service.environment_files = self.environment_files(field.value(), &path),
4246                "labels" => service.labels = self.service_labels(field.value()),
4247                "annotations" => service.annotations = self.service_annotations(field.value()),
4248                "extra_hosts" => service.extra_hosts = self.extra_hosts(field.value()),
4249                "user" => service.user = self.user(field.value()),
4250                "userns_mode" => service.userns_mode = self.userns_mode(field.value()),
4251                "group_add" => {
4252                    service.group_add = self.string_collection(field.value(), "group_add must be a sequence");
4253                }
4254                "cap_add" => service.cap_add = self.capability_add(field.value()),
4255                "cap_drop" => service.cap_drop = self.capability_drop(field.value()),
4256                "devices" => service.devices = self.devices(field.value(), &path),
4257                "dns" => service.dns = self.dns(field.value()),
4258                "dns_opt" => service.dns_options = self.dns_options(field.value()),
4259                "dns_search" => service.dns_search = self.dns_search(field.value()),
4260                "expose" => service.expose = self.expose(field.value()),
4261                "security_opt" => service.security_options = self.security_options(field.value()),
4262                "working_dir" => service.working_dir = self.project_string(field.value(), "service working directory"),
4263                "read_only" => service.read_only = self.read_only(field.value()),
4264                "shm_size" => service.shm_size = self.shm_size(field.value()),
4265                "mem_limit" => service.mem_limit = self.mem_limit(field.value()),
4266                "tmpfs" => service.tmpfs = self.tmpfs(field.value()),
4267                "sysctls" => service.sysctls = self.sysctls(field.value()),
4268                "logging" => {
4269                    service.logging = self.logging(field.value(), &path);
4270                    if service.logging.is_none() {
4271                        service.unmodeled_fields.push(field_reference(&path, field));
4272                    }
4273                }
4274                "ulimits" => {
4275                    let (ulimits, unmodeled) = self.ulimits(field.value(), &path);
4276                    service.ulimits = ulimits;
4277                    service.unmodeled_fields.extend(unmodeled);
4278                    if service.ulimits.is_none() {
4279                        service.unmodeled_fields.push(field_reference(&path, field));
4280                    }
4281                }
4282                "pull_policy" => service.pull_policy = self.pull_policy(field.value()),
4283                "pull_refresh_after" => self.set_pull_refresh_after(&mut service, field, &path),
4284                "restart" => service.restart = self.restart_policy(field.value()),
4285                "runtime" => self.set_runtime(&mut service, field, &path),
4286                "stop_signal" => service.stop_signal = self.project_string(field.value(), "service stop signal"),
4287                "stop_grace_period" => {
4288                    service.stop_grace_period = self.stop_grace_period(field.value());
4289                }
4290                "healthcheck" => service.healthcheck = self.healthcheck(field.value(), &path),
4291                "build" => service.build = self.service_build(field.value(), &path),
4292                "deploy" => self.set_service_deploy(&mut service, field, &path),
4293                "depends_on" => service.depends_on = self.depends_on(field.value(), &path),
4294                "ports" => service.ports = self.ports(field.value(), &path),
4295                "volumes" => self.set_volumes(&mut service, field, &path),
4296                "configs" => service.configs = self.grants(field.value(), &path, "config"),
4297                "secrets" => service.secrets = self.grants(field.value(), &path, "secret"),
4298                "networks" => service.networks = self.service_networks(field.value(), &path),
4299                "profiles" => service.profiles = self.string_collection(field.value(), "profiles must be a sequence"),
4300                _ => service.unmodeled_fields.push(field_reference(&path, field)),
4301            }
4302        }
4303        service
4304            .unmodeled_fields
4305            .extend(self.pending_unmodeled.drain(pending_start..));
4306        self.validate_service_cross_fields(&service);
4307        Some(service)
4308    }
4309
4310    fn set_extra_service_field(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) -> bool {
4311        self.set_service_runtime_field(service, field, path) || self.set_service_remaining_field(service, field, path)
4312    }
4313
4314    fn set_service_remaining_field(
4315        &mut self,
4316        service: &mut ProjectService,
4317        field: &MergedEntry,
4318        path: &[String],
4319    ) -> bool {
4320        match field.key() {
4321            "domainname" => {
4322                service.domainname =
4323                    self.strict_project_string(field.value(), "service domainname must be a YAML string scalar");
4324            }
4325            "isolation" => {
4326                service.isolation =
4327                    self.strict_project_string(field.value(), "service isolation must be a YAML string scalar");
4328            }
4329            "mac_address" => {
4330                service.mac_address =
4331                    self.strict_project_string(field.value(), "service mac_address must be a YAML string scalar");
4332            }
4333            "uts" => {
4334                service.uts = self.strict_project_string(field.value(), "service uts must be a YAML string scalar");
4335            }
4336            "gpus" => service.gpus = self.gpus(field.value(), path),
4337            "use_api_socket" => {
4338                service.use_api_socket = self.boolean(field.value(), "service use_api_socket must be a boolean");
4339            }
4340            "label_file" => {
4341                service.label_files = self.label_files(field.value(), path);
4342                if service.label_files.is_none() {
4343                    service.unmodeled_fields.push(field_reference(path, field));
4344                }
4345            }
4346            "external_links" => {
4347                service.external_links = self.string_collection(field.value(), "external_links must be a sequence");
4348            }
4349            "links" => service.links = self.string_collection(field.value(), "links must be a sequence"),
4350            "storage_opt" => {
4351                service.storage_opt = self
4352                    .labels(field.value())
4353                    .map(|value| ProjectValue::new(value, field.value()));
4354            }
4355            "models" => service.models = self.service_models(field, path),
4356            "develop" if field.value().as_mapping().is_some() => service.develop = self.develop(field.value(), path),
4357            "develop" => {
4358                self.expected(field.value(), "service develop must be a mapping");
4359                service.unmodeled_fields.push(field_reference(path, field));
4360            }
4361            _ => return false,
4362        }
4363        true
4364    }
4365
4366    fn includes(&mut self, entry: &MergedEntry) -> Option<ProjectValue<Includes>> {
4367        let value = entry.value();
4368        let sequence = value.as_sequence()?;
4369        let mut items = Vec::new();
4370        for item in sequence {
4371            match item.kind() {
4372                MergedValueKind::Scalar(_) => {
4373                    if let Some(path) = self.strict_located_string(item, "include item must be a YAML string scalar") {
4374                        items.push(IncludeItem::Short(path));
4375                    } else {
4376                        self.record_root_unmodeled(&[], entry);
4377                        items.push(IncludeItem::Unmodeled);
4378                    }
4379                }
4380                MergedValueKind::Mapping(fields) => {
4381                    let span = effective_span(item);
4382                    let mut paths = Vec::new();
4383                    let mut env_files = Vec::new();
4384                    let mut project_directory = None;
4385                    let mut unmodeled_fields = Vec::new();
4386                    for field in fields {
4387                        match field.key() {
4388                            "path" => {
4389                                paths = self.strict_string_or_sequence(
4390                                    field.value(),
4391                                    "include path must be a YAML string or sequence",
4392                                );
4393                                if !Self::is_strict_string_or_sequence(field.value()) {
4394                                    self.record_root_unmodeled(&["include".to_owned()], field);
4395                                    unmodeled_fields.push(FieldReference::generated(
4396                                        Located::new(field.key().to_owned(), entry_span(field)),
4397                                        entry_span(field),
4398                                        Some(effective_span(field.value())),
4399                                    ));
4400                                }
4401                            }
4402                            "env_file" => {
4403                                env_files = self.strict_string_or_sequence(
4404                                    field.value(),
4405                                    "include env_file must be a YAML string or sequence",
4406                                );
4407                                if !Self::is_strict_string_or_sequence(field.value()) {
4408                                    self.record_root_unmodeled(&["include".to_owned()], field);
4409                                    unmodeled_fields.push(FieldReference::generated(
4410                                        Located::new(field.key().to_owned(), entry_span(field)),
4411                                        entry_span(field),
4412                                        Some(effective_span(field.value())),
4413                                    ));
4414                                }
4415                            }
4416                            "project_directory" => {
4417                                project_directory = self.strict_located_string(
4418                                    field.value(),
4419                                    "include project_directory must be a YAML string",
4420                                );
4421                                if project_directory.is_none() {
4422                                    self.record_root_unmodeled(&["include".to_owned()], field);
4423                                    unmodeled_fields.push(FieldReference::generated(
4424                                        Located::new(field.key().to_owned(), entry_span(field)),
4425                                        entry_span(field),
4426                                        Some(effective_span(field.value())),
4427                                    ));
4428                                }
4429                            }
4430                            _ => {
4431                                self.record_root_unmodeled(&["include".to_owned()], field);
4432                                unmodeled_fields.push(FieldReference::generated(
4433                                    Located::new(field.key().to_owned(), entry_span(field)),
4434                                    entry_span(field),
4435                                    Some(effective_span(field.value())),
4436                                ));
4437                            }
4438                        }
4439                    }
4440                    if paths.is_empty() {
4441                        self.diagnostics.push(
4442                            Diagnostic::new(PROJECT_MISSING_FIELD, Severity::Error, "include mapping requires path")
4443                                .with_label(DiagnosticLabel::primary(span, "path is missing")),
4444                        );
4445                    }
4446                    items.push(IncludeItem::Long(IncludeLong::new(
4447                        span,
4448                        paths,
4449                        env_files,
4450                        project_directory,
4451                        unmodeled_fields,
4452                    )));
4453                }
4454                _ => {
4455                    self.expected(item, "include items must be strings or mappings");
4456                    self.record_root_unmodeled(&[], entry);
4457                    items.push(IncludeItem::Unmodeled);
4458                }
4459            }
4460        }
4461        Some(ProjectValue::new(
4462            Includes::new(effective_span(value), items, Vec::new()),
4463            value,
4464        ))
4465    }
4466
4467    fn model_definitions(&mut self, value: &MergedValue) -> Option<ProjectValue<ModelDefinitions>> {
4468        let entries = value.as_mapping()?;
4469        let mut definitions = Vec::new();
4470        for entry in entries {
4471            let span = effective_span(entry.value());
4472            let mut definition = ModelDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
4473            let Some(members) = entry.value().as_mapping() else {
4474                self.expected(entry.value(), "model definition must be a mapping");
4475                self.record_root_unmodeled(&["models".to_owned()], entry);
4476                continue;
4477            };
4478            for member in members {
4479                match member.key() {
4480                    "name" => {
4481                        let parsed = self.strict_located_string(member.value(), "model name must be a YAML string");
4482                        if let Some(value) = parsed {
4483                            definition.set_name(value);
4484                        } else {
4485                            definition.push_unmodeled(FieldReference::generated(
4486                                Located::new(member.key().to_owned(), entry_span(member)),
4487                                entry_span(member),
4488                                Some(effective_span(member.value())),
4489                            ));
4490                            self.record_root_unmodeled(&["models".to_owned(), entry.key().to_owned()], member);
4491                        }
4492                    }
4493                    "model" => {
4494                        let parsed =
4495                            self.strict_located_string(member.value(), "model reference must be a YAML string");
4496                        if let Some(value) = parsed {
4497                            definition.set_model(value);
4498                        } else {
4499                            definition.push_unmodeled(FieldReference::generated(
4500                                Located::new(member.key().to_owned(), entry_span(member)),
4501                                entry_span(member),
4502                                Some(effective_span(member.value())),
4503                            ));
4504                            self.record_root_unmodeled(&["models".to_owned(), entry.key().to_owned()], member);
4505                        }
4506                    }
4507                    "context_size" => {
4508                        if let Some(value) =
4509                            self.located_yaml_integer(member.value(), "model context_size must be a YAML integer")
4510                        {
4511                            definition.set_context_size(value);
4512                        } else {
4513                            self.record_root_unmodeled(&["models".to_owned(), entry.key().to_owned()], member);
4514                        }
4515                    }
4516                    "runtime_flags" => {
4517                        let flags = self.strict_string_or_sequence(
4518                            member.value(),
4519                            "model runtime_flags must be a string or sequence",
4520                        );
4521                        if !Self::is_strict_string_or_sequence(member.value()) {
4522                            definition.push_unmodeled(FieldReference::generated(
4523                                Located::new(member.key().to_owned(), entry_span(member)),
4524                                entry_span(member),
4525                                Some(effective_span(member.value())),
4526                            ));
4527                            self.record_root_unmodeled(&["models".to_owned(), entry.key().to_owned()], member);
4528                        }
4529                        definition.set_runtime_flags(flags);
4530                    }
4531                    _ => {
4532                        definition.push_unmodeled(FieldReference::generated(
4533                            Located::new(member.key().to_owned(), entry_span(member)),
4534                            entry_span(member),
4535                            Some(effective_span(member.value())),
4536                        ));
4537                        self.record_root_unmodeled(&["models".to_owned(), entry.key().to_owned()], member);
4538                    }
4539                }
4540            }
4541            if definition.model().is_none() {
4542                self.diagnostics.push(
4543                    Diagnostic::new(
4544                        PROJECT_MISSING_FIELD,
4545                        Severity::Error,
4546                        "model definition requires model",
4547                    )
4548                    .with_label(DiagnosticLabel::primary(span, "model is missing")),
4549                );
4550            }
4551            definitions.push(definition);
4552        }
4553        Some(ProjectValue::new(
4554            ModelDefinitions::new(effective_span(value), definitions, Vec::new()),
4555            value,
4556        ))
4557    }
4558
4559    fn service_models(&mut self, field: &MergedEntry, path: &[String]) -> Option<ProjectValue<ServiceModels>> {
4560        let value = field.value();
4561        let mut bindings = Vec::new();
4562        match value.kind() {
4563            MergedValueKind::Sequence(items) => {
4564                for item in items {
4565                    if let Some(model) = self.strict_located_string(item, "service model binding must be a YAML string")
4566                    {
4567                        bindings.push(ServiceModelBinding::new(model, effective_span(item)));
4568                    } else {
4569                        self.record_pending_unmodeled(path, field);
4570                    }
4571                }
4572            }
4573            MergedValueKind::Mapping(entries) => {
4574                for entry in entries {
4575                    let mut binding = ServiceModelBinding::new(
4576                        Located::new(entry.key().to_owned(), entry_span(entry)),
4577                        effective_span(entry.value()),
4578                    );
4579                    if let Some(members) = entry.value().as_mapping() {
4580                        for member in members {
4581                            match member.key() {
4582                                "endpoint_var" => {
4583                                    let parsed = self.strict_located_string(
4584                                        member.value(),
4585                                        "model endpoint_var must be a YAML string",
4586                                    );
4587                                    if let Some(value) = parsed {
4588                                        binding.set_endpoint_var(value);
4589                                    } else {
4590                                        binding.push_unmodeled(FieldReference::generated(
4591                                            Located::new(member.key().to_owned(), entry_span(member)),
4592                                            entry_span(member),
4593                                            Some(effective_span(member.value())),
4594                                        ));
4595                                    }
4596                                }
4597                                "model_var" => {
4598                                    let parsed = self
4599                                        .strict_located_string(member.value(), "model model_var must be a YAML string");
4600                                    if let Some(value) = parsed {
4601                                        binding.set_model_var(value);
4602                                    } else {
4603                                        binding.push_unmodeled(FieldReference::generated(
4604                                            Located::new(member.key().to_owned(), entry_span(member)),
4605                                            entry_span(member),
4606                                            Some(effective_span(member.value())),
4607                                        ));
4608                                    }
4609                                }
4610                                _ => binding.push_unmodeled(FieldReference::generated(
4611                                    Located::new(member.key().to_owned(), entry_span(member)),
4612                                    entry_span(member),
4613                                    Some(effective_span(member.value())),
4614                                )),
4615                            }
4616                        }
4617                    } else if !matches!(entry.value().kind(), MergedValueKind::Null(_)) {
4618                        self.expected(entry.value(), "service model binding must be null or a mapping");
4619                        binding.push_unmodeled(FieldReference::generated(
4620                            Located::new(entry.key().to_owned(), entry_span(entry)),
4621                            entry_span(entry),
4622                            Some(effective_span(entry.value())),
4623                        ));
4624                    }
4625                    if !binding.unmodeled_fields().is_empty() {
4626                        self.record_pending_unmodeled(path, field);
4627                    }
4628                    bindings.push(binding);
4629                }
4630            }
4631            _ => return None,
4632        }
4633        Some(ProjectValue::new(
4634            ServiceModels::new(effective_span(value), bindings, Vec::new()),
4635            value,
4636        ))
4637    }
4638
4639    #[expect(
4640        clippy::too_many_lines,
4641        reason = "GPU selectors retain every supported field and malformed source evidence"
4642    )]
4643    fn gpus(&mut self, value: &MergedValue, path: &[String]) -> Option<ProjectValue<Gpus>> {
4644        let mut gpu_path = path.to_vec();
4645        gpu_path.push("gpus".to_owned());
4646        let result = match value.kind() {
4647            MergedValueKind::Scalar(_) => self
4648                .located_string(value, "service gpus must be a string or sequence")
4649                .and_then(|selector| {
4650                    if selector.value() == "all" {
4651                        Some(Gpus::All(selector))
4652                    } else {
4653                        self.expected(value, "service gpus scalar must be exactly `all`");
4654                        None
4655                    }
4656                }),
4657            MergedValueKind::Sequence(items) => {
4658                let mut devices = Vec::new();
4659                for item in items {
4660                    let Some(members) = item.as_mapping() else {
4661                        self.expected(item, "GPU selector must be a mapping");
4662                        self.pending_unmodeled.push(value_reference(&gpu_path, item));
4663                        continue;
4664                    };
4665                    let mut device = GpuDevice::new(effective_span(item));
4666                    for member in members {
4667                        match member.key() {
4668                            "capabilities" => {
4669                                let capabilities = self.strict_string_sequence(
4670                                    member.value(),
4671                                    "GPU capabilities must be a YAML sequence of strings",
4672                                );
4673                                if !Self::is_strict_string_sequence(member.value()) {
4674                                    self.record_pending_unmodeled(&gpu_path, member);
4675                                }
4676                                device.set_capabilities(capabilities);
4677                            }
4678                            "count" => {
4679                                let parsed = self.located_integer_or_string_scalar(
4680                                    member.value(),
4681                                    "GPU count must be a YAML integer or string",
4682                                );
4683                                if let Some(value) = parsed {
4684                                    device.set_count(value);
4685                                } else {
4686                                    self.record_pending_unmodeled(&gpu_path, member);
4687                                }
4688                            }
4689                            "device_ids" => {
4690                                let ids = self.strict_string_or_sequence(
4691                                    member.value(),
4692                                    "GPU device_ids must be a string or sequence",
4693                                );
4694                                if !Self::is_strict_string_or_sequence(member.value()) {
4695                                    self.record_pending_unmodeled(&gpu_path, member);
4696                                }
4697                                device.set_device_ids(ids);
4698                            }
4699                            "driver" => {
4700                                let parsed =
4701                                    self.strict_located_string(member.value(), "GPU driver must be a YAML string");
4702                                if let Some(value) = parsed {
4703                                    device.set_driver(value);
4704                                } else {
4705                                    self.record_pending_unmodeled(&gpu_path, member);
4706                                }
4707                            }
4708                            "options" => match member.value().kind() {
4709                                MergedValueKind::Mapping(_) => self
4710                                    .key_value_mapping(member.value(), "GPU options must be a scalar mapping")
4711                                    .map(GpuOptions::Mapping)
4712                                    .into_iter()
4713                                    .for_each(|value| device.set_options(value)),
4714                                MergedValueKind::Sequence(_) => {
4715                                    let options = self.strict_string_or_sequence(
4716                                        member.value(),
4717                                        "GPU options must be a string sequence",
4718                                    );
4719                                    if !Self::is_strict_string_or_sequence(member.value()) {
4720                                        self.record_pending_unmodeled(&gpu_path, member);
4721                                    }
4722                                    device.set_options(GpuOptions::List(options));
4723                                }
4724                                _ => {
4725                                    self.expected(member.value(), "GPU options must be a mapping or sequence");
4726                                    self.record_pending_unmodeled(&gpu_path, member);
4727                                }
4728                            },
4729                            _ => self.record_pending_unmodeled(&gpu_path, member),
4730                        }
4731                    }
4732                    if device.count().is_some() && !device.device_ids().is_empty() {
4733                        self.diagnostics.push(
4734                            Diagnostic::new(
4735                                crate::model::GPU_COUNT_DEVICE_IDS_CONFLICT,
4736                                Severity::Warning,
4737                                "GPU selector cannot use count and device_ids together",
4738                            )
4739                            .with_label(DiagnosticLabel::primary(
4740                                device.span(),
4741                                "conflicting GPU allocation selectors",
4742                            )),
4743                        );
4744                    }
4745                    if !device.capabilities().iter().any(|value| !value.value().is_empty()) {
4746                        self.diagnostics.push(
4747                            Diagnostic::new(
4748                                GPU_MISSING_CAPABILITIES,
4749                                Severity::Error,
4750                                "GPU selector requires a non-empty capabilities declaration",
4751                            )
4752                            .with_label(DiagnosticLabel::primary(
4753                                device.span(),
4754                                "capabilities are missing, empty, or malformed",
4755                            )),
4756                        );
4757                    }
4758                    devices.push(device);
4759                }
4760                Some(Gpus::Devices {
4761                    span: effective_span(value),
4762                    devices,
4763                    unmodeled_items: Vec::new(),
4764                })
4765            }
4766            _ => None,
4767        }?;
4768        Some(ProjectValue::new(result, value))
4769    }
4770
4771    #[expect(
4772        clippy::too_many_lines,
4773        reason = "develop watch retains each documented member and nested source evidence explicitly"
4774    )]
4775    fn develop(&mut self, value: &MergedValue, path: &[String]) -> Option<ProjectValue<Develop>> {
4776        let entries = value.as_mapping()?;
4777        let mut develop_path = path.to_vec();
4778        develop_path.push("develop".to_owned());
4779        let mut watch = Vec::new();
4780        for entry in entries {
4781            if entry.key() != "watch" {
4782                self.record_pending_unmodeled(&develop_path, entry);
4783                continue;
4784            }
4785            let Some(items) = entry.value().as_sequence() else {
4786                self.expected(entry.value(), "develop watch must be a sequence");
4787                self.record_pending_unmodeled(&develop_path, entry);
4788                continue;
4789            };
4790            for item_value in items {
4791                let Some(members) = item_value.as_mapping() else {
4792                    self.expected(item_value, "develop watch item must be a mapping");
4793                    self.pending_unmodeled.push(value_reference(&develop_path, item_value));
4794                    continue;
4795                };
4796                let mut item = DevelopWatch::new(effective_span(item_value));
4797                let mut has_exec = false;
4798                for member in members {
4799                    match member.key() {
4800                        "action" => {
4801                            let parsed =
4802                                self.strict_located_string(member.value(), "develop action must be a YAML string");
4803                            if let Some(value) = parsed {
4804                                item.set_action(value);
4805                            } else {
4806                                self.record_pending_unmodeled(&develop_path, member);
4807                            }
4808                        }
4809                        "path" => {
4810                            let parsed =
4811                                self.strict_located_string(member.value(), "develop path must be a YAML string");
4812                            if let Some(value) = parsed {
4813                                item.set_path(value);
4814                            } else {
4815                                self.record_pending_unmodeled(&develop_path, member);
4816                            }
4817                        }
4818                        "target" => {
4819                            let parsed =
4820                                self.strict_located_string(member.value(), "develop target must be a YAML string");
4821                            if let Some(value) = parsed {
4822                                item.set_target(value);
4823                            } else {
4824                                self.record_pending_unmodeled(&develop_path, member);
4825                            }
4826                        }
4827                        "ignore" => {
4828                            let values = self.strict_string_or_sequence(
4829                                member.value(),
4830                                "develop ignore must be a string or sequence",
4831                            );
4832                            if !Self::is_strict_string_or_sequence(member.value()) {
4833                                self.record_pending_unmodeled(&develop_path, member);
4834                            }
4835                            item.set_ignore(values);
4836                        }
4837                        "include" => {
4838                            let values = self.strict_string_or_sequence(
4839                                member.value(),
4840                                "develop include must be a string or sequence",
4841                            );
4842                            if !Self::is_strict_string_or_sequence(member.value()) {
4843                                self.record_pending_unmodeled(&develop_path, member);
4844                            }
4845                            item.set_include(values);
4846                        }
4847                        "initial_sync" => {
4848                            let parsed = self.located_boolean(member.value(), "develop initial_sync must be a boolean");
4849                            if let Some(value) = parsed {
4850                                item.set_initial_sync(value);
4851                            } else {
4852                                self.record_pending_unmodeled(&develop_path, member);
4853                            }
4854                        }
4855                        "exec" if matches!(member.value().kind(), MergedValueKind::Mapping(_)) => {
4856                            let exec = self.develop_exec(member.value(), &develop_path);
4857                            has_exec = exec.command().is_some_and(project_command_is_non_empty);
4858                            if !has_exec {
4859                                self.diagnostics.push(
4860                                    Diagnostic::new(
4861                                        DEVELOP_WATCH_EXEC_MISSING_COMMAND,
4862                                        Severity::Error,
4863                                        "develop watch exec mapping requires a non-empty command member",
4864                                    )
4865                                    .with_label(DiagnosticLabel::primary(
4866                                        entry_span(member),
4867                                        "exec command is missing",
4868                                    )),
4869                                );
4870                            }
4871                            item.set_exec(exec);
4872                        }
4873                        "exec" => {
4874                            self.expected(member.value(), "develop exec must be a mapping");
4875                            self.record_pending_unmodeled(&develop_path, member);
4876                        }
4877                        _ => {
4878                            item.push_unmodeled(FieldReference::generated(
4879                                Located::new(member.key().to_owned(), entry_span(member)),
4880                                entry_span(member),
4881                                Some(effective_span(member.value())),
4882                            ));
4883                            self.record_pending_unmodeled(&develop_path, member);
4884                        }
4885                    }
4886                }
4887                self.validate_develop_watch(&item, has_exec);
4888                watch.push(item);
4889            }
4890        }
4891        if watch.is_empty() {
4892            self.diagnostics.push(
4893                Diagnostic::new(
4894                    crate::model::DEVELOP_MISSING_WATCH,
4895                    Severity::Error,
4896                    "develop requires a watch sequence",
4897                )
4898                .with_label(DiagnosticLabel::primary(effective_span(value), "watch is missing")),
4899            );
4900        }
4901        Some(ProjectValue::new(
4902            Develop::new(effective_span(value), watch, Vec::new(), Vec::new()),
4903            value,
4904        ))
4905    }
4906
4907    fn develop_exec(&mut self, value: &MergedValue, path: &[String]) -> DevelopWatchExec {
4908        let mut exec = DevelopWatchExec::new(effective_span(value));
4909        let Some(entries) = value.as_mapping() else {
4910            return exec;
4911        };
4912        for entry in entries {
4913            match entry.key() {
4914                "command" => match self.command(entry.value()) {
4915                    Some(value) => exec.set_command(value.into_value()),
4916                    None => self.record_pending_unmodeled(path, entry),
4917                },
4918                "user" => match self.strict_located_string(entry.value(), "develop exec user must be a YAML string") {
4919                    Some(value) => exec.set_user(value),
4920                    None => self.record_pending_unmodeled(path, entry),
4921                },
4922                "privileged" => {
4923                    match self.located_boolean(entry.value(), "develop exec privileged must be a boolean") {
4924                        Some(value) => exec.set_privileged(value),
4925                        None => self.record_pending_unmodeled(path, entry),
4926                    }
4927                }
4928                "working_dir" => {
4929                    match self.strict_located_string(entry.value(), "develop exec working_dir must be a YAML string") {
4930                        Some(value) => exec.set_working_dir(value),
4931                        None => self.record_pending_unmodeled(path, entry),
4932                    }
4933                }
4934                "environment" => match self.develop_exec_environment(entry.value()) {
4935                    Some(value) => exec.set_environment(value),
4936                    None => self.record_pending_unmodeled(path, entry),
4937                },
4938                _ => exec.push_unmodeled(FieldReference::generated(
4939                    Located::new(entry.key().to_owned(), entry_span(entry)),
4940                    entry_span(entry),
4941                    Some(effective_span(entry.value())),
4942                )),
4943            }
4944        }
4945        exec
4946    }
4947
4948    fn develop_exec_environment(&mut self, value: &MergedValue) -> Option<Environment> {
4949        let span = effective_span(value);
4950        match value.kind() {
4951            MergedValueKind::Sequence(items) => {
4952                let mut entries = Vec::new();
4953                for item in items {
4954                    entries.push(EnvironmentListEntry::parse(
4955                        self.located_string(item, "develop exec environment list item must be a scalar")?,
4956                    ));
4957                }
4958                Some(Environment::List { span, entries })
4959            }
4960            MergedValueKind::Mapping(items) => {
4961                let mut entries = Vec::new();
4962                for item in items {
4963                    let scalar = self.compose_scalar(
4964                        item.value(),
4965                        "develop exec environment mapping value must be a scalar or null",
4966                    )?;
4967                    entries.push(EnvironmentMapEntry::new(
4968                        Located::new(item.key().to_owned(), entry_span(item)),
4969                        Located::new(scalar, effective_span(item.value())),
4970                        entry_span(item),
4971                    ));
4972                }
4973                Some(Environment::Map { span, entries })
4974            }
4975            _ => {
4976                self.expected(value, "develop exec environment must be a sequence or mapping");
4977                None
4978            }
4979        }
4980    }
4981
4982    fn validate_develop_watch(&mut self, item: &DevelopWatch, has_exec: bool) {
4983        if item.action().is_none() {
4984            self.diagnostics.push(
4985                Diagnostic::new(
4986                    DEVELOP_WATCH_MISSING_ACTION,
4987                    Severity::Error,
4988                    "develop watch item requires an action",
4989                )
4990                .with_label(DiagnosticLabel::primary(item.span(), "action is missing")),
4991            );
4992        }
4993        if item.path().is_none() {
4994            self.diagnostics.push(
4995                Diagnostic::new(
4996                    DEVELOP_WATCH_MISSING_PATH,
4997                    Severity::Error,
4998                    "develop watch item requires a path",
4999                )
5000                .with_label(DiagnosticLabel::primary(item.span(), "path is missing")),
5001            );
5002        }
5003        let Some(action) = item.action() else {
5004            return;
5005        };
5006        let action_value = action.value();
5007        let synchronizes = matches!(action_value.as_str(), "sync" | "sync+restart" | "sync+exec");
5008        if !matches!(
5009            action_value.as_str(),
5010            "rebuild" | "sync" | "restart" | "sync+restart" | "sync+exec"
5011        ) {
5012            self.diagnostics.push(
5013                Diagnostic::new(
5014                    DEVELOP_WATCH_INVALID_ACTION,
5015                    Severity::Error,
5016                    "develop watch action is not one of rebuild, sync, restart, sync+restart, or sync+exec",
5017                )
5018                .with_label(DiagnosticLabel::primary(action.span(), "unknown watch action")),
5019            );
5020        }
5021        if synchronizes && item.target().is_none() {
5022            self.diagnostics.push(
5023                Diagnostic::new(
5024                    DEVELOP_WATCH_MISSING_TARGET,
5025                    Severity::Error,
5026                    "synchronizing develop watch action requires a target",
5027                )
5028                .with_label(DiagnosticLabel::primary(item.span(), "target is missing")),
5029            );
5030        }
5031        if action_value == "sync+exec" && !has_exec {
5032            self.diagnostics.push(
5033                Diagnostic::new(
5034                    DEVELOP_WATCH_MISSING_EXEC,
5035                    Severity::Error,
5036                    "sync+exec develop watch action requires an exec command mapping",
5037                )
5038                .with_label(DiagnosticLabel::primary(item.span(), "exec is missing")),
5039            );
5040        }
5041    }
5042
5043    fn strict_string_or_sequence(&mut self, value: &MergedValue, message: &str) -> Vec<Located<String>> {
5044        match value.kind() {
5045            MergedValueKind::Scalar(_) => self.strict_located_string(value, message).into_iter().collect(),
5046            MergedValueKind::Sequence(items) => items
5047                .iter()
5048                .filter_map(|item| self.strict_located_string(item, message))
5049                .collect(),
5050            _ => {
5051                self.expected(value, message);
5052                Vec::new()
5053            }
5054        }
5055    }
5056
5057    fn strict_string_sequence(&mut self, value: &MergedValue, message: &str) -> Vec<Located<String>> {
5058        let MergedValueKind::Sequence(items) = value.kind() else {
5059            self.expected(value, message);
5060            return Vec::new();
5061        };
5062        items
5063            .iter()
5064            .filter_map(|item| self.strict_located_string(item, message))
5065            .collect()
5066    }
5067
5068    fn is_strict_string_or_sequence(value: &MergedValue) -> bool {
5069        match value.kind() {
5070            MergedValueKind::Scalar(scalar) => {
5071                scalar.kind() == MergedScalarKind::String && scalar.is_strict_yaml_string()
5072            }
5073            MergedValueKind::Sequence(items) => items.iter().all(|item| {
5074                matches!(
5075                    item.kind(),
5076                    MergedValueKind::Scalar(scalar)
5077                        if scalar.kind() == MergedScalarKind::String && scalar.is_strict_yaml_string()
5078                )
5079            }),
5080            _ => false,
5081        }
5082    }
5083
5084    fn is_strict_string_sequence(value: &MergedValue) -> bool {
5085        matches!(value.kind(), MergedValueKind::Sequence(items) if items.iter().all(|item| {
5086            matches!(
5087                item.kind(),
5088                MergedValueKind::Scalar(scalar)
5089                    if scalar.kind() == MergedScalarKind::String && scalar.is_strict_yaml_string()
5090            )
5091        }))
5092    }
5093
5094    fn label_files(
5095        &mut self,
5096        value: &MergedValue,
5097        service_path: &[String],
5098    ) -> Option<ProjectValue<ProjectServiceLabelFiles>> {
5099        let form = match value.kind() {
5100            MergedValueKind::Scalar(_) => {
5101                let path =
5102                    self.strict_project_string(value, "service label_file must be a YAML string scalar or sequence")?;
5103                ProjectLabelFiles::Scalar(path)
5104            }
5105            MergedValueKind::Sequence(items) => {
5106                let mut paths = Vec::new();
5107                let mut unmodeled_items = Vec::new();
5108                for (index, item) in items.iter().enumerate() {
5109                    let Some(path) =
5110                        self.strict_project_string(item, "service label_file entries must be YAML string scalars")
5111                    else {
5112                        let mut item_path = service_path.to_vec();
5113                        item_path.push("label_file".to_owned());
5114                        item_path.push(index.to_string());
5115                        unmodeled_items.push(ProjectLabelFileEvidence::new(value_reference(&item_path, item)));
5116                        continue;
5117                    };
5118                    paths.push(path);
5119                }
5120                return Some(ProjectValue::new(
5121                    ProjectServiceLabelFiles::new(ProjectLabelFiles::List(paths), unmodeled_items),
5122                    value,
5123                ));
5124            }
5125            _ => {
5126                self.expected(value, "service label_file must be a YAML string scalar or sequence");
5127                return None;
5128            }
5129        };
5130        Some(ProjectValue::new(
5131            ProjectServiceLabelFiles::new(form, Vec::new()),
5132            value,
5133        ))
5134    }
5135
5136    fn located_integer_or_string_scalar(
5137        &mut self,
5138        value: &MergedValue,
5139        message: &str,
5140    ) -> Option<Located<ComposeScalar>> {
5141        let scalar = value.as_scalar()?;
5142        let parsed = match scalar.kind() {
5143            MergedScalarKind::Number if CpuCount::yaml_integer_spelling(scalar.raw()) => {
5144                ComposeScalar::Number(scalar.value().to_owned())
5145            }
5146            MergedScalarKind::String if scalar.is_strict_yaml_string() => {
5147                ComposeScalar::String(scalar.value().to_owned())
5148            }
5149            _ => {
5150                self.expected(value, message);
5151                return None;
5152            }
5153        };
5154        Some(Located::new(parsed, effective_span(value)))
5155    }
5156
5157    fn located_yaml_integer(&mut self, value: &MergedValue, message: &str) -> Option<Located<ComposeScalar>> {
5158        let scalar = value.as_scalar()?;
5159        if scalar.kind() != MergedScalarKind::Number || !CpuCount::yaml_integer_spelling(scalar.raw()) {
5160            self.expected(value, message);
5161            return None;
5162        }
5163        Some(Located::new(
5164            ComposeScalar::Number(scalar.value().to_owned()),
5165            effective_span(value),
5166        ))
5167    }
5168
5169    fn set_service_runtime_field(
5170        &mut self,
5171        service: &mut ProjectService,
5172        field: &MergedEntry,
5173        path: &[String],
5174    ) -> bool {
5175        match field.key() {
5176            "cpu_count" | "cpu_percent" | "cpu_period" | "cpu_quota" | "cpu_rt_period" | "cpu_rt_runtime"
5177            | "cpu_shares" | "cpus" | "mem_swappiness" | "oom_score_adj" | "scale" | "pids_limit" => {
5178                self.set_service_count(service, field, path);
5179            }
5180            "cpuset" => {
5181                service.cpuset = self.project_string(field.value(), "service cpuset must be a YAML string scalar");
5182                if service.cpuset.is_none() {
5183                    service.unmodeled_fields.push(field_reference(path, field));
5184                }
5185            }
5186            "device_cgroup_rules" => {
5187                if field.value().as_sequence().is_none() {
5188                    service.unmodeled_fields.push(field_reference(path, field));
5189                }
5190                let (items, invalid) = self.strict_string_collection(field.value(), "device_cgroup_rules");
5191                service.device_cgroup_rules = items;
5192                service.invalid_device_cgroup_rules = invalid;
5193            }
5194            "ipc" => {
5195                service.ipc = self
5196                    .project_string(field.value(), "service ipc must be a YAML string scalar")
5197                    .map(|raw| ProjectValue {
5198                        value: IpcMode::parse(raw.value),
5199                        provenance: raw.provenance,
5200                        sensitive: raw.sensitive,
5201                    });
5202                if service.ipc.is_none() {
5203                    service.unmodeled_fields.push(field_reference(path, field));
5204                }
5205            }
5206            "mem_reservation" => {
5207                service.mem_reservation = self.mem_limit(field.value());
5208                if service.mem_reservation.is_none() {
5209                    service.unmodeled_fields.push(field_reference(path, field));
5210                }
5211            }
5212            "memswap_limit" => {
5213                service.memswap_limit = self.memswap_limit(field.value());
5214                if service.memswap_limit.is_none() {
5215                    service.unmodeled_fields.push(field_reference(path, field));
5216                }
5217            }
5218            "network_mode" => {
5219                service.network_mode = self
5220                    .project_string(field.value(), "service network_mode must be a YAML string scalar")
5221                    .map(|raw| ProjectValue {
5222                        value: NetworkMode::parse(raw.value),
5223                        provenance: raw.provenance,
5224                        sensitive: raw.sensitive,
5225                    });
5226                if service.network_mode.is_none() {
5227                    service.unmodeled_fields.push(field_reference(path, field));
5228                }
5229            }
5230            "oom_kill_disable" => {
5231                service.oom_kill_disable = self.boolean(field.value(), "service oom_kill_disable must be a boolean");
5232                if service.oom_kill_disable.is_none() {
5233                    service.unmodeled_fields.push(field_reference(path, field));
5234                }
5235            }
5236            "pid" => {
5237                service.pid = self
5238                    .project_string(field.value(), "service pid must be a YAML string scalar")
5239                    .map(|raw| ProjectValue {
5240                        value: PidMode::parse(raw.value),
5241                        provenance: raw.provenance,
5242                        sensitive: raw.sensitive,
5243                    });
5244                if service.pid.is_none() {
5245                    service.unmodeled_fields.push(field_reference(path, field));
5246                }
5247            }
5248            "volumes_from" => {
5249                if field.value().as_sequence().is_none() {
5250                    service.unmodeled_fields.push(field_reference(path, field));
5251                }
5252                let (items, invalid) = self.volumes_from(field.value());
5253                service.volumes_from = items;
5254                service.invalid_volumes_from = invalid;
5255            }
5256            _ => return false,
5257        }
5258        true
5259    }
5260
5261    fn validate_service_cross_fields(&mut self, service: &ProjectService) {
5262        if let (Some(network_mode), Some(networks)) = (service.network_mode.as_ref(), service.networks.as_ref()) {
5263            self.diagnostics.push(
5264                Diagnostic::new(
5265                    SERVICE_NETWORK_MODE_NETWORKS_CONFLICT,
5266                    Severity::Error,
5267                    "network_mode and networks are mutually exclusive service settings",
5268                )
5269                .with_label(DiagnosticLabel::primary(
5270                    project_value_span(network_mode),
5271                    "network_mode retained",
5272                ))
5273                .with_label(DiagnosticLabel::secondary(
5274                    project_value_span(networks),
5275                    "networks retained",
5276                )),
5277            );
5278        }
5279        let deploy = service.deploy.as_ref().map(ProjectValue::value);
5280        self.validate_service_memory_cross_fields(service, deploy);
5281        self.validate_service_scale_cross_fields(service, deploy);
5282        self.validate_service_cpu_cross_fields(service, deploy);
5283        if let (Some(gpus), Some(devices)) = (
5284            service.gpus.as_ref(),
5285            deploy
5286                .and_then(ProjectDeploy::resources)
5287                .and_then(|value| value.value().reservations())
5288                .and_then(|value| value.value().devices()),
5289        ) {
5290            self.diagnostics.push(
5291                Diagnostic::new(
5292                    SERVICE_GPUS_DEPLOY_DEVICES_CONFLICT,
5293                    Severity::Warning,
5294                    "service gpus and deploy reservation devices are both retained for caller reconciliation",
5295                )
5296                .with_label(DiagnosticLabel::primary(
5297                    project_value_span(gpus),
5298                    "service gpus retained",
5299                ))
5300                .with_label(DiagnosticLabel::secondary(
5301                    project_value_span(devices),
5302                    "deploy reservation devices retained",
5303                )),
5304            );
5305        }
5306    }
5307
5308    fn validate_service_memory_cross_fields(&mut self, service: &ProjectService, deploy: Option<&ProjectDeploy>) {
5309        if let (Some(reservation), Some(deploy_reservation)) = (
5310            service.mem_reservation.as_ref(),
5311            deploy
5312                .and_then(ProjectDeploy::resources)
5313                .and_then(|value| value.value().reservations())
5314                .and_then(|value| value.value().memory()),
5315        ) {
5316            if same_memory_value(reservation.value(), deploy_reservation.value()) == Some(false) {
5317                self.diagnostics.push(
5318                Diagnostic::new(
5319                    SERVICE_MEMORY_RESERVATION_DEPLOY_CONFLICT,
5320                    Severity::Warning,
5321                    "service mem_reservation and deploy reservation memory are both retained for caller reconciliation",
5322                )
5323                .with_label(DiagnosticLabel::primary(project_value_span(reservation), "service mem_reservation retained"))
5324                .with_label(DiagnosticLabel::secondary(project_value_span(deploy_reservation), "deploy reservation memory retained")),
5325            );
5326            }
5327        }
5328        if let Some(memswap) = service
5329            .memswap_limit
5330            .as_ref()
5331            .filter(|value| service.mem_limit.is_none() && value.value().is_positive())
5332        {
5333            self.diagnostics.push(
5334                Diagnostic::new(
5335                    SERVICE_MEMSWAP_REQUIRES_MEMORY_LIMIT,
5336                    Severity::Error,
5337                    "memswap_limit requires an explicit service mem_limit; no host default is inferred",
5338                )
5339                .with_label(DiagnosticLabel::primary(
5340                    project_value_span(memswap),
5341                    "memory-plus-swap value retained without memory limit",
5342                )),
5343            );
5344        }
5345        if let (Some(memswap), Some(memory)) = (service.memswap_limit.as_ref(), service.mem_limit.as_ref()) {
5346            if memswap.value().is_positive()
5347                && memory_compare_same_unit(memswap.value().raw().value(), memory.value().raw().value())
5348                    == Some(std::cmp::Ordering::Less)
5349            {
5350                self.diagnostics.push(
5351                    Diagnostic::new(
5352                        SERVICE_MEMSWAP_BELOW_MEMORY_LIMIT,
5353                        Severity::Error,
5354                        "positive memswap_limit is below the comparable service mem_limit",
5355                    )
5356                    .with_label(DiagnosticLabel::primary(
5357                        project_value_span(memswap),
5358                        "memory-plus-swap limit retained",
5359                    ))
5360                    .with_label(DiagnosticLabel::secondary(
5361                        project_value_span(memory),
5362                        "service memory limit retained",
5363                    )),
5364                );
5365            }
5366        }
5367    }
5368
5369    fn validate_service_scale_cross_fields(&mut self, service: &ProjectService, deploy: Option<&ProjectDeploy>) {
5370        if let (Some(scale), Some(replicas)) = (service.scale.as_ref(), deploy.and_then(ProjectDeploy::replicas)) {
5371            let scale_raw = match scale.value() {
5372                ServiceInteger::Valid(raw) | ServiceInteger::OutOfRange(raw) | ServiceInteger::Other(raw) => raw,
5373            };
5374            let replica_raw = match replicas.value() {
5375                DeployReplicas::YamlNumber(raw) | DeployReplicas::String(raw) => raw,
5376            };
5377            if let (Some(scale_number), Some(replica_number)) =
5378                (normalize_integer(scale_raw), normalize_integer(replica_raw))
5379            {
5380                if scale_number != replica_number {
5381                    self.diagnostics.push(
5382                        Diagnostic::new(
5383                            SERVICE_SCALE_DEPLOY_REPLICAS_CONFLICT,
5384                            Severity::Warning,
5385                            "scale and deploy.replicas have different proven integer values",
5386                        )
5387                        .with_label(DiagnosticLabel::primary(
5388                            project_value_span(scale),
5389                            "service scale retained",
5390                        ))
5391                        .with_label(DiagnosticLabel::secondary(
5392                            project_value_span(replicas),
5393                            "deploy replicas retained",
5394                        )),
5395                    );
5396                }
5397            }
5398        }
5399    }
5400
5401    fn validate_service_cpu_cross_fields(&mut self, service: &ProjectService, deploy: Option<&ProjectDeploy>) {
5402        if let (Some(cpus), Some(limit)) = (
5403            service.cpus.as_ref(),
5404            deploy
5405                .and_then(ProjectDeploy::resources)
5406                .and_then(|value| value.value().limits())
5407                .and_then(|value| value.value().cpus()),
5408        ) {
5409            let service_raw = match cpus.value() {
5410                Cpus::Decimal(raw) | Cpus::Expression(raw) | Cpus::Other(raw) => raw,
5411            };
5412            let limit_raw = match limit.value() {
5413                DeployResourceCpus::YamlNumber(raw) | DeployResourceCpus::String(raw) => raw,
5414            };
5415            if let (Some(service_number), Some(limit_number)) =
5416                (normalize_decimal(service_raw), normalize_decimal(limit_raw))
5417            {
5418                if service_number != limit_number {
5419                    self.diagnostics.push(
5420                        Diagnostic::new(
5421                            SERVICE_CPUS_DEPLOY_LIMIT_CONFLICT,
5422                            Severity::Warning,
5423                            "cpus and deploy.resources.limits.cpus have different proven decimal values",
5424                        )
5425                        .with_label(DiagnosticLabel::primary(
5426                            project_value_span(cpus),
5427                            "service cpus retained",
5428                        ))
5429                        .with_label(DiagnosticLabel::secondary(
5430                            project_value_span(limit),
5431                            "deploy CPU limit retained",
5432                        )),
5433                    );
5434                }
5435            }
5436        }
5437    }
5438
5439    fn boolean(&mut self, field: &MergedValue, message: &str) -> Option<ProjectValue<BooleanValue>> {
5440        self.located_boolean(field, message)
5441            .map(|value| ProjectValue::new(value.into_value(), field))
5442    }
5443
5444    fn read_only(&mut self, field: &MergedValue) -> Option<ProjectValue<BooleanValue>> {
5445        self.located_boolean(field, "service read_only must be a boolean")
5446            .map(|value| ProjectValue::new(value.into_value(), field))
5447    }
5448
5449    fn set_service_deploy(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5450        service.deploy = self.service_deploy(field, path);
5451        if service.deploy.is_none() {
5452            service.unmodeled_fields.push(field_reference(path, field));
5453        }
5454    }
5455
5456    fn set_volumes(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5457        service.volume_views.volumes = self.volumes(field.value(), path);
5458        service.volume_views.options = Self::project_volume_mount_options_collection(field.value(), path);
5459    }
5460
5461    fn set_credential_spec(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5462        service.credential_spec = self.credential_spec(field.value(), path);
5463        if service.credential_spec.is_none() {
5464            service.unmodeled_fields.push(field_reference(path, field));
5465        }
5466    }
5467
5468    fn set_extends(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5469        service.extends = self.extends(field.value(), path);
5470        if service.extends.is_none() {
5471            service.unmodeled_fields.push(field_reference(path, field));
5472        }
5473    }
5474
5475    fn set_provider(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5476        service.provider = self.provider(field.value(), path);
5477        if service.provider.is_none() {
5478            service.unmodeled_fields.push(field_reference(path, field));
5479        }
5480    }
5481
5482    fn set_post_start(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5483        service.post_start = self.post_start(field.value(), path);
5484        if service.post_start.is_none() {
5485            service.unmodeled_fields.push(field_reference(path, field));
5486        }
5487    }
5488
5489    fn set_pre_stop(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5490        service.pre_stop = self.pre_stop(field.value(), path);
5491        if service.pre_stop.is_none() {
5492            service.unmodeled_fields.push(field_reference(path, field));
5493        }
5494    }
5495
5496    fn set_pre_start(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5497        service.pre_start = self.pre_start(field.value(), path);
5498        if service.pre_start.is_none() {
5499            service.unmodeled_fields.push(field_reference(path, field));
5500        }
5501    }
5502
5503    fn set_runtime(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5504        service.runtime = self.strict_project_string(field.value(), "service runtime must be a YAML string scalar");
5505        if service.runtime.is_none() {
5506            service.unmodeled_fields.push(field_reference(path, field));
5507        }
5508    }
5509
5510    fn set_pull_refresh_after(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5511        service.pull_refresh_after =
5512            self.strict_project_string(field.value(), "service pull_refresh_after must be a YAML string scalar");
5513        if service.pull_refresh_after.is_none() {
5514            service.unmodeled_fields.push(field_reference(path, field));
5515        }
5516    }
5517
5518    fn set_platform(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5519        service.platform = self.strict_project_string(field.value(), "service platform must be a YAML string scalar");
5520        if service.platform.is_none() {
5521            service.unmodeled_fields.push(field_reference(path, field));
5522        }
5523    }
5524
5525    fn set_attach(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5526        service.attach = self.boolean(field.value(), "service attach must be a boolean");
5527        if service.attach.is_none() {
5528            service.unmodeled_fields.push(field_reference(path, field));
5529        }
5530    }
5531
5532    fn set_service_boolean(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5533        match field.key() {
5534            "attach" => self.set_attach(service, field, path),
5535            "init" => service.init = self.boolean(field.value(), "service init must be a boolean"),
5536            "stdin_open" => {
5537                service.stdin_open = self.boolean(field.value(), "service stdin_open must be a boolean");
5538            }
5539            "tty" => service.tty = self.boolean(field.value(), "service tty must be a boolean"),
5540            "privileged" => {
5541                service.privileged = self.boolean(field.value(), "service privileged must be a boolean");
5542            }
5543            "use_api_socket" => {
5544                service.use_api_socket = self.boolean(field.value(), "service use_api_socket must be a boolean");
5545            }
5546            _ => {}
5547        }
5548    }
5549
5550    fn set_service_count(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5551        match field.key() {
5552            "cpu_count" => {
5553                service.cpu_count = self.cpu_count(field.value());
5554                if service.cpu_count.is_none() {
5555                    service.unmodeled_fields.push(field_reference(path, field));
5556                }
5557            }
5558            "cpu_percent" => {
5559                service.cpu_percent = self.cpu_percent(field.value());
5560                if service.cpu_percent.is_none() {
5561                    service.unmodeled_fields.push(field_reference(path, field));
5562                }
5563            }
5564            "cpu_period" => {
5565                service.cpu_period = self.cpu_period(field.value());
5566                if service.cpu_period.is_none() {
5567                    service.unmodeled_fields.push(field_reference(path, field));
5568                }
5569            }
5570            "cpu_quota" => {
5571                service.cpu_quota = self.cpu_quota(field.value());
5572                if service.cpu_quota.is_none() {
5573                    service.unmodeled_fields.push(field_reference(path, field));
5574                }
5575            }
5576            "cpu_rt_period" => {
5577                service.cpu_rt_period = self.cpu_rt_period(field.value());
5578                if service.cpu_rt_period.is_none() {
5579                    service.unmodeled_fields.push(field_reference(path, field));
5580                }
5581            }
5582            "cpu_rt_runtime" => {
5583                service.cpu_rt_runtime = self.cpu_rt_runtime(field.value());
5584                if service.cpu_rt_runtime.is_none() {
5585                    service.unmodeled_fields.push(field_reference(path, field));
5586                }
5587            }
5588            "cpu_shares" => {
5589                service.cpu_shares = self.service_integer(field.value(), 0, i128::MAX, "cpu_shares");
5590                if service.cpu_shares.is_none() {
5591                    service.unmodeled_fields.push(field_reference(path, field));
5592                }
5593            }
5594            "cpus" => {
5595                service.cpus = self.cpus(field.value());
5596                if service.cpus.is_none() {
5597                    service.unmodeled_fields.push(field_reference(path, field));
5598                }
5599            }
5600            "mem_swappiness" => {
5601                service.mem_swappiness = self.service_integer(field.value(), 0, 100, "mem_swappiness");
5602                if service.mem_swappiness.is_none() {
5603                    service.unmodeled_fields.push(field_reference(path, field));
5604                }
5605            }
5606            "oom_score_adj" => {
5607                service.oom_score_adj = self.service_integer(field.value(), -1000, 1000, "oom_score_adj");
5608                if service.oom_score_adj.is_none() {
5609                    service.unmodeled_fields.push(field_reference(path, field));
5610                }
5611            }
5612            "scale" => {
5613                service.scale = self.service_integer(field.value(), 0, i128::MAX, "scale");
5614                if service.scale.is_none() {
5615                    service.unmodeled_fields.push(field_reference(path, field));
5616                }
5617            }
5618            "pids_limit" => service.pids_limit = self.pids_limit(field.value()),
5619            _ => {}
5620        }
5621    }
5622
5623    fn set_blkio_config(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5624        service.blkio_config = self.blkio_config(field.value(), path);
5625        if service.blkio_config.is_none() {
5626            service.unmodeled_fields.push(field_reference(path, field));
5627        }
5628    }
5629
5630    fn set_cgroup(&mut self, service: &mut ProjectService, field: &MergedEntry, path: &[String]) {
5631        match field.key() {
5632            "cgroup" => {
5633                service.cgroup = self.cgroup_namespace(field.value());
5634                if service.cgroup.is_none() {
5635                    service.unmodeled_fields.push(field_reference(path, field));
5636                }
5637            }
5638            "cgroup_parent" => {
5639                service.cgroup_parent =
5640                    self.strict_project_string(field.value(), "service cgroup_parent must be a YAML string scalar");
5641                if service.cgroup_parent.is_none() {
5642                    service.unmodeled_fields.push(field_reference(path, field));
5643                }
5644            }
5645            _ => {}
5646        }
5647    }
5648
5649    fn cgroup_namespace(&mut self, value: &MergedValue) -> Option<ProjectValue<CgroupNamespace>> {
5650        let raw = self.strict_project_string(value, "service cgroup must be a YAML string scalar")?;
5651        let cgroup = CgroupNamespace::parse(Located::new(raw.value, effective_span(value)));
5652        if !cgroup.is_valid() {
5653            self.diagnostics.push(
5654                Diagnostic::new(
5655                    crate::model::CGROUP_NAMESPACE_INVALID,
5656                    Severity::Warning,
5657                    "service cgroup must be `host`, `private`, or a deferred expression",
5658                )
5659                .with_label(DiagnosticLabel::primary(
5660                    cgroup.raw().span(),
5661                    "retained unsupported cgroup namespace",
5662                )),
5663            );
5664        }
5665        Some(ProjectValue {
5666            value: cgroup,
5667            provenance: raw.provenance,
5668            sensitive: raw.sensitive,
5669        })
5670    }
5671
5672    fn blkio_config(&mut self, value: &MergedValue, path: &[String]) -> Option<ProjectValue<ProjectBlkioConfig>> {
5673        let fields = self.mapping(value, "blkio_config must be a mapping")?;
5674        let mut config = ProjectBlkioConfig {
5675            weight: None,
5676            device_read_bps: None,
5677            device_read_iops: None,
5678            device_write_bps: None,
5679            device_write_iops: None,
5680            weight_device: None,
5681            unmodeled_fields: Vec::new(),
5682        };
5683        let mut config_path = path.to_vec();
5684        config_path.push("blkio_config".to_owned());
5685        for field in fields {
5686            match field.key() {
5687                "weight" => {
5688                    if let Some(value) = self.blkio_scalar(field.value()) {
5689                        config.weight = Some(value);
5690                    } else {
5691                        config.unmodeled_fields.push(field_reference(&config_path, field));
5692                    }
5693                }
5694                "device_read_bps" | "device_read_iops" | "device_write_bps" | "device_write_iops" => {
5695                    let rates = self.blkio_rates(field.value(), &config_path, field.key());
5696                    let malformed = rates.is_none();
5697                    match field.key() {
5698                        "device_read_bps" => config.device_read_bps = rates,
5699                        "device_read_iops" => config.device_read_iops = rates,
5700                        "device_write_bps" => config.device_write_bps = rates,
5701                        "device_write_iops" => config.device_write_iops = rates,
5702                        _ => unreachable!("blkio rate key was matched above"),
5703                    }
5704                    if malformed {
5705                        config.unmodeled_fields.push(field_reference(&config_path, field));
5706                    }
5707                }
5708                "weight_device" => {
5709                    config.weight_device = self.blkio_weight_devices(field.value(), &config_path, field.key());
5710                    if config.weight_device.is_none() {
5711                        config.unmodeled_fields.push(field_reference(&config_path, field));
5712                    }
5713                }
5714                _ => config.unmodeled_fields.push(field_reference(&config_path, field)),
5715            }
5716        }
5717        Some(ProjectValue::new(config, value))
5718    }
5719
5720    fn blkio_scalar(&mut self, value: &MergedValue) -> Option<ProjectValue<BlkioScalar>> {
5721        let Some(scalar) = value.as_scalar() else {
5722            self.expected(value, "blkio value must be a YAML integer or string scalar");
5723            return None;
5724        };
5725        let parsed = match scalar.kind() {
5726            MergedScalarKind::Number if Self::yaml_integer_spelling(scalar.value()) => {
5727                BlkioScalar::YamlInteger(scalar.value().to_owned())
5728            }
5729            MergedScalarKind::String if scalar.is_strict_yaml_string() => {
5730                BlkioScalar::String(scalar.value().to_owned())
5731            }
5732            _ => {
5733                self.expected(value, "blkio value must be a YAML integer or string scalar");
5734                return None;
5735            }
5736        };
5737        Some(ProjectValue::new(parsed, value))
5738    }
5739
5740    fn blkio_rates(
5741        &mut self,
5742        value: &MergedValue,
5743        path: &[String],
5744        sequence_name: &str,
5745    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectBlkioDeviceRate>>>> {
5746        let Some(values) = value.as_sequence() else {
5747            self.expected(value, "blkio device rates must be a sequence");
5748            return None;
5749        };
5750        let mut item_path = path.to_vec();
5751        item_path.push(sequence_name.to_owned());
5752        let mut items = Vec::new();
5753        for item in values {
5754            let mut rate = ProjectBlkioDeviceRate {
5755                form: ProjectBlkioDeviceRateForm::Unmodeled,
5756                path: None,
5757                rate: None,
5758                unmodeled_fields: Vec::new(),
5759            };
5760            if let Some(fields) = self.mapping(item, "blkio device rate entries must be mappings") {
5761                rate.form = ProjectBlkioDeviceRateForm::Mapping;
5762                for field in fields {
5763                    match field.key() {
5764                        "path" => {
5765                            rate.path = self
5766                                .strict_project_string(field.value(), "blkio device path must be a YAML string scalar");
5767                            if rate.path.is_none() {
5768                                rate.unmodeled_fields.push(field_reference(&item_path, field));
5769                            }
5770                        }
5771                        "rate" => {
5772                            rate.rate = self.blkio_scalar(field.value());
5773                            if rate.rate.is_none() {
5774                                rate.unmodeled_fields.push(field_reference(&item_path, field));
5775                            }
5776                        }
5777                        _ => rate.unmodeled_fields.push(field_reference(&item_path, field)),
5778                    }
5779                }
5780            }
5781            items.push(ProjectValue::new(rate, item));
5782        }
5783        Some(ProjectValue::new(items, value))
5784    }
5785
5786    fn blkio_weight_devices(
5787        &mut self,
5788        value: &MergedValue,
5789        path: &[String],
5790        sequence_name: &str,
5791    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectBlkioWeightDevice>>>> {
5792        let Some(values) = value.as_sequence() else {
5793            self.expected(value, "blkio weight devices must be a sequence");
5794            return None;
5795        };
5796        let mut item_path = path.to_vec();
5797        item_path.push(sequence_name.to_owned());
5798        let mut items = Vec::new();
5799        for item in values {
5800            let mut weight = ProjectBlkioWeightDevice {
5801                form: ProjectBlkioWeightDeviceForm::Unmodeled,
5802                path: None,
5803                weight: None,
5804                unmodeled_fields: Vec::new(),
5805            };
5806            if let Some(fields) = self.mapping(item, "blkio weight device entries must be mappings") {
5807                weight.form = ProjectBlkioWeightDeviceForm::Mapping;
5808                for field in fields {
5809                    match field.key() {
5810                        "path" => {
5811                            weight.path = self.strict_project_string(
5812                                field.value(),
5813                                "blkio weight device path must be a YAML string scalar",
5814                            );
5815                            if weight.path.is_none() {
5816                                weight.unmodeled_fields.push(field_reference(&item_path, field));
5817                            }
5818                        }
5819                        "weight" => {
5820                            weight.weight = self.blkio_scalar(field.value());
5821                            if weight.weight.is_none() {
5822                                weight.unmodeled_fields.push(field_reference(&item_path, field));
5823                            }
5824                        }
5825                        _ => weight.unmodeled_fields.push(field_reference(&item_path, field)),
5826                    }
5827                }
5828            }
5829            items.push(ProjectValue::new(weight, item));
5830        }
5831        Some(ProjectValue::new(items, value))
5832    }
5833
5834    fn service_deploy(&mut self, field: &MergedEntry, path: &[String]) -> Option<ProjectValue<ProjectDeploy>> {
5835        let mut deploy_path = path.to_vec();
5836        deploy_path.push(field.key().to_owned());
5837        let fields = self.mapping(field.value(), "deploy must be a mapping")?;
5838        let mut deploy = ProjectDeploy {
5839            endpoint_mode: None,
5840            labels: None,
5841            mode: None,
5842            placement: None,
5843            replicas: None,
5844            resources: None,
5845            restart_policy: None,
5846            rollback_config: None,
5847            update_config: None,
5848            unmodeled_fields: Vec::new(),
5849        };
5850        for nested in fields {
5851            match nested.key() {
5852                "endpoint_mode" => {
5853                    if let Some(endpoint_mode) = self.deploy_endpoint_mode(nested.value()) {
5854                        deploy.endpoint_mode = Some(endpoint_mode);
5855                    } else {
5856                        deploy.unmodeled_fields.push(field_reference(&deploy_path, nested));
5857                    }
5858                }
5859                "labels" => {
5860                    let (labels, malformed) = self.deploy_labels(nested.value());
5861                    if let Some(labels) = labels {
5862                        deploy.labels = Some(labels);
5863                    }
5864                    if malformed {
5865                        deploy.unmodeled_fields.push(field_reference(&deploy_path, nested));
5866                    }
5867                }
5868                "mode" => {
5869                    if let Some(mode) = self.deploy_mode(nested.value()) {
5870                        deploy.mode = Some(mode);
5871                    } else {
5872                        deploy.unmodeled_fields.push(field_reference(&deploy_path, nested));
5873                    }
5874                }
5875                "placement" => {
5876                    if let Some(placement) = self.deploy_placement(nested.value(), &deploy_path) {
5877                        deploy.placement = Some(placement);
5878                    } else {
5879                        deploy.unmodeled_fields.push(field_reference(&deploy_path, nested));
5880                    }
5881                }
5882                "replicas" => {
5883                    if let Some(replicas) = self.deploy_replicas(nested.value()) {
5884                        deploy.replicas = Some(replicas);
5885                    } else {
5886                        deploy.unmodeled_fields.push(field_reference(&deploy_path, nested));
5887                    }
5888                }
5889                "resources" => {
5890                    if let Some(resources) = self.deploy_resources(nested.value(), &deploy_path) {
5891                        deploy.resources = Some(resources);
5892                    } else {
5893                        deploy.unmodeled_fields.push(field_reference(&deploy_path, nested));
5894                    }
5895                }
5896                "restart_policy" => {
5897                    if let Some(policy) = self.deploy_restart_policy(nested.value(), &deploy_path) {
5898                        deploy.restart_policy = Some(policy);
5899                    } else {
5900                        deploy.unmodeled_fields.push(field_reference(&deploy_path, nested));
5901                    }
5902                }
5903                "rollback_config" => {
5904                    if let Some(config) = self.deploy_rollback_config(nested.value(), &deploy_path) {
5905                        deploy.rollback_config = Some(config);
5906                    } else {
5907                        deploy.unmodeled_fields.push(field_reference(&deploy_path, nested));
5908                    }
5909                }
5910                "update_config" => {
5911                    if let Some(config) = self.deploy_update_config(nested.value(), &deploy_path) {
5912                        deploy.update_config = Some(config);
5913                    } else {
5914                        deploy.unmodeled_fields.push(field_reference(&deploy_path, nested));
5915                    }
5916                }
5917                _ => deploy.unmodeled_fields.push(field_reference(&deploy_path, nested)),
5918            }
5919        }
5920        Some(ProjectValue::new(deploy, field.value()))
5921    }
5922
5923    fn deploy_labels(&mut self, value: &MergedValue) -> (Option<ProjectValue<ProjectLabels>>, bool) {
5924        let mut entries = Vec::new();
5925        let mut malformed = false;
5926        let form = match value.kind() {
5927            MergedValueKind::Mapping(values) => {
5928                for entry in values {
5929                    let scalar = if entry.syntax() == EntrySyntax::ListKeyOnly {
5930                        Some(ComposeScalar::String(String::new()))
5931                    } else {
5932                        self.compose_scalar(entry.value(), "deploy label value must be a scalar or null")
5933                    };
5934                    let Some(scalar) = scalar else {
5935                        malformed = true;
5936                        continue;
5937                    };
5938                    entries.push(ProjectLabelEntry {
5939                        name: ProjectKey::from_entry(entry),
5940                        value: ProjectValue::new(scalar, entry.value()),
5941                        syntax: entry.syntax(),
5942                    });
5943                }
5944                ProjectLabelsForm::Map
5945            }
5946            MergedValueKind::Sequence(values) => {
5947                for item in values {
5948                    let Some(raw) = self.located_string(item, "deploy label list item must be a scalar") else {
5949                        malformed = true;
5950                        continue;
5951                    };
5952                    let (name, value, syntax) = raw.value().split_once('=').map_or_else(
5953                        || (raw.value().clone(), String::new(), EntrySyntax::ListKeyOnly),
5954                        |(name, value)| (name.to_owned(), value.to_owned(), EntrySyntax::ListKeyValue),
5955                    );
5956                    entries.push(ProjectLabelEntry {
5957                        name: ProjectKey::from_value(name, item),
5958                        value: ProjectValue::new(ComposeScalar::String(value), item),
5959                        syntax,
5960                    });
5961                }
5962                ProjectLabelsForm::List
5963            }
5964            _ => {
5965                self.expected(value, "deploy labels must be a mapping or sequence");
5966                return (None, true);
5967            }
5968        };
5969        (
5970            Some(ProjectValue::new(ProjectLabels { form, entries }, value)),
5971            malformed,
5972        )
5973    }
5974
5975    fn deploy_endpoint_mode(&mut self, value: &MergedValue) -> Option<ProjectValue<DeployEndpointMode>> {
5976        let scalar = match value.kind() {
5977            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
5978            _ => {
5979                self.expected(value, "deploy endpoint_mode must be a YAML string scalar");
5980                return None;
5981            }
5982        };
5983        let endpoint_mode = DeployEndpointMode::parse(scalar.value().to_owned());
5984        if !endpoint_mode.is_documented() {
5985            self.diagnostics.push(
5986                Diagnostic::new(
5987                    DEPLOY_ENDPOINT_MODE_PORTABILITY,
5988                    Severity::Warning,
5989                    "deploy endpoint_mode is outside Compose's documented portable values",
5990                )
5991                .with_label(DiagnosticLabel::primary(
5992                    effective_span(value),
5993                    "retained provider-specific endpoint mode",
5994                )),
5995            );
5996        }
5997        Some(ProjectValue::new(endpoint_mode, value))
5998    }
5999
6000    fn deploy_mode(&mut self, value: &MergedValue) -> Option<ProjectValue<DeployMode>> {
6001        let scalar = match value.kind() {
6002            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
6003            _ => {
6004                self.expected(value, "deploy mode must be a YAML string scalar");
6005                return None;
6006            }
6007        };
6008        let mode = DeployMode::parse(scalar.value().to_owned());
6009        if !mode.is_documented() {
6010            self.diagnostics.push(
6011                Diagnostic::new(
6012                    DEPLOY_MODE_PORTABILITY,
6013                    Severity::Warning,
6014                    "deploy mode is outside Compose's documented portable values",
6015                )
6016                .with_label(DiagnosticLabel::primary(
6017                    effective_span(value),
6018                    "retained provider-specific deploy mode",
6019                )),
6020            );
6021        }
6022        Some(ProjectValue::new(mode, value))
6023    }
6024
6025    fn deploy_replicas(&mut self, value: &MergedValue) -> Option<ProjectValue<DeployReplicas>> {
6026        let MergedValueKind::Scalar(scalar) = value.kind() else {
6027            self.expected(value, "deploy replicas must be a YAML number or string scalar");
6028            return None;
6029        };
6030        let replicas = match scalar.kind() {
6031            MergedScalarKind::Number => DeployReplicas::YamlNumber(scalar.value().to_owned()),
6032            MergedScalarKind::String => DeployReplicas::String(scalar.value().to_owned()),
6033            MergedScalarKind::Boolean => {
6034                self.expected(value, "deploy replicas must be a YAML number or string scalar");
6035                return None;
6036            }
6037        };
6038        Some(ProjectValue::new(replicas, value))
6039    }
6040
6041    fn deploy_resources(
6042        &mut self,
6043        value: &MergedValue,
6044        path: &[String],
6045    ) -> Option<ProjectValue<ProjectDeployResources>> {
6046        let fields = self.mapping(value, "deploy resources must be a mapping")?;
6047        let mut resources_path = path.to_vec();
6048        resources_path.push("resources".to_owned());
6049        let mut resources = ProjectDeployResources {
6050            limits: None,
6051            reservations: None,
6052            unmodeled_fields: Vec::new(),
6053        };
6054        for field in fields {
6055            match field.key() {
6056                "limits" => {
6057                    if let Some(limits) = self.deploy_resource_limits(field.value(), &resources_path) {
6058                        resources.limits = Some(limits);
6059                    } else {
6060                        resources.unmodeled_fields.push(field_reference(&resources_path, field));
6061                    }
6062                }
6063                "reservations" => {
6064                    if let Some(reservations) = self.deploy_resource_reservations(field.value(), &resources_path) {
6065                        resources.reservations = Some(reservations);
6066                    } else {
6067                        resources.unmodeled_fields.push(field_reference(&resources_path, field));
6068                    }
6069                }
6070                _ => resources.unmodeled_fields.push(field_reference(&resources_path, field)),
6071            }
6072        }
6073        Some(ProjectValue::new(resources, value))
6074    }
6075
6076    fn deploy_resource_limits(
6077        &mut self,
6078        value: &MergedValue,
6079        path: &[String],
6080    ) -> Option<ProjectValue<ProjectDeployResourceLimits>> {
6081        let fields = self.mapping(value, "deploy resource limits must be a mapping")?;
6082        let mut limits_path = path.to_vec();
6083        limits_path.push("limits".to_owned());
6084        let mut limits = ProjectDeployResourceLimits {
6085            cpus: None,
6086            memory: None,
6087            pids: None,
6088            unmodeled_fields: Vec::new(),
6089        };
6090        for field in fields {
6091            match field.key() {
6092                "cpus" => {
6093                    if let Some(cpus) = self.deploy_resource_cpus(
6094                        field.value(),
6095                        "deploy resource limits cpus must be a YAML number or string scalar",
6096                    ) {
6097                        limits.cpus = Some(cpus);
6098                    } else {
6099                        limits.unmodeled_fields.push(field_reference(&limits_path, field));
6100                    }
6101                }
6102                "memory" => {
6103                    if let Some(memory) = self.deploy_resource_memory(
6104                        field.value(),
6105                        "deploy resource limits memory must be a YAML string scalar",
6106                    ) {
6107                        limits.memory = Some(memory);
6108                    } else {
6109                        limits.unmodeled_fields.push(field_reference(&limits_path, field));
6110                    }
6111                }
6112                "pids" => {
6113                    if let Some(pids) = self.deploy_resource_pids(field.value()) {
6114                        limits.pids = Some(pids);
6115                    } else {
6116                        limits.unmodeled_fields.push(field_reference(&limits_path, field));
6117                    }
6118                }
6119                _ => limits.unmodeled_fields.push(field_reference(&limits_path, field)),
6120            }
6121        }
6122        Some(ProjectValue::new(limits, value))
6123    }
6124
6125    fn deploy_resource_reservations(
6126        &mut self,
6127        value: &MergedValue,
6128        path: &[String],
6129    ) -> Option<ProjectValue<ProjectDeployResourceReservations>> {
6130        let fields = self.mapping(value, "deploy resource reservations must be a mapping")?;
6131        let mut reservations_path = path.to_vec();
6132        reservations_path.push("reservations".to_owned());
6133        let mut reservations = ProjectDeployResourceReservations {
6134            cpus: None,
6135            memory: None,
6136            generic_resources: None,
6137            devices: None,
6138            unmodeled_fields: Vec::new(),
6139        };
6140        for field in fields {
6141            match field.key() {
6142                "cpus" => {
6143                    if let Some(cpus) = self.deploy_resource_cpus(
6144                        field.value(),
6145                        "deploy resource reservations cpus must be a YAML number or string scalar",
6146                    ) {
6147                        reservations.cpus = Some(cpus);
6148                    } else {
6149                        reservations
6150                            .unmodeled_fields
6151                            .push(field_reference(&reservations_path, field));
6152                    }
6153                }
6154                "memory" => {
6155                    if let Some(memory) = self.deploy_resource_memory(
6156                        field.value(),
6157                        "deploy resource reservations memory must be a YAML string scalar",
6158                    ) {
6159                        reservations.memory = Some(memory);
6160                    } else {
6161                        reservations
6162                            .unmodeled_fields
6163                            .push(field_reference(&reservations_path, field));
6164                    }
6165                }
6166                "generic_resources" => {
6167                    if let Some(values) = self.deploy_generic_resources(field.value(), &reservations_path) {
6168                        reservations.generic_resources = Some(values);
6169                    } else {
6170                        reservations
6171                            .unmodeled_fields
6172                            .push(field_reference(&reservations_path, field));
6173                    }
6174                }
6175                "devices" => {
6176                    if let Some(values) = self.deploy_reservation_devices(field.value(), &reservations_path) {
6177                        reservations.devices = Some(values);
6178                    } else {
6179                        reservations
6180                            .unmodeled_fields
6181                            .push(field_reference(&reservations_path, field));
6182                    }
6183                }
6184                _ => reservations
6185                    .unmodeled_fields
6186                    .push(field_reference(&reservations_path, field)),
6187            }
6188        }
6189        Some(ProjectValue::new(reservations, value))
6190    }
6191
6192    fn deploy_reservation_devices(
6193        &mut self,
6194        value: &MergedValue,
6195        path: &[String],
6196    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectDeployReservationDevice>>>> {
6197        let Some(values) = value.as_sequence() else {
6198            self.expected(value, "deploy resource reservation devices must be a sequence");
6199            return None;
6200        };
6201        let mut item_path = path.to_vec();
6202        item_path.push("devices".to_owned());
6203        let mut items = Vec::new();
6204        for item in values {
6205            let Some(fields) = self.mapping(item, "deploy resource reservation device entries must be mappings") else {
6206                items.push(ProjectValue::new(
6207                    ProjectDeployReservationDevice {
6208                        form: ProjectDeployReservationDeviceForm::Unmodeled,
6209                        capabilities: None,
6210                        driver: None,
6211                        count: None,
6212                        device_ids: None,
6213                        options: None,
6214                        unmodeled_fields: Vec::new(),
6215                    },
6216                    item,
6217                ));
6218                continue;
6219            };
6220            let mut device = ProjectDeployReservationDevice {
6221                form: ProjectDeployReservationDeviceForm::Mapping,
6222                capabilities: None,
6223                driver: None,
6224                count: None,
6225                device_ids: None,
6226                options: None,
6227                unmodeled_fields: Vec::new(),
6228            };
6229            let mut has_capabilities = false;
6230            let mut count_field = None;
6231            let mut device_ids_field = None;
6232            for field in fields {
6233                match field.key() {
6234                    "capabilities" => {
6235                        has_capabilities = true;
6236                        if let Some(capabilities) = self.deploy_reservation_device_capabilities(field.value()) {
6237                            device.capabilities = Some(capabilities);
6238                        } else {
6239                            device.unmodeled_fields.push(field_reference(&item_path, field));
6240                        }
6241                    }
6242                    "driver" => {
6243                        let Some(scalar) = field.value().as_scalar() else {
6244                            self.expected(
6245                                field.value(),
6246                                "deploy resource reservation device driver must be a YAML string scalar",
6247                            );
6248                            device.unmodeled_fields.push(field_reference(&item_path, field));
6249                            continue;
6250                        };
6251                        if !scalar.is_strict_yaml_string() {
6252                            self.expected(
6253                                field.value(),
6254                                "deploy resource reservation device driver must be a YAML string scalar",
6255                            );
6256                            device.unmodeled_fields.push(field_reference(&item_path, field));
6257                            continue;
6258                        }
6259                        device.driver = Some(ProjectValue::new(scalar.value().to_owned(), field.value()));
6260                    }
6261                    "count" => {
6262                        count_field.get_or_insert(field);
6263                        if let Some(count) = self.deploy_reservation_device_count(field.value()) {
6264                            device.count = Some(count);
6265                        } else {
6266                            device.unmodeled_fields.push(field_reference(&item_path, field));
6267                        }
6268                    }
6269                    "device_ids" => {
6270                        device_ids_field.get_or_insert(field);
6271                        if let Some(device_ids) = self.deploy_reservation_device_ids(field.value()) {
6272                            device.device_ids = Some(device_ids);
6273                        } else {
6274                            device.unmodeled_fields.push(field_reference(&item_path, field));
6275                        }
6276                    }
6277                    "options" => {
6278                        if let Some(options) = self.deploy_reservation_device_options(field.value(), &item_path) {
6279                            device.options = Some(options);
6280                        } else {
6281                            device.unmodeled_fields.push(field_reference(&item_path, field));
6282                        }
6283                    }
6284                    _ => device.unmodeled_fields.push(field_reference(&item_path, field)),
6285                }
6286            }
6287            self.reservation_device_allocation_selector_conflict(count_field, device_ids_field);
6288            if !has_capabilities {
6289                self.missing(
6290                    item,
6291                    "deploy resource reservation device is missing required `capabilities`",
6292                );
6293            }
6294            items.push(ProjectValue::new(device, item));
6295        }
6296        Some(ProjectValue::new(items, value))
6297    }
6298
6299    fn reservation_device_allocation_selector_conflict(
6300        &mut self,
6301        count: Option<&MergedEntry>,
6302        device_ids: Option<&MergedEntry>,
6303    ) {
6304        let (Some(count), Some(device_ids)) = (count, device_ids) else {
6305            return;
6306        };
6307        self.diagnostics.push(
6308            Diagnostic::new(
6309                DEPLOY_RESERVATION_DEVICE_ALLOCATION_SELECTOR_CONFLICT,
6310                Severity::Error,
6311                "deploy resource reservation device count and device_ids are mutually exclusive",
6312            )
6313            .with_label(DiagnosticLabel::primary(entry_span(count), "count retained"))
6314            .with_label(DiagnosticLabel::secondary(
6315                entry_span(device_ids),
6316                "device_ids retained",
6317            )),
6318        );
6319    }
6320
6321    fn deploy_reservation_device_count(
6322        &mut self,
6323        value: &MergedValue,
6324    ) -> Option<ProjectValue<DeployReservationDeviceCount>> {
6325        let MergedValueKind::Scalar(scalar) = value.kind() else {
6326            self.expected(
6327                value,
6328                "deploy resource reservation device count must be a YAML integer or string scalar",
6329            );
6330            return None;
6331        };
6332        let count = match scalar.kind() {
6333            MergedScalarKind::Number if Self::yaml_integer_spelling(scalar.value()) => {
6334                DeployReservationDeviceCount::YamlInteger(scalar.value().to_owned())
6335            }
6336            MergedScalarKind::String if scalar.is_strict_yaml_string() => {
6337                DeployReservationDeviceCount::String(scalar.value().to_owned())
6338            }
6339            MergedScalarKind::Number | MergedScalarKind::String | MergedScalarKind::Boolean => {
6340                self.expected(
6341                    value,
6342                    "deploy resource reservation device count must be a YAML integer or string scalar",
6343                );
6344                return None;
6345            }
6346        };
6347        Some(ProjectValue::new(count, value))
6348    }
6349
6350    fn deploy_reservation_device_ids(
6351        &mut self,
6352        value: &MergedValue,
6353    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectDeployReservationDeviceId>>>> {
6354        let Some(values) = value.as_sequence() else {
6355            self.expected(
6356                value,
6357                "deploy resource reservation device device_ids must be a sequence",
6358            );
6359            return None;
6360        };
6361        let mut items = Vec::new();
6362        for item in values {
6363            let MergedValueKind::Scalar(scalar) = item.kind() else {
6364                self.expected(
6365                    item,
6366                    "deploy resource reservation device device_ids must be string scalars",
6367                );
6368                items.push(ProjectValue::new(
6369                    ProjectDeployReservationDeviceId {
6370                        form: ProjectDeployReservationDeviceIdForm::Unmodeled,
6371                        value: None,
6372                    },
6373                    item,
6374                ));
6375                continue;
6376            };
6377            if scalar.kind() != MergedScalarKind::String || !scalar.is_strict_yaml_string() {
6378                self.expected(
6379                    item,
6380                    "deploy resource reservation device device_ids must be string scalars",
6381                );
6382                items.push(ProjectValue::new(
6383                    ProjectDeployReservationDeviceId {
6384                        form: ProjectDeployReservationDeviceIdForm::Unmodeled,
6385                        value: None,
6386                    },
6387                    item,
6388                ));
6389                continue;
6390            }
6391            items.push(ProjectValue::new(
6392                ProjectDeployReservationDeviceId {
6393                    form: ProjectDeployReservationDeviceIdForm::String,
6394                    value: Some(ProjectValue::new(scalar.value().to_owned(), item)),
6395                },
6396                item,
6397            ));
6398        }
6399        Some(ProjectValue::new(items, value))
6400    }
6401
6402    fn deploy_reservation_device_options(
6403        &mut self,
6404        value: &MergedValue,
6405        path: &[String],
6406    ) -> Option<ProjectValue<ProjectDeployReservationDeviceOptions>> {
6407        match value.kind() {
6408            MergedValueKind::Mapping(entries) => {
6409                let mut items = Vec::new();
6410                let mut unmodeled_entries = Vec::new();
6411                let mut option_path = path.to_vec();
6412                option_path.push("options".to_owned());
6413                for entry in entries {
6414                    if entry.key().is_empty() || !entry.is_strict_yaml_string() {
6415                        self.diagnostics.push(Diagnostic::new(DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY, Severity::Error,
6416                            "deploy resource reservation device options mapping keys must be non-empty strict YAML strings")
6417                            .with_label(DiagnosticLabel::primary(entry_span(entry), "invalid option key")));
6418                        unmodeled_entries.push(field_reference(&option_path, entry));
6419                        continue;
6420                    }
6421                    let Some(option_value) = self.compose_scalar(entry.value(),
6422                        "deploy resource reservation device options mapping values must be scalar strings, numbers, booleans, or null") else {
6423                        unmodeled_entries.push(field_reference(&option_path, entry));
6424                        continue;
6425                    };
6426                    items.push(ProjectValue::new(
6427                        ProjectDeployReservationDeviceOption {
6428                            name: ProjectKey::from_entry(entry),
6429                            value: ProjectValue::new(option_value, entry.value()),
6430                        },
6431                        entry.value(),
6432                    ));
6433                }
6434                Some(ProjectValue::new(
6435                    ProjectDeployReservationDeviceOptions::Map {
6436                        entries: items,
6437                        unmodeled_entries,
6438                    },
6439                    value,
6440                ))
6441            }
6442            MergedValueKind::Sequence(values) => {
6443                let mut items = Vec::new();
6444                let mut seen = BTreeMap::new();
6445                for item in values {
6446                    let MergedValueKind::Scalar(scalar) = item.kind() else {
6447                        self.expected(item, "deploy resource reservation device options list entries must be strict YAML string scalars");
6448                        items.push(ProjectValue::new(
6449                            ProjectDeployReservationDeviceOptionItem {
6450                                form: ProjectDeployReservationDeviceOptionItemForm::Unmodeled,
6451                                value: None,
6452                            },
6453                            item,
6454                        ));
6455                        continue;
6456                    };
6457                    if scalar.kind() != MergedScalarKind::String || !scalar.is_strict_yaml_string() {
6458                        self.expected(item, "deploy resource reservation device options list entries must be strict YAML string scalars");
6459                        items.push(ProjectValue::new(
6460                            ProjectDeployReservationDeviceOptionItem {
6461                                form: ProjectDeployReservationDeviceOptionItemForm::Unmodeled,
6462                                value: None,
6463                            },
6464                            item,
6465                        ));
6466                        continue;
6467                    }
6468                    let span = effective_span(item);
6469                    if let Some(first) = seen.get(scalar.value()) {
6470                        self.diagnostics.push(
6471                            Diagnostic::new(
6472                                DEPLOY_RESERVATION_DEVICE_OPTIONS_DUPLICATE_ITEM,
6473                                Severity::Error,
6474                                "deploy resource reservation device options list entries must be unique exact strings",
6475                            )
6476                            .with_label(DiagnosticLabel::primary(span, "duplicate option string"))
6477                            .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
6478                        );
6479                    } else {
6480                        seen.insert(scalar.value().to_owned(), span);
6481                    }
6482                    items.push(ProjectValue::new(
6483                        ProjectDeployReservationDeviceOptionItem {
6484                            form: ProjectDeployReservationDeviceOptionItemForm::String,
6485                            value: Some(ProjectValue::new(scalar.value().to_owned(), item)),
6486                        },
6487                        item,
6488                    ));
6489                }
6490                Some(ProjectValue::new(
6491                    ProjectDeployReservationDeviceOptions::List(items),
6492                    value,
6493                ))
6494            }
6495            _ => {
6496                self.expected(
6497                    value,
6498                    "deploy resource reservation device options must be a mapping or sequence",
6499                );
6500                None
6501            }
6502        }
6503    }
6504
6505    fn deploy_reservation_device_capabilities(
6506        &mut self,
6507        value: &MergedValue,
6508    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectDeployReservationDeviceCapability>>>> {
6509        let Some(values) = value.as_sequence() else {
6510            self.expected(
6511                value,
6512                "deploy resource reservation device capabilities must be a sequence of strings",
6513            );
6514            return None;
6515        };
6516        let mut items = Vec::new();
6517        let mut seen = BTreeMap::new();
6518        for item in values {
6519            let MergedValueKind::Scalar(scalar) = item.kind() else {
6520                self.expected(
6521                    item,
6522                    "deploy resource reservation device capabilities must be string scalars",
6523                );
6524                items.push(ProjectValue::new(
6525                    ProjectDeployReservationDeviceCapability {
6526                        form: ProjectDeployReservationDeviceCapabilityForm::Unmodeled,
6527                        value: None,
6528                    },
6529                    item,
6530                ));
6531                continue;
6532            };
6533            if scalar.kind() != MergedScalarKind::String {
6534                self.expected(
6535                    item,
6536                    "deploy resource reservation device capabilities must be string scalars",
6537                );
6538                items.push(ProjectValue::new(
6539                    ProjectDeployReservationDeviceCapability {
6540                        form: ProjectDeployReservationDeviceCapabilityForm::Unmodeled,
6541                        value: None,
6542                    },
6543                    item,
6544                ));
6545                continue;
6546            }
6547            let span = effective_span(item);
6548            if let Some(first) = seen.get(scalar.value()) {
6549                self.diagnostics.push(
6550                    Diagnostic::new(
6551                        DEPLOY_RESERVATION_DEVICE_CAPABILITY_DUPLICATE_ITEM,
6552                        Severity::Error,
6553                        "deploy resource reservation device capabilities must be unique exact strings",
6554                    )
6555                    .with_label(DiagnosticLabel::primary(span, "duplicate capability string"))
6556                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
6557                );
6558            } else {
6559                seen.insert(scalar.value().to_owned(), span);
6560            }
6561            items.push(ProjectValue::new(
6562                ProjectDeployReservationDeviceCapability {
6563                    form: ProjectDeployReservationDeviceCapabilityForm::String,
6564                    value: Some(ProjectValue::new(scalar.value().to_owned(), item)),
6565                },
6566                item,
6567            ));
6568        }
6569        Some(ProjectValue::new(items, value))
6570    }
6571
6572    fn deploy_generic_resources(
6573        &mut self,
6574        value: &MergedValue,
6575        path: &[String],
6576    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectDeployGenericResource>>>> {
6577        let Some(values) = value.as_sequence() else {
6578            self.expected(
6579                value,
6580                "deploy resource reservations generic_resources must be a sequence",
6581            );
6582            return None;
6583        };
6584        let mut item_path = path.to_vec();
6585        item_path.push("generic_resources".to_owned());
6586        let mut items = Vec::new();
6587        for item in values {
6588            let Some(fields) = self.mapping(item, "deploy resource generic-resource entries must be mappings") else {
6589                items.push(ProjectValue::new(
6590                    ProjectDeployGenericResource {
6591                        form: ProjectDeployGenericResourceForm::Unmodeled,
6592                        discrete_resource_spec: None,
6593                        unmodeled_fields: Vec::new(),
6594                    },
6595                    item,
6596                ));
6597                continue;
6598            };
6599            let mut resource = ProjectDeployGenericResource {
6600                form: ProjectDeployGenericResourceForm::Mapping,
6601                discrete_resource_spec: None,
6602                unmodeled_fields: Vec::new(),
6603            };
6604            for field in fields {
6605                match field.key() {
6606                    "discrete_resource_spec" => {
6607                        if let Some(spec) = self.deploy_discrete_resource_spec(field.value(), &item_path) {
6608                            resource.discrete_resource_spec = Some(spec);
6609                        } else {
6610                            resource.unmodeled_fields.push(field_reference(&item_path, field));
6611                        }
6612                    }
6613                    _ => resource.unmodeled_fields.push(field_reference(&item_path, field)),
6614                }
6615            }
6616            items.push(ProjectValue::new(resource, item));
6617        }
6618        Some(ProjectValue::new(items, value))
6619    }
6620
6621    fn deploy_discrete_resource_spec(
6622        &mut self,
6623        value: &MergedValue,
6624        path: &[String],
6625    ) -> Option<ProjectValue<ProjectDeployDiscreteResourceSpec>> {
6626        let fields = self.mapping(value, "deploy discrete_resource_spec must be a mapping")?;
6627        let mut spec_path = path.to_vec();
6628        spec_path.push("discrete_resource_spec".to_owned());
6629        let mut spec = ProjectDeployDiscreteResourceSpec {
6630            kind: None,
6631            value: None,
6632            unmodeled_fields: Vec::new(),
6633        };
6634        for field in fields {
6635            match field.key() {
6636                "kind" => {
6637                    let MergedValueKind::Scalar(scalar) = field.value().kind() else {
6638                        self.expected(
6639                            field.value(),
6640                            "deploy discrete_resource_spec kind must be a YAML string scalar",
6641                        );
6642                        spec.unmodeled_fields.push(field_reference(&spec_path, field));
6643                        continue;
6644                    };
6645                    if scalar.kind() == MergedScalarKind::String {
6646                        spec.kind = Some(ProjectValue::new(scalar.value().to_owned(), field.value()));
6647                    } else {
6648                        self.expected(
6649                            field.value(),
6650                            "deploy discrete_resource_spec kind must be a YAML string scalar",
6651                        );
6652                        spec.unmodeled_fields.push(field_reference(&spec_path, field));
6653                    }
6654                }
6655                "value" => {
6656                    if let Some(number) = self.deploy_discrete_resource_value(field.value()) {
6657                        spec.value = Some(number);
6658                    } else {
6659                        spec.unmodeled_fields.push(field_reference(&spec_path, field));
6660                    }
6661                }
6662                _ => spec.unmodeled_fields.push(field_reference(&spec_path, field)),
6663            }
6664        }
6665        Some(ProjectValue::new(spec, value))
6666    }
6667
6668    fn deploy_discrete_resource_value(
6669        &mut self,
6670        value: &MergedValue,
6671    ) -> Option<ProjectValue<DeployDiscreteResourceValue>> {
6672        let MergedValueKind::Scalar(scalar) = value.kind() else {
6673            self.expected(
6674                value,
6675                "deploy discrete_resource_spec value must be a YAML number or string scalar",
6676            );
6677            return None;
6678        };
6679        let scalar_value = match scalar.kind() {
6680            MergedScalarKind::Number => DeployDiscreteResourceValue::YamlNumber(scalar.value().to_owned()),
6681            MergedScalarKind::String => DeployDiscreteResourceValue::String(scalar.value().to_owned()),
6682            MergedScalarKind::Boolean => {
6683                self.expected(
6684                    value,
6685                    "deploy discrete_resource_spec value must be a YAML number or string scalar",
6686                );
6687                return None;
6688            }
6689        };
6690        Some(ProjectValue::new(scalar_value, value))
6691    }
6692
6693    fn deploy_resource_cpus(
6694        &mut self,
6695        value: &MergedValue,
6696        message: &'static str,
6697    ) -> Option<ProjectValue<DeployResourceCpus>> {
6698        let MergedValueKind::Scalar(scalar) = value.kind() else {
6699            self.expected(value, message);
6700            return None;
6701        };
6702        let cpus = match scalar.kind() {
6703            MergedScalarKind::Number => DeployResourceCpus::YamlNumber(scalar.value().to_owned()),
6704            MergedScalarKind::String => DeployResourceCpus::String(scalar.value().to_owned()),
6705            MergedScalarKind::Boolean => {
6706                self.expected(value, message);
6707                return None;
6708            }
6709        };
6710        Some(ProjectValue::new(cpus, value))
6711    }
6712
6713    fn deploy_resource_memory(
6714        &mut self,
6715        value: &MergedValue,
6716        message: &'static str,
6717    ) -> Option<ProjectValue<DeployResourceMemory>> {
6718        let MergedValueKind::Scalar(scalar) = value.kind() else {
6719            self.expected(value, message);
6720            return None;
6721        };
6722        if scalar.kind() != MergedScalarKind::String {
6723            self.expected(value, message);
6724            return None;
6725        }
6726        Some(ProjectValue::new(
6727            DeployResourceMemory::parse(scalar.value().to_owned()),
6728            value,
6729        ))
6730    }
6731
6732    fn deploy_resource_pids(&mut self, value: &MergedValue) -> Option<ProjectValue<DeployResourcePids>> {
6733        let MergedValueKind::Scalar(scalar) = value.kind() else {
6734            self.expected(
6735                value,
6736                "deploy resource limits pids must be a YAML integer or string scalar",
6737            );
6738            return None;
6739        };
6740        let pids = match scalar.kind() {
6741            MergedScalarKind::Number if Self::yaml_integer_spelling(scalar.value()) => {
6742                DeployResourcePids::YamlInteger(scalar.value().to_owned())
6743            }
6744            MergedScalarKind::String => DeployResourcePids::String(scalar.value().to_owned()),
6745            MergedScalarKind::Number | MergedScalarKind::Boolean => {
6746                self.expected(
6747                    value,
6748                    "deploy resource limits pids must be a YAML integer or string scalar",
6749                );
6750                return None;
6751            }
6752        };
6753        Some(ProjectValue::new(pids, value))
6754    }
6755
6756    fn deploy_restart_policy(
6757        &mut self,
6758        value: &MergedValue,
6759        path: &[String],
6760    ) -> Option<ProjectValue<ProjectDeployRestartPolicy>> {
6761        let fields = self.mapping(value, "deploy restart_policy must be a mapping")?;
6762        let mut member_path = path.to_vec();
6763        member_path.push("restart_policy".to_owned());
6764        let mut policy = ProjectDeployRestartPolicy {
6765            condition: None,
6766            delay: None,
6767            max_attempts: None,
6768            window: None,
6769            unmodeled_fields: Vec::new(),
6770        };
6771        for field in fields {
6772            let typed = match field.key() {
6773                "condition" => self
6774                    .project_string(field.value(), "deploy restart-policy condition")
6775                    .map(|raw| ProjectValue {
6776                        value: DeployRestartCondition::parse(raw.value),
6777                        provenance: raw.provenance,
6778                        sensitive: raw.sensitive,
6779                    })
6780                    .map(|value| {
6781                        policy.condition = Some(value);
6782                    }),
6783                "delay" => self
6784                    .project_string(field.value(), "deploy restart-policy delay")
6785                    .map(|raw| ProjectValue {
6786                        value: DeployRestartDuration::new(raw.value),
6787                        provenance: raw.provenance,
6788                        sensitive: raw.sensitive,
6789                    })
6790                    .map(|value| {
6791                        policy.delay = Some(value);
6792                    }),
6793                "window" => self
6794                    .project_string(field.value(), "deploy restart-policy window")
6795                    .map(|raw| ProjectValue {
6796                        value: DeployRestartDuration::new(raw.value),
6797                        provenance: raw.provenance,
6798                        sensitive: raw.sensitive,
6799                    })
6800                    .map(|value| {
6801                        policy.window = Some(value);
6802                    }),
6803                "max_attempts" => self.deploy_restart_max_attempts(field.value()).map(|value| {
6804                    policy.max_attempts = Some(value);
6805                }),
6806                _ => {
6807                    policy.unmodeled_fields.push(field_reference(&member_path, field));
6808                    Some(())
6809                }
6810            };
6811            if typed.is_none() {
6812                policy.unmodeled_fields.push(field_reference(&member_path, field));
6813            }
6814        }
6815        Some(ProjectValue::new(policy, value))
6816    }
6817
6818    fn deploy_restart_max_attempts(&mut self, value: &MergedValue) -> Option<ProjectValue<DeployRestartMaxAttempts>> {
6819        let MergedValueKind::Scalar(scalar) = value.kind() else {
6820            self.expected(
6821                value,
6822                "deploy restart-policy max_attempts must be a YAML integer or string scalar",
6823            );
6824            return None;
6825        };
6826        let parsed = match scalar.kind() {
6827            MergedScalarKind::Number if Self::yaml_integer_spelling(scalar.value()) => {
6828                DeployRestartMaxAttempts::YamlNumber(scalar.value().to_owned())
6829            }
6830            MergedScalarKind::String => DeployRestartMaxAttempts::String(scalar.value().to_owned()),
6831            MergedScalarKind::Number | MergedScalarKind::Boolean => {
6832                self.expected(
6833                    value,
6834                    "deploy restart-policy max_attempts must be a YAML integer or string scalar",
6835                );
6836                return None;
6837            }
6838        };
6839        Some(ProjectValue::new(parsed, value))
6840    }
6841
6842    fn deploy_update_config(
6843        &mut self,
6844        value: &MergedValue,
6845        path: &[String],
6846    ) -> Option<ProjectValue<ProjectDeployUpdateConfig>> {
6847        let fields = self.mapping(value, "deploy update_config must be a mapping")?;
6848        let mut member_path = path.to_vec();
6849        member_path.push("update_config".to_owned());
6850        let mut config = ProjectDeployUpdateConfig {
6851            parallelism: None,
6852            delay: None,
6853            monitor: None,
6854            failure_action: None,
6855            max_failure_ratio: None,
6856            order: None,
6857            unmodeled_fields: Vec::new(),
6858        };
6859        for field in fields {
6860            let typed = match field.key() {
6861                "parallelism" => self.deploy_update_parallelism(field.value()).map(|value| {
6862                    config.parallelism = Some(value);
6863                }),
6864                "delay" => self
6865                    .strict_project_string(field.value(), "deploy update_config delay must be a YAML string scalar")
6866                    .map(|value| {
6867                        config.delay = Some(value);
6868                    }),
6869                "monitor" => self
6870                    .strict_project_string(
6871                        field.value(),
6872                        "deploy update_config monitor must be a YAML string scalar",
6873                    )
6874                    .map(|value| {
6875                        config.monitor = Some(value);
6876                    }),
6877                "failure_action" => self
6878                    .strict_project_string(
6879                        field.value(),
6880                        "deploy update_config failure_action must be a YAML string scalar",
6881                    )
6882                    .map(|value| {
6883                        config.failure_action = Some(value);
6884                    }),
6885                "max_failure_ratio" => self.deploy_update_max_failure_ratio(field.value()).map(|value| {
6886                    config.max_failure_ratio = Some(value);
6887                }),
6888                "order" => self.deploy_update_order(field.value()).map(|value| {
6889                    config.order = Some(value);
6890                }),
6891                _ => {
6892                    config.unmodeled_fields.push(field_reference(&member_path, field));
6893                    Some(())
6894                }
6895            };
6896            if typed.is_none() {
6897                config.unmodeled_fields.push(field_reference(&member_path, field));
6898            }
6899        }
6900        Some(ProjectValue::new(config, value))
6901    }
6902
6903    fn deploy_rollback_config(
6904        &mut self,
6905        value: &MergedValue,
6906        path: &[String],
6907    ) -> Option<ProjectValue<ProjectDeployRollbackConfig>> {
6908        let fields = self.mapping(value, "deploy rollback_config must be a mapping")?;
6909        let mut member_path = path.to_vec();
6910        member_path.push("rollback_config".to_owned());
6911        let mut config = ProjectDeployRollbackConfig {
6912            parallelism: None,
6913            delay: None,
6914            monitor: None,
6915            failure_action: None,
6916            max_failure_ratio: None,
6917            order: None,
6918            unmodeled_fields: Vec::new(),
6919        };
6920        for field in fields {
6921            let typed = match field.key() {
6922                "parallelism" => self.deploy_rollback_parallelism(field.value()).map(|value| {
6923                    config.parallelism = Some(value);
6924                }),
6925                "delay" => self
6926                    .strict_project_string(
6927                        field.value(),
6928                        "deploy rollback_config delay must be a YAML string scalar",
6929                    )
6930                    .map(|value| {
6931                        config.delay = Some(value);
6932                    }),
6933                "monitor" => self
6934                    .strict_project_string(
6935                        field.value(),
6936                        "deploy rollback_config monitor must be a YAML string scalar",
6937                    )
6938                    .map(|value| {
6939                        config.monitor = Some(value);
6940                    }),
6941                "failure_action" => self
6942                    .strict_project_string(
6943                        field.value(),
6944                        "deploy rollback_config failure_action must be a YAML string scalar",
6945                    )
6946                    .map(|value| {
6947                        config.failure_action = Some(value);
6948                    }),
6949                "max_failure_ratio" => self.deploy_rollback_max_failure_ratio(field.value()).map(|value| {
6950                    config.max_failure_ratio = Some(value);
6951                }),
6952                "order" => self.deploy_rollback_order(field.value()).map(|value| {
6953                    config.order = Some(value);
6954                }),
6955                _ => {
6956                    config.unmodeled_fields.push(field_reference(&member_path, field));
6957                    Some(())
6958                }
6959            };
6960            if typed.is_none() {
6961                config.unmodeled_fields.push(field_reference(&member_path, field));
6962            }
6963        }
6964        Some(ProjectValue::new(config, value))
6965    }
6966
6967    fn deploy_rollback_parallelism(&mut self, value: &MergedValue) -> Option<ProjectValue<DeployRollbackParallelism>> {
6968        let MergedValueKind::Scalar(scalar) = value.kind() else {
6969            self.expected(
6970                value,
6971                "deploy rollback_config parallelism must be a YAML integer or string scalar",
6972            );
6973            return None;
6974        };
6975        let parsed = match scalar.kind() {
6976            MergedScalarKind::Number if Self::yaml_integer_spelling(scalar.value()) => {
6977                DeployRollbackParallelism::YamlInteger(scalar.value().to_owned())
6978            }
6979            MergedScalarKind::String if scalar.is_strict_yaml_string() => {
6980                DeployRollbackParallelism::String(scalar.value().to_owned())
6981            }
6982            _ => {
6983                self.expected(
6984                    value,
6985                    "deploy rollback_config parallelism must be a YAML integer or string scalar",
6986                );
6987                return None;
6988            }
6989        };
6990        Some(ProjectValue::new(parsed, value))
6991    }
6992
6993    fn deploy_rollback_max_failure_ratio(
6994        &mut self,
6995        value: &MergedValue,
6996    ) -> Option<ProjectValue<DeployRollbackMaxFailureRatio>> {
6997        let MergedValueKind::Scalar(scalar) = value.kind() else {
6998            self.expected(
6999                value,
7000                "deploy rollback_config max_failure_ratio must be a YAML number or string scalar",
7001            );
7002            return None;
7003        };
7004        let parsed = match scalar.kind() {
7005            MergedScalarKind::Number => DeployRollbackMaxFailureRatio::YamlNumber(scalar.value().to_owned()),
7006            MergedScalarKind::String if scalar.is_strict_yaml_string() => {
7007                DeployRollbackMaxFailureRatio::String(scalar.value().to_owned())
7008            }
7009            _ => {
7010                self.expected(
7011                    value,
7012                    "deploy rollback_config max_failure_ratio must be a YAML number or string scalar",
7013                );
7014                return None;
7015            }
7016        };
7017        Some(ProjectValue::new(parsed, value))
7018    }
7019
7020    fn deploy_rollback_order(&mut self, value: &MergedValue) -> Option<ProjectValue<DeployRollbackOrder>> {
7021        let raw = self.strict_project_string(value, "deploy rollback_config order must be a YAML string scalar")?;
7022        let order = DeployRollbackOrder::parse(raw.value().to_owned());
7023        if !order.is_documented() {
7024            self.diagnostics.push(
7025                Diagnostic::new(
7026                    DEPLOY_ROLLBACK_CONFIG_ORDER_PORTABILITY,
7027                    Severity::Warning,
7028                    "deploy rollback_config order is outside Compose's documented portable values",
7029                )
7030                .with_label(DiagnosticLabel::primary(
7031                    effective_span(value),
7032                    "retained provider-specific rollback order",
7033                )),
7034            );
7035        }
7036        Some(ProjectValue {
7037            value: order,
7038            provenance: raw.provenance,
7039            sensitive: raw.sensitive,
7040        })
7041    }
7042
7043    fn strict_project_string(&mut self, value: &MergedValue, message: &str) -> Option<ProjectValue<String>> {
7044        let MergedValueKind::Scalar(scalar) = value.kind() else {
7045            self.expected(value, message);
7046            return None;
7047        };
7048        if scalar.kind() != MergedScalarKind::String || !scalar.is_strict_yaml_string() {
7049            self.expected(value, message);
7050            return None;
7051        }
7052        Some(ProjectValue::new(scalar.value().to_owned(), value))
7053    }
7054    fn deploy_update_parallelism(&mut self, value: &MergedValue) -> Option<ProjectValue<DeployUpdateParallelism>> {
7055        let MergedValueKind::Scalar(scalar) = value.kind() else {
7056            self.expected(
7057                value,
7058                "deploy update_config parallelism must be a YAML integer or string scalar",
7059            );
7060            return None;
7061        };
7062        let parsed = match scalar.kind() {
7063            MergedScalarKind::Number if Self::yaml_integer_spelling(scalar.value()) => {
7064                DeployUpdateParallelism::YamlInteger(scalar.value().to_owned())
7065            }
7066            MergedScalarKind::String if scalar.is_strict_yaml_string() => {
7067                DeployUpdateParallelism::String(scalar.value().to_owned())
7068            }
7069            _ => {
7070                self.expected(
7071                    value,
7072                    "deploy update_config parallelism must be a YAML integer or string scalar",
7073                );
7074                return None;
7075            }
7076        };
7077        Some(ProjectValue::new(parsed, value))
7078    }
7079    fn deploy_update_max_failure_ratio(
7080        &mut self,
7081        value: &MergedValue,
7082    ) -> Option<ProjectValue<DeployUpdateMaxFailureRatio>> {
7083        let MergedValueKind::Scalar(scalar) = value.kind() else {
7084            self.expected(
7085                value,
7086                "deploy update_config max_failure_ratio must be a YAML number or string scalar",
7087            );
7088            return None;
7089        };
7090        let parsed = match scalar.kind() {
7091            MergedScalarKind::Number => DeployUpdateMaxFailureRatio::YamlNumber(scalar.value().to_owned()),
7092            MergedScalarKind::String if scalar.is_strict_yaml_string() => {
7093                DeployUpdateMaxFailureRatio::String(scalar.value().to_owned())
7094            }
7095            _ => {
7096                self.expected(
7097                    value,
7098                    "deploy update_config max_failure_ratio must be a YAML number or string scalar",
7099                );
7100                return None;
7101            }
7102        };
7103        Some(ProjectValue::new(parsed, value))
7104    }
7105    fn deploy_update_order(&mut self, value: &MergedValue) -> Option<ProjectValue<DeployUpdateOrder>> {
7106        let raw = self.strict_project_string(value, "deploy update_config order must be a YAML string scalar")?;
7107        let order = DeployUpdateOrder::parse(raw.value().to_owned());
7108        if !order.is_documented() {
7109            self.diagnostics.push(
7110                Diagnostic::new(
7111                    DEPLOY_UPDATE_CONFIG_ORDER_PORTABILITY,
7112                    Severity::Warning,
7113                    "deploy update_config order is outside Compose's documented portable values",
7114                )
7115                .with_label(DiagnosticLabel::primary(
7116                    effective_span(value),
7117                    "retained provider-specific update order",
7118                )),
7119            );
7120        }
7121        Some(ProjectValue {
7122            value: order,
7123            provenance: raw.provenance,
7124            sensitive: raw.sensitive,
7125        })
7126    }
7127
7128    fn deploy_placement(
7129        &mut self,
7130        value: &MergedValue,
7131        path: &[String],
7132    ) -> Option<ProjectValue<ProjectDeployPlacement>> {
7133        let fields = self.mapping(value, "deploy placement must be a mapping")?;
7134        let mut placement_path = path.to_vec();
7135        placement_path.push("placement".to_owned());
7136        let mut placement = ProjectDeployPlacement {
7137            constraints: None,
7138            preferences: None,
7139            max_replicas_per_node: None,
7140            unmodeled_fields: Vec::new(),
7141        };
7142        for field in fields {
7143            match field.key() {
7144                "constraints" => {
7145                    let (constraints, malformed) = self.deploy_placement_constraints(field.value());
7146                    placement.constraints = constraints;
7147                    if malformed {
7148                        placement.unmodeled_fields.push(field_reference(&placement_path, field));
7149                    }
7150                }
7151                "preferences" => {
7152                    let (preferences, malformed) = self.deploy_placement_preferences(field.value(), &placement_path);
7153                    placement.preferences = preferences;
7154                    if malformed {
7155                        placement.unmodeled_fields.push(field_reference(&placement_path, field));
7156                    }
7157                }
7158                "max_replicas_per_node" => {
7159                    if let Some(maximum) = self.deploy_placement_max_replicas_per_node(field.value()) {
7160                        placement.max_replicas_per_node = Some(maximum);
7161                    } else {
7162                        placement.unmodeled_fields.push(field_reference(&placement_path, field));
7163                    }
7164                }
7165                _ => placement.unmodeled_fields.push(field_reference(&placement_path, field)),
7166            }
7167        }
7168        Some(ProjectValue::new(placement, value))
7169    }
7170
7171    fn deploy_placement_constraints(
7172        &mut self,
7173        value: &MergedValue,
7174    ) -> (Option<ProjectValue<Vec<ProjectValue<String>>>>, bool) {
7175        let Some(values) = value.as_sequence() else {
7176            self.expected(
7177                value,
7178                "deploy placement constraints must be a sequence of YAML string scalars",
7179            );
7180            return (None, true);
7181        };
7182        let mut constraints = Vec::new();
7183        let mut malformed = false;
7184        for item in values {
7185            match item.kind() {
7186                MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
7187                    constraints.push(ProjectValue::new(scalar.value().to_owned(), item));
7188                }
7189                _ => {
7190                    self.expected(item, "deploy placement constraints must contain YAML string scalars");
7191                    malformed = true;
7192                }
7193            }
7194        }
7195        (Some(ProjectValue::new(constraints, value)), malformed)
7196    }
7197
7198    fn deploy_placement_preferences(
7199        &mut self,
7200        value: &MergedValue,
7201        path: &[String],
7202    ) -> (
7203        Option<ProjectValue<Vec<ProjectValue<ProjectDeployPlacementPreference>>>>,
7204        bool,
7205    ) {
7206        let Some(values) = value.as_sequence() else {
7207            self.expected(value, "deploy placement preferences must be a sequence of mappings");
7208            return (None, true);
7209        };
7210        let mut preferences = Vec::new();
7211        let mut malformed = false;
7212        for (index, item) in values.iter().enumerate() {
7213            let Some(fields) = item.as_mapping() else {
7214                self.expected(item, "deploy placement preferences must contain mappings");
7215                malformed = true;
7216                continue;
7217            };
7218            let mut preference_path = path.to_vec();
7219            preference_path.extend(["preferences".to_owned(), index.to_string()]);
7220            let mut preference = ProjectDeployPlacementPreference {
7221                spread: None,
7222                unmodeled_fields: Vec::new(),
7223            };
7224            for field in fields {
7225                match field.key() {
7226                    "spread" => match field.value().kind() {
7227                        MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
7228                            preference.spread = Some(ProjectValue::new(scalar.value().to_owned(), field.value()));
7229                        }
7230                        _ => {
7231                            self.expected(
7232                                field.value(),
7233                                "deploy placement preference spread must be a YAML string scalar",
7234                            );
7235                            preference
7236                                .unmodeled_fields
7237                                .push(field_reference(&preference_path, field));
7238                        }
7239                    },
7240                    _ => preference
7241                        .unmodeled_fields
7242                        .push(field_reference(&preference_path, field)),
7243                }
7244            }
7245            preferences.push(ProjectValue::new(preference, item));
7246        }
7247        (Some(ProjectValue::new(preferences, value)), malformed)
7248    }
7249
7250    fn deploy_placement_max_replicas_per_node(
7251        &mut self,
7252        value: &MergedValue,
7253    ) -> Option<ProjectValue<DeployPlacementMaxReplicasPerNode>> {
7254        let MergedValueKind::Scalar(scalar) = value.kind() else {
7255            self.expected(
7256                value,
7257                "deploy placement max_replicas_per_node must be a YAML integer or string scalar",
7258            );
7259            return None;
7260        };
7261        let maximum = match scalar.kind() {
7262            MergedScalarKind::Number if Self::yaml_integer_spelling(scalar.value()) => {
7263                DeployPlacementMaxReplicasPerNode::YamlInteger(scalar.value().to_owned())
7264            }
7265            MergedScalarKind::String => DeployPlacementMaxReplicasPerNode::String(scalar.value().to_owned()),
7266            MergedScalarKind::Number | MergedScalarKind::Boolean => {
7267                self.expected(
7268                    value,
7269                    "deploy placement max_replicas_per_node must be a YAML integer or string scalar",
7270                );
7271                return None;
7272            }
7273        };
7274        Some(ProjectValue::new(maximum, value))
7275    }
7276
7277    /// Recognizes raw YAML integer spellings after merge has coalesced integer and float scalars.
7278    ///
7279    /// This deliberately preserves spelling and accepts only decimal, binary, octal, and
7280    /// hexadecimal integer forms with optional signs and digit separators. It therefore never
7281    /// promotes a float-shaped merged number to any integer-only deploy field.
7282    fn yaml_integer_spelling(value: &str) -> bool {
7283        let digits = value
7284            .strip_prefix('+')
7285            .or_else(|| value.strip_prefix('-'))
7286            .unwrap_or(value);
7287        let (radix, digits) = if let Some(value) = digits.strip_prefix("0b") {
7288            (2, value)
7289        } else if let Some(value) = digits.strip_prefix("0o") {
7290            (8, value)
7291        } else if let Some(value) = digits.strip_prefix("0x") {
7292            (16, value)
7293        } else {
7294            (10, digits)
7295        };
7296        let mut saw_digit = false;
7297        let mut previous_separator = false;
7298        for byte in digits.bytes() {
7299            if byte == b'_' {
7300                if !saw_digit || previous_separator {
7301                    return false;
7302                }
7303                previous_separator = true;
7304            } else if if radix == 16 {
7305                byte.is_ascii_hexdigit()
7306            } else {
7307                byte.is_ascii_digit() && (byte - b'0') < radix
7308            } {
7309                saw_digit = true;
7310                previous_separator = false;
7311            } else {
7312                return false;
7313            }
7314        }
7315        saw_digit && !previous_separator
7316    }
7317
7318    fn hostname(&mut self, value: &MergedValue) -> Option<ProjectValue<Hostname>> {
7319        let scalar = match value.kind() {
7320            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
7321            _ => {
7322                self.expected(value, "hostname must be a YAML string scalar");
7323                return None;
7324            }
7325        };
7326        let hostname = Hostname::parse(Located::new(scalar.value().to_owned(), effective_span(value)));
7327        if hostname.kind() == &HostnameKind::Invalid {
7328            self.invalid(
7329                effective_span(value),
7330                "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",
7331            );
7332        }
7333        Some(ProjectValue::new(hostname, value))
7334    }
7335
7336    fn restart_policy(&mut self, value: &MergedValue) -> Option<ProjectValue<RestartPolicy>> {
7337        let policy = RestartPolicy::parse(self.located_string(value, "restart must be a non-null scalar")?);
7338        if !policy.is_valid() {
7339            self.invalid(
7340                effective_span(value),
7341                "restart must be `no`, `always`, `on-failure[:max-retries]`, or `unless-stopped`",
7342            );
7343        }
7344        Some(ProjectValue::new(policy, value))
7345    }
7346
7347    fn service_build(&mut self, value: &MergedValue, path: &[String]) -> Option<ProjectValue<ProjectBuild>> {
7348        let build = match value.kind() {
7349            MergedValueKind::Scalar(_) => ProjectBuild::Context(self.project_string(value, "build context")?),
7350            MergedValueKind::Mapping(fields) => {
7351                let mut definition = ProjectBuildDefinition {
7352                    additional_contexts: None,
7353                    context: None,
7354                    args: None,
7355                    entitlements: None,
7356                    extra_hosts: None,
7357                    dockerfile: None,
7358                    dockerfile_inline: None,
7359                    target: None,
7360                    network: None,
7361                    isolation: None,
7362                    platforms: None,
7363                    cache_options: None,
7364                    no_cache_filter: None,
7365                    privileged: None,
7366                    sbom: None,
7367                    provenance: None,
7368                    shm_size: None,
7369                    tags: None,
7370                    labels: None,
7371                    secrets: None,
7372                    ssh: None,
7373                    ulimits: None,
7374                    unmodeled_fields: Arc::new(Vec::new()),
7375                };
7376                let (mut dockerfile, mut dockerfile_inline) = (None, None);
7377                for field in fields {
7378                    match field.key() {
7379                        "additional_contexts" => self.set_build_additional_contexts(&mut definition, field, path),
7380                        "args" => {
7381                            let (args, malformed) = self.build_args(field.value());
7382                            definition.args = args.map(Box::new);
7383                            if malformed {
7384                                definition.push_unmodeled(field_reference(path, field));
7385                            }
7386                        }
7387                        "cache_from" | "cache_to" => self.cache_locations(&mut definition, field, path),
7388                        "context" => {
7389                            if let Some(context) = self.project_string(field.value(), "build context") {
7390                                definition.context = Some(Box::new(context));
7391                            } else {
7392                                definition.push_unmodeled(field_reference(path, field));
7393                            }
7394                        }
7395                        "dockerfile" => {
7396                            dockerfile = Some(field);
7397                            if let Some(dockerfile) = self.non_empty_project_string(field.value(), "build dockerfile") {
7398                                definition.dockerfile = Some(Box::new(dockerfile));
7399                            } else {
7400                                definition.push_unmodeled(field_reference(path, field));
7401                            }
7402                        }
7403                        "dockerfile_inline" => self.set_inline(&mut definition, field, path, &mut dockerfile_inline),
7404                        "entitlements" => self.set_build_entitlements(&mut definition, field, path),
7405                        "extra_hosts" => self.set_build_extra_hosts(&mut definition, field, path),
7406                        "target" => {
7407                            if let Some(target) = self.project_string(field.value(), "build target") {
7408                                definition.target = Some(Box::new(target));
7409                            } else {
7410                                definition.push_unmodeled(field_reference(path, field));
7411                            }
7412                        }
7413                        "network" => self.set_build_network(&mut definition, field, path),
7414                        "isolation" => self.set_build_isolation(&mut definition, field, path),
7415                        "platforms" => self.set_build_platforms(&mut definition, field, path),
7416                        "no_cache" => self.set_build_no_cache(&mut definition, field, path),
7417                        "no_cache_filter" => self.set_build_no_cache_filter(&mut definition, field, path),
7418                        "privileged" => self.set_build_privileged(&mut definition, field, path),
7419                        "sbom" | "provenance" => self.set_build_attestation(&mut definition, field, path),
7420                        "pull" => self.set_build_pull(&mut definition, field, path),
7421                        "shm_size" => self.set_build_shm_size(&mut definition, field, path),
7422                        "tags" => {
7423                            let (tags, malformed) = self.build_tags(field.value());
7424                            definition.tags = tags.map(Box::new);
7425                            if malformed {
7426                                definition.push_unmodeled(field_reference(path, field));
7427                            }
7428                        }
7429                        "labels" => {
7430                            let (labels, malformed) = self.build_labels(field.value());
7431                            definition.labels = labels.map(Box::new);
7432                            if malformed {
7433                                definition.push_unmodeled(field_reference(path, field));
7434                            }
7435                        }
7436                        "secrets" => self.set_build_secrets(&mut definition, field, path),
7437                        "ssh" => self.set_build_ssh(&mut definition, field, path),
7438                        "ulimits" => self.set_build_ulimits(&mut definition, field, path),
7439                        _ => definition.push_unmodeled(field_reference(path, field)),
7440                    }
7441                }
7442                self.report_build_dockerfile_conflict(dockerfile, dockerfile_inline);
7443                ProjectBuild::Definition(definition)
7444            }
7445            _ => return self.invalid_project_build_form(value),
7446        };
7447        Some(ProjectValue::new(build, value))
7448    }
7449
7450    fn invalid_project_build_form(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectBuild>> {
7451        self.expected(value, "build must be a scalar context or mapping");
7452        None
7453    }
7454
7455    fn report_build_dockerfile_conflict(
7456        &mut self,
7457        dockerfile: Option<&MergedEntry>,
7458        dockerfile_inline: Option<&MergedEntry>,
7459    ) {
7460        let (Some(dockerfile), Some(dockerfile_inline)) = (dockerfile, dockerfile_inline) else {
7461            return;
7462        };
7463        self.diagnostics.push(
7464            Diagnostic::new(
7465                BUILD_DOCKERFILE_INLINE_CONFLICT,
7466                Severity::Error,
7467                "build `dockerfile` and `dockerfile_inline` are mutually exclusive",
7468            )
7469            .with_label(DiagnosticLabel::primary(entry_span(dockerfile), "dockerfile retained"))
7470            .with_label(DiagnosticLabel::secondary(
7471                entry_span(dockerfile_inline),
7472                "dockerfile_inline retained",
7473            )),
7474        );
7475    }
7476
7477    fn set_build_secrets(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7478        let (secrets, malformed) = self.build_secret_grants(field.value(), path);
7479        definition.secrets = secrets.map(Box::new);
7480        if malformed {
7481            definition.push_unmodeled(field_reference(path, field));
7482        }
7483    }
7484
7485    fn set_build_ssh(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7486        let (ssh, malformed) = self.build_ssh(field.value());
7487        definition.ssh = ssh.map(Box::new);
7488        if malformed {
7489            definition.push_unmodeled(field_reference(path, field));
7490        }
7491    }
7492
7493    fn set_build_ulimits(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7494        let (ulimits, unmodeled) = self.ulimits(field.value(), path);
7495        definition.ulimits = ulimits.map(Box::new);
7496        for field in unmodeled {
7497            definition.push_unmodeled(field);
7498        }
7499        if definition.ulimits.is_none() {
7500            definition.push_unmodeled(field_reference(path, field));
7501        }
7502    }
7503
7504    fn set_build_additional_contexts(
7505        &mut self,
7506        definition: &mut ProjectBuildDefinition,
7507        field: &MergedEntry,
7508        path: &[String],
7509    ) {
7510        let (additional_contexts, malformed) = self.build_additional_contexts(field.value());
7511        definition.additional_contexts = additional_contexts.map(Box::new);
7512        if malformed {
7513            definition.push_unmodeled(field_reference(path, field));
7514        }
7515    }
7516
7517    fn set_build_entitlements(
7518        &mut self,
7519        definition: &mut ProjectBuildDefinition,
7520        field: &MergedEntry,
7521        path: &[String],
7522    ) {
7523        let (entitlements, malformed) = self.build_entitlements(field.value());
7524        definition.entitlements = entitlements.map(Box::new);
7525        if malformed {
7526            definition.push_unmodeled(field_reference(path, field));
7527        }
7528    }
7529
7530    fn set_build_dockerfile_inline<'field>(
7531        &mut self,
7532        definition: &mut ProjectBuildDefinition,
7533        field: &'field MergedEntry,
7534        path: &[String],
7535        dockerfile_inline: &mut Option<&'field MergedEntry>,
7536    ) {
7537        *dockerfile_inline = Some(field);
7538        let dockerfile_inline = match field.value().kind() {
7539            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
7540                Some(ProjectValue::new(scalar.value().to_owned(), field.value()))
7541            }
7542            MergedValueKind::Scalar(_) => {
7543                self.invalid(
7544                    effective_span(field.value()),
7545                    "build dockerfile_inline must be a YAML string scalar",
7546                );
7547                None
7548            }
7549            _ => {
7550                self.expected(field.value(), "build dockerfile_inline must be a YAML string scalar");
7551                None
7552            }
7553        };
7554        if let Some(dockerfile_inline) = dockerfile_inline {
7555            definition.dockerfile_inline = Some(Box::new(dockerfile_inline));
7556        } else {
7557            definition.push_unmodeled(field_reference(path, field));
7558        }
7559    }
7560    fn set_inline<'field>(
7561        &mut self,
7562        definition: &mut ProjectBuildDefinition,
7563        field: &'field MergedEntry,
7564        path: &[String],
7565        inline: &mut Option<&'field MergedEntry>,
7566    ) {
7567        self.set_build_dockerfile_inline(definition, field, path, inline);
7568    }
7569
7570    fn set_build_extra_hosts(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7571        let pending_start = self.pending_unmodeled.len();
7572        let (extra_hosts, malformed) = self.build_extra_hosts(field.value(), path);
7573        definition.extra_hosts = extra_hosts.map(Box::new);
7574        for nested in self.pending_unmodeled.drain(pending_start..) {
7575            definition.push_unmodeled(nested);
7576        }
7577        if malformed {
7578            definition.push_unmodeled(field_reference(path, field));
7579        }
7580    }
7581
7582    fn set_build_platforms(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7583        let (platforms, malformed) = self.build_platforms(field.value());
7584        definition.platforms = platforms.map(Box::new);
7585        if malformed {
7586            definition.push_unmodeled(field_reference(path, field));
7587        }
7588    }
7589
7590    fn set_build_cache_locations(
7591        &mut self,
7592        definition: &mut ProjectBuildDefinition,
7593        field: &MergedEntry,
7594        path: &[String],
7595        name: &str,
7596    ) {
7597        let (locations, malformed) = self.build_cache_locations(field.value(), name);
7598        if name == "cache_from" {
7599            definition.cache_options().cache_from = locations;
7600        } else {
7601            definition.cache_options().cache_to = locations;
7602        }
7603        if malformed {
7604            definition.push_unmodeled(field_reference(path, field));
7605        }
7606    }
7607    fn cache_locations(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7608        self.set_build_cache_locations(definition, field, path, field.key());
7609    }
7610
7611    fn set_build_network(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7612        if let Some(network) = self.project_string(field.value(), "build network") {
7613            definition.network = Some(Box::new(network));
7614        } else {
7615            definition.push_unmodeled(field_reference(path, field));
7616        }
7617    }
7618
7619    fn set_build_isolation(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7620        let isolation = match field.value().kind() {
7621            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
7622                Some(ProjectValue::new(scalar.value().to_owned(), field.value()))
7623            }
7624            MergedValueKind::Scalar(_) => {
7625                self.invalid(
7626                    effective_span(field.value()),
7627                    "build isolation must be a YAML string scalar",
7628                );
7629                None
7630            }
7631            _ => {
7632                self.expected(field.value(), "build isolation must be a YAML string scalar");
7633                None
7634            }
7635        };
7636        if let Some(isolation) = isolation {
7637            definition.isolation = Some(Box::new(isolation));
7638        } else {
7639            definition.push_unmodeled(field_reference(path, field));
7640        }
7641    }
7642
7643    fn set_build_pull(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7644        if let Some(pull) = self.boolean(field.value(), "build pull must be a boolean") {
7645            definition.cache_options().pull = Some(pull);
7646        } else {
7647            definition.push_unmodeled(field_reference(path, field));
7648        }
7649    }
7650
7651    fn set_build_no_cache(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7652        let value = match field.value().kind() {
7653            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::Boolean => {
7654                Some(BuildNoCache::Boolean(scalar.value().eq_ignore_ascii_case("true")))
7655            }
7656            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
7657                Some(BuildNoCache::String(scalar.value().to_owned()))
7658            }
7659            MergedValueKind::Scalar(_) => {
7660                self.invalid(
7661                    effective_span(field.value()),
7662                    "build no_cache must be a YAML boolean or string scalar",
7663                );
7664                None
7665            }
7666            _ => {
7667                self.expected(field.value(), "build no_cache must be a YAML boolean or string scalar");
7668                None
7669            }
7670        };
7671        if let Some(value) = value {
7672            definition.cache_options().no_cache = Some(ProjectValue::new(value, field.value()));
7673        } else {
7674            definition.push_unmodeled(field_reference(path, field));
7675        }
7676    }
7677    fn set_build_no_cache_filter(
7678        &mut self,
7679        definition: &mut ProjectBuildDefinition,
7680        field: &MergedEntry,
7681        path: &[String],
7682    ) {
7683        let value = match field.value().kind() {
7684            MergedValueKind::Scalar(s) if s.kind() == MergedScalarKind::String => Some(
7685                ProjectBuildNoCacheFilter::Scalar(ProjectValue::new(s.value().to_owned(), field.value())),
7686            ),
7687            MergedValueKind::Sequence(items) => {
7688                let mut values = Vec::new();
7689                let mut bad = false;
7690                for item in items {
7691                    if let MergedValueKind::Scalar(s) = item.kind() {
7692                        if s.kind() == MergedScalarKind::String {
7693                            if values
7694                                .iter()
7695                                .any(|value: &ProjectValue<String>| value.value() == s.value())
7696                            {
7697                                self.diagnostics.push(
7698                                    Diagnostic::new(
7699                                        BUILD_NO_CACHE_FILTER_DUPLICATE_ITEM,
7700                                        Severity::Warning,
7701                                        "build no_cache_filter retains duplicate stage",
7702                                    )
7703                                    .with_label(DiagnosticLabel::primary(effective_span(item), "duplicate retained")),
7704                                );
7705                            }
7706                            values.push(ProjectValue::new(s.value().to_owned(), item));
7707                            continue;
7708                        }
7709                    }
7710                    self.expected(item, "build no_cache_filter entries must be string scalars");
7711                    bad = true;
7712                }
7713                if bad {
7714                    definition.push_unmodeled(field_reference(path, field));
7715                }
7716                Some(ProjectBuildNoCacheFilter::List(values))
7717            }
7718            _ => {
7719                self.expected(
7720                    field.value(),
7721                    "build no_cache_filter must be a string scalar or sequence",
7722                );
7723                None
7724            }
7725        };
7726        if let Some(value) = value {
7727            definition.no_cache_filter = Some(Box::new(ProjectValue::new(value, field.value())));
7728        } else {
7729            definition.push_unmodeled(field_reference(path, field));
7730        }
7731    }
7732    fn set_build_privileged(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7733        if let Some(value) = self.boolean(field.value(), "build privileged must be a boolean") {
7734            definition.privileged = Some(Box::new(value));
7735        } else {
7736            definition.push_unmodeled(field_reference(path, field));
7737        }
7738    }
7739
7740    fn set_build_sbom(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7741        let value = match field.value().kind() {
7742            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::Boolean => {
7743                Some(BuildSbom::Boolean(scalar.value().eq_ignore_ascii_case("true")))
7744            }
7745            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
7746                Some(BuildSbom::String(scalar.value().to_owned()))
7747            }
7748            MergedValueKind::Scalar(_) => {
7749                self.invalid(
7750                    effective_span(field.value()),
7751                    "build sbom must be a YAML boolean or string scalar",
7752                );
7753                None
7754            }
7755            _ => {
7756                self.expected(field.value(), "build sbom must be a YAML boolean or string scalar");
7757                None
7758            }
7759        };
7760        if let Some(value) = value {
7761            definition.sbom = Some(Box::new(ProjectValue::new(value, field.value())));
7762        } else {
7763            definition.push_unmodeled(field_reference(path, field));
7764        }
7765    }
7766
7767    fn set_build_attestation(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7768        if field.key() == "sbom" {
7769            self.set_build_sbom(definition, field, path);
7770        } else {
7771            self.set_build_provenance(definition, field, path);
7772        }
7773    }
7774    fn set_build_provenance(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7775        let value = match field.value().kind() {
7776            MergedValueKind::Scalar(s) if s.kind() == MergedScalarKind::Boolean => {
7777                Some(BuildProvenance::Boolean(s.value().eq_ignore_ascii_case("true")))
7778            }
7779            MergedValueKind::Scalar(s) if s.kind() == MergedScalarKind::String => {
7780                Some(BuildProvenance::String(s.value().to_owned()))
7781            }
7782            MergedValueKind::Scalar(_) => {
7783                self.invalid(
7784                    effective_span(field.value()),
7785                    "build provenance must be a YAML boolean or string scalar",
7786                );
7787                None
7788            }
7789            _ => {
7790                self.expected(
7791                    field.value(),
7792                    "build provenance must be a YAML boolean or string scalar",
7793                );
7794                None
7795            }
7796        };
7797        if let Some(value) = value {
7798            definition.provenance = Some(Box::new(ProjectValue::new(value, field.value())));
7799        } else {
7800            definition.push_unmodeled(field_reference(path, field));
7801        }
7802    }
7803
7804    fn set_build_shm_size(&mut self, definition: &mut ProjectBuildDefinition, field: &MergedEntry, path: &[String]) {
7805        if let Some(shm_size) = self.shm_size(field.value()) {
7806            definition.shm_size = Some(Box::new(shm_size));
7807        } else {
7808            definition.push_unmodeled(field_reference(path, field));
7809        }
7810    }
7811
7812    fn build_tags(&mut self, value: &MergedValue) -> (Option<ProjectValue<Vec<ProjectValue<String>>>>, bool) {
7813        let Some(values) = value.as_sequence() else {
7814            self.expected(value, "build tags must be a sequence of non-null scalars");
7815            return (None, true);
7816        };
7817        let mut tags = Vec::new();
7818        let mut malformed = false;
7819        for item in values {
7820            let Some(scalar) = item.as_scalar() else {
7821                self.expected(item, "build tag entries must be non-null scalars");
7822                malformed = true;
7823                continue;
7824            };
7825            tags.push(ProjectValue::new(scalar.value().to_owned(), item));
7826        }
7827        (Some(ProjectValue::new(tags, value)), malformed)
7828    }
7829
7830    fn build_entitlements(&mut self, value: &MergedValue) -> (Option<ProjectValue<Vec<ProjectValue<String>>>>, bool) {
7831        let Some(values) = value.as_sequence() else {
7832            self.expected(value, "build entitlements must be a sequence of string scalars");
7833            return (None, true);
7834        };
7835        let mut entitlements = Vec::new();
7836        let mut malformed = false;
7837        for item in values {
7838            let MergedValueKind::Scalar(scalar) = item.kind() else {
7839                self.expected(item, "build entitlement entries must be string scalars");
7840                malformed = true;
7841                continue;
7842            };
7843            if scalar.kind() != MergedScalarKind::String {
7844                self.expected(item, "build entitlement entries must be string scalars");
7845                malformed = true;
7846                continue;
7847            }
7848            entitlements.push(ProjectValue::new(scalar.value().to_owned(), item));
7849        }
7850        (Some(ProjectValue::new(entitlements, value)), malformed)
7851    }
7852
7853    fn build_platforms(&mut self, value: &MergedValue) -> (Option<ProjectValue<Vec<ProjectValue<String>>>>, bool) {
7854        let Some(values) = value.as_sequence() else {
7855            self.expected(value, "build platforms must be a sequence of non-null scalars");
7856            return (None, true);
7857        };
7858        let mut platforms = Vec::new();
7859        let mut malformed = false;
7860        for item in values {
7861            let Some(scalar) = item.as_scalar() else {
7862                self.expected(item, "build platform entries must be non-null scalars");
7863                malformed = true;
7864                continue;
7865            };
7866            platforms.push(ProjectValue::new(scalar.value().to_owned(), item));
7867        }
7868        (Some(ProjectValue::new(platforms, value)), malformed)
7869    }
7870
7871    fn build_cache_locations(
7872        &mut self,
7873        value: &MergedValue,
7874        name: &str,
7875    ) -> (Option<ProjectValue<Vec<ProjectValue<String>>>>, bool) {
7876        let Some(values) = value.as_sequence() else {
7877            self.expected(value, &format!("build {name} must be a sequence of string scalars"));
7878            return (None, true);
7879        };
7880        let mut locations = Vec::new();
7881        let mut malformed = false;
7882        for item in values {
7883            let MergedValueKind::Scalar(scalar) = item.kind() else {
7884                self.expected(item, &format!("build {name} entries must be string scalars"));
7885                malformed = true;
7886                continue;
7887            };
7888            if scalar.kind() != MergedScalarKind::String {
7889                self.expected(item, &format!("build {name} entries must be string scalars"));
7890                malformed = true;
7891                continue;
7892            }
7893            locations.push(ProjectValue::new(scalar.value().to_owned(), item));
7894        }
7895        (Some(ProjectValue::new(locations, value)), malformed)
7896    }
7897
7898    fn build_args(&mut self, value: &MergedValue) -> (Option<ProjectValue<ProjectBuildArgs>>, bool) {
7899        match value.kind() {
7900            MergedValueKind::Mapping(entries) => {
7901                let mut args = Vec::new();
7902                let mut malformed = false;
7903                for entry in entries {
7904                    let Some(value) =
7905                        self.compose_scalar(entry.value(), "build argument value must be a scalar or null")
7906                    else {
7907                        malformed = true;
7908                        continue;
7909                    };
7910                    args.push(ProjectBuildArgEntry {
7911                        name: ProjectKey::from_entry(entry),
7912                        value: ProjectValue::new(value, entry.value()),
7913                    });
7914                }
7915                (Some(ProjectValue::new(ProjectBuildArgs::Map(args), value)), malformed)
7916            }
7917            MergedValueKind::Sequence(values) => {
7918                let mut args = Vec::new();
7919                let mut malformed = false;
7920                for item in values {
7921                    let MergedValueKind::Scalar(argument) = item.kind() else {
7922                        self.expected(item, "build argument list item must be a string scalar");
7923                        malformed = true;
7924                        continue;
7925                    };
7926                    if argument.kind() != MergedScalarKind::String {
7927                        self.expected(item, "build argument list item must be a string scalar");
7928                        malformed = true;
7929                        continue;
7930                    }
7931                    args.push(ProjectValue::new(argument.value().to_owned(), item));
7932                }
7933                (Some(ProjectValue::new(ProjectBuildArgs::List(args), value)), malformed)
7934            }
7935            _ => {
7936                self.expected(value, "build args must be a mapping or sequence");
7937                (None, true)
7938            }
7939        }
7940    }
7941
7942    fn build_additional_contexts(
7943        &mut self,
7944        value: &MergedValue,
7945    ) -> (Option<ProjectValue<ProjectBuildAdditionalContexts>>, bool) {
7946        match value.kind() {
7947            MergedValueKind::Mapping(entries) => {
7948                let mut contexts = Vec::new();
7949                let mut malformed = false;
7950                let mut names = BTreeSet::new();
7951                for entry in entries {
7952                    if !names.insert(entry.key().to_owned()) {
7953                        self.invalid(
7954                            entry_span(entry),
7955                            "build additional context mapping names must be unique",
7956                        );
7957                        malformed = true;
7958                        continue;
7959                    }
7960                    let Some(value) = self.compose_scalar(
7961                        entry.value(),
7962                        "build additional context mapping values must be scalars or null",
7963                    ) else {
7964                        malformed = true;
7965                        continue;
7966                    };
7967                    contexts.push(ProjectBuildAdditionalContextEntry {
7968                        name: ProjectKey::from_entry(entry),
7969                        value: ProjectValue::new(value, entry.value()),
7970                    });
7971                }
7972                (
7973                    Some(ProjectValue::new(ProjectBuildAdditionalContexts::Map(contexts), value)),
7974                    malformed,
7975                )
7976            }
7977            MergedValueKind::Sequence(values) => {
7978                let mut contexts = Vec::new();
7979                let mut malformed = false;
7980                for item in values {
7981                    let MergedValueKind::Scalar(context) = item.kind() else {
7982                        self.expected(item, "build additional context list items must be string scalars");
7983                        malformed = true;
7984                        continue;
7985                    };
7986                    if context.kind() != MergedScalarKind::String {
7987                        self.expected(item, "build additional context list items must be string scalars");
7988                        malformed = true;
7989                        continue;
7990                    }
7991                    contexts.push(ProjectValue::new(context.value().to_owned(), item));
7992                }
7993                (
7994                    Some(ProjectValue::new(ProjectBuildAdditionalContexts::List(contexts), value)),
7995                    malformed,
7996                )
7997            }
7998            _ => {
7999                self.expected(value, "build additional_contexts must be a mapping or sequence");
8000                (None, true)
8001            }
8002        }
8003    }
8004
8005    fn build_extra_hosts(
8006        &mut self,
8007        value: &MergedValue,
8008        path: &[String],
8009    ) -> (Option<ProjectValue<ProjectBuildExtraHosts>>, bool) {
8010        match value.kind() {
8011            MergedValueKind::Sequence(values) => {
8012                let mut entries = Vec::new();
8013                let mut malformed = false;
8014                for item in values {
8015                    let MergedValueKind::Scalar(scalar) = item.kind() else {
8016                        self.expected(item, "build extra_hosts list entries must be string scalars");
8017                        malformed = true;
8018                        continue;
8019                    };
8020                    if scalar.kind() != MergedScalarKind::String {
8021                        self.expected(item, "build extra_hosts list entries must be string scalars");
8022                        malformed = true;
8023                        continue;
8024                    }
8025                    entries.push(ProjectValue::new(scalar.value().to_owned(), item));
8026                }
8027                (
8028                    Some(ProjectValue::new(ProjectBuildExtraHosts::List(entries), value)),
8029                    malformed,
8030                )
8031            }
8032            MergedValueKind::Mapping(entries) => {
8033                let mut hosts = Vec::new();
8034                let mut malformed = false;
8035                for entry in entries {
8036                    let mut entry_path = path.to_vec();
8037                    entry_path.push("extra_hosts".to_owned());
8038                    entry_path.push(entry.key().to_owned());
8039                    let (addresses, invalid_addresses) = self.build_extra_host_addresses(entry.value(), &entry_path);
8040                    malformed |= invalid_addresses;
8041                    let Some(addresses) = addresses else {
8042                        continue;
8043                    };
8044                    hosts.push(ProjectBuildExtraHostEntry {
8045                        hostname: ProjectKey::from_entry(entry),
8046                        addresses,
8047                    });
8048                }
8049                (
8050                    Some(ProjectValue::new(ProjectBuildExtraHosts::Map(hosts), value)),
8051                    malformed,
8052                )
8053            }
8054            _ => {
8055                self.expected(value, "build extra_hosts must be a mapping or sequence");
8056                (None, true)
8057            }
8058        }
8059    }
8060
8061    fn build_extra_host_addresses(
8062        &mut self,
8063        value: &MergedValue,
8064        path: &[String],
8065    ) -> (Option<ProjectBuildExtraHostAddresses>, bool) {
8066        match value.kind() {
8067            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => (
8068                Some(ProjectBuildExtraHostAddresses::Scalar(ProjectValue::new(
8069                    scalar.value().to_owned(),
8070                    value,
8071                ))),
8072                false,
8073            ),
8074            MergedValueKind::Sequence(values) => {
8075                let mut addresses = Vec::new();
8076                let mut malformed = false;
8077                for (index, item) in values.iter().enumerate() {
8078                    let MergedValueKind::Scalar(scalar) = item.kind() else {
8079                        self.expected(item, "build extra_hosts address-list entries must be string scalars");
8080                        let mut item_path = path.to_vec();
8081                        item_path.push(index.to_string());
8082                        self.pending_unmodeled.push(value_reference(&item_path, item));
8083                        malformed = true;
8084                        continue;
8085                    };
8086                    if scalar.kind() != MergedScalarKind::String {
8087                        self.expected(item, "build extra_hosts address-list entries must be string scalars");
8088                        let mut item_path = path.to_vec();
8089                        item_path.push(index.to_string());
8090                        self.pending_unmodeled.push(value_reference(&item_path, item));
8091                        malformed = true;
8092                        continue;
8093                    }
8094                    addresses.push(ProjectValue::new(scalar.value().to_owned(), item));
8095                }
8096                (Some(ProjectBuildExtraHostAddresses::List(addresses)), malformed)
8097            }
8098            _ => {
8099                self.expected(
8100                    value,
8101                    "build extra_hosts mapping addresses must be string scalars or sequences of string scalars",
8102                );
8103                self.pending_unmodeled.push(value_reference(path, value));
8104                (None, true)
8105            }
8106        }
8107    }
8108
8109    fn build_ssh(&mut self, value: &MergedValue) -> (Option<ProjectValue<ProjectBuildSsh>>, bool) {
8110        match value.kind() {
8111            MergedValueKind::Mapping(entries) => {
8112                let mut ssh = Vec::new();
8113                let mut malformed = false;
8114                for entry in entries {
8115                    let Some(value) =
8116                        self.compose_scalar(entry.value(), "build ssh mapping values must be scalars or null")
8117                    else {
8118                        malformed = true;
8119                        continue;
8120                    };
8121                    ssh.push(ProjectBuildSshEntry {
8122                        name: ProjectKey::from_sensitive_entry(entry),
8123                        value: ProjectValue::new_sensitive(value, entry.value()),
8124                    });
8125                }
8126                (
8127                    Some(ProjectValue::new_sensitive(ProjectBuildSsh::Map(ssh), value)),
8128                    malformed,
8129                )
8130            }
8131            MergedValueKind::Sequence(values) => {
8132                let mut ssh = Vec::new();
8133                let mut malformed = false;
8134                for item in values {
8135                    let MergedValueKind::Scalar(scalar) = item.kind() else {
8136                        self.expected(item, "build ssh list entries must be string scalars");
8137                        malformed = true;
8138                        continue;
8139                    };
8140                    if scalar.kind() != MergedScalarKind::String {
8141                        self.expected(item, "build ssh list entries must be string scalars");
8142                        malformed = true;
8143                        continue;
8144                    }
8145                    ssh.push(ProjectValue::new_sensitive(scalar.value().to_owned(), item));
8146                }
8147                (
8148                    Some(ProjectValue::new_sensitive(ProjectBuildSsh::List(ssh), value)),
8149                    malformed,
8150                )
8151            }
8152            _ => {
8153                self.expected(value, "build ssh must be a mapping or sequence");
8154                (None, true)
8155            }
8156        }
8157    }
8158
8159    fn build_labels(&mut self, value: &MergedValue) -> (Option<ProjectValue<ProjectBuildLabels>>, bool) {
8160        match value.kind() {
8161            MergedValueKind::Mapping(entries) => {
8162                let mut labels = Vec::new();
8163                let mut malformed = false;
8164                for entry in entries {
8165                    let Some(value) = self.compose_scalar(entry.value(), "build label value must be a scalar or null")
8166                    else {
8167                        malformed = true;
8168                        continue;
8169                    };
8170                    labels.push(ProjectLabelEntry {
8171                        name: ProjectKey::from_entry(entry),
8172                        value: ProjectValue::new(value, entry.value()),
8173                        syntax: entry.syntax(),
8174                    });
8175                }
8176                (
8177                    Some(ProjectValue::new(ProjectBuildLabels::Map(labels), value)),
8178                    malformed,
8179                )
8180            }
8181            MergedValueKind::Sequence(values) => {
8182                let mut labels = Vec::new();
8183                let mut malformed = false;
8184                for item in values {
8185                    let Some(label) = self.located_string(item, "build label list item must be a scalar") else {
8186                        malformed = true;
8187                        continue;
8188                    };
8189                    labels.push(ProjectValue::new(label.value().to_owned(), item));
8190                }
8191                (
8192                    Some(ProjectValue::new(ProjectBuildLabels::List(labels), value)),
8193                    malformed,
8194                )
8195            }
8196            _ => {
8197                self.expected(value, "build labels must be a mapping or sequence");
8198                (None, true)
8199            }
8200        }
8201    }
8202
8203    fn cpu_count(&mut self, value: &MergedValue) -> Option<ProjectValue<CpuCount>> {
8204        let MergedValueKind::Scalar(scalar) = value.kind() else {
8205            self.expected(value, "cpu_count must be a YAML integer or string scalar");
8206            return None;
8207        };
8208        let count = match scalar.kind() {
8209            MergedScalarKind::Number if CpuCount::yaml_integer_spelling(scalar.raw()) => {
8210                CpuCount::yaml_integer(scalar.raw().to_owned())
8211            }
8212            MergedScalarKind::String
8213                if scalar.is_strict_yaml_string()
8214                    && scalar.is_plain_style()
8215                    && CpuCount::yaml_integer_spelling(scalar.raw()) =>
8216            {
8217                CpuCount::yaml_integer(scalar.raw().to_owned())
8218            }
8219            MergedScalarKind::String if scalar.is_strict_yaml_string() => CpuCount::String(scalar.value().to_owned()),
8220            MergedScalarKind::String | MergedScalarKind::Number | MergedScalarKind::Boolean => {
8221                self.expected(value, "cpu_count must be a YAML integer or string scalar");
8222                return None;
8223            }
8224        };
8225        if !count.is_valid() {
8226            self.diagnostics.push(
8227                Diagnostic::new(
8228                    CPU_COUNT_NEGATIVE,
8229                    Severity::Error,
8230                    "cpu_count YAML integers must be nonnegative",
8231                )
8232                .with_label(DiagnosticLabel::primary(
8233                    effective_span(value),
8234                    "negative CPU count retained as invalid evidence",
8235                )),
8236            );
8237        }
8238        Some(ProjectValue::new(count, value))
8239    }
8240
8241    fn cpu_percent(&mut self, value: &MergedValue) -> Option<ProjectValue<CpuPercent>> {
8242        let MergedValueKind::Scalar(scalar) = value.kind() else {
8243            self.expected(value, "cpu_percent must be a YAML integer or string scalar");
8244            return None;
8245        };
8246        let percent = match scalar.kind() {
8247            MergedScalarKind::Number if CpuPercent::yaml_integer_spelling(scalar.raw()) => {
8248                CpuPercent::yaml_integer(scalar.raw().to_owned())
8249            }
8250            MergedScalarKind::String
8251                if scalar.is_strict_yaml_string()
8252                    && scalar.is_plain_style()
8253                    && CpuPercent::yaml_integer_spelling(scalar.raw()) =>
8254            {
8255                CpuPercent::yaml_integer(scalar.raw().to_owned())
8256            }
8257            MergedScalarKind::String if scalar.is_strict_yaml_string() => CpuPercent::String(scalar.value().to_owned()),
8258            MergedScalarKind::String | MergedScalarKind::Number | MergedScalarKind::Boolean => {
8259                self.expected(value, "cpu_percent must be a YAML integer or string scalar");
8260                return None;
8261            }
8262        };
8263        if !percent.is_valid() {
8264            self.diagnostics.push(
8265                Diagnostic::new(
8266                    CPU_PERCENT_OUT_OF_RANGE,
8267                    Severity::Error,
8268                    "cpu_percent YAML integers must be between 0 and 100 inclusive",
8269                )
8270                .with_label(DiagnosticLabel::primary(
8271                    effective_span(value),
8272                    "out-of-range CPU percentage retained as invalid evidence",
8273                )),
8274            );
8275        }
8276        Some(ProjectValue::new(percent, value))
8277    }
8278
8279    fn cpu_period(&mut self, value: &MergedValue) -> Option<ProjectValue<CpuPeriod>> {
8280        let MergedValueKind::Scalar(scalar) = value.kind() else {
8281            self.expected(value, "cpu_period must be a YAML number or string scalar");
8282            return None;
8283        };
8284        let period = match scalar.kind() {
8285            MergedScalarKind::Number => CpuPeriod::YamlNumber(scalar.raw().to_owned()),
8286            MergedScalarKind::String
8287                if scalar.is_strict_yaml_string()
8288                    && scalar.is_plain_style()
8289                    && CpuPeriod::yaml_number_spelling(scalar.raw()) =>
8290            {
8291                CpuPeriod::YamlNumber(scalar.raw().to_owned())
8292            }
8293            MergedScalarKind::String if scalar.is_strict_yaml_string() => CpuPeriod::String(scalar.value().to_owned()),
8294            MergedScalarKind::String | MergedScalarKind::Boolean => {
8295                self.expected(value, "cpu_period must be a YAML number or string scalar");
8296                return None;
8297            }
8298        };
8299        Some(ProjectValue::new(period, value))
8300    }
8301
8302    fn cpu_quota(&mut self, value: &MergedValue) -> Option<ProjectValue<CpuQuota>> {
8303        let MergedValueKind::Scalar(scalar) = value.kind() else {
8304            self.expected(value, "cpu_quota must be a YAML number or string scalar");
8305            return None;
8306        };
8307        let quota = match scalar.kind() {
8308            MergedScalarKind::Number => CpuQuota::YamlNumber(scalar.raw().to_owned()),
8309            MergedScalarKind::String
8310                if scalar.is_strict_yaml_string()
8311                    && scalar.is_plain_style()
8312                    && CpuPeriod::yaml_number_spelling(scalar.raw()) =>
8313            {
8314                CpuQuota::YamlNumber(scalar.raw().to_owned())
8315            }
8316            MergedScalarKind::String if scalar.is_strict_yaml_string() => CpuQuota::String(scalar.value().to_owned()),
8317            MergedScalarKind::String | MergedScalarKind::Boolean => {
8318                self.expected(value, "cpu_quota must be a YAML number or string scalar");
8319                return None;
8320            }
8321        };
8322        Some(ProjectValue::new(quota, value))
8323    }
8324
8325    fn cpu_rt_period(&mut self, value: &MergedValue) -> Option<ProjectValue<CpuRtPeriod>> {
8326        let MergedValueKind::Scalar(scalar) = value.kind() else {
8327            self.expected(value, "cpu_rt_period must be a YAML number or string scalar");
8328            return None;
8329        };
8330        let period = match scalar.kind() {
8331            MergedScalarKind::Number => CpuRtPeriod::YamlNumber(scalar.raw().to_owned()),
8332            MergedScalarKind::String
8333                if scalar.is_strict_yaml_string()
8334                    && scalar.is_plain_style()
8335                    && CpuPeriod::yaml_number_spelling(scalar.raw()) =>
8336            {
8337                CpuRtPeriod::YamlNumber(scalar.raw().to_owned())
8338            }
8339            MergedScalarKind::String if scalar.is_strict_yaml_string() => {
8340                CpuRtPeriod::parse_string(scalar.value().to_owned())
8341            }
8342            MergedScalarKind::String | MergedScalarKind::Boolean => {
8343                self.expected(value, "cpu_rt_period must be a YAML number or string scalar");
8344                return None;
8345            }
8346        };
8347        if !period.is_valid() {
8348            self.invalid(
8349                effective_span(value),
8350                "cpu_rt_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
8351            );
8352        }
8353        Some(ProjectValue::new(period, value))
8354    }
8355
8356    fn cpu_rt_runtime(&mut self, value: &MergedValue) -> Option<ProjectValue<CpuRtRuntime>> {
8357        let MergedValueKind::Scalar(scalar) = value.kind() else {
8358            self.expected(
8359                value,
8360                "cpu_rt_runtime must be an integer microsecond or duration string scalar",
8361            );
8362            return None;
8363        };
8364        let runtime = match scalar.kind() {
8365            MergedScalarKind::Number if scalar.raw().bytes().all(|byte| byte.is_ascii_digit()) => {
8366                CpuRtRuntime::Microseconds(scalar.raw().to_owned())
8367            }
8368            MergedScalarKind::Number => CpuRtRuntime::Other(scalar.raw().to_owned()),
8369            MergedScalarKind::String if scalar.is_strict_yaml_string() => {
8370                CpuRtRuntime::parse_string(scalar.value().to_owned())
8371            }
8372            MergedScalarKind::String | MergedScalarKind::Boolean => {
8373                self.expected(
8374                    value,
8375                    "cpu_rt_runtime must be an integer microsecond or duration string scalar",
8376                );
8377                return None;
8378            }
8379        };
8380        if !runtime.is_valid() {
8381            self.diagnostics.push(
8382                Diagnostic::new(
8383                    CPU_RT_RUNTIME_INVALID,
8384                    Severity::Error,
8385                    "cpu_rt_runtime must be an integer microsecond value, Compose duration, or interpolation expression",
8386                )
8387                .with_label(DiagnosticLabel::primary(
8388                    effective_span(value),
8389                    "invalid service real-time CPU runtime",
8390                )),
8391            );
8392        }
8393        Some(ProjectValue::new(runtime, value))
8394    }
8395
8396    fn service_integer(
8397        &mut self,
8398        value: &MergedValue,
8399        min: i128,
8400        max: i128,
8401        description: &str,
8402    ) -> Option<ProjectValue<ServiceInteger>> {
8403        let MergedValueKind::Scalar(scalar) = value.kind() else {
8404            self.expected(value, &format!("{description} must be an integer scalar"));
8405            return None;
8406        };
8407        if matches!(scalar.kind(), MergedScalarKind::Boolean) {
8408            self.expected(value, &format!("{description} must be an integer scalar"));
8409            return None;
8410        }
8411        let parsed = ServiceInteger::parse(scalar.value().to_owned(), min, max);
8412        if !parsed.is_valid() {
8413            let _ = description;
8414            self.invalid(effective_span(value), "service integer must be in its documented range");
8415        }
8416        Some(ProjectValue::new(parsed, value))
8417    }
8418
8419    fn cpus(&mut self, value: &MergedValue) -> Option<ProjectValue<Cpus>> {
8420        let MergedValueKind::Scalar(scalar) = value.kind() else {
8421            self.expected(value, "cpus must be a decimal scalar");
8422            return None;
8423        };
8424        if matches!(scalar.kind(), MergedScalarKind::Boolean) {
8425            self.expected(value, "cpus must be a decimal scalar");
8426            return None;
8427        }
8428        let parsed = Cpus::parse(scalar.value().to_owned());
8429        if !parsed.is_valid() {
8430            self.invalid(
8431                effective_span(value),
8432                "cpus must be a decimal allocation or interpolation expression",
8433            );
8434        }
8435        Some(ProjectValue::new(parsed, value))
8436    }
8437
8438    fn volumes_from(
8439        &mut self,
8440        value: &MergedValue,
8441    ) -> (ProjectVolumesFromCollection, ProjectInvalidServiceStringItems) {
8442        let MergedValueKind::Sequence(items) = value.kind() else {
8443            self.expected(value, "volumes_from must be a sequence");
8444            return (None, Vec::new());
8445        };
8446        let mut result = Vec::new();
8447        let mut invalid = Vec::new();
8448        for item in items {
8449            let MergedValueKind::Scalar(scalar) = item.kind() else {
8450                self.expected(item, "volumes_from entries must be string scalars");
8451                invalid.push(ProjectInvalidServiceStringItem {
8452                    span: effective_span(item),
8453                });
8454                continue;
8455            };
8456            if !matches!(scalar.kind(), MergedScalarKind::String) {
8457                self.expected(item, "volumes_from entries must be string scalars");
8458                invalid.push(ProjectInvalidServiceStringItem {
8459                    span: effective_span(item),
8460                });
8461                continue;
8462            }
8463            let raw = Located::new(scalar.value().to_owned(), effective_span(item));
8464            result.push(ProjectValue::new(VolumesFrom::parse(raw), item));
8465        }
8466        (Some(ProjectValue::new(result, value)), invalid)
8467    }
8468
8469    fn pids_limit(&mut self, value: &MergedValue) -> Option<ProjectValue<PidsLimit>> {
8470        let scalar = match value.kind() {
8471            MergedValueKind::Scalar(scalar) if scalar.kind() != MergedScalarKind::Boolean => scalar,
8472            _ => {
8473                self.expected(value, "pids_limit must be a number or string scalar");
8474                return None;
8475            }
8476        };
8477        let limit = PidsLimit::parse(Located::new(scalar.value().to_owned(), effective_span(value)));
8478        match limit.kind() {
8479            PidsLimitKind::Zero => self.diagnostics.push(
8480                Diagnostic::new(
8481                    PIDS_LIMIT_AMBIGUOUS_ZERO,
8482                    Severity::Warning,
8483                    "pids_limit zero is preserved as an ambiguous and unportable native state",
8484                )
8485                .with_label(DiagnosticLabel::primary(
8486                    effective_span(value),
8487                    "ambiguous zero PID limit",
8488                )),
8489            ),
8490            PidsLimitKind::Other => self.invalid(
8491                effective_span(value),
8492                "pids_limit must be `-1`, a positive integral decimal, or interpolation",
8493            ),
8494            _ => {}
8495        }
8496        Some(ProjectValue::new(limit, value))
8497    }
8498
8499    fn shm_size(&mut self, value: &MergedValue) -> Option<ProjectValue<ShmSize>> {
8500        let scalar = match value.kind() {
8501            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::Number => {
8502                (scalar, ShmSizeScalarKind::Number)
8503            }
8504            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
8505                (scalar, ShmSizeScalarKind::String)
8506            }
8507            _ => {
8508                self.diagnostics.push(
8509                    Diagnostic::new(
8510                        SHM_SIZE_EXPECTED_VALUE,
8511                        Severity::Error,
8512                        "shm_size must be a YAML number or string scalar",
8513                    )
8514                    .with_label(DiagnosticLabel::primary(
8515                        effective_span(value),
8516                        "unexpected shared-memory-size form",
8517                    )),
8518                );
8519                return None;
8520            }
8521        };
8522        let size = ShmSize::parse(
8523            Located::new(scalar.0.value().to_owned(), effective_span(value)),
8524            scalar.1,
8525        );
8526        let (code, message, label, note) = match size.kind() {
8527            ShmSizeKind::Zero { .. } => (
8528                SHM_SIZE_AMBIGUOUS_ZERO,
8529                "shm_size zero is preserved because Compose does not define its semantics",
8530                "ambiguous zero shared-memory size",
8531                "choose a positive size with an explicit documented lowercase unit",
8532            ),
8533            ShmSizeKind::ProviderDependentNumber => (
8534                SHM_SIZE_PROVIDER_DEPENDENT_NUMBER,
8535                "numeric shm_size is schema-accepted but lacks a documented explicit unit",
8536                "provider-dependent numeric shared-memory size",
8537                "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for portable intent",
8538            ),
8539            ShmSizeKind::ProviderDependentString => (
8540                SHM_SIZE_PROVIDER_DEPENDENT_STRING,
8541                "string shm_size is schema-accepted but falls outside the documented lowercase suffix family",
8542                "provider-dependent string shared-memory size",
8543                "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
8544            ),
8545            ShmSizeKind::Documented { .. } | ShmSizeKind::Expression => {
8546                return Some(ProjectValue::new(size, value));
8547            }
8548        };
8549        self.diagnostics.push(
8550            Diagnostic::new(code, Severity::Warning, message)
8551                .with_label(DiagnosticLabel::primary(effective_span(value), label))
8552                .with_note(note),
8553        );
8554        Some(ProjectValue::new(size, value))
8555    }
8556
8557    fn mem_limit(&mut self, value: &MergedValue) -> Option<ProjectValue<MemLimit>> {
8558        let scalar = match value.kind() {
8559            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::Number => {
8560                (scalar, MemLimitScalarKind::Number)
8561            }
8562            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
8563                (scalar, MemLimitScalarKind::String)
8564            }
8565            _ => {
8566                self.diagnostics.push(
8567                    Diagnostic::new(
8568                        MEM_LIMIT_EXPECTED_VALUE,
8569                        Severity::Error,
8570                        "mem_limit must be a YAML number or string scalar",
8571                    )
8572                    .with_label(DiagnosticLabel::primary(
8573                        effective_span(value),
8574                        "unexpected memory-limit form",
8575                    )),
8576                );
8577                return None;
8578            }
8579        };
8580        let limit = MemLimit::parse(
8581            Located::new(scalar.0.value().to_owned(), effective_span(value)),
8582            scalar.1,
8583        );
8584        let (code, message, label, note) = match limit.kind() {
8585            MemLimitKind::Zero { .. } => (
8586                MEM_LIMIT_AMBIGUOUS_ZERO,
8587                "mem_limit zero is preserved without inferring portable runtime behavior",
8588                "ambiguous zero memory limit",
8589                "choose a positive size with an explicit documented lowercase unit",
8590            ),
8591            MemLimitKind::SchemaNumber => (
8592                MEM_LIMIT_SCHEMA_NUMBER,
8593                "numeric mem_limit is schema-accepted but lacks a documented explicit unit",
8594                "schema-only numeric memory limit",
8595                "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for explicit intent",
8596            ),
8597            MemLimitKind::ProviderDependentString => (
8598                MEM_LIMIT_PROVIDER_DEPENDENT_STRING,
8599                "string mem_limit is schema-accepted but falls outside the documented lowercase suffix family",
8600                "provider-dependent string memory limit",
8601                "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
8602            ),
8603            MemLimitKind::Documented { .. } | MemLimitKind::Expression => {
8604                return Some(ProjectValue::new(limit, value));
8605            }
8606        };
8607        self.diagnostics.push(
8608            Diagnostic::new(code, Severity::Warning, message)
8609                .with_label(DiagnosticLabel::primary(effective_span(value), label))
8610                .with_note(note),
8611        );
8612        Some(ProjectValue::new(limit, value))
8613    }
8614
8615    fn memswap_limit(&mut self, value: &MergedValue) -> Option<ProjectValue<MemswapLimit>> {
8616        let scalar = match value.kind() {
8617            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::Number => {
8618                (scalar, MemswapLimitScalarKind::Number)
8619            }
8620            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
8621                (scalar, MemswapLimitScalarKind::String)
8622            }
8623            _ => {
8624                self.diagnostics.push(
8625                    Diagnostic::new(
8626                        MEMSWAP_LIMIT_EXPECTED_VALUE,
8627                        Severity::Error,
8628                        "memswap_limit must be a YAML number or string scalar",
8629                    )
8630                    .with_label(DiagnosticLabel::primary(
8631                        effective_span(value),
8632                        "unexpected memory-plus-swap-limit form",
8633                    )),
8634                );
8635                return None;
8636            }
8637        };
8638        let limit = MemswapLimit::parse(
8639            Located::new(scalar.0.value().to_owned(), effective_span(value)),
8640            scalar.1,
8641        );
8642        if matches!(limit.kind(), MemswapLimitKind::Other(_)) {
8643            self.diagnostics.push(
8644                Diagnostic::new(
8645                    MEMSWAP_LIMIT_INVALID,
8646                    Severity::Error,
8647                    "memswap_limit must be `-1`, a decimal byte quantity, or an interpolation expression",
8648                )
8649                .with_label(DiagnosticLabel::primary(
8650                    effective_span(value),
8651                    "invalid memory-plus-swap limit retained",
8652                )),
8653            );
8654        }
8655        Some(ProjectValue::new(limit, value))
8656    }
8657
8658    fn dns(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectDns>> {
8659        let form = match value.kind() {
8660            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
8661                ProjectDns::Scalar(ProjectValue::new(scalar.value().to_owned(), value))
8662            }
8663            MergedValueKind::Sequence(values) => {
8664                let mut items = Vec::new();
8665                for item_value in values {
8666                    let MergedValueKind::Scalar(scalar) = item_value.kind() else {
8667                        self.diagnostics.push(
8668                            Diagnostic::new(
8669                                DNS_EXPECTED_STRING,
8670                                Severity::Error,
8671                                "dns entries must be string scalars",
8672                            )
8673                            .with_label(DiagnosticLabel::primary(
8674                                effective_span(item_value),
8675                                "unexpected DNS server list item",
8676                            )),
8677                        );
8678                        continue;
8679                    };
8680                    if scalar.kind() != MergedScalarKind::String {
8681                        self.diagnostics.push(
8682                            Diagnostic::new(
8683                                DNS_EXPECTED_STRING,
8684                                Severity::Error,
8685                                "dns entries must be string scalars",
8686                            )
8687                            .with_label(DiagnosticLabel::primary(
8688                                effective_span(item_value),
8689                                "unexpected DNS server list item",
8690                            )),
8691                        );
8692                        continue;
8693                    }
8694                    items.push(ProjectValue::new(scalar.value().to_owned(), item_value));
8695                }
8696                ProjectDns::List(items)
8697            }
8698            _ => {
8699                self.diagnostics.push(
8700                    Diagnostic::new(
8701                        DNS_EXPECTED_FORM,
8702                        Severity::Error,
8703                        "dns must be a string scalar or a sequence of string scalars",
8704                    )
8705                    .with_label(DiagnosticLabel::primary(
8706                        effective_span(value),
8707                        "unexpected service DNS form",
8708                    )),
8709                );
8710                return None;
8711            }
8712        };
8713        Some(ProjectValue::new(form, value))
8714    }
8715
8716    fn dns_options(&mut self, value: &MergedValue) -> Option<ProjectValue<Vec<ProjectValue<String>>>> {
8717        let MergedValueKind::Sequence(values) = value.kind() else {
8718            self.diagnostics.push(
8719                Diagnostic::new(
8720                    DNS_OPT_EXPECTED_SEQUENCE,
8721                    Severity::Error,
8722                    "dns_opt must be a sequence of string scalars",
8723                )
8724                .with_label(DiagnosticLabel::primary(
8725                    effective_span(value),
8726                    "unexpected service DNS option form",
8727                )),
8728            );
8729            return None;
8730        };
8731        let mut items = Vec::new();
8732        let mut seen = BTreeSet::new();
8733        for item_value in values {
8734            let MergedValueKind::Scalar(scalar) = item_value.kind() else {
8735                self.diagnostics.push(
8736                    Diagnostic::new(
8737                        DNS_OPT_EXPECTED_STRING,
8738                        Severity::Error,
8739                        "dns_opt entries must be string scalars",
8740                    )
8741                    .with_label(DiagnosticLabel::primary(
8742                        effective_span(item_value),
8743                        "unexpected DNS option list item",
8744                    )),
8745                );
8746                continue;
8747            };
8748            if scalar.kind() != MergedScalarKind::String {
8749                self.diagnostics.push(
8750                    Diagnostic::new(
8751                        DNS_OPT_EXPECTED_STRING,
8752                        Severity::Error,
8753                        "dns_opt entries must be string scalars",
8754                    )
8755                    .with_label(DiagnosticLabel::primary(
8756                        effective_span(item_value),
8757                        "unexpected DNS option list item",
8758                    )),
8759                );
8760                continue;
8761            }
8762            if !seen.insert(scalar.value().to_owned()) {
8763                self.diagnostics.push(
8764                    Diagnostic::new(
8765                        DNS_OPT_DUPLICATE_ITEM,
8766                        Severity::Warning,
8767                        "dns_opt entries must be unique exact strings",
8768                    )
8769                    .with_label(DiagnosticLabel::primary(
8770                        effective_span(item_value),
8771                        "duplicate DNS option retained",
8772                    )),
8773                );
8774            }
8775            items.push(ProjectValue::new(scalar.value().to_owned(), item_value));
8776        }
8777        Some(ProjectValue::new(items, value))
8778    }
8779
8780    fn dns_search(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectDnsSearch>> {
8781        let form = match value.kind() {
8782            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
8783                ProjectDnsSearch::Scalar(ProjectValue::new(scalar.value().to_owned(), value))
8784            }
8785            MergedValueKind::Sequence(values) => {
8786                let mut items = Vec::new();
8787                let mut seen = BTreeSet::new();
8788                for item_value in values {
8789                    let MergedValueKind::Scalar(scalar) = item_value.kind() else {
8790                        self.diagnostics.push(
8791                            Diagnostic::new(
8792                                DNS_SEARCH_EXPECTED_STRING,
8793                                Severity::Error,
8794                                "dns_search entries must be string scalars",
8795                            )
8796                            .with_label(DiagnosticLabel::primary(
8797                                effective_span(item_value),
8798                                "unexpected DNS search-domain list item",
8799                            )),
8800                        );
8801                        continue;
8802                    };
8803                    if scalar.kind() != MergedScalarKind::String {
8804                        self.diagnostics.push(
8805                            Diagnostic::new(
8806                                DNS_SEARCH_EXPECTED_STRING,
8807                                Severity::Error,
8808                                "dns_search entries must be string scalars",
8809                            )
8810                            .with_label(DiagnosticLabel::primary(
8811                                effective_span(item_value),
8812                                "unexpected DNS search-domain list item",
8813                            )),
8814                        );
8815                        continue;
8816                    }
8817                    if !seen.insert(scalar.value().to_owned()) {
8818                        self.diagnostics.push(
8819                            Diagnostic::new(
8820                                DNS_SEARCH_DUPLICATE_ITEM,
8821                                Severity::Warning,
8822                                "dns_search schema entries are unique, but duplicate merge behavior is ambiguous",
8823                            )
8824                            .with_label(DiagnosticLabel::primary(
8825                                effective_span(item_value),
8826                                "duplicate DNS search domain retained",
8827                            )),
8828                        );
8829                    }
8830                    items.push(ProjectValue::new(scalar.value().to_owned(), item_value));
8831                }
8832                ProjectDnsSearch::List(items)
8833            }
8834            _ => {
8835                self.diagnostics.push(
8836                    Diagnostic::new(
8837                        DNS_SEARCH_EXPECTED_FORM,
8838                        Severity::Error,
8839                        "dns_search must be a string scalar or a sequence of string scalars",
8840                    )
8841                    .with_label(DiagnosticLabel::primary(
8842                        effective_span(value),
8843                        "unexpected service DNS search-domain form",
8844                    )),
8845                );
8846                return None;
8847            }
8848        };
8849        Some(ProjectValue::new(form, value))
8850    }
8851
8852    fn expose(&mut self, value: &MergedValue) -> Option<ProjectValue<Vec<ProjectValue<ProjectExposeItem>>>> {
8853        let MergedValueKind::Sequence(values) = value.kind() else {
8854            self.diagnostics.push(
8855                Diagnostic::new(
8856                    EXPOSE_EXPECTED_SEQUENCE,
8857                    Severity::Error,
8858                    "expose must be a sequence of string or number scalars",
8859                )
8860                .with_label(DiagnosticLabel::primary(
8861                    effective_span(value),
8862                    "unexpected service expose form",
8863                )),
8864            );
8865            return None;
8866        };
8867        let mut items = Vec::new();
8868        let mut seen = Vec::new();
8869        for item_value in values {
8870            let MergedValueKind::Scalar(scalar) = item_value.kind() else {
8871                self.diagnostics.push(
8872                    Diagnostic::new(
8873                        EXPOSE_EXPECTED_SCALAR,
8874                        Severity::Error,
8875                        "expose entries must be string or number scalars",
8876                    )
8877                    .with_label(DiagnosticLabel::primary(
8878                        effective_span(item_value),
8879                        "unexpected exposed-port item",
8880                    )),
8881                );
8882                continue;
8883            };
8884            let scalar_kind = match scalar.kind() {
8885                MergedScalarKind::String => ExposeScalarKind::String,
8886                MergedScalarKind::Number => ExposeScalarKind::Number,
8887                MergedScalarKind::Boolean => {
8888                    self.diagnostics.push(
8889                        Diagnostic::new(
8890                            EXPOSE_EXPECTED_SCALAR,
8891                            Severity::Error,
8892                            "expose entries must be string or number scalars",
8893                        )
8894                        .with_label(DiagnosticLabel::primary(
8895                            effective_span(item_value),
8896                            "unexpected exposed-port item",
8897                        )),
8898                    );
8899                    continue;
8900                }
8901            };
8902            if seen.contains(&(scalar_kind, scalar.value().to_owned())) {
8903                self.diagnostics.push(
8904                    Diagnostic::new(
8905                        EXPOSE_DUPLICATE_ITEM,
8906                        Severity::Warning,
8907                        "expose entries must be unique by exact scalar identity",
8908                    )
8909                    .with_label(DiagnosticLabel::primary(
8910                        effective_span(item_value),
8911                        "duplicate exposed-port item retained",
8912                    )),
8913                );
8914            } else {
8915                seen.push((scalar_kind, scalar.value().to_owned()));
8916            }
8917            let kind = classify_expose_item(scalar.value(), scalar_kind);
8918            self.diagnose_expose_item(&kind, item_value);
8919            items.push(ProjectValue::new(
8920                ProjectExposeItem {
8921                    authored: scalar.raw().to_owned(),
8922                    value: scalar.value().to_owned(),
8923                    scalar_kind,
8924                    kind,
8925                },
8926                item_value,
8927            ));
8928        }
8929        Some(ProjectValue::new(items, value))
8930    }
8931
8932    fn diagnose_expose_item(&mut self, kind: &ExposeItemKind, value: &MergedValue) {
8933        let (code, severity, message, label) = match kind {
8934            ExposeItemKind::Documented { .. } | ExposeItemKind::Expression => return,
8935            ExposeItemKind::Sctp { .. } | ExposeItemKind::UnknownProtocol { .. } => (
8936                EXPOSE_PROVIDER_DEPENDENT,
8937                Severity::Warning,
8938                "expose protocol is outside the documented portable `tcp` and `udp` set",
8939                "provider-dependent exposed-port protocol retained",
8940            ),
8941            ExposeItemKind::Malformed => (
8942                EXPOSE_INVALID_ITEM,
8943                Severity::Error,
8944                "expose item must be a decimal port or range with an optional protocol",
8945                "malformed exposed-port item retained",
8946            ),
8947        };
8948        self.diagnostics.push(
8949            Diagnostic::new(code, severity, message).with_label(DiagnosticLabel::primary(effective_span(value), label)),
8950        );
8951    }
8952
8953    fn security_options(
8954        &mut self,
8955        value: &MergedValue,
8956    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectSecurityOptionItem>>>> {
8957        let MergedValueKind::Sequence(values) = value.kind() else {
8958            self.diagnostics.push(
8959                Diagnostic::new(
8960                    SECURITY_OPT_EXPECTED_SEQUENCE,
8961                    Severity::Error,
8962                    "security_opt must be a sequence of string scalars",
8963                )
8964                .with_label(DiagnosticLabel::primary(
8965                    effective_span(value),
8966                    "unexpected service security-option form",
8967                )),
8968            );
8969            return None;
8970        };
8971        let mut items = Vec::new();
8972        let mut candidates = SecurityOptionCandidateCounts::default();
8973        for item_value in values {
8974            let MergedValueKind::Scalar(scalar) = item_value.kind() else {
8975                self.diagnostics.push(
8976                    Diagnostic::new(
8977                        SECURITY_OPT_EXPECTED_STRING,
8978                        Severity::Error,
8979                        "security_opt entries must be string scalars",
8980                    )
8981                    .with_label(DiagnosticLabel::primary(
8982                        effective_span(item_value),
8983                        "unexpected security-option item retained in source evidence",
8984                    )),
8985                );
8986                continue;
8987            };
8988            if scalar.kind() != MergedScalarKind::String {
8989                self.diagnostics.push(
8990                    Diagnostic::new(
8991                        SECURITY_OPT_EXPECTED_STRING,
8992                        Severity::Error,
8993                        "security_opt entries must be string scalars",
8994                    )
8995                    .with_label(DiagnosticLabel::primary(
8996                        effective_span(item_value),
8997                        "unexpected security-option scalar kind retained in source evidence",
8998                    )),
8999                );
9000                continue;
9001            }
9002            let kind = classify_security_option(scalar.value());
9003            self.diagnose_security_option_item(&kind, effective_span(item_value), &mut candidates);
9004            items.push(ProjectValue::new(
9005                ProjectSecurityOptionItem {
9006                    authored: scalar.raw().to_owned(),
9007                    value: scalar.value().to_owned(),
9008                    scalar_kind: scalar.kind(),
9009                    kind,
9010                },
9011                item_value,
9012            ));
9013        }
9014        Some(ProjectValue::new(items, value))
9015    }
9016
9017    fn diagnose_security_option_item(
9018        &mut self,
9019        kind: &SecurityOptionKind,
9020        span: SourceSpan,
9021        candidates: &mut SecurityOptionCandidateCounts,
9022    ) {
9023        let diagnostic = match kind {
9024            SecurityOptionKind::AppArmor { .. } => {
9025                candidates.apparmor += 1;
9026                (candidates.apparmor > 1).then(|| {
9027                    Diagnostic::new(
9028                        SECURITY_OPT_APPARMOR_CONFLICT,
9029                        Severity::Warning,
9030                        "multiple AppArmor candidates are retained; a consumer must resolve the conflict explicitly",
9031                    )
9032                    .with_label(DiagnosticLabel::primary(
9033                        span,
9034                        "additional effective AppArmor candidate retained",
9035                    ))
9036                })
9037            }
9038            SecurityOptionKind::AppArmorNearMiss => Some(
9039                Diagnostic::new(
9040                    SECURITY_OPT_APPARMOR_NEAR_MISS,
9041                    Severity::Warning,
9042                    "AppArmor candidates require exact lowercase `apparmor=<profile>` spelling without whitespace",
9043                )
9044                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
9045            ),
9046            SecurityOptionKind::Seccomp { .. } => {
9047                candidates.seccomp += 1;
9048                (candidates.seccomp > 1).then(|| {
9049                    Diagnostic::new(
9050                        SECURITY_OPT_SECCOMP_CONFLICT,
9051                        Severity::Warning,
9052                        "multiple seccomp candidates are retained; a consumer must resolve the conflict explicitly",
9053                    )
9054                    .with_label(DiagnosticLabel::primary(
9055                        span,
9056                        "additional effective seccomp candidate retained",
9057                    ))
9058                })
9059            }
9060            SecurityOptionKind::SeccompNearMiss => Some(
9061                Diagnostic::new(
9062                    SECURITY_OPT_SECCOMP_NEAR_MISS,
9063                    Severity::Warning,
9064                    "seccomp candidates require exact lowercase `seccomp=<profile>` spelling without whitespace",
9065                )
9066                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
9067            ),
9068            SecurityOptionKind::NoNewPrivileges { .. } => {
9069                candidates.no_new_privileges += 1;
9070                (candidates.no_new_privileges > 1).then(|| {
9071                    Diagnostic::new(
9072                        SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT,
9073                        Severity::Warning,
9074                        "multiple no-new-privileges candidates are retained; a consumer must resolve the conflict explicitly",
9075                    )
9076                    .with_label(DiagnosticLabel::primary(
9077                        span,
9078                        "additional effective no-new-privileges candidate retained",
9079                    ))
9080                })
9081            }
9082            SecurityOptionKind::NoNewPrivilegesNearMiss => Some(
9083                Diagnostic::new(
9084                    SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS,
9085                    Severity::Warning,
9086                    "no-new-privileges candidates require exact lowercase `no-new-privileges:true` or `no-new-privileges:false` spelling without whitespace",
9087                )
9088                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
9089            ),
9090            SecurityOptionKind::Mask { .. }
9091            | SecurityOptionKind::MaskNearMiss
9092            | SecurityOptionKind::Unmask { .. }
9093            | SecurityOptionKind::UnmaskNearMiss => security_path_option_diagnostic(kind, span),
9094            SecurityOptionKind::SecurityLabelDisable { .. }
9095            | SecurityOptionKind::SecurityLabelDisableNearMiss
9096            | SecurityOptionKind::SecurityLabelFileType { .. }
9097            | SecurityOptionKind::SecurityLabelFileTypeNearMiss
9098            | SecurityOptionKind::SecurityLabelLevel { .. }
9099            | SecurityOptionKind::SecurityLabelLevelNearMiss
9100            | SecurityOptionKind::SecurityLabelNested { .. }
9101            | SecurityOptionKind::SecurityLabelNestedNearMiss
9102            | SecurityOptionKind::SecurityLabelType { .. }
9103            | SecurityOptionKind::SecurityLabelTypeNearMiss => {
9104                effective_security_label_diagnostic(kind, span, candidates)
9105            }
9106            SecurityOptionKind::Empty => Some(
9107                Diagnostic::new(
9108                    SECURITY_OPT_EMPTY_ITEM,
9109                    Severity::Error,
9110                    "security_opt entries must not be empty strings",
9111                )
9112                .with_label(DiagnosticLabel::primary(span, "empty security option retained")),
9113            ),
9114            SecurityOptionKind::Expression | SecurityOptionKind::Other => None,
9115        };
9116        if let Some(diagnostic) = diagnostic {
9117            self.diagnostics.push(diagnostic);
9118        }
9119    }
9120
9121    fn tmpfs(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectTmpfs>> {
9122        let form = match value.kind() {
9123            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
9124                let item = self.tmpfs_item(value, scalar.value());
9125                ProjectTmpfs::Scalar(ProjectValue::new(item, value))
9126            }
9127            MergedValueKind::Sequence(values) => {
9128                let mut items = Vec::new();
9129                for item_value in values {
9130                    let MergedValueKind::Scalar(scalar) = item_value.kind() else {
9131                        self.diagnostics.push(
9132                            Diagnostic::new(
9133                                TMPFS_EXPECTED_STRING,
9134                                Severity::Error,
9135                                "tmpfs entries must be string scalars",
9136                            )
9137                            .with_label(DiagnosticLabel::primary(
9138                                effective_span(item_value),
9139                                "unexpected temporary-filesystem list item",
9140                            )),
9141                        );
9142                        continue;
9143                    };
9144                    if scalar.kind() != MergedScalarKind::String {
9145                        self.diagnostics.push(
9146                            Diagnostic::new(
9147                                TMPFS_EXPECTED_STRING,
9148                                Severity::Error,
9149                                "tmpfs entries must be string scalars",
9150                            )
9151                            .with_label(DiagnosticLabel::primary(
9152                                effective_span(item_value),
9153                                "unexpected temporary-filesystem list item",
9154                            )),
9155                        );
9156                        continue;
9157                    }
9158                    let item = self.tmpfs_item(item_value, scalar.value());
9159                    items.push(ProjectValue::new(item, item_value));
9160                }
9161                ProjectTmpfs::List(items)
9162            }
9163            _ => {
9164                self.diagnostics.push(
9165                    Diagnostic::new(
9166                        TMPFS_EXPECTED_FORM,
9167                        Severity::Error,
9168                        "tmpfs must be a string scalar or a sequence of string scalars",
9169                    )
9170                    .with_label(DiagnosticLabel::primary(
9171                        effective_span(value),
9172                        "unexpected service-level temporary-filesystem form",
9173                    )),
9174                );
9175                return None;
9176            }
9177        };
9178        Some(ProjectValue::new(form, value))
9179    }
9180
9181    fn tmpfs_item(&mut self, source: &MergedValue, raw: &str) -> TmpfsItem {
9182        let item = TmpfsItem::parse(Located::new(raw.to_owned(), effective_span(source)));
9183        if item.kind() == TmpfsItemKind::ProviderDependent {
9184            self.diagnostics.push(
9185                Diagnostic::new(
9186                    TMPFS_PROVIDER_DEPENDENT,
9187                    Severity::Warning,
9188                    "tmpfs item is malformed or uses provider- or target-specific options",
9189                )
9190                .with_label(DiagnosticLabel::primary(
9191                    effective_span(source),
9192                    "provider-dependent temporary-filesystem item",
9193                ))
9194                .with_note("use a non-empty path with only non-empty `mode`, `uid`, or `gid` assignments for documented portable syntax"),
9195            );
9196        }
9197        item
9198    }
9199
9200    fn sysctls(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectSysctls>> {
9201        let form = match value.kind() {
9202            MergedValueKind::Mapping(entries) => ProjectSysctls::Map(self.sysctls_map(entries)),
9203            MergedValueKind::Sequence(items) => ProjectSysctls::List(self.sysctls_list(items)),
9204            _ => {
9205                self.diagnostics.push(
9206                    Diagnostic::new(
9207                        SYSCTLS_EXPECTED_FORM,
9208                        Severity::Error,
9209                        "sysctls must be a mapping or a sequence of string scalars",
9210                    )
9211                    .with_label(DiagnosticLabel::primary(
9212                        effective_span(value),
9213                        "unexpected service sysctls form",
9214                    )),
9215                );
9216                return None;
9217            }
9218        };
9219        Some(ProjectValue::new(form, value))
9220    }
9221
9222    fn credential_spec(
9223        &mut self,
9224        value: &MergedValue,
9225        service_path: &[String],
9226    ) -> Option<ProjectValue<ProjectCredentialSpec>> {
9227        let fields = self.mapping(value, "credential_spec must be a mapping")?;
9228        let mut path = service_path.to_vec();
9229        path.push("credential_spec".to_owned());
9230        let mut credential_spec = ProjectCredentialSpec {
9231            config: None,
9232            file: None,
9233            registry: None,
9234            unmodeled_fields: Vec::new(),
9235        };
9236        for field in fields {
9237            let value = match field.key() {
9238                "config" => self
9239                    .strict_project_string(field.value(), "credential_spec config must be a YAML string scalar")
9240                    .map(|value| credential_spec.config = Some(value)),
9241                "file" => self
9242                    .strict_project_string(field.value(), "credential_spec file must be a YAML string scalar")
9243                    .map(|value| credential_spec.file = Some(value)),
9244                "registry" => self
9245                    .strict_project_string(field.value(), "credential_spec registry must be a YAML string scalar")
9246                    .map(|value| credential_spec.registry = Some(value)),
9247                _ => {
9248                    credential_spec.unmodeled_fields.push(field_reference(&path, field));
9249                    Some(())
9250                }
9251            };
9252            if value.is_none() {
9253                credential_spec.unmodeled_fields.push(field_reference(&path, field));
9254            }
9255        }
9256        Some(ProjectValue::new(credential_spec, value))
9257    }
9258
9259    fn extends(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<ProjectExtends>> {
9260        match value.kind() {
9261            MergedValueKind::Scalar(scalar)
9262                if scalar.kind() == MergedScalarKind::String && scalar.is_strict_yaml_string() =>
9263            {
9264                Some(ProjectValue::new(
9265                    ProjectExtends::Short(ProjectValue::new(scalar.value().to_owned(), value)),
9266                    value,
9267                ))
9268            }
9269            MergedValueKind::Mapping(fields) => {
9270                let mut path = service_path.to_vec();
9271                path.push("extends".to_owned());
9272                let mut reference = ProjectExtendsReference {
9273                    service: None,
9274                    file: None,
9275                    unmodeled_fields: Vec::new(),
9276                };
9277                for field in fields {
9278                    let typed = match field.key() {
9279                        "service" => self
9280                            .strict_project_string(field.value(), "extends service must be a YAML string scalar")
9281                            .map(|value| reference.service = Some(value)),
9282                        "file" => self
9283                            .strict_project_string(field.value(), "extends file must be a YAML string scalar")
9284                            .map(|value| reference.file = Some(value)),
9285                        _ => {
9286                            reference.unmodeled_fields.push(field_reference(&path, field));
9287                            Some(())
9288                        }
9289                    };
9290                    if typed.is_none() {
9291                        reference.unmodeled_fields.push(field_reference(&path, field));
9292                    }
9293                }
9294                if reference.service.is_none() {
9295                    self.diagnostics.push(
9296                        Diagnostic::new(
9297                            EXTENDS_MISSING_SERVICE,
9298                            Severity::Error,
9299                            "long extends is missing required `service`",
9300                        )
9301                        .with_label(DiagnosticLabel::primary(
9302                            effective_span(value),
9303                            "long extends mapping retained without service",
9304                        )),
9305                    );
9306                }
9307                Some(ProjectValue::new(ProjectExtends::Long(reference), value))
9308            }
9309            _ => {
9310                self.expected(value, "extends must be a YAML string scalar or mapping");
9311                None
9312            }
9313        }
9314    }
9315
9316    fn provider(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<ProjectProvider>> {
9317        let fields = self.mapping(value, "provider must be a mapping")?;
9318        let mut path = service_path.to_vec();
9319        path.push("provider".to_owned());
9320        let mut provider = ProjectProvider {
9321            type_: None,
9322            options: None,
9323            unmodeled_fields: Vec::new(),
9324        };
9325        for field in fields {
9326            let parsed = match field.key() {
9327                "type" => self
9328                    .strict_project_string(field.value(), "provider type must be a YAML string scalar")
9329                    .map(|value| provider.type_ = Some(value)),
9330                "options" => self
9331                    .provider_options(field.value(), &path)
9332                    .map(|value| provider.options = Some(value)),
9333                _ => {
9334                    provider.unmodeled_fields.push(field_reference(&path, field));
9335                    Some(())
9336                }
9337            };
9338            if parsed.is_none() {
9339                provider.unmodeled_fields.push(field_reference(&path, field));
9340            }
9341        }
9342        if provider.type_.is_none() {
9343            self.diagnostics.push(
9344                Diagnostic::new(
9345                    PROVIDER_MISSING_TYPE,
9346                    Severity::Error,
9347                    "provider is missing required `type`",
9348                )
9349                .with_label(DiagnosticLabel::primary(
9350                    effective_span(value),
9351                    "provider mapping retained without type",
9352                )),
9353            );
9354        }
9355        Some(ProjectValue::new(provider, value))
9356    }
9357
9358    fn post_start(
9359        &mut self,
9360        value: &MergedValue,
9361        service_path: &[String],
9362    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectPostStartHook>>>> {
9363        self.lifecycle_hooks(
9364            value,
9365            service_path,
9366            "post_start",
9367            POST_START_MISSING_COMMAND,
9368            ProjectPostStartHook::Hook,
9369            || ProjectPostStartHook::Unmodeled,
9370        )
9371    }
9372
9373    fn pre_stop(
9374        &mut self,
9375        value: &MergedValue,
9376        service_path: &[String],
9377    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectPreStopHook>>>> {
9378        self.lifecycle_hooks(
9379            value,
9380            service_path,
9381            "pre_stop",
9382            PRE_STOP_MISSING_COMMAND,
9383            ProjectPreStopHook::Hook,
9384            || ProjectPreStopHook::Unmodeled,
9385        )
9386    }
9387
9388    fn pre_start(
9389        &mut self,
9390        value: &MergedValue,
9391        service_path: &[String],
9392    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectPreStartHook>>>> {
9393        let Some(items) = value.as_sequence() else {
9394            self.expected(value, "pre_start must be a sequence of hook mappings");
9395            return None;
9396        };
9397        let mut path = service_path.to_vec();
9398        path.push("pre_start".to_owned());
9399        let mut hooks = Vec::new();
9400        for item in items {
9401            let hook = if let Some(fields) = item.as_mapping() {
9402                ProjectPreStartHook::Hook(Box::new(self.pre_start_service_hook(fields, &path)))
9403            } else {
9404                self.expected(item, "pre_start items must be hook mappings");
9405                ProjectPreStartHook::Unmodeled
9406            };
9407            hooks.push(ProjectValue::new(hook, item));
9408        }
9409        Some(ProjectValue::new(hooks, value))
9410    }
9411
9412    fn lifecycle_hooks<T>(
9413        &mut self,
9414        value: &MergedValue,
9415        service_path: &[String],
9416        name: &str,
9417        missing_code: DiagnosticCode,
9418        hook_entry: fn(Box<ProjectServiceHook>) -> T,
9419        unmodeled_entry: fn() -> T,
9420    ) -> Option<ProjectValue<Vec<ProjectValue<T>>>> {
9421        let Some(items) = value.as_sequence() else {
9422            self.expected(value, &format!("{name} must be a sequence of hook mappings"));
9423            return None;
9424        };
9425        let mut path = service_path.to_vec();
9426        path.push(name.to_owned());
9427        let mut hooks = Vec::new();
9428        for item in items {
9429            let hook = if let Some(fields) = item.as_mapping() {
9430                hook_entry(Box::new(self.service_hook(item, fields, &path, name, missing_code)))
9431            } else {
9432                self.expected(item, &format!("{name} items must be hook mappings"));
9433                unmodeled_entry()
9434            };
9435            hooks.push(ProjectValue::new(hook, item));
9436        }
9437        Some(ProjectValue::new(hooks, value))
9438    }
9439
9440    fn service_hook(
9441        &mut self,
9442        value: &MergedValue,
9443        fields: &[MergedEntry],
9444        path: &[String],
9445        name: &str,
9446        missing_code: DiagnosticCode,
9447    ) -> ProjectServiceHook {
9448        let mut hook = ProjectServiceHook {
9449            command: None,
9450            environment: None,
9451            privileged: None,
9452            user: None,
9453            working_dir: None,
9454            unmodeled_fields: Vec::new(),
9455        };
9456        for field in fields {
9457            let parsed = match field.key() {
9458                "command" => self.command(field.value()).map(|value| hook.command = Some(value)),
9459                "environment" => self
9460                    .environment(field.value())
9461                    .map(|value| hook.environment = Some(value)),
9462                "privileged" => self
9463                    .boolean(field.value(), &format!("{name} privileged must be a boolean"))
9464                    .map(|value| hook.privileged = Some(value)),
9465                "user" => self
9466                    .strict_project_string(field.value(), &format!("{name} user must be a YAML string scalar"))
9467                    .map(|value| hook.user = Some(value)),
9468                "working_dir" => self
9469                    .strict_project_string(
9470                        field.value(),
9471                        &format!("{name} working_dir must be a YAML string scalar"),
9472                    )
9473                    .map(|value| hook.working_dir = Some(value)),
9474                _ => {
9475                    hook.unmodeled_fields.push(field_reference(path, field));
9476                    Some(())
9477                }
9478            };
9479            if parsed.is_none() {
9480                hook.unmodeled_fields.push(field_reference(path, field));
9481            }
9482        }
9483        if hook.command.is_none() {
9484            self.diagnostics.push(
9485                Diagnostic::new(
9486                    missing_code,
9487                    Severity::Error,
9488                    format!("{name} hook is missing required `command`"),
9489                )
9490                .with_label(DiagnosticLabel::primary(
9491                    effective_span(value),
9492                    format!("{name} hook retained without command"),
9493                )),
9494            );
9495        }
9496        hook
9497    }
9498
9499    fn pre_start_service_hook(&mut self, fields: &[MergedEntry], path: &[String]) -> ProjectPreStartServiceHook {
9500        let mut hook = ProjectPreStartServiceHook {
9501            command: None,
9502            image: None,
9503            environment: None,
9504            privileged: None,
9505            per_replica: None,
9506            user: None,
9507            working_dir: None,
9508            unmodeled_fields: Vec::new(),
9509        };
9510        for field in fields {
9511            let parsed = match field.key() {
9512                "command" => self.command(field.value()).map(|value| hook.command = Some(value)),
9513                "image" => self
9514                    .strict_project_string(field.value(), "pre_start image must be a YAML string scalar")
9515                    .map(|value| hook.image = Some(value)),
9516                "environment" => self
9517                    .environment(field.value())
9518                    .map(|value| hook.environment = Some(value)),
9519                "privileged" => self
9520                    .boolean(field.value(), "pre_start privileged must be a boolean")
9521                    .map(|value| hook.privileged = Some(value)),
9522                "per_replica" => self
9523                    .boolean(field.value(), "pre_start per_replica must be a boolean")
9524                    .map(|value| hook.per_replica = Some(value)),
9525                "user" => self
9526                    .strict_project_string(field.value(), "pre_start user must be a YAML string scalar")
9527                    .map(|value| hook.user = Some(value)),
9528                "working_dir" => self
9529                    .strict_project_string(field.value(), "pre_start working_dir must be a YAML string scalar")
9530                    .map(|value| hook.working_dir = Some(value)),
9531                _ => {
9532                    hook.unmodeled_fields.push(field_reference(path, field));
9533                    Some(())
9534                }
9535            };
9536            if parsed.is_none() {
9537                hook.unmodeled_fields.push(field_reference(path, field));
9538            }
9539        }
9540        hook
9541    }
9542
9543    fn provider_options(
9544        &mut self,
9545        value: &MergedValue,
9546        provider_path: &[String],
9547    ) -> Option<ProjectValue<ProjectProviderOptions>> {
9548        let Some(entries) = value.as_mapping() else {
9549            self.expected(value, "provider options must be a mapping");
9550            return None;
9551        };
9552        let mut path = provider_path.to_vec();
9553        path.push("options".to_owned());
9554        let mut options = Vec::new();
9555        let mut unmodeled_entries = Vec::new();
9556        for entry in entries {
9557            if entry.key().is_empty() {
9558                self.expected(entry.value(), "provider option keys must not be empty");
9559                unmodeled_entries.push(field_reference(&path, entry));
9560                continue;
9561            }
9562            let Some(value) = self.provider_option_value(entry.value()) else {
9563                unmodeled_entries.push(field_reference(&path, entry));
9564                continue;
9565            };
9566            let option = ProjectProviderOption {
9567                name: ProjectKey::from_entry(entry),
9568                value: ProjectValue::new(value, entry.value()),
9569            };
9570            options.push(ProjectValue::new(option, entry.value()));
9571        }
9572        Some(ProjectValue::new(
9573            ProjectProviderOptions {
9574                entries: options,
9575                unmodeled_entries,
9576            },
9577            value,
9578        ))
9579    }
9580
9581    fn provider_option_value(&mut self, value: &MergedValue) -> Option<ProjectProviderOptionValue> {
9582        if let Some(scalar) = Self::provider_option_scalar(value) {
9583            return Some(ProjectProviderOptionValue::Scalar(scalar));
9584        }
9585        let Some(items) = value.as_sequence() else {
9586            self.expected(
9587                value,
9588                "provider option values must be YAML string, number, or boolean scalars or sequences of them",
9589            );
9590            return None;
9591        };
9592        let mut parsed = Vec::new();
9593        for item in items {
9594            let value = if let Some(scalar) = Self::provider_option_scalar(item) {
9595                ProjectProviderOptionItem::Scalar(scalar)
9596            } else {
9597                self.expected(
9598                    item,
9599                    "provider option sequence items must be YAML string, number, or boolean scalars",
9600                );
9601                ProjectProviderOptionItem::Unmodeled
9602            };
9603            parsed.push(ProjectValue::new(value, item));
9604        }
9605        Some(ProjectProviderOptionValue::Sequence(parsed))
9606    }
9607
9608    fn provider_option_scalar(value: &MergedValue) -> Option<ComposeScalar> {
9609        let MergedValueKind::Scalar(scalar) = value.kind() else {
9610            return None;
9611        };
9612        match scalar.kind() {
9613            MergedScalarKind::String if scalar.is_strict_yaml_string() => {
9614                Some(ComposeScalar::String(scalar.value().to_owned()))
9615            }
9616            MergedScalarKind::Number => Some(ComposeScalar::Number(scalar.value().to_owned())),
9617            MergedScalarKind::Boolean => Some(ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true"))),
9618            MergedScalarKind::String => None,
9619        }
9620    }
9621
9622    fn logging(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<ProjectLogging>> {
9623        let Some(fields) = value.as_mapping() else {
9624            self.diagnostics.push(
9625                Diagnostic::new(
9626                    LOGGING_EXPECTED_MAPPING,
9627                    Severity::Error,
9628                    "logging must be a mapping with optional driver and options fields",
9629                )
9630                .with_label(DiagnosticLabel::primary(
9631                    effective_span(value),
9632                    "unexpected logging form",
9633                )),
9634            );
9635            return None;
9636        };
9637        let mut driver = None;
9638        let mut options = None;
9639        let mut unmodeled_fields = Vec::new();
9640        let mut path = service_path.to_vec();
9641        path.push("logging".to_owned());
9642        for field in fields {
9643            match field.key() {
9644                "driver" => {
9645                    let Some(scalar) = field.value().as_scalar() else {
9646                        self.logging_driver_finding(field.value());
9647                        unmodeled_fields.push(field_reference(&path, field));
9648                        continue;
9649                    };
9650                    if scalar.kind() != MergedScalarKind::String {
9651                        self.logging_driver_finding(field.value());
9652                        unmodeled_fields.push(field_reference(&path, field));
9653                        continue;
9654                    }
9655                    driver = Some(ProjectValue::new(scalar.value().to_owned(), field.value()));
9656                }
9657                "options" => {
9658                    let Some(entries) = field.value().as_mapping() else {
9659                        self.diagnostics.push(
9660                            Diagnostic::new(
9661                                LOGGING_OPTIONS_EXPECTED_MAPPING,
9662                                Severity::Error,
9663                                "logging options must be a mapping",
9664                            )
9665                            .with_label(DiagnosticLabel::primary(
9666                                effective_span(field.value()),
9667                                "unexpected logging options form",
9668                            )),
9669                        );
9670                        unmodeled_fields.push(field_reference(&path, field));
9671                        continue;
9672                    };
9673                    options = Some(self.logging_options(field.value(), entries, &path));
9674                }
9675                _ => unmodeled_fields.push(field_reference(&path, field)),
9676            }
9677        }
9678        Some(ProjectValue::new(
9679            ProjectLogging {
9680                driver,
9681                options,
9682                unmodeled_fields,
9683            },
9684            value,
9685        ))
9686    }
9687
9688    fn logging_driver_finding(&mut self, value: &MergedValue) {
9689        self.diagnostics.push(
9690            Diagnostic::new(
9691                LOGGING_DRIVER_EXPECTED_STRING,
9692                Severity::Error,
9693                "logging driver must be a YAML string scalar",
9694            )
9695            .with_label(DiagnosticLabel::primary(
9696                effective_span(value),
9697                "non-string logging driver retained as unmodeled evidence",
9698            )),
9699        );
9700    }
9701
9702    fn logging_options(
9703        &mut self,
9704        source: &MergedValue,
9705        entries: &[MergedEntry],
9706        logging_path: &[String],
9707    ) -> ProjectValue<ProjectLoggingOptions> {
9708        let mut values = Vec::new();
9709        let mut unmodeled_entries = Vec::new();
9710        let mut path = logging_path.to_vec();
9711        path.push("options".to_owned());
9712        for entry in entries {
9713            if entry.key().is_empty() {
9714                self.diagnostics.push(
9715                    Diagnostic::new(
9716                        LOGGING_OPTION_EMPTY_KEY,
9717                        Severity::Error,
9718                        "logging option keys must not be empty",
9719                    )
9720                    .with_label(DiagnosticLabel::primary(entry_span(entry), "empty logging option key")),
9721                );
9722                unmodeled_entries.push(field_reference(&path, entry));
9723                continue;
9724            }
9725            let Some(value) = self.logging_option_value(entry.value()) else {
9726                unmodeled_entries.push(field_reference(&path, entry));
9727                continue;
9728            };
9729            let option = ProjectLoggingOption {
9730                name: ProjectKey::from_entry(entry),
9731                value: ProjectValue::new(value, entry.value()),
9732            };
9733            values.push(ProjectValue::new(option, entry.value()));
9734        }
9735        ProjectValue::new(
9736            ProjectLoggingOptions {
9737                entries: values,
9738                unmodeled_entries,
9739            },
9740            source,
9741        )
9742    }
9743
9744    fn logging_option_value(&mut self, value: &MergedValue) -> Option<ProjectLoggingOptionValue> {
9745        let parsed = match value.kind() {
9746            MergedValueKind::Null(_) => ProjectLoggingOptionValue::Null,
9747            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
9748                ProjectLoggingOptionValue::String {
9749                    authored: scalar.raw().to_owned(),
9750                    value: scalar.value().to_owned(),
9751                }
9752            }
9753            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::Number => {
9754                ProjectLoggingOptionValue::Number(scalar.value().to_owned())
9755            }
9756            _ => {
9757                self.diagnostics.push(
9758                    Diagnostic::new(
9759                        LOGGING_OPTION_EXPECTED_SCALAR,
9760                        Severity::Error,
9761                        "logging option values must be YAML string, number, or null scalars",
9762                    )
9763                    .with_label(DiagnosticLabel::primary(
9764                        effective_span(value),
9765                        "unsupported logging option value retained as unmodeled evidence",
9766                    )),
9767                );
9768                return None;
9769            }
9770        };
9771        Some(parsed)
9772    }
9773
9774    fn sysctls_map(&mut self, entries: &[MergedEntry]) -> Vec<ProjectValue<ProjectSysctl>> {
9775        let mut sysctls = Vec::new();
9776        for entry in entries {
9777            if entry.key().is_empty() {
9778                self.diagnostics.push(
9779                    Diagnostic::new(
9780                        SYSCTLS_EMPTY_KEY,
9781                        Severity::Error,
9782                        "sysctls mapping keys must not be empty",
9783                    )
9784                    .with_label(DiagnosticLabel::primary(entry_span(entry), "empty sysctl name")),
9785                );
9786                continue;
9787            }
9788            let Some(scalar) = self.sysctl_scalar(entry.value()) else {
9789                continue;
9790            };
9791            let sysctl = ProjectSysctl {
9792                name: ProjectKey::from_entry(entry),
9793                value: ProjectValue::new(scalar, entry.value()),
9794            };
9795            sysctls.push(ProjectValue::new(sysctl, entry.value()));
9796        }
9797        sysctls
9798    }
9799
9800    fn sysctl_scalar(&mut self, value: &MergedValue) -> Option<ComposeScalar> {
9801        match value.kind() {
9802            MergedValueKind::Null(_) => Some(ComposeScalar::Null),
9803            MergedValueKind::Scalar(scalar) => Some(match scalar.kind() {
9804                MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
9805                MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
9806                MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
9807            }),
9808            _ => {
9809                self.diagnostics.push(
9810                    Diagnostic::new(
9811                        SYSCTLS_EXPECTED_SCALAR,
9812                        Severity::Error,
9813                        "sysctls mapping values must be scalar strings, numbers, booleans, or null",
9814                    )
9815                    .with_label(DiagnosticLabel::primary(
9816                        effective_span(value),
9817                        "non-scalar sysctl value",
9818                    )),
9819                );
9820                None
9821            }
9822        }
9823    }
9824
9825    fn sysctls_list(&mut self, items: &[MergedValue]) -> Vec<ProjectValue<String>> {
9826        let mut sysctls = Vec::new();
9827        let mut seen = BTreeMap::new();
9828        for item in items {
9829            let MergedValueKind::Scalar(scalar) = item.kind() else {
9830                self.invalid_sysctl_list_item(item);
9831                continue;
9832            };
9833            if scalar.kind() != MergedScalarKind::String {
9834                self.invalid_sysctl_list_item(item);
9835                continue;
9836            }
9837            let span = effective_span(item);
9838            if let Some(first) = seen.get(scalar.value()) {
9839                self.diagnostics.push(
9840                    Diagnostic::new(
9841                        SYSCTLS_DUPLICATE_ITEM,
9842                        Severity::Error,
9843                        "effective sysctls list entries must be unique exact strings",
9844                    )
9845                    .with_label(DiagnosticLabel::primary(span, "duplicate sysctl string"))
9846                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
9847                );
9848            } else {
9849                seen.insert(scalar.value().to_owned(), span);
9850            }
9851            sysctls.push(ProjectValue::new(scalar.value().to_owned(), item));
9852        }
9853        sysctls
9854    }
9855
9856    fn invalid_sysctl_list_item(&mut self, value: &MergedValue) {
9857        self.diagnostics.push(
9858            Diagnostic::new(
9859                SYSCTLS_EXPECTED_STRING,
9860                Severity::Error,
9861                "sysctls list entries must be YAML string scalars",
9862            )
9863            .with_label(DiagnosticLabel::primary(
9864                effective_span(value),
9865                "non-string sysctl list item",
9866            )),
9867        );
9868    }
9869
9870    fn ulimits(
9871        &mut self,
9872        value: &MergedValue,
9873        service_path: &[String],
9874    ) -> (Option<ProjectValue<ProjectUlimits>>, Vec<ProjectFieldReference>) {
9875        let Some(entries) = value.as_mapping() else {
9876            self.expected(value, "ulimits must be a mapping");
9877            return (None, Vec::new());
9878        };
9879        let mut limits = Vec::new();
9880        let mut unmodeled = Vec::new();
9881        let mut path = service_path.to_vec();
9882        path.push("ulimits".to_owned());
9883        for entry in entries {
9884            if !valid_ulimit_name(entry.key()) {
9885                self.diagnostics.push(
9886                    Diagnostic::new(
9887                        ULIMIT_INVALID_NAME,
9888                        Severity::Error,
9889                        "ulimit names must contain only lowercase ASCII letters",
9890                    )
9891                    .with_label(DiagnosticLabel::primary(entry_span(entry), "invalid ulimit name")),
9892                );
9893                unmodeled.push(field_reference(&path, entry));
9894                continue;
9895            }
9896            let Some(limit) = self.ulimit(entry, &path) else {
9897                unmodeled.push(field_reference(&path, entry));
9898                continue;
9899            };
9900            limits.push(ProjectValue::new(limit, entry.value()));
9901        }
9902        (
9903            Some(ProjectValue::new(ProjectUlimits { entries: limits }, value)),
9904            unmodeled,
9905        )
9906    }
9907
9908    fn ulimit(&mut self, entry: &MergedEntry, parent_path: &[String]) -> Option<ProjectUlimit> {
9909        let value = match entry.value().kind() {
9910            MergedValueKind::Scalar(_) => ProjectUlimitValue::Single(
9911                self.ulimit_scalar(entry.value())
9912                    .map(|scalar| ProjectValue::new(scalar, entry.value()))?,
9913            ),
9914            MergedValueKind::Mapping(fields) => {
9915                let mut soft = None;
9916                let mut hard = None;
9917                let mut unmodeled_fields = Vec::new();
9918                let mut range_path = parent_path.to_vec();
9919                range_path.push(entry.key().to_owned());
9920                for field in fields {
9921                    match field.key() {
9922                        "soft" => {
9923                            let parsed = self
9924                                .ulimit_scalar(field.value())
9925                                .map(|scalar| ProjectValue::new(scalar, field.value()));
9926                            if parsed.is_none() {
9927                                unmodeled_fields.push(field_reference(&range_path, field));
9928                            }
9929                            soft = parsed;
9930                        }
9931                        "hard" => {
9932                            let parsed = self
9933                                .ulimit_scalar(field.value())
9934                                .map(|scalar| ProjectValue::new(scalar, field.value()));
9935                            if parsed.is_none() {
9936                                unmodeled_fields.push(field_reference(&range_path, field));
9937                            }
9938                            hard = parsed;
9939                        }
9940                        _ => unmodeled_fields.push(field_reference(&range_path, field)),
9941                    }
9942                }
9943                if soft.is_none() {
9944                    self.diagnostics.push(
9945                        Diagnostic::new(
9946                            ULIMIT_MISSING_RANGE_MEMBER,
9947                            Severity::Error,
9948                            "ulimit range is missing required `soft`",
9949                        )
9950                        .with_label(DiagnosticLabel::primary(
9951                            effective_span(entry.value()),
9952                            "missing soft limit",
9953                        )),
9954                    );
9955                }
9956                if hard.is_none() {
9957                    self.diagnostics.push(
9958                        Diagnostic::new(
9959                            ULIMIT_MISSING_RANGE_MEMBER,
9960                            Severity::Error,
9961                            "ulimit range is missing required `hard`",
9962                        )
9963                        .with_label(DiagnosticLabel::primary(
9964                            effective_span(entry.value()),
9965                            "missing hard limit",
9966                        )),
9967                    );
9968                }
9969                ProjectUlimitValue::Range(ProjectUlimitRange {
9970                    soft,
9971                    hard,
9972                    unmodeled_fields,
9973                })
9974            }
9975            _ => {
9976                self.expected(
9977                    entry.value(),
9978                    "ulimit must be a number/string scalar or a soft/hard mapping",
9979                );
9980                return None;
9981            }
9982        };
9983        Some(ProjectUlimit {
9984            name: ProjectKey::from_entry(entry),
9985            value,
9986        })
9987    }
9988
9989    fn ulimit_scalar(&mut self, value: &MergedValue) -> Option<ProjectUlimitScalar> {
9990        let Some(scalar) = value.as_scalar() else {
9991            self.expected(value, "ulimit values must be number or string scalars");
9992            return None;
9993        };
9994        if !matches!(scalar.kind(), MergedScalarKind::String | MergedScalarKind::Number) {
9995            self.diagnostics.push(
9996                Diagnostic::new(
9997                    ULIMIT_INVALID_VALUE,
9998                    Severity::Error,
9999                    "ulimit values must be number or string scalars",
10000                )
10001                .with_label(DiagnosticLabel::primary(
10002                    effective_span(value),
10003                    "invalid ulimit scalar kind",
10004                )),
10005            );
10006            return None;
10007        }
10008        let parsed = LimitValue::parse(scalar.value().to_owned());
10009        if !parsed.is_valid() {
10010            self.diagnostics.push(
10011                Diagnostic::new(
10012                    ULIMIT_INVALID_VALUE,
10013                    Severity::Error,
10014                    "ulimit must be -1, a non-negative integer, or an interpolation expression",
10015                )
10016                .with_label(DiagnosticLabel::primary(effective_span(value), "invalid ulimit value")),
10017            );
10018        }
10019        Some(ProjectUlimitScalar {
10020            authored: scalar.raw().to_owned(),
10021            value: parsed,
10022            kind: scalar.kind(),
10023        })
10024    }
10025
10026    fn pull_policy(&mut self, value: &MergedValue) -> Option<ProjectValue<PullPolicy>> {
10027        let policy = PullPolicy::parse(self.located_string(value, "pull_policy must be a non-null scalar")?);
10028        if !policy.is_recognized() {
10029            self.invalid(
10030                effective_span(value),
10031                "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",
10032            );
10033        }
10034        Some(ProjectValue::new(policy, value))
10035    }
10036
10037    fn stop_grace_period(&mut self, value: &MergedValue) -> Option<ProjectValue<StopGracePeriod>> {
10038        let scalar = self.scalar(value, "stop_grace_period must be a non-null scalar")?;
10039        let period = StopGracePeriod::parse(scalar.value().to_owned());
10040        if !period.is_valid() {
10041            self.invalid(
10042                effective_span(value),
10043                "stop_grace_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
10044            );
10045        }
10046        Some(ProjectValue::new(period, value))
10047    }
10048
10049    fn command(&mut self, value: &MergedValue) -> Option<ProjectValue<Command>> {
10050        let span = effective_span(value);
10051        let command = match value.kind() {
10052            MergedValueKind::Null(_) => Command::Null(span),
10053            MergedValueKind::Scalar(scalar) => Command::String(Located::new(scalar.value().to_owned(), span)),
10054            MergedValueKind::Sequence(values) => {
10055                let mut arguments = Vec::new();
10056                for value in values {
10057                    arguments.push(self.located_string(value, "command list item must be a scalar")?);
10058                }
10059                Command::List {
10060                    span,
10061                    values: arguments,
10062                }
10063            }
10064            _ => {
10065                self.expected(value, "command must be null, a scalar, or a sequence");
10066                return None;
10067            }
10068        };
10069        Some(ProjectValue::new(command, value))
10070    }
10071
10072    fn entrypoint(&mut self, value: &MergedValue) -> Option<ProjectValue<Entrypoint>> {
10073        let span = effective_span(value);
10074        let entrypoint = match value.kind() {
10075            MergedValueKind::Null(_) => Entrypoint::Null(span),
10076            MergedValueKind::Scalar(scalar) => Entrypoint::String(Located::new(scalar.value().to_owned(), span)),
10077            MergedValueKind::Sequence(values) => {
10078                let mut arguments = Vec::new();
10079                for value in values {
10080                    arguments.push(self.located_string(value, "entrypoint list item must be a scalar")?);
10081                }
10082                Entrypoint::List {
10083                    span,
10084                    values: arguments,
10085                }
10086            }
10087            _ => {
10088                self.expected(value, "entrypoint must be null, a scalar, or a sequence");
10089                return None;
10090            }
10091        };
10092        Some(ProjectValue::new(entrypoint, value))
10093    }
10094
10095    fn user(&mut self, value: &MergedValue) -> Option<ProjectValue<UserSpec>> {
10096        let raw = self.project_string(value, "service user")?;
10097        Some(ProjectValue {
10098            value: UserSpec::parse(Located::new(raw.value, effective_span(value))),
10099            provenance: raw.provenance,
10100            sensitive: raw.sensitive,
10101        })
10102    }
10103
10104    fn userns_mode(&mut self, value: &MergedValue) -> Option<ProjectValue<UserNamespaceMode>> {
10105        let raw = self.project_string(value, "service user namespace mode")?;
10106        Some(ProjectValue {
10107            value: UserNamespaceMode::parse(Located::new(raw.value, effective_span(value))),
10108            provenance: raw.provenance,
10109            sensitive: raw.sensitive,
10110        })
10111    }
10112
10113    fn environment(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectEnvironment>> {
10114        let mut entries = Vec::new();
10115        match value.kind() {
10116            MergedValueKind::Mapping(values) => {
10117                for entry in values {
10118                    let scalar = self.compose_scalar(entry.value(), "environment value must be a scalar or null")?;
10119                    entries.push(ProjectEnvironmentEntry {
10120                        name: ProjectKey::from_entry(entry),
10121                        value: ProjectValue::new(scalar, entry.value()),
10122                        syntax: entry.syntax(),
10123                    });
10124                }
10125            }
10126            MergedValueKind::Sequence(values) => {
10127                for item in values {
10128                    let raw = self.located_string(item, "environment list item must be a scalar")?;
10129                    let (name, scalar, syntax) = raw.value().split_once('=').map_or_else(
10130                        || (raw.value().clone(), ComposeScalar::Null, EntrySyntax::ListKeyOnly),
10131                        |(name, value)| {
10132                            (
10133                                name.to_owned(),
10134                                ComposeScalar::String(value.to_owned()),
10135                                EntrySyntax::ListKeyValue,
10136                            )
10137                        },
10138                    );
10139                    entries.push(ProjectEnvironmentEntry {
10140                        name: ProjectKey {
10141                            value: name,
10142                            sources: item.provenance().sources().to_vec(),
10143                            sensitive: item.is_sensitive(),
10144                        },
10145                        value: ProjectValue::new(scalar, item),
10146                        syntax,
10147                    });
10148                }
10149            }
10150            _ => {
10151                self.expected(value, "environment must be a mapping or sequence");
10152                return None;
10153            }
10154        }
10155        Some(ProjectValue::new(ProjectEnvironment { entries }, value))
10156    }
10157
10158    fn environment_files(
10159        &mut self,
10160        value: &MergedValue,
10161        service_path: &[String],
10162    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectEnvironmentFile>>>> {
10163        let values = match value.kind() {
10164            MergedValueKind::Scalar(_) => std::slice::from_ref(value),
10165            MergedValueKind::Sequence(values) => values,
10166            _ => {
10167                self.expected(
10168                    value,
10169                    "env_file must be a scalar path or sequence of short/long entries",
10170                );
10171                return None;
10172            }
10173        };
10174        let mut environment_files = Vec::new();
10175        for (index, item) in values.iter().enumerate() {
10176            let mut path = service_path.to_vec();
10177            path.push("env_file".to_owned());
10178            path.push(index.to_string());
10179            let environment_file = match item.kind() {
10180                MergedValueKind::Scalar(scalar) => ProjectEnvironmentFile::Short(scalar.value().to_owned()),
10181                MergedValueKind::Mapping(fields) => {
10182                    ProjectEnvironmentFile::Long(Box::new(self.long_environment_file(item, fields, &path)))
10183                }
10184                _ => {
10185                    self.expected(
10186                        item,
10187                        "env_file item must use scalar short syntax or mapping long syntax",
10188                    );
10189                    continue;
10190                }
10191            };
10192            environment_files.push(ProjectValue::new(environment_file, item));
10193        }
10194        Some(ProjectValue::new(environment_files, value))
10195    }
10196
10197    fn long_environment_file(
10198        &mut self,
10199        value: &MergedValue,
10200        fields: &[MergedEntry],
10201        path: &[String],
10202    ) -> ProjectLongEnvironmentFile {
10203        let mut environment_file = ProjectLongEnvironmentFile {
10204            path: None,
10205            required: None,
10206            format: None,
10207            unmodeled_fields: Vec::new(),
10208        };
10209        for field in fields {
10210            match field.key() {
10211                "path" => {
10212                    environment_file.path = self.project_string(field.value(), "environment-file path");
10213                }
10214                "required" => {
10215                    environment_file.required = self
10216                        .located_boolean(field.value(), "environment-file required option must be a boolean")
10217                        .map(|value| ProjectValue::new(value.into_value(), field.value()));
10218                }
10219                "format" => {
10220                    environment_file.format = self.environment_file_format(field.value());
10221                }
10222                _ => environment_file.unmodeled_fields.push(field_reference(path, field)),
10223            }
10224        }
10225        if environment_file.path.is_none() {
10226            self.missing(value, "long-syntax environment file is missing `path`");
10227        }
10228        environment_file
10229    }
10230
10231    fn environment_file_format(&mut self, value: &MergedValue) -> Option<ProjectValue<EnvironmentFileFormat>> {
10232        let raw = self.project_string(value, "environment-file format")?;
10233        let format = EnvironmentFileFormat::parse(Located::new(raw.value, effective_span(value)));
10234        if matches!(format.kind(), EnvironmentFileFormatKind::Other) {
10235            self.invalid(
10236                effective_span(value),
10237                "environment-file format must be `raw` or interpolation",
10238            );
10239        }
10240        Some(ProjectValue {
10241            value: format,
10242            provenance: raw.provenance,
10243            sensitive: raw.sensitive,
10244        })
10245    }
10246
10247    fn service_labels(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectLabels>> {
10248        let mut entries = Vec::new();
10249        let form = match value.kind() {
10250            MergedValueKind::Mapping(values) => {
10251                for entry in values {
10252                    let scalar = if entry.syntax() == EntrySyntax::ListKeyOnly {
10253                        ComposeScalar::String(String::new())
10254                    } else {
10255                        self.compose_scalar(entry.value(), "label value must be a scalar or null")?
10256                    };
10257                    entries.push(ProjectLabelEntry {
10258                        name: ProjectKey::from_entry(entry),
10259                        value: ProjectValue::new(scalar, entry.value()),
10260                        syntax: entry.syntax(),
10261                    });
10262                }
10263                ProjectLabelsForm::Map
10264            }
10265            MergedValueKind::Sequence(values) => {
10266                for item in values {
10267                    let raw = self.located_string(item, "label list item must be a scalar")?;
10268                    let (name, value, syntax) = raw.value().split_once('=').map_or_else(
10269                        || (raw.value().clone(), String::new(), EntrySyntax::ListKeyOnly),
10270                        |(name, value)| (name.to_owned(), value.to_owned(), EntrySyntax::ListKeyValue),
10271                    );
10272                    entries.push(ProjectLabelEntry {
10273                        name: ProjectKey::from_value(name, item),
10274                        value: ProjectValue::new(ComposeScalar::String(value), item),
10275                        syntax,
10276                    });
10277                }
10278                ProjectLabelsForm::List
10279            }
10280            _ => {
10281                self.expected(value, "labels must be a mapping or sequence");
10282                return None;
10283            }
10284        };
10285        Some(ProjectValue::new(ProjectLabels { form, entries }, value))
10286    }
10287
10288    fn service_annotations(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectAnnotations>> {
10289        let entries = match value.kind() {
10290            MergedValueKind::Mapping(values) => self.annotation_mapping(values),
10291            MergedValueKind::Sequence(values) => self.annotation_sequence(values),
10292            _ => {
10293                self.expected(value, "annotations must be a mapping or sequence");
10294                return None;
10295            }
10296        };
10297        Some(ProjectValue::new(ProjectAnnotations { entries }, value))
10298    }
10299
10300    fn annotation_mapping(&mut self, values: &[MergedEntry]) -> Vec<ProjectAnnotationEntry> {
10301        let mut entries = Vec::new();
10302        for entry in values {
10303            if entry.key().is_empty() {
10304                self.annotation_finding(
10305                    ANNOTATIONS_EMPTY_NAME,
10306                    Severity::Error,
10307                    entry.value(),
10308                    "service annotation name must not be empty",
10309                    "empty annotation name",
10310                );
10311            }
10312            if entry
10313                .raw_list_item()
10314                .is_some_and(|raw| raw.kind() != MergedScalarKind::String)
10315            {
10316                self.annotation_finding(
10317                    ANNOTATIONS_EXPECTED_STRING,
10318                    Severity::Error,
10319                    entry.value(),
10320                    "annotation list entries must be string scalars",
10321                    "non-string annotation item retained",
10322                );
10323            }
10324            let raw_list_item = entry.raw_list_item().map(|raw| ProjectValue {
10325                value: ProjectAnnotationScalar {
10326                    authored: raw.raw().to_owned(),
10327                    effective: compose_scalar_from_merged(raw),
10328                },
10329                provenance: entry.value().provenance().clone(),
10330                sensitive: raw.is_sensitive(),
10331            });
10332            let value = self.annotation_mapping_value(entry);
10333            let candidate = ProjectAnnotationEntry {
10334                name: ProjectKey::from_entry(entry),
10335                value,
10336                raw_list_item,
10337                syntax: entry.syntax(),
10338                contributors: vec![entry.value().provenance().clone()],
10339            };
10340            self.upsert_annotation(&mut entries, candidate, entry.value());
10341        }
10342        entries
10343    }
10344
10345    fn annotation_mapping_value(&mut self, entry: &MergedEntry) -> Option<ProjectValue<ProjectAnnotationScalar>> {
10346        if entry.syntax() == EntrySyntax::ListKeyOnly {
10347            self.annotation_finding(
10348                ANNOTATIONS_KEY_ONLY,
10349                Severity::Warning,
10350                entry.value(),
10351                "key-only service annotation has no explicit value",
10352                "ambiguous key-only annotation",
10353            );
10354            return None;
10355        }
10356        self.annotation_scalar(entry.value()).map(|scalar| ProjectValue {
10357            value: scalar,
10358            provenance: entry.value().provenance().clone(),
10359            sensitive: entry.value().is_sensitive(),
10360        })
10361    }
10362
10363    fn annotation_sequence(&mut self, values: &[MergedValue]) -> Vec<ProjectAnnotationEntry> {
10364        let mut entries = Vec::new();
10365        for item in values {
10366            let Some(candidate) = self.annotation_list_item(item) else {
10367                continue;
10368            };
10369            self.upsert_annotation(&mut entries, candidate, item);
10370        }
10371        entries
10372    }
10373
10374    fn annotation_list_item(&mut self, item: &MergedValue) -> Option<ProjectAnnotationEntry> {
10375        let Some(scalar) = item.as_scalar() else {
10376            self.invalid_annotation_list_item(item);
10377            return None;
10378        };
10379        if scalar.kind() != MergedScalarKind::String {
10380            self.invalid_annotation_list_item(item);
10381            return None;
10382        }
10383        let raw_list_item = Some(ProjectValue {
10384            value: ProjectAnnotationScalar {
10385                authored: scalar.raw().to_owned(),
10386                effective: ComposeScalar::String(scalar.value().to_owned()),
10387            },
10388            provenance: item.provenance().clone(),
10389            sensitive: scalar.is_sensitive(),
10390        });
10391        let (name, value, syntax) = if let Some((name, value)) = scalar.value().split_once('=') {
10392            (
10393                name.to_owned(),
10394                Some(ProjectValue {
10395                    value: ProjectAnnotationScalar {
10396                        authored: scalar.raw().to_owned(),
10397                        effective: ComposeScalar::String(value.to_owned()),
10398                    },
10399                    provenance: item.provenance().clone(),
10400                    sensitive: scalar.is_sensitive(),
10401                }),
10402                EntrySyntax::ListKeyValue,
10403            )
10404        } else {
10405            self.annotation_finding(
10406                ANNOTATIONS_KEY_ONLY,
10407                Severity::Warning,
10408                item,
10409                "key-only service annotation has no explicit value",
10410                "ambiguous key-only annotation",
10411            );
10412            (scalar.value().to_owned(), None, EntrySyntax::ListKeyOnly)
10413        };
10414        if name.is_empty() {
10415            self.annotation_finding(
10416                ANNOTATIONS_EMPTY_NAME,
10417                Severity::Error,
10418                item,
10419                "service annotation name must not be empty",
10420                "empty annotation name",
10421            );
10422        }
10423        Some(ProjectAnnotationEntry {
10424            name: ProjectKey::from_value(name, item),
10425            value,
10426            raw_list_item,
10427            syntax,
10428            contributors: vec![item.provenance().clone()],
10429        })
10430    }
10431
10432    fn invalid_annotation_list_item(&mut self, item: &MergedValue) {
10433        self.annotation_finding(
10434            ANNOTATIONS_EXPECTED_STRING,
10435            Severity::Error,
10436            item,
10437            "annotation list entries must be string scalars",
10438            "non-string annotation item retained in merged source",
10439        );
10440    }
10441
10442    fn annotation_scalar(&mut self, value: &MergedValue) -> Option<ProjectAnnotationScalar> {
10443        let effective = self.compose_scalar(
10444            value,
10445            "annotation mapping values must be scalar strings, numbers, booleans, or null",
10446        )?;
10447        let authored = match value.kind() {
10448            MergedValueKind::Null(crate::merge::NullStyle::Empty) => String::new(),
10449            MergedValueKind::Null(crate::merge::NullStyle::Explicit) => "null".to_owned(),
10450            MergedValueKind::Scalar(scalar) => scalar.raw().to_owned(),
10451            _ => unreachable!("compose_scalar accepted only scalar or null"),
10452        };
10453        Some(ProjectAnnotationScalar { authored, effective })
10454    }
10455
10456    fn upsert_annotation(
10457        &mut self,
10458        entries: &mut Vec<ProjectAnnotationEntry>,
10459        mut candidate: ProjectAnnotationEntry,
10460        source: &MergedValue,
10461    ) {
10462        if let Some(existing) = entries
10463            .iter_mut()
10464            .find(|entry| entry.name.value == candidate.name.value)
10465        {
10466            self.annotation_finding(
10467                ANNOTATIONS_DUPLICATE_NAME,
10468                Severity::Error,
10469                source,
10470                "service annotation names must be unique",
10471                "later annotation value replaces earlier effective value",
10472            );
10473            for span in candidate.name.sources.drain(..) {
10474                if !existing.name.sources.contains(&span) {
10475                    existing.name.sources.push(span);
10476                }
10477            }
10478            existing.name.sensitive |= candidate.name.sensitive;
10479            existing.contributors.append(&mut candidate.contributors);
10480            existing.value = candidate.value;
10481            existing.raw_list_item = candidate.raw_list_item;
10482            existing.syntax = candidate.syntax;
10483        } else {
10484            entries.push(candidate);
10485        }
10486    }
10487
10488    fn annotation_finding(
10489        &mut self,
10490        code: DiagnosticCode,
10491        severity: Severity,
10492        value: &MergedValue,
10493        message: &'static str,
10494        label: &'static str,
10495    ) {
10496        self.diagnostics.push(
10497            Diagnostic::new(code, severity, message).with_label(DiagnosticLabel::primary(effective_span(value), label)),
10498        );
10499    }
10500
10501    fn healthcheck(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ProjectValue<ProjectHealthcheck>> {
10502        let fields = self.mapping(value, "healthcheck must be a mapping")?;
10503        let mut healthcheck = ProjectHealthcheck {
10504            test: None,
10505            interval: None,
10506            timeout: None,
10507            retries: None,
10508            start_period: None,
10509            start_interval: None,
10510            disable: None,
10511            unmodeled_fields: Vec::new(),
10512        };
10513        let mut path = parent_path.to_vec();
10514        path.push("healthcheck".to_owned());
10515        for field in fields {
10516            match field.key() {
10517                "test" => healthcheck.test = self.healthcheck_test(field.value()),
10518                "interval" => {
10519                    healthcheck.interval =
10520                        self.healthcheck_duration(field.value(), "healthcheck interval must be a scalar");
10521                }
10522                "timeout" => {
10523                    healthcheck.timeout =
10524                        self.healthcheck_duration(field.value(), "healthcheck timeout must be a scalar");
10525                }
10526                "retries" => healthcheck.retries = self.healthcheck_retries(field.value()),
10527                "start_period" => {
10528                    healthcheck.start_period =
10529                        self.healthcheck_duration(field.value(), "healthcheck start_period must be a scalar");
10530                }
10531                "start_interval" => {
10532                    healthcheck.start_interval =
10533                        self.healthcheck_duration(field.value(), "healthcheck start_interval must be a scalar");
10534                }
10535                "disable" => {
10536                    healthcheck.disable = self
10537                        .located_boolean(field.value(), "healthcheck disable must be a boolean")
10538                        .map(|value| ProjectValue::new(value.into_value(), field.value()));
10539                }
10540                _ => healthcheck.unmodeled_fields.push(field_reference(&path, field)),
10541            }
10542        }
10543        Some(ProjectValue::new(healthcheck, value))
10544    }
10545
10546    fn depends_on(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ProjectValue<ProjectDependsOn>> {
10547        let dependencies = match value.kind() {
10548            MergedValueKind::Sequence(values) => {
10549                let mut dependencies = Vec::new();
10550                for value in values {
10551                    let Some(service) = self.project_string(value, "dependency service name") else {
10552                        continue;
10553                    };
10554                    let dependency = ProjectServiceDependency {
10555                        service: ProjectKey::from_value(service.value, value),
10556                        condition: None,
10557                        restart: None,
10558                        required: None,
10559                        unmodeled_fields: Vec::new(),
10560                    };
10561                    dependencies.push(ProjectValue::new(dependency, value));
10562                }
10563                ProjectDependsOn::Short(dependencies)
10564            }
10565            MergedValueKind::Mapping(entries) => {
10566                let mut dependencies = Vec::new();
10567                let mut path = parent_path.to_vec();
10568                path.push("depends_on".to_owned());
10569                for entry in entries {
10570                    let mut dependency = ProjectServiceDependency {
10571                        service: ProjectKey::from_entry(entry),
10572                        condition: None,
10573                        restart: None,
10574                        required: None,
10575                        unmodeled_fields: Vec::new(),
10576                    };
10577                    let fields = match entry.value().kind() {
10578                        MergedValueKind::Null(_) => &[][..],
10579                        MergedValueKind::Mapping(fields) => fields.as_slice(),
10580                        _ => {
10581                            self.expected(entry.value(), "long dependency options must be a mapping or null");
10582                            continue;
10583                        }
10584                    };
10585                    let mut dependency_path = path.clone();
10586                    dependency_path.push(entry.key().to_owned());
10587                    for field in fields {
10588                        match field.key() {
10589                            "condition" => {
10590                                let Some(condition) = self.project_string(field.value(), "dependency condition") else {
10591                                    continue;
10592                                };
10593                                let parsed = DependencyCondition::parse(condition.value);
10594                                if !parsed.is_known() {
10595                                    self.invalid(
10596                                        effective_span(field.value()),
10597                                        "dependency condition is not defined by Compose",
10598                                    );
10599                                }
10600                                dependency.condition = Some(ProjectValue {
10601                                    value: parsed,
10602                                    provenance: condition.provenance,
10603                                    sensitive: condition.sensitive,
10604                                });
10605                            }
10606                            "restart" => {
10607                                dependency.restart = self
10608                                    .located_boolean(field.value(), "dependency restart must be a boolean")
10609                                    .map(|value| ProjectValue::new(value.into_value(), field.value()));
10610                            }
10611                            "required" => {
10612                                dependency.required = self
10613                                    .located_boolean(field.value(), "dependency required must be a boolean")
10614                                    .map(|value| ProjectValue::new(value.into_value(), field.value()));
10615                            }
10616                            _ => dependency
10617                                .unmodeled_fields
10618                                .push(field_reference(&dependency_path, field)),
10619                        }
10620                    }
10621                    dependencies.push(ProjectValue::new(dependency, entry.value()));
10622                }
10623                ProjectDependsOn::Long(dependencies)
10624            }
10625            _ => {
10626                self.expected(value, "depends_on must be a sequence or mapping");
10627                return None;
10628            }
10629        };
10630        Some(ProjectValue::new(dependencies, value))
10631    }
10632
10633    fn healthcheck_test(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckTest>> {
10634        let span = effective_span(value);
10635        let test = match value.kind() {
10636            MergedValueKind::Scalar(scalar) => HealthcheckTest::String(Located::new(scalar.value().to_owned(), span)),
10637            MergedValueKind::Sequence(values) => {
10638                let mut items = Vec::new();
10639                for value in values {
10640                    items.push(self.located_string(value, "healthcheck test item must be a scalar")?);
10641                }
10642                let kind = items.first().map(|item| HealthcheckTestKind::parse(item.value()));
10643                HealthcheckTest::List {
10644                    span,
10645                    kind,
10646                    values: items,
10647                }
10648            }
10649            _ => {
10650                self.expected(value, "healthcheck test must be a scalar or sequence");
10651                return None;
10652            }
10653        };
10654        Some(ProjectValue::new(test, value))
10655    }
10656
10657    fn healthcheck_duration(
10658        &mut self,
10659        value: &MergedValue,
10660        message: &str,
10661    ) -> Option<ProjectValue<HealthcheckDuration>> {
10662        let scalar = self.scalar(value, message)?;
10663        Some(ProjectValue::new(
10664            HealthcheckDuration::parse(scalar.value().to_owned()),
10665            value,
10666        ))
10667    }
10668
10669    fn healthcheck_retries(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckRetries>> {
10670        let scalar = self.scalar(value, "healthcheck retries must be a scalar")?;
10671        Some(ProjectValue::new(
10672            HealthcheckRetries::parse(scalar.value().to_owned()),
10673            value,
10674        ))
10675    }
10676
10677    fn extra_hosts(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectExtraHosts>> {
10678        let mut entries = Vec::new();
10679        match value.kind() {
10680            MergedValueKind::Mapping(values) => {
10681                for entry in values {
10682                    let scalar = self.scalar(entry.value(), "extra_hosts address must be a scalar")?;
10683                    entries.push(ProjectExtraHost {
10684                        hostname: ProjectKey::from_entry(entry),
10685                        address: ProjectValue::new(HostAddress::parse(scalar.value().to_owned()), entry.value()),
10686                        syntax: EntrySyntax::Mapping,
10687                    });
10688                }
10689            }
10690            MergedValueKind::Sequence(values) => {
10691                for item in values {
10692                    let raw = self.located_string(item, "extra_hosts list item must be a scalar")?;
10693                    let parsed = ShortExtraHost::parse(raw);
10694                    let (Some(hostname), Some(address)) = (parsed.hostname(), parsed.address()) else {
10695                        self.invalid(
10696                            effective_span(item),
10697                            "extra_hosts entry must contain a hostname and address",
10698                        );
10699                        continue;
10700                    };
10701                    entries.push(ProjectExtraHost {
10702                        hostname: ProjectKey {
10703                            value: hostname.to_owned(),
10704                            sources: item.provenance().sources().to_vec(),
10705                            sensitive: item.is_sensitive(),
10706                        },
10707                        address: ProjectValue::new(address.clone(), item),
10708                        syntax: EntrySyntax::ListKeyValue,
10709                    });
10710                }
10711            }
10712            _ => {
10713                self.expected(value, "extra_hosts must be a mapping or sequence");
10714                return None;
10715            }
10716        }
10717        Some(ProjectValue::new(ProjectExtraHosts { entries }, value))
10718    }
10719
10720    fn project_string(&mut self, value: &MergedValue, description: &str) -> Option<ProjectValue<String>> {
10721        let scalar = self.scalar(value, &format!("{description} must be a non-null scalar"))?;
10722        Some(ProjectValue::new(scalar.value().to_owned(), value))
10723    }
10724
10725    fn non_empty_project_string(&mut self, value: &MergedValue, description: &str) -> Option<ProjectValue<String>> {
10726        let string = self.project_string(value, description)?;
10727        if string.value().is_empty() {
10728            self.invalid(effective_span(value), "build dockerfile must be a non-empty scalar");
10729            return None;
10730        }
10731        Some(string)
10732    }
10733
10734    fn string_collection(
10735        &mut self,
10736        value: &MergedValue,
10737        message: &str,
10738    ) -> Option<ProjectValue<Vec<ProjectValue<String>>>> {
10739        let Some(values) = value.as_sequence() else {
10740            self.expected(value, message);
10741            return None;
10742        };
10743        let mut strings = Vec::new();
10744        for value in values {
10745            let scalar = self.scalar(value, "sequence item must be a non-null scalar")?;
10746            strings.push(ProjectValue::new(scalar.value().to_owned(), value));
10747        }
10748        Some(ProjectValue::new(strings, value))
10749    }
10750
10751    fn strict_string_collection(
10752        &mut self,
10753        value: &MergedValue,
10754        description: &str,
10755    ) -> (ProjectStringCollection, ProjectInvalidServiceStringItems) {
10756        let Some(values) = value.as_sequence() else {
10757            self.expected(value, &format!("{description} must be a sequence"));
10758            return (None, Vec::new());
10759        };
10760        let mut strings = Vec::new();
10761        let mut invalid = Vec::new();
10762        for item in values {
10763            match item.kind() {
10764                MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
10765                    strings.push(ProjectValue::new(scalar.value().to_owned(), item));
10766                }
10767                _ => {
10768                    self.expected(item, &format!("{description} entries must be YAML string scalars"));
10769                    invalid.push(ProjectInvalidServiceStringItem {
10770                        span: effective_span(item),
10771                    });
10772                }
10773            }
10774        }
10775        (Some(ProjectValue::new(strings, value)), invalid)
10776    }
10777
10778    fn capability_drop(&mut self, value: &MergedValue) -> Option<ProjectValue<Vec<ProjectValue<CapabilityDropItem>>>> {
10779        let Some(values) = value.as_sequence() else {
10780            self.expected(value, "cap_drop must be a sequence of string scalars");
10781            return None;
10782        };
10783        let mut items = Vec::new();
10784        let mut seen = BTreeMap::new();
10785        for item in values {
10786            let scalar = match item.kind() {
10787                MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
10788                _ => {
10789                    self.expected(item, "cap_drop entries must be string scalars");
10790                    continue;
10791                }
10792            };
10793            let span = effective_span(item);
10794            if let Some(first) = seen.get(scalar.value()) {
10795                self.diagnostics.push(
10796                    Diagnostic::new(
10797                        CAP_DROP_DUPLICATE_ITEM,
10798                        Severity::Error,
10799                        "cap_drop entries must be unique exact strings",
10800                    )
10801                    .with_label(DiagnosticLabel::primary(span, "duplicate capability string"))
10802                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
10803                );
10804            } else {
10805                seen.insert(scalar.value().to_owned(), span);
10806            }
10807            let typed = CapabilityDropItem::new(Located::new(scalar.value().to_owned(), span));
10808            items.push(ProjectValue::new(typed, item));
10809        }
10810        Some(ProjectValue::new(items, value))
10811    }
10812
10813    fn capability_add(&mut self, value: &MergedValue) -> Option<ProjectValue<Vec<ProjectValue<CapabilityAddItem>>>> {
10814        let Some(values) = value.as_sequence() else {
10815            self.expected(value, "cap_add must be a sequence of string scalars");
10816            return None;
10817        };
10818        let mut items = Vec::new();
10819        let mut seen = BTreeMap::new();
10820        for item in values {
10821            let scalar = match item.kind() {
10822                MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
10823                _ => {
10824                    self.expected(item, "cap_add entries must be string scalars");
10825                    continue;
10826                }
10827            };
10828            let span = effective_span(item);
10829            if let Some(first) = seen.get(scalar.value()) {
10830                self.diagnostics.push(
10831                    Diagnostic::new(
10832                        CAP_ADD_DUPLICATE_ITEM,
10833                        Severity::Error,
10834                        "cap_add entries must be unique exact strings",
10835                    )
10836                    .with_label(DiagnosticLabel::primary(span, "duplicate capability string"))
10837                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
10838                );
10839            } else {
10840                seen.insert(scalar.value().to_owned(), span);
10841            }
10842            let typed = CapabilityAddItem::new(Located::new(scalar.value().to_owned(), span));
10843            items.push(ProjectValue::new(typed, item));
10844        }
10845        Some(ProjectValue::new(items, value))
10846    }
10847
10848    fn devices(
10849        &mut self,
10850        value: &MergedValue,
10851        service_path: &[String],
10852    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectDevice>>>> {
10853        let Some(values) = value.as_sequence() else {
10854            self.expected(value, "service devices must be a sequence");
10855            return None;
10856        };
10857        let mut devices = Vec::new();
10858        for (index, item) in values.iter().enumerate() {
10859            let mut path = service_path.to_vec();
10860            path.push("devices".to_owned());
10861            path.push(index.to_string());
10862            let device = match item.kind() {
10863                MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => ProjectDevice::Short(
10864                    ShortDevice::new(Located::new(scalar.value().to_owned(), effective_span(item))),
10865                ),
10866                MergedValueKind::Mapping(fields) => ProjectDevice::Long(self.long_device(item, fields, &path)),
10867                _ => {
10868                    self.diagnostics.push(
10869                        Diagnostic::new(
10870                            DEVICE_EXPECTED_FORM,
10871                            Severity::Error,
10872                            "service device must use string short syntax or mapping long syntax",
10873                        )
10874                        .with_label(DiagnosticLabel::primary(
10875                            effective_span(item),
10876                            "unsupported device form",
10877                        )),
10878                    );
10879                    continue;
10880                }
10881            };
10882            devices.push(ProjectValue::new(device, item));
10883        }
10884        Some(ProjectValue::new(devices, value))
10885    }
10886
10887    fn long_device(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> ProjectLongDevice {
10888        let mut device = ProjectLongDevice {
10889            source: None,
10890            target: None,
10891            permissions: None,
10892            extension_fields: Vec::new(),
10893            unknown_fields: Vec::new(),
10894        };
10895        for field in fields {
10896            let parsed = match field.key() {
10897                "source" | "target" | "permissions" => self.device_string(field.value(), field.key()),
10898                name if name.starts_with("x-") => {
10899                    device.extension_fields.push(field_reference(path, field));
10900                    continue;
10901                }
10902                _ => {
10903                    device.unknown_fields.push(field_reference(path, field));
10904                    continue;
10905                }
10906            };
10907            match field.key() {
10908                "source" => device.source = parsed,
10909                "target" => device.target = parsed,
10910                "permissions" => device.permissions = parsed,
10911                _ => unreachable!("unrecognized device fields continue before assignment"),
10912            }
10913        }
10914        if device.source.is_none() {
10915            self.missing(value, "long-syntax device is missing required string `source`");
10916        }
10917        device
10918    }
10919
10920    fn device_string(&mut self, value: &MergedValue, member: &str) -> Option<ProjectValue<String>> {
10921        let scalar = match value.kind() {
10922            MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
10923            _ => {
10924                self.diagnostics.push(
10925                    Diagnostic::new(
10926                        DEVICE_EXPECTED_STRING,
10927                        Severity::Error,
10928                        format!("device {member} must be a string scalar"),
10929                    )
10930                    .with_label(DiagnosticLabel::primary(
10931                        effective_span(value),
10932                        "unexpected long-device member form",
10933                    )),
10934                );
10935                return None;
10936            }
10937        };
10938        Some(ProjectValue::new(scalar.value().to_owned(), value))
10939    }
10940
10941    fn scalar<'value>(
10942        &mut self,
10943        value: &'value MergedValue,
10944        message: &str,
10945    ) -> Option<&'value crate::merge::MergedScalar> {
10946        let Some(scalar) = value.as_scalar() else {
10947            self.expected(value, message);
10948            return None;
10949        };
10950        Some(scalar)
10951    }
10952
10953    fn located_string(&mut self, value: &MergedValue, message: &str) -> Option<Located<String>> {
10954        let scalar = self.scalar(value, message)?;
10955        Some(Located::new(scalar.value().to_owned(), effective_span(value)))
10956    }
10957
10958    fn located_string_or_number(&mut self, value: &MergedValue, message: &str) -> Option<Located<ComposeScalar>> {
10959        let scalar = self.scalar(value, message)?;
10960        if !matches!(scalar.kind(), MergedScalarKind::String | MergedScalarKind::Number) {
10961            self.expected(value, message);
10962            return None;
10963        }
10964        Some(Located::new(compose_scalar_from_merged(scalar), effective_span(value)))
10965    }
10966
10967    fn strict_located_string(&mut self, value: &MergedValue, message: &str) -> Option<Located<String>> {
10968        let MergedValueKind::Scalar(scalar) = value.kind() else {
10969            self.expected(value, message);
10970            return None;
10971        };
10972        if scalar.kind() != MergedScalarKind::String || !scalar.is_strict_yaml_string() {
10973            self.expected(value, message);
10974            return None;
10975        }
10976        Some(Located::new(scalar.value().to_owned(), effective_span(value)))
10977    }
10978
10979    fn compose_scalar(&mut self, value: &MergedValue, message: &str) -> Option<ComposeScalar> {
10980        match value.kind() {
10981            MergedValueKind::Null(_) => Some(ComposeScalar::Null),
10982            MergedValueKind::Scalar(scalar) => Some(match scalar.kind() {
10983                MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
10984                MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
10985                MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
10986            }),
10987            _ => {
10988                self.expected(value, message);
10989                None
10990            }
10991        }
10992    }
10993
10994    fn mapping<'value>(&mut self, value: &'value MergedValue, message: &str) -> Option<&'value [MergedEntry]> {
10995        let Some(entries) = value.as_mapping() else {
10996            self.expected(value, message);
10997            return None;
10998        };
10999        Some(entries)
11000    }
11001
11002    fn expected(&mut self, value: &MergedValue, message: &str) {
11003        self.diagnostics.push(
11004            Diagnostic::new(PROJECT_EXPECTED_FORM, Severity::Error, message).with_label(DiagnosticLabel::primary(
11005                effective_span(value),
11006                "unexpected merged value form",
11007            )),
11008        );
11009    }
11010
11011    fn missing(&mut self, value: &MergedValue, message: &str) {
11012        self.diagnostics.push(
11013            Diagnostic::new(PROJECT_MISSING_FIELD, Severity::Error, message).with_label(DiagnosticLabel::primary(
11014                effective_span(value),
11015                "required field is missing",
11016            )),
11017        );
11018    }
11019
11020    fn invalid(&mut self, span: SourceSpan, message: &str) {
11021        self.diagnostics.push(
11022            Diagnostic::new(PROJECT_INVALID_VALUE, Severity::Error, message)
11023                .with_label(DiagnosticLabel::primary(span, "invalid native value")),
11024        );
11025    }
11026
11027    fn record_root_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
11028        self.root_unmodeled.push(field_reference(path, entry));
11029    }
11030
11031    fn record_pending_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
11032        self.pending_unmodeled.push(field_reference(path, entry));
11033    }
11034}
11035
11036fn effective_security_label_diagnostic(
11037    kind: &SecurityOptionKind,
11038    span: SourceSpan,
11039    candidates: &mut SecurityOptionCandidateCounts,
11040) -> Option<Diagnostic> {
11041    match kind {
11042        SecurityOptionKind::SecurityLabelDisable { .. } | SecurityOptionKind::SecurityLabelDisableNearMiss => {
11043            security_label_disable_diagnostic(kind, span, &mut candidates.security_label_disable)
11044        }
11045        SecurityOptionKind::SecurityLabelFileType { .. } | SecurityOptionKind::SecurityLabelFileTypeNearMiss => {
11046            security_label_filetype_diagnostic(kind, span, &mut candidates.security_label_filetype)
11047        }
11048        SecurityOptionKind::SecurityLabelLevel { .. } | SecurityOptionKind::SecurityLabelLevelNearMiss => {
11049            security_label_level_diagnostic(kind, span, &mut candidates.security_label_level)
11050        }
11051        SecurityOptionKind::SecurityLabelNested { .. } | SecurityOptionKind::SecurityLabelNestedNearMiss => {
11052            security_label_nested_diagnostic(kind, span, &mut candidates.security_label_nested)
11053        }
11054        SecurityOptionKind::SecurityLabelType { .. } | SecurityOptionKind::SecurityLabelTypeNearMiss => {
11055            security_label_type_diagnostic(kind, span, &mut candidates.security_label_type)
11056        }
11057        _ => None,
11058    }
11059}
11060
11061fn security_label_disable_diagnostic(
11062    kind: &SecurityOptionKind,
11063    span: SourceSpan,
11064    candidates: &mut usize,
11065) -> Option<Diagnostic> {
11066    match kind {
11067        SecurityOptionKind::SecurityLabelDisable { .. } => {
11068            *candidates += 1;
11069            (*candidates > 1).then(|| {
11070                Diagnostic::new(
11071                    SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT,
11072                    Severity::Warning,
11073                    "multiple SELinux label-disable candidates are retained; a consumer must resolve the conflict explicitly",
11074                )
11075                .with_label(DiagnosticLabel::primary(
11076                    span,
11077                    "additional effective SELinux label-disable candidate retained",
11078                ))
11079            })
11080        }
11081        SecurityOptionKind::SecurityLabelDisableNearMiss => Some(
11082            Diagnostic::new(
11083                SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS,
11084                Severity::Warning,
11085                "SELinux label-disable candidates require exact lowercase `label:disable` spelling without whitespace",
11086            )
11087            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
11088        ),
11089        _ => None,
11090    }
11091}
11092
11093fn security_label_filetype_diagnostic(
11094    kind: &SecurityOptionKind,
11095    span: SourceSpan,
11096    candidates: &mut usize,
11097) -> Option<Diagnostic> {
11098    match kind {
11099        SecurityOptionKind::SecurityLabelFileType { .. } => {
11100            *candidates += 1;
11101            (*candidates > 1).then(|| {
11102                Diagnostic::new(
11103                    SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT,
11104                    Severity::Warning,
11105                    "multiple SELinux label-filetype candidates are retained; a consumer must resolve the conflict explicitly",
11106                )
11107                .with_label(DiagnosticLabel::primary(
11108                    span,
11109                    "additional effective SELinux label-filetype candidate retained",
11110                ))
11111            })
11112        }
11113        SecurityOptionKind::SecurityLabelFileTypeNearMiss => Some(
11114            Diagnostic::new(
11115                SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS,
11116                Severity::Warning,
11117                "SELinux label-filetype candidates require exact lowercase `label:filetype:<type>` spelling without whitespace",
11118            )
11119            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
11120        ),
11121        _ => None,
11122    }
11123}
11124
11125fn security_label_level_diagnostic(
11126    kind: &SecurityOptionKind,
11127    span: SourceSpan,
11128    candidates: &mut usize,
11129) -> Option<Diagnostic> {
11130    match kind {
11131        SecurityOptionKind::SecurityLabelLevel { .. } => {
11132            *candidates += 1;
11133            (*candidates > 1).then(|| {
11134                Diagnostic::new(
11135                    SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT,
11136                    Severity::Warning,
11137                    "multiple SELinux label-level candidates are retained; a consumer must resolve the conflict explicitly",
11138                )
11139                .with_label(DiagnosticLabel::primary(
11140                    span,
11141                    "additional effective SELinux label-level candidate retained",
11142                ))
11143            })
11144        }
11145        SecurityOptionKind::SecurityLabelLevelNearMiss => Some(
11146            Diagnostic::new(
11147                SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS,
11148                Severity::Warning,
11149                "SELinux label-level candidates require exact lowercase `label:level:<level>` spelling without whitespace",
11150            )
11151            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
11152        ),
11153        _ => None,
11154    }
11155}
11156
11157fn security_label_nested_diagnostic(
11158    kind: &SecurityOptionKind,
11159    span: SourceSpan,
11160    candidates: &mut usize,
11161) -> Option<Diagnostic> {
11162    match kind {
11163        SecurityOptionKind::SecurityLabelNested { .. } => {
11164            *candidates += 1;
11165            (*candidates > 1).then(|| {
11166                Diagnostic::new(
11167                    SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT,
11168                    Severity::Warning,
11169                    "multiple SELinux label-nested candidates are retained; a consumer must resolve the conflict explicitly",
11170                )
11171                .with_label(DiagnosticLabel::primary(
11172                    span,
11173                    "additional effective SELinux label-nested candidate retained",
11174                ))
11175            })
11176        }
11177        SecurityOptionKind::SecurityLabelNestedNearMiss => Some(
11178            Diagnostic::new(
11179                SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS,
11180                Severity::Warning,
11181                "SELinux label-nested candidates require exact lowercase `label:nested` spelling without whitespace",
11182            )
11183            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
11184        ),
11185        _ => None,
11186    }
11187}
11188
11189fn security_label_type_diagnostic(
11190    kind: &SecurityOptionKind,
11191    span: SourceSpan,
11192    candidates: &mut usize,
11193) -> Option<Diagnostic> {
11194    match kind {
11195        SecurityOptionKind::SecurityLabelType { .. } => {
11196            *candidates += 1;
11197            (*candidates > 1).then(|| {
11198                Diagnostic::new(
11199                    SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT,
11200                    Severity::Warning,
11201                    "multiple SELinux label-type candidates are retained; a consumer must resolve the conflict explicitly",
11202                )
11203                .with_label(DiagnosticLabel::primary(
11204                    span,
11205                    "additional effective SELinux label-type candidate retained",
11206                ))
11207            })
11208        }
11209        SecurityOptionKind::SecurityLabelTypeNearMiss => Some(
11210            Diagnostic::new(
11211                SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS,
11212                Severity::Warning,
11213                "SELinux label-type candidates require exact lowercase `label:type:<type>` spelling with one non-empty whitespace-free type",
11214            )
11215            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
11216        ),
11217        _ => None,
11218    }
11219}
11220
11221impl Builder<'_> {
11222    fn ports(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<Vec<ProjectValue<Port>>>> {
11223        let Some(values) = value.as_sequence() else {
11224            self.expected(value, "service ports must be a sequence");
11225            return None;
11226        };
11227        let mut ports = Vec::new();
11228        for (index, item) in values.iter().enumerate() {
11229            let mut path = service_path.to_vec();
11230            path.push("ports".to_owned());
11231            path.push(index.to_string());
11232            let port = match item.kind() {
11233                MergedValueKind::Scalar(scalar) => Port::Short(ShortPort::parse(Located::new(
11234                    scalar.value().to_owned(),
11235                    effective_span(item),
11236                ))),
11237                MergedValueKind::Mapping(fields) => Port::Long(Box::new(self.long_port(item, fields, &path))),
11238                _ => {
11239                    self.expected(item, "service port must use scalar short syntax or mapping long syntax");
11240                    continue;
11241                }
11242            };
11243            ports.push(ProjectValue::new(port, item));
11244        }
11245        Some(ProjectValue::new(ports, value))
11246    }
11247
11248    fn long_port(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongPort {
11249        let mut port = LongPort::new(effective_span(value));
11250        let mut has_target = false;
11251        for field in fields {
11252            match field.key() {
11253                "target" => {
11254                    if let Some(value) = self.located_string(field.value(), "port target must be a scalar") {
11255                        port.set_target(value);
11256                        has_target = true;
11257                    }
11258                }
11259                "published" => self
11260                    .located_string(field.value(), "published port must be a scalar")
11261                    .into_iter()
11262                    .for_each(|value| port.set_published(value)),
11263                "host_ip" => self
11264                    .located_string(field.value(), "port host_ip must be a scalar")
11265                    .into_iter()
11266                    .for_each(|value| port.set_host_ip(value)),
11267                "protocol" => self
11268                    .located_string(field.value(), "port protocol must be a scalar")
11269                    .into_iter()
11270                    .for_each(|value| port.set_protocol(value)),
11271                "app_protocol" => self
11272                    .located_string(field.value(), "port app_protocol must be a scalar")
11273                    .into_iter()
11274                    .for_each(|value| port.set_app_protocol(value)),
11275                "mode" => self
11276                    .located_string(field.value(), "port mode must be a scalar")
11277                    .into_iter()
11278                    .for_each(|value| port.set_mode(value)),
11279                "name" => self
11280                    .located_string(field.value(), "port name must be a scalar")
11281                    .into_iter()
11282                    .for_each(|value| port.set_name(value)),
11283                _ => self.record_pending_unmodeled(path, field),
11284            }
11285        }
11286        if !has_target {
11287            self.missing(value, "long-syntax port is missing `target`");
11288        }
11289        port
11290    }
11291
11292    fn volumes(
11293        &mut self,
11294        value: &MergedValue,
11295        service_path: &[String],
11296    ) -> Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
11297        let Some(values) = value.as_sequence() else {
11298            self.expected(value, "service volumes must be a sequence");
11299            return None;
11300        };
11301        let mut mounts = Vec::new();
11302        for (index, item) in values.iter().enumerate() {
11303            let mut path = service_path.to_vec();
11304            path.push("volumes".to_owned());
11305            path.push(index.to_string());
11306            let mount = match item.kind() {
11307                MergedValueKind::Scalar(scalar) => VolumeMount::Short(ShortVolumeMount::new(Located::new(
11308                    scalar.value().to_owned(),
11309                    effective_span(item),
11310                ))),
11311                MergedValueKind::Mapping(fields) => VolumeMount::Long(Box::new(self.long_volume(item, fields, &path))),
11312                _ => {
11313                    self.expected(
11314                        item,
11315                        "service volume must use scalar short syntax or mapping long syntax",
11316                    );
11317                    continue;
11318                }
11319            };
11320            mounts.push(ProjectValue::new(mount, item));
11321        }
11322        Some(ProjectValue::new(mounts, value))
11323    }
11324
11325    /// Builds the additive project-aware view for long-mount option blocks without emitting a
11326    /// second copy of diagnostics already produced by [`Self::volumes`].
11327    fn project_volume_mount_options_collection(
11328        value: &MergedValue,
11329        service_path: &[String],
11330    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectVolumeMountOptions>>>> {
11331        let values = value.as_sequence()?;
11332        let mut mounts = Vec::new();
11333        for (index, item) in values.iter().enumerate() {
11334            let mut path = service_path.to_vec();
11335            path.push("volumes".to_owned());
11336            path.push(index.to_string());
11337            let options = match item.kind() {
11338                MergedValueKind::Scalar(_) => ProjectVolumeMountOptions::new(),
11339                MergedValueKind::Mapping(fields) => Self::project_volume_mount_options(fields, &path),
11340                _ => continue,
11341            };
11342            mounts.push(ProjectValue::new(options, item));
11343        }
11344        Some(ProjectValue::new(mounts, value))
11345    }
11346
11347    fn project_volume_mount_options(fields: &[MergedEntry], path: &[String]) -> ProjectVolumeMountOptions {
11348        let mut options = ProjectVolumeMountOptions::new();
11349        for field in fields {
11350            match field.key() {
11351                "type" | "source" | "target" | "read_only" => {}
11352                "consistency" => match Self::volume_option_string(field.value()) {
11353                    Some(value) => options.consistency = Some(value),
11354                    None => options.unmodeled_fields.push(field_reference(path, field)),
11355                },
11356                "bind" => match Self::project_bind_mount_options(field.value(), path) {
11357                    Some(value) => options.bind = Some(value),
11358                    None => options.unmodeled_fields.push(field_reference(path, field)),
11359                },
11360                "image" => match Self::project_image_mount_options(field.value(), path) {
11361                    Some(value) => options.image = Some(value),
11362                    None => options.unmodeled_fields.push(field_reference(path, field)),
11363                },
11364                "tmpfs" => match Self::project_tmpfs_mount_options(field.value(), path) {
11365                    Some(value) => options.tmpfs = Some(value),
11366                    None => options.unmodeled_fields.push(field_reference(path, field)),
11367                },
11368                "volume" => match Self::project_named_volume_mount_options(field.value(), path) {
11369                    Some(value) => options.volume = Some(value),
11370                    None => options.unmodeled_fields.push(field_reference(path, field)),
11371                },
11372                _ => options.unmodeled_fields.push(field_reference(path, field)),
11373            }
11374        }
11375        options
11376    }
11377
11378    fn project_bind_mount_options(value: &MergedValue, parent_path: &[String]) -> Option<ProjectBindMountOptions> {
11379        let fields = value.as_mapping()?;
11380        let mut options = ProjectBindMountOptions {
11381            recursive: None,
11382            extension_fields: Vec::new(),
11383            unknown_fields: Vec::new(),
11384        };
11385        let mut path = parent_path.to_vec();
11386        path.push("bind".to_owned());
11387        for field in fields {
11388            match field.key() {
11389                "recursive" => match Self::volume_option_string(field.value()) {
11390                    Some(value) => options.recursive = Some(value),
11391                    None => options.unknown_fields.push(field_reference(&path, field)),
11392                },
11393                "propagation" | "create_host_path" | "selinux" => {}
11394                key if key.starts_with("x-") => options.extension_fields.push(field_reference(&path, field)),
11395                _ => options.unknown_fields.push(field_reference(&path, field)),
11396            }
11397        }
11398        Some(options)
11399    }
11400
11401    fn project_image_mount_options(value: &MergedValue, parent_path: &[String]) -> Option<ProjectImageMountOptions> {
11402        let fields = value.as_mapping()?;
11403        let mut options = ProjectImageMountOptions {
11404            subpath: None,
11405            extension_fields: Vec::new(),
11406            unknown_fields: Vec::new(),
11407        };
11408        let mut path = parent_path.to_vec();
11409        path.push("image".to_owned());
11410        for field in fields {
11411            match field.key() {
11412                "subpath" => match Self::volume_option_string(field.value()) {
11413                    Some(value) => options.subpath = Some(value),
11414                    None => options.unknown_fields.push(field_reference(&path, field)),
11415                },
11416                key if key.starts_with("x-") => options.extension_fields.push(field_reference(&path, field)),
11417                _ => options.unknown_fields.push(field_reference(&path, field)),
11418            }
11419        }
11420        Some(options)
11421    }
11422
11423    fn project_tmpfs_mount_options(value: &MergedValue, parent_path: &[String]) -> Option<ProjectTmpfsMountOptions> {
11424        let fields = value.as_mapping()?;
11425        let mut options = ProjectTmpfsMountOptions {
11426            size: None,
11427            mode: None,
11428            extension_fields: Vec::new(),
11429            unknown_fields: Vec::new(),
11430        };
11431        let mut path = parent_path.to_vec();
11432        path.push("tmpfs".to_owned());
11433        for field in fields {
11434            match field.key() {
11435                "size" => match Self::volume_option_string_or_number(field.value()) {
11436                    Some(value) => options.size = Some(value),
11437                    None => options.unknown_fields.push(field_reference(&path, field)),
11438                },
11439                "mode" => match Self::volume_option_string_or_number(field.value()) {
11440                    Some(value) => options.mode = Some(value),
11441                    None => options.unknown_fields.push(field_reference(&path, field)),
11442                },
11443                key if key.starts_with("x-") => options.extension_fields.push(field_reference(&path, field)),
11444                _ => options.unknown_fields.push(field_reference(&path, field)),
11445            }
11446        }
11447        Some(options)
11448    }
11449
11450    fn project_named_volume_mount_options(
11451        value: &MergedValue,
11452        parent_path: &[String],
11453    ) -> Option<ProjectNamedVolumeMountOptions> {
11454        let fields = value.as_mapping()?;
11455        let mut options = ProjectNamedVolumeMountOptions {
11456            nocopy: None,
11457            subpath: None,
11458            labels: None,
11459            extension_fields: Vec::new(),
11460            unknown_fields: Vec::new(),
11461        };
11462        let mut path = parent_path.to_vec();
11463        path.push("volume".to_owned());
11464        for field in fields {
11465            match field.key() {
11466                "nocopy" => match Self::volume_option_boolean(field.value()) {
11467                    Some(value) => options.nocopy = Some(value),
11468                    None => options.unknown_fields.push(field_reference(&path, field)),
11469                },
11470                "subpath" => match Self::volume_option_string(field.value()) {
11471                    Some(value) => options.subpath = Some(value),
11472                    None => options.unknown_fields.push(field_reference(&path, field)),
11473                },
11474                "labels" => match Self::volume_option_labels(field.value()) {
11475                    Some(value) => options.labels = Some(value),
11476                    None => options.unknown_fields.push(field_reference(&path, field)),
11477                },
11478                key if key.starts_with("x-") => options.extension_fields.push(field_reference(&path, field)),
11479                _ => options.unknown_fields.push(field_reference(&path, field)),
11480            }
11481        }
11482        Some(options)
11483    }
11484
11485    fn volume_option_string(value: &MergedValue) -> Option<ProjectValue<String>> {
11486        let scalar = value.as_scalar()?;
11487        Some(ProjectValue::new(scalar.value().to_owned(), value))
11488    }
11489
11490    fn volume_option_string_or_number(value: &MergedValue) -> Option<ProjectValue<ComposeScalar>> {
11491        let scalar = value.as_scalar()?;
11492        matches!(scalar.kind(), MergedScalarKind::String | MergedScalarKind::Number)
11493            .then(|| ProjectValue::new(compose_scalar_from_merged(scalar), value))
11494    }
11495
11496    fn volume_option_boolean(value: &MergedValue) -> Option<ProjectValue<BooleanValue>> {
11497        let scalar = value.as_scalar()?;
11498        let boolean = if scalar.kind() == MergedScalarKind::Boolean {
11499            BooleanValue::Literal(scalar.value().eq_ignore_ascii_case("true"))
11500        } else if value.is_sensitive() && matches!(scalar.value(), "true" | "false") {
11501            BooleanValue::Literal(scalar.value() == "true")
11502        } else if scalar.value().contains('$') {
11503            BooleanValue::Expression(scalar.value().to_owned())
11504        } else {
11505            return None;
11506        };
11507        Some(ProjectValue::new(boolean, value))
11508    }
11509
11510    fn volume_option_labels(value: &MergedValue) -> Option<ProjectValue<Labels>> {
11511        let labels = match value.kind() {
11512            MergedValueKind::Sequence(values) => {
11513                let mut labels = Vec::new();
11514                for value in values {
11515                    let scalar = value.as_scalar()?;
11516                    labels.push(Located::new(scalar.value().to_owned(), effective_span(value)));
11517                }
11518                Labels::List {
11519                    span: effective_span(value),
11520                    values: labels,
11521                }
11522            }
11523            MergedValueKind::Mapping(entries) => {
11524                let mut labels = Vec::new();
11525                for entry in entries {
11526                    let scalar = Self::volume_option_compose_scalar(entry.value())?;
11527                    let span = effective_span(entry.value());
11528                    labels.push(KeyValueEntry::new(
11529                        Located::new(entry.key().to_owned(), entry_span(entry)),
11530                        Located::new(scalar, span),
11531                        span,
11532                    ));
11533                }
11534                Labels::Map {
11535                    span: effective_span(value),
11536                    entries: labels,
11537                }
11538            }
11539            _ => return None,
11540        };
11541        Some(ProjectValue::new(labels, value))
11542    }
11543
11544    fn volume_option_compose_scalar(value: &MergedValue) -> Option<ComposeScalar> {
11545        match value.kind() {
11546            MergedValueKind::Null(_) => Some(ComposeScalar::Null),
11547            MergedValueKind::Scalar(scalar) => Some(compose_scalar_from_merged(scalar)),
11548            _ => None,
11549        }
11550    }
11551
11552    fn long_volume(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongVolumeMount {
11553        let mut mount = LongVolumeMount::new(effective_span(value));
11554        let mut has_type = false;
11555        let mut has_target = false;
11556        for field in fields {
11557            match field.key() {
11558                "type" => {
11559                    if let Some(value) = self.located_string(field.value(), "volume type must be a scalar") {
11560                        mount.set_mount_type(Located::new(MountType::from_text(value.value().clone()), value.span()));
11561                        has_type = true;
11562                    }
11563                }
11564                "source" => self
11565                    .located_string(field.value(), "volume source must be a scalar")
11566                    .into_iter()
11567                    .for_each(|value| mount.set_source(value)),
11568                "target" => {
11569                    if let Some(value) = self.located_string(field.value(), "volume target must be a scalar") {
11570                        mount.set_target(value);
11571                        has_target = true;
11572                    }
11573                }
11574                "read_only" => self
11575                    .located_boolean(field.value(), "volume read_only must be a boolean")
11576                    .into_iter()
11577                    .for_each(|value| mount.set_read_only(value)),
11578                "consistency" => {
11579                    if let Some(value) = self.located_string(field.value(), "volume consistency must be a scalar") {
11580                        mount.set_consistency(value);
11581                    } else {
11582                        self.record_pending_unmodeled(path, field);
11583                    }
11584                }
11585                "bind" => self
11586                    .bind_options(field.value(), path)
11587                    .into_iter()
11588                    .for_each(|value| mount.set_bind(value)),
11589                "image" => {
11590                    if let Some(value) = self.image_mount_options(field.value(), path) {
11591                        mount.set_image(value);
11592                    } else {
11593                        self.record_pending_unmodeled(path, field);
11594                    }
11595                }
11596                "tmpfs" => {
11597                    if let Some(value) = self.tmpfs_mount_options(field.value(), path) {
11598                        mount.set_tmpfs(value);
11599                    } else {
11600                        self.record_pending_unmodeled(path, field);
11601                    }
11602                }
11603                "volume" => {
11604                    if let Some(value) = self.volume_mount_options(field.value(), path) {
11605                        mount.set_volume(value);
11606                    } else {
11607                        self.record_pending_unmodeled(path, field);
11608                    }
11609                }
11610                _ => self.record_pending_unmodeled(path, field),
11611            }
11612        }
11613        if !has_type {
11614            self.missing(value, "long-syntax volume is missing `type`");
11615        }
11616        if !has_target {
11617            self.missing(value, "long-syntax volume is missing `target`");
11618        }
11619        mount
11620    }
11621
11622    fn grants(
11623        &mut self,
11624        value: &MergedValue,
11625        service_path: &[String],
11626        kind: &str,
11627    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
11628        self.grants_at(value, service_path, &format!("{kind}s"), kind).0
11629    }
11630
11631    fn build_secret_grants(
11632        &mut self,
11633        value: &MergedValue,
11634        build_path: &[String],
11635    ) -> (Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>, bool) {
11636        self.grants_at(value, build_path, "secrets", "build secret")
11637    }
11638
11639    fn grants_at(
11640        &mut self,
11641        value: &MergedValue,
11642        parent_path: &[String],
11643        field_name: &str,
11644        kind: &str,
11645    ) -> (Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>, bool) {
11646        let Some(values) = value.as_sequence() else {
11647            self.expected(value, &format!("{kind}s must be a sequence"));
11648            return (None, true);
11649        };
11650        let mut grants = Vec::new();
11651        let mut malformed = false;
11652        for (index, item) in values.iter().enumerate() {
11653            let mut path = parent_path.to_vec();
11654            path.push(field_name.to_owned());
11655            path.push(index.to_string());
11656            let grant = match item.kind() {
11657                MergedValueKind::Scalar(scalar) => ProjectGrant::Short(scalar.value().to_owned()),
11658                MergedValueKind::Mapping(fields) => {
11659                    let grant = self.long_grant(item, fields, &path, kind);
11660                    malformed |= grant.source().is_none();
11661                    ProjectGrant::Long(Box::new(grant))
11662                }
11663                _ => {
11664                    self.expected(
11665                        item,
11666                        &format!("{kind} must use scalar short syntax or mapping long syntax"),
11667                    );
11668                    malformed = true;
11669                    continue;
11670                }
11671            };
11672            grants.push(ProjectValue::new(grant, item));
11673        }
11674        (Some(ProjectValue::new(grants, value)), malformed)
11675    }
11676
11677    fn long_grant(
11678        &mut self,
11679        value: &MergedValue,
11680        fields: &[MergedEntry],
11681        path: &[String],
11682        kind: &str,
11683    ) -> ProjectLongGrant {
11684        let mut grant = ProjectLongGrant {
11685            source: None,
11686            target: None,
11687            uid: None,
11688            gid: None,
11689            mode: None,
11690            unmodeled_fields: Vec::new(),
11691        };
11692        for field in fields {
11693            let parsed = match field.key() {
11694                "source" => self.project_string(field.value(), &format!("{kind} source")),
11695                "target" => self.project_string(field.value(), &format!("{kind} target")),
11696                "uid" => self.project_string(field.value(), &format!("{kind} uid")),
11697                "gid" => self.project_string(field.value(), &format!("{kind} gid")),
11698                "mode" => self.project_string(field.value(), &format!("{kind} mode")),
11699                _ => {
11700                    grant.unmodeled_fields.push(field_reference(path, field));
11701                    continue;
11702                }
11703            };
11704            if parsed.is_none() {
11705                grant.unmodeled_fields.push(field_reference(path, field));
11706            }
11707            match field.key() {
11708                "source" => grant.source = parsed,
11709                "target" => grant.target = parsed,
11710                "uid" => grant.uid = parsed,
11711                "gid" => grant.gid = parsed,
11712                "mode" => grant.mode = parsed,
11713                _ => unreachable!("unrecognized grant fields continue before assignment"),
11714            }
11715        }
11716        if grant.source.is_none() {
11717            self.missing(value, &format!("long-syntax {kind} is missing `source`"));
11718        }
11719        grant
11720    }
11721
11722    fn bind_options(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<BindOptions> {
11723        let fields = self.mapping(value, "volume bind options must be a mapping")?;
11724        let mut bind = BindOptions::new(effective_span(value));
11725        let mut path = parent_path.to_vec();
11726        path.push("bind".to_owned());
11727        for field in fields {
11728            match field.key() {
11729                "propagation" => self
11730                    .located_string(field.value(), "bind propagation must be a scalar")
11731                    .into_iter()
11732                    .for_each(|value| bind.set_propagation(value)),
11733                "create_host_path" => self
11734                    .located_boolean(field.value(), "bind create_host_path must be a boolean")
11735                    .into_iter()
11736                    .for_each(|value| bind.set_create_host_path(value)),
11737                "selinux" => {
11738                    if let Some(value) = self.located_string(field.value(), "bind SELinux mode must be a scalar") {
11739                        let mode = match value.value().as_str() {
11740                            "z" => Some(SelinuxRelabel::Shared),
11741                            "Z" => Some(SelinuxRelabel::Private),
11742                            _ => None,
11743                        };
11744                        if let Some(mode) = mode {
11745                            bind.set_selinux(Located::new(mode, value.span()));
11746                        } else {
11747                            self.invalid(value.span(), "bind SELinux mode must be `z` or `Z`");
11748                        }
11749                    }
11750                }
11751                "recursive" => {
11752                    if let Some(value) = self.located_string(field.value(), "bind recursive mode must be a scalar") {
11753                        bind.set_recursive(value);
11754                    } else {
11755                        self.record_pending_unmodeled(&path, field);
11756                    }
11757                }
11758                _ => self.record_pending_unmodeled(&path, field),
11759            }
11760        }
11761        Some(bind)
11762    }
11763
11764    fn image_mount_options(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ImageMountOptions> {
11765        let fields = self.mapping(value, "volume image options must be a mapping")?;
11766        let mut options = ImageMountOptions::new(effective_span(value));
11767        let mut path = parent_path.to_vec();
11768        path.push("image".to_owned());
11769        for field in fields {
11770            match field.key() {
11771                "subpath" => {
11772                    if let Some(value) = self.located_string(field.value(), "image mount subpath must be a scalar") {
11773                        options.set_subpath(value);
11774                    } else {
11775                        self.record_pending_unmodeled(&path, field);
11776                    }
11777                }
11778                _ => self.record_pending_unmodeled(&path, field),
11779            }
11780        }
11781        Some(options)
11782    }
11783
11784    fn tmpfs_mount_options(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<TmpfsMountOptions> {
11785        let fields = self.mapping(value, "volume tmpfs options must be a mapping")?;
11786        let mut options = TmpfsMountOptions::new(effective_span(value));
11787        let mut path = parent_path.to_vec();
11788        path.push("tmpfs".to_owned());
11789        for field in fields {
11790            match field.key() {
11791                "size" => {
11792                    if let Some(value) =
11793                        self.located_string_or_number(field.value(), "tmpfs size must be a number or string")
11794                    {
11795                        options.set_size(value);
11796                    } else {
11797                        self.record_pending_unmodeled(&path, field);
11798                    }
11799                }
11800                "mode" => {
11801                    if let Some(value) =
11802                        self.located_string_or_number(field.value(), "tmpfs mode must be a number or string")
11803                    {
11804                        options.set_mode(value);
11805                    } else {
11806                        self.record_pending_unmodeled(&path, field);
11807                    }
11808                }
11809                _ => self.record_pending_unmodeled(&path, field),
11810            }
11811        }
11812        Some(options)
11813    }
11814
11815    fn volume_mount_options(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<VolumeMountOptions> {
11816        let fields = self.mapping(value, "named-volume options must be a mapping")?;
11817        let mut options = VolumeMountOptions::new(effective_span(value));
11818        let mut path = parent_path.to_vec();
11819        path.push("volume".to_owned());
11820        for field in fields {
11821            match field.key() {
11822                "nocopy" => {
11823                    if let Some(value) = self.located_boolean(field.value(), "volume nocopy must be a boolean") {
11824                        options.set_nocopy(value);
11825                    } else {
11826                        self.record_pending_unmodeled(&path, field);
11827                    }
11828                }
11829                "subpath" => {
11830                    if let Some(value) = self.located_string(field.value(), "volume subpath must be a scalar") {
11831                        options.set_subpath(value);
11832                    } else {
11833                        self.record_pending_unmodeled(&path, field);
11834                    }
11835                }
11836                "labels" => {
11837                    if let Some(value) = self.labels(field.value()) {
11838                        options.set_labels(value);
11839                    } else {
11840                        self.record_pending_unmodeled(&path, field);
11841                    }
11842                }
11843                _ => self.record_pending_unmodeled(&path, field),
11844            }
11845        }
11846        Some(options)
11847    }
11848
11849    fn service_networks(
11850        &mut self,
11851        value: &MergedValue,
11852        service_path: &[String],
11853    ) -> Option<ProjectValue<ServiceNetworks>> {
11854        let span = effective_span(value);
11855        let networks = match value.kind() {
11856            MergedValueKind::Sequence(values) => {
11857                let mut names = Vec::new();
11858                for value in values {
11859                    names.push(self.located_string(value, "service network name must be a scalar")?);
11860                }
11861                ServiceNetworks::Short { span, names }
11862            }
11863            MergedValueKind::Mapping(entries) => {
11864                let mut networks = Vec::new();
11865                for entry in entries {
11866                    let mut path = service_path.to_vec();
11867                    path.push("networks".to_owned());
11868                    path.push(entry.key().to_owned());
11869                    networks.push(self.service_network(entry, &path)?);
11870                }
11871                ServiceNetworks::Long { span, networks }
11872            }
11873            _ => {
11874                self.expected(value, "service networks must be a sequence or mapping");
11875                return None;
11876            }
11877        };
11878        Some(ProjectValue::new(networks, value))
11879    }
11880
11881    fn service_network(&mut self, entry: &MergedEntry, path: &[String]) -> Option<ServiceNetwork> {
11882        let value = entry.value();
11883        let span = effective_span(value);
11884        let mut network = ServiceNetwork::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
11885        let fields = match value.kind() {
11886            MergedValueKind::Null(_) => return Some(network),
11887            MergedValueKind::Mapping(fields) => fields,
11888            _ => {
11889                self.expected(value, "service network attachment must be a mapping or null");
11890                return None;
11891            }
11892        };
11893        for field in fields {
11894            match field.key() {
11895                "aliases" => self
11896                    .located_string_sequence(field.value(), "network aliases must be a sequence")
11897                    .into_iter()
11898                    .for_each(|value| network.set_aliases(value)),
11899                "interface_name" => self
11900                    .located_string(field.value(), "network interface_name must be a scalar")
11901                    .into_iter()
11902                    .for_each(|value| network.set_interface_name(value)),
11903                "ipv4_address" => self
11904                    .located_string(field.value(), "network ipv4_address must be a scalar")
11905                    .into_iter()
11906                    .for_each(|value| network.set_ipv4_address(value)),
11907                "ipv6_address" => self
11908                    .located_string(field.value(), "network ipv6_address must be a scalar")
11909                    .into_iter()
11910                    .for_each(|value| network.set_ipv6_address(value)),
11911                "link_local_ips" => self
11912                    .located_string_sequence(field.value(), "link_local_ips must be a sequence")
11913                    .into_iter()
11914                    .for_each(|value| network.set_link_local_ips(value)),
11915                "mac_address" => self
11916                    .located_string(field.value(), "network mac_address must be a scalar")
11917                    .into_iter()
11918                    .for_each(|value| network.set_mac_address(value)),
11919                "driver_opts" => self
11920                    .key_value_mapping(field.value(), "network driver_opts must be a mapping")
11921                    .into_iter()
11922                    .for_each(|value| network.set_driver_opts(value)),
11923                "gw_priority" => self
11924                    .located_string(field.value(), "network gw_priority must be a scalar")
11925                    .into_iter()
11926                    .for_each(|value| network.set_gw_priority(value)),
11927                "priority" => self
11928                    .located_string(field.value(), "network priority must be a scalar")
11929                    .into_iter()
11930                    .for_each(|value| network.set_priority(value)),
11931                _ => self.record_pending_unmodeled(path, field),
11932            }
11933        }
11934        Some(network)
11935    }
11936
11937    fn located_boolean(&mut self, value: &MergedValue, message: &str) -> Option<Located<BooleanValue>> {
11938        let scalar = self.scalar(value, message)?;
11939        let boolean = if scalar.kind() == MergedScalarKind::Boolean {
11940            BooleanValue::Literal(scalar.value().eq_ignore_ascii_case("true"))
11941        } else if value.is_sensitive() && matches!(scalar.value(), "true" | "false") {
11942            BooleanValue::Literal(scalar.value() == "true")
11943        } else if scalar.value().contains('$') {
11944            BooleanValue::Expression(scalar.value().to_owned())
11945        } else {
11946            self.invalid(effective_span(value), message);
11947            return None;
11948        };
11949        Some(Located::new(boolean, effective_span(value)))
11950    }
11951
11952    fn resource_external(&mut self, value: &MergedValue, kind: &str) -> Option<ResourceExternal> {
11953        let Some(entries) = value.as_mapping() else {
11954            return self
11955                .located_boolean(value, &format!("{kind} external must be a boolean"))
11956                .map(ResourceExternal::Boolean);
11957        };
11958        let mut name_mapping = ExternalNameMapping::new(effective_span(value));
11959        for entry in entries {
11960            match entry.key() {
11961                "name" => match self.strict_located_string(
11962                    entry.value(),
11963                    "deprecated external name must be a non-empty YAML string",
11964                ) {
11965                    Some(name) if !name.value().is_empty() => name_mapping.set_name(name),
11966                    Some(_) | None => name_mapping.push_unknown(FieldReference::generated(
11967                        Located::new(entry.key().to_owned(), entry_span(entry)),
11968                        entry_span(entry),
11969                        Some(effective_span(entry.value())),
11970                    )),
11971                },
11972                key if key.starts_with("x-") => name_mapping.push_extension(FieldReference::generated(
11973                    Located::new(key.to_owned(), entry_span(entry)),
11974                    entry_span(entry),
11975                    Some(effective_span(entry.value())),
11976                )),
11977                _ => name_mapping.push_unknown(FieldReference::generated(
11978                    Located::new(entry.key().to_owned(), entry_span(entry)),
11979                    entry_span(entry),
11980                    Some(effective_span(entry.value())),
11981                )),
11982            }
11983        }
11984        if name_mapping.name().is_none() {
11985            self.missing(value, "deprecated external mapping requires a non-empty `name` string");
11986        }
11987        self.diagnostics.push(
11988            Diagnostic::new(
11989                crate::model::RESOURCE_EXTERNAL_NAME_MAPPING_DEPRECATED,
11990                Severity::Warning,
11991                format!("{kind} `external.name` mapping syntax is deprecated"),
11992            )
11993            .with_label(DiagnosticLabel::primary(
11994                name_mapping.span(),
11995                "use `name: VALUE` with `external: true` instead",
11996            )),
11997        );
11998        Some(ResourceExternal::NameMapping(Box::new(name_mapping)))
11999    }
12000
12001    fn validate_resource_external_name(
12002        &mut self,
12003        external: Option<&ResourceExternal>,
12004        modern: Option<&Located<String>>,
12005        fallback: SourceSpan,
12006    ) {
12007        let (Some(ResourceExternal::NameMapping(name_mapping)), Some(modern)) = (external, modern) else {
12008            return;
12009        };
12010        self.diagnostics.push(
12011            Diagnostic::new(
12012                crate::model::RESOURCE_EXTERNAL_NAME_CONFLICT,
12013                Severity::Error,
12014                "resource cannot use both `name` and deprecated `external.name` mapping syntax",
12015            )
12016            .with_label(DiagnosticLabel::primary(modern.span(), "resource name"))
12017            .with_label(DiagnosticLabel::secondary(
12018                name_mapping.name().map_or_else(|| name_mapping.span(), Located::span),
12019                "deprecated external name",
12020            ))
12021            .with_label(DiagnosticLabel::secondary(fallback, "both values remain retained")),
12022        );
12023    }
12024
12025    fn validate_external_creation_configuration(
12026        &mut self,
12027        external: Option<&ResourceExternal>,
12028        fields: &[Option<SourceSpan>],
12029        kind: &str,
12030    ) {
12031        if !external.is_some_and(ResourceExternal::is_explicitly_external) {
12032            return;
12033        }
12034        let Some(span) = fields.iter().flatten().next().copied() else {
12035            return;
12036        };
12037        self.diagnostics.push(
12038            Diagnostic::new(
12039                crate::model::RESOURCE_EXTERNAL_CREATION_CONFIGURATION,
12040                Severity::Error,
12041                format!("external {kind} cannot also configure creation-time metadata"),
12042            )
12043            .with_label(DiagnosticLabel::primary(span, "metadata remains retained for review")),
12044        );
12045    }
12046
12047    fn located_string_sequence(&mut self, value: &MergedValue, message: &str) -> Option<Vec<Located<String>>> {
12048        let Some(values) = value.as_sequence() else {
12049            self.expected(value, message);
12050            return None;
12051        };
12052        let mut strings = Vec::new();
12053        for value in values {
12054            strings.push(self.located_string(value, "sequence item must be a scalar")?);
12055        }
12056        Some(strings)
12057    }
12058
12059    fn key_value_mapping(&mut self, value: &MergedValue, message: &str) -> Option<Vec<KeyValueEntry>> {
12060        let Some(entries) = value.as_mapping() else {
12061            self.expected(value, message);
12062            return None;
12063        };
12064        let mut values = Vec::new();
12065        for entry in entries {
12066            let scalar = self.compose_scalar(entry.value(), "mapping value must be a scalar or null")?;
12067            let value_span = effective_span(entry.value());
12068            values.push(KeyValueEntry::new(
12069                Located::new(entry.key().to_owned(), entry_span(entry)),
12070                Located::new(scalar, value_span),
12071                value_span,
12072            ));
12073        }
12074        Some(values)
12075    }
12076
12077    fn network_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<NetworkDefinition>> {
12078        let Some(entries) = self.mapping(value, "top-level networks must be a mapping") else {
12079            return Vec::new();
12080        };
12081        entries
12082            .iter()
12083            .filter_map(|entry| {
12084                let definition = self.network_definition(entry)?;
12085                Some(ProjectResource {
12086                    name: ProjectKey::from_entry(entry),
12087                    definition: ProjectValue::new(definition, entry.value()),
12088                })
12089            })
12090            .collect()
12091    }
12092
12093    fn network_definition(&mut self, entry: &MergedEntry) -> Option<NetworkDefinition> {
12094        let value = entry.value();
12095        let span = effective_span(value);
12096        let mut network = NetworkDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
12097        let fields = match value.kind() {
12098            MergedValueKind::Null(_) => return Some(network),
12099            MergedValueKind::Mapping(fields) => fields,
12100            _ => {
12101                self.expected(value, "network definition must be a mapping or null");
12102                return None;
12103            }
12104        };
12105        let path = ["networks".to_owned(), entry.key().to_owned()];
12106        for field in fields {
12107            match field.key() {
12108                "driver" => self
12109                    .located_string(field.value(), "network driver must be a scalar")
12110                    .into_iter()
12111                    .for_each(|value| network.set_driver(value)),
12112                "driver_opts" => self
12113                    .key_value_mapping(field.value(), "network driver_opts must be a mapping")
12114                    .into_iter()
12115                    .for_each(|value| network.set_driver_opts(value)),
12116                "attachable" => self
12117                    .located_boolean(field.value(), "network attachable must be a boolean")
12118                    .into_iter()
12119                    .for_each(|value| network.set_attachable(value)),
12120                "enable_ipv4" => self
12121                    .located_boolean(field.value(), "network enable_ipv4 must be a boolean")
12122                    .into_iter()
12123                    .for_each(|value| network.set_enable_ipv4(value)),
12124                "enable_ipv6" => self
12125                    .located_boolean(field.value(), "network enable_ipv6 must be a boolean")
12126                    .into_iter()
12127                    .for_each(|value| network.set_enable_ipv6(value)),
12128                "external" => self
12129                    .resource_external(field.value(), "network")
12130                    .into_iter()
12131                    .for_each(|value| network.set_external(value)),
12132                "internal" => self
12133                    .located_boolean(field.value(), "network internal must be a boolean")
12134                    .into_iter()
12135                    .for_each(|value| network.set_internal(value)),
12136                "ipam" => self
12137                    .ipam(field.value(), &path)
12138                    .into_iter()
12139                    .for_each(|value| network.set_ipam(value)),
12140                "labels" => self
12141                    .labels(field.value())
12142                    .into_iter()
12143                    .for_each(|value| network.set_labels(value)),
12144                "name" => self
12145                    .located_string(field.value(), "network custom name must be a scalar")
12146                    .into_iter()
12147                    .for_each(|value| network.set_custom_name(value)),
12148                _ => self.record_root_unmodeled(&path, field),
12149            }
12150        }
12151        self.validate_resource_external_name(network.external(), network.custom_name(), network.span());
12152        Some(network)
12153    }
12154
12155    fn ipam(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Ipam> {
12156        let fields = self.mapping(value, "network IPAM must be a mapping")?;
12157        let mut ipam = Ipam::new(effective_span(value));
12158        let mut path = parent_path.to_vec();
12159        path.push("ipam".to_owned());
12160        for field in fields {
12161            match field.key() {
12162                "driver" => self
12163                    .located_string(field.value(), "IPAM driver must be a scalar")
12164                    .into_iter()
12165                    .for_each(|value| ipam.set_driver(value)),
12166                "config" => self
12167                    .ipam_configs(field.value(), &path)
12168                    .into_iter()
12169                    .for_each(|value| ipam.set_config(value)),
12170                "options" => self
12171                    .key_value_mapping(field.value(), "IPAM options must be a mapping")
12172                    .into_iter()
12173                    .for_each(|value| ipam.set_options(value)),
12174                _ => self.record_root_unmodeled(&path, field),
12175            }
12176        }
12177        Some(ipam)
12178    }
12179
12180    fn ipam_configs(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Vec<IpamConfig>> {
12181        let Some(values) = value.as_sequence() else {
12182            self.expected(value, "IPAM config must be a sequence");
12183            return None;
12184        };
12185        let mut configs = Vec::new();
12186        for (index, value) in values.iter().enumerate() {
12187            let Some(fields) = value.as_mapping() else {
12188                self.expected(value, "IPAM config entry must be a mapping");
12189                continue;
12190            };
12191            let mut config = IpamConfig::new(effective_span(value));
12192            let mut path = parent_path.to_vec();
12193            path.push("config".to_owned());
12194            path.push(index.to_string());
12195            for field in fields {
12196                match field.key() {
12197                    "subnet" => self
12198                        .located_string(field.value(), "IPAM subnet must be a scalar")
12199                        .into_iter()
12200                        .for_each(|value| config.set_subnet(value)),
12201                    "ip_range" => self
12202                        .located_string(field.value(), "IPAM ip_range must be a scalar")
12203                        .into_iter()
12204                        .for_each(|value| config.set_ip_range(value)),
12205                    "gateway" => self
12206                        .located_string(field.value(), "IPAM gateway must be a scalar")
12207                        .into_iter()
12208                        .for_each(|value| config.set_gateway(value)),
12209                    "aux_addresses" => self
12210                        .key_value_mapping(field.value(), "IPAM aux_addresses must be a mapping")
12211                        .into_iter()
12212                        .for_each(|value| config.set_aux_addresses(value)),
12213                    _ => self.record_root_unmodeled(&path, field),
12214                }
12215            }
12216            configs.push(config);
12217        }
12218        Some(configs)
12219    }
12220
12221    fn volume_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<VolumeDefinition>> {
12222        let Some(entries) = self.mapping(value, "top-level volumes must be a mapping") else {
12223            return Vec::new();
12224        };
12225        entries
12226            .iter()
12227            .filter_map(|entry| {
12228                let definition = self.volume_definition(entry)?;
12229                Some(ProjectResource {
12230                    name: ProjectKey::from_entry(entry),
12231                    definition: ProjectValue::new(definition, entry.value()),
12232                })
12233            })
12234            .collect()
12235    }
12236
12237    fn volume_definition(&mut self, entry: &MergedEntry) -> Option<VolumeDefinition> {
12238        let value = entry.value();
12239        let span = effective_span(value);
12240        let mut volume = VolumeDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
12241        let fields = match value.kind() {
12242            MergedValueKind::Null(_) => return Some(volume),
12243            MergedValueKind::Mapping(fields) => fields,
12244            _ => {
12245                self.expected(value, "volume definition must be a mapping or null");
12246                return None;
12247            }
12248        };
12249        let path = ["volumes".to_owned(), entry.key().to_owned()];
12250        for field in fields {
12251            match field.key() {
12252                "driver" => self
12253                    .located_string(field.value(), "volume driver must be a scalar")
12254                    .into_iter()
12255                    .for_each(|value| volume.set_driver(value)),
12256                "driver_opts" => self
12257                    .key_value_mapping(field.value(), "volume driver_opts must be a mapping")
12258                    .into_iter()
12259                    .for_each(|value| volume.set_driver_opts(value)),
12260                "external" => self
12261                    .resource_external(field.value(), "volume")
12262                    .into_iter()
12263                    .for_each(|value| volume.set_external(value)),
12264                "labels" => self
12265                    .labels(field.value())
12266                    .into_iter()
12267                    .for_each(|value| volume.set_labels(value)),
12268                "name" => self
12269                    .located_string(field.value(), "volume custom name must be a scalar")
12270                    .into_iter()
12271                    .for_each(|value| volume.set_custom_name(value)),
12272                _ => self.record_root_unmodeled(&path, field),
12273            }
12274        }
12275        self.validate_external_volume_driver_configuration(&volume);
12276        self.validate_external_volume_labels_configuration(&volume);
12277        self.validate_resource_external_name(volume.external(), volume.custom_name(), volume.span());
12278        Some(volume)
12279    }
12280
12281    fn validate_external_volume_driver_configuration(&mut self, volume: &VolumeDefinition) {
12282        if !volume.external().is_some_and(ResourceExternal::is_explicitly_external)
12283            || (volume.driver().is_none() && volume.driver_opts().is_empty())
12284        {
12285            return;
12286        }
12287        let span = volume
12288            .driver()
12289            .map(Located::span)
12290            .or_else(|| volume.driver_opts().first().map(KeyValueEntry::span))
12291            .unwrap_or_else(|| volume.span());
12292        self.diagnostics.push(
12293            Diagnostic::new(
12294                VOLUME_EXTERNAL_DRIVER_CONFIGURATION,
12295                Severity::Error,
12296                "external volume cannot also configure `driver` or `driver_opts`",
12297            )
12298            .with_label(DiagnosticLabel::primary(
12299                span,
12300                "driver configuration remains retained for review",
12301            )),
12302        );
12303    }
12304
12305    fn validate_external_volume_labels_configuration(&mut self, volume: &VolumeDefinition) {
12306        if !volume.external().is_some_and(ResourceExternal::is_explicitly_external) || volume.labels().is_none() {
12307            return;
12308        }
12309        let span = volume.labels().map_or_else(|| volume.span(), Labels::span);
12310        self.diagnostics.push(
12311            Diagnostic::new(
12312                VOLUME_EXTERNAL_LABELS_CONFIGURATION,
12313                Severity::Error,
12314                "external volume cannot also configure `labels`",
12315            )
12316            .with_label(DiagnosticLabel::primary(span, "labels remain retained for review")),
12317        );
12318    }
12319
12320    fn config_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<ConfigDefinition>> {
12321        let Some(entries) = self.mapping(value, "top-level configs must be a mapping") else {
12322            return Vec::new();
12323        };
12324        entries
12325            .iter()
12326            .filter_map(|entry| {
12327                let definition = self.config_definition(entry)?;
12328                Some(ProjectResource {
12329                    name: ProjectKey::from_entry(entry),
12330                    definition: ProjectValue::new(definition, entry.value()),
12331                })
12332            })
12333            .collect()
12334    }
12335
12336    fn config_definition(&mut self, entry: &MergedEntry) -> Option<ConfigDefinition> {
12337        let value = entry.value();
12338        let span = effective_span(value);
12339        let mut config = ConfigDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
12340        let fields = match value.kind() {
12341            MergedValueKind::Null(_) => return Some(config),
12342            MergedValueKind::Mapping(fields) => fields,
12343            _ => {
12344                self.expected(value, "config definition must be a mapping or null");
12345                return None;
12346            }
12347        };
12348        let path = ["configs".to_owned(), entry.key().to_owned()];
12349        for field in fields {
12350            match field.key() {
12351                "file" => self
12352                    .located_string(field.value(), "config file must be a scalar")
12353                    .into_iter()
12354                    .for_each(|value| config.set_file(value)),
12355                "environment" => self
12356                    .located_string(field.value(), "config environment must be a scalar")
12357                    .into_iter()
12358                    .for_each(|value| config.set_environment(value)),
12359                "content" => self
12360                    .located_string(field.value(), "config content must be a scalar")
12361                    .into_iter()
12362                    .for_each(|value| config.set_content(value)),
12363                "external" => self
12364                    .resource_external(field.value(), "config")
12365                    .into_iter()
12366                    .for_each(|value| config.set_external(value)),
12367                "labels" => self
12368                    .labels(field.value())
12369                    .into_iter()
12370                    .for_each(|value| config.set_labels(value)),
12371                "template_driver" => self
12372                    .strict_located_string(field.value(), "config template_driver must be a YAML string")
12373                    .into_iter()
12374                    .for_each(|value| config.set_template_driver(value)),
12375                "name" => self
12376                    .located_string(field.value(), "config custom name must be a scalar")
12377                    .into_iter()
12378                    .for_each(|value| config.set_custom_name(value)),
12379                _ => self.record_root_unmodeled(&path, field),
12380            }
12381        }
12382        self.validate_resource_external_name(config.external(), config.custom_name(), config.span());
12383        self.validate_external_creation_configuration(
12384            config.external(),
12385            &[
12386                config.file().map(Located::span),
12387                config.environment().map(Located::span),
12388                config.content().map(Located::span),
12389                config.labels().map(Labels::span),
12390                config.template_driver().map(Located::span),
12391            ],
12392            "config",
12393        );
12394        Some(config)
12395    }
12396
12397    fn secret_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<SecretDefinition>> {
12398        let Some(entries) = self.mapping(value, "top-level secrets must be a mapping") else {
12399            return Vec::new();
12400        };
12401        entries
12402            .iter()
12403            .filter_map(|entry| {
12404                let definition = self.secret_definition(entry)?;
12405                Some(ProjectResource {
12406                    name: ProjectKey::from_entry(entry),
12407                    definition: ProjectValue::new(definition, entry.value()),
12408                })
12409            })
12410            .collect()
12411    }
12412
12413    fn secret_definition(&mut self, entry: &MergedEntry) -> Option<SecretDefinition> {
12414        let value = entry.value();
12415        let span = effective_span(value);
12416        let mut secret = SecretDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
12417        let fields = match value.kind() {
12418            MergedValueKind::Null(_) => return Some(secret),
12419            MergedValueKind::Mapping(fields) => fields,
12420            _ => {
12421                self.expected(value, "secret definition must be a mapping or null");
12422                return None;
12423            }
12424        };
12425        let path = ["secrets".to_owned(), entry.key().to_owned()];
12426        for field in fields {
12427            match field.key() {
12428                "file" => self
12429                    .located_string(field.value(), "secret file must be a scalar")
12430                    .into_iter()
12431                    .for_each(|value| secret.set_file(value)),
12432                "environment" => self
12433                    .located_string(field.value(), "secret environment must be a scalar")
12434                    .into_iter()
12435                    .for_each(|value| secret.set_environment(value)),
12436                "external" => self
12437                    .resource_external(field.value(), "secret")
12438                    .into_iter()
12439                    .for_each(|value| secret.set_external(value)),
12440                "driver" => self
12441                    .strict_located_string(field.value(), "secret driver must be a YAML string")
12442                    .into_iter()
12443                    .for_each(|value| secret.set_driver(value)),
12444                "driver_opts" => self
12445                    .key_value_mapping(field.value(), "secret driver_opts must be a mapping")
12446                    .into_iter()
12447                    .for_each(|value| secret.set_driver_opts(value)),
12448                "labels" => self
12449                    .labels(field.value())
12450                    .into_iter()
12451                    .for_each(|value| secret.set_labels(value)),
12452                "template_driver" => self
12453                    .strict_located_string(field.value(), "secret template_driver must be a YAML string")
12454                    .into_iter()
12455                    .for_each(|value| secret.set_template_driver(value)),
12456                "name" => self
12457                    .located_string(field.value(), "secret custom name must be a scalar")
12458                    .into_iter()
12459                    .for_each(|value| secret.set_custom_name(value)),
12460                _ => self.record_root_unmodeled(&path, field),
12461            }
12462        }
12463        self.validate_resource_external_name(secret.external(), secret.custom_name(), secret.span());
12464        self.validate_external_creation_configuration(
12465            secret.external(),
12466            &[
12467                secret.file().map(Located::span),
12468                secret.environment().map(Located::span),
12469                secret.driver().map(Located::span),
12470                secret.driver_opts().first().map(KeyValueEntry::span),
12471                secret.labels().map(Labels::span),
12472                secret.template_driver().map(Located::span),
12473            ],
12474            "secret",
12475        );
12476        Some(secret)
12477    }
12478
12479    fn labels(&mut self, value: &MergedValue) -> Option<Labels> {
12480        let span = effective_span(value);
12481        match value.kind() {
12482            MergedValueKind::Sequence(_) => self
12483                .located_string_sequence(value, "labels must be a scalar sequence")
12484                .map(|values| Labels::List { span, values }),
12485            MergedValueKind::Mapping(_) => self
12486                .key_value_mapping(value, "labels must be a scalar mapping")
12487                .map(|entries| Labels::Map { span, entries }),
12488            _ => {
12489                self.expected(value, "labels must be a sequence or mapping");
12490                None
12491            }
12492        }
12493    }
12494}
12495
12496fn compose_scalar_from_merged(scalar: &crate::merge::MergedScalar) -> ComposeScalar {
12497    match scalar.kind() {
12498        MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
12499        MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
12500        MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
12501    }
12502}
12503
12504fn project_command_is_non_empty(command: &Command) -> bool {
12505    match command {
12506        Command::Null(_) => false,
12507        Command::String(value) => !value.value().is_empty(),
12508        Command::List { values, .. } => !values.is_empty(),
12509    }
12510}
12511
12512fn project_value_span<T>(value: &ProjectValue<T>) -> SourceSpan {
12513    let Some(span) = value.provenance().effective_source() else {
12514        unreachable!("merged project values retain source provenance");
12515    };
12516    span
12517}
12518
12519/// Returns one canonical arbitrary-precision unsigned integer spelling.
12520///
12521/// Sign, exponent, whitespace, and expression spellings intentionally have no proven semantic
12522/// comparison at this layer.
12523fn normalize_integer(value: &str) -> Option<String> {
12524    if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
12525        return None;
12526    }
12527    Some(value.trim_start_matches('0').to_owned().if_empty_then("0"))
12528}
12529
12530trait EmptyStringExt {
12531    fn if_empty_then(self, fallback: &str) -> Self;
12532}
12533impl EmptyStringExt for String {
12534    fn if_empty_then(self, fallback: &str) -> Self {
12535        if self.is_empty() { fallback.to_owned() } else { self }
12536    }
12537}
12538
12539/// Returns a canonical arbitrary-precision decimal without floating-point conversion.
12540fn normalize_decimal(value: &str) -> Option<String> {
12541    let (whole, fraction) = value.split_once('.').map_or((value, ""), |parts| parts);
12542    if whole.is_empty()
12543        || (value.contains('.') && fraction.is_empty())
12544        || !whole.bytes().all(|byte| byte.is_ascii_digit())
12545        || !fraction.bytes().all(|byte| byte.is_ascii_digit())
12546    {
12547        return None;
12548    }
12549    let whole = whole.trim_start_matches('0');
12550    let fraction = fraction.trim_end_matches('0');
12551    let whole = if whole.is_empty() { "0" } else { whole };
12552    Some(if fraction.is_empty() {
12553        whole.to_owned()
12554    } else {
12555        format!("{whole}.{fraction}")
12556    })
12557}
12558
12559/// Compares only documented byte spellings using the same explicit suffix and arbitrary-precision
12560/// integral amount. Different units remain unresolved coexistence rather than an inferred ratio.
12561fn same_memory_value(service: &MemLimit, deploy: &DeployResourceMemory) -> Option<bool> {
12562    fn parts(value: &str) -> Option<(&str, &str)> {
12563        ["kb", "mb", "gb", "b", "k", "m", "g"]
12564            .into_iter()
12565            .find_map(|suffix| value.strip_suffix(suffix).map(|amount| (amount, suffix)))
12566    }
12567    let (service_amount, service_unit) = parts(service.raw().value())?;
12568    let (deploy_amount, deploy_unit) = parts(deploy.raw())?;
12569    let (Some(service_amount), Some(deploy_amount)) =
12570        (normalize_integer(service_amount), normalize_integer(deploy_amount))
12571    else {
12572        return None;
12573    };
12574    Some(service_unit == deploy_unit && service_amount == deploy_amount)
12575}
12576
12577fn memory_compare_same_unit(left: &str, right: &str) -> Option<std::cmp::Ordering> {
12578    fn parts(value: &str) -> Option<(&str, &str)> {
12579        ["kb", "mb", "gb", "b", "k", "m", "g"]
12580            .into_iter()
12581            .find_map(|unit| value.strip_suffix(unit).map(|amount| (amount, unit)))
12582    }
12583    let (left_amount, left_unit) = parts(left)?;
12584    let (right_amount, right_unit) = parts(right)?;
12585    if left_unit != right_unit {
12586        return None;
12587    }
12588    let left_amount = normalize_integer(left_amount)?;
12589    let right_amount = normalize_integer(right_amount)?;
12590    Some(
12591        left_amount
12592            .len()
12593            .cmp(&right_amount.len())
12594            .then_with(|| left_amount.cmp(&right_amount)),
12595    )
12596}
12597
12598fn field_reference(path: &[String], entry: &MergedEntry) -> ProjectFieldReference {
12599    let mut complete_path = path.to_vec();
12600    complete_path.push(entry.key().to_owned());
12601    ProjectFieldReference {
12602        path: complete_path,
12603        key: ProjectKey::from_entry(entry),
12604        provenance: entry.value().provenance().clone(),
12605        extension: entry.key().starts_with("x-"),
12606        sensitive: entry.value().is_sensitive(),
12607    }
12608}
12609
12610fn value_reference(path: &[String], value: &MergedValue) -> ProjectFieldReference {
12611    let key = path.last().cloned().unwrap_or_default();
12612    ProjectFieldReference {
12613        path: path.to_vec(),
12614        key: ProjectKey::from_value(key, value),
12615        provenance: value.provenance().clone(),
12616        extension: false,
12617        sensitive: value.is_sensitive(),
12618    }
12619}
12620
12621fn effective_span(value: &MergedValue) -> SourceSpan {
12622    value
12623        .provenance()
12624        .effective_source()
12625        .or_else(|| value.provenance().sources().first().copied())
12626        .unwrap_or_else(|| SourceSpan::from_valid_offsets(SourceId::new(0), 0, 0))
12627}
12628
12629fn entry_span(entry: &MergedEntry) -> SourceSpan {
12630    entry
12631        .key_sources()
12632        .last()
12633        .copied()
12634        .or_else(|| entry.key_sources().first().copied())
12635        .unwrap_or_else(|| effective_span(entry.value()))
12636}