Skip to main content

compose_lens/model/
mod.rs

1//! Source-aware native Compose document types.
2
3mod annotation;
4mod blkio;
5mod build_extra_host;
6mod capability;
7mod cgroup;
8mod command;
9mod cpu_count;
10mod cpu_percent;
11mod cpu_period;
12mod cpu_quota;
13mod cpu_rt_period;
14mod cpu_rt_runtime;
15mod credential_spec;
16mod dependency;
17mod device;
18mod dns;
19mod dns_option;
20mod dns_search;
21mod entrypoint;
22mod environment;
23mod expose;
24mod extends;
25mod host;
26mod hostname;
27mod identity;
28mod image;
29mod lifecycle;
30mod lifecycle_hook;
31mod logging;
32mod memory;
33mod network;
34mod pids;
35mod port;
36mod provider;
37mod pull;
38mod remaining;
39mod resource;
40mod restart;
41mod sections;
42mod security_option;
43mod service_runtime;
44mod shm;
45mod sysctl;
46mod tmpfs;
47mod ulimit;
48mod value;
49mod volume;
50
51pub use annotation::{Annotations, AnnotationsForm};
52pub use blkio::{
53    BlkioConfig, BlkioDeviceRate, BlkioDeviceRateForm, BlkioScalar, BlkioWeightDevice, BlkioWeightDeviceForm,
54};
55pub use build_extra_host::{BuildExtraHostAddresses, BuildExtraHostEntry, BuildExtraHosts};
56pub use capability::{CapabilityAdd, CapabilityAddItem, CapabilityDrop, CapabilityDropItem};
57pub use cgroup::{CgroupNamespace, CgroupNamespaceKind};
58pub use command::Command;
59pub use cpu_count::CpuCount;
60pub use cpu_percent::CpuPercent;
61pub use cpu_period::CpuPeriod;
62pub use cpu_quota::CpuQuota;
63pub use cpu_rt_period::CpuRtPeriod;
64pub use cpu_rt_runtime::CpuRtRuntime;
65pub use credential_spec::CredentialSpec;
66pub use dependency::{
67    DependencyCondition, DependsOn, Healthcheck, HealthcheckDuration, HealthcheckRetries, HealthcheckTest,
68    HealthcheckTestKind, ServiceDependency,
69};
70pub(crate) use device::valid_generated_device_string;
71pub use device::{Device, Devices, LongDevice, ShortDevice, ShortDeviceKind};
72pub use dns::{Dns, DnsForm};
73pub use dns_option::DnsOptions;
74pub use dns_search::{DnsSearch, DnsSearchForm};
75pub use entrypoint::Entrypoint;
76pub use environment::{
77    Environment, EnvironmentFile, EnvironmentFileFormat, EnvironmentFileFormatKind, EnvironmentListEntry,
78    EnvironmentMapEntry, LongEnvironmentFile,
79};
80pub use expose::{Expose, ExposeItem, ExposeItemKind, ExposePort, ExposeProtocol, ExposeScalarKind};
81pub(crate) use expose::{classify_expose_item, valid_generated_expose_item};
82pub use extends::{Extends, ExtendsReference};
83pub use host::{ExtraHostSeparator, ExtraHosts, HostAddress, HostAddressKind, LongExtraHost, ShortExtraHost};
84pub(crate) use hostname::valid_hostname;
85pub use hostname::{Hostname, HostnameKind};
86pub use identity::{IdentityComponent, UserNamespaceMode, UserNamespaceModeKind, UserSpec};
87pub use image::{ImageDigest, ImageReference};
88pub use lifecycle::StopGracePeriod;
89pub use lifecycle_hook::{
90    PostStartHook, PostStartHooks, PreStartHook, PreStartHooks, PreStartServiceHook, PreStopHook, PreStopHooks,
91    ServiceHook,
92};
93pub use logging::{Logging, LoggingOption, LoggingOptionValue, LoggingOptions};
94pub(crate) use memory::valid_generated_mem_amount;
95pub use memory::{MemLimit, MemLimitKind, MemLimitScalarKind, MemLimitUnit};
96pub use network::{Ipam, IpamConfig, NetworkDefinition, ServiceNetwork, ServiceNetworks};
97pub(crate) use pids::valid_positive_pids_decimal;
98pub use pids::{PidsLimit, PidsLimitKind};
99pub use port::{LongPort, Port, ShortPort};
100pub use provider::{Provider, ProviderOption, ProviderOptionItem, ProviderOptionValue, ProviderOptions};
101pub(crate) use pull::valid_pull_policy_duration;
102pub use pull::{PullPolicy, PullPolicyKind};
103pub use remaining::{
104    Develop, DevelopWatch, DevelopWatchExec, GpuDevice, GpuOptions, Gpus, IncludeItem, IncludeLong, Includes,
105    LabelFiles, LabelFilesForm, ModelDefinition, ModelDefinitions, ServiceModelBinding, ServiceModels,
106};
107pub use resource::{
108    ConfigDefinition, ConfigGrant, ExternalNameMapping, LongGrant, ResourceExternal, SecretDefinition, SecretGrant,
109    VolumeDefinition,
110};
111pub use restart::{RestartPolicy, RestartPolicyKind};
112pub use sections::{
113    Build, BuildAdditionalContexts, BuildArgs, BuildDefinition, BuildField, BuildFieldKind, BuildNoCacheFilter,
114    BuildSsh, BuildSshForm, DeployDefinition, DeployDiscreteResourceSpec, DeployDiscreteResourceValue,
115    DeployEndpointMode, DeployField, DeployFieldKind, DeployGenericResource, DeployGenericResourceForm,
116    DeployGenericResources, DeployMode, DeployPlacement, DeployPlacementMaxReplicasPerNode, DeployPlacementPreference,
117    DeployReplicas, DeployReservationDevice, DeployReservationDeviceCapabilities, DeployReservationDeviceCapability,
118    DeployReservationDeviceCapabilityForm, DeployReservationDeviceCount, DeployReservationDeviceForm,
119    DeployReservationDeviceId, DeployReservationDeviceIdForm, DeployReservationDeviceIds,
120    DeployReservationDeviceOptionItem, DeployReservationDeviceOptionItemForm, DeployReservationDeviceOptions,
121    DeployReservationDevices, DeployResourceCpus, DeployResourceLimits, DeployResourceMemory, DeployResourceMemoryKind,
122    DeployResourceMemoryUnit, DeployResourcePids, DeployResourceReservations, DeployResources, DeployRestartCondition,
123    DeployRestartDuration, DeployRestartMaxAttempts, DeployRestartPolicy, DeployRollbackConfig,
124    DeployRollbackMaxFailureRatio, DeployRollbackOrder, DeployRollbackParallelism, DeployUpdateConfig,
125    DeployUpdateMaxFailureRatio, DeployUpdateOrder, DeployUpdateParallelism,
126};
127pub(crate) use security_option::{SecurityOptionCandidateCounts, classify_security_option};
128pub use security_option::{SecurityOptionItem, SecurityOptionKind, SecurityOptions};
129pub use service_runtime::{
130    Cpus, InvalidServiceStringItem, IpcMode, MemswapLimit, MemswapLimitKind, MemswapLimitScalarKind, NetworkMode,
131    PidMode, ServiceInteger, VolumesFrom,
132};
133pub(crate) use shm::valid_generated_shm_amount;
134pub use shm::{ShmSize, ShmSizeKind, ShmSizeScalarKind, ShmSizeUnit};
135pub use sysctl::{Sysctls, SysctlsForm};
136pub(crate) use tmpfs::valid_generated_tmpfs_item;
137pub use tmpfs::{Tmpfs, TmpfsForm, TmpfsItem, TmpfsItemKind};
138pub(crate) use ulimit::valid_ulimit_name;
139pub use ulimit::{LimitValue, Ulimit, UlimitRange, UlimitValue, Ulimits};
140pub use value::{BooleanValue, BuildNoCache, BuildProvenance, BuildSbom, ComposeScalar, KeyValueEntry, Labels};
141pub use volume::{
142    BindOptions, ContainerPath, ContainerPathKind, ImageMountOptions, LongVolumeMount, MountType, SelinuxRelabel,
143    ShortVolumeMount, TmpfsMountOptions, VolumeMount, VolumeMountOptions, VolumeSyntax,
144};
145
146use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
147use crate::source::{SourceId, SourceSpan};
148use crate::syntax::{SyntaxDocument, scalar_string_from_source};
149use std::collections::{BTreeMap, BTreeSet};
150use yaml_edit::{AnchorRegistry, AsYaml, Mapping, Scalar, ScalarStyle, ScalarType, ScalarValue, YamlNode};
151
152/// A Compose document root must be a mapping.
153pub const DOCUMENT_ROOT_TYPE: DiagnosticCode = DiagnosticCode::new("compose.document.expected-mapping");
154
155/// `ComposeLens` currently types the first document in a multi-document YAML stream.
156pub const MULTIPLE_DOCUMENTS: DiagnosticCode = DiagnosticCode::new("compose.document.multiple-documents");
157
158/// A mapping contains a duplicate field.
159pub const DUPLICATE_FIELD: DiagnosticCode = DiagnosticCode::new("compose.model.duplicate-field");
160/// The legacy top-level Compose `version` field is retained but no longer selects a specification version.
161pub const VERSION_OBSOLETE: DiagnosticCode = DiagnosticCode::new("compose.version.obsolete");
162/// A service `develop` declaration lacks the required watch sequence.
163pub const DEVELOP_MISSING_WATCH: DiagnosticCode = DiagnosticCode::new("compose.develop.missing-watch");
164/// A Compose develop watch entry lacks its required action member.
165pub const DEVELOP_WATCH_MISSING_ACTION: DiagnosticCode = DiagnosticCode::new("compose.develop.watch.missing-action");
166/// A Compose develop watch entry lacks its required path member.
167pub const DEVELOP_WATCH_MISSING_PATH: DiagnosticCode = DiagnosticCode::new("compose.develop.watch.missing-path");
168/// A Compose develop watch entry uses an action outside the documented action set.
169pub const DEVELOP_WATCH_INVALID_ACTION: DiagnosticCode = DiagnosticCode::new("compose.develop.watch.invalid-action");
170/// A synchronizing Compose develop watch action lacks its required target member.
171pub const DEVELOP_WATCH_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.develop.watch.missing-target");
172/// A `sync+exec` Compose develop watch action lacks its required exec command mapping.
173pub const DEVELOP_WATCH_MISSING_EXEC: DiagnosticCode = DiagnosticCode::new("compose.develop.watch.missing-exec");
174/// A `develop.watch.exec` mapping omits its required non-empty command member.
175pub const DEVELOP_WATCH_EXEC_MISSING_COMMAND: DiagnosticCode =
176    DiagnosticCode::new("compose.develop.watch.exec.missing-command");
177/// A long GPU selector asks for both a count and explicit device IDs.
178pub const GPU_COUNT_DEVICE_IDS_CONFLICT: DiagnosticCode = DiagnosticCode::new("compose.gpus.count-device-ids-conflict");
179/// A long GPU selector omits its required non-empty capabilities declaration.
180pub const GPU_MISSING_CAPABILITIES: DiagnosticCode = DiagnosticCode::new("compose.gpus.missing-capabilities");
181/// A strict YAML-string service cgroup namespace is not a documented literal or deferred expression.
182pub const CGROUP_NAMESPACE_INVALID: DiagnosticCode = DiagnosticCode::new("compose.cgroup.invalid-namespace");
183/// A deploy endpoint mode is retained but is outside Compose's documented portable values.
184pub const DEPLOY_ENDPOINT_MODE_PORTABILITY: DiagnosticCode =
185    DiagnosticCode::new("compose.deploy.endpoint-mode.portability");
186/// A deploy mode is retained but is outside Compose's documented portable values.
187pub const DEPLOY_MODE_PORTABILITY: DiagnosticCode = DiagnosticCode::new("compose.deploy.mode.portability");
188/// An update-config order is retained but outside Compose's documented portable values.
189pub const DEPLOY_UPDATE_CONFIG_ORDER_PORTABILITY: DiagnosticCode =
190    DiagnosticCode::new("compose.deploy.update-config.order.portability");
191/// A rollback-config order is retained but outside Compose's documented portable values.
192pub const DEPLOY_ROLLBACK_CONFIG_ORDER_PORTABILITY: DiagnosticCode =
193    DiagnosticCode::new("compose.deploy.rollback-config.order.portability");
194/// A long service `extends` mapping is missing its required `service` member.
195pub const EXTENDS_MISSING_SERVICE: DiagnosticCode = DiagnosticCode::new("compose.extends.missing-service");
196/// A service provider mapping is missing its required `type` member.
197pub const PROVIDER_MISSING_TYPE: DiagnosticCode = DiagnosticCode::new("compose.provider.missing-type");
198/// A `post_start` hook mapping is missing its required command.
199pub const POST_START_MISSING_COMMAND: DiagnosticCode = DiagnosticCode::new("compose.post-start.missing-command");
200/// A `pre_stop` hook mapping is missing its required command.
201pub const PRE_STOP_MISSING_COMMAND: DiagnosticCode = DiagnosticCode::new("compose.pre-stop.missing-command");
202/// A reservation-device capabilities sequence contains an exact duplicate string.
203pub const DEPLOY_RESERVATION_DEVICE_CAPABILITY_DUPLICATE_ITEM: DiagnosticCode =
204    DiagnosticCode::new("compose.deploy.reservations.devices.capabilities.duplicate-item");
205/// A reservation-device mapping omits its required capabilities field.
206pub const DEPLOY_RESERVATION_DEVICE_MISSING_CAPABILITIES: DiagnosticCode =
207    DiagnosticCode::new("compose.deploy.reservations.devices.missing-capabilities");
208/// A reservation-device mapping supplies incompatible allocation selectors.
209pub const DEPLOY_RESERVATION_DEVICE_ALLOCATION_SELECTOR_CONFLICT: DiagnosticCode =
210    DiagnosticCode::new("compose.deploy.reservations.devices.allocation-selector-conflict");
211/// Reservation-device options must use mapping or sequence syntax.
212pub const DEPLOY_RESERVATION_DEVICE_OPTIONS_EXPECTED_FORM: DiagnosticCode =
213    DiagnosticCode::new("compose.deploy.reservations.devices.options.expected-form");
214/// A reservation-device options mapping key is invalid.
215pub const DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY: DiagnosticCode =
216    DiagnosticCode::new("compose.deploy.reservations.devices.options.invalid-key");
217/// A reservation-device options list repeats an exact string.
218pub const DEPLOY_RESERVATION_DEVICE_OPTIONS_DUPLICATE_ITEM: DiagnosticCode =
219    DiagnosticCode::new("compose.deploy.reservations.devices.options.duplicate-item");
220/// A Build no-cache filter list repeats an exact stage name.
221pub const BUILD_NO_CACHE_FILTER_DUPLICATE_ITEM: DiagnosticCode =
222    DiagnosticCode::new("compose.build.no-cache-filter.duplicate-item");
223
224/// A Compose value has to be a mapping at this location.
225pub const EXPECTED_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.model.expected-mapping");
226
227/// A Compose value has to be a sequence at this location.
228pub const EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.model.expected-sequence");
229
230/// A Compose value has to be a scalar at this location.
231pub const EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.model.expected-scalar");
232
233/// A Compose value has to be a boolean at this location.
234pub const EXPECTED_BOOLEAN: DiagnosticCode = DiagnosticCode::new("compose.model.expected-boolean");
235
236/// A field supports multiple Compose syntax forms, but the authored form is invalid here.
237pub const EXPECTED_FIELD_FORM: DiagnosticCode = DiagnosticCode::new("compose.model.expected-field-form");
238
239/// A build `dockerfile` must be a non-empty scalar.
240pub const BUILD_DOCKERFILE_EXPECTED_NON_EMPTY: DiagnosticCode =
241    DiagnosticCode::new("compose.build.dockerfile.expected-non-empty-scalar");
242
243/// A build definition declares both `dockerfile` and `dockerfile_inline`.
244pub const BUILD_DOCKERFILE_INLINE_CONFLICT: DiagnosticCode =
245    DiagnosticCode::new("compose.build.dockerfile-inline-conflict");
246
247/// A build `no_cache` value is neither a YAML boolean nor a YAML string scalar.
248pub const BUILD_NO_CACHE_EXPECTED_BOOLEAN_OR_STRING: DiagnosticCode =
249    DiagnosticCode::new("compose.build.no-cache.expected-boolean-or-string");
250
251/// A build `sbom` value is neither a YAML boolean nor a YAML string scalar.
252pub const BUILD_SBOM_EXPECTED_BOOLEAN_OR_STRING: DiagnosticCode =
253    DiagnosticCode::new("compose.build.sbom.expected-boolean-or-string");
254
255/// A build `isolation` value is not a YAML string scalar.
256pub const BUILD_ISOLATION_EXPECTED_STRING: DiagnosticCode =
257    DiagnosticCode::new("compose.build.isolation.expected-string");
258
259/// Build `extra_hosts` has neither list nor mapping syntax.
260pub const BUILD_EXTRA_HOSTS_EXPECTED_FORM: DiagnosticCode =
261    DiagnosticCode::new("compose.build.extra-hosts.expected-form");
262
263/// A build `extra_hosts` list item or address is not a YAML string scalar.
264pub const BUILD_EXTRA_HOSTS_EXPECTED_STRING: DiagnosticCode =
265    DiagnosticCode::new("compose.build.extra-hosts.expected-string");
266
267/// A build `extra_hosts` list repeats a schema-unique raw entry.
268pub const BUILD_EXTRA_HOSTS_DUPLICATE_ITEM: DiagnosticCode =
269    DiagnosticCode::new("compose.build.extra-hosts.duplicate-item");
270
271/// A service port is neither scalar short syntax nor mapping long syntax.
272pub const PORT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.port.expected-short-or-long");
273
274/// A long-syntax service port is missing `target`.
275pub const PORT_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.port.long.missing-target");
276
277/// A service config or secret grant is neither scalar short syntax nor mapping long syntax.
278pub const GRANT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.grant.expected-short-or-long");
279
280/// A long-syntax service config or secret grant is missing `source`.
281pub const GRANT_MISSING_SOURCE: DiagnosticCode = DiagnosticCode::new("compose.grant.long.missing-source");
282
283/// A top-level resource definition must be a mapping or an explicit null.
284pub const RESOURCE_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.resource.expected-mapping-or-null");
285
286/// An external volume also configures a local driver or driver options.
287///
288/// Both authored values remain available for diagnosis; `ComposeLens` does not silently select one.
289pub const VOLUME_EXTERNAL_DRIVER_CONFIGURATION: DiagnosticCode =
290    DiagnosticCode::new("compose.volume.external-driver-configuration");
291
292/// An external volume also configures labels.
293///
294/// The authored labels remain available for diagnosis; `ComposeLens` does not silently discard
295/// them or repurpose the driver-configuration diagnostic.
296pub const VOLUME_EXTERNAL_LABELS_CONFIGURATION: DiagnosticCode =
297    DiagnosticCode::new("compose.volume.external-labels-configuration");
298
299/// A deprecated `external: { name: ... }` resource declaration is retained for migration.
300pub const RESOURCE_EXTERNAL_NAME_MAPPING_DEPRECATED: DiagnosticCode =
301    DiagnosticCode::new("compose.resource.external-name-mapping-deprecated");
302/// A resource uses both the modern `name` field and the deprecated external name mapping.
303pub const RESOURCE_EXTERNAL_NAME_CONFLICT: DiagnosticCode =
304    DiagnosticCode::new("compose.resource.external-name-conflict");
305/// An explicitly external resource also declares creation-time metadata.
306pub const RESOURCE_EXTERNAL_CREATION_CONFIGURATION: DiagnosticCode =
307    DiagnosticCode::new("compose.resource.external-creation-configuration");
308
309/// A service-volume item is neither short nor long syntax.
310pub const VOLUME_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.volume.expected-short-or-long");
311
312/// A long-syntax service volume is missing `type`.
313pub const VOLUME_MISSING_TYPE: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-type");
314
315/// A long-syntax service volume is missing `target`.
316pub const VOLUME_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-target");
317
318/// A long-syntax bind mount has an invalid `SELinux` value.
319pub const VOLUME_INVALID_SELINUX: DiagnosticCode = DiagnosticCode::new("compose.volume.bind.invalid-selinux");
320
321/// A long-mount nested option block must be a mapping.
322pub const VOLUME_OPTION_EXPECTED_MAPPING: DiagnosticCode =
323    DiagnosticCode::new("compose.volume.long.option.expected-mapping");
324
325/// A short `extra_hosts` entry does not contain a hostname/address separator.
326pub const EXTRA_HOST_INVALID_ENTRY: DiagnosticCode = DiagnosticCode::new("compose.extra-hosts.invalid-entry");
327
328/// A service limit is neither unlimited, a non-negative integer, nor deferred.
329pub const ULIMIT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.ulimits.invalid-value");
330
331/// A service limit name is outside Compose's portable lowercase-name grammar.
332pub const ULIMIT_INVALID_NAME: DiagnosticCode = DiagnosticCode::new("compose.ulimits.invalid-name");
333
334/// A service limit range is missing its required `soft` or `hard` member.
335pub const ULIMIT_MISSING_RANGE_MEMBER: DiagnosticCode = DiagnosticCode::new("compose.ulimits.missing-range-member");
336
337/// A health-check list has no valid command-mode token.
338pub const HEALTHCHECK_INVALID_TEST: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-test");
339
340/// A health-check duration does not follow Compose duration syntax.
341pub const HEALTHCHECK_INVALID_DURATION: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-duration");
342
343/// A health-check retry count is not a non-negative integer or deferred expression.
344pub const HEALTHCHECK_INVALID_RETRIES: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-retries");
345
346/// A service-level restart policy is not one of the Compose-defined forms or an expression.
347pub const RESTART_INVALID_POLICY: DiagnosticCode = DiagnosticCode::new("compose.restart.invalid-policy");
348
349/// A service hostname is not authored as a YAML string scalar.
350pub const HOSTNAME_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.hostname.expected-string");
351
352/// A resolved service hostname does not satisfy the conservative RFC-1123 grammar.
353pub const HOSTNAME_INVALID: DiagnosticCode = DiagnosticCode::new("compose.hostname.invalid-value");
354
355/// A service PID limit is not a number or string scalar.
356pub const PIDS_LIMIT_EXPECTED_VALUE: DiagnosticCode =
357    DiagnosticCode::new("compose.pids-limit.expected-number-or-string");
358
359/// A service PID limit is neither unlimited, positive integral decimal, nor deferred.
360pub const PIDS_LIMIT_INVALID: DiagnosticCode = DiagnosticCode::new("compose.pids-limit.invalid-value");
361
362/// A zero service PID limit has ambiguous and unportable native semantics.
363pub const PIDS_LIMIT_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.pids-limit.ambiguous-zero");
364
365/// A service CPU count is not a YAML integer or string scalar.
366pub const CPU_COUNT_EXPECTED_VALUE: DiagnosticCode =
367    DiagnosticCode::new("compose.cpu-count.expected-integer-or-string");
368
369/// A service CPU count is a negative YAML integer.
370pub const CPU_COUNT_NEGATIVE: DiagnosticCode = DiagnosticCode::new("compose.cpu-count.negative-value");
371
372/// A service CPU percentage YAML integer is outside the schema's inclusive `0..=100` range.
373pub const CPU_PERCENT_OUT_OF_RANGE: DiagnosticCode = DiagnosticCode::new("compose.cpu-percent.out-of-range");
374
375/// A service CPU percentage is not a YAML integer or string scalar.
376pub const CPU_PERCENT_EXPECTED_VALUE: DiagnosticCode =
377    DiagnosticCode::new("compose.cpu-percent.expected-integer-or-string");
378
379/// A service CPU period is not a YAML number or string scalar.
380pub const CPU_PERIOD_EXPECTED_VALUE: DiagnosticCode =
381    DiagnosticCode::new("compose.cpu-period.expected-number-or-string");
382
383/// A service CPU quota is not a YAML number or string scalar.
384pub const CPU_QUOTA_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.cpu-quota.expected-number-or-string");
385
386/// A service real-time CPU period is not a YAML number or string scalar.
387pub const CPU_RT_PERIOD_EXPECTED_VALUE: DiagnosticCode =
388    DiagnosticCode::new("compose.cpu-rt-period.expected-number-or-string");
389
390/// A service real-time CPU-period string is outside the raw Compose duration policy.
391pub const CPU_RT_PERIOD_INVALID: DiagnosticCode = DiagnosticCode::new("compose.cpu-rt-period.invalid-duration");
392/// A `cpu_rt_runtime` scalar is neither an integer microsecond value, duration, nor deferred expression.
393pub const CPU_RT_RUNTIME_INVALID: DiagnosticCode = DiagnosticCode::new("compose.cpu-rt-runtime.invalid-value");
394
395/// A service shared-memory size is not a number or string scalar.
396pub const SHM_SIZE_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.shm-size.expected-number-or-string");
397
398/// A zero service shared-memory size has no defined Compose semantics.
399pub const SHM_SIZE_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.shm-size.ambiguous-zero");
400
401/// A schema-accepted numeric shared-memory size lacks a documented explicit unit.
402pub const SHM_SIZE_PROVIDER_DEPENDENT_NUMBER: DiagnosticCode =
403    DiagnosticCode::new("compose.shm-size.provider-dependent-number");
404
405/// A schema-accepted string shared-memory size is outside the documented lowercase suffix family.
406pub const SHM_SIZE_PROVIDER_DEPENDENT_STRING: DiagnosticCode =
407    DiagnosticCode::new("compose.shm-size.provider-dependent-string");
408
409/// A service memory limit is not a number or string scalar.
410pub const MEM_LIMIT_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.expected-number-or-string");
411
412/// A zero service memory limit has no portable cross-provider meaning inferred by `ComposeLens`.
413pub const MEM_LIMIT_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.ambiguous-zero");
414
415/// A schema-accepted numeric memory limit lacks a documented explicit unit.
416pub const MEM_LIMIT_SCHEMA_NUMBER: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.schema-number");
417
418/// A schema-accepted string memory limit is outside the documented lowercase suffix family.
419pub const MEM_LIMIT_PROVIDER_DEPENDENT_STRING: DiagnosticCode =
420    DiagnosticCode::new("compose.mem-limit.provider-dependent-string");
421
422/// A service memory-plus-swap limit is not a YAML number or string scalar.
423pub const MEMSWAP_LIMIT_EXPECTED_VALUE: DiagnosticCode =
424    DiagnosticCode::new("compose.memswap-limit.expected-number-or-string");
425
426/// A service memory-plus-swap limit is neither `-1`, a decimal quantity, nor an expression.
427pub const MEMSWAP_LIMIT_INVALID: DiagnosticCode = DiagnosticCode::new("compose.memswap-limit.invalid-value");
428
429/// A service image pull policy is not documented, schema-recognized, or deferred.
430pub const PULL_POLICY_INVALID: DiagnosticCode = DiagnosticCode::new("compose.pull-policy.invalid-policy");
431
432/// A service stop grace period does not match the raw-preserving policy based on documented Compose units.
433pub const STOP_GRACE_PERIOD_INVALID: DiagnosticCode =
434    DiagnosticCode::new("compose.lifecycle.invalid-stop-grace-period");
435
436/// A service `cap_drop` value is not a YAML sequence.
437pub const CAP_DROP_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.expected-sequence");
438
439/// A service `cap_drop` item is not a YAML string scalar.
440pub const CAP_DROP_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.expected-string");
441
442/// A service `cap_drop` sequence contains an exact duplicate string.
443pub const CAP_DROP_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.duplicate-item");
444
445/// A service `cap_add` value is not a YAML sequence.
446pub const CAP_ADD_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.cap-add.expected-sequence");
447
448/// A service `cap_add` item is not a YAML string scalar.
449pub const CAP_ADD_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.cap-add.expected-string");
450
451/// A service `cap_add` sequence contains an exact duplicate string.
452pub const CAP_ADD_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.cap-add.duplicate-item");
453
454/// A service `devices` value is not a YAML sequence.
455pub const DEVICES_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-sequence");
456
457/// A service device item is neither a string scalar nor a mapping.
458pub const DEVICE_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-short-or-long");
459
460/// A short device or long-device member is not a YAML string scalar.
461pub const DEVICE_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-string");
462
463/// A long-syntax service device is missing its required `source` string.
464pub const DEVICE_MISSING_SOURCE: DiagnosticCode = DiagnosticCode::new("compose.devices.long.missing-source");
465
466/// A service `dns` value is neither a YAML string scalar nor a sequence.
467pub const DNS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.dns.expected-string-or-list");
468
469/// A service `dns` list item is not a YAML string scalar.
470pub const DNS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns.expected-string");
471
472/// A service `dns_opt` value is not a YAML sequence.
473pub const DNS_OPT_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.expected-sequence");
474
475/// A service `dns_opt` item is not a YAML string scalar.
476pub const DNS_OPT_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.expected-string");
477
478/// A service `dns_opt` sequence contains an exact duplicate string.
479pub const DNS_OPT_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.duplicate-item");
480
481/// A service `dns_search` value is neither a YAML string scalar nor a sequence.
482pub const DNS_SEARCH_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.dns-search.expected-string-or-list");
483
484/// A service `dns_search` list item is not a YAML string scalar.
485pub const DNS_SEARCH_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns-search.expected-string");
486
487/// A service `dns_search` list contains an exact duplicate string.
488pub const DNS_SEARCH_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.dns-search.duplicate-item");
489
490/// A service `expose` value is not a YAML sequence.
491pub const EXPOSE_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.expose.expected-sequence");
492
493/// A service `expose` item is not a YAML string or number scalar.
494pub const EXPOSE_EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.expose.expected-string-or-number");
495
496/// A service `expose` item does not match the documented decimal port/range grammar.
497pub const EXPOSE_INVALID_ITEM: DiagnosticCode = DiagnosticCode::new("compose.expose.invalid-item");
498
499/// A service `expose` item uses a protocol outside the documented portable set.
500pub const EXPOSE_PROVIDER_DEPENDENT: DiagnosticCode = DiagnosticCode::new("compose.expose.provider-dependent-protocol");
501
502/// A service `expose` sequence contains an exact duplicate scalar identity.
503pub const EXPOSE_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.expose.duplicate-item");
504
505/// A service `security_opt` value is not a YAML sequence.
506pub const SECURITY_OPT_EXPECTED_SEQUENCE: DiagnosticCode =
507    DiagnosticCode::new("compose.security-opt.expected-sequence");
508
509/// A service `security_opt` item is not a YAML string scalar.
510pub const SECURITY_OPT_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.security-opt.expected-string");
511
512/// A service `security_opt` item is an explicitly empty string.
513pub const SECURITY_OPT_EMPTY_ITEM: DiagnosticCode = DiagnosticCode::new("compose.security-opt.empty-item");
514
515/// An AppArmor-shaped service `security_opt` item is not the exact narrow candidate form.
516pub const SECURITY_OPT_APPARMOR_NEAR_MISS: DiagnosticCode =
517    DiagnosticCode::new("compose.security-opt.apparmor-near-miss");
518
519/// More than one exact `AppArmor` candidate remains in a service `security_opt` sequence.
520pub const SECURITY_OPT_APPARMOR_CONFLICT: DiagnosticCode =
521    DiagnosticCode::new("compose.security-opt.apparmor-conflict");
522
523/// A seccomp-shaped service `security_opt` item is not the exact narrow candidate form.
524pub const SECURITY_OPT_SECCOMP_NEAR_MISS: DiagnosticCode =
525    DiagnosticCode::new("compose.security-opt.seccomp-near-miss");
526
527/// More than one exact seccomp candidate remains in a service `security_opt` sequence.
528pub const SECURITY_OPT_SECCOMP_CONFLICT: DiagnosticCode = DiagnosticCode::new("compose.security-opt.seccomp-conflict");
529
530/// A no-new-privileges-shaped item is not an exact lowercase boolean candidate.
531pub const SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS: DiagnosticCode =
532    DiagnosticCode::new("compose.security-opt.no-new-privileges-near-miss");
533
534/// More than one exact no-new-privileges candidate remains in one effective sequence.
535pub const SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT: DiagnosticCode =
536    DiagnosticCode::new("compose.security-opt.no-new-privileges-conflict");
537
538/// A mask-shaped service `security_opt` item is not the exact narrow candidate form.
539pub const SECURITY_OPT_MASK_NEAR_MISS: DiagnosticCode = DiagnosticCode::new("compose.security-opt.mask-near-miss");
540
541/// An unmask-shaped service `security_opt` item is not the exact narrow candidate form.
542pub const SECURITY_OPT_UNMASK_NEAR_MISS: DiagnosticCode = DiagnosticCode::new("compose.security-opt.unmask-near-miss");
543
544pub(crate) fn security_path_option_diagnostic(kind: &SecurityOptionKind, span: SourceSpan) -> Option<Diagnostic> {
545    let (code, message) = match kind {
546        SecurityOptionKind::MaskNearMiss => (
547            SECURITY_OPT_MASK_NEAR_MISS,
548            "mask candidates require exact lowercase `mask=<paths>` spelling with a non-empty whitespace-free payload",
549        ),
550        SecurityOptionKind::UnmaskNearMiss => (
551            SECURITY_OPT_UNMASK_NEAR_MISS,
552            "unmask candidates require exact lowercase `unmask=ALL` or colon-separated slash-prefixed paths without whitespace",
553        ),
554        _ => return None,
555    };
556    Some(
557        Diagnostic::new(code, Severity::Warning, message)
558            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
559    )
560}
561
562/// A `SELinux` label-disable-shaped item is not the exact lowercase candidate.
563pub const SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS: DiagnosticCode =
564    DiagnosticCode::new("compose.security-opt.security-label-disable-near-miss");
565
566/// More than one exact `SELinux` label-disable candidate remains in one effective sequence.
567pub const SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT: DiagnosticCode =
568    DiagnosticCode::new("compose.security-opt.security-label-disable-conflict");
569
570/// A `SELinux` label-filetype-shaped item is not the exact lowercase candidate.
571pub const SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS: DiagnosticCode =
572    DiagnosticCode::new("compose.security-opt.security-label-filetype-near-miss");
573
574/// More than one exact `SELinux` label-filetype candidate remains in one effective sequence.
575pub const SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT: DiagnosticCode =
576    DiagnosticCode::new("compose.security-opt.security-label-filetype-conflict");
577
578/// A `SELinux` label-level-shaped item is not the exact lowercase candidate.
579pub const SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS: DiagnosticCode =
580    DiagnosticCode::new("compose.security-opt.security-label-level-near-miss");
581
582/// More than one exact `SELinux` label-level candidate remains in one effective sequence.
583pub const SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT: DiagnosticCode =
584    DiagnosticCode::new("compose.security-opt.security-label-level-conflict");
585
586/// A `SELinux` label-nested-shaped item is not the exact lowercase candidate.
587pub const SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS: DiagnosticCode =
588    DiagnosticCode::new("compose.security-opt.security-label-nested-near-miss");
589
590/// More than one exact `SELinux` label-nested candidate remains in one effective sequence.
591pub const SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT: DiagnosticCode =
592    DiagnosticCode::new("compose.security-opt.security-label-nested-conflict");
593
594/// A `SELinux` label-type-shaped item is not the exact lowercase candidate.
595pub const SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS: DiagnosticCode =
596    DiagnosticCode::new("compose.security-opt.security-label-type-near-miss");
597
598/// More than one exact `SELinux` label-type candidate remains in one effective sequence.
599pub const SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT: DiagnosticCode =
600    DiagnosticCode::new("compose.security-opt.security-label-type-conflict");
601
602fn authored_security_label_diagnostic(
603    kind: &SecurityOptionKind,
604    span: SourceSpan,
605    candidates: &mut SecurityOptionCandidateCounts,
606) -> Option<Diagnostic> {
607    match kind {
608        SecurityOptionKind::SecurityLabelDisable { .. } => {
609            candidates.security_label_disable += 1;
610            (candidates.security_label_disable > 1).then(|| {
611                Diagnostic::new(
612                    SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT,
613                    Severity::Warning,
614                    "multiple SELinux label-disable candidates are retained; a consumer must resolve the conflict explicitly",
615                )
616                .with_label(DiagnosticLabel::primary(
617                    span,
618                    "additional SELinux label-disable candidate retained",
619                ))
620            })
621        }
622        SecurityOptionKind::SecurityLabelDisableNearMiss => Some(
623            Diagnostic::new(
624                SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS,
625                Severity::Warning,
626                "SELinux label-disable candidates require exact lowercase `label:disable` spelling without whitespace",
627            )
628            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
629        ),
630        SecurityOptionKind::SecurityLabelFileType { .. } => {
631            candidates.security_label_filetype += 1;
632            (candidates.security_label_filetype > 1).then(|| {
633                Diagnostic::new(
634                    SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT,
635                    Severity::Warning,
636                    "multiple SELinux label-filetype candidates are retained; a consumer must resolve the conflict explicitly",
637                )
638                .with_label(DiagnosticLabel::primary(
639                    span,
640                    "additional SELinux label-filetype candidate retained",
641                ))
642            })
643        }
644        SecurityOptionKind::SecurityLabelFileTypeNearMiss => Some(
645            Diagnostic::new(
646                SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS,
647                Severity::Warning,
648                "SELinux label-filetype candidates require exact lowercase `label:filetype:<type>` spelling without whitespace",
649            )
650            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
651        ),
652        SecurityOptionKind::SecurityLabelLevel { .. } => {
653            candidates.security_label_level += 1;
654            (candidates.security_label_level > 1).then(|| {
655                Diagnostic::new(
656                    SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT,
657                    Severity::Warning,
658                    "multiple SELinux label-level candidates are retained; a consumer must resolve the conflict explicitly",
659                )
660                .with_label(DiagnosticLabel::primary(
661                    span,
662                    "additional SELinux label-level candidate retained",
663                ))
664            })
665        }
666        SecurityOptionKind::SecurityLabelLevelNearMiss => Some(
667            Diagnostic::new(
668                SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS,
669                Severity::Warning,
670                "SELinux label-level candidates require exact lowercase `label:level:<level>` spelling without whitespace",
671            )
672            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
673        ),
674        SecurityOptionKind::SecurityLabelNested { .. } => {
675            candidates.security_label_nested += 1;
676            (candidates.security_label_nested > 1).then(|| {
677                Diagnostic::new(
678                    SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT,
679                    Severity::Warning,
680                    "multiple SELinux label-nested candidates are retained; a consumer must resolve the conflict explicitly",
681                )
682                .with_label(DiagnosticLabel::primary(
683                    span,
684                    "additional SELinux label-nested candidate retained",
685                ))
686            })
687        }
688        SecurityOptionKind::SecurityLabelNestedNearMiss => Some(
689            Diagnostic::new(
690                SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS,
691                Severity::Warning,
692                "SELinux label-nested candidates require exact lowercase `label:nested` spelling without whitespace",
693            )
694            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
695        ),
696        SecurityOptionKind::SecurityLabelType { .. } | SecurityOptionKind::SecurityLabelTypeNearMiss => {
697            authored_security_label_type_diagnostic(kind, span, &mut candidates.security_label_type)
698        }
699        _ => None,
700    }
701}
702
703fn authored_security_label_type_diagnostic(
704    kind: &SecurityOptionKind,
705    span: SourceSpan,
706    candidates: &mut usize,
707) -> Option<Diagnostic> {
708    match kind {
709        SecurityOptionKind::SecurityLabelType { .. } => {
710            *candidates += 1;
711            (*candidates > 1).then(|| {
712                Diagnostic::new(
713                    SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT,
714                    Severity::Warning,
715                    "multiple SELinux label-type candidates are retained; a consumer must resolve the conflict explicitly",
716                )
717                .with_label(DiagnosticLabel::primary(
718                    span,
719                    "additional SELinux label-type candidate retained",
720                ))
721            })
722        }
723        SecurityOptionKind::SecurityLabelTypeNearMiss => Some(
724            Diagnostic::new(
725                SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS,
726                Severity::Warning,
727                "SELinux label-type candidates require exact lowercase `label:type:<type>` spelling with one non-empty whitespace-free type",
728            )
729            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
730        ),
731        _ => None,
732    }
733}
734
735/// A service `annotations` value is neither mapping nor list syntax.
736pub const ANNOTATIONS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.annotations.expected-map-or-list");
737
738/// A service annotation list item is not a YAML string scalar.
739pub const ANNOTATIONS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.annotations.expected-string");
740
741/// A service annotation has an empty semantic name.
742pub const ANNOTATIONS_EMPTY_NAME: DiagnosticCode = DiagnosticCode::new("compose.annotations.empty-name");
743
744/// A key-only service annotation list item has no defined explicit value.
745pub const ANNOTATIONS_KEY_ONLY: DiagnosticCode = DiagnosticCode::new("compose.annotations.key-only");
746
747/// More than one authored service annotation resolves to the same semantic name.
748pub const ANNOTATIONS_DUPLICATE_NAME: DiagnosticCode = DiagnosticCode::new("compose.annotations.duplicate-name");
749
750/// A service-level `tmpfs` value is neither a string scalar nor a sequence.
751pub const TMPFS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.expected-string-or-list");
752
753/// A service-level `tmpfs` sequence item is not a YAML string scalar.
754pub const TMPFS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.expected-string");
755
756/// A service-level `tmpfs` item is malformed or depends on provider- or target-specific behavior.
757pub const TMPFS_PROVIDER_DEPENDENT: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.provider-dependent-item");
758
759/// A service `sysctls` value is neither a mapping nor a sequence.
760pub const SYSCTLS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-map-or-list");
761
762/// A service `sysctls` mapping contains an empty key.
763pub const SYSCTLS_EMPTY_KEY: DiagnosticCode = DiagnosticCode::new("compose.sysctls.empty-key");
764
765/// A service `sysctls` mapping value is not a scalar or null.
766pub const SYSCTLS_EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-scalar");
767
768/// A service `sysctls` list item is not a YAML string scalar.
769pub const SYSCTLS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-string");
770
771/// A service `sysctls` list contains an exact duplicate string.
772pub const SYSCTLS_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.sysctls.duplicate-item");
773
774/// A service `logging` value is not a mapping.
775pub const LOGGING_EXPECTED_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.logging.expected-mapping");
776
777/// A service logging driver is not a YAML string scalar.
778pub const LOGGING_DRIVER_EXPECTED_STRING: DiagnosticCode =
779    DiagnosticCode::new("compose.logging.driver.expected-string");
780
781/// A service logging options value is not a mapping.
782pub const LOGGING_OPTIONS_EXPECTED_MAPPING: DiagnosticCode =
783    DiagnosticCode::new("compose.logging.options.expected-mapping");
784
785/// A service logging option has an empty key.
786pub const LOGGING_OPTION_EMPTY_KEY: DiagnosticCode = DiagnosticCode::new("compose.logging.option.empty-key");
787
788/// A service logging option is not a YAML string, number, or null scalar.
789pub const LOGGING_OPTION_EXPECTED_SCALAR: DiagnosticCode =
790    DiagnosticCode::new("compose.logging.option.expected-scalar");
791
792/// A service environment-file item is neither scalar short syntax nor mapping long syntax.
793pub const ENVIRONMENT_FILE_EXPECTED_FORM: DiagnosticCode =
794    DiagnosticCode::new("compose.environment-file.expected-short-or-long");
795
796/// A long-syntax service environment-file entry is missing `path`.
797pub const ENVIRONMENT_FILE_MISSING_PATH: DiagnosticCode =
798    DiagnosticCode::new("compose.environment-file.long.missing-path");
799
800/// A long-syntax service environment-file format is not defined by Compose.
801pub const ENVIRONMENT_FILE_INVALID_FORMAT: DiagnosticCode =
802    DiagnosticCode::new("compose.environment-file.invalid-format");
803
804/// A long dependency uses an unrecognized condition.
805pub const DEPENDENCY_INVALID_CONDITION: DiagnosticCode = DiagnosticCode::new("compose.dependencies.invalid-condition");
806
807/// A typed dependency names a service missing from the same document.
808pub const DEPENDENCY_MISSING_SERVICE: DiagnosticCode = DiagnosticCode::new("compose.dependencies.missing-service");
809
810/// A `service_healthy` dependency has no enabled health check.
811pub const DEPENDENCY_MISSING_HEALTHCHECK: DiagnosticCode =
812    DiagnosticCode::new("compose.dependencies.missing-healthcheck");
813
814/// A `service_healthy` dependency may rely on health metadata from its image.
815pub const DEPENDENCY_HEALTHCHECK_UNVERIFIED: DiagnosticCode =
816    DiagnosticCode::new("compose.dependencies.healthcheck-unverified");
817
818/// A `BuildKit` SSH declaration has an unsupported outer or item form.
819pub const BUILD_SSH_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.build.ssh-expected-form");
820
821/// A `BuildKit` SSH list repeats an item despite the schema uniqueness rule.
822pub const BUILD_SSH_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.build.ssh-duplicate-item");
823
824/// A typed value and the exact source span from which it was read.
825#[derive(Debug, Clone, PartialEq, Eq)]
826pub struct Located<T> {
827    value: T,
828    span: SourceSpan,
829}
830
831impl<T> Located<T> {
832    pub(crate) const fn new(value: T, span: SourceSpan) -> Self {
833        Self { value, span }
834    }
835
836    /// Returns the typed value.
837    #[must_use]
838    pub const fn value(&self) -> &T {
839        &self.value
840    }
841
842    /// Returns the value's source span.
843    #[must_use]
844    pub const fn span(&self) -> SourceSpan {
845        self.span
846    }
847
848    /// Removes the source wrapper and returns the typed value.
849    #[must_use]
850    pub fn into_value(self) -> T {
851        self.value
852    }
853}
854
855/// Source provenance for an extension or not-yet-typed field.
856///
857/// The loss-aware [`SyntaxDocument`] retains the actual value and spelling. This reference lets
858/// typed callers locate it without exposing the private YAML implementation.
859#[derive(Debug, Clone, PartialEq, Eq)]
860pub struct FieldReference {
861    name: Located<String>,
862    span: SourceSpan,
863    value_span: Option<SourceSpan>,
864}
865
866impl FieldReference {
867    pub(crate) const fn generated(name: Located<String>, span: SourceSpan, value_span: Option<SourceSpan>) -> Self {
868        Self { name, span, value_span }
869    }
870
871    /// Returns the semantic field name and its source span.
872    #[must_use]
873    pub const fn name(&self) -> &Located<String> {
874        &self.name
875    }
876
877    /// Returns the span covering the key and value when both are available.
878    #[must_use]
879    pub const fn span(&self) -> SourceSpan {
880        self.span
881    }
882
883    /// Returns the value span when the YAML node exposes one.
884    #[must_use]
885    pub const fn value_span(&self) -> Option<SourceSpan> {
886        self.value_span
887    }
888}
889
890/// A source-aware typed Compose service.
891#[derive(Debug, Clone, PartialEq, Eq)]
892pub struct Service {
893    name: Located<String>,
894    span: SourceSpan,
895    hostname: Option<Hostname>,
896    domainname: Option<Located<String>>,
897    container_name: Option<Located<String>>,
898    image: Option<Located<ImageReference>>,
899    platform: Option<Located<String>>,
900    isolation: Option<Located<String>>,
901    mac_address: Option<Located<String>>,
902    uts: Option<Located<String>>,
903    entrypoint: Option<Entrypoint>,
904    command: Option<Command>,
905    credential_spec: Option<CredentialSpec>,
906    extends: Option<Extends>,
907    provider: Option<Provider>,
908    post_start: Option<PostStartHooks>,
909    pre_stop: Option<PreStopHooks>,
910    pre_start: Option<PreStartHooks>,
911    blkio_config: Option<BlkioConfig>,
912    cgroup: Option<CgroupNamespace>,
913    cgroup_parent: Option<Located<String>>,
914    attach: Option<Located<BooleanValue>>,
915    init: Option<Located<BooleanValue>>,
916    stdin_open: Option<Located<BooleanValue>>,
917    tty: Option<Located<BooleanValue>>,
918    privileged: Option<Located<BooleanValue>>,
919    use_api_socket: Option<Located<BooleanValue>>,
920    environment: Option<Environment>,
921    environment_files: Vec<EnvironmentFile>,
922    label_files: Option<LabelFiles>,
923    labels: Option<Labels>,
924    annotations: Option<Annotations>,
925    extra_hosts: Option<ExtraHosts>,
926    external_links: Vec<Located<String>>,
927    links: Vec<Located<String>>,
928    storage_opt: Option<Labels>,
929    models: Option<ServiceModels>,
930    gpus: Option<Gpus>,
931    develop: Option<Develop>,
932    user: Option<UserSpec>,
933    userns_mode: Option<UserNamespaceMode>,
934    group_add: Vec<Located<String>>,
935    cap_add: Option<CapabilityAdd>,
936    cap_drop: Option<CapabilityDrop>,
937    devices: Option<Devices>,
938    dns: Option<Dns>,
939    dns_options: Option<DnsOptions>,
940    dns_search: Option<DnsSearch>,
941    expose: Option<Expose>,
942    security_options: Option<SecurityOptions>,
943    working_dir: Option<Located<String>>,
944    read_only: Option<Located<BooleanValue>>,
945    pids_limit: Option<PidsLimit>,
946    cpu_count: Option<Located<CpuCount>>,
947    cpu_percent: Option<Located<CpuPercent>>,
948    cpu_period: Option<Located<CpuPeriod>>,
949    cpu_quota: Option<Located<CpuQuota>>,
950    cpu_rt_period: Option<Located<CpuRtPeriod>>,
951    cpu_rt_runtime: Option<Located<CpuRtRuntime>>,
952    cpu_shares: Option<Located<ServiceInteger>>,
953    cpus: Option<Located<Cpus>>,
954    cpuset: Option<Located<String>>,
955    device_cgroup_rules: Vec<Located<String>>,
956    invalid_device_cgroup_rules: Vec<InvalidServiceStringItem>,
957    ipc: Option<Located<IpcMode>>,
958    mem_reservation: Option<MemLimit>,
959    mem_swappiness: Option<Located<ServiceInteger>>,
960    memswap_limit: Option<MemswapLimit>,
961    network_mode: Option<Located<NetworkMode>>,
962    oom_kill_disable: Option<Located<BooleanValue>>,
963    oom_score_adj: Option<Located<ServiceInteger>>,
964    pid: Option<Located<PidMode>>,
965    scale: Option<Located<ServiceInteger>>,
966    volumes_from: Vec<VolumesFrom>,
967    invalid_volumes_from: Vec<InvalidServiceStringItem>,
968    shm_size: Option<ShmSize>,
969    mem_limit: Option<MemLimit>,
970    tmpfs: Option<Tmpfs>,
971    sysctls: Option<Sysctls>,
972    logging: Option<Logging>,
973    pull_policy: Option<PullPolicy>,
974    pull_refresh_after: Option<Located<String>>,
975    restart: Option<RestartPolicy>,
976    runtime: Option<Located<String>>,
977    stop_signal: Option<Located<String>>,
978    stop_grace_period: Option<Located<StopGracePeriod>>,
979    ulimits: Option<Ulimits>,
980    depends_on: Option<DependsOn>,
981    healthcheck: Option<Healthcheck>,
982    build: Option<Build>,
983    deploy: Option<DeployDefinition>,
984    ports: Vec<Port>,
985    volumes: Vec<VolumeMount>,
986    networks: Option<ServiceNetworks>,
987    profiles: Vec<Located<String>>,
988    configs: Vec<ConfigGrant>,
989    secrets: Vec<SecretGrant>,
990    extension_fields: Vec<FieldReference>,
991    unknown_fields: Vec<FieldReference>,
992}
993
994impl Service {
995    #[expect(
996        clippy::too_many_lines,
997        reason = "each source-aware field has an explicit omission state; grouping would obscure that contract"
998    )]
999    fn new(name: Located<String>, span: SourceSpan) -> Self {
1000        Self {
1001            name,
1002            span,
1003            hostname: None,
1004            domainname: None,
1005            container_name: None,
1006            image: None,
1007            platform: None,
1008            isolation: None,
1009            mac_address: None,
1010            uts: None,
1011            entrypoint: None,
1012            command: None,
1013            credential_spec: None,
1014            extends: None,
1015            provider: None,
1016            post_start: None,
1017            pre_stop: None,
1018            pre_start: None,
1019            blkio_config: None,
1020            cgroup: None,
1021            cgroup_parent: None,
1022            attach: None,
1023            init: None,
1024            stdin_open: None,
1025            tty: None,
1026            privileged: None,
1027            use_api_socket: None,
1028            environment: None,
1029            environment_files: Vec::new(),
1030            label_files: None,
1031            labels: None,
1032            annotations: None,
1033            extra_hosts: None,
1034            external_links: Vec::new(),
1035            links: Vec::new(),
1036            storage_opt: None,
1037            models: None,
1038            gpus: None,
1039            develop: None,
1040            user: None,
1041            userns_mode: None,
1042            group_add: Vec::new(),
1043            cap_add: None,
1044            cap_drop: None,
1045            devices: None,
1046            dns: None,
1047            dns_options: None,
1048            dns_search: None,
1049            expose: None,
1050            security_options: None,
1051            working_dir: None,
1052            read_only: None,
1053            pids_limit: None,
1054            cpu_count: None,
1055            cpu_percent: None,
1056            cpu_period: None,
1057            cpu_quota: None,
1058            cpu_rt_period: None,
1059            cpu_rt_runtime: None,
1060            cpu_shares: None,
1061            cpus: None,
1062            cpuset: None,
1063            device_cgroup_rules: Vec::new(),
1064            invalid_device_cgroup_rules: Vec::new(),
1065            ipc: None,
1066            mem_reservation: None,
1067            mem_swappiness: None,
1068            memswap_limit: None,
1069            network_mode: None,
1070            oom_kill_disable: None,
1071            oom_score_adj: None,
1072            pid: None,
1073            scale: None,
1074            volumes_from: Vec::new(),
1075            invalid_volumes_from: Vec::new(),
1076            shm_size: None,
1077            mem_limit: None,
1078            tmpfs: None,
1079            sysctls: None,
1080            logging: None,
1081            pull_policy: None,
1082            pull_refresh_after: None,
1083            restart: None,
1084            runtime: None,
1085            stop_signal: None,
1086            stop_grace_period: None,
1087            ulimits: None,
1088            depends_on: None,
1089            healthcheck: None,
1090            build: None,
1091            deploy: None,
1092            ports: Vec::new(),
1093            volumes: Vec::new(),
1094            networks: None,
1095            profiles: Vec::new(),
1096            configs: Vec::new(),
1097            secrets: Vec::new(),
1098            extension_fields: Vec::new(),
1099            unknown_fields: Vec::new(),
1100        }
1101    }
1102
1103    /// Returns the service name.
1104    #[must_use]
1105    pub const fn name(&self) -> &Located<String> {
1106        &self.name
1107    }
1108
1109    /// Returns the complete service definition span.
1110    #[must_use]
1111    pub const fn span(&self) -> SourceSpan {
1112        self.span
1113    }
1114
1115    /// Returns the explicitly authored raw-preserving service hostname.
1116    #[must_use]
1117    pub const fn hostname(&self) -> Option<&Hostname> {
1118        self.hostname.as_ref()
1119    }
1120
1121    /// Returns the explicitly authored service domain name without DNS or runtime interpretation.
1122    #[must_use]
1123    pub const fn domainname(&self) -> Option<&Located<String>> {
1124        self.domainname.as_ref()
1125    }
1126
1127    /// Returns the explicitly authored runtime container name.
1128    #[must_use]
1129    pub const fn container_name(&self) -> Option<&Located<String>> {
1130        self.container_name.as_ref()
1131    }
1132
1133    /// Returns the explicitly authored image reference.
1134    #[must_use]
1135    pub const fn image(&self) -> Option<&Located<ImageReference>> {
1136        self.image.as_ref()
1137    }
1138
1139    /// Returns the strict raw service platform string without OCI interpretation.
1140    #[must_use]
1141    pub const fn platform(&self) -> Option<&Located<String>> {
1142        self.platform.as_ref()
1143    }
1144
1145    /// Returns the raw service isolation spelling without platform or runtime interpretation.
1146    #[must_use]
1147    pub const fn isolation(&self) -> Option<&Located<String>> {
1148        self.isolation.as_ref()
1149    }
1150
1151    /// Returns the raw service MAC-address spelling without validating host or network support.
1152    #[must_use]
1153    pub const fn mac_address(&self) -> Option<&Located<String>> {
1154        self.mac_address.as_ref()
1155    }
1156
1157    /// Returns the raw service UTS-mode spelling without namespace interpretation.
1158    #[must_use]
1159    pub const fn uts(&self) -> Option<&Located<String>> {
1160        self.uts.as_ref()
1161    }
1162
1163    /// Returns the entrypoint without normalizing its authored form.
1164    #[must_use]
1165    pub const fn entrypoint(&self) -> Option<&Entrypoint> {
1166        self.entrypoint.as_ref()
1167    }
1168
1169    /// Returns the command without normalizing its authored form.
1170    #[must_use]
1171    pub const fn command(&self) -> Option<&Command> {
1172        self.command.as_ref()
1173    }
1174
1175    /// Returns the authored credential-spec mapping without resolving its references.
1176    #[must_use]
1177    pub const fn credential_spec(&self) -> Option<&CredentialSpec> {
1178        self.credential_spec.as_ref()
1179    }
1180
1181    /// Returns the authored raw extends directive without resolving its reference.
1182    #[must_use]
1183    pub const fn extends(&self) -> Option<&Extends> {
1184        self.extends.as_ref()
1185    }
1186
1187    /// Returns the authored provider configuration without provider execution or discovery.
1188    #[must_use]
1189    pub const fn provider(&self) -> Option<&Provider> {
1190        self.provider.as_ref()
1191    }
1192
1193    /// Returns ordered post-start hooks without executing them or inferring lifecycle behavior.
1194    #[must_use]
1195    pub const fn post_start(&self) -> Option<&PostStartHooks> {
1196        self.post_start.as_ref()
1197    }
1198
1199    /// Returns ordered pre-stop hooks without executing them or inferring lifecycle behavior.
1200    #[must_use]
1201    pub const fn pre_stop(&self) -> Option<&PreStopHooks> {
1202        self.pre_stop.as_ref()
1203    }
1204
1205    /// Returns ordered pre-start hooks without executing them or inferring lifecycle behavior.
1206    #[must_use]
1207    pub const fn pre_start(&self) -> Option<&PreStartHooks> {
1208        self.pre_start.as_ref()
1209    }
1210
1211    /// Returns authored block-I/O configuration without controller or runtime interpretation.
1212    #[must_use]
1213    pub const fn blkio_config(&self) -> Option<&BlkioConfig> {
1214        self.blkio_config.as_ref()
1215    }
1216
1217    /// Returns the authored cgroup namespace without controller or runtime interpretation.
1218    #[must_use]
1219    pub const fn cgroup(&self) -> Option<&CgroupNamespace> {
1220        self.cgroup.as_ref()
1221    }
1222
1223    /// Returns the authored raw cgroup parent string without path or runtime interpretation.
1224    #[must_use]
1225    pub const fn cgroup_parent(&self) -> Option<&Located<String>> {
1226        self.cgroup_parent.as_ref()
1227    }
1228
1229    /// Returns the strict raw service runtime string without runtime interpretation.
1230    #[must_use]
1231    pub const fn runtime(&self) -> Option<&Located<String>> {
1232        self.runtime.as_ref()
1233    }
1234
1235    /// Returns the strict raw service pull-refresh interval without refresh interpretation.
1236    #[must_use]
1237    pub const fn pull_refresh_after(&self) -> Option<&Located<String>> {
1238        self.pull_refresh_after.as_ref()
1239    }
1240
1241    /// Returns the authored attach choice without runtime interpretation.
1242    #[must_use]
1243    pub const fn attach(&self) -> Option<&Located<BooleanValue>> {
1244        self.attach.as_ref()
1245    }
1246
1247    /// Returns whether Compose should run its platform-specific init process.
1248    #[must_use]
1249    pub const fn init(&self) -> Option<&Located<BooleanValue>> {
1250        self.init.as_ref()
1251    }
1252
1253    /// Returns whether Compose should keep standard input open for the service.
1254    #[must_use]
1255    pub const fn stdin_open(&self) -> Option<&Located<BooleanValue>> {
1256        self.stdin_open.as_ref()
1257    }
1258
1259    /// Returns whether Compose should allocate a terminal for the service.
1260    #[must_use]
1261    pub const fn tty(&self) -> Option<&Located<BooleanValue>> {
1262        self.tty.as_ref()
1263    }
1264
1265    /// Returns whether Compose should run the service with its privileged choice.
1266    #[must_use]
1267    pub const fn privileged(&self) -> Option<&Located<BooleanValue>> {
1268        self.privileged.as_ref()
1269    }
1270
1271    /// Returns the explicit API-socket mount choice without inspecting the socket or host.
1272    #[must_use]
1273    pub const fn use_api_socket(&self) -> Option<&Located<BooleanValue>> {
1274        self.use_api_socket.as_ref()
1275    }
1276
1277    /// Returns environment variables with list and mapping forms kept distinct.
1278    #[must_use]
1279    pub const fn environment(&self) -> Option<&Environment> {
1280        self.environment.as_ref()
1281    }
1282
1283    /// Returns service environment files in authored order with syntax retained.
1284    #[must_use]
1285    pub fn environment_files(&self) -> &[EnvironmentFile] {
1286        &self.environment_files
1287    }
1288
1289    /// Returns the authored scalar or ordered-list `label_file` form. `ComposeLens` never reads these files.
1290    #[must_use]
1291    pub const fn label_files(&self) -> Option<&LabelFiles> {
1292        self.label_files.as_ref()
1293    }
1294
1295    /// Returns service metadata labels with list and mapping forms kept distinct.
1296    #[must_use]
1297    pub const fn labels(&self) -> Option<&Labels> {
1298        self.labels.as_ref()
1299    }
1300
1301    /// Returns service annotations with list and mapping forms kept distinct.
1302    #[must_use]
1303    pub const fn annotations(&self) -> Option<&Annotations> {
1304        self.annotations.as_ref()
1305    }
1306
1307    /// Returns additional host mappings with short and long forms retained.
1308    #[must_use]
1309    pub const fn extra_hosts(&self) -> Option<&ExtraHosts> {
1310        self.extra_hosts.as_ref()
1311    }
1312
1313    /// Returns raw external-link declarations in authored order without resolving them.
1314    #[must_use]
1315    pub fn external_links(&self) -> &[Located<String>] {
1316        &self.external_links
1317    }
1318
1319    /// Returns raw legacy link declarations in authored order without resolving them.
1320    #[must_use]
1321    pub fn links(&self) -> &[Located<String>] {
1322        &self.links
1323    }
1324
1325    /// Returns storage options as a source-aware scalar map without storage-driver interpretation.
1326    #[must_use]
1327    pub const fn storage_opt(&self) -> Option<&Labels> {
1328        self.storage_opt.as_ref()
1329    }
1330
1331    /// Returns raw service model bindings without loading model definitions or providers.
1332    #[must_use]
1333    pub const fn models(&self) -> Option<&ServiceModels> {
1334        self.models.as_ref()
1335    }
1336
1337    /// Returns the raw GPU selector when it uses the scalar form.
1338    #[must_use]
1339    pub const fn gpus(&self) -> Option<&Gpus> {
1340        self.gpus.as_ref()
1341    }
1342
1343    /// Returns side-effect-free `develop` watch declarations.
1344    #[must_use]
1345    pub const fn develop(&self) -> Option<&Develop> {
1346        self.develop.as_ref()
1347    }
1348
1349    /// Returns the raw-preserving container user/group value.
1350    #[must_use]
1351    pub const fn user(&self) -> Option<&UserSpec> {
1352        self.user.as_ref()
1353    }
1354
1355    /// Returns the raw-preserving user-namespace mode.
1356    #[must_use]
1357    pub const fn userns_mode(&self) -> Option<&UserNamespaceMode> {
1358        self.userns_mode.as_ref()
1359    }
1360
1361    /// Returns supplementary groups in authored order without resolving names or IDs.
1362    #[must_use]
1363    pub fn group_add(&self) -> &[Located<String>] {
1364        &self.group_add
1365    }
1366
1367    /// Returns the explicitly authored capability-add sequence, including an explicit empty one.
1368    #[must_use]
1369    pub const fn cap_add(&self) -> Option<&CapabilityAdd> {
1370        self.cap_add.as_ref()
1371    }
1372
1373    /// Returns the explicitly authored capability-drop sequence, including an explicit empty one.
1374    #[must_use]
1375    pub const fn cap_drop(&self) -> Option<&CapabilityDrop> {
1376        self.cap_drop.as_ref()
1377    }
1378
1379    /// Returns the explicitly authored ordered device sequence, including an explicit empty one.
1380    #[must_use]
1381    pub const fn devices(&self) -> Option<&Devices> {
1382        self.devices.as_ref()
1383    }
1384
1385    /// Returns raw service DNS servers with scalar and ordered-list forms retained.
1386    #[must_use]
1387    pub const fn dns(&self) -> Option<&Dns> {
1388        self.dns.as_ref()
1389    }
1390
1391    /// Returns the explicitly authored ordered DNS resolver-option sequence.
1392    #[must_use]
1393    pub const fn dns_options(&self) -> Option<&DnsOptions> {
1394        self.dns_options.as_ref()
1395    }
1396
1397    /// Returns raw DNS search domains with scalar and ordered-list forms retained.
1398    #[must_use]
1399    pub const fn dns_search(&self) -> Option<&DnsSearch> {
1400        self.dns_search.as_ref()
1401    }
1402
1403    /// Returns the explicitly authored ordered exposed-port sequence.
1404    #[must_use]
1405    pub const fn expose(&self) -> Option<&Expose> {
1406        self.expose.as_ref()
1407    }
1408
1409    /// Returns the explicitly authored ordered raw service security options.
1410    #[must_use]
1411    pub const fn security_options(&self) -> Option<&SecurityOptions> {
1412        self.security_options.as_ref()
1413    }
1414
1415    /// Returns the container working-directory override.
1416    #[must_use]
1417    pub const fn working_dir(&self) -> Option<&Located<String>> {
1418        self.working_dir.as_ref()
1419    }
1420
1421    /// Returns the explicit read-only root-filesystem choice.
1422    #[must_use]
1423    pub const fn read_only(&self) -> Option<&Located<BooleanValue>> {
1424        self.read_only.as_ref()
1425    }
1426
1427    /// Returns the raw-preserving service PID limit.
1428    #[must_use]
1429    pub const fn pids_limit(&self) -> Option<&PidsLimit> {
1430        self.pids_limit.as_ref()
1431    }
1432
1433    /// Returns the authored CPU-count scalar without quota or runtime interpretation.
1434    #[must_use]
1435    pub const fn cpu_count(&self) -> Option<&Located<CpuCount>> {
1436        self.cpu_count.as_ref()
1437    }
1438
1439    /// Returns the authored CPU-percentage scalar without CPU or runtime interpretation.
1440    #[must_use]
1441    pub const fn cpu_percent(&self) -> Option<&Located<CpuPercent>> {
1442        self.cpu_percent.as_ref()
1443    }
1444
1445    /// Returns the authored CPU-period scalar without CPU or runtime interpretation.
1446    #[must_use]
1447    pub const fn cpu_period(&self) -> Option<&Located<CpuPeriod>> {
1448        self.cpu_period.as_ref()
1449    }
1450
1451    /// Returns the authored CPU-quota scalar without CPU or runtime interpretation.
1452    #[must_use]
1453    pub const fn cpu_quota(&self) -> Option<&Located<CpuQuota>> {
1454        self.cpu_quota.as_ref()
1455    }
1456
1457    /// Returns the authored real-time CPU-period scalar without CPU or runtime interpretation.
1458    #[must_use]
1459    pub const fn cpu_rt_period(&self) -> Option<&Located<CpuRtPeriod>> {
1460        self.cpu_rt_period.as_ref()
1461    }
1462
1463    /// Returns the authored real-time CPU-runtime scalar without scheduler interpretation.
1464    #[must_use]
1465    pub const fn cpu_rt_runtime(&self) -> Option<&Located<CpuRtRuntime>> {
1466        self.cpu_rt_runtime.as_ref()
1467    }
1468    /// Returns the authored relative CPU-share scalar.
1469    #[must_use]
1470    pub const fn cpu_shares(&self) -> Option<&Located<ServiceInteger>> {
1471        self.cpu_shares.as_ref()
1472    }
1473    /// Returns the authored decimal CPU allocation spelling.
1474    #[must_use]
1475    pub const fn cpus(&self) -> Option<&Located<Cpus>> {
1476        self.cpus.as_ref()
1477    }
1478    /// Returns the raw CPU-set spelling without provider grammar inference.
1479    #[must_use]
1480    pub const fn cpuset(&self) -> Option<&Located<String>> {
1481        self.cpuset.as_ref()
1482    }
1483    /// Returns ordered raw device cgroup rules, including duplicates.
1484    #[must_use]
1485    pub fn device_cgroup_rules(&self) -> &[Located<String>] {
1486        &self.device_cgroup_rules
1487    }
1488    /// Returns malformed device-cgroup rule items retained with their source spans.
1489    #[must_use]
1490    pub fn invalid_device_cgroup_rules(&self) -> &[InvalidServiceStringItem] {
1491        &self.invalid_device_cgroup_rules
1492    }
1493    /// Returns the authored IPC mode.
1494    #[must_use]
1495    pub const fn ipc(&self) -> Option<&Located<IpcMode>> {
1496        self.ipc.as_ref()
1497    }
1498    /// Returns the authored memory reservation.
1499    #[must_use]
1500    pub const fn mem_reservation(&self) -> Option<&MemLimit> {
1501        self.mem_reservation.as_ref()
1502    }
1503    /// Returns the authored memory-swappiness scalar.
1504    #[must_use]
1505    pub const fn mem_swappiness(&self) -> Option<&Located<ServiceInteger>> {
1506        self.mem_swappiness.as_ref()
1507    }
1508    /// Returns the authored memory-plus-swap limit spelling.
1509    #[must_use]
1510    pub const fn memswap_limit(&self) -> Option<&MemswapLimit> {
1511        self.memswap_limit.as_ref()
1512    }
1513    /// Returns the authored network mode.
1514    #[must_use]
1515    pub const fn network_mode(&self) -> Option<&Located<NetworkMode>> {
1516        self.network_mode.as_ref()
1517    }
1518    /// Returns the authored OOM-kill choice or deferred expression.
1519    #[must_use]
1520    pub const fn oom_kill_disable(&self) -> Option<&Located<BooleanValue>> {
1521        self.oom_kill_disable.as_ref()
1522    }
1523    /// Returns the authored OOM-score adjustment scalar.
1524    #[must_use]
1525    pub const fn oom_score_adj(&self) -> Option<&Located<ServiceInteger>> {
1526        self.oom_score_adj.as_ref()
1527    }
1528    /// Returns the authored PID namespace mode.
1529    #[must_use]
1530    pub const fn pid(&self) -> Option<&Located<PidMode>> {
1531        self.pid.as_ref()
1532    }
1533    /// Returns the authored scale scalar.
1534    #[must_use]
1535    pub const fn scale(&self) -> Option<&Located<ServiceInteger>> {
1536        self.scale.as_ref()
1537    }
1538    /// Returns ordered `volumes_from` references with default access retained.
1539    #[must_use]
1540    pub fn volumes_from(&self) -> &[VolumesFrom] {
1541        &self.volumes_from
1542    }
1543    /// Returns malformed `volumes_from` items retained with their source spans.
1544    #[must_use]
1545    pub fn invalid_volumes_from(&self) -> &[InvalidServiceStringItem] {
1546        &self.invalid_volumes_from
1547    }
1548
1549    /// Returns the raw-preserving service shared-memory size.
1550    #[must_use]
1551    pub const fn shm_size(&self) -> Option<&ShmSize> {
1552        self.shm_size.as_ref()
1553    }
1554
1555    /// Returns the raw-preserving service memory limit.
1556    #[must_use]
1557    pub const fn mem_limit(&self) -> Option<&MemLimit> {
1558        self.mem_limit.as_ref()
1559    }
1560
1561    /// Returns service-level temporary filesystems with scalar and list forms retained.
1562    #[must_use]
1563    pub const fn tmpfs(&self) -> Option<&Tmpfs> {
1564        self.tmpfs.as_ref()
1565    }
1566
1567    /// Returns service sysctls with mapping/list form and scalar spelling retained.
1568    #[must_use]
1569    pub const fn sysctls(&self) -> Option<&Sysctls> {
1570        self.sysctls.as_ref()
1571    }
1572
1573    /// Returns service logging configuration with an uninterpreted driver and ordered options.
1574    #[must_use]
1575    pub const fn logging(&self) -> Option<&Logging> {
1576        self.logging.as_ref()
1577    }
1578
1579    /// Returns the raw-preserving service image pull policy.
1580    #[must_use]
1581    pub const fn pull_policy(&self) -> Option<&PullPolicy> {
1582        self.pull_policy.as_ref()
1583    }
1584
1585    /// Returns the service-level container restart policy.
1586    #[must_use]
1587    pub const fn restart(&self) -> Option<&RestartPolicy> {
1588        self.restart.as_ref()
1589    }
1590
1591    /// Returns the explicitly authored signal used to stop the service.
1592    #[must_use]
1593    pub const fn stop_signal(&self) -> Option<&Located<String>> {
1594        self.stop_signal.as_ref()
1595    }
1596
1597    /// Returns the raw-preserving service stop grace period.
1598    #[must_use]
1599    pub const fn stop_grace_period(&self) -> Option<&Located<StopGracePeriod>> {
1600        self.stop_grace_period.as_ref()
1601    }
1602
1603    /// Returns explicitly authored service resource limits.
1604    #[must_use]
1605    pub const fn ulimits(&self) -> Option<&Ulimits> {
1606        self.ulimits.as_ref()
1607    }
1608
1609    /// Returns service dependencies with short and long forms retained.
1610    #[must_use]
1611    pub const fn depends_on(&self) -> Option<&DependsOn> {
1612        self.depends_on.as_ref()
1613    }
1614
1615    /// Returns the service health-check definition.
1616    #[must_use]
1617    pub const fn healthcheck(&self) -> Option<&Healthcheck> {
1618        self.healthcheck.as_ref()
1619    }
1620
1621    /// Returns the build declaration with short and long forms retained.
1622    #[must_use]
1623    pub const fn build(&self) -> Option<&Build> {
1624        self.build.as_ref()
1625    }
1626
1627    /// Returns independently classified deploy subfields.
1628    #[must_use]
1629    pub const fn deploy(&self) -> Option<&DeployDefinition> {
1630        self.deploy.as_ref()
1631    }
1632
1633    /// Returns published ports in authored order.
1634    #[must_use]
1635    pub fn ports(&self) -> &[Port] {
1636        &self.ports
1637    }
1638
1639    /// Returns service-volume mounts in authored order.
1640    #[must_use]
1641    pub fn volumes(&self) -> &[VolumeMount] {
1642        &self.volumes
1643    }
1644
1645    /// Returns service network attachments with short and long forms kept distinct.
1646    #[must_use]
1647    pub const fn networks(&self) -> Option<&ServiceNetworks> {
1648        self.networks.as_ref()
1649    }
1650
1651    /// Returns explicitly authored profile names.
1652    #[must_use]
1653    pub fn profiles(&self) -> &[Located<String>] {
1654        &self.profiles
1655    }
1656
1657    /// Returns service config grants in authored order.
1658    #[must_use]
1659    pub fn configs(&self) -> &[ConfigGrant] {
1660        &self.configs
1661    }
1662
1663    /// Returns service secret grants in authored order.
1664    #[must_use]
1665    pub fn secrets(&self) -> &[SecretGrant] {
1666        &self.secrets
1667    }
1668
1669    /// Returns retained service `x-` extension fields.
1670    #[must_use]
1671    pub fn extension_fields(&self) -> &[FieldReference] {
1672        &self.extension_fields
1673    }
1674
1675    /// Returns service fields not yet represented by the typed subset.
1676    #[must_use]
1677    pub fn unknown_fields(&self) -> &[FieldReference] {
1678        &self.unknown_fields
1679    }
1680}
1681
1682/// A source-aware native Compose document.
1683#[derive(Debug, Clone, PartialEq, Eq)]
1684pub struct ComposeDocument {
1685    source_id: SourceId,
1686    span: SourceSpan,
1687    name: Option<Located<String>>,
1688    version: Option<Located<String>>,
1689    include: Option<Includes>,
1690    models: Option<ModelDefinitions>,
1691    services: Vec<Service>,
1692    networks: Vec<NetworkDefinition>,
1693    volumes: Vec<VolumeDefinition>,
1694    configs: Vec<ConfigDefinition>,
1695    secrets: Vec<SecretDefinition>,
1696    extension_fields: Vec<FieldReference>,
1697    unknown_fields: Vec<FieldReference>,
1698}
1699
1700impl ComposeDocument {
1701    /// Extracts the initial typed Compose subset from a loss-aware syntax document.
1702    ///
1703    /// Parsing does not interpolate values, apply defaults, normalize short and long forms, or
1704    /// access the environment. Structural problems produce diagnostics and as much typed data as
1705    /// can be recovered.
1706    #[must_use]
1707    pub fn parse(syntax: &SyntaxDocument) -> ModelParse {
1708        Parser::new(syntax).parse()
1709    }
1710
1711    /// Returns the source identifier.
1712    #[must_use]
1713    pub const fn source_id(&self) -> SourceId {
1714        self.source_id
1715    }
1716
1717    /// Returns the typed root mapping span.
1718    #[must_use]
1719    pub const fn span(&self) -> SourceSpan {
1720        self.span
1721    }
1722
1723    /// Returns the explicitly authored project name.
1724    #[must_use]
1725    pub const fn name(&self) -> Option<&Located<String>> {
1726        self.name.as_ref()
1727    }
1728
1729    /// Returns the authored obsolete Compose version string. It is retained for migration tooling
1730    /// and never selects a schema or provider behavior.
1731    #[must_use]
1732    pub const fn version(&self) -> Option<&Located<String>> {
1733        self.version.as_ref()
1734    }
1735
1736    /// Returns simple include paths in authored order. `ComposeLens` does not read or load them.
1737    #[must_use]
1738    pub const fn include(&self) -> Option<&Includes> {
1739        self.include.as_ref()
1740    }
1741
1742    /// Returns top-level model definitions as raw scalar-map evidence without provider access.
1743    #[must_use]
1744    pub const fn models(&self) -> Option<&ModelDefinitions> {
1745        self.models.as_ref()
1746    }
1747
1748    /// Returns services in authored order.
1749    #[must_use]
1750    pub fn services(&self) -> &[Service] {
1751        &self.services
1752    }
1753
1754    /// Finds the first service with the requested name.
1755    #[must_use]
1756    pub fn service(&self, name: &str) -> Option<&Service> {
1757        self.services.iter().find(|service| service.name.value == name)
1758    }
1759
1760    /// Validates dependency targets and `service_healthy` health-check requirements in this document.
1761    ///
1762    /// Multi-file callers should validate the merged project view through
1763    /// [`crate::resolution::validate_references`] instead.
1764    #[must_use]
1765    pub fn validate_dependencies(&self) -> Vec<Diagnostic> {
1766        let mut diagnostics = Vec::new();
1767        for service in &self.services {
1768            let Some(depends_on) = service.depends_on() else {
1769                continue;
1770            };
1771            match depends_on {
1772                DependsOn::Short { services, .. } => {
1773                    for target in services {
1774                        if self.service(target.value()).is_none() {
1775                            diagnostics.push(missing_dependency_diagnostic(target.span(), false, true));
1776                        }
1777                    }
1778                }
1779                DependsOn::Long { services, .. } => {
1780                    for dependency in services {
1781                        let required = !matches!(
1782                            dependency.required().map(Located::value),
1783                            Some(BooleanValue::Literal(false))
1784                        );
1785                        let Some(target) = self.service(dependency.service().value()) else {
1786                            diagnostics.push(missing_dependency_diagnostic(
1787                                dependency.service().span(),
1788                                false,
1789                                required,
1790                            ));
1791                            continue;
1792                        };
1793                        let needs_healthcheck = matches!(
1794                            dependency.condition().map(Located::value),
1795                            Some(DependencyCondition::ServiceHealthy)
1796                        );
1797                        if needs_healthcheck && target.healthcheck().is_none() {
1798                            let span = dependency
1799                                .condition()
1800                                .map_or_else(|| dependency.service().span(), Located::span);
1801                            diagnostics.push(unverified_healthcheck_diagnostic(span));
1802                        } else if needs_healthcheck && target.healthcheck().is_some_and(Healthcheck::is_disabled) {
1803                            let span = dependency
1804                                .condition()
1805                                .map_or_else(|| dependency.service().span(), Located::span);
1806                            diagnostics.push(missing_dependency_diagnostic(span, true, required));
1807                        }
1808                    }
1809                }
1810            }
1811        }
1812        diagnostics
1813    }
1814
1815    /// Returns top-level network definitions in authored order.
1816    #[must_use]
1817    pub fn networks(&self) -> &[NetworkDefinition] {
1818        &self.networks
1819    }
1820
1821    /// Returns top-level volume definitions in authored order.
1822    #[must_use]
1823    pub fn volumes(&self) -> &[VolumeDefinition] {
1824        &self.volumes
1825    }
1826
1827    /// Returns top-level config definitions in authored order.
1828    #[must_use]
1829    pub fn configs(&self) -> &[ConfigDefinition] {
1830        &self.configs
1831    }
1832
1833    /// Returns top-level secret definitions in authored order.
1834    #[must_use]
1835    pub fn secrets(&self) -> &[SecretDefinition] {
1836        &self.secrets
1837    }
1838
1839    /// Returns retained top-level `x-` extension fields.
1840    #[must_use]
1841    pub fn extension_fields(&self) -> &[FieldReference] {
1842        &self.extension_fields
1843    }
1844
1845    /// Returns top-level fields not yet represented by the typed subset.
1846    #[must_use]
1847    pub fn unknown_fields(&self) -> &[FieldReference] {
1848        &self.unknown_fields
1849    }
1850}
1851
1852/// A recoverable typed-model parse result.
1853#[derive(Debug, Clone, PartialEq, Eq)]
1854pub struct ModelParse {
1855    document: Option<ComposeDocument>,
1856    diagnostics: Vec<Diagnostic>,
1857}
1858
1859impl ModelParse {
1860    /// Returns the typed document when the root could be interpreted.
1861    #[must_use]
1862    pub const fn document(&self) -> Option<&ComposeDocument> {
1863        self.document.as_ref()
1864    }
1865
1866    /// Returns structural typed-model diagnostics in source order.
1867    #[must_use]
1868    pub fn diagnostics(&self) -> &[Diagnostic] {
1869        &self.diagnostics
1870    }
1871
1872    /// Reports whether no error diagnostics were emitted.
1873    #[must_use]
1874    pub fn is_valid(&self) -> bool {
1875        !self
1876            .diagnostics
1877            .iter()
1878            .any(|diagnostic| diagnostic.severity() == Severity::Error)
1879    }
1880
1881    /// Separates the recovered document and diagnostics.
1882    #[must_use]
1883    pub fn into_parts(self) -> (Option<ComposeDocument>, Vec<Diagnostic>) {
1884        (self.document, self.diagnostics)
1885    }
1886}
1887
1888fn missing_dependency_diagnostic(span: SourceSpan, healthcheck: bool, required: bool) -> Diagnostic {
1889    let severity = if required { Severity::Error } else { Severity::Warning };
1890    if healthcheck {
1891        Diagnostic::new(
1892            DEPENDENCY_MISSING_HEALTHCHECK,
1893            severity,
1894            if required {
1895                "service_healthy dependency requires an enabled health check"
1896            } else {
1897                "optional service_healthy dependency has no enabled health check"
1898            },
1899        )
1900        .with_label(DiagnosticLabel::primary(span, "dependency cannot become healthy"))
1901    } else {
1902        Diagnostic::new(
1903            DEPENDENCY_MISSING_SERVICE,
1904            severity,
1905            if required {
1906                "service dependency is not declared in this Compose document"
1907            } else {
1908                "optional service dependency is not declared in this Compose document"
1909            },
1910        )
1911        .with_label(DiagnosticLabel::primary(span, "missing dependency service"))
1912    }
1913}
1914
1915fn unverified_healthcheck_diagnostic(span: SourceSpan) -> Diagnostic {
1916    Diagnostic::new(
1917        DEPENDENCY_HEALTHCHECK_UNVERIFIED,
1918        Severity::Warning,
1919        "service_healthy dependency has no Compose healthcheck to validate",
1920    )
1921    .with_label(DiagnosticLabel::primary(span, "image health metadata is not available"))
1922    .with_note("the dependency image may still define a health check; verify it at build or runtime")
1923}
1924
1925fn annotation_diagnostic(
1926    code: DiagnosticCode,
1927    severity: Severity,
1928    span: SourceSpan,
1929    message: &'static str,
1930    label: &'static str,
1931) -> Diagnostic {
1932    Diagnostic::new(code, severity, message).with_label(DiagnosticLabel::primary(span, label))
1933}
1934
1935#[derive(Debug)]
1936struct Parser {
1937    source_id: SourceId,
1938    source_span: SourceSpan,
1939    source: String,
1940    tree: yaml_edit::YamlFile,
1941    anchors: AnchorRegistry,
1942    diagnostics: Vec<Diagnostic>,
1943}
1944
1945impl Parser {
1946    fn new(syntax: &SyntaxDocument) -> Self {
1947        let tree = syntax.yaml_file();
1948        let anchors = tree
1949            .document()
1950            .map_or_else(AnchorRegistry::new, |document| AnchorRegistry::from_document(&document));
1951        Self {
1952            source_id: syntax.source_id(),
1953            source_span: syntax.source_span(),
1954            source: syntax.source_text().to_owned(),
1955            tree,
1956            anchors,
1957            diagnostics: Vec::new(),
1958        }
1959    }
1960
1961    fn parse(mut self) -> ModelParse {
1962        if self.tree.documents().count() > 1 {
1963            self.diagnostics.push(
1964                Diagnostic::new(
1965                    MULTIPLE_DOCUMENTS,
1966                    Severity::Error,
1967                    "Compose input must contain one YAML document",
1968                )
1969                .with_label(DiagnosticLabel::primary(self.source_span, "multiple YAML documents")),
1970            );
1971        }
1972
1973        let Some(root) = self.tree.document() else {
1974            self.diagnostics.push(
1975                Diagnostic::new(
1976                    DOCUMENT_ROOT_TYPE,
1977                    Severity::Error,
1978                    "Compose document root must be a mapping",
1979                )
1980                .with_label(DiagnosticLabel::primary(self.source_span, "empty document")),
1981            );
1982            return ModelParse {
1983                document: None,
1984                diagnostics: self.diagnostics,
1985            };
1986        };
1987        let root_span = span_from_position(self.source_id, root.byte_range());
1988        let Some(mapping) = root.as_mapping() else {
1989            self.diagnostics.push(
1990                Diagnostic::new(
1991                    DOCUMENT_ROOT_TYPE,
1992                    Severity::Error,
1993                    "Compose document root must be a mapping",
1994                )
1995                .with_label(DiagnosticLabel::primary(root_span, "not a mapping")),
1996            );
1997            return ModelParse {
1998                document: None,
1999                diagnostics: self.diagnostics,
2000            };
2001        };
2002
2003        let document = self.parse_root(&mapping, root_span);
2004        ModelParse {
2005            document: Some(document),
2006            diagnostics: self.diagnostics,
2007        }
2008    }
2009
2010    fn parse_root(&mut self, mapping: &Mapping, span: SourceSpan) -> ComposeDocument {
2011        let mut document = ComposeDocument {
2012            source_id: self.source_id,
2013            span,
2014            name: None,
2015            version: None,
2016            include: None,
2017            models: None,
2018            services: Vec::new(),
2019            networks: Vec::new(),
2020            volumes: Vec::new(),
2021            configs: Vec::new(),
2022            secrets: Vec::new(),
2023            extension_fields: Vec::new(),
2024            unknown_fields: Vec::new(),
2025        };
2026        let mut seen = BTreeMap::new();
2027
2028        for field in self.fields(mapping) {
2029            let duplicate = self.record_duplicate(&mut seen, &field);
2030            match field.name.value.as_str() {
2031                "name" if !duplicate => {
2032                    document.name = self.parse_string(&field, "project name");
2033                }
2034                "version" if !duplicate => {
2035                    document.version = self.parse_string(&field, "Compose version");
2036                    self.diagnostics.push(
2037                        Diagnostic::new(
2038                            VERSION_OBSOLETE,
2039                            Severity::Warning,
2040                            "top-level Compose version is obsolete and does not select provider behavior",
2041                        )
2042                        .with_label(DiagnosticLabel::primary(field.span, "obsolete version retained")),
2043                    );
2044                }
2045                "include" if !duplicate => {
2046                    document.include = self.parse_includes(&field);
2047                    if document.include.is_none() {
2048                        document.unknown_fields.push(field.reference());
2049                    }
2050                }
2051                "models" if !duplicate => {
2052                    document.models = self.parse_model_definitions(&field);
2053                    if document.models.is_none() {
2054                        document.unknown_fields.push(field.reference());
2055                    }
2056                }
2057                "services" if !duplicate => {
2058                    document.services = self.parse_services(&field);
2059                }
2060                "networks" if !duplicate => {
2061                    document.networks = self.parse_network_definitions(&field);
2062                }
2063                "volumes" if !duplicate => {
2064                    document.volumes = self.parse_volume_definitions(&field);
2065                }
2066                "configs" if !duplicate => {
2067                    document.configs = self.parse_config_definitions(&field);
2068                }
2069                "secrets" if !duplicate => {
2070                    document.secrets = self.parse_secret_definitions(&field);
2071                }
2072                name if name.starts_with("x-") => {
2073                    document.extension_fields.push(field.reference());
2074                }
2075                _ if duplicate => {}
2076                _ => document.unknown_fields.push(field.reference()),
2077            }
2078        }
2079        document
2080    }
2081
2082    fn parse_services(&mut self, field: &ParsedField) -> Vec<Service> {
2083        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
2084            self.expected(EXPECTED_MAPPING, field, "services must be a mapping");
2085            return Vec::new();
2086        };
2087        let mut services = Vec::new();
2088        let mut seen = BTreeMap::new();
2089        for service_field in self.fields(mapping) {
2090            self.record_duplicate(&mut seen, &service_field);
2091            let Some(service_mapping) = service_field.value.as_ref().and_then(YamlNode::as_mapping) else {
2092                self.expected(EXPECTED_MAPPING, &service_field, "service definition must be a mapping");
2093                continue;
2094            };
2095            services.push(self.parse_service(&service_field, service_mapping));
2096        }
2097        services
2098    }
2099
2100    fn parse_service(&mut self, field: &ParsedField, mapping: &Mapping) -> Service {
2101        let (mut service, mut seen) = (Service::new(field.name.clone(), field.span), BTreeMap::new());
2102        for service_field in self.fields(mapping) {
2103            let duplicate = self.record_duplicate(&mut seen, &service_field);
2104            if !duplicate && self.parse_extra_service_field(&mut service, &service_field) {
2105                continue;
2106            }
2107            match service_field.name.value.as_str() {
2108                "hostname" if !duplicate => service.hostname = self.parse_hostname(&service_field),
2109                "container_name" if !duplicate => {
2110                    service.container_name = self.parse_string(&service_field, "container name");
2111                }
2112                "image" if !duplicate => service.image = self.parse_image(&service_field),
2113                "platform" if !duplicate => self.set_service_platform(&mut service, &service_field),
2114                "entrypoint" if !duplicate => service.entrypoint = self.parse_entrypoint(&service_field),
2115                "command" if !duplicate => service.command = self.parse_command(&service_field),
2116                "credential_spec" if !duplicate => service.credential_spec = self.parse_credential_spec(&service_field),
2117                "extends" if !duplicate => service.extends = self.parse_extends(&service_field),
2118                "provider" if !duplicate => service.provider = self.parse_provider(&service_field),
2119                "post_start" | "pre_stop" | "pre_start" if !duplicate => self.hooks(&mut service, &service_field),
2120                "blkio_config" if !duplicate => self.set_service_blkio_config(&mut service, &service_field),
2121                "cgroup" | "cgroup_parent" if !duplicate => self.set_service_cgroup(&mut service, &service_field),
2122                "attach" | "init" | "stdin_open" | "tty" | "privileged" if !duplicate => {
2123                    self.set_service_boolean(&mut service, &service_field);
2124                }
2125                "environment" if !duplicate => service.environment = self.parse_environment(&service_field),
2126                "env_file" if !duplicate => service.environment_files = self.parse_environment_files(&service_field),
2127                "labels" if !duplicate => service.labels = self.parse_labels(&service_field),
2128                "annotations" if !duplicate => service.annotations = self.parse_annotations(&service_field),
2129                "extra_hosts" if !duplicate => service.extra_hosts = self.parse_extra_hosts(&service_field),
2130                "user" if !duplicate => service.user = self.parse_service_user(&service_field),
2131                "userns_mode" if !duplicate => {
2132                    service.userns_mode = self
2133                        .parse_string(&service_field, "service user namespace mode")
2134                        .map(UserNamespaceMode::parse);
2135                }
2136                "group_add" if !duplicate => service.group_add = self.parse_service_group_add(&service_field),
2137                "cap_add" if !duplicate => service.cap_add = self.parse_cap_add(&service_field),
2138                "cap_drop" if !duplicate => service.cap_drop = self.parse_cap_drop(&service_field),
2139                "devices" if !duplicate => service.devices = self.parse_devices(&service_field),
2140                "dns" if !duplicate => service.dns = self.parse_dns(&service_field),
2141                "dns_opt" if !duplicate => service.dns_options = self.parse_dns_options(&service_field),
2142                "dns_search" if !duplicate => service.dns_search = self.parse_dns_search(&service_field),
2143                "expose" if !duplicate => service.expose = self.parse_expose(&service_field),
2144                "security_opt" if !duplicate => service.security_options = self.parse_security_options(&service_field),
2145                "working_dir" if !duplicate => {
2146                    service.working_dir = self.parse_string(&service_field, "service working directory");
2147                }
2148                "read_only" if !duplicate => service.read_only = self.parse_service_read_only(&service_field),
2149                "shm_size" if !duplicate => service.shm_size = self.parse_shm_size(&service_field),
2150                "mem_limit" if !duplicate => service.mem_limit = self.parse_mem_limit(&service_field),
2151                "tmpfs" if !duplicate => service.tmpfs = self.parse_tmpfs(&service_field),
2152                "sysctls" if !duplicate => service.sysctls = self.parse_sysctls(&service_field),
2153                "logging" if !duplicate => service.logging = self.parse_logging(&service_field),
2154                "pull_policy" if !duplicate => service.pull_policy = self.parse_pull_policy(&service_field),
2155                "pull_refresh_after" if !duplicate => self.set_service_pull_refresh_after(&mut service, &service_field),
2156                "restart" if !duplicate => service.restart = self.parse_restart_policy(&service_field),
2157                "runtime" if !duplicate => self.set_service_runtime(&mut service, &service_field),
2158                "stop_signal" if !duplicate => {
2159                    service.stop_signal = self.parse_string(&service_field, "service stop signal");
2160                }
2161                "stop_grace_period" if !duplicate => {
2162                    service.stop_grace_period = self.parse_stop_grace_period(&service_field);
2163                }
2164                "ulimits" if !duplicate => service.ulimits = self.parse_ulimits(&service_field),
2165                "depends_on" if !duplicate => {
2166                    service.depends_on = self.parse_depends_on(&service_field);
2167                }
2168                "healthcheck" if !duplicate => {
2169                    service.healthcheck = self.parse_healthcheck(&service_field);
2170                }
2171                "build" if !duplicate => service.build = self.parse_build(&service_field),
2172                "deploy" if !duplicate => {
2173                    service.deploy = self.parse_deploy(&service_field);
2174                }
2175                "ports" if !duplicate => {
2176                    service.ports = self.parse_service_ports(&service_field);
2177                }
2178                "volumes" if !duplicate => {
2179                    service.volumes = self.parse_service_volumes(&service_field);
2180                }
2181                "networks" if !duplicate => {
2182                    service.networks = self.parse_service_networks(&service_field);
2183                }
2184                "profiles" if !duplicate => {
2185                    service.profiles = self.parse_string_sequence(&service_field, "service profiles");
2186                }
2187                "configs" if !duplicate => {
2188                    service.configs = self.parse_config_grants(&service_field);
2189                }
2190                "secrets" if !duplicate => {
2191                    service.secrets = self.parse_secret_grants(&service_field).unwrap_or_default();
2192                }
2193                name if name.starts_with("x-") => service.extension_fields.push(service_field.reference()),
2194                _ if duplicate => {}
2195                _ => service.unknown_fields.push(service_field.reference()),
2196            }
2197        }
2198        service
2199    }
2200
2201    fn parse_extra_service_field(&mut self, service: &mut Service, field: &ParsedField) -> bool {
2202        self.parse_service_runtime_field(service, field) || self.parse_service_remaining_field(service, field)
2203    }
2204
2205    fn parse_service_remaining_field(&mut self, service: &mut Service, field: &ParsedField) -> bool {
2206        let scalar = |parser: &mut Self, description| parser.parse_extends_string(field, description);
2207        match field.name.value().as_str() {
2208            "domainname" => service.domainname = scalar(self, "service domainname must be a YAML string scalar"),
2209            "isolation" => service.isolation = scalar(self, "service isolation must be a YAML string scalar"),
2210            "mac_address" => service.mac_address = scalar(self, "service mac_address must be a YAML string scalar"),
2211            "uts" => service.uts = scalar(self, "service uts must be a YAML string scalar"),
2212            "gpus" => {
2213                service.gpus = self.parse_gpus(field);
2214                if service.gpus.as_ref().is_some_and(|gpus| {
2215                    !gpus.unmodeled_items().is_empty()
2216                        || matches!(gpus, Gpus::Devices { devices, .. } if devices.iter().any(|device| !device.unmodeled_fields().is_empty()))
2217                }) {
2218                    service.unknown_fields.push(field.reference());
2219                }
2220            }
2221            "use_api_socket" => service.use_api_socket = self.parse_boolean(field, "service use_api_socket"),
2222            "label_file" => service.label_files = self.parse_label_files(field),
2223            "external_links" => service.external_links = self.parse_string_sequence(field, "service external_links"),
2224            "links" => service.links = self.parse_string_sequence(field, "service links"),
2225            "storage_opt" => service.storage_opt = self.parse_labels(field),
2226            "models" => service.models = self.parse_service_models(field),
2227            "develop" => {
2228                service.develop = self.parse_develop(field);
2229            }
2230            _ => return false,
2231        }
2232        let is_scalar_sequence = matches!(field.name.value().as_str(), "external_links" | "links");
2233        let parsed = match field.name.value().as_str() {
2234            "domainname" => service.domainname.is_some(),
2235            "isolation" => service.isolation.is_some(),
2236            "mac_address" => service.mac_address.is_some(),
2237            "uts" => service.uts.is_some(),
2238            "gpus" => service.gpus.is_some(),
2239            "use_api_socket" => service.use_api_socket.is_some(),
2240            "storage_opt" => service.storage_opt.is_some(),
2241            "models" => service.models.is_some(),
2242            "develop" => service.develop.is_some(),
2243            "label_file" => service.label_files.is_some(),
2244            "external_links" | "links" => field.value.as_ref().is_some_and(|value| value.as_sequence().is_some()),
2245            _ => unreachable!("all supported remaining service fields are listed above"),
2246        };
2247        if !parsed || (is_scalar_sequence && field.value.as_ref().is_some_and(|value| value.as_sequence().is_none())) {
2248            service.unknown_fields.push(field.reference());
2249        }
2250        true
2251    }
2252
2253    fn parse_service_runtime_field(&mut self, service: &mut Service, field: &ParsedField) -> bool {
2254        match field.name.value().as_str() {
2255            "cpu_count" | "cpu_percent" | "cpu_period" | "cpu_quota" | "cpu_rt_period" | "cpu_rt_runtime"
2256            | "cpu_shares" | "cpus" | "mem_swappiness" | "oom_score_adj" | "scale" | "pids_limit" => {
2257                self.set_service_count(service, field);
2258            }
2259            "cpuset" => {
2260                service.cpuset = self.parse_string(field, "service cpuset");
2261                if service.cpuset.is_none() {
2262                    service.unknown_fields.push(field.reference());
2263                }
2264            }
2265            "device_cgroup_rules" => {
2266                if field.value.as_ref().and_then(YamlNode::as_sequence).is_none() {
2267                    service.unknown_fields.push(field.reference());
2268                }
2269                let (items, invalid) = self.parse_strict_string_sequence(field, "device_cgroup_rules");
2270                service.device_cgroup_rules = items;
2271                service.invalid_device_cgroup_rules = invalid;
2272            }
2273            "ipc" => {
2274                service.ipc = self
2275                    .parse_string(field, "service ipc mode")
2276                    .map(|value| Located::new(IpcMode::parse(value.value), value.span));
2277                if service.ipc.is_none() {
2278                    service.unknown_fields.push(field.reference());
2279                }
2280            }
2281            "mem_reservation" => {
2282                service.mem_reservation = self.parse_mem_limit(field);
2283                if service.mem_reservation.is_none() {
2284                    service.unknown_fields.push(field.reference());
2285                }
2286            }
2287            "memswap_limit" => {
2288                service.memswap_limit = self.parse_memswap_limit(field);
2289                if service.memswap_limit.is_none() {
2290                    service.unknown_fields.push(field.reference());
2291                }
2292            }
2293            "network_mode" => {
2294                service.network_mode = self
2295                    .parse_string(field, "service network mode")
2296                    .map(|value| Located::new(NetworkMode::parse(value.value), value.span));
2297                if service.network_mode.is_none() {
2298                    service.unknown_fields.push(field.reference());
2299                }
2300            }
2301            "oom_kill_disable" => {
2302                service.oom_kill_disable = self.parse_boolean(field, "service oom_kill_disable");
2303                if service.oom_kill_disable.is_none() {
2304                    service.unknown_fields.push(field.reference());
2305                }
2306            }
2307            "pid" => {
2308                service.pid = self
2309                    .parse_string(field, "service pid mode")
2310                    .map(|value| Located::new(PidMode::parse(value.value), value.span));
2311                if service.pid.is_none() {
2312                    service.unknown_fields.push(field.reference());
2313                }
2314            }
2315            "volumes_from" => {
2316                if field.value.as_ref().and_then(YamlNode::as_sequence).is_none() {
2317                    service.unknown_fields.push(field.reference());
2318                }
2319                let (items, invalid) = self.parse_strict_string_sequence(field, "volumes_from");
2320                service.volumes_from = items.into_iter().map(VolumesFrom::parse).collect();
2321                service.invalid_volumes_from = invalid;
2322            }
2323            _ => return false,
2324        }
2325        true
2326    }
2327
2328    fn hooks(&mut self, service: &mut Service, field: &ParsedField) {
2329        match field.name.value().as_str() {
2330            "post_start" => service.post_start = self.parse_post_start(field),
2331            "pre_stop" => service.pre_stop = self.parse_pre_stop(field),
2332            "pre_start" => service.pre_start = self.parse_pre_start(field),
2333            _ => {}
2334        }
2335    }
2336
2337    fn parse_includes(&mut self, field: &ParsedField) -> Option<Includes> {
2338        let sequence = field.value.as_ref().and_then(YamlNode::as_sequence)?;
2339        let span = span_from_position(self.source_id, sequence.byte_range());
2340        let mut items = Vec::new();
2341        let mut unmodeled_fields = Vec::new();
2342        for node in sequence.values() {
2343            match node {
2344                YamlNode::Scalar(scalar) if ScalarValue::from_scalar(&scalar).scalar_type() == ScalarType::String => {
2345                    items.push(IncludeItem::Short(Located::new(
2346                        scalar_string_from_source(&self.source, &scalar),
2347                        span_from_position(self.source_id, scalar.byte_range()),
2348                    )));
2349                }
2350                YamlNode::Scalar(_) => {
2351                    self.unsupported_sequence_item(
2352                        EXPECTED_SCALAR,
2353                        &node,
2354                        field.span,
2355                        "include items must be YAML string paths or mappings",
2356                    );
2357                    unmodeled_fields.push(field.reference());
2358                    items.push(IncludeItem::Unmodeled);
2359                }
2360                YamlNode::Mapping(mapping) => items.push(IncludeItem::Long(self.parse_include_long(&mapping))),
2361                other => {
2362                    self.unsupported_sequence_item(
2363                        EXPECTED_FIELD_FORM,
2364                        &other,
2365                        field.span,
2366                        "include items must be paths or mappings",
2367                    );
2368                    unmodeled_fields.push(field.reference());
2369                    items.push(IncludeItem::Unmodeled);
2370                }
2371            }
2372        }
2373        Some(Includes::new(span, items, unmodeled_fields))
2374    }
2375
2376    fn parse_include_long(&mut self, mapping: &Mapping) -> IncludeLong {
2377        let span = span_from_position(self.source_id, mapping.byte_range());
2378        let mut paths = Vec::new();
2379        let mut env_files = Vec::new();
2380        let mut project_directory = None;
2381        let mut unmodeled_fields = Vec::new();
2382        let mut seen = BTreeMap::new();
2383        for field in self.fields(mapping) {
2384            if self.record_duplicate(&mut seen, &field) {
2385                unmodeled_fields.push(field.reference());
2386                continue;
2387            }
2388            match field.name.value().as_str() {
2389                "path" => {
2390                    paths = self.string_or_sequence(&field, "include path must be a YAML string or sequence");
2391                    if !Self::is_strict_string_or_sequence(&field) {
2392                        unmodeled_fields.push(field.reference());
2393                    }
2394                }
2395                "env_file" => {
2396                    env_files = self.string_or_sequence(&field, "include env_file must be a YAML string or sequence");
2397                    if !Self::is_strict_string_or_sequence(&field) {
2398                        unmodeled_fields.push(field.reference());
2399                    }
2400                }
2401                "project_directory" => {
2402                    project_directory =
2403                        self.parse_extends_string(&field, "include project_directory must be a YAML string scalar");
2404                    if project_directory.is_none() {
2405                        unmodeled_fields.push(field.reference());
2406                    }
2407                }
2408                _ => unmodeled_fields.push(field.reference()),
2409            }
2410        }
2411        if paths.is_empty() {
2412            self.diagnostics.push(
2413                Diagnostic::new(EXPECTED_FIELD_FORM, Severity::Error, "include mapping requires path")
2414                    .with_label(DiagnosticLabel::primary(span, "path is missing")),
2415            );
2416        }
2417        IncludeLong::new(span, paths, env_files, project_directory, unmodeled_fields)
2418    }
2419
2420    fn string_or_sequence(&mut self, field: &ParsedField, message: &str) -> Vec<Located<String>> {
2421        if field.value.as_ref().and_then(YamlNode::as_scalar).is_some() {
2422            self.parse_extends_string(field, message).into_iter().collect()
2423        } else {
2424            self.parse_strict_string_sequence(field, message).0
2425        }
2426    }
2427
2428    fn is_strict_string_or_sequence(field: &ParsedField) -> bool {
2429        match field.value.as_ref() {
2430            Some(YamlNode::Scalar(scalar)) => ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::String,
2431            Some(YamlNode::Sequence(sequence)) => sequence.values().all(|item| {
2432                item.as_scalar()
2433                    .is_some_and(|scalar| ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::String)
2434            }),
2435            _ => false,
2436        }
2437    }
2438
2439    fn parse_label_files(&mut self, field: &ParsedField) -> Option<LabelFiles> {
2440        let value = field.value.as_ref()?;
2441        if let Some(scalar) = value.as_scalar() {
2442            if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
2443                self.expected(
2444                    EXPECTED_SCALAR,
2445                    field,
2446                    "service label_file must be a YAML string scalar or sequence",
2447                );
2448                return None;
2449            }
2450            let span = span_from_position(self.source_id, scalar.byte_range());
2451            return Some(LabelFiles::new(
2452                span,
2453                LabelFilesForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
2454                Vec::new(),
2455            ));
2456        }
2457        let Some(sequence) = value.as_sequence() else {
2458            self.expected(
2459                EXPECTED_FIELD_FORM,
2460                field,
2461                "service label_file must be a YAML string scalar or sequence",
2462            );
2463            return None;
2464        };
2465        let span = span_from_position(self.source_id, sequence.byte_range());
2466        let mut paths = Vec::new();
2467        let mut unmodeled_items = Vec::new();
2468        for item in sequence.values() {
2469            let item_span = node_span(self.source_id, &item).unwrap_or(field.span);
2470            let Some(scalar) = item.as_scalar() else {
2471                self.unsupported_sequence_item(
2472                    EXPECTED_SCALAR,
2473                    &item,
2474                    field.span,
2475                    "service label_file entries must be YAML string scalars",
2476                );
2477                unmodeled_items.push(item_span);
2478                continue;
2479            };
2480            if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
2481                self.unsupported_sequence_item(
2482                    EXPECTED_SCALAR,
2483                    &item,
2484                    field.span,
2485                    "service label_file entries must be YAML string scalars",
2486                );
2487                unmodeled_items.push(item_span);
2488                continue;
2489            }
2490            paths.push(Located::new(scalar_string_from_source(&self.source, scalar), item_span));
2491        }
2492        Some(LabelFiles::new(span, LabelFilesForm::List(paths), unmodeled_items))
2493    }
2494
2495    fn parse_model_definitions(&mut self, field: &ParsedField) -> Option<ModelDefinitions> {
2496        let mapping = field.value.as_ref().and_then(YamlNode::as_mapping)?;
2497        let span = span_from_position(self.source_id, mapping.byte_range());
2498        let mut definitions = Vec::new();
2499        let mut unmodeled_fields = Vec::new();
2500        let mut seen = BTreeMap::new();
2501        for definition_field in self.fields(mapping) {
2502            if self.record_duplicate(&mut seen, &definition_field) {
2503                unmodeled_fields.push(definition_field.reference());
2504                continue;
2505            }
2506            let Some(definition_mapping) = definition_field.value.as_ref().and_then(YamlNode::as_mapping) else {
2507                self.expected(
2508                    EXPECTED_MAPPING,
2509                    &definition_field,
2510                    "model definition must be a mapping",
2511                );
2512                unmodeled_fields.push(definition_field.reference());
2513                continue;
2514            };
2515            let mut definition = ModelDefinition::new(definition_field.name.clone(), definition_field.span);
2516            let mut member_seen = BTreeMap::new();
2517            for member in self.fields(definition_mapping) {
2518                if self.record_duplicate(&mut member_seen, &member) {
2519                    definition.push_unmodeled(member.reference());
2520                    continue;
2521                }
2522                match member.name.value().as_str() {
2523                    "name" => {
2524                        let parsed = self.parse_extends_string(&member, "model name must be a YAML string scalar");
2525                        if let Some(value) = parsed {
2526                            definition.set_name(value);
2527                        } else {
2528                            definition.push_unmodeled(member.reference());
2529                        }
2530                    }
2531                    "model" => {
2532                        let parsed = self.parse_extends_string(&member, "model reference must be a YAML string scalar");
2533                        if let Some(value) = parsed {
2534                            definition.set_model(value);
2535                        } else {
2536                            definition.push_unmodeled(member.reference());
2537                        }
2538                    }
2539                    "context_size" => {
2540                        let parsed = self.parse_integer_scalar(&member, "model context_size must be a YAML integer");
2541                        if let Some(value) = parsed {
2542                            definition.set_context_size(value);
2543                        } else {
2544                            definition.push_unmodeled(member.reference());
2545                        }
2546                    }
2547                    "runtime_flags" => {
2548                        let flags =
2549                            self.string_or_sequence(&member, "model runtime_flags must be a YAML string or sequence");
2550                        if !Self::is_strict_string_or_sequence(&member) {
2551                            definition.push_unmodeled(member.reference());
2552                        }
2553                        definition.set_runtime_flags(flags);
2554                    }
2555                    _ => definition.push_unmodeled(member.reference()),
2556                }
2557            }
2558            if definition.model().is_none() {
2559                self.diagnostics.push(
2560                    Diagnostic::new(EXPECTED_FIELD_FORM, Severity::Error, "model definition requires model")
2561                        .with_label(DiagnosticLabel::primary(definition.span(), "model is missing")),
2562                );
2563            }
2564            definitions.push(definition);
2565        }
2566        Some(ModelDefinitions::new(span, definitions, unmodeled_fields))
2567    }
2568
2569    fn parse_service_models(&mut self, field: &ParsedField) -> Option<ServiceModels> {
2570        let span = field
2571            .value
2572            .as_ref()
2573            .and_then(|value| node_span(self.source_id, value))
2574            .unwrap_or(field.span);
2575        let mut bindings = Vec::new();
2576        let mut unmodeled_fields = Vec::new();
2577        match field.value.as_ref()? {
2578            YamlNode::Sequence(sequence) => {
2579                for node in sequence.values() {
2580                    let Some(scalar) = node.as_scalar() else {
2581                        self.unsupported_sequence_item(
2582                            EXPECTED_SCALAR,
2583                            &node,
2584                            field.span,
2585                            "service model bindings must be strings",
2586                        );
2587                        unmodeled_fields.push(field.reference());
2588                        continue;
2589                    };
2590                    if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
2591                        self.unsupported_sequence_item(
2592                            EXPECTED_SCALAR,
2593                            &node,
2594                            field.span,
2595                            "service model bindings must be YAML strings",
2596                        );
2597                        unmodeled_fields.push(field.reference());
2598                        continue;
2599                    }
2600                    bindings.push(ServiceModelBinding::new(
2601                        Located::new(
2602                            scalar_string_from_source(&self.source, scalar),
2603                            span_from_position(self.source_id, scalar.byte_range()),
2604                        ),
2605                        span_from_position(self.source_id, scalar.byte_range()),
2606                    ));
2607                }
2608            }
2609            YamlNode::Mapping(mapping) => {
2610                for binding_field in self.fields(mapping) {
2611                    let mut binding = ServiceModelBinding::new(binding_field.name.clone(), binding_field.span);
2612                    match binding_field.value.as_ref() {
2613                        None => {}
2614                        Some(YamlNode::Scalar(scalar))
2615                            if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null => {}
2616                        Some(YamlNode::Mapping(value)) => {
2617                            let mut seen = BTreeMap::new();
2618                            for member in self.fields(value) {
2619                                if self.record_duplicate(&mut seen, &member) {
2620                                    continue;
2621                                }
2622                                match member.name.value().as_str() {
2623                                    "endpoint_var" => self
2624                                        .parse_extends_string(
2625                                            &member,
2626                                            "model endpoint_var must be a YAML string scalar",
2627                                        )
2628                                        .into_iter()
2629                                        .for_each(|value| binding.set_endpoint_var(value)),
2630                                    "model_var" => self
2631                                        .parse_extends_string(&member, "model model_var must be a YAML string scalar")
2632                                        .into_iter()
2633                                        .for_each(|value| binding.set_model_var(value)),
2634                                    _ => binding.push_unmodeled(member.reference()),
2635                                }
2636                            }
2637                        }
2638                        Some(_) => {
2639                            self.expected(
2640                                EXPECTED_FIELD_FORM,
2641                                &binding_field,
2642                                "service model binding must be null or a mapping",
2643                            );
2644                            binding.push_unmodeled(binding_field.reference());
2645                        }
2646                    }
2647                    bindings.push(binding);
2648                }
2649            }
2650            _ => return None,
2651        }
2652        Some(ServiceModels::new(span, bindings, unmodeled_fields))
2653    }
2654
2655    #[expect(
2656        clippy::too_many_lines,
2657        reason = "GPU selectors retain supported members and every rejected source member"
2658    )]
2659    fn parse_gpus(&mut self, field: &ParsedField) -> Option<Gpus> {
2660        match field.value.as_ref()? {
2661            YamlNode::Scalar(_) => self
2662                .parse_extends_string(field, "service gpus must be a YAML string scalar or sequence")
2663                .and_then(|value| {
2664                    if value.value() == "all" {
2665                        Some(Gpus::All(value))
2666                    } else {
2667                        self.expected(EXPECTED_FIELD_FORM, field, "service gpus scalar must be exactly `all`");
2668                        None
2669                    }
2670                }),
2671            YamlNode::Sequence(sequence) => {
2672                let span = span_from_position(self.source_id, sequence.byte_range());
2673                let mut devices = Vec::new();
2674                let mut unmodeled_items = Vec::new();
2675                for node in sequence.values() {
2676                    let Some(mapping) = node.as_mapping() else {
2677                        self.unsupported_sequence_item(
2678                            EXPECTED_MAPPING,
2679                            &node,
2680                            field.span,
2681                            "GPU selector must be a mapping",
2682                        );
2683                        unmodeled_items.push(node_span(self.source_id, &node).unwrap_or(field.span));
2684                        continue;
2685                    };
2686                    let mut device = GpuDevice::new(node_span(self.source_id, &node).unwrap_or(field.span));
2687                    let mut seen = BTreeMap::new();
2688                    for member in self.fields(mapping) {
2689                        if self.record_duplicate(&mut seen, &member) {
2690                            continue;
2691                        }
2692                        match member.name.value().as_str() {
2693                            "capabilities" => {
2694                                let (values, invalid_items) =
2695                                    self.parse_strict_string_sequence(&member, "GPU capabilities");
2696                                if !matches!(member.value.as_ref(), Some(YamlNode::Sequence(_)))
2697                                    || !invalid_items.is_empty()
2698                                {
2699                                    device.push_unmodeled(member.reference());
2700                                }
2701                                device.set_capabilities(values);
2702                            }
2703                            "count" => {
2704                                let parsed = self.parse_integer_or_string_scalar(
2705                                    &member,
2706                                    "GPU count must be a YAML integer or string",
2707                                );
2708                                if let Some(value) = parsed {
2709                                    device.set_count(value);
2710                                } else {
2711                                    device.push_unmodeled(member.reference());
2712                                }
2713                            }
2714                            "device_ids" => {
2715                                let values = self
2716                                    .string_or_sequence(&member, "GPU device_ids must be a YAML string or sequence");
2717                                if !Self::is_strict_string_or_sequence(&member) {
2718                                    device.push_unmodeled(member.reference());
2719                                }
2720                                device.set_device_ids(values);
2721                            }
2722                            "driver" => {
2723                                let parsed =
2724                                    self.parse_extends_string(&member, "GPU driver must be a YAML string scalar");
2725                                if let Some(value) = parsed {
2726                                    device.set_driver(value);
2727                                } else {
2728                                    device.push_unmodeled(member.reference());
2729                                }
2730                            }
2731                            "options" => match member.value.as_ref() {
2732                                Some(YamlNode::Mapping(_)) => {
2733                                    device.set_options(GpuOptions::Mapping(
2734                                        self.parse_scalar_mapping(&member, "GPU options"),
2735                                    ));
2736                                }
2737                                Some(YamlNode::Sequence(_)) => {
2738                                    let options = self.parse_string_sequence(&member, "GPU options");
2739                                    if !Self::is_strict_string_or_sequence(&member) {
2740                                        device.push_unmodeled(member.reference());
2741                                    }
2742                                    device.set_options(GpuOptions::List(options));
2743                                }
2744                                _ => {
2745                                    self.expected(
2746                                        EXPECTED_FIELD_FORM,
2747                                        &member,
2748                                        "GPU options must be a mapping or sequence",
2749                                    );
2750                                    device.push_unmodeled(member.reference());
2751                                }
2752                            },
2753                            _ => device.push_unmodeled(member.reference()),
2754                        }
2755                    }
2756                    if device.count().is_some() && !device.device_ids().is_empty() {
2757                        self.diagnostics.push(
2758                            Diagnostic::new(
2759                                GPU_COUNT_DEVICE_IDS_CONFLICT,
2760                                Severity::Warning,
2761                                "GPU selector cannot use count and device_ids together",
2762                            )
2763                            .with_label(DiagnosticLabel::primary(
2764                                device.span(),
2765                                "conflicting GPU allocation selectors",
2766                            )),
2767                        );
2768                    }
2769                    if !gpu_has_capabilities(&device) {
2770                        self.diagnostics.push(
2771                            Diagnostic::new(
2772                                GPU_MISSING_CAPABILITIES,
2773                                Severity::Error,
2774                                "GPU selector requires a non-empty capabilities declaration",
2775                            )
2776                            .with_label(DiagnosticLabel::primary(
2777                                device.span(),
2778                                "capabilities are missing, empty, or malformed",
2779                            )),
2780                        );
2781                    }
2782                    devices.push(device);
2783                }
2784                Some(Gpus::Devices {
2785                    span,
2786                    devices,
2787                    unmodeled_items,
2788                })
2789            }
2790            _ => None,
2791        }
2792    }
2793
2794    #[expect(
2795        clippy::too_many_lines,
2796        reason = "develop watch retains supported members and every rejected source member"
2797    )]
2798    fn parse_develop(&mut self, field: &ParsedField) -> Option<Develop> {
2799        let mapping = field.value.as_ref().and_then(YamlNode::as_mapping)?;
2800        let span = span_from_position(self.source_id, mapping.byte_range());
2801        let mut watch = Vec::new();
2802        let mut unmodeled = Vec::new();
2803        let mut unmodeled_items = Vec::new();
2804        let mut seen = BTreeMap::new();
2805        for member in self.fields(mapping) {
2806            if self.record_duplicate(&mut seen, &member) {
2807                unmodeled.push(member.reference());
2808                continue;
2809            }
2810            if member.name.value() != "watch" {
2811                unmodeled.push(member.reference());
2812                continue;
2813            }
2814            let Some(sequence) = member.value.as_ref().and_then(YamlNode::as_sequence) else {
2815                self.expected(EXPECTED_SEQUENCE, &member, "develop watch must be a sequence");
2816                unmodeled.push(member.reference());
2817                continue;
2818            };
2819            for node in sequence.values() {
2820                let Some(watch_mapping) = node.as_mapping() else {
2821                    self.unsupported_sequence_item(
2822                        EXPECTED_MAPPING,
2823                        &node,
2824                        member.span,
2825                        "develop watch items must be mappings",
2826                    );
2827                    unmodeled_items.push(node_span(self.source_id, &node).unwrap_or(member.span));
2828                    continue;
2829                };
2830                let mut item = DevelopWatch::new(node_span(self.source_id, &node).unwrap_or(member.span));
2831                let mut item_seen = BTreeMap::new();
2832                for watch_member in self.fields(watch_mapping) {
2833                    if self.record_duplicate(&mut item_seen, &watch_member) {
2834                        item.push_unmodeled(watch_member.reference());
2835                        continue;
2836                    }
2837                    match watch_member.name.value().as_str() {
2838                        "action" => {
2839                            let parsed = self.parse_extends_string(
2840                                &watch_member,
2841                                "develop watch action must be a YAML string scalar",
2842                            );
2843                            if let Some(value) = parsed {
2844                                item.set_action(value);
2845                            } else {
2846                                item.push_unmodeled(watch_member.reference());
2847                            }
2848                        }
2849                        "path" => {
2850                            let parsed = self
2851                                .parse_extends_string(&watch_member, "develop watch path must be a YAML string scalar");
2852                            if let Some(value) = parsed {
2853                                item.set_path(value);
2854                            } else {
2855                                item.push_unmodeled(watch_member.reference());
2856                            }
2857                        }
2858                        "target" => {
2859                            let parsed = self.parse_extends_string(
2860                                &watch_member,
2861                                "develop watch target must be a YAML string scalar",
2862                            );
2863                            if let Some(value) = parsed {
2864                                item.set_target(value);
2865                            } else {
2866                                item.push_unmodeled(watch_member.reference());
2867                            }
2868                        }
2869                        "ignore" => {
2870                            let values = self.string_or_sequence(
2871                                &watch_member,
2872                                "develop watch ignore must be a YAML string or sequence",
2873                            );
2874                            if !Self::is_strict_string_or_sequence(&watch_member) {
2875                                item.push_unmodeled(watch_member.reference());
2876                            }
2877                            item.set_ignore(values);
2878                        }
2879                        "include" => {
2880                            let values = self.string_or_sequence(
2881                                &watch_member,
2882                                "develop watch include must be a YAML string or sequence",
2883                            );
2884                            if !Self::is_strict_string_or_sequence(&watch_member) {
2885                                item.push_unmodeled(watch_member.reference());
2886                            }
2887                            item.set_include(values);
2888                        }
2889                        "initial_sync" => {
2890                            let parsed =
2891                                self.parse_boolean(&watch_member, "develop watch initial_sync must be a boolean");
2892                            if let Some(value) = parsed {
2893                                item.set_initial_sync(value);
2894                            } else {
2895                                item.push_unmodeled(watch_member.reference());
2896                            }
2897                        }
2898                        "exec" if matches!(watch_member.value.as_ref(), Some(YamlNode::Mapping(_))) => {
2899                            let exec = self.parse_develop_exec(&watch_member);
2900                            if !exec.command().is_some_and(command_is_non_empty) {
2901                                self.diagnostics.push(
2902                                    Diagnostic::new(
2903                                        DEVELOP_WATCH_EXEC_MISSING_COMMAND,
2904                                        Severity::Error,
2905                                        "develop watch exec mapping requires a non-empty command member",
2906                                    )
2907                                    .with_label(DiagnosticLabel::primary(watch_member.span, "exec command is missing")),
2908                                );
2909                            }
2910                            item.set_exec(exec);
2911                        }
2912                        "exec" => {
2913                            self.expected(EXPECTED_MAPPING, &watch_member, "develop watch exec must be a mapping");
2914                            item.push_unmodeled(watch_member.reference());
2915                        }
2916                        _ => item.push_unmodeled(watch_member.reference()),
2917                    }
2918                }
2919                self.validate_develop_watch(&item);
2920                watch.push(item);
2921            }
2922        }
2923        if watch.is_empty() {
2924            self.diagnostics.push(
2925                Diagnostic::new(
2926                    DEVELOP_MISSING_WATCH,
2927                    Severity::Error,
2928                    "develop requires a watch sequence",
2929                )
2930                .with_label(DiagnosticLabel::primary(span, "watch is missing")),
2931            );
2932        }
2933        Some(Develop::new(span, watch, unmodeled, unmodeled_items))
2934    }
2935
2936    fn validate_develop_watch(&mut self, item: &DevelopWatch) {
2937        if item.action().is_none() {
2938            self.diagnostics.push(
2939                Diagnostic::new(
2940                    DEVELOP_WATCH_MISSING_ACTION,
2941                    Severity::Error,
2942                    "develop watch item requires an action",
2943                )
2944                .with_label(DiagnosticLabel::primary(item.span(), "action is missing")),
2945            );
2946        }
2947        if item.path().is_none() {
2948            self.diagnostics.push(
2949                Diagnostic::new(
2950                    DEVELOP_WATCH_MISSING_PATH,
2951                    Severity::Error,
2952                    "develop watch item requires a path",
2953                )
2954                .with_label(DiagnosticLabel::primary(item.span(), "path is missing")),
2955            );
2956        }
2957        let Some(action) = item.action() else {
2958            return;
2959        };
2960        let action_value = action.value();
2961        let synchronizes = matches!(action_value.as_str(), "sync" | "sync+restart" | "sync+exec");
2962        if !matches!(
2963            action_value.as_str(),
2964            "rebuild" | "sync" | "restart" | "sync+restart" | "sync+exec"
2965        ) {
2966            self.diagnostics.push(
2967                Diagnostic::new(
2968                    DEVELOP_WATCH_INVALID_ACTION,
2969                    Severity::Error,
2970                    "develop watch action is not one of rebuild, sync, restart, sync+restart, or sync+exec",
2971                )
2972                .with_label(DiagnosticLabel::primary(action.span(), "unknown watch action")),
2973            );
2974        }
2975        if synchronizes && item.target().is_none() {
2976            self.diagnostics.push(
2977                Diagnostic::new(
2978                    DEVELOP_WATCH_MISSING_TARGET,
2979                    Severity::Error,
2980                    "synchronizing develop watch action requires a target",
2981                )
2982                .with_label(DiagnosticLabel::primary(item.span(), "target is missing")),
2983            );
2984        }
2985        if action_value == "sync+exec" && item.exec().is_none() {
2986            self.diagnostics.push(
2987                Diagnostic::new(
2988                    DEVELOP_WATCH_MISSING_EXEC,
2989                    Severity::Error,
2990                    "sync+exec develop watch action requires an exec command mapping",
2991                )
2992                .with_label(DiagnosticLabel::primary(item.span(), "exec is missing")),
2993            );
2994        }
2995    }
2996
2997    fn parse_develop_exec(&mut self, field: &ParsedField) -> DevelopWatchExec {
2998        let Some(YamlNode::Mapping(mapping)) = field.value.as_ref() else {
2999            unreachable!("caller checks the mapping form");
3000        };
3001        let mut exec = DevelopWatchExec::new(field.span);
3002        let mut seen = BTreeMap::new();
3003        for member in self.fields(mapping) {
3004            if self.record_duplicate(&mut seen, &member) {
3005                exec.push_unmodeled(member.reference());
3006                continue;
3007            }
3008            match member.name.value().as_str() {
3009                "command" => match self.parse_command(&member) {
3010                    Some(value) => exec.set_command(value),
3011                    None => exec.push_unmodeled(member.reference()),
3012                },
3013                "user" => match self.parse_extends_string(&member, "develop exec user must be a YAML string scalar") {
3014                    Some(value) => exec.set_user(value),
3015                    None => exec.push_unmodeled(member.reference()),
3016                },
3017                "privileged" => match self.parse_boolean(&member, "develop exec privileged must be a boolean") {
3018                    Some(value) => exec.set_privileged(value),
3019                    None => exec.push_unmodeled(member.reference()),
3020                },
3021                "working_dir" => {
3022                    match self.parse_extends_string(&member, "develop exec working_dir must be a YAML string scalar") {
3023                        Some(value) => exec.set_working_dir(value),
3024                        None => exec.push_unmodeled(member.reference()),
3025                    }
3026                }
3027                "environment" => match self.parse_environment(&member) {
3028                    Some(value) => exec.set_environment(value),
3029                    None => exec.push_unmodeled(member.reference()),
3030                },
3031                _ => exec.push_unmodeled(member.reference()),
3032            }
3033        }
3034        exec
3035    }
3036
3037    fn parse_service_read_only(&mut self, field: &ParsedField) -> Option<Located<BooleanValue>> {
3038        self.parse_boolean(field, "service read_only")
3039    }
3040
3041    fn parse_service_group_add(&mut self, field: &ParsedField) -> Vec<Located<String>> {
3042        self.parse_string_sequence(field, "service supplementary groups")
3043    }
3044
3045    fn set_service_count(&mut self, service: &mut Service, field: &ParsedField) {
3046        match field.name.value().as_str() {
3047            "cpu_count" => {
3048                service.cpu_count = self.parse_cpu_count(field);
3049                if service.cpu_count.is_none() {
3050                    service.unknown_fields.push(field.reference());
3051                }
3052            }
3053            "cpu_percent" => {
3054                service.cpu_percent = self.parse_cpu_percent(field);
3055                if service.cpu_percent.is_none() {
3056                    service.unknown_fields.push(field.reference());
3057                }
3058            }
3059            "cpu_period" => {
3060                service.cpu_period = self.parse_cpu_period(field);
3061                if service.cpu_period.is_none() {
3062                    service.unknown_fields.push(field.reference());
3063                }
3064            }
3065            "cpu_quota" => {
3066                service.cpu_quota = self.parse_cpu_quota(field);
3067                if service.cpu_quota.is_none() {
3068                    service.unknown_fields.push(field.reference());
3069                }
3070            }
3071            "cpu_rt_period" => {
3072                service.cpu_rt_period = self.parse_cpu_rt_period(field);
3073                if service.cpu_rt_period.is_none() {
3074                    service.unknown_fields.push(field.reference());
3075                }
3076            }
3077            "cpu_rt_runtime" => {
3078                service.cpu_rt_runtime = self.parse_cpu_rt_runtime(field);
3079                if service.cpu_rt_runtime.is_none() {
3080                    service.unknown_fields.push(field.reference());
3081                }
3082            }
3083            "cpu_shares" => {
3084                service.cpu_shares = self.parse_service_integer(field, 0, i128::MAX, "cpu_shares");
3085                if service.cpu_shares.is_none() {
3086                    service.unknown_fields.push(field.reference());
3087                }
3088            }
3089            "cpus" => {
3090                service.cpus = self.parse_cpus(field);
3091                if service.cpus.is_none() {
3092                    service.unknown_fields.push(field.reference());
3093                }
3094            }
3095            "mem_swappiness" => {
3096                service.mem_swappiness = self.parse_service_integer(field, 0, 100, "mem_swappiness");
3097                if service.mem_swappiness.is_none() {
3098                    service.unknown_fields.push(field.reference());
3099                }
3100            }
3101            "oom_score_adj" => {
3102                service.oom_score_adj = self.parse_service_integer(field, -1000, 1000, "oom_score_adj");
3103                if service.oom_score_adj.is_none() {
3104                    service.unknown_fields.push(field.reference());
3105                }
3106            }
3107            "scale" => {
3108                service.scale = self.parse_service_integer(field, 0, i128::MAX, "scale");
3109                if service.scale.is_none() {
3110                    service.unknown_fields.push(field.reference());
3111                }
3112            }
3113            "pids_limit" => service.pids_limit = self.parse_pids_limit(field),
3114            _ => {}
3115        }
3116    }
3117
3118    fn set_service_runtime(&mut self, service: &mut Service, field: &ParsedField) {
3119        service.runtime = self.parse_extends_string(field, "service runtime must be a YAML string scalar");
3120        if service.runtime.is_none() {
3121            service.unknown_fields.push(field.reference());
3122        }
3123    }
3124
3125    fn set_service_pull_refresh_after(&mut self, service: &mut Service, field: &ParsedField) {
3126        service.pull_refresh_after =
3127            self.parse_extends_string(field, "service pull_refresh_after must be a YAML string scalar");
3128        if service.pull_refresh_after.is_none() {
3129            service.unknown_fields.push(field.reference());
3130        }
3131    }
3132
3133    fn set_service_platform(&mut self, service: &mut Service, field: &ParsedField) {
3134        service.platform = self.parse_extends_string(field, "service platform must be a YAML string scalar");
3135        if service.platform.is_none() {
3136            service.unknown_fields.push(field.reference());
3137        }
3138    }
3139
3140    fn set_service_attach(&mut self, service: &mut Service, field: &ParsedField) {
3141        service.attach = self.parse_boolean(field, "service attach");
3142        if service.attach.is_none() {
3143            service.unknown_fields.push(field.reference());
3144        }
3145    }
3146
3147    fn set_service_boolean(&mut self, service: &mut Service, field: &ParsedField) {
3148        match field.name.value().as_str() {
3149            "attach" => self.set_service_attach(service, field),
3150            "init" => service.init = self.parse_boolean(field, "service init"),
3151            "stdin_open" => service.stdin_open = self.parse_boolean(field, "stdin_open"),
3152            "tty" => service.tty = self.parse_boolean(field, "tty"),
3153            "privileged" => service.privileged = self.parse_boolean(field, "privileged"),
3154            "use_api_socket" => service.use_api_socket = self.parse_boolean(field, "use_api_socket"),
3155            _ => {}
3156        }
3157    }
3158
3159    fn set_service_blkio_config(&mut self, service: &mut Service, field: &ParsedField) {
3160        service.blkio_config = self.parse_blkio_config(field);
3161        if service.blkio_config.is_none() {
3162            service.unknown_fields.push(field.reference());
3163        }
3164    }
3165
3166    fn set_service_cgroup(&mut self, service: &mut Service, field: &ParsedField) {
3167        match field.name.value().as_str() {
3168            "cgroup" => {
3169                service.cgroup = self.parse_cgroup_namespace(field);
3170                if service.cgroup.is_none() {
3171                    service.unknown_fields.push(field.reference());
3172                }
3173            }
3174            "cgroup_parent" => {
3175                service.cgroup_parent =
3176                    self.parse_extends_string(field, "service cgroup_parent must be a YAML string scalar");
3177                if service.cgroup_parent.is_none() {
3178                    service.unknown_fields.push(field.reference());
3179                }
3180            }
3181            _ => {}
3182        }
3183    }
3184
3185    fn parse_cgroup_namespace(&mut self, field: &ParsedField) -> Option<CgroupNamespace> {
3186        let raw = self.parse_extends_string(field, "service cgroup must be a YAML string scalar")?;
3187        let cgroup = CgroupNamespace::parse(raw);
3188        if !cgroup.is_valid() {
3189            self.diagnostics.push(
3190                Diagnostic::new(
3191                    CGROUP_NAMESPACE_INVALID,
3192                    Severity::Warning,
3193                    "service cgroup must be `host`, `private`, or a deferred expression",
3194                )
3195                .with_label(DiagnosticLabel::primary(
3196                    cgroup.raw().span(),
3197                    "retained unsupported cgroup namespace",
3198                )),
3199            );
3200        }
3201        Some(cgroup)
3202    }
3203
3204    fn parse_blkio_config(&mut self, field: &ParsedField) -> Option<BlkioConfig> {
3205        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3206            self.expected(EXPECTED_MAPPING, field, "blkio_config must be a mapping");
3207            return None;
3208        };
3209        let mut config = BlkioConfig::new(span_from_position(self.source_id, mapping.byte_range()));
3210        let mut seen = BTreeMap::new();
3211        for option in self.fields(mapping) {
3212            if self.record_duplicate(&mut seen, &option) {
3213                continue;
3214            }
3215            match option.name.value().as_str() {
3216                name if name.starts_with("x-") => config.push_extension(option.reference()),
3217                "weight" => match self.parse_blkio_scalar(&option) {
3218                    Some(value) => config.set_weight(value),
3219                    None => config.push_unknown(option.reference()),
3220                },
3221                "device_read_bps" | "device_read_iops" | "device_write_bps" | "device_write_iops" => {
3222                    if let Some(values) = self.parse_blkio_device_rates(&option) {
3223                        if let Some(items) = config.device_rates_mut(option.name.value()) {
3224                            items.extend(values);
3225                        }
3226                    } else {
3227                        config.push_unknown(option.reference());
3228                    }
3229                }
3230                "weight_device" => {
3231                    if let Some(values) = self.parse_blkio_weight_devices(&option) {
3232                        config.weight_devices_mut().extend(values);
3233                    } else {
3234                        config.push_unknown(option.reference());
3235                    }
3236                }
3237                _ => config.push_unknown(option.reference()),
3238            }
3239        }
3240        Some(config)
3241    }
3242
3243    fn parse_blkio_scalar(&mut self, field: &ParsedField) -> Option<Located<BlkioScalar>> {
3244        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3245            self.expected(
3246                EXPECTED_SCALAR,
3247                field,
3248                "blkio value must be a YAML integer or string scalar",
3249            );
3250            return None;
3251        };
3252        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
3253            ScalarType::Integer => BlkioScalar::YamlInteger(scalar_string_from_source(&self.source, scalar)),
3254            ScalarType::String => BlkioScalar::String(scalar_string_from_source(&self.source, scalar)),
3255            _ => {
3256                self.expected(
3257                    EXPECTED_SCALAR,
3258                    field,
3259                    "blkio value must be a YAML integer or string scalar",
3260                );
3261                return None;
3262            }
3263        };
3264        Some(Located::new(
3265            value,
3266            span_from_position(self.source_id, scalar.byte_range()),
3267        ))
3268    }
3269
3270    fn parse_blkio_device_rates(&mut self, field: &ParsedField) -> Option<Vec<BlkioDeviceRate>> {
3271        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3272            self.expected(EXPECTED_SEQUENCE, field, "blkio device rates must be sequences");
3273            return None;
3274        };
3275        let mut items = Vec::new();
3276        for node in sequence.values() {
3277            let Some(mapping) = node.as_mapping() else {
3278                self.unsupported_sequence_item(
3279                    EXPECTED_MAPPING,
3280                    &node,
3281                    field.span,
3282                    "blkio device rate entries must be mappings",
3283                );
3284                items.push(BlkioDeviceRate::unmodeled(
3285                    node_span(self.source_id, &node).unwrap_or(field.span),
3286                ));
3287                continue;
3288            };
3289            let mut item = BlkioDeviceRate::new(span_from_position(self.source_id, mapping.byte_range()));
3290            let mut seen = BTreeMap::new();
3291            for option in self.fields(mapping) {
3292                if self.record_duplicate(&mut seen, &option) {
3293                    continue;
3294                }
3295                match option.name.value().as_str() {
3296                    name if name.starts_with("x-") => item.push_extension(option.reference()),
3297                    "path" => match self.parse_string(&option, "blkio device path") {
3298                        Some(value) => item.set_path(value),
3299                        None => item.push_unknown(option.reference()),
3300                    },
3301                    "rate" => match self.parse_blkio_scalar(&option) {
3302                        Some(value) => item.set_rate(value),
3303                        None => item.push_unknown(option.reference()),
3304                    },
3305                    _ => item.push_unknown(option.reference()),
3306                }
3307            }
3308            items.push(item);
3309        }
3310        Some(items)
3311    }
3312
3313    fn parse_blkio_weight_devices(&mut self, field: &ParsedField) -> Option<Vec<BlkioWeightDevice>> {
3314        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3315            self.expected(EXPECTED_SEQUENCE, field, "blkio weight devices must be sequences");
3316            return None;
3317        };
3318        let mut items = Vec::new();
3319        for node in sequence.values() {
3320            let Some(mapping) = node.as_mapping() else {
3321                self.unsupported_sequence_item(
3322                    EXPECTED_MAPPING,
3323                    &node,
3324                    field.span,
3325                    "blkio weight device entries must be mappings",
3326                );
3327                items.push(BlkioWeightDevice::unmodeled(
3328                    node_span(self.source_id, &node).unwrap_or(field.span),
3329                ));
3330                continue;
3331            };
3332            let mut item = BlkioWeightDevice::new(span_from_position(self.source_id, mapping.byte_range()));
3333            let mut seen = BTreeMap::new();
3334            for option in self.fields(mapping) {
3335                if self.record_duplicate(&mut seen, &option) {
3336                    continue;
3337                }
3338                match option.name.value().as_str() {
3339                    name if name.starts_with("x-") => item.push_extension(option.reference()),
3340                    "path" => match self.parse_string(&option, "blkio weight device path") {
3341                        Some(value) => item.set_path(value),
3342                        None => item.push_unknown(option.reference()),
3343                    },
3344                    "weight" => match self.parse_blkio_scalar(&option) {
3345                        Some(value) => item.set_weight(value),
3346                        None => item.push_unknown(option.reference()),
3347                    },
3348                    _ => item.push_unknown(option.reference()),
3349                }
3350            }
3351            items.push(item);
3352        }
3353        Some(items)
3354    }
3355
3356    fn parse_hostname(&mut self, field: &ParsedField) -> Option<Hostname> {
3357        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3358            self.expected(HOSTNAME_EXPECTED_STRING, field, "hostname must be a YAML string scalar");
3359            return None;
3360        };
3361        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
3362            self.expected(HOSTNAME_EXPECTED_STRING, field, "hostname must be a YAML string scalar");
3363            return None;
3364        }
3365        let span = span_from_position(self.source_id, scalar.byte_range());
3366        let hostname = Hostname::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
3367        if hostname.kind() == &HostnameKind::Invalid {
3368            self.diagnostics.push(
3369                Diagnostic::new(
3370                    HOSTNAME_INVALID,
3371                    Severity::Error,
3372                    "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",
3373                )
3374                .with_label(DiagnosticLabel::primary(span, "invalid service hostname"))
3375                .with_note("each label must start and end with an ASCII letter or digit"),
3376            );
3377        }
3378        Some(hostname)
3379    }
3380
3381    fn parse_image(&mut self, field: &ParsedField) -> Option<Located<ImageReference>> {
3382        self.parse_string(field, "service image")
3383            .map(|value| Located::new(ImageReference::parse(value.value), value.span))
3384    }
3385
3386    fn parse_cap_drop(&mut self, field: &ParsedField) -> Option<CapabilityDrop> {
3387        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3388            self.expected(
3389                CAP_DROP_EXPECTED_SEQUENCE,
3390                field,
3391                "cap_drop must be a sequence of string scalars",
3392            );
3393            return None;
3394        };
3395        let span = span_from_position(self.source_id, sequence.byte_range());
3396        let mut items = Vec::new();
3397        let mut seen = BTreeMap::new();
3398        for node in sequence.values() {
3399            let YamlNode::Scalar(scalar) = node else {
3400                self.unsupported_sequence_item(
3401                    CAP_DROP_EXPECTED_STRING,
3402                    &node,
3403                    field.span,
3404                    "cap_drop entries must be string scalars",
3405                );
3406                continue;
3407            };
3408            let scalar_type = ScalarValue::from_scalar(&scalar).scalar_type();
3409            if !matches!(
3410                scalar_type,
3411                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3412            ) {
3413                self.unsupported_sequence_item(
3414                    CAP_DROP_EXPECTED_STRING,
3415                    &YamlNode::Scalar(scalar),
3416                    field.span,
3417                    "cap_drop entries must be string scalars",
3418                );
3419                continue;
3420            }
3421            let item_span = span_from_position(self.source_id, scalar.byte_range());
3422            let value = scalar_string_from_source(&self.source, &scalar);
3423            if let Some(first) = seen.get(&value) {
3424                self.diagnostics.push(
3425                    Diagnostic::new(
3426                        CAP_DROP_DUPLICATE_ITEM,
3427                        Severity::Error,
3428                        "cap_drop entries must be unique exact strings",
3429                    )
3430                    .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
3431                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
3432                );
3433            } else {
3434                seen.insert(value.clone(), item_span);
3435            }
3436            items.push(CapabilityDropItem::new(Located::new(value, item_span)));
3437        }
3438        Some(CapabilityDrop::new(span, items))
3439    }
3440
3441    fn parse_cap_add(&mut self, field: &ParsedField) -> Option<CapabilityAdd> {
3442        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3443            self.expected(
3444                CAP_ADD_EXPECTED_SEQUENCE,
3445                field,
3446                "cap_add must be a sequence of string scalars",
3447            );
3448            return None;
3449        };
3450        let span = span_from_position(self.source_id, sequence.byte_range());
3451        let mut items = Vec::new();
3452        let mut seen = BTreeMap::new();
3453        for node in sequence.values() {
3454            let YamlNode::Scalar(scalar) = node else {
3455                self.unsupported_sequence_item(
3456                    CAP_ADD_EXPECTED_STRING,
3457                    &node,
3458                    field.span,
3459                    "cap_add entries must be string scalars",
3460                );
3461                continue;
3462            };
3463            let scalar_type = ScalarValue::from_scalar(&scalar).scalar_type();
3464            if !matches!(
3465                scalar_type,
3466                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3467            ) {
3468                self.unsupported_sequence_item(
3469                    CAP_ADD_EXPECTED_STRING,
3470                    &YamlNode::Scalar(scalar),
3471                    field.span,
3472                    "cap_add entries must be string scalars",
3473                );
3474                continue;
3475            }
3476            let item_span = span_from_position(self.source_id, scalar.byte_range());
3477            let value = scalar_string_from_source(&self.source, &scalar);
3478            if let Some(first) = seen.get(&value) {
3479                self.diagnostics.push(
3480                    Diagnostic::new(
3481                        CAP_ADD_DUPLICATE_ITEM,
3482                        Severity::Error,
3483                        "cap_add entries must be unique exact strings",
3484                    )
3485                    .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
3486                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
3487                );
3488            } else {
3489                seen.insert(value.clone(), item_span);
3490            }
3491            items.push(CapabilityAddItem::new(Located::new(value, item_span)));
3492        }
3493        Some(CapabilityAdd::new(span, items))
3494    }
3495
3496    fn parse_devices(&mut self, field: &ParsedField) -> Option<Devices> {
3497        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3498            self.expected(
3499                DEVICES_EXPECTED_SEQUENCE,
3500                field,
3501                "service devices must be a sequence of string scalars or mappings",
3502            );
3503            return None;
3504        };
3505        let span = span_from_position(self.source_id, sequence.byte_range());
3506        let mut devices = Vec::new();
3507        for node in sequence.values() {
3508            match node {
3509                YamlNode::Scalar(scalar)
3510                    if matches!(
3511                        ScalarValue::from_scalar(&scalar).scalar_type(),
3512                        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3513                    ) =>
3514                {
3515                    let item_span = span_from_position(self.source_id, scalar.byte_range());
3516                    let raw = Located::new(scalar_string_from_source(&self.source, &scalar), item_span);
3517                    devices.push(Device::Short(ShortDevice::new(raw)));
3518                }
3519                YamlNode::Mapping(mapping) => devices.push(Device::Long(self.parse_long_device(&mapping))),
3520                other => self.unsupported_sequence_item(
3521                    DEVICE_EXPECTED_FORM,
3522                    &other,
3523                    field.span,
3524                    "service device must use string short syntax or mapping long syntax",
3525                ),
3526            }
3527        }
3528        Some(Devices::new(span, devices))
3529    }
3530
3531    fn parse_long_device(&mut self, mapping: &Mapping) -> LongDevice {
3532        let span = span_from_position(self.source_id, mapping.byte_range());
3533        let mut device = LongDevice::new(span);
3534        let mut seen = BTreeMap::new();
3535        for field in self.fields(mapping) {
3536            let duplicate = self.record_duplicate(&mut seen, &field);
3537            match field.name.value.as_str() {
3538                "source" if !duplicate => self
3539                    .parse_device_string(&field, "device source")
3540                    .into_iter()
3541                    .for_each(|value| device.set_source(value)),
3542                "target" if !duplicate => self
3543                    .parse_device_string(&field, "device target")
3544                    .into_iter()
3545                    .for_each(|value| device.set_target(value)),
3546                "permissions" if !duplicate => self
3547                    .parse_device_string(&field, "device permissions")
3548                    .into_iter()
3549                    .for_each(|value| device.set_permissions(value)),
3550                name if name.starts_with("x-") => device.push_extension(field.reference()),
3551                _ if duplicate => {}
3552                _ => device.push_unknown(field.reference()),
3553            }
3554        }
3555        if device.source().is_none() {
3556            self.missing(
3557                DEVICE_MISSING_SOURCE,
3558                span,
3559                "long service device is missing required string `source`",
3560            );
3561        }
3562        device
3563    }
3564
3565    fn parse_device_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
3566        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3567            self.expected(
3568                DEVICE_EXPECTED_STRING,
3569                field,
3570                format!("{description} must be a string scalar"),
3571            );
3572            return None;
3573        };
3574        if !matches!(
3575            ScalarValue::from_scalar(scalar).scalar_type(),
3576            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3577        ) {
3578            self.expected(
3579                DEVICE_EXPECTED_STRING,
3580                field,
3581                format!("{description} must be a string scalar"),
3582            );
3583            return None;
3584        }
3585        Some(Located::new(
3586            scalar_string_from_source(&self.source, scalar),
3587            span_from_position(self.source_id, scalar.byte_range()),
3588        ))
3589    }
3590
3591    fn parse_dns(&mut self, field: &ParsedField) -> Option<Dns> {
3592        let value = field.value.as_ref()?;
3593        if let Some(scalar) = value.as_scalar() {
3594            if !matches!(
3595                ScalarValue::from_scalar(scalar).scalar_type(),
3596                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3597            ) {
3598                self.expected(
3599                    DNS_EXPECTED_FORM,
3600                    field,
3601                    "dns must be a string scalar or a sequence of string scalars",
3602                );
3603                return None;
3604            }
3605            let span = span_from_position(self.source_id, scalar.byte_range());
3606            return Some(Dns::new(
3607                span,
3608                DnsForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
3609            ));
3610        }
3611
3612        let Some(sequence) = value.as_sequence() else {
3613            self.expected(
3614                DNS_EXPECTED_FORM,
3615                field,
3616                "dns must be a string scalar or a sequence of string scalars",
3617            );
3618            return None;
3619        };
3620        let span = span_from_position(self.source_id, sequence.byte_range());
3621        let mut items = Vec::new();
3622        for node in sequence.values() {
3623            let YamlNode::Scalar(scalar) = node else {
3624                self.unsupported_sequence_item(
3625                    DNS_EXPECTED_STRING,
3626                    &node,
3627                    field.span,
3628                    "dns entries must be string scalars",
3629                );
3630                continue;
3631            };
3632            if !matches!(
3633                ScalarValue::from_scalar(&scalar).scalar_type(),
3634                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3635            ) {
3636                self.unsupported_sequence_item(
3637                    DNS_EXPECTED_STRING,
3638                    &YamlNode::Scalar(scalar),
3639                    field.span,
3640                    "dns entries must be string scalars",
3641                );
3642                continue;
3643            }
3644            let item_span = span_from_position(self.source_id, scalar.byte_range());
3645            items.push(Located::new(
3646                scalar_string_from_source(&self.source, &scalar),
3647                item_span,
3648            ));
3649        }
3650        Some(Dns::new(span, DnsForm::List(items)))
3651    }
3652
3653    fn parse_dns_options(&mut self, field: &ParsedField) -> Option<DnsOptions> {
3654        let value = field.value.as_ref()?;
3655        let Some(sequence) = value.as_sequence() else {
3656            self.expected(
3657                DNS_OPT_EXPECTED_SEQUENCE,
3658                field,
3659                "dns_opt must be a sequence of string scalars",
3660            );
3661            return None;
3662        };
3663        let span = span_from_position(self.source_id, sequence.byte_range());
3664        let mut items = Vec::new();
3665        let mut seen = BTreeSet::new();
3666        for node in sequence.values() {
3667            let YamlNode::Scalar(scalar) = node else {
3668                self.unsupported_sequence_item(
3669                    DNS_OPT_EXPECTED_STRING,
3670                    &node,
3671                    field.span,
3672                    "dns_opt entries must be string scalars",
3673                );
3674                continue;
3675            };
3676            if !matches!(
3677                ScalarValue::from_scalar(&scalar).scalar_type(),
3678                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3679            ) {
3680                self.unsupported_sequence_item(
3681                    DNS_OPT_EXPECTED_STRING,
3682                    &YamlNode::Scalar(scalar),
3683                    field.span,
3684                    "dns_opt entries must be string scalars",
3685                );
3686                continue;
3687            }
3688            let item_span = span_from_position(self.source_id, scalar.byte_range());
3689            let option = scalar_string_from_source(&self.source, &scalar);
3690            if !seen.insert(option.clone()) {
3691                self.diagnostics.push(
3692                    Diagnostic::new(
3693                        DNS_OPT_DUPLICATE_ITEM,
3694                        Severity::Warning,
3695                        "dns_opt entries must be unique exact strings",
3696                    )
3697                    .with_label(DiagnosticLabel::primary(item_span, "duplicate DNS option retained")),
3698                );
3699            }
3700            items.push(Located::new(option, item_span));
3701        }
3702        Some(DnsOptions::new(span, items))
3703    }
3704
3705    fn parse_dns_search(&mut self, field: &ParsedField) -> Option<DnsSearch> {
3706        let value = field.value.as_ref()?;
3707        if let Some(scalar) = value.as_scalar() {
3708            if !matches!(
3709                ScalarValue::from_scalar(scalar).scalar_type(),
3710                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3711            ) {
3712                self.expected(
3713                    DNS_SEARCH_EXPECTED_FORM,
3714                    field,
3715                    "dns_search must be a string scalar or a sequence of string scalars",
3716                );
3717                return None;
3718            }
3719            let span = span_from_position(self.source_id, scalar.byte_range());
3720            return Some(DnsSearch::new(
3721                span,
3722                DnsSearchForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
3723            ));
3724        }
3725
3726        let Some(sequence) = value.as_sequence() else {
3727            self.expected(
3728                DNS_SEARCH_EXPECTED_FORM,
3729                field,
3730                "dns_search must be a string scalar or a sequence of string scalars",
3731            );
3732            return None;
3733        };
3734        let span = span_from_position(self.source_id, sequence.byte_range());
3735        let mut items = Vec::new();
3736        let mut seen = BTreeSet::new();
3737        for node in sequence.values() {
3738            let YamlNode::Scalar(scalar) = node else {
3739                self.unsupported_sequence_item(
3740                    DNS_SEARCH_EXPECTED_STRING,
3741                    &node,
3742                    field.span,
3743                    "dns_search entries must be string scalars",
3744                );
3745                continue;
3746            };
3747            if !matches!(
3748                ScalarValue::from_scalar(&scalar).scalar_type(),
3749                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3750            ) {
3751                self.unsupported_sequence_item(
3752                    DNS_SEARCH_EXPECTED_STRING,
3753                    &YamlNode::Scalar(scalar),
3754                    field.span,
3755                    "dns_search entries must be string scalars",
3756                );
3757                continue;
3758            }
3759            let item_span = span_from_position(self.source_id, scalar.byte_range());
3760            let search = scalar_string_from_source(&self.source, &scalar);
3761            if !seen.insert(search.clone()) {
3762                self.diagnostics.push(
3763                    Diagnostic::new(
3764                        DNS_SEARCH_DUPLICATE_ITEM,
3765                        Severity::Warning,
3766                        "dns_search schema entries are unique, but duplicate merge behavior is ambiguous",
3767                    )
3768                    .with_label(DiagnosticLabel::primary(
3769                        item_span,
3770                        "duplicate DNS search domain retained",
3771                    )),
3772                );
3773            }
3774            items.push(Located::new(search, item_span));
3775        }
3776        Some(DnsSearch::new(span, DnsSearchForm::List(items)))
3777    }
3778
3779    fn parse_expose(&mut self, field: &ParsedField) -> Option<Expose> {
3780        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3781            self.expected(
3782                EXPOSE_EXPECTED_SEQUENCE,
3783                field,
3784                "expose must be a sequence of string or number scalars",
3785            );
3786            return None;
3787        };
3788        let span = span_from_position(self.source_id, sequence.byte_range());
3789        let mut items = Vec::new();
3790        let mut seen = Vec::new();
3791        for node in sequence.values() {
3792            let YamlNode::Scalar(scalar) = node else {
3793                self.unsupported_sequence_item(
3794                    EXPOSE_EXPECTED_SCALAR,
3795                    &node,
3796                    field.span,
3797                    "expose entries must be string or number scalars",
3798                );
3799                continue;
3800            };
3801            let scalar_kind = match ScalarValue::from_scalar(&scalar).scalar_type() {
3802                ScalarType::Integer | ScalarType::Float => ExposeScalarKind::Number,
3803                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => ExposeScalarKind::String,
3804                ScalarType::Null | ScalarType::Boolean => {
3805                    self.unsupported_sequence_item(
3806                        EXPOSE_EXPECTED_SCALAR,
3807                        &YamlNode::Scalar(scalar),
3808                        field.span,
3809                        "expose entries must be string or number scalars",
3810                    );
3811                    continue;
3812                }
3813            };
3814            let item_span = span_from_position(self.source_id, scalar.byte_range());
3815            let raw = scalar_string_from_source(&self.source, &scalar);
3816            if seen.contains(&(scalar_kind, raw.clone())) {
3817                self.diagnostics.push(
3818                    Diagnostic::new(
3819                        EXPOSE_DUPLICATE_ITEM,
3820                        Severity::Warning,
3821                        "expose entries must be unique by exact scalar identity",
3822                    )
3823                    .with_label(DiagnosticLabel::primary(
3824                        item_span,
3825                        "duplicate exposed-port item retained",
3826                    )),
3827                );
3828            } else {
3829                seen.push((scalar_kind, raw.clone()));
3830            }
3831            let item = ExposeItem::parse(Located::new(raw, item_span), scalar_kind);
3832            self.diagnose_expose_item(&item);
3833            items.push(item);
3834        }
3835        Some(Expose::new(span, items))
3836    }
3837
3838    fn diagnose_expose_item(&mut self, item: &ExposeItem) {
3839        match item.kind() {
3840            ExposeItemKind::Documented { .. } | ExposeItemKind::Expression => {}
3841            ExposeItemKind::Sctp { .. } | ExposeItemKind::UnknownProtocol { .. } => {
3842                self.diagnostics.push(
3843                    Diagnostic::new(
3844                        EXPOSE_PROVIDER_DEPENDENT,
3845                        Severity::Warning,
3846                        "expose protocol is outside the documented portable `tcp` and `udp` set",
3847                    )
3848                    .with_label(DiagnosticLabel::primary(
3849                        item.span(),
3850                        "provider-dependent exposed-port protocol retained",
3851                    ))
3852                    .with_note("ComposeLens does not normalize or reject the raw protocol spelling"),
3853                );
3854            }
3855            ExposeItemKind::Malformed => {
3856                self.diagnostics.push(
3857                    Diagnostic::new(
3858                        EXPOSE_INVALID_ITEM,
3859                        Severity::Error,
3860                        "expose item must be a decimal port or range with an optional protocol",
3861                    )
3862                    .with_label(DiagnosticLabel::primary(
3863                        item.span(),
3864                        "malformed exposed-port item retained",
3865                    ))
3866                    .with_note("use `PORT`, `START-END`, `PORT/tcp`, or `PORT/udp` for documented portable syntax"),
3867                );
3868            }
3869        }
3870    }
3871
3872    fn parse_security_options(&mut self, field: &ParsedField) -> Option<SecurityOptions> {
3873        let value = field.value.as_ref()?;
3874        let Some(sequence) = value.as_sequence() else {
3875            self.expected(
3876                SECURITY_OPT_EXPECTED_SEQUENCE,
3877                field,
3878                "security_opt must be a sequence of string scalars",
3879            );
3880            return None;
3881        };
3882        let span = span_from_position(self.source_id, sequence.byte_range());
3883        let mut items = Vec::new();
3884        let mut candidates = SecurityOptionCandidateCounts::default();
3885        for node in sequence.values() {
3886            let YamlNode::Scalar(scalar) = node else {
3887                self.unsupported_sequence_item(
3888                    SECURITY_OPT_EXPECTED_STRING,
3889                    &node,
3890                    field.span,
3891                    "security_opt entries must be string scalars",
3892                );
3893                continue;
3894            };
3895            if !matches!(
3896                ScalarValue::from_scalar(&scalar).scalar_type(),
3897                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3898            ) {
3899                self.unsupported_sequence_item(
3900                    SECURITY_OPT_EXPECTED_STRING,
3901                    &YamlNode::Scalar(scalar),
3902                    field.span,
3903                    "security_opt entries must be string scalars",
3904                );
3905                continue;
3906            }
3907            let item_span = span_from_position(self.source_id, scalar.byte_range());
3908            let raw = scalar_string_from_source(&self.source, &scalar);
3909            let item = SecurityOptionItem::parse(Located::new(raw, item_span));
3910            self.diagnose_security_option_item(item.kind(), item_span, &mut candidates);
3911            items.push(item);
3912        }
3913        Some(SecurityOptions::new(span, items))
3914    }
3915
3916    fn diagnose_security_option_item(
3917        &mut self,
3918        kind: &SecurityOptionKind,
3919        span: SourceSpan,
3920        candidates: &mut SecurityOptionCandidateCounts,
3921    ) {
3922        let diagnostic = match kind {
3923            SecurityOptionKind::AppArmor { .. } => {
3924                candidates.apparmor += 1;
3925                (candidates.apparmor > 1).then(|| {
3926                    Diagnostic::new(
3927                        SECURITY_OPT_APPARMOR_CONFLICT,
3928                        Severity::Warning,
3929                        "multiple AppArmor candidates are retained; a consumer must resolve the conflict explicitly",
3930                    )
3931                    .with_label(DiagnosticLabel::primary(span, "additional AppArmor candidate retained"))
3932                })
3933            }
3934            SecurityOptionKind::AppArmorNearMiss => Some(
3935                Diagnostic::new(
3936                    SECURITY_OPT_APPARMOR_NEAR_MISS,
3937                    Severity::Warning,
3938                    "AppArmor candidates require exact lowercase `apparmor=<profile>` spelling without whitespace",
3939                )
3940                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3941            ),
3942            SecurityOptionKind::Seccomp { .. } => {
3943                candidates.seccomp += 1;
3944                (candidates.seccomp > 1).then(|| {
3945                    Diagnostic::new(
3946                        SECURITY_OPT_SECCOMP_CONFLICT,
3947                        Severity::Warning,
3948                        "multiple seccomp candidates are retained; a consumer must resolve the conflict explicitly",
3949                    )
3950                    .with_label(DiagnosticLabel::primary(span, "additional seccomp candidate retained"))
3951                })
3952            }
3953            SecurityOptionKind::SeccompNearMiss => Some(
3954                Diagnostic::new(
3955                    SECURITY_OPT_SECCOMP_NEAR_MISS,
3956                    Severity::Warning,
3957                    "seccomp candidates require exact lowercase `seccomp=<profile>` spelling without whitespace",
3958                )
3959                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3960            ),
3961            SecurityOptionKind::NoNewPrivileges { .. } => {
3962                candidates.no_new_privileges += 1;
3963                (candidates.no_new_privileges > 1).then(|| {
3964                    Diagnostic::new(
3965                        SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT,
3966                        Severity::Warning,
3967                        "multiple no-new-privileges candidates are retained; a consumer must resolve the conflict explicitly",
3968                    )
3969                    .with_label(DiagnosticLabel::primary(
3970                        span,
3971                        "additional no-new-privileges candidate retained",
3972                    ))
3973                })
3974            }
3975            SecurityOptionKind::NoNewPrivilegesNearMiss => Some(
3976                Diagnostic::new(
3977                    SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS,
3978                    Severity::Warning,
3979                    "no-new-privileges candidates require exact lowercase `no-new-privileges:true` or `no-new-privileges:false` spelling without whitespace",
3980                )
3981                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3982            ),
3983            SecurityOptionKind::Mask { .. }
3984            | SecurityOptionKind::MaskNearMiss
3985            | SecurityOptionKind::Unmask { .. }
3986            | SecurityOptionKind::UnmaskNearMiss => security_path_option_diagnostic(kind, span),
3987            SecurityOptionKind::SecurityLabelDisable { .. }
3988            | SecurityOptionKind::SecurityLabelDisableNearMiss
3989            | SecurityOptionKind::SecurityLabelFileType { .. }
3990            | SecurityOptionKind::SecurityLabelFileTypeNearMiss
3991            | SecurityOptionKind::SecurityLabelLevel { .. }
3992            | SecurityOptionKind::SecurityLabelLevelNearMiss
3993            | SecurityOptionKind::SecurityLabelNested { .. }
3994            | SecurityOptionKind::SecurityLabelNestedNearMiss
3995            | SecurityOptionKind::SecurityLabelType { .. }
3996            | SecurityOptionKind::SecurityLabelTypeNearMiss => {
3997                authored_security_label_diagnostic(kind, span, candidates)
3998            }
3999            SecurityOptionKind::Empty => Some(
4000                Diagnostic::new(
4001                    SECURITY_OPT_EMPTY_ITEM,
4002                    Severity::Error,
4003                    "security_opt entries must not be empty strings",
4004                )
4005                .with_label(DiagnosticLabel::primary(span, "empty security option retained")),
4006            ),
4007            SecurityOptionKind::Expression | SecurityOptionKind::Other => None,
4008        };
4009        if let Some(diagnostic) = diagnostic {
4010            self.diagnostics.push(diagnostic);
4011        }
4012    }
4013
4014    fn parse_tmpfs(&mut self, field: &ParsedField) -> Option<Tmpfs> {
4015        let value = field.value.as_ref()?;
4016        if let Some(scalar) = value.as_scalar() {
4017            if !matches!(
4018                ScalarValue::from_scalar(scalar).scalar_type(),
4019                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4020            ) {
4021                self.expected(
4022                    TMPFS_EXPECTED_FORM,
4023                    field,
4024                    "tmpfs must be a string scalar or a sequence of string scalars",
4025                );
4026                return None;
4027            }
4028            let span = span_from_position(self.source_id, scalar.byte_range());
4029            let item = TmpfsItem::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
4030            self.diagnose_tmpfs_item(&item);
4031            return Some(Tmpfs::new(span, TmpfsForm::Scalar(item)));
4032        }
4033
4034        let Some(sequence) = value.as_sequence() else {
4035            self.expected(
4036                TMPFS_EXPECTED_FORM,
4037                field,
4038                "tmpfs must be a string scalar or a sequence of string scalars",
4039            );
4040            return None;
4041        };
4042        let span = span_from_position(self.source_id, sequence.byte_range());
4043        let mut items = Vec::new();
4044        for node in sequence.values() {
4045            let YamlNode::Scalar(scalar) = node else {
4046                self.unsupported_sequence_item(
4047                    TMPFS_EXPECTED_STRING,
4048                    &node,
4049                    field.span,
4050                    "tmpfs entries must be string scalars",
4051                );
4052                continue;
4053            };
4054            if !matches!(
4055                ScalarValue::from_scalar(&scalar).scalar_type(),
4056                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4057            ) {
4058                self.unsupported_sequence_item(
4059                    TMPFS_EXPECTED_STRING,
4060                    &YamlNode::Scalar(scalar),
4061                    field.span,
4062                    "tmpfs entries must be string scalars",
4063                );
4064                continue;
4065            }
4066            let item_span = span_from_position(self.source_id, scalar.byte_range());
4067            let raw = scalar_string_from_source(&self.source, &scalar);
4068            let item = TmpfsItem::parse(Located::new(raw, item_span));
4069            self.diagnose_tmpfs_item(&item);
4070            items.push(item);
4071        }
4072        Some(Tmpfs::new(span, TmpfsForm::List(items)))
4073    }
4074
4075    fn diagnose_tmpfs_item(&mut self, item: &TmpfsItem) {
4076        if item.kind() != TmpfsItemKind::ProviderDependent {
4077            return;
4078        }
4079        self.diagnostics.push(
4080            Diagnostic::new(
4081                TMPFS_PROVIDER_DEPENDENT,
4082                Severity::Warning,
4083                "tmpfs item is malformed or uses provider- or target-specific options",
4084            )
4085            .with_label(DiagnosticLabel::primary(
4086                item.span(),
4087                "provider-dependent temporary-filesystem item",
4088            ))
4089            .with_note("use a non-empty path with only non-empty `mode`, `uid`, or `gid` assignments for documented portable syntax"),
4090        );
4091    }
4092
4093    fn parse_sysctls(&mut self, field: &ParsedField) -> Option<Sysctls> {
4094        match field.value.as_ref() {
4095            Some(YamlNode::Mapping(mapping)) => {
4096                let span = span_from_position(self.source_id, mapping.byte_range());
4097                let mut entries = Vec::new();
4098                let mut seen = BTreeMap::new();
4099                for entry in self.fields(mapping) {
4100                    if self.record_duplicate(&mut seen, &entry) {
4101                        continue;
4102                    }
4103                    if entry.name.value.is_empty() {
4104                        self.diagnostics.push(
4105                            Diagnostic::new(
4106                                SYSCTLS_EMPTY_KEY,
4107                                Severity::Error,
4108                                "sysctls mapping keys must not be empty",
4109                            )
4110                            .with_label(DiagnosticLabel::primary(entry.name.span, "empty sysctl name")),
4111                        );
4112                        continue;
4113                    }
4114                    if entry.value.as_ref().is_some_and(|value| value.as_scalar().is_none()) {
4115                        self.diagnostics.push(
4116                            Diagnostic::new(
4117                                SYSCTLS_EXPECTED_SCALAR,
4118                                Severity::Error,
4119                                "sysctls mapping values must be scalar strings, numbers, booleans, or null",
4120                            )
4121                            .with_label(DiagnosticLabel::primary(
4122                                entry.value_span.unwrap_or(entry.span),
4123                                "non-scalar sysctl value",
4124                            )),
4125                        );
4126                        continue;
4127                    }
4128                    let Some(value) = self.parse_compose_scalar(&entry, "sysctls mapping values must be scalars")
4129                    else {
4130                        continue;
4131                    };
4132                    entries.push(KeyValueEntry::new(entry.name, value, entry.span));
4133                }
4134                Some(Sysctls::new(span, SysctlsForm::Map(entries)))
4135            }
4136            Some(YamlNode::Sequence(sequence)) => {
4137                let span = span_from_position(self.source_id, sequence.byte_range());
4138                let mut items = Vec::new();
4139                let mut seen = BTreeMap::new();
4140                for node in sequence.values() {
4141                    let YamlNode::Scalar(scalar) = node else {
4142                        self.unsupported_sequence_item(
4143                            SYSCTLS_EXPECTED_STRING,
4144                            &node,
4145                            field.span,
4146                            "sysctls list entries must be YAML string scalars",
4147                        );
4148                        continue;
4149                    };
4150                    if !matches!(
4151                        ScalarValue::from_scalar(&scalar).scalar_type(),
4152                        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4153                    ) {
4154                        self.unsupported_sequence_item(
4155                            SYSCTLS_EXPECTED_STRING,
4156                            &YamlNode::Scalar(scalar),
4157                            field.span,
4158                            "sysctls list entries must be YAML string scalars",
4159                        );
4160                        continue;
4161                    }
4162                    let item_span = span_from_position(self.source_id, scalar.byte_range());
4163                    let value = scalar_string_from_source(&self.source, &scalar);
4164                    if let Some(first) = seen.get(&value) {
4165                        self.diagnostics.push(
4166                            Diagnostic::new(
4167                                SYSCTLS_DUPLICATE_ITEM,
4168                                Severity::Error,
4169                                "sysctls list entries must be unique exact strings",
4170                            )
4171                            .with_label(DiagnosticLabel::primary(item_span, "duplicate sysctl string"))
4172                            .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
4173                        );
4174                    } else {
4175                        seen.insert(value.clone(), item_span);
4176                    }
4177                    items.push(Located::new(value, item_span));
4178                }
4179                Some(Sysctls::new(span, SysctlsForm::List(items)))
4180            }
4181            _ => {
4182                self.expected(
4183                    SYSCTLS_EXPECTED_FORM,
4184                    field,
4185                    "sysctls must be a mapping or a sequence of string scalars",
4186                );
4187                None
4188            }
4189        }
4190    }
4191
4192    fn parse_logging(&mut self, field: &ParsedField) -> Option<Logging> {
4193        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4194            self.expected(
4195                LOGGING_EXPECTED_MAPPING,
4196                field,
4197                "logging must be a mapping with optional driver and options fields",
4198            );
4199            return None;
4200        };
4201        let span = span_from_position(self.source_id, mapping.byte_range());
4202        let mut logging = Logging::new(span);
4203        let mut seen = BTreeMap::new();
4204        for member in self.fields(mapping) {
4205            let duplicate = self.record_duplicate(&mut seen, &member);
4206            match member.name.value.as_str() {
4207                "driver" if !duplicate => {
4208                    if let Some(driver) = self.parse_logging_driver(&member) {
4209                        logging.set_driver(driver);
4210                    }
4211                }
4212                "options" if !duplicate => {
4213                    if let Some(options) = self.parse_logging_options(&member) {
4214                        logging.set_options(options);
4215                    }
4216                }
4217                name if name.starts_with("x-") => logging.push_extension(member.reference()),
4218                _ if duplicate => {}
4219                _ => logging.push_unknown(member.reference()),
4220            }
4221        }
4222        Some(logging)
4223    }
4224
4225    fn parse_credential_spec(&mut self, field: &ParsedField) -> Option<CredentialSpec> {
4226        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4227            self.expected(EXPECTED_MAPPING, field, "credential_spec must be a mapping");
4228            return None;
4229        };
4230        let mut credential_spec = CredentialSpec::new(span_from_position(self.source_id, mapping.byte_range()));
4231        let mut seen = BTreeMap::new();
4232        for member in self.fields(mapping) {
4233            let duplicate = self.record_duplicate(&mut seen, &member);
4234            match member.name.value().as_str() {
4235                "config" if !duplicate => {
4236                    if let Some(value) = self
4237                        .parse_credential_spec_string(&member, "credential_spec config must be a YAML string scalar")
4238                    {
4239                        credential_spec.set_config(value);
4240                    } else {
4241                        credential_spec.push_unknown(member.reference());
4242                    }
4243                }
4244                "file" if !duplicate => {
4245                    if let Some(value) =
4246                        self.parse_credential_spec_string(&member, "credential_spec file must be a YAML string scalar")
4247                    {
4248                        credential_spec.set_file(value);
4249                    } else {
4250                        credential_spec.push_unknown(member.reference());
4251                    }
4252                }
4253                "registry" if !duplicate => {
4254                    if let Some(value) = self
4255                        .parse_credential_spec_string(&member, "credential_spec registry must be a YAML string scalar")
4256                    {
4257                        credential_spec.set_registry(value);
4258                    } else {
4259                        credential_spec.push_unknown(member.reference());
4260                    }
4261                }
4262                name if name.starts_with("x-") => credential_spec.push_extension(member.reference()),
4263                _ if duplicate => {}
4264                _ => credential_spec.push_unknown(member.reference()),
4265            }
4266        }
4267        Some(credential_spec)
4268    }
4269
4270    fn parse_credential_spec_string(&mut self, field: &ParsedField, message: &'static str) -> Option<Located<String>> {
4271        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4272            self.expected(EXPECTED_SCALAR, field, message);
4273            return None;
4274        };
4275        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
4276            self.expected(EXPECTED_SCALAR, field, message);
4277            return None;
4278        }
4279        Some(Located::new(
4280            scalar_string_from_source(&self.source, scalar),
4281            span_from_position(self.source_id, scalar.byte_range()),
4282        ))
4283    }
4284
4285    fn parse_extends(&mut self, field: &ParsedField) -> Option<Extends> {
4286        match field.value.as_ref() {
4287            Some(YamlNode::Scalar(scalar)) if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::String => {
4288                Some(Extends::Short(Located::new(
4289                    scalar_string_from_source(&self.source, scalar),
4290                    span_from_position(self.source_id, scalar.byte_range()),
4291                )))
4292            }
4293            Some(YamlNode::Mapping(mapping)) => {
4294                let span = span_from_position(self.source_id, mapping.byte_range());
4295                let mut reference = ExtendsReference::new(span);
4296                let mut seen = BTreeMap::new();
4297                for member in self.fields(mapping) {
4298                    let duplicate = self.record_duplicate(&mut seen, &member);
4299                    match member.name.value().as_str() {
4300                        "service" if !duplicate => {
4301                            if let Some(value) =
4302                                self.parse_extends_string(&member, "extends service must be a YAML string scalar")
4303                            {
4304                                reference.set_service(value);
4305                            } else {
4306                                reference.push_unknown(member.reference());
4307                            }
4308                        }
4309                        "file" if !duplicate => {
4310                            if let Some(value) =
4311                                self.parse_extends_string(&member, "extends file must be a YAML string scalar")
4312                            {
4313                                reference.set_file(value);
4314                            } else {
4315                                reference.push_unknown(member.reference());
4316                            }
4317                        }
4318                        name if name.starts_with("x-") => reference.push_extension(member.reference()),
4319                        _ if duplicate => {}
4320                        _ => reference.push_unknown(member.reference()),
4321                    }
4322                }
4323                if reference.service().is_none() {
4324                    self.missing(
4325                        EXTENDS_MISSING_SERVICE,
4326                        span,
4327                        "long extends is missing required `service`",
4328                    );
4329                }
4330                Some(Extends::Long(reference))
4331            }
4332            _ => {
4333                self.expected(
4334                    EXPECTED_FIELD_FORM,
4335                    field,
4336                    "extends must be a YAML string scalar or mapping",
4337                );
4338                None
4339            }
4340        }
4341    }
4342
4343    fn parse_extends_string(&mut self, field: &ParsedField, message: impl Into<String>) -> Option<Located<String>> {
4344        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4345            self.expected(EXPECTED_SCALAR, field, message);
4346            return None;
4347        };
4348        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
4349            self.expected(EXPECTED_SCALAR, field, message);
4350            return None;
4351        }
4352        Some(Located::new(
4353            scalar_string_from_source(&self.source, scalar),
4354            span_from_position(self.source_id, scalar.byte_range()),
4355        ))
4356    }
4357
4358    fn parse_provider(&mut self, field: &ParsedField) -> Option<Provider> {
4359        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4360            self.expected(EXPECTED_MAPPING, field, "provider must be a mapping");
4361            return None;
4362        };
4363        let span = span_from_position(self.source_id, mapping.byte_range());
4364        let mut provider = Provider::new(span);
4365        let mut seen = BTreeMap::new();
4366        for member in self.fields(mapping) {
4367            let duplicate = self.record_duplicate(&mut seen, &member);
4368            match member.name.value().as_str() {
4369                "type" if !duplicate => {
4370                    if let Some(value) = self.parse_provider_type(&member) {
4371                        provider.set_type(value);
4372                    } else {
4373                        provider.push_unknown(member.reference());
4374                    }
4375                }
4376                "options" if !duplicate => {
4377                    if let Some(options) = self.parse_provider_options(&member) {
4378                        provider.set_options(options);
4379                    } else {
4380                        provider.push_unknown(member.reference());
4381                    }
4382                }
4383                name if name.starts_with("x-") => provider.push_extension(member.reference()),
4384                _ if duplicate => {}
4385                _ => provider.push_unknown(member.reference()),
4386            }
4387        }
4388        if provider.type_().is_none() {
4389            self.missing(PROVIDER_MISSING_TYPE, span, "provider is missing required `type`");
4390        }
4391        Some(provider)
4392    }
4393
4394    fn parse_provider_type(&mut self, field: &ParsedField) -> Option<Located<String>> {
4395        self.parse_extends_string(field, "provider type must be a YAML string scalar")
4396    }
4397
4398    fn parse_post_start(&mut self, field: &ParsedField) -> Option<PostStartHooks> {
4399        let (span, entries) = self.parse_lifecycle_hooks(
4400            field,
4401            "post_start",
4402            POST_START_MISSING_COMMAND,
4403            PostStartHook::Hook,
4404            |span| PostStartHook::Unmodeled { span },
4405        )?;
4406        Some(PostStartHooks::new(span, entries))
4407    }
4408
4409    fn parse_pre_stop(&mut self, field: &ParsedField) -> Option<PreStopHooks> {
4410        let (span, entries) =
4411            self.parse_lifecycle_hooks(field, "pre_stop", PRE_STOP_MISSING_COMMAND, PreStopHook::Hook, |span| {
4412                PreStopHook::Unmodeled { span }
4413            })?;
4414        Some(PreStopHooks::new(span, entries))
4415    }
4416
4417    fn parse_pre_start(&mut self, field: &ParsedField) -> Option<PreStartHooks> {
4418        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
4419            self.expected(
4420                EXPECTED_SEQUENCE,
4421                field,
4422                "pre_start must be a sequence of hook mappings",
4423            );
4424            return None;
4425        };
4426        let span = span_from_position(self.source_id, sequence.byte_range());
4427        let mut entries = Vec::new();
4428        for item in sequence.values() {
4429            let Some(mapping) = item.as_mapping() else {
4430                self.unsupported_sequence_item(
4431                    EXPECTED_MAPPING,
4432                    &item,
4433                    field.span,
4434                    "pre_start items must be hook mappings",
4435                );
4436                entries.push(PreStartHook::Unmodeled {
4437                    span: node_span(self.source_id, &item).unwrap_or(field.span),
4438                });
4439                continue;
4440            };
4441            entries.push(PreStartHook::Hook(Box::new(self.parse_pre_start_service_hook(mapping))));
4442        }
4443        Some(PreStartHooks::new(span, entries))
4444    }
4445
4446    fn parse_lifecycle_hooks<T>(
4447        &mut self,
4448        field: &ParsedField,
4449        name: &str,
4450        missing_code: DiagnosticCode,
4451        hook_entry: fn(Box<ServiceHook>) -> T,
4452        unmodeled_entry: fn(SourceSpan) -> T,
4453    ) -> Option<(SourceSpan, Vec<T>)> {
4454        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
4455            self.expected(
4456                EXPECTED_SEQUENCE,
4457                field,
4458                format!("{name} must be a sequence of hook mappings"),
4459            );
4460            return None;
4461        };
4462        let span = span_from_position(self.source_id, sequence.byte_range());
4463        let mut entries = Vec::new();
4464        for item in sequence.values() {
4465            let Some(mapping) = item.as_mapping() else {
4466                self.unsupported_sequence_item(
4467                    EXPECTED_MAPPING,
4468                    &item,
4469                    field.span,
4470                    format!("{name} items must be hook mappings"),
4471                );
4472                entries.push(unmodeled_entry(node_span(self.source_id, &item).unwrap_or(field.span)));
4473                continue;
4474            };
4475            entries.push(hook_entry(Box::new(self.parse_service_hook(
4476                mapping,
4477                name,
4478                missing_code,
4479            ))));
4480        }
4481        Some((span, entries))
4482    }
4483
4484    fn parse_service_hook(&mut self, mapping: &Mapping, name: &str, missing_code: DiagnosticCode) -> ServiceHook {
4485        let span = span_from_position(self.source_id, mapping.byte_range());
4486        let mut hook = ServiceHook::new(span);
4487        let mut seen = BTreeMap::new();
4488        for member in self.fields(mapping) {
4489            let duplicate = self.record_duplicate(&mut seen, &member);
4490            if duplicate {
4491                hook.push_unknown(member.reference());
4492                continue;
4493            }
4494            match member.name.value().as_str() {
4495                "command" => {
4496                    if let Some(value) = self.parse_command(&member) {
4497                        hook.set_command(value);
4498                    } else {
4499                        hook.push_unknown(member.reference());
4500                    }
4501                }
4502                "environment" => {
4503                    if let Some(value) = self.parse_environment(&member) {
4504                        hook.set_environment(value);
4505                    } else {
4506                        hook.push_unknown(member.reference());
4507                    }
4508                }
4509                "privileged" => {
4510                    if let Some(value) = self.parse_boolean(&member, &format!("{name} privileged")) {
4511                        hook.set_privileged(value);
4512                    } else {
4513                        hook.push_unknown(member.reference());
4514                    }
4515                }
4516                "user" => {
4517                    if let Some(value) =
4518                        self.parse_extends_string(&member, format!("{name} user must be a YAML string scalar"))
4519                    {
4520                        hook.set_user(value);
4521                    } else {
4522                        hook.push_unknown(member.reference());
4523                    }
4524                }
4525                "working_dir" => {
4526                    if let Some(value) =
4527                        self.parse_extends_string(&member, format!("{name} working_dir must be a YAML string scalar"))
4528                    {
4529                        hook.set_working_dir(value);
4530                    } else {
4531                        hook.push_unknown(member.reference());
4532                    }
4533                }
4534                name if name.starts_with("x-") => hook.push_extension(member.reference()),
4535                _ => hook.push_unknown(member.reference()),
4536            }
4537        }
4538        if hook.command().is_none() {
4539            self.missing(missing_code, span, format!("{name} hook is missing required `command`"));
4540        }
4541        hook
4542    }
4543
4544    fn parse_pre_start_service_hook(&mut self, mapping: &Mapping) -> PreStartServiceHook {
4545        let span = span_from_position(self.source_id, mapping.byte_range());
4546        let mut hook = PreStartServiceHook::new(span);
4547        let mut seen = BTreeMap::new();
4548        for member in self.fields(mapping) {
4549            let duplicate = self.record_duplicate(&mut seen, &member);
4550            if duplicate {
4551                hook.push_unknown(member.reference());
4552                continue;
4553            }
4554            match member.name.value().as_str() {
4555                "command" => {
4556                    if let Some(value) = self.parse_command(&member) {
4557                        hook.set_command(value);
4558                    } else {
4559                        hook.push_unknown(member.reference());
4560                    }
4561                }
4562                "image" => {
4563                    if let Some(value) =
4564                        self.parse_extends_string(&member, "pre_start image must be a YAML string scalar")
4565                    {
4566                        hook.set_image(value);
4567                    } else {
4568                        hook.push_unknown(member.reference());
4569                    }
4570                }
4571                "environment" => {
4572                    if let Some(value) = self.parse_environment(&member) {
4573                        hook.set_environment(value);
4574                    } else {
4575                        hook.push_unknown(member.reference());
4576                    }
4577                }
4578                "privileged" => {
4579                    if let Some(value) = self.parse_boolean(&member, "pre_start privileged") {
4580                        hook.set_privileged(value);
4581                    } else {
4582                        hook.push_unknown(member.reference());
4583                    }
4584                }
4585                "per_replica" => {
4586                    if let Some(value) = self.parse_boolean(&member, "pre_start per_replica") {
4587                        hook.set_per_replica(value);
4588                    } else {
4589                        hook.push_unknown(member.reference());
4590                    }
4591                }
4592                "user" => {
4593                    if let Some(value) =
4594                        self.parse_extends_string(&member, "pre_start user must be a YAML string scalar")
4595                    {
4596                        hook.set_user(value);
4597                    } else {
4598                        hook.push_unknown(member.reference());
4599                    }
4600                }
4601                "working_dir" => {
4602                    if let Some(value) =
4603                        self.parse_extends_string(&member, "pre_start working_dir must be a YAML string scalar")
4604                    {
4605                        hook.set_working_dir(value);
4606                    } else {
4607                        hook.push_unknown(member.reference());
4608                    }
4609                }
4610                name if name.starts_with("x-") => hook.push_extension(member.reference()),
4611                _ => hook.push_unknown(member.reference()),
4612            }
4613        }
4614        hook
4615    }
4616
4617    fn parse_provider_options(&mut self, field: &ParsedField) -> Option<ProviderOptions> {
4618        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4619            self.expected(EXPECTED_MAPPING, field, "provider options must be a mapping");
4620            return None;
4621        };
4622        let span = span_from_position(self.source_id, mapping.byte_range());
4623        let mut entries = Vec::new();
4624        let mut unmodeled_entries = Vec::new();
4625        let mut seen = BTreeMap::new();
4626        for option in self.fields(mapping) {
4627            if self.record_duplicate(&mut seen, &option) {
4628                unmodeled_entries.push(option.reference());
4629                continue;
4630            }
4631            if option.name.value().is_empty() {
4632                self.expected(EXPECTED_FIELD_FORM, &option, "provider option keys must not be empty");
4633                unmodeled_entries.push(option.reference());
4634                continue;
4635            }
4636            let Some(value) = self.parse_provider_option_value(&option) else {
4637                unmodeled_entries.push(option.reference());
4638                continue;
4639            };
4640            entries.push(ProviderOption::new(option.name, value, option.span));
4641        }
4642        Some(ProviderOptions::new(span, entries, unmodeled_entries))
4643    }
4644
4645    fn parse_provider_option_value(&mut self, field: &ParsedField) -> Option<ProviderOptionValue> {
4646        let Some(value) = field.value.as_ref() else {
4647            self.expected(
4648                EXPECTED_FIELD_FORM,
4649                field,
4650                "provider option values must be YAML string, number, or boolean scalars or sequences of them",
4651            );
4652            return None;
4653        };
4654        if let Some(scalar) = value.as_scalar() {
4655            return self
4656                .provider_option_scalar(scalar)
4657                .map(ProviderOptionValue::Scalar)
4658                .or_else(|| {
4659                    self.expected(
4660                        EXPECTED_FIELD_FORM,
4661                        field,
4662                        "provider option values must be YAML string, number, or boolean scalars or sequences of them",
4663                    );
4664                    None
4665                });
4666        }
4667        let Some(sequence) = value.as_sequence() else {
4668            self.expected(
4669                EXPECTED_FIELD_FORM,
4670                field,
4671                "provider option values must be YAML string, number, or boolean scalars or sequences of them",
4672            );
4673            return None;
4674        };
4675        let span = span_from_position(self.source_id, sequence.byte_range());
4676        let mut items = Vec::new();
4677        for item in sequence.values() {
4678            let Some(scalar) = item.as_scalar() else {
4679                self.unsupported_sequence_item(
4680                    EXPECTED_SCALAR,
4681                    &item,
4682                    field.span,
4683                    "provider option sequence items must be YAML string, number, or boolean scalars",
4684                );
4685                items.push(ProviderOptionItem::Unmodeled {
4686                    span: node_span(self.source_id, &item).unwrap_or(field.span),
4687                });
4688                continue;
4689            };
4690            if let Some(value) = self.provider_option_scalar(scalar) {
4691                items.push(ProviderOptionItem::Scalar(value));
4692            } else {
4693                self.unsupported_sequence_item(
4694                    EXPECTED_SCALAR,
4695                    &item,
4696                    field.span,
4697                    "provider option sequence items must be YAML string, number, or boolean scalars",
4698                );
4699                items.push(ProviderOptionItem::Unmodeled {
4700                    span: node_span(self.source_id, &item).unwrap_or(field.span),
4701                });
4702            }
4703        }
4704        Some(ProviderOptionValue::Sequence { span, items })
4705    }
4706
4707    fn provider_option_scalar(&self, scalar: &Scalar) -> Option<Located<ComposeScalar>> {
4708        let scalar_value = ScalarValue::from_scalar(scalar);
4709        let value = match scalar_value.scalar_type() {
4710            ScalarType::String => ComposeScalar::String(scalar_string_from_source(&self.source, scalar)),
4711            ScalarType::Integer | ScalarType::Float => {
4712                ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
4713            }
4714            ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
4715            ScalarType::Null | ScalarType::Timestamp | ScalarType::Regex => return None,
4716        };
4717        Some(Located::new(
4718            value,
4719            span_from_position(self.source_id, scalar.byte_range()),
4720        ))
4721    }
4722
4723    fn parse_logging_driver(&mut self, field: &ParsedField) -> Option<Located<String>> {
4724        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4725            self.expected(
4726                LOGGING_DRIVER_EXPECTED_STRING,
4727                field,
4728                "logging driver must be a YAML string scalar",
4729            );
4730            return None;
4731        };
4732        if !matches!(
4733            ScalarValue::from_scalar(scalar).scalar_type(),
4734            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4735        ) {
4736            self.expected(
4737                LOGGING_DRIVER_EXPECTED_STRING,
4738                field,
4739                "logging driver must be a YAML string scalar",
4740            );
4741            return None;
4742        }
4743        let span = span_from_position(self.source_id, scalar.byte_range());
4744        Some(Located::new(scalar_string_from_source(&self.source, scalar), span))
4745    }
4746
4747    fn parse_logging_options(&mut self, field: &ParsedField) -> Option<LoggingOptions> {
4748        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4749            self.expected(
4750                LOGGING_OPTIONS_EXPECTED_MAPPING,
4751                field,
4752                "logging options must be a mapping",
4753            );
4754            return None;
4755        };
4756        let span = span_from_position(self.source_id, mapping.byte_range());
4757        let mut entries = Vec::new();
4758        let mut unmodeled_entries = Vec::new();
4759        let mut seen = BTreeMap::new();
4760        for option in self.fields(mapping) {
4761            if self.record_duplicate(&mut seen, &option) {
4762                continue;
4763            }
4764            if option.name.value.is_empty() {
4765                self.diagnostics.push(
4766                    Diagnostic::new(
4767                        LOGGING_OPTION_EMPTY_KEY,
4768                        Severity::Error,
4769                        "logging option keys must not be empty",
4770                    )
4771                    .with_label(DiagnosticLabel::primary(option.name.span, "empty logging option key")),
4772                );
4773                unmodeled_entries.push(option.reference());
4774                continue;
4775            }
4776            let Some(value) = self.parse_logging_option_scalar(&option) else {
4777                unmodeled_entries.push(option.reference());
4778                continue;
4779            };
4780            entries.push(LoggingOption::new(option.name, value, option.span));
4781        }
4782        Some(LoggingOptions::new(span, entries, unmodeled_entries))
4783    }
4784
4785    fn parse_logging_option_scalar(&mut self, field: &ParsedField) -> Option<Located<LoggingOptionValue>> {
4786        let Some(node) = field.value.as_ref() else {
4787            return Some(Located::new(LoggingOptionValue::Null, field.name.span));
4788        };
4789        let Some(scalar) = node.as_scalar() else {
4790            self.expected(
4791                LOGGING_OPTION_EXPECTED_SCALAR,
4792                field,
4793                "logging option values must be YAML string, number, or null scalars",
4794            );
4795            return None;
4796        };
4797        let span = span_from_position(self.source_id, scalar.byte_range());
4798        let scalar_value = ScalarValue::from_scalar(scalar);
4799        let value = match scalar_value.scalar_type() {
4800            ScalarType::Null => LoggingOptionValue::Null,
4801            ScalarType::Integer | ScalarType::Float => {
4802                LoggingOptionValue::Number(scalar_string_from_source(&self.source, scalar))
4803            }
4804            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
4805                LoggingOptionValue::String(scalar_string_from_source(&self.source, scalar))
4806            }
4807            ScalarType::Boolean => {
4808                self.diagnostics.push(
4809                    Diagnostic::new(
4810                        LOGGING_OPTION_EXPECTED_SCALAR,
4811                        Severity::Error,
4812                        "logging option values must be YAML string, number, or null scalars",
4813                    )
4814                    .with_label(DiagnosticLabel::primary(
4815                        span,
4816                        "boolean logging option retained as malformed",
4817                    )),
4818                );
4819                return None;
4820            }
4821        };
4822        Some(Located::new(value, span))
4823    }
4824
4825    fn parse_restart_policy(&mut self, field: &ParsedField) -> Option<RestartPolicy> {
4826        let value = self.parse_string(field, "service restart policy")?;
4827        let policy = RestartPolicy::parse(value);
4828        if !policy.is_valid() {
4829            self.diagnostics.push(
4830                Diagnostic::new(
4831                    RESTART_INVALID_POLICY,
4832                    Severity::Error,
4833                    "restart must be `no`, `always`, `on-failure[:max-retries]`, `unless-stopped`, or interpolation",
4834                )
4835                .with_label(DiagnosticLabel::primary(
4836                    policy.raw().span(),
4837                    "invalid service restart policy",
4838                )),
4839            );
4840        }
4841        Some(policy)
4842    }
4843
4844    fn parse_pids_limit(&mut self, field: &ParsedField) -> Option<PidsLimit> {
4845        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4846            self.expected(
4847                PIDS_LIMIT_EXPECTED_VALUE,
4848                field,
4849                "pids_limit must be a number or string scalar",
4850            );
4851            return None;
4852        };
4853        if matches!(
4854            ScalarValue::from_scalar(scalar).scalar_type(),
4855            ScalarType::Boolean | ScalarType::Null
4856        ) {
4857            self.expected(
4858                PIDS_LIMIT_EXPECTED_VALUE,
4859                field,
4860                "pids_limit must be a number or string scalar",
4861            );
4862            return None;
4863        }
4864        let span = span_from_position(self.source_id, scalar.byte_range());
4865        let limit = PidsLimit::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
4866        match limit.kind() {
4867            PidsLimitKind::Zero => self.diagnostics.push(
4868                Diagnostic::new(
4869                    PIDS_LIMIT_AMBIGUOUS_ZERO,
4870                    Severity::Warning,
4871                    "pids_limit zero is preserved as an ambiguous and unportable native state",
4872                )
4873                .with_label(DiagnosticLabel::primary(span, "ambiguous zero PID limit")),
4874            ),
4875            PidsLimitKind::Other => self.diagnostics.push(
4876                Diagnostic::new(
4877                    PIDS_LIMIT_INVALID,
4878                    Severity::Error,
4879                    "pids_limit must be `-1`, a positive integral decimal, or interpolation",
4880                )
4881                .with_label(DiagnosticLabel::primary(span, "unsupported service PID limit")),
4882            ),
4883            _ => {}
4884        }
4885        Some(limit)
4886    }
4887
4888    fn parse_cpu_count(&mut self, field: &ParsedField) -> Option<Located<CpuCount>> {
4889        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4890            self.expected(
4891                CPU_COUNT_EXPECTED_VALUE,
4892                field,
4893                "cpu_count must be a YAML integer or string scalar",
4894            );
4895            return None;
4896        };
4897        let span = span_from_position(self.source_id, scalar.byte_range());
4898        let scalar_value = ScalarValue::from_scalar(scalar);
4899        let raw = scalar_string_from_source(&self.source, scalar);
4900        let count = match scalar_value.scalar_type() {
4901            ScalarType::Integer | ScalarType::Float if CpuCount::yaml_integer_spelling(&raw) => {
4902                CpuCount::yaml_integer(raw)
4903            }
4904            ScalarType::String
4905                if scalar_value.style() == ScalarStyle::Plain
4906                    && !scalar_uses_block_style(&self.source, scalar)
4907                    && CpuCount::yaml_integer_spelling(&raw) =>
4908            {
4909                CpuCount::yaml_integer(raw)
4910            }
4911            ScalarType::String => CpuCount::String(scalar_string_from_source(&self.source, scalar)),
4912            _ => {
4913                self.expected(
4914                    CPU_COUNT_EXPECTED_VALUE,
4915                    field,
4916                    "cpu_count must be a YAML integer or string scalar",
4917                );
4918                return None;
4919            }
4920        };
4921        if !count.is_valid() {
4922            self.diagnostics.push(
4923                Diagnostic::new(
4924                    CPU_COUNT_NEGATIVE,
4925                    Severity::Error,
4926                    "cpu_count YAML integers must be nonnegative",
4927                )
4928                .with_label(DiagnosticLabel::primary(
4929                    span,
4930                    "negative CPU count retained as invalid evidence",
4931                )),
4932            );
4933        }
4934        Some(Located::new(count, span))
4935    }
4936
4937    fn parse_cpu_percent(&mut self, field: &ParsedField) -> Option<Located<CpuPercent>> {
4938        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4939            self.expected(
4940                CPU_PERCENT_EXPECTED_VALUE,
4941                field,
4942                "cpu_percent must be a YAML integer or string scalar",
4943            );
4944            return None;
4945        };
4946        let span = span_from_position(self.source_id, scalar.byte_range());
4947        let scalar_value = ScalarValue::from_scalar(scalar);
4948        let raw = scalar_string_from_source(&self.source, scalar);
4949        let percent = match scalar_value.scalar_type() {
4950            ScalarType::Integer | ScalarType::Float if CpuPercent::yaml_integer_spelling(&raw) => {
4951                CpuPercent::yaml_integer(raw)
4952            }
4953            ScalarType::String
4954                if scalar_value.style() == ScalarStyle::Plain
4955                    && !scalar_uses_block_style(&self.source, scalar)
4956                    && CpuPercent::yaml_integer_spelling(&raw) =>
4957            {
4958                CpuPercent::yaml_integer(raw)
4959            }
4960            ScalarType::String => CpuPercent::String(scalar_string_from_source(&self.source, scalar)),
4961            _ => {
4962                self.expected(
4963                    CPU_PERCENT_EXPECTED_VALUE,
4964                    field,
4965                    "cpu_percent must be a YAML integer or string scalar",
4966                );
4967                return None;
4968            }
4969        };
4970        if !percent.is_valid() {
4971            self.diagnostics.push(
4972                Diagnostic::new(
4973                    CPU_PERCENT_OUT_OF_RANGE,
4974                    Severity::Error,
4975                    "cpu_percent YAML integers must be between 0 and 100 inclusive",
4976                )
4977                .with_label(DiagnosticLabel::primary(
4978                    span,
4979                    "out-of-range CPU percentage retained as invalid evidence",
4980                )),
4981            );
4982        }
4983        Some(Located::new(percent, span))
4984    }
4985
4986    fn parse_cpu_period(&mut self, field: &ParsedField) -> Option<Located<CpuPeriod>> {
4987        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4988            self.expected(
4989                CPU_PERIOD_EXPECTED_VALUE,
4990                field,
4991                "cpu_period must be a YAML number or string scalar",
4992            );
4993            return None;
4994        };
4995        let span = span_from_position(self.source_id, scalar.byte_range());
4996        let scalar_value = ScalarValue::from_scalar(scalar);
4997        let raw = scalar_string_from_source(&self.source, scalar);
4998        let period = match scalar_value.scalar_type() {
4999            ScalarType::Integer | ScalarType::Float => CpuPeriod::YamlNumber(raw),
5000            ScalarType::String
5001                if scalar_value.style() == ScalarStyle::Plain
5002                    && !scalar_uses_block_style(&self.source, scalar)
5003                    && CpuPeriod::yaml_number_spelling(&raw) =>
5004            {
5005                CpuPeriod::YamlNumber(raw)
5006            }
5007            ScalarType::String => CpuPeriod::String(raw),
5008            _ => {
5009                self.expected(
5010                    CPU_PERIOD_EXPECTED_VALUE,
5011                    field,
5012                    "cpu_period must be a YAML number or string scalar",
5013                );
5014                return None;
5015            }
5016        };
5017        Some(Located::new(period, span))
5018    }
5019
5020    fn parse_cpu_quota(&mut self, field: &ParsedField) -> Option<Located<CpuQuota>> {
5021        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5022            self.expected(
5023                CPU_QUOTA_EXPECTED_VALUE,
5024                field,
5025                "cpu_quota must be a YAML number or string scalar",
5026            );
5027            return None;
5028        };
5029        let span = span_from_position(self.source_id, scalar.byte_range());
5030        let scalar_value = ScalarValue::from_scalar(scalar);
5031        let raw = scalar_string_from_source(&self.source, scalar);
5032        let quota = match scalar_value.scalar_type() {
5033            ScalarType::Integer | ScalarType::Float => CpuQuota::YamlNumber(raw),
5034            ScalarType::String
5035                if scalar_value.style() == ScalarStyle::Plain
5036                    && !scalar_uses_block_style(&self.source, scalar)
5037                    && CpuPeriod::yaml_number_spelling(&raw) =>
5038            {
5039                CpuQuota::YamlNumber(raw)
5040            }
5041            ScalarType::String => CpuQuota::String(raw),
5042            _ => {
5043                self.expected(
5044                    CPU_QUOTA_EXPECTED_VALUE,
5045                    field,
5046                    "cpu_quota must be a YAML number or string scalar",
5047                );
5048                return None;
5049            }
5050        };
5051        Some(Located::new(quota, span))
5052    }
5053
5054    fn parse_cpu_rt_period(&mut self, field: &ParsedField) -> Option<Located<CpuRtPeriod>> {
5055        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5056            self.expected(
5057                CPU_RT_PERIOD_EXPECTED_VALUE,
5058                field,
5059                "cpu_rt_period must be a YAML number or string scalar",
5060            );
5061            return None;
5062        };
5063        let span = span_from_position(self.source_id, scalar.byte_range());
5064        let scalar_value = ScalarValue::from_scalar(scalar);
5065        let raw = scalar_string_from_source(&self.source, scalar);
5066        let period = match scalar_value.scalar_type() {
5067            ScalarType::Integer | ScalarType::Float => CpuRtPeriod::YamlNumber(raw),
5068            ScalarType::String
5069                if scalar_value.style() == ScalarStyle::Plain
5070                    && !scalar_uses_block_style(&self.source, scalar)
5071                    && CpuPeriod::yaml_number_spelling(&raw) =>
5072            {
5073                CpuRtPeriod::YamlNumber(raw)
5074            }
5075            ScalarType::String => CpuRtPeriod::parse_string(raw),
5076            _ => {
5077                self.expected(
5078                    CPU_RT_PERIOD_EXPECTED_VALUE,
5079                    field,
5080                    "cpu_rt_period must be a YAML number or string scalar",
5081                );
5082                return None;
5083            }
5084        };
5085        if !period.is_valid() {
5086            self.diagnostics.push(
5087                Diagnostic::new(
5088                    CPU_RT_PERIOD_INVALID,
5089                    Severity::Error,
5090                    "cpu_rt_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
5091                )
5092                .with_label(DiagnosticLabel::primary(span, "invalid service real-time CPU period")),
5093            );
5094        }
5095        Some(Located::new(period, span))
5096    }
5097
5098    fn parse_cpu_rt_runtime(&mut self, field: &ParsedField) -> Option<Located<CpuRtRuntime>> {
5099        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5100            self.expected(
5101                EXPECTED_SCALAR,
5102                field,
5103                "cpu_rt_runtime must be an integer microsecond or duration string scalar",
5104            );
5105            return None;
5106        };
5107        let span = span_from_position(self.source_id, scalar.byte_range());
5108        let scalar_value = ScalarValue::from_scalar(scalar);
5109        let raw = scalar_string_from_source(&self.source, scalar);
5110        let runtime = match scalar_value.scalar_type() {
5111            ScalarType::Integer => CpuRtRuntime::parse_number(raw, true),
5112            ScalarType::Float => CpuRtRuntime::parse_number(raw, false),
5113            ScalarType::String => CpuRtRuntime::parse_string(raw),
5114            _ => {
5115                self.expected(
5116                    EXPECTED_SCALAR,
5117                    field,
5118                    "cpu_rt_runtime must be an integer microsecond or duration string scalar",
5119                );
5120                return None;
5121            }
5122        };
5123        if !runtime.is_valid() {
5124            self.diagnostics.push(Diagnostic::new(CPU_RT_RUNTIME_INVALID, Severity::Error,
5125                "cpu_rt_runtime must be an integer microsecond value, a Compose duration, or an interpolation expression")
5126                .with_label(DiagnosticLabel::primary(span, "invalid service real-time CPU runtime")));
5127        }
5128        Some(Located::new(runtime, span))
5129    }
5130
5131    fn parse_service_integer(
5132        &mut self,
5133        field: &ParsedField,
5134        min: i128,
5135        max: i128,
5136        description: &str,
5137    ) -> Option<Located<ServiceInteger>> {
5138        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5139            self.expected(
5140                EXPECTED_SCALAR,
5141                field,
5142                format!("{description} must be an integer scalar"),
5143            );
5144            return None;
5145        };
5146        let span = span_from_position(self.source_id, scalar.byte_range());
5147        let value = ScalarValue::from_scalar(scalar);
5148        if matches!(
5149            value.scalar_type(),
5150            ScalarType::Boolean | ScalarType::Timestamp | ScalarType::Regex
5151        ) {
5152            self.expected(
5153                EXPECTED_SCALAR,
5154                field,
5155                format!("{description} must be an integer scalar"),
5156            );
5157            return None;
5158        }
5159        let parsed = ServiceInteger::parse(scalar_string_from_source(&self.source, scalar), min, max);
5160        if !parsed.is_valid() {
5161            self.diagnostics.push(
5162                Diagnostic::new(
5163                    EXPECTED_FIELD_FORM,
5164                    Severity::Error,
5165                    format!("{description} must be an integer in its documented range"),
5166                )
5167                .with_label(DiagnosticLabel::primary(span, "invalid integer spelling retained")),
5168            );
5169        }
5170        Some(Located::new(parsed, span))
5171    }
5172
5173    fn parse_cpus(&mut self, field: &ParsedField) -> Option<Located<Cpus>> {
5174        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5175            self.expected(EXPECTED_SCALAR, field, "cpus must be a decimal scalar");
5176            return None;
5177        };
5178        let span = span_from_position(self.source_id, scalar.byte_range());
5179        let kind = ScalarValue::from_scalar(scalar).scalar_type();
5180        if matches!(kind, ScalarType::Boolean | ScalarType::Timestamp | ScalarType::Regex) {
5181            self.expected(EXPECTED_SCALAR, field, "cpus must be a decimal scalar");
5182            return None;
5183        }
5184        let value = Cpus::parse(scalar_string_from_source(&self.source, scalar));
5185        if !value.is_valid() {
5186            self.diagnostics.push(
5187                Diagnostic::new(
5188                    EXPECTED_FIELD_FORM,
5189                    Severity::Error,
5190                    "cpus must be a decimal allocation or interpolation expression",
5191                )
5192                .with_label(DiagnosticLabel::primary(span, "invalid CPU allocation retained")),
5193            );
5194        }
5195        Some(Located::new(value, span))
5196    }
5197
5198    fn parse_shm_size(&mut self, field: &ParsedField) -> Option<ShmSize> {
5199        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5200            self.expected(
5201                SHM_SIZE_EXPECTED_VALUE,
5202                field,
5203                "shm_size must be a YAML number or string scalar",
5204            );
5205            return None;
5206        };
5207        let scalar_kind = match ScalarValue::from_scalar(scalar).scalar_type() {
5208            ScalarType::Integer | ScalarType::Float => ShmSizeScalarKind::Number,
5209            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => ShmSizeScalarKind::String,
5210            ScalarType::Boolean | ScalarType::Null => {
5211                self.expected(
5212                    SHM_SIZE_EXPECTED_VALUE,
5213                    field,
5214                    "shm_size must be a YAML number or string scalar",
5215                );
5216                return None;
5217            }
5218        };
5219        let span = span_from_position(self.source_id, scalar.byte_range());
5220        let size = ShmSize::parse(
5221            Located::new(scalar_string_from_source(&self.source, scalar), span),
5222            scalar_kind,
5223        );
5224        self.diagnose_shm_size(&size);
5225        Some(size)
5226    }
5227
5228    fn diagnose_shm_size(&mut self, size: &ShmSize) {
5229        let (code, message, label, note) = match size.kind() {
5230            ShmSizeKind::Zero { .. } => (
5231                SHM_SIZE_AMBIGUOUS_ZERO,
5232                "shm_size zero is preserved because Compose does not define its semantics",
5233                "ambiguous zero shared-memory size",
5234                "choose a positive size with an explicit documented lowercase unit",
5235            ),
5236            ShmSizeKind::ProviderDependentNumber => (
5237                SHM_SIZE_PROVIDER_DEPENDENT_NUMBER,
5238                "numeric shm_size is schema-accepted but lacks a documented explicit unit",
5239                "provider-dependent numeric shared-memory size",
5240                "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for portable intent",
5241            ),
5242            ShmSizeKind::ProviderDependentString => (
5243                SHM_SIZE_PROVIDER_DEPENDENT_STRING,
5244                "string shm_size is schema-accepted but falls outside the documented lowercase suffix family",
5245                "provider-dependent string shared-memory size",
5246                "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
5247            ),
5248            ShmSizeKind::Documented { .. } | ShmSizeKind::Expression => return,
5249        };
5250        self.diagnostics.push(
5251            Diagnostic::new(code, Severity::Warning, message)
5252                .with_label(DiagnosticLabel::primary(size.raw().span(), label))
5253                .with_note(note),
5254        );
5255    }
5256
5257    fn parse_mem_limit(&mut self, field: &ParsedField) -> Option<MemLimit> {
5258        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5259            self.expected(
5260                MEM_LIMIT_EXPECTED_VALUE,
5261                field,
5262                "mem_limit must be a YAML number or string scalar",
5263            );
5264            return None;
5265        };
5266        let scalar_kind = match ScalarValue::from_scalar(scalar).scalar_type() {
5267            ScalarType::Integer | ScalarType::Float => MemLimitScalarKind::Number,
5268            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MemLimitScalarKind::String,
5269            ScalarType::Boolean | ScalarType::Null => {
5270                self.expected(
5271                    MEM_LIMIT_EXPECTED_VALUE,
5272                    field,
5273                    "mem_limit must be a YAML number or string scalar",
5274                );
5275                return None;
5276            }
5277        };
5278        let span = span_from_position(self.source_id, scalar.byte_range());
5279        let limit = MemLimit::parse(
5280            Located::new(scalar_string_from_source(&self.source, scalar), span),
5281            scalar_kind,
5282        );
5283        self.diagnose_mem_limit(&limit);
5284        Some(limit)
5285    }
5286
5287    fn parse_memswap_limit(&mut self, field: &ParsedField) -> Option<MemswapLimit> {
5288        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5289            self.expected(
5290                MEMSWAP_LIMIT_EXPECTED_VALUE,
5291                field,
5292                "memswap_limit must be a YAML number or string scalar",
5293            );
5294            return None;
5295        };
5296        let scalar_kind = match ScalarValue::from_scalar(scalar).scalar_type() {
5297            ScalarType::Integer | ScalarType::Float => MemswapLimitScalarKind::Number,
5298            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MemswapLimitScalarKind::String,
5299            ScalarType::Boolean | ScalarType::Null => {
5300                self.expected(
5301                    MEMSWAP_LIMIT_EXPECTED_VALUE,
5302                    field,
5303                    "memswap_limit must be a YAML number or string scalar",
5304                );
5305                return None;
5306            }
5307        };
5308        let span = span_from_position(self.source_id, scalar.byte_range());
5309        let limit = MemswapLimit::parse(
5310            Located::new(scalar_string_from_source(&self.source, scalar), span),
5311            scalar_kind,
5312        );
5313        if matches!(limit.kind(), MemswapLimitKind::Other(_)) {
5314            self.diagnostics.push(
5315                Diagnostic::new(
5316                    MEMSWAP_LIMIT_INVALID,
5317                    Severity::Error,
5318                    "memswap_limit must be `-1`, a decimal byte quantity, or an interpolation expression",
5319                )
5320                .with_label(DiagnosticLabel::primary(
5321                    span,
5322                    "invalid memory-plus-swap limit retained",
5323                )),
5324            );
5325        }
5326        Some(limit)
5327    }
5328
5329    fn diagnose_mem_limit(&mut self, limit: &MemLimit) {
5330        let (code, message, label, note) = match limit.kind() {
5331            MemLimitKind::Zero { .. } => (
5332                MEM_LIMIT_AMBIGUOUS_ZERO,
5333                "mem_limit zero is preserved without inferring portable runtime behavior",
5334                "ambiguous zero memory limit",
5335                "choose a positive size with an explicit documented lowercase unit",
5336            ),
5337            MemLimitKind::SchemaNumber => (
5338                MEM_LIMIT_SCHEMA_NUMBER,
5339                "numeric mem_limit is schema-accepted but lacks a documented explicit unit",
5340                "schema-only numeric memory limit",
5341                "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for explicit intent",
5342            ),
5343            MemLimitKind::ProviderDependentString => (
5344                MEM_LIMIT_PROVIDER_DEPENDENT_STRING,
5345                "string mem_limit is schema-accepted but falls outside the documented lowercase suffix family",
5346                "provider-dependent string memory limit",
5347                "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
5348            ),
5349            MemLimitKind::Documented { .. } | MemLimitKind::Expression => return,
5350        };
5351        self.diagnostics.push(
5352            Diagnostic::new(code, Severity::Warning, message)
5353                .with_label(DiagnosticLabel::primary(limit.raw().span(), label))
5354                .with_note(note),
5355        );
5356    }
5357
5358    fn parse_pull_policy(&mut self, field: &ParsedField) -> Option<PullPolicy> {
5359        let value = self.parse_string(field, "service pull policy")?;
5360        let policy = PullPolicy::parse(value);
5361        if !policy.is_recognized() {
5362            self.diagnostics.push(
5363                Diagnostic::new(
5364                    PULL_POLICY_INVALID,
5365                    Severity::Error,
5366                    "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",
5367                )
5368                .with_label(DiagnosticLabel::primary(
5369                    policy.raw().span(),
5370                    "invalid or provider-specific service pull policy",
5371                )),
5372            );
5373        }
5374        Some(policy)
5375    }
5376
5377    fn parse_stop_grace_period(&mut self, field: &ParsedField) -> Option<Located<StopGracePeriod>> {
5378        let value = self.parse_string(field, "service stop grace period")?;
5379        let period = StopGracePeriod::parse(value.value);
5380        if !period.is_valid() {
5381            self.diagnostics.push(
5382                Diagnostic::new(
5383                    STOP_GRACE_PERIOD_INVALID,
5384                    Severity::Error,
5385                    "stop_grace_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
5386                )
5387                .with_label(DiagnosticLabel::primary(
5388                    value.span,
5389                    "invalid service stop grace period",
5390                )),
5391            );
5392        }
5393        Some(Located::new(period, value.span))
5394    }
5395
5396    fn parse_command(&mut self, field: &ParsedField) -> Option<Command> {
5397        match field.value.as_ref() {
5398            Some(YamlNode::Scalar(scalar)) => {
5399                let span = span_from_position(self.source_id, scalar.byte_range());
5400                if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
5401                    Some(Command::Null(span))
5402                } else {
5403                    Some(Command::String(Located::new(
5404                        scalar_string_from_source(&self.source, scalar),
5405                        span,
5406                    )))
5407                }
5408            }
5409            Some(YamlNode::Sequence(sequence)) => {
5410                let span = span_from_position(self.source_id, sequence.byte_range());
5411                let values =
5412                    self.parse_scalar_nodes(sequence.values(), field.span, "command list items must be scalars");
5413                Some(Command::List { span, values })
5414            }
5415            _ => {
5416                self.expected(
5417                    EXPECTED_FIELD_FORM,
5418                    field,
5419                    "command must be null, a scalar, or a sequence",
5420                );
5421                None
5422            }
5423        }
5424    }
5425
5426    fn parse_service_user(&mut self, field: &ParsedField) -> Option<UserSpec> {
5427        self.parse_string(field, "service user").map(UserSpec::parse)
5428    }
5429
5430    fn parse_entrypoint(&mut self, field: &ParsedField) -> Option<Entrypoint> {
5431        match field.value.as_ref() {
5432            Some(YamlNode::Scalar(scalar)) => {
5433                let span = span_from_position(self.source_id, scalar.byte_range());
5434                if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
5435                    Some(Entrypoint::Null(span))
5436                } else {
5437                    Some(Entrypoint::String(Located::new(
5438                        scalar_string_from_source(&self.source, scalar),
5439                        span,
5440                    )))
5441                }
5442            }
5443            Some(YamlNode::Sequence(sequence)) => {
5444                let span = span_from_position(self.source_id, sequence.byte_range());
5445                let values =
5446                    self.parse_scalar_nodes(sequence.values(), field.span, "entrypoint list items must be scalars");
5447                Some(Entrypoint::List { span, values })
5448            }
5449            _ => {
5450                self.expected(
5451                    EXPECTED_FIELD_FORM,
5452                    field,
5453                    "entrypoint must be null, a scalar, or a sequence",
5454                );
5455                None
5456            }
5457        }
5458    }
5459
5460    fn parse_environment(&mut self, field: &ParsedField) -> Option<Environment> {
5461        match field.value.as_ref() {
5462            Some(YamlNode::Sequence(sequence)) => {
5463                let span = span_from_position(self.source_id, sequence.byte_range());
5464                let entries = self
5465                    .parse_scalar_nodes(sequence.values(), field.span, "environment list items must be scalars")
5466                    .into_iter()
5467                    .map(EnvironmentListEntry::parse)
5468                    .collect();
5469                Some(Environment::List { span, entries })
5470            }
5471            Some(YamlNode::Mapping(mapping)) => {
5472                let span = span_from_position(self.source_id, mapping.byte_range());
5473                let entries = self.parse_environment_map(mapping);
5474                Some(Environment::Map { span, entries })
5475            }
5476            _ => {
5477                self.expected(EXPECTED_FIELD_FORM, field, "environment must be a sequence or mapping");
5478                None
5479            }
5480        }
5481    }
5482
5483    fn parse_environment_map(&mut self, mapping: &Mapping) -> Vec<EnvironmentMapEntry> {
5484        let mut entries = Vec::new();
5485        let mut seen = BTreeMap::new();
5486        for field in self.fields(mapping) {
5487            if self.record_duplicate(&mut seen, &field) {
5488                continue;
5489            }
5490            let value = self.parse_compose_scalar(&field, "environment values must be scalars");
5491            if let Some(value) = value {
5492                entries.push(EnvironmentMapEntry::new(field.name, value, field.span));
5493            }
5494        }
5495        entries
5496    }
5497
5498    fn parse_environment_files(&mut self, field: &ParsedField) -> Vec<EnvironmentFile> {
5499        match field.value.as_ref() {
5500            Some(YamlNode::Scalar(_)) => self
5501                .parse_string(field, "service environment-file path")
5502                .map(EnvironmentFile::Short)
5503                .into_iter()
5504                .collect(),
5505            Some(YamlNode::Sequence(sequence)) => sequence
5506                .values()
5507                .filter_map(|value| match value {
5508                    YamlNode::Scalar(scalar) => {
5509                        let span = span_from_position(self.source_id, scalar.byte_range());
5510                        Some(EnvironmentFile::Short(Located::new(
5511                            scalar_string_from_source(&self.source, &scalar),
5512                            span,
5513                        )))
5514                    }
5515                    YamlNode::Mapping(mapping) => Some(EnvironmentFile::Long(Box::new(
5516                        self.parse_long_environment_file(&mapping),
5517                    ))),
5518                    _ => {
5519                        self.diagnostics.push(
5520                            Diagnostic::new(
5521                                ENVIRONMENT_FILE_EXPECTED_FORM,
5522                                Severity::Error,
5523                                "env_file item must use scalar short syntax or mapping long syntax",
5524                            )
5525                            .with_label(DiagnosticLabel::primary(
5526                                node_span(self.source_id, &value).unwrap_or(field.span),
5527                                "invalid environment-file item",
5528                            )),
5529                        );
5530                        None
5531                    }
5532                })
5533                .collect(),
5534            _ => {
5535                self.expected(
5536                    EXPECTED_FIELD_FORM,
5537                    field,
5538                    "env_file must be a scalar path or a sequence of short/long entries",
5539                );
5540                Vec::new()
5541            }
5542        }
5543    }
5544
5545    fn parse_long_environment_file(&mut self, mapping: &Mapping) -> LongEnvironmentFile {
5546        let span = span_from_position(self.source_id, mapping.byte_range());
5547        let mut environment_file = LongEnvironmentFile::new(span);
5548        let mut seen = BTreeMap::new();
5549        for field in self.fields(mapping) {
5550            let duplicate = self.record_duplicate(&mut seen, &field);
5551            match field.name.value.as_str() {
5552                "path" if !duplicate => self
5553                    .parse_string(&field, "environment-file path")
5554                    .into_iter()
5555                    .for_each(|value| environment_file.set_path(value)),
5556                "required" if !duplicate => self
5557                    .parse_boolean(&field, "environment-file required option")
5558                    .into_iter()
5559                    .for_each(|value| environment_file.set_required(value)),
5560                "format" if !duplicate => {
5561                    if let Some(raw) = self.parse_string(&field, "environment-file format") {
5562                        let format = EnvironmentFileFormat::parse(raw);
5563                        if !format.is_valid() {
5564                            self.diagnostics.push(
5565                                Diagnostic::new(
5566                                    ENVIRONMENT_FILE_INVALID_FORMAT,
5567                                    Severity::Error,
5568                                    "environment-file format must be `raw` or interpolation",
5569                                )
5570                                .with_label(DiagnosticLabel::primary(format.raw().span(), "invalid format")),
5571                            );
5572                        }
5573                        environment_file.set_format(format);
5574                    }
5575                }
5576                name if name.starts_with("x-") => environment_file.push_extension(field.reference()),
5577                _ if duplicate => {}
5578                _ => environment_file.push_unknown(field.reference()),
5579            }
5580        }
5581        if environment_file.path().is_none() {
5582            self.missing(
5583                ENVIRONMENT_FILE_MISSING_PATH,
5584                span,
5585                "long environment-file entry is missing `path`",
5586            );
5587        }
5588        environment_file
5589    }
5590
5591    fn parse_extra_hosts(&mut self, field: &ParsedField) -> Option<ExtraHosts> {
5592        match field.value.as_ref() {
5593            Some(YamlNode::Sequence(sequence)) => {
5594                let span = span_from_position(self.source_id, sequence.byte_range());
5595                let entries = self
5596                    .parse_scalar_nodes(sequence.values(), field.span, "extra_hosts entries must be scalars")
5597                    .into_iter()
5598                    .map(|raw| {
5599                        let entry = ShortExtraHost::parse(raw);
5600                        if !entry.is_complete() {
5601                            self.diagnostics.push(
5602                                Diagnostic::new(
5603                                    EXTRA_HOST_INVALID_ENTRY,
5604                                    Severity::Error,
5605                                    "short extra_hosts entry must contain a hostname and address",
5606                                )
5607                                .with_label(DiagnosticLabel::primary(
5608                                    entry.raw().span(),
5609                                    "missing separator or value",
5610                                )),
5611                            );
5612                        }
5613                        entry
5614                    })
5615                    .collect();
5616                Some(ExtraHosts::Short { span, entries })
5617            }
5618            Some(YamlNode::Mapping(mapping)) => {
5619                let span = span_from_position(self.source_id, mapping.byte_range());
5620                let mut entries = Vec::new();
5621                let mut seen = BTreeMap::new();
5622                for host in self.fields(mapping) {
5623                    if self.record_duplicate(&mut seen, &host) {
5624                        continue;
5625                    }
5626                    if let Some(address) = self.parse_string(&host, "extra host address") {
5627                        let address = Located::new(HostAddress::parse(address.value), address.span);
5628                        entries.push(LongExtraHost::new(host.name, address, host.span));
5629                    }
5630                }
5631                Some(ExtraHosts::Long { span, entries })
5632            }
5633            _ => {
5634                self.expected(EXPECTED_FIELD_FORM, field, "extra_hosts must be a sequence or mapping");
5635                None
5636            }
5637        }
5638    }
5639
5640    fn parse_ulimits(&mut self, field: &ParsedField) -> Option<Ulimits> {
5641        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5642            self.expected(EXPECTED_MAPPING, field, "ulimits must be a mapping");
5643            return None;
5644        };
5645        let span = span_from_position(self.source_id, mapping.byte_range());
5646        let mut entries = Vec::new();
5647        let mut seen = BTreeMap::new();
5648        for limit in self.fields(mapping) {
5649            if self.record_duplicate(&mut seen, &limit) {
5650                continue;
5651            }
5652            if !valid_ulimit_name(limit.name.value()) {
5653                self.diagnostics.push(
5654                    Diagnostic::new(
5655                        ULIMIT_INVALID_NAME,
5656                        Severity::Error,
5657                        "ulimit names must contain only lowercase ASCII letters",
5658                    )
5659                    .with_label(DiagnosticLabel::primary(limit.name.span, "invalid ulimit name")),
5660                );
5661            }
5662            let value = match limit.value.as_ref() {
5663                Some(YamlNode::Scalar(_)) => self.parse_limit_value(&limit, "ulimit value").map(UlimitValue::Single),
5664                Some(YamlNode::Mapping(range)) => Some(UlimitValue::Range(self.parse_ulimit_range(range))),
5665                _ => {
5666                    self.expected(
5667                        EXPECTED_FIELD_FORM,
5668                        &limit,
5669                        "ulimit must be a scalar or soft/hard mapping",
5670                    );
5671                    None
5672                }
5673            };
5674            if let Some(value) = value {
5675                entries.push(Ulimit::new(limit.name, limit.span, value));
5676            }
5677        }
5678        Some(Ulimits::new(span, entries))
5679    }
5680
5681    fn parse_ulimit_range(&mut self, mapping: &Mapping) -> UlimitRange {
5682        let span = span_from_position(self.source_id, mapping.byte_range());
5683        let mut range = UlimitRange::new(span);
5684        let mut seen = BTreeMap::new();
5685        for field in self.fields(mapping) {
5686            let duplicate = self.record_duplicate(&mut seen, &field);
5687            match field.name.value.as_str() {
5688                "soft" if !duplicate => self
5689                    .parse_limit_value(&field, "ulimit soft value")
5690                    .into_iter()
5691                    .for_each(|value| range.set_soft(value)),
5692                "hard" if !duplicate => self
5693                    .parse_limit_value(&field, "ulimit hard value")
5694                    .into_iter()
5695                    .for_each(|value| range.set_hard(value)),
5696                name if name.starts_with("x-") => range.push_extension(field.reference()),
5697                _ if duplicate => {}
5698                _ => range.push_unknown(field.reference()),
5699            }
5700        }
5701        if range.soft().is_none() {
5702            self.missing(
5703                ULIMIT_MISSING_RANGE_MEMBER,
5704                span,
5705                "ulimit range is missing required `soft`",
5706            );
5707        }
5708        if range.hard().is_none() {
5709            self.missing(
5710                ULIMIT_MISSING_RANGE_MEMBER,
5711                span,
5712                "ulimit range is missing required `hard`",
5713            );
5714        }
5715        range
5716    }
5717
5718    fn parse_limit_value(&mut self, field: &ParsedField, description: &str) -> Option<Located<LimitValue>> {
5719        let value = self.parse_string(field, description)?;
5720        let parsed = LimitValue::parse(value.value);
5721        if !parsed.is_valid() {
5722            self.diagnostics.push(
5723                Diagnostic::new(
5724                    ULIMIT_INVALID_VALUE,
5725                    Severity::Error,
5726                    "ulimit must be -1, a non-negative integer, or an interpolation expression",
5727                )
5728                .with_label(DiagnosticLabel::primary(value.span, "invalid ulimit value")),
5729            );
5730        }
5731        Some(Located::new(parsed, value.span))
5732    }
5733
5734    fn parse_depends_on(&mut self, field: &ParsedField) -> Option<DependsOn> {
5735        match field.value.as_ref() {
5736            Some(YamlNode::Sequence(sequence)) => {
5737                let span = span_from_position(self.source_id, sequence.byte_range());
5738                let services = self.parse_scalar_nodes(
5739                    sequence.values(),
5740                    field.span,
5741                    "dependency service names must be scalars",
5742                );
5743                Some(DependsOn::Short { span, services })
5744            }
5745            Some(YamlNode::Mapping(mapping)) => {
5746                let span = span_from_position(self.source_id, mapping.byte_range());
5747                let mut services = Vec::new();
5748                let mut seen = BTreeMap::new();
5749                for dependency in self.fields(mapping) {
5750                    if self.record_duplicate(&mut seen, &dependency) {
5751                        continue;
5752                    }
5753                    let mut parsed = ServiceDependency::new(dependency.name.clone(), dependency.span);
5754                    if Self::field_is_null(&dependency) {
5755                        services.push(parsed);
5756                        continue;
5757                    }
5758                    let Some(options) = dependency.value.as_ref().and_then(YamlNode::as_mapping) else {
5759                        self.expected(
5760                            EXPECTED_MAPPING,
5761                            &dependency,
5762                            "long dependency options must be a mapping or null",
5763                        );
5764                        continue;
5765                    };
5766                    let mut option_seen = BTreeMap::new();
5767                    for option in self.fields(options) {
5768                        let duplicate = self.record_duplicate(&mut option_seen, &option);
5769                        match option.name.value.as_str() {
5770                            "condition" if !duplicate => {
5771                                if let Some(value) = self.parse_string(&option, "dependency condition") {
5772                                    let condition = DependencyCondition::parse(value.value);
5773                                    if !condition.is_known() {
5774                                        self.diagnostics.push(
5775                                            Diagnostic::new(
5776                                                DEPENDENCY_INVALID_CONDITION,
5777                                                Severity::Error,
5778                                                "dependency condition is not defined by Compose",
5779                                            )
5780                                            .with_label(
5781                                                DiagnosticLabel::primary(value.span, "unknown dependency condition"),
5782                                            ),
5783                                        );
5784                                    }
5785                                    parsed.set_condition(Located::new(condition, value.span));
5786                                }
5787                            }
5788                            "restart" if !duplicate => self
5789                                .parse_boolean(&option, "dependency restart")
5790                                .into_iter()
5791                                .for_each(|value| parsed.set_restart(value)),
5792                            "required" if !duplicate => self
5793                                .parse_boolean(&option, "dependency required")
5794                                .into_iter()
5795                                .for_each(|value| parsed.set_required(value)),
5796                            name if name.starts_with("x-") => parsed.push_extension(option.reference()),
5797                            _ if duplicate => {}
5798                            _ => parsed.push_unknown(option.reference()),
5799                        }
5800                    }
5801                    services.push(parsed);
5802                }
5803                Some(DependsOn::Long { span, services })
5804            }
5805            _ => {
5806                self.expected(EXPECTED_FIELD_FORM, field, "depends_on must be a sequence or mapping");
5807                None
5808            }
5809        }
5810    }
5811
5812    fn parse_healthcheck(&mut self, field: &ParsedField) -> Option<Healthcheck> {
5813        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5814            self.expected(EXPECTED_MAPPING, field, "healthcheck must be a mapping");
5815            return None;
5816        };
5817        let span = span_from_position(self.source_id, mapping.byte_range());
5818        let mut healthcheck = Healthcheck::new(span);
5819        let mut seen = BTreeMap::new();
5820        for option in self.fields(mapping) {
5821            let duplicate = self.record_duplicate(&mut seen, &option);
5822            match option.name.value.as_str() {
5823                "test" if !duplicate => self
5824                    .parse_healthcheck_test(&option)
5825                    .into_iter()
5826                    .for_each(|value| healthcheck.set_test(value)),
5827                "interval" if !duplicate => self
5828                    .parse_healthcheck_duration(&option, "healthcheck interval")
5829                    .into_iter()
5830                    .for_each(|value| healthcheck.set_interval(value)),
5831                "timeout" if !duplicate => self
5832                    .parse_healthcheck_duration(&option, "healthcheck timeout")
5833                    .into_iter()
5834                    .for_each(|value| healthcheck.set_timeout(value)),
5835                "retries" if !duplicate => self
5836                    .parse_healthcheck_retries(&option)
5837                    .into_iter()
5838                    .for_each(|value| healthcheck.set_retries(value)),
5839                "start_period" if !duplicate => self
5840                    .parse_healthcheck_duration(&option, "healthcheck start period")
5841                    .into_iter()
5842                    .for_each(|value| healthcheck.set_start_period(value)),
5843                "start_interval" if !duplicate => self
5844                    .parse_healthcheck_duration(&option, "healthcheck start interval")
5845                    .into_iter()
5846                    .for_each(|value| healthcheck.set_start_interval(value)),
5847                "disable" if !duplicate => self
5848                    .parse_boolean(&option, "healthcheck disable")
5849                    .into_iter()
5850                    .for_each(|value| healthcheck.set_disable(value)),
5851                name if name.starts_with("x-") => healthcheck.push_extension(option.reference()),
5852                _ if duplicate => {}
5853                _ => healthcheck.push_unknown(option.reference()),
5854            }
5855        }
5856        Some(healthcheck)
5857    }
5858
5859    fn parse_healthcheck_duration(
5860        &mut self,
5861        field: &ParsedField,
5862        description: &str,
5863    ) -> Option<Located<HealthcheckDuration>> {
5864        let value = self.parse_string(field, description)?;
5865        let duration = HealthcheckDuration::parse(value.value);
5866        if !duration.is_valid() {
5867            self.diagnostics.push(
5868                Diagnostic::new(
5869                    HEALTHCHECK_INVALID_DURATION,
5870                    Severity::Error,
5871                    "healthcheck duration must use Compose duration syntax or interpolation",
5872                )
5873                .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck duration")),
5874            );
5875        }
5876        Some(Located::new(duration, value.span))
5877    }
5878
5879    fn parse_healthcheck_retries(&mut self, field: &ParsedField) -> Option<Located<HealthcheckRetries>> {
5880        let value = self.parse_string(field, "healthcheck retries")?;
5881        let retries = HealthcheckRetries::parse(value.value);
5882        if !retries.is_valid() {
5883            self.diagnostics.push(
5884                Diagnostic::new(
5885                    HEALTHCHECK_INVALID_RETRIES,
5886                    Severity::Error,
5887                    "healthcheck retries must be a non-negative integer or interpolation expression",
5888                )
5889                .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck retry count")),
5890            );
5891        }
5892        Some(Located::new(retries, value.span))
5893    }
5894
5895    fn parse_healthcheck_test(&mut self, field: &ParsedField) -> Option<HealthcheckTest> {
5896        match field.value.as_ref() {
5897            Some(YamlNode::Scalar(_)) => self
5898                .parse_string(field, "healthcheck test")
5899                .map(HealthcheckTest::String),
5900            Some(YamlNode::Sequence(sequence)) => {
5901                let span = span_from_position(self.source_id, sequence.byte_range());
5902                let values =
5903                    self.parse_scalar_nodes(sequence.values(), field.span, "healthcheck test items must be scalars");
5904                let kind = values.first().map(|value| HealthcheckTestKind::parse(value.value()));
5905                if kind.is_none()
5906                    || kind == Some(HealthcheckTestKind::Other)
5907                    || (kind == Some(HealthcheckTestKind::None) && values.len() != 1)
5908                {
5909                    self.diagnostics.push(
5910                        Diagnostic::new(
5911                            HEALTHCHECK_INVALID_TEST,
5912                            Severity::Error,
5913                            "healthcheck list must begin with NONE, CMD, or CMD-SHELL",
5914                        )
5915                        .with_label(DiagnosticLabel::primary(span, "invalid healthcheck command mode")),
5916                    );
5917                }
5918                Some(HealthcheckTest::List { span, kind, values })
5919            }
5920            _ => {
5921                self.expected(
5922                    EXPECTED_FIELD_FORM,
5923                    field,
5924                    "healthcheck test must be a scalar or sequence",
5925                );
5926                None
5927            }
5928        }
5929    }
5930
5931    fn parse_build(&mut self, field: &ParsedField) -> Option<Build> {
5932        match field.value.as_ref() {
5933            Some(YamlNode::Scalar(_)) => self.parse_string(field, "build context").map(Build::Context),
5934            Some(YamlNode::Mapping(mapping)) => {
5935                let mut definition = BuildDefinition::new(span_from_position(self.source_id, mapping.byte_range()));
5936                let mut seen = BTreeMap::new();
5937                let (mut dockerfile, mut dockerfile_inline) = (None, None);
5938                for option in self.fields(mapping) {
5939                    if self.record_duplicate(&mut seen, &option) {
5940                        continue;
5941                    }
5942                    if let Some(kind) = BuildFieldKind::from_name(option.name.value()) {
5943                        definition.push_field(BuildField::new(kind, option.reference()));
5944                        match kind {
5945                            BuildFieldKind::AdditionalContexts => {
5946                                definition.set_additional_contexts(self.parse_build_additional_contexts(&option));
5947                            }
5948                            BuildFieldKind::Args => {
5949                                if let Some(args) = self.parse_build_args(&option) {
5950                                    definition.set_args(args);
5951                                }
5952                            }
5953                            BuildFieldKind::CacheFrom | BuildFieldKind::CacheTo => {
5954                                self.set_build_cache_locations(&mut definition, &option, kind);
5955                            }
5956                            BuildFieldKind::Entitlements => {
5957                                if let Some(entitlements) = self.parse_build_entitlements(&option) {
5958                                    definition.set_entitlements(entitlements);
5959                                }
5960                            }
5961                            BuildFieldKind::ExtraHosts => {
5962                                if let Some(extra_hosts) = self.parse_build_extra_hosts(&option) {
5963                                    definition.set_extra_hosts(extra_hosts);
5964                                }
5965                            }
5966                            BuildFieldKind::Context => {
5967                                if let Some(context) = self.parse_string(&option, "build context") {
5968                                    definition.set_context(context);
5969                                }
5970                            }
5971                            BuildFieldKind::Dockerfile => {
5972                                dockerfile = Some(self.parse_build_dockerfile(&mut definition, &option));
5973                            }
5974                            BuildFieldKind::DockerfileInline => {
5975                                self.set_build_dockerfile_inline(&mut definition, &option, &mut dockerfile_inline);
5976                            }
5977                            BuildFieldKind::Target => {
5978                                if let Some(target) = self.parse_string(&option, "build target") {
5979                                    definition.set_target(target);
5980                                }
5981                            }
5982                            BuildFieldKind::Network => {
5983                                if let Some(network) = self.parse_string(&option, "build network") {
5984                                    definition.set_network(network);
5985                                }
5986                            }
5987                            BuildFieldKind::Isolation => self.set_build_isolation(&mut definition, &option),
5988                            BuildFieldKind::Platforms => {
5989                                if let Some(platforms) = self.parse_build_platforms(&option) {
5990                                    definition.set_platforms(platforms);
5991                                }
5992                            }
5993                            BuildFieldKind::NoCache => self.set_build_no_cache(&mut definition, &option),
5994                            BuildFieldKind::NoCacheFilter => self.set_build_no_cache_filter(&mut definition, &option),
5995                            BuildFieldKind::Privileged => self.set_build_privileged(&mut definition, &option),
5996                            BuildFieldKind::Sbom => self.set_build_sbom(&mut definition, &option),
5997                            BuildFieldKind::Provenance => self.set_build_provenance(&mut definition, &option),
5998                            BuildFieldKind::Pull => {
5999                                if let Some(pull) = self.parse_boolean(&option, "build pull") {
6000                                    definition.set_pull(pull);
6001                                }
6002                            }
6003                            BuildFieldKind::ShmSize => self.set_build_shm_size(&mut definition, &option),
6004                            BuildFieldKind::Tags => {
6005                                if let Some(tags) = self.parse_build_tags(&option) {
6006                                    definition.set_tags(tags);
6007                                }
6008                            }
6009                            BuildFieldKind::Labels => {
6010                                if let Some(labels) = self.parse_labels(&option) {
6011                                    definition.set_labels(labels);
6012                                }
6013                            }
6014                            BuildFieldKind::Secrets => self
6015                                .parse_secret_grants(&option)
6016                                .into_iter()
6017                                .for_each(|secrets| definition.set_secrets(secrets)),
6018                            BuildFieldKind::Ssh => self.set_build_ssh(&mut definition, &option),
6019                            BuildFieldKind::Ulimits => self.set_build_ulimits(&mut definition, &option),
6020                        }
6021                    } else if option.name.value().starts_with("x-") {
6022                        definition.push_extension(option.reference());
6023                    } else {
6024                        definition.push_unknown(option.reference());
6025                    }
6026                }
6027                self.report_build_dockerfile_conflict(dockerfile, dockerfile_inline);
6028                Some(Build::Definition(definition))
6029            }
6030            _ => self.invalid_build_form(field),
6031        }
6032    }
6033
6034    fn invalid_build_form(&mut self, field: &ParsedField) -> Option<Build> {
6035        self.expected(EXPECTED_FIELD_FORM, field, "build must be a scalar context or mapping");
6036        None
6037    }
6038
6039    fn report_build_dockerfile_conflict(
6040        &mut self,
6041        dockerfile: Option<FieldReference>,
6042        dockerfile_inline: Option<FieldReference>,
6043    ) {
6044        let (Some(dockerfile), Some(dockerfile_inline)) = (dockerfile, dockerfile_inline) else {
6045            return;
6046        };
6047        self.diagnostics.push(
6048            Diagnostic::new(
6049                BUILD_DOCKERFILE_INLINE_CONFLICT,
6050                Severity::Error,
6051                "build `dockerfile` and `dockerfile_inline` are mutually exclusive",
6052            )
6053            .with_label(DiagnosticLabel::primary(dockerfile.span(), "dockerfile retained"))
6054            .with_label(DiagnosticLabel::secondary(
6055                dockerfile_inline.span(),
6056                "dockerfile_inline retained",
6057            )),
6058        );
6059    }
6060
6061    fn set_build_shm_size(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
6062        if let Some(shm_size) = self.parse_shm_size(field) {
6063            definition.set_shm_size(shm_size);
6064        }
6065    }
6066
6067    fn set_build_ulimits(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
6068        if let Some(ulimits) = self.parse_ulimits(field) {
6069            definition.set_ulimits(ulimits);
6070        }
6071    }
6072
6073    fn parse_build_tags(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
6074        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6075            self.expected(EXPECTED_SEQUENCE, field, "build tags must be a sequence of scalars");
6076            return None;
6077        };
6078        Some(self.parse_scalar_nodes(
6079            sequence.values(),
6080            field.span,
6081            "build tag entries must be non-null scalars",
6082        ))
6083    }
6084
6085    fn parse_build_entitlements(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
6086        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6087            self.expected(
6088                EXPECTED_SEQUENCE,
6089                field,
6090                "build entitlements must be a sequence of string scalars",
6091            );
6092            return None;
6093        };
6094        Some(self.parse_string_scalar_nodes(
6095            sequence.values(),
6096            field.span,
6097            "build entitlement entries must be string scalars",
6098        ))
6099    }
6100
6101    fn parse_build_cache_locations(&mut self, field: &ParsedField, name: &str) -> Option<Vec<Located<String>>> {
6102        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6103            self.expected(
6104                EXPECTED_SEQUENCE,
6105                field,
6106                format!("build {name} must be a sequence of string scalars"),
6107            );
6108            return None;
6109        };
6110        Some(self.parse_string_scalar_nodes(
6111            sequence.values(),
6112            field.span,
6113            format!("build {name} entries must be string scalars"),
6114        ))
6115    }
6116
6117    fn set_build_cache_locations(
6118        &mut self,
6119        definition: &mut BuildDefinition,
6120        field: &ParsedField,
6121        kind: BuildFieldKind,
6122    ) {
6123        let name = if kind == BuildFieldKind::CacheFrom {
6124            "cache_from"
6125        } else {
6126            "cache_to"
6127        };
6128        if let Some(locations) = self.parse_build_cache_locations(field, name) {
6129            if kind == BuildFieldKind::CacheFrom {
6130                definition.set_cache_from(locations);
6131            } else {
6132                definition.set_cache_to(locations);
6133            }
6134        }
6135    }
6136
6137    fn parse_build_extra_hosts(&mut self, field: &ParsedField) -> Option<BuildExtraHosts> {
6138        match field.value.as_ref() {
6139            Some(YamlNode::Sequence(sequence)) => {
6140                let span = span_from_position(self.source_id, sequence.byte_range());
6141                let mut values = Vec::new();
6142                let mut seen = BTreeSet::new();
6143                for node in sequence.values() {
6144                    let YamlNode::Scalar(scalar) = node else {
6145                        self.unsupported_sequence_item(
6146                            BUILD_EXTRA_HOSTS_EXPECTED_STRING,
6147                            &node,
6148                            field.span,
6149                            "build extra_hosts list entries must be string scalars",
6150                        );
6151                        continue;
6152                    };
6153                    if !matches!(
6154                        ScalarValue::from_scalar(&scalar).scalar_type(),
6155                        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
6156                    ) {
6157                        self.unsupported_sequence_item(
6158                            BUILD_EXTRA_HOSTS_EXPECTED_STRING,
6159                            &YamlNode::Scalar(scalar),
6160                            field.span,
6161                            "build extra_hosts list entries must be string scalars",
6162                        );
6163                        continue;
6164                    }
6165                    let raw = scalar_string_from_source(&self.source, &scalar);
6166                    let item_span = span_from_position(self.source_id, scalar.byte_range());
6167                    if !seen.insert(raw.clone()) {
6168                        self.diagnostics.push(
6169                            Diagnostic::new(
6170                                BUILD_EXTRA_HOSTS_DUPLICATE_ITEM,
6171                                Severity::Error,
6172                                "build extra_hosts list entries must be unique raw strings",
6173                            )
6174                            .with_label(DiagnosticLabel::primary(item_span, "duplicate entry retained")),
6175                        );
6176                    }
6177                    values.push(Located::new(raw, item_span));
6178                }
6179                Some(BuildExtraHosts::List { span, values })
6180            }
6181            Some(YamlNode::Mapping(mapping)) => {
6182                let span = span_from_position(self.source_id, mapping.byte_range());
6183                let mut entries = Vec::new();
6184                let mut seen = BTreeMap::new();
6185                for entry in self.fields(mapping) {
6186                    if self.record_duplicate(&mut seen, &entry) {
6187                        continue;
6188                    }
6189                    let Some(addresses) = self.parse_build_extra_host_addresses(&entry) else {
6190                        continue;
6191                    };
6192                    entries.push(BuildExtraHostEntry::new(entry.name, addresses, entry.span));
6193                }
6194                Some(BuildExtraHosts::Map { span, entries })
6195            }
6196            _ => {
6197                self.expected(
6198                    BUILD_EXTRA_HOSTS_EXPECTED_FORM,
6199                    field,
6200                    "build extra_hosts must be a sequence or mapping",
6201                );
6202                None
6203            }
6204        }
6205    }
6206
6207    fn parse_build_extra_host_addresses(&mut self, field: &ParsedField) -> Option<BuildExtraHostAddresses> {
6208        match field.value.as_ref() {
6209            Some(YamlNode::Scalar(scalar)) => {
6210                if !matches!(
6211                    ScalarValue::from_scalar(scalar).scalar_type(),
6212                    ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
6213                ) {
6214                    self.expected(
6215                        BUILD_EXTRA_HOSTS_EXPECTED_STRING,
6216                        field,
6217                        "build extra_hosts mapping addresses must be string scalars or sequences of string scalars",
6218                    );
6219                    return None;
6220                }
6221                let span = span_from_position(self.source_id, scalar.byte_range());
6222                Some(BuildExtraHostAddresses::Scalar(Located::new(
6223                    scalar_string_from_source(&self.source, scalar),
6224                    span,
6225                )))
6226            }
6227            Some(YamlNode::Sequence(sequence)) => {
6228                let span = span_from_position(self.source_id, sequence.byte_range());
6229                let values = self.parse_string_scalar_nodes(
6230                    sequence.values(),
6231                    field.span,
6232                    "build extra_hosts mapping address lists must contain string scalars",
6233                );
6234                Some(BuildExtraHostAddresses::List { span, values })
6235            }
6236            _ => {
6237                self.expected(
6238                    BUILD_EXTRA_HOSTS_EXPECTED_STRING,
6239                    field,
6240                    "build extra_hosts mapping addresses must be string scalars or sequences of string scalars",
6241                );
6242                None
6243            }
6244        }
6245    }
6246
6247    fn parse_build_additional_contexts(&mut self, field: &ParsedField) -> Option<BuildAdditionalContexts> {
6248        match field.value.as_ref() {
6249            Some(YamlNode::Sequence(sequence)) => {
6250                let span = span_from_position(self.source_id, sequence.byte_range());
6251                let values = self.parse_string_scalar_nodes(
6252                    sequence.values(),
6253                    field.span,
6254                    "build additional context list entries must be string scalars",
6255                );
6256                Some(BuildAdditionalContexts::List { span, values })
6257            }
6258            Some(YamlNode::Mapping(mapping)) => {
6259                let span = span_from_position(self.source_id, mapping.byte_range());
6260                let entries = self.parse_scalar_mapping(field, "build additional contexts");
6261                Some(BuildAdditionalContexts::Map { span, entries })
6262            }
6263            _ => {
6264                self.expected(
6265                    EXPECTED_FIELD_FORM,
6266                    field,
6267                    "build additional_contexts must be a sequence or mapping",
6268                );
6269                None
6270            }
6271        }
6272    }
6273
6274    fn parse_build_platforms(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
6275        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6276            self.expected(
6277                EXPECTED_SEQUENCE,
6278                field,
6279                "build platforms must be a sequence of scalars",
6280            );
6281            return None;
6282        };
6283        Some(self.parse_scalar_nodes(
6284            sequence.values(),
6285            field.span,
6286            "build platform entries must be non-null scalars",
6287        ))
6288    }
6289
6290    fn parse_build_args(&mut self, field: &ParsedField) -> Option<BuildArgs> {
6291        match field.value.as_ref() {
6292            Some(YamlNode::Sequence(sequence)) => {
6293                let span = span_from_position(self.source_id, sequence.byte_range());
6294                let values = self.parse_string_scalar_nodes(
6295                    sequence.values(),
6296                    field.span,
6297                    "build argument list entries must be string scalars",
6298                );
6299                Some(BuildArgs::List { span, values })
6300            }
6301            Some(YamlNode::Mapping(mapping)) => {
6302                let span = span_from_position(self.source_id, mapping.byte_range());
6303                let entries = self.parse_scalar_mapping(field, "build arguments");
6304                Some(BuildArgs::Map { span, entries })
6305            }
6306            _ => {
6307                self.expected(EXPECTED_FIELD_FORM, field, "build args must be a sequence or mapping");
6308                None
6309            }
6310        }
6311    }
6312
6313    fn parse_build_ssh(&mut self, field: &ParsedField) -> Option<BuildSsh> {
6314        match field.value.as_ref() {
6315            Some(YamlNode::Sequence(sequence)) => {
6316                let span = span_from_position(self.source_id, sequence.byte_range());
6317                let mut values = Vec::new();
6318                let mut seen = BTreeSet::new();
6319                for node in sequence.values() {
6320                    let YamlNode::Scalar(scalar) = node else {
6321                        self.unsupported_sequence_item(
6322                            BUILD_SSH_EXPECTED_FORM,
6323                            &node,
6324                            field.span,
6325                            "build ssh list entries must be string scalars",
6326                        );
6327                        continue;
6328                    };
6329                    if !matches!(
6330                        ScalarValue::from_scalar(&scalar).scalar_type(),
6331                        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
6332                    ) {
6333                        self.unsupported_sequence_item(
6334                            BUILD_SSH_EXPECTED_FORM,
6335                            &YamlNode::Scalar(scalar),
6336                            field.span,
6337                            "build ssh list entries must be string scalars",
6338                        );
6339                        continue;
6340                    }
6341                    let value = scalar_string_from_source(&self.source, &scalar);
6342                    let span = span_from_position(self.source_id, scalar.byte_range());
6343                    if !seen.insert(value.clone()) {
6344                        self.diagnostics.push(
6345                            Diagnostic::new(
6346                                BUILD_SSH_DUPLICATE_ITEM,
6347                                Severity::Error,
6348                                "build ssh list entries must be unique",
6349                            )
6350                            .with_label(DiagnosticLabel::primary(span, "duplicate SSH entry retained")),
6351                        );
6352                    }
6353                    values.push(Located::new(value, span));
6354                }
6355                Some(BuildSsh::list(span, values))
6356            }
6357            Some(YamlNode::Mapping(mapping)) => {
6358                let span = span_from_position(self.source_id, mapping.byte_range());
6359                let mut entries = Vec::new();
6360                let mut seen = BTreeMap::new();
6361                for entry in self.fields(mapping) {
6362                    if self.record_duplicate(&mut seen, &entry) {
6363                        continue;
6364                    }
6365                    let key_span = entry.name.span;
6366                    let Some(value) = entry.value.as_ref() else {
6367                        entries.push(KeyValueEntry::new(
6368                            entry.name,
6369                            Located::new(ComposeScalar::Null, key_span),
6370                            entry.span,
6371                        ));
6372                        continue;
6373                    };
6374                    let Some(scalar) = value.as_scalar() else {
6375                        self.expected(
6376                            BUILD_SSH_EXPECTED_FORM,
6377                            &entry,
6378                            "build ssh mapping values must be scalars or null",
6379                        );
6380                        continue;
6381                    };
6382                    let scalar_span = span_from_position(self.source_id, scalar.byte_range());
6383                    let scalar_value = ScalarValue::from_scalar(scalar);
6384                    let value = match scalar_value.scalar_type() {
6385                        ScalarType::Null => ComposeScalar::Null,
6386                        ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
6387                        ScalarType::Integer | ScalarType::Float => {
6388                            ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
6389                        }
6390                        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
6391                            ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
6392                        }
6393                    };
6394                    entries.push(KeyValueEntry::new(
6395                        entry.name,
6396                        Located::new(value, scalar_span),
6397                        entry.span,
6398                    ));
6399                }
6400                Some(BuildSsh::map(span, entries))
6401            }
6402            _ => {
6403                self.expected(
6404                    BUILD_SSH_EXPECTED_FORM,
6405                    field,
6406                    "build ssh must be a sequence or mapping",
6407                );
6408                None
6409            }
6410        }
6411    }
6412
6413    fn set_build_ssh(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
6414        if let Some(ssh) = self.parse_build_ssh(field) {
6415            definition.set_ssh(ssh);
6416        }
6417    }
6418
6419    fn parse_deploy(&mut self, field: &ParsedField) -> Option<DeployDefinition> {
6420        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6421            self.expected(EXPECTED_MAPPING, field, "deploy must be a mapping");
6422            return None;
6423        };
6424        let span = span_from_position(self.source_id, mapping.byte_range());
6425        let mut definition = DeployDefinition::new(span);
6426        let mut seen = BTreeMap::new();
6427        for option in self.fields(mapping) {
6428            let duplicate = self.record_duplicate(&mut seen, &option);
6429            if duplicate {
6430                continue;
6431            }
6432            if let Some(kind) = DeployFieldKind::from_name(option.name.value()) {
6433                definition.push_field(DeployField::new(kind, option.reference()));
6434                match kind {
6435                    DeployFieldKind::EndpointMode => self.set_deploy_endpoint_mode(&mut definition, &option),
6436                    DeployFieldKind::Labels => self
6437                        .parse_labels(&option)
6438                        .into_iter()
6439                        .for_each(|labels| definition.set_labels(labels)),
6440                    DeployFieldKind::Mode => self.set_deploy_mode(&mut definition, &option),
6441                    DeployFieldKind::Placement => self
6442                        .parse_deploy_placement(&option)
6443                        .into_iter()
6444                        .for_each(|value| definition.set_placement(value)),
6445                    DeployFieldKind::Replicas => self.set_deploy_replicas(&mut definition, &option),
6446                    DeployFieldKind::Resources => self
6447                        .parse_deploy_resources(&option)
6448                        .into_iter()
6449                        .for_each(|value| definition.set_resources(value)),
6450                    DeployFieldKind::RestartPolicy => self
6451                        .parse_deploy_restart_policy(&option)
6452                        .into_iter()
6453                        .for_each(|value| definition.set_restart_policy(value)),
6454                    DeployFieldKind::RollbackConfig => self
6455                        .parse_deploy_rollback_config(&option)
6456                        .into_iter()
6457                        .for_each(|value| definition.set_rollback_config(value)),
6458                    DeployFieldKind::UpdateConfig => self
6459                        .parse_deploy_update_config(&option)
6460                        .into_iter()
6461                        .for_each(|value| definition.set_update_config(value)),
6462                }
6463            } else if option.name.value().starts_with("x-") {
6464                definition.push_extension(option.reference());
6465            } else {
6466                definition.push_unknown(option.reference());
6467            }
6468        }
6469        Some(definition)
6470    }
6471
6472    fn set_deploy_endpoint_mode(&mut self, definition: &mut DeployDefinition, field: &ParsedField) {
6473        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6474            self.expected(
6475                EXPECTED_SCALAR,
6476                field,
6477                "deploy endpoint_mode must be a YAML string scalar",
6478            );
6479            return;
6480        };
6481        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
6482            self.expected(
6483                EXPECTED_SCALAR,
6484                field,
6485                "deploy endpoint_mode must be a YAML string scalar",
6486            );
6487            return;
6488        }
6489        let endpoint_mode = Located::new(
6490            DeployEndpointMode::parse(scalar_string_from_source(&self.source, scalar)),
6491            span_from_position(self.source_id, scalar.byte_range()),
6492        );
6493        if !endpoint_mode.value().is_documented() {
6494            self.diagnostics.push(
6495                Diagnostic::new(
6496                    DEPLOY_ENDPOINT_MODE_PORTABILITY,
6497                    Severity::Warning,
6498                    "deploy endpoint_mode is outside Compose's documented portable values",
6499                )
6500                .with_label(DiagnosticLabel::primary(
6501                    endpoint_mode.span(),
6502                    "retained provider-specific endpoint mode",
6503                )),
6504            );
6505        }
6506        definition.set_endpoint_mode(endpoint_mode);
6507    }
6508
6509    fn set_deploy_mode(&mut self, definition: &mut DeployDefinition, field: &ParsedField) {
6510        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6511            self.expected(EXPECTED_SCALAR, field, "deploy mode must be a YAML string scalar");
6512            return;
6513        };
6514        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
6515            self.expected(EXPECTED_SCALAR, field, "deploy mode must be a YAML string scalar");
6516            return;
6517        }
6518        let mode = Located::new(
6519            DeployMode::parse(scalar_string_from_source(&self.source, scalar)),
6520            span_from_position(self.source_id, scalar.byte_range()),
6521        );
6522        if !mode.value().is_documented() {
6523            self.diagnostics.push(
6524                Diagnostic::new(
6525                    DEPLOY_MODE_PORTABILITY,
6526                    Severity::Warning,
6527                    "deploy mode is outside Compose's documented portable values",
6528                )
6529                .with_label(DiagnosticLabel::primary(
6530                    mode.span(),
6531                    "retained provider-specific deploy mode",
6532                )),
6533            );
6534        }
6535        definition.set_mode(mode);
6536    }
6537
6538    fn set_deploy_replicas(&mut self, definition: &mut DeployDefinition, field: &ParsedField) {
6539        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6540            self.expected(
6541                EXPECTED_SCALAR,
6542                field,
6543                "deploy replicas must be a YAML number or string scalar",
6544            );
6545            return;
6546        };
6547        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
6548            ScalarType::Integer | ScalarType::Float => {
6549                DeployReplicas::YamlNumber(scalar_string_from_source(&self.source, scalar))
6550            }
6551            ScalarType::String => DeployReplicas::String(scalar_string_from_source(&self.source, scalar)),
6552            ScalarType::Boolean | ScalarType::Null | ScalarType::Timestamp | ScalarType::Regex => {
6553                self.expected(
6554                    EXPECTED_SCALAR,
6555                    field,
6556                    "deploy replicas must be a YAML number or string scalar",
6557                );
6558                return;
6559            }
6560        };
6561        definition.set_replicas(Located::new(
6562            value,
6563            span_from_position(self.source_id, scalar.byte_range()),
6564        ));
6565    }
6566
6567    fn parse_deploy_restart_policy(&mut self, field: &ParsedField) -> Option<DeployRestartPolicy> {
6568        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6569            self.expected(EXPECTED_MAPPING, field, "deploy restart_policy must be a mapping");
6570            return None;
6571        };
6572        let mut policy = DeployRestartPolicy::new(span_from_position(self.source_id, mapping.byte_range()));
6573        let mut seen = BTreeMap::new();
6574        for option in self.fields(mapping) {
6575            if self.record_duplicate(&mut seen, &option) {
6576                continue;
6577            }
6578            match option.name.value().as_str() {
6579                name if name.starts_with("x-") => {
6580                    policy.push_extension(option.reference());
6581                    continue;
6582                }
6583                "condition" | "delay" | "max_attempts" | "window" => {}
6584                _ => {
6585                    policy.push_unknown(option.reference());
6586                    continue;
6587                }
6588            }
6589            let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) else {
6590                self.expected(EXPECTED_SCALAR, &option, "deploy restart-policy members must be scalar");
6591                continue;
6592            };
6593            let span = span_from_position(self.source_id, scalar.byte_range());
6594            match option.name.value().as_str() {
6595                "condition" if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::String => policy
6596                    .set_condition(Located::new(
6597                        DeployRestartCondition::parse(scalar_string_from_source(&self.source, scalar)),
6598                        span,
6599                    )),
6600                "delay" | "window" if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::String => {
6601                    let value = Located::new(
6602                        DeployRestartDuration::new(scalar_string_from_source(&self.source, scalar)),
6603                        span,
6604                    );
6605                    if option.name.value() == "delay" {
6606                        policy.set_delay(value);
6607                    } else {
6608                        policy.set_window(value);
6609                    }
6610                }
6611                "max_attempts" => match ScalarValue::from_scalar(scalar).scalar_type() {
6612                    ScalarType::Integer => policy.set_max_attempts(Located::new(
6613                        DeployRestartMaxAttempts::YamlNumber(scalar_string_from_source(&self.source, scalar)),
6614                        span,
6615                    )),
6616                    ScalarType::String => policy.set_max_attempts(Located::new(
6617                        DeployRestartMaxAttempts::String(scalar_string_from_source(&self.source, scalar)),
6618                        span,
6619                    )),
6620                    _ => self.expected(
6621                        EXPECTED_SCALAR,
6622                        &option,
6623                        "deploy restart-policy max_attempts must be a YAML integer or string scalar",
6624                    ),
6625                },
6626                "condition" | "delay" | "window" => self.expected(
6627                    EXPECTED_SCALAR,
6628                    &option,
6629                    "deploy restart-policy condition, delay, and window must be YAML string scalars",
6630                ),
6631                _ => unreachable!("recognized deploy restart-policy field already matched"),
6632            }
6633        }
6634        Some(policy)
6635    }
6636
6637    fn parse_deploy_update_config(&mut self, field: &ParsedField) -> Option<DeployUpdateConfig> {
6638        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6639            self.expected(EXPECTED_MAPPING, field, "deploy update_config must be a mapping");
6640            return None;
6641        };
6642        let mut config = DeployUpdateConfig::new(span_from_position(self.source_id, mapping.byte_range()));
6643        let mut seen = BTreeMap::new();
6644        for member in self.fields(mapping) {
6645            if self.record_duplicate(&mut seen, &member) {
6646                continue;
6647            }
6648            let parsed = match member.name.value().as_str() {
6649                name if name.starts_with("x-") => {
6650                    config.push_extension(member.reference());
6651                    true
6652                }
6653                "parallelism" => self
6654                    .parse_deploy_update_parallelism(&member)
6655                    .map(|value| {
6656                        config.set_parallelism(value);
6657                    })
6658                    .is_some(),
6659                "delay" => self
6660                    .parse_deploy_update_string(&member, "deploy update_config delay must be a YAML string scalar")
6661                    .map(|value| {
6662                        config.set_delay(value);
6663                    })
6664                    .is_some(),
6665                "monitor" => self
6666                    .parse_deploy_update_string(&member, "deploy update_config monitor must be a YAML string scalar")
6667                    .map(|value| {
6668                        config.set_monitor(value);
6669                    })
6670                    .is_some(),
6671                "failure_action" => self
6672                    .parse_deploy_update_string(
6673                        &member,
6674                        "deploy update_config failure_action must be a YAML string scalar",
6675                    )
6676                    .map(|value| {
6677                        config.set_failure_action(value);
6678                    })
6679                    .is_some(),
6680                "max_failure_ratio" => self
6681                    .parse_deploy_update_max_failure_ratio(&member)
6682                    .map(|value| {
6683                        config.set_max_failure_ratio(value);
6684                    })
6685                    .is_some(),
6686                "order" => self
6687                    .parse_deploy_update_order(&member)
6688                    .map(|value| {
6689                        config.set_order(value);
6690                    })
6691                    .is_some(),
6692                _ => {
6693                    config.push_unknown(member.reference());
6694                    true
6695                }
6696            };
6697            if !parsed {
6698                config.push_unknown(member.reference());
6699            }
6700        }
6701        Some(config)
6702    }
6703
6704    fn parse_deploy_rollback_config(&mut self, field: &ParsedField) -> Option<DeployRollbackConfig> {
6705        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6706            self.expected(EXPECTED_MAPPING, field, "deploy rollback_config must be a mapping");
6707            return None;
6708        };
6709        let mut config = DeployRollbackConfig::new(span_from_position(self.source_id, mapping.byte_range()));
6710        let mut seen = BTreeMap::new();
6711        for member in self.fields(mapping) {
6712            if self.record_duplicate(&mut seen, &member) {
6713                continue;
6714            }
6715            let parsed = match member.name.value().as_str() {
6716                name if name.starts_with("x-") => {
6717                    config.push_extension(member.reference());
6718                    true
6719                }
6720                "parallelism" => self
6721                    .parse_deploy_rollback_parallelism(&member)
6722                    .map(|value| config.set_parallelism(value))
6723                    .is_some(),
6724                "delay" => self
6725                    .parse_deploy_rollback_string(&member, "deploy rollback_config delay must be a YAML string scalar")
6726                    .map(|value| config.set_delay(value))
6727                    .is_some(),
6728                "monitor" => self
6729                    .parse_deploy_rollback_string(
6730                        &member,
6731                        "deploy rollback_config monitor must be a YAML string scalar",
6732                    )
6733                    .map(|value| config.set_monitor(value))
6734                    .is_some(),
6735                "failure_action" => self
6736                    .parse_deploy_rollback_string(
6737                        &member,
6738                        "deploy rollback_config failure_action must be a YAML string scalar",
6739                    )
6740                    .map(|value| config.set_failure_action(value))
6741                    .is_some(),
6742                "max_failure_ratio" => self
6743                    .parse_deploy_rollback_max_failure_ratio(&member)
6744                    .map(|value| config.set_max_failure_ratio(value))
6745                    .is_some(),
6746                "order" => self
6747                    .parse_deploy_rollback_order(&member)
6748                    .map(|value| config.set_order(value))
6749                    .is_some(),
6750                _ => {
6751                    config.push_unknown(member.reference());
6752                    true
6753                }
6754            };
6755            if !parsed {
6756                config.push_unknown(member.reference());
6757            }
6758        }
6759        Some(config)
6760    }
6761
6762    fn parse_deploy_rollback_string(&mut self, field: &ParsedField, message: &'static str) -> Option<Located<String>> {
6763        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6764            self.expected(EXPECTED_SCALAR, field, message);
6765            return None;
6766        };
6767        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
6768            self.expected(EXPECTED_SCALAR, field, message);
6769            return None;
6770        }
6771        Some(Located::new(
6772            scalar_string_from_source(&self.source, scalar),
6773            span_from_position(self.source_id, scalar.byte_range()),
6774        ))
6775    }
6776
6777    fn parse_deploy_rollback_parallelism(&mut self, field: &ParsedField) -> Option<Located<DeployRollbackParallelism>> {
6778        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6779            self.expected(
6780                EXPECTED_SCALAR,
6781                field,
6782                "deploy rollback_config parallelism must be a YAML integer or string scalar",
6783            );
6784            return None;
6785        };
6786        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
6787            ScalarType::Integer => {
6788                DeployRollbackParallelism::YamlInteger(scalar_string_from_source(&self.source, scalar))
6789            }
6790            ScalarType::String => DeployRollbackParallelism::String(scalar_string_from_source(&self.source, scalar)),
6791            _ => {
6792                self.expected(
6793                    EXPECTED_SCALAR,
6794                    field,
6795                    "deploy rollback_config parallelism must be a YAML integer or string scalar",
6796                );
6797                return None;
6798            }
6799        };
6800        Some(Located::new(
6801            value,
6802            span_from_position(self.source_id, scalar.byte_range()),
6803        ))
6804    }
6805
6806    fn parse_deploy_rollback_max_failure_ratio(
6807        &mut self,
6808        field: &ParsedField,
6809    ) -> Option<Located<DeployRollbackMaxFailureRatio>> {
6810        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6811            self.expected(
6812                EXPECTED_SCALAR,
6813                field,
6814                "deploy rollback_config max_failure_ratio must be a YAML number or string scalar",
6815            );
6816            return None;
6817        };
6818        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
6819            ScalarType::Integer | ScalarType::Float => {
6820                DeployRollbackMaxFailureRatio::YamlNumber(scalar_string_from_source(&self.source, scalar))
6821            }
6822            ScalarType::String => {
6823                DeployRollbackMaxFailureRatio::String(scalar_string_from_source(&self.source, scalar))
6824            }
6825            _ => {
6826                self.expected(
6827                    EXPECTED_SCALAR,
6828                    field,
6829                    "deploy rollback_config max_failure_ratio must be a YAML number or string scalar",
6830                );
6831                return None;
6832            }
6833        };
6834        Some(Located::new(
6835            value,
6836            span_from_position(self.source_id, scalar.byte_range()),
6837        ))
6838    }
6839
6840    fn parse_deploy_rollback_order(&mut self, field: &ParsedField) -> Option<Located<DeployRollbackOrder>> {
6841        let raw =
6842            self.parse_deploy_rollback_string(field, "deploy rollback_config order must be a YAML string scalar")?;
6843        let order = DeployRollbackOrder::parse(raw.value().clone());
6844        if !order.is_documented() {
6845            self.diagnostics.push(
6846                Diagnostic::new(
6847                    DEPLOY_ROLLBACK_CONFIG_ORDER_PORTABILITY,
6848                    Severity::Warning,
6849                    "deploy rollback_config order is outside Compose's documented portable values",
6850                )
6851                .with_label(DiagnosticLabel::primary(
6852                    raw.span(),
6853                    "retained provider-specific rollback order",
6854                )),
6855            );
6856        }
6857        Some(Located::new(order, raw.span()))
6858    }
6859
6860    fn parse_deploy_update_string(&mut self, field: &ParsedField, message: &'static str) -> Option<Located<String>> {
6861        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6862            self.expected(EXPECTED_SCALAR, field, message);
6863            return None;
6864        };
6865        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
6866            self.expected(EXPECTED_SCALAR, field, message);
6867            return None;
6868        }
6869        Some(Located::new(
6870            scalar_string_from_source(&self.source, scalar),
6871            span_from_position(self.source_id, scalar.byte_range()),
6872        ))
6873    }
6874
6875    fn parse_deploy_update_parallelism(&mut self, field: &ParsedField) -> Option<Located<DeployUpdateParallelism>> {
6876        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6877            self.expected(
6878                EXPECTED_SCALAR,
6879                field,
6880                "deploy update_config parallelism must be a YAML integer or string scalar",
6881            );
6882            return None;
6883        };
6884        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
6885            ScalarType::Integer => {
6886                DeployUpdateParallelism::YamlInteger(scalar_string_from_source(&self.source, scalar))
6887            }
6888            ScalarType::String => DeployUpdateParallelism::String(scalar_string_from_source(&self.source, scalar)),
6889            _ => {
6890                self.expected(
6891                    EXPECTED_SCALAR,
6892                    field,
6893                    "deploy update_config parallelism must be a YAML integer or string scalar",
6894                );
6895                return None;
6896            }
6897        };
6898        Some(Located::new(
6899            value,
6900            span_from_position(self.source_id, scalar.byte_range()),
6901        ))
6902    }
6903    fn parse_deploy_update_max_failure_ratio(
6904        &mut self,
6905        field: &ParsedField,
6906    ) -> Option<Located<DeployUpdateMaxFailureRatio>> {
6907        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6908            self.expected(
6909                EXPECTED_SCALAR,
6910                field,
6911                "deploy update_config max_failure_ratio must be a YAML number or string scalar",
6912            );
6913            return None;
6914        };
6915        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
6916            ScalarType::Integer | ScalarType::Float => {
6917                DeployUpdateMaxFailureRatio::YamlNumber(scalar_string_from_source(&self.source, scalar))
6918            }
6919            ScalarType::String => DeployUpdateMaxFailureRatio::String(scalar_string_from_source(&self.source, scalar)),
6920            _ => {
6921                self.expected(
6922                    EXPECTED_SCALAR,
6923                    field,
6924                    "deploy update_config max_failure_ratio must be a YAML number or string scalar",
6925                );
6926                return None;
6927            }
6928        };
6929        Some(Located::new(
6930            value,
6931            span_from_position(self.source_id, scalar.byte_range()),
6932        ))
6933    }
6934    fn parse_deploy_update_order(&mut self, field: &ParsedField) -> Option<Located<DeployUpdateOrder>> {
6935        let raw = self.parse_deploy_update_string(field, "deploy update_config order must be a YAML string scalar")?;
6936        let order = DeployUpdateOrder::parse(raw.value().clone());
6937        if !order.is_documented() {
6938            self.diagnostics.push(
6939                Diagnostic::new(
6940                    DEPLOY_UPDATE_CONFIG_ORDER_PORTABILITY,
6941                    Severity::Warning,
6942                    "deploy update_config order is outside Compose's documented portable values",
6943                )
6944                .with_label(DiagnosticLabel::primary(
6945                    raw.span(),
6946                    "retained provider-specific update order",
6947                )),
6948            );
6949        }
6950        Some(Located::new(order, raw.span()))
6951    }
6952
6953    fn parse_deploy_placement(&mut self, field: &ParsedField) -> Option<DeployPlacement> {
6954        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6955            self.expected(EXPECTED_MAPPING, field, "deploy placement must be a mapping");
6956            return None;
6957        };
6958        let mut placement = DeployPlacement::new(span_from_position(self.source_id, mapping.byte_range()));
6959        let mut seen = BTreeMap::new();
6960        for option in self.fields(mapping) {
6961            if self.record_duplicate(&mut seen, &option) {
6962                continue;
6963            }
6964            match option.name.value().as_str() {
6965                name if name.starts_with("x-") => placement.push_extension(option.reference()),
6966                "constraints" => self
6967                    .parse_deploy_placement_constraints(&option)
6968                    .into_iter()
6969                    .for_each(|value| placement.set_constraints(value)),
6970                "preferences" => self
6971                    .parse_deploy_placement_preferences(&option)
6972                    .into_iter()
6973                    .for_each(|value| placement.set_preferences(value)),
6974                "max_replicas_per_node" => self
6975                    .parse_deploy_placement_max_replicas_per_node(&option)
6976                    .into_iter()
6977                    .for_each(|value| placement.set_max_replicas_per_node(value)),
6978                _ => placement.push_unknown(option.reference()),
6979            }
6980        }
6981        Some(placement)
6982    }
6983
6984    fn parse_deploy_placement_constraints(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
6985        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6986            self.expected(
6987                EXPECTED_SEQUENCE,
6988                field,
6989                "deploy placement constraints must be a sequence",
6990            );
6991            return None;
6992        };
6993        let mut constraints = Vec::new();
6994        for value in sequence.values() {
6995            let YamlNode::Scalar(scalar) = value else {
6996                self.unsupported_sequence_item(
6997                    EXPECTED_SCALAR,
6998                    &value,
6999                    field.span,
7000                    "deploy placement constraints must contain YAML string scalars",
7001                );
7002                continue;
7003            };
7004            if ScalarValue::from_scalar(&scalar).scalar_type() != ScalarType::String {
7005                self.unsupported_sequence_item(
7006                    EXPECTED_SCALAR,
7007                    &YamlNode::Scalar(scalar),
7008                    field.span,
7009                    "deploy placement constraints must contain YAML string scalars",
7010                );
7011                continue;
7012            }
7013            constraints.push(Located::new(
7014                scalar_string_from_source(&self.source, &scalar),
7015                span_from_position(self.source_id, scalar.byte_range()),
7016            ));
7017        }
7018        Some(constraints)
7019    }
7020
7021    fn parse_deploy_placement_preferences(&mut self, field: &ParsedField) -> Option<Vec<DeployPlacementPreference>> {
7022        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7023            self.expected(
7024                EXPECTED_SEQUENCE,
7025                field,
7026                "deploy placement preferences must be a sequence",
7027            );
7028            return None;
7029        };
7030        let mut preferences = Vec::new();
7031        for value in sequence.values() {
7032            let YamlNode::Mapping(mapping) = value else {
7033                self.unsupported_sequence_item(
7034                    EXPECTED_MAPPING,
7035                    &value,
7036                    field.span,
7037                    "deploy placement preferences must contain mappings",
7038                );
7039                continue;
7040            };
7041            let mut preference =
7042                DeployPlacementPreference::new(span_from_position(self.source_id, mapping.byte_range()));
7043            let mut seen = BTreeMap::new();
7044            for member in self.fields(&mapping) {
7045                if self.record_duplicate(&mut seen, &member) {
7046                    continue;
7047                }
7048                match member.name.value().as_str() {
7049                    name if name.starts_with("x-") => preference.push_extension(member.reference()),
7050                    "spread" => self
7051                        .parse_deploy_placement_string(&member, "deploy placement preference spread")
7052                        .into_iter()
7053                        .for_each(|value| preference.set_spread(value)),
7054                    _ => preference.push_unknown(member.reference()),
7055                }
7056            }
7057            preferences.push(preference);
7058        }
7059        Some(preferences)
7060    }
7061
7062    fn parse_deploy_placement_max_replicas_per_node(
7063        &mut self,
7064        field: &ParsedField,
7065    ) -> Option<Located<DeployPlacementMaxReplicasPerNode>> {
7066        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7067            self.expected(
7068                EXPECTED_SCALAR,
7069                field,
7070                "deploy placement max_replicas_per_node must be a YAML integer or string scalar",
7071            );
7072            return None;
7073        };
7074        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
7075            ScalarType::Integer => {
7076                DeployPlacementMaxReplicasPerNode::YamlInteger(scalar_string_from_source(&self.source, scalar))
7077            }
7078            ScalarType::String => {
7079                DeployPlacementMaxReplicasPerNode::String(scalar_string_from_source(&self.source, scalar))
7080            }
7081            _ => {
7082                self.expected(
7083                    EXPECTED_SCALAR,
7084                    field,
7085                    "deploy placement max_replicas_per_node must be a YAML integer or string scalar",
7086                );
7087                return None;
7088            }
7089        };
7090        Some(Located::new(
7091            value,
7092            span_from_position(self.source_id, scalar.byte_range()),
7093        ))
7094    }
7095
7096    fn parse_deploy_placement_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
7097        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7098            self.expected(
7099                EXPECTED_SCALAR,
7100                field,
7101                format!("{description} must be a YAML string scalar"),
7102            );
7103            return None;
7104        };
7105        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
7106            self.expected(
7107                EXPECTED_SCALAR,
7108                field,
7109                format!("{description} must be a YAML string scalar"),
7110            );
7111            return None;
7112        }
7113        Some(Located::new(
7114            scalar_string_from_source(&self.source, scalar),
7115            span_from_position(self.source_id, scalar.byte_range()),
7116        ))
7117    }
7118
7119    fn parse_deploy_resources(&mut self, field: &ParsedField) -> Option<DeployResources> {
7120        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7121            self.expected(EXPECTED_MAPPING, field, "deploy resources must be a mapping");
7122            return None;
7123        };
7124        let mut resources = DeployResources::new(span_from_position(self.source_id, mapping.byte_range()));
7125        let mut seen = BTreeMap::new();
7126        for option in self.fields(mapping) {
7127            if self.record_duplicate(&mut seen, &option) {
7128                continue;
7129            }
7130            match option.name.value().as_str() {
7131                name if name.starts_with("x-") => resources.push_extension(option.reference()),
7132                "limits" => self
7133                    .parse_deploy_resource_limits(&option)
7134                    .into_iter()
7135                    .for_each(|value| resources.set_limits(value)),
7136                "reservations" => self
7137                    .parse_deploy_resource_reservations(&option)
7138                    .into_iter()
7139                    .for_each(|value| resources.set_reservations(value)),
7140                _ => resources.push_unknown(option.reference()),
7141            }
7142        }
7143        Some(resources)
7144    }
7145
7146    fn parse_deploy_resource_limits(&mut self, field: &ParsedField) -> Option<DeployResourceLimits> {
7147        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7148            self.expected(EXPECTED_MAPPING, field, "deploy resource limits must be a mapping");
7149            return None;
7150        };
7151        let mut limits = DeployResourceLimits::new(span_from_position(self.source_id, mapping.byte_range()));
7152        let mut seen = BTreeMap::new();
7153        for option in self.fields(mapping) {
7154            if self.record_duplicate(&mut seen, &option) {
7155                continue;
7156            }
7157            match option.name.value().as_str() {
7158                name if name.starts_with("x-") => limits.push_extension(option.reference()),
7159                "cpus" => self
7160                    .parse_deploy_resource_cpus(&option, "deploy resource limits cpus")
7161                    .into_iter()
7162                    .for_each(|value| limits.set_cpus(value)),
7163                "memory" => self
7164                    .parse_deploy_resource_memory(&option, "deploy resource limits memory must be a YAML string scalar")
7165                    .into_iter()
7166                    .for_each(|value| limits.set_memory(value)),
7167                "pids" => self
7168                    .parse_deploy_resource_pids(&option)
7169                    .into_iter()
7170                    .for_each(|value| limits.set_pids(value)),
7171                _ => limits.push_unknown(option.reference()),
7172            }
7173        }
7174        Some(limits)
7175    }
7176
7177    fn parse_deploy_resource_reservations(&mut self, field: &ParsedField) -> Option<DeployResourceReservations> {
7178        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7179            self.expected(
7180                EXPECTED_MAPPING,
7181                field,
7182                "deploy resource reservations must be a mapping",
7183            );
7184            return None;
7185        };
7186        let mut reservations =
7187            DeployResourceReservations::new(span_from_position(self.source_id, mapping.byte_range()));
7188        let mut seen = BTreeMap::new();
7189        for option in self.fields(mapping) {
7190            if self.record_duplicate(&mut seen, &option) {
7191                continue;
7192            }
7193            match option.name.value().as_str() {
7194                name if name.starts_with("x-") => reservations.push_extension(option.reference()),
7195                "cpus" => self
7196                    .parse_deploy_resource_cpus(&option, "deploy resource reservations cpus")
7197                    .into_iter()
7198                    .for_each(|value| reservations.set_cpus(value)),
7199                "memory" => self
7200                    .parse_deploy_resource_memory(
7201                        &option,
7202                        "deploy resource reservations memory must be a YAML string scalar",
7203                    )
7204                    .into_iter()
7205                    .for_each(|value| reservations.set_memory(value)),
7206                "generic_resources" => {
7207                    if let Some(value) = self.parse_deploy_generic_resources(&option) {
7208                        reservations.set_generic_resources(value);
7209                    } else {
7210                        reservations.push_unknown(option.reference());
7211                    }
7212                }
7213                "devices" => {
7214                    if let Some(value) = self.parse_deploy_reservation_devices(&option) {
7215                        reservations.set_devices(value);
7216                    } else {
7217                        reservations.push_unknown(option.reference());
7218                    }
7219                }
7220                _ => reservations.push_unknown(option.reference()),
7221            }
7222        }
7223        Some(reservations)
7224    }
7225
7226    fn parse_deploy_resource_pids(&mut self, field: &ParsedField) -> Option<Located<DeployResourcePids>> {
7227        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7228            self.expected(
7229                EXPECTED_SCALAR,
7230                field,
7231                "deploy resource limits pids must be a YAML integer or string scalar",
7232            );
7233            return None;
7234        };
7235        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
7236            ScalarType::Integer => DeployResourcePids::YamlInteger(scalar_string_from_source(&self.source, scalar)),
7237            ScalarType::String => DeployResourcePids::String(scalar_string_from_source(&self.source, scalar)),
7238            _ => {
7239                self.expected(
7240                    EXPECTED_SCALAR,
7241                    field,
7242                    "deploy resource limits pids must be a YAML integer or string scalar",
7243                );
7244                return None;
7245            }
7246        };
7247        Some(Located::new(
7248            value,
7249            span_from_position(self.source_id, scalar.byte_range()),
7250        ))
7251    }
7252
7253    fn parse_deploy_resource_cpus(
7254        &mut self,
7255        field: &ParsedField,
7256        context: &'static str,
7257    ) -> Option<Located<DeployResourceCpus>> {
7258        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7259            self.expected(
7260                EXPECTED_SCALAR,
7261                field,
7262                format!("{context} must be a YAML number or string scalar"),
7263            );
7264            return None;
7265        };
7266        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
7267            ScalarType::Integer | ScalarType::Float => {
7268                DeployResourceCpus::YamlNumber(scalar_string_from_source(&self.source, scalar))
7269            }
7270            ScalarType::String => DeployResourceCpus::String(scalar_string_from_source(&self.source, scalar)),
7271            _ => {
7272                self.expected(
7273                    EXPECTED_SCALAR,
7274                    field,
7275                    format!("{context} must be a YAML number or string scalar"),
7276                );
7277                return None;
7278            }
7279        };
7280        Some(Located::new(
7281            value,
7282            span_from_position(self.source_id, scalar.byte_range()),
7283        ))
7284    }
7285
7286    fn parse_deploy_resource_memory(
7287        &mut self,
7288        field: &ParsedField,
7289        message: &'static str,
7290    ) -> Option<Located<DeployResourceMemory>> {
7291        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7292            self.expected(EXPECTED_SCALAR, field, message);
7293            return None;
7294        };
7295        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
7296            self.expected(EXPECTED_SCALAR, field, message);
7297            return None;
7298        }
7299        Some(Located::new(
7300            DeployResourceMemory::parse(scalar_string_from_source(&self.source, scalar)),
7301            span_from_position(self.source_id, scalar.byte_range()),
7302        ))
7303    }
7304
7305    fn parse_deploy_generic_resources(&mut self, field: &ParsedField) -> Option<DeployGenericResources> {
7306        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7307            self.expected(
7308                EXPECTED_SEQUENCE,
7309                field,
7310                "deploy resource reservations generic_resources must be a sequence",
7311            );
7312            return None;
7313        };
7314        let mut items = Vec::new();
7315        for node in sequence.values() {
7316            let Some(mapping) = node.as_mapping() else {
7317                self.unsupported_sequence_item(
7318                    EXPECTED_MAPPING,
7319                    &node,
7320                    field.span,
7321                    "deploy resource generic-resource entries must be mappings",
7322                );
7323                items.push(DeployGenericResource::unmodeled(
7324                    node_span(self.source_id, &node).unwrap_or(field.span),
7325                ));
7326                continue;
7327            };
7328            let mut item = DeployGenericResource::new(span_from_position(self.source_id, mapping.byte_range()));
7329            let mut seen = BTreeMap::new();
7330            for option in self.fields(mapping) {
7331                if self.record_duplicate(&mut seen, &option) {
7332                    continue;
7333                }
7334                match option.name.value().as_str() {
7335                    name if name.starts_with("x-") => item.push_extension(option.reference()),
7336                    "discrete_resource_spec" => {
7337                        if let Some(value) = self.parse_deploy_discrete_resource_spec(&option) {
7338                            item.set_discrete_resource_spec(value);
7339                        } else {
7340                            item.push_unknown(option.reference());
7341                        }
7342                    }
7343                    _ => item.push_unknown(option.reference()),
7344                }
7345            }
7346            items.push(item);
7347        }
7348        Some(DeployGenericResources::new(
7349            span_from_position(self.source_id, sequence.byte_range()),
7350            items,
7351        ))
7352    }
7353
7354    fn parse_deploy_reservation_devices(&mut self, field: &ParsedField) -> Option<DeployReservationDevices> {
7355        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7356            self.expected(
7357                EXPECTED_SEQUENCE,
7358                field,
7359                "deploy resource reservation devices must be a sequence",
7360            );
7361            return None;
7362        };
7363        let mut items = Vec::new();
7364        for node in sequence.values() {
7365            let Some(mapping) = node.as_mapping() else {
7366                self.unsupported_sequence_item(
7367                    EXPECTED_MAPPING,
7368                    &node,
7369                    field.span,
7370                    "deploy resource reservation device entries must be mappings",
7371                );
7372                items.push(DeployReservationDevice::unmodeled(
7373                    node_span(self.source_id, &node).unwrap_or(field.span),
7374                ));
7375                continue;
7376            };
7377            let item_span = span_from_position(self.source_id, mapping.byte_range());
7378            let mut item = DeployReservationDevice::new(item_span);
7379            let mut seen = BTreeMap::new();
7380            let mut has_capabilities = false;
7381            let mut count_field = None;
7382            let mut device_ids_field = None;
7383            for option in self.fields(mapping) {
7384                if self.record_duplicate(&mut seen, &option) {
7385                    continue;
7386                }
7387                match option.name.value().as_str() {
7388                    name if name.starts_with("x-") => item.push_extension(option.reference()),
7389                    "capabilities" => {
7390                        has_capabilities = true;
7391                        if let Some(value) = self.parse_deploy_reservation_device_capabilities(&option) {
7392                            item.set_capabilities(value);
7393                        } else {
7394                            item.push_unknown(option.reference());
7395                        }
7396                    }
7397                    "driver" => {
7398                        let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) else {
7399                            self.expected(
7400                                EXPECTED_SCALAR,
7401                                &option,
7402                                "deploy resource reservation device driver must be a YAML string scalar",
7403                            );
7404                            item.push_unknown(option.reference());
7405                            continue;
7406                        };
7407                        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
7408                            self.expected(
7409                                EXPECTED_SCALAR,
7410                                &option,
7411                                "deploy resource reservation device driver must be a YAML string scalar",
7412                            );
7413                            item.push_unknown(option.reference());
7414                            continue;
7415                        }
7416                        item.set_driver(Located::new(
7417                            scalar_string_from_source(&self.source, scalar),
7418                            span_from_position(self.source_id, scalar.byte_range()),
7419                        ));
7420                    }
7421                    "count" => {
7422                        count_field.get_or_insert(option.span);
7423                        if let Some(value) = self.parse_deploy_reservation_device_count(&option) {
7424                            item.set_count(value);
7425                        } else {
7426                            item.push_unknown(option.reference());
7427                        }
7428                    }
7429                    "device_ids" => {
7430                        device_ids_field.get_or_insert(option.span);
7431                        if let Some(value) = self.parse_deploy_reservation_device_ids(&option) {
7432                            item.set_device_ids(value);
7433                        } else {
7434                            item.push_unknown(option.reference());
7435                        }
7436                    }
7437                    "options" => self.set_deploy_reservation_device_options(&mut item, &option),
7438                    _ => item.push_unknown(option.reference()),
7439                }
7440            }
7441            self.reservation_device_allocation_selector_conflict(count_field, device_ids_field);
7442            if !has_capabilities {
7443                self.missing(
7444                    DEPLOY_RESERVATION_DEVICE_MISSING_CAPABILITIES,
7445                    item_span,
7446                    "deploy resource reservation device is missing required `capabilities`",
7447                );
7448            }
7449            items.push(item);
7450        }
7451        Some(DeployReservationDevices::new(
7452            span_from_position(self.source_id, sequence.byte_range()),
7453            items,
7454        ))
7455    }
7456
7457    fn reservation_device_allocation_selector_conflict(
7458        &mut self,
7459        count: Option<SourceSpan>,
7460        device_ids: Option<SourceSpan>,
7461    ) {
7462        let (Some(count), Some(device_ids)) = (count, device_ids) else {
7463            return;
7464        };
7465        self.diagnostics.push(
7466            Diagnostic::new(
7467                DEPLOY_RESERVATION_DEVICE_ALLOCATION_SELECTOR_CONFLICT,
7468                Severity::Error,
7469                "deploy resource reservation device count and device_ids are mutually exclusive",
7470            )
7471            .with_label(DiagnosticLabel::primary(count, "count retained"))
7472            .with_label(DiagnosticLabel::secondary(device_ids, "device_ids retained")),
7473        );
7474    }
7475
7476    fn parse_deploy_reservation_device_count(
7477        &mut self,
7478        field: &ParsedField,
7479    ) -> Option<Located<DeployReservationDeviceCount>> {
7480        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7481            self.expected(
7482                EXPECTED_SCALAR,
7483                field,
7484                "deploy resource reservation device count must be a YAML integer or string scalar",
7485            );
7486            return None;
7487        };
7488        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
7489            ScalarType::Integer => {
7490                DeployReservationDeviceCount::YamlInteger(scalar_string_from_source(&self.source, scalar))
7491            }
7492            ScalarType::String => DeployReservationDeviceCount::String(scalar_string_from_source(&self.source, scalar)),
7493            _ => {
7494                self.expected(
7495                    EXPECTED_SCALAR,
7496                    field,
7497                    "deploy resource reservation device count must be a YAML integer or string scalar",
7498                );
7499                return None;
7500            }
7501        };
7502        Some(Located::new(
7503            value,
7504            span_from_position(self.source_id, scalar.byte_range()),
7505        ))
7506    }
7507
7508    fn parse_deploy_reservation_device_ids(&mut self, field: &ParsedField) -> Option<DeployReservationDeviceIds> {
7509        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7510            self.expected(
7511                EXPECTED_SEQUENCE,
7512                field,
7513                "deploy resource reservation device device_ids must be a sequence",
7514            );
7515            return None;
7516        };
7517        let mut items = Vec::new();
7518        for node in sequence.values() {
7519            let Some(scalar) = node.as_scalar() else {
7520                self.unsupported_sequence_item(
7521                    EXPECTED_SCALAR,
7522                    &node,
7523                    field.span,
7524                    "deploy resource reservation device device_ids must be string scalars",
7525                );
7526                items.push(DeployReservationDeviceId::unmodeled(
7527                    node_span(self.source_id, &node).unwrap_or(field.span),
7528                ));
7529                continue;
7530            };
7531            let item_span = span_from_position(self.source_id, scalar.byte_range());
7532            if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
7533                self.unsupported_sequence_item(
7534                    EXPECTED_SCALAR,
7535                    &YamlNode::Scalar(scalar.clone()),
7536                    field.span,
7537                    "deploy resource reservation device device_ids must be string scalars",
7538                );
7539                items.push(DeployReservationDeviceId::unmodeled(item_span));
7540                continue;
7541            }
7542            items.push(DeployReservationDeviceId::string(Located::new(
7543                scalar_string_from_source(&self.source, scalar),
7544                item_span,
7545            )));
7546        }
7547        Some(DeployReservationDeviceIds::new(
7548            span_from_position(self.source_id, sequence.byte_range()),
7549            items,
7550        ))
7551    }
7552
7553    fn parse_deploy_reservation_device_options(
7554        &mut self,
7555        field: &ParsedField,
7556    ) -> Option<DeployReservationDeviceOptions> {
7557        match field.value.as_ref() {
7558            Some(YamlNode::Mapping(mapping)) => Some(self.parse_deploy_reservation_device_options_map(mapping)),
7559            Some(YamlNode::Sequence(sequence)) => {
7560                let mut items = Vec::new();
7561                let mut seen = BTreeMap::new();
7562                for node in sequence.values() {
7563                    let Some(scalar) = node.as_scalar() else {
7564                        self.unsupported_sequence_item(EXPECTED_SCALAR, &node, field.span,
7565                            "deploy resource reservation device options list entries must be strict YAML string scalars");
7566                        items.push(DeployReservationDeviceOptionItem::unmodeled(
7567                            node_span(self.source_id, &node).unwrap_or(field.span),
7568                        ));
7569                        continue;
7570                    };
7571                    let span = span_from_position(self.source_id, scalar.byte_range());
7572                    if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
7573                        self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar.clone()), field.span,
7574                            "deploy resource reservation device options list entries must be strict YAML string scalars");
7575                        items.push(DeployReservationDeviceOptionItem::unmodeled(span));
7576                        continue;
7577                    }
7578                    let value = scalar_string_from_source(&self.source, scalar);
7579                    if let Some(first) = seen.get(&value) {
7580                        self.diagnostics.push(
7581                            Diagnostic::new(
7582                                DEPLOY_RESERVATION_DEVICE_OPTIONS_DUPLICATE_ITEM,
7583                                Severity::Error,
7584                                "deploy resource reservation device options list entries must be unique exact strings",
7585                            )
7586                            .with_label(DiagnosticLabel::primary(span, "duplicate option string"))
7587                            .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
7588                        );
7589                    } else {
7590                        seen.insert(value.clone(), span);
7591                    }
7592                    items.push(DeployReservationDeviceOptionItem::string(Located::new(value, span)));
7593                }
7594                Some(DeployReservationDeviceOptions::List {
7595                    span: span_from_position(self.source_id, sequence.byte_range()),
7596                    items,
7597                })
7598            }
7599            _ => {
7600                self.expected(
7601                    DEPLOY_RESERVATION_DEVICE_OPTIONS_EXPECTED_FORM,
7602                    field,
7603                    "deploy resource reservation device options must be a mapping or sequence",
7604                );
7605                None
7606            }
7607        }
7608    }
7609
7610    fn set_deploy_reservation_device_options(&mut self, device: &mut DeployReservationDevice, field: &ParsedField) {
7611        if let Some(options) = self.parse_deploy_reservation_device_options(field) {
7612            device.set_options(options);
7613        } else {
7614            device.push_unknown(field.reference());
7615        }
7616    }
7617
7618    fn parse_deploy_reservation_device_options_map(&mut self, mapping: &Mapping) -> DeployReservationDeviceOptions {
7619        let mut entries = Vec::new();
7620        let mut unmodeled_entries = Vec::new();
7621        let mut seen = BTreeMap::new();
7622        for entry in mapping.entries() {
7623            let Some(key) = entry.key_node() else { continue };
7624            let key_span = node_span(self.source_id, &key)
7625                .unwrap_or_else(|| span_from_position(self.source_id, mapping.byte_range()));
7626            let authored_value = entry.value_node();
7627            let value_span = authored_value
7628                .as_ref()
7629                .and_then(|value| node_span(self.source_id, value));
7630            let field_span = value_span.map_or(key_span, |value| union(key_span, value));
7631            let Some(scalar) = key.as_scalar() else {
7632                self.diagnostics.push(
7633                    Diagnostic::new(
7634                        DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY,
7635                        Severity::Error,
7636                        "deploy resource reservation device options mapping keys must be non-empty strict YAML strings",
7637                    )
7638                    .with_label(DiagnosticLabel::primary(key_span, "invalid option key")),
7639                );
7640                unmodeled_entries.push(FieldReference {
7641                    name: Located::new("<unmodeled-key>".to_owned(), key_span),
7642                    span: field_span,
7643                    value_span,
7644                });
7645                continue;
7646            };
7647            let name = Located::new(
7648                scalar_string_from_source(&self.source, scalar),
7649                span_from_position(self.source_id, scalar.byte_range()),
7650            );
7651            let field_span = value_span.map_or(name.span(), |value| union(name.span(), value));
7652            let parsed = ParsedField {
7653                name,
7654                value: authored_value
7655                    .map(unwrap_processing_tag)
7656                    .map(|value| self.resolve_alias(value)),
7657                value_span,
7658                span: field_span,
7659            };
7660            if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String || parsed.name.value().is_empty() {
7661                self.diagnostics.push(
7662                    Diagnostic::new(
7663                        DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY,
7664                        Severity::Error,
7665                        "deploy resource reservation device options mapping keys must be non-empty strict YAML strings",
7666                    )
7667                    .with_label(DiagnosticLabel::primary(parsed.name.span(), "invalid option key")),
7668                );
7669                unmodeled_entries.push(parsed.reference());
7670                continue;
7671            }
7672            if self.record_duplicate(&mut seen, &parsed) {
7673                unmodeled_entries.push(parsed.reference());
7674                continue;
7675            }
7676            let Some(value) = self.compose_scalar_from_field(&parsed,
7677                "deploy resource reservation device options mapping values must be scalar strings, numbers, booleans, or null") else {
7678                unmodeled_entries.push(parsed.reference()); continue;
7679            };
7680            entries.push(KeyValueEntry::new(parsed.name, value, parsed.span));
7681        }
7682        DeployReservationDeviceOptions::Map {
7683            span: span_from_position(self.source_id, mapping.byte_range()),
7684            entries,
7685            unmodeled_entries,
7686        }
7687    }
7688
7689    fn compose_scalar_from_field(
7690        &mut self,
7691        field: &ParsedField,
7692        message: &'static str,
7693    ) -> Option<Located<ComposeScalar>> {
7694        let Some(node) = field.value.as_ref() else {
7695            return Some(Located::new(ComposeScalar::Null, field.name.span()));
7696        };
7697        let Some(scalar) = node.as_scalar() else {
7698            self.expected(EXPECTED_SCALAR, field, message);
7699            return None;
7700        };
7701        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
7702            ScalarType::Null => ComposeScalar::Null,
7703            ScalarType::Boolean => ComposeScalar::Boolean(ScalarValue::from_scalar(scalar).to_bool().unwrap_or(false)),
7704            ScalarType::Integer | ScalarType::Float => {
7705                ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
7706            }
7707            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
7708                ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
7709            }
7710        };
7711        Some(Located::new(
7712            value,
7713            span_from_position(self.source_id, scalar.byte_range()),
7714        ))
7715    }
7716
7717    fn parse_deploy_reservation_device_capabilities(
7718        &mut self,
7719        field: &ParsedField,
7720    ) -> Option<DeployReservationDeviceCapabilities> {
7721        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7722            self.expected(
7723                EXPECTED_SEQUENCE,
7724                field,
7725                "deploy resource reservation device capabilities must be a sequence of strings",
7726            );
7727            return None;
7728        };
7729        let mut items = Vec::new();
7730        let mut seen = BTreeMap::new();
7731        for node in sequence.values() {
7732            let Some(scalar) = node.as_scalar() else {
7733                self.unsupported_sequence_item(
7734                    EXPECTED_SCALAR,
7735                    &node,
7736                    field.span,
7737                    "deploy resource reservation device capabilities must be string scalars",
7738                );
7739                items.push(DeployReservationDeviceCapability::unmodeled(
7740                    node_span(self.source_id, &node).unwrap_or(field.span),
7741                ));
7742                continue;
7743            };
7744            let item_span = span_from_position(self.source_id, scalar.byte_range());
7745            if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
7746                self.unsupported_sequence_item(
7747                    EXPECTED_SCALAR,
7748                    &YamlNode::Scalar(scalar.clone()),
7749                    field.span,
7750                    "deploy resource reservation device capabilities must be string scalars",
7751                );
7752                items.push(DeployReservationDeviceCapability::unmodeled(item_span));
7753                continue;
7754            }
7755            let value = scalar_string_from_source(&self.source, scalar);
7756            if let Some(first) = seen.get(&value) {
7757                self.diagnostics.push(
7758                    Diagnostic::new(
7759                        DEPLOY_RESERVATION_DEVICE_CAPABILITY_DUPLICATE_ITEM,
7760                        Severity::Error,
7761                        "deploy resource reservation device capabilities must be unique exact strings",
7762                    )
7763                    .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
7764                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
7765                );
7766            } else {
7767                seen.insert(value.clone(), item_span);
7768            }
7769            items.push(DeployReservationDeviceCapability::string(Located::new(
7770                value, item_span,
7771            )));
7772        }
7773        Some(DeployReservationDeviceCapabilities::new(
7774            span_from_position(self.source_id, sequence.byte_range()),
7775            items,
7776        ))
7777    }
7778
7779    fn parse_deploy_discrete_resource_spec(&mut self, field: &ParsedField) -> Option<DeployDiscreteResourceSpec> {
7780        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7781            self.expected(
7782                EXPECTED_MAPPING,
7783                field,
7784                "deploy discrete_resource_spec must be a mapping",
7785            );
7786            return None;
7787        };
7788        let mut spec = DeployDiscreteResourceSpec::new(span_from_position(self.source_id, mapping.byte_range()));
7789        let mut seen = BTreeMap::new();
7790        for option in self.fields(mapping) {
7791            if self.record_duplicate(&mut seen, &option) {
7792                continue;
7793            }
7794            match option.name.value().as_str() {
7795                name if name.starts_with("x-") => spec.push_extension(option.reference()),
7796                "kind" => {
7797                    if let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) {
7798                        if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::String {
7799                            spec.set_kind(Located::new(
7800                                scalar_string_from_source(&self.source, scalar),
7801                                span_from_position(self.source_id, scalar.byte_range()),
7802                            ));
7803                        } else {
7804                            self.expected(
7805                                EXPECTED_SCALAR,
7806                                &option,
7807                                "deploy discrete_resource_spec kind must be a YAML string scalar",
7808                            );
7809                            spec.push_unknown(option.reference());
7810                        }
7811                    } else {
7812                        self.expected(
7813                            EXPECTED_SCALAR,
7814                            &option,
7815                            "deploy discrete_resource_spec kind must be a YAML string scalar",
7816                        );
7817                        spec.push_unknown(option.reference());
7818                    }
7819                }
7820                "value" => {
7821                    if let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) {
7822                        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
7823                            ScalarType::Integer | ScalarType::Float => Some(DeployDiscreteResourceValue::YamlNumber(
7824                                scalar_string_from_source(&self.source, scalar),
7825                            )),
7826                            ScalarType::String => Some(DeployDiscreteResourceValue::String(scalar_string_from_source(
7827                                &self.source,
7828                                scalar,
7829                            ))),
7830                            _ => None,
7831                        };
7832                        if let Some(value) = value {
7833                            spec.set_value(Located::new(
7834                                value,
7835                                span_from_position(self.source_id, scalar.byte_range()),
7836                            ));
7837                        } else {
7838                            self.expected(
7839                                EXPECTED_SCALAR,
7840                                &option,
7841                                "deploy discrete_resource_spec value must be a YAML number or string scalar",
7842                            );
7843                            spec.push_unknown(option.reference());
7844                        }
7845                    } else {
7846                        self.expected(
7847                            EXPECTED_SCALAR,
7848                            &option,
7849                            "deploy discrete_resource_spec value must be a YAML number or string scalar",
7850                        );
7851                        spec.push_unknown(option.reference());
7852                    }
7853                }
7854                _ => spec.push_unknown(option.reference()),
7855            }
7856        }
7857        Some(spec)
7858    }
7859
7860    fn source_column(&self, offset: usize) -> usize {
7861        let prefix = self.source.get(..offset).unwrap_or_default();
7862        let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
7863        self.source[line_start..offset].chars().count()
7864    }
7865
7866    fn parse_service_ports(&mut self, field: &ParsedField) -> Vec<Port> {
7867        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7868            self.expected(EXPECTED_SEQUENCE, field, "service ports must be a sequence");
7869            return Vec::new();
7870        };
7871
7872        let mut ports = Vec::new();
7873        for value in sequence.values() {
7874            match value {
7875                YamlNode::Scalar(scalar) => {
7876                    let span = span_from_position(self.source_id, scalar.byte_range());
7877                    ports.push(Port::Short(ShortPort::parse(Located::new(
7878                        scalar_string_from_source(&self.source, &scalar),
7879                        span,
7880                    ))));
7881                }
7882                YamlNode::Mapping(mapping) => {
7883                    ports.push(Port::Long(Box::new(self.parse_long_port(&mapping))));
7884                }
7885                other => self.unsupported_sequence_item(
7886                    PORT_EXPECTED_FORM,
7887                    &other,
7888                    field.span,
7889                    "service port must use scalar short syntax or mapping long syntax",
7890                ),
7891            }
7892        }
7893        ports
7894    }
7895
7896    fn parse_long_port(&mut self, mapping: &Mapping) -> LongPort {
7897        let span = span_from_position(self.source_id, mapping.byte_range());
7898        let mut port = LongPort::new(span);
7899        let mut seen = BTreeMap::new();
7900        for field in self.fields(mapping) {
7901            let duplicate = self.record_duplicate(&mut seen, &field);
7902            match field.name.value.as_str() {
7903                "target" if !duplicate => self
7904                    .parse_string(&field, "port target")
7905                    .into_iter()
7906                    .for_each(|value| port.set_target(value)),
7907                "published" if !duplicate => self
7908                    .parse_string(&field, "published port")
7909                    .into_iter()
7910                    .for_each(|value| port.set_published(value)),
7911                "host_ip" if !duplicate => self
7912                    .parse_string(&field, "port host IP")
7913                    .into_iter()
7914                    .for_each(|value| port.set_host_ip(value)),
7915                "protocol" if !duplicate => self
7916                    .parse_string(&field, "port protocol")
7917                    .into_iter()
7918                    .for_each(|value| port.set_protocol(value)),
7919                "app_protocol" if !duplicate => self
7920                    .parse_string(&field, "port application protocol")
7921                    .into_iter()
7922                    .for_each(|value| port.set_app_protocol(value)),
7923                "mode" if !duplicate => self
7924                    .parse_string(&field, "port mode")
7925                    .into_iter()
7926                    .for_each(|value| port.set_mode(value)),
7927                "name" if !duplicate => self
7928                    .parse_string(&field, "port name")
7929                    .into_iter()
7930                    .for_each(|value| port.set_name(value)),
7931                name if name.starts_with("x-") => port.push_extension(field.reference()),
7932                _ if duplicate => {}
7933                _ => port.push_unknown(field.reference()),
7934            }
7935        }
7936        if port.target().is_none() {
7937            self.missing(PORT_MISSING_TARGET, span, "long port is missing `target`");
7938        }
7939        port
7940    }
7941
7942    fn parse_service_networks(&mut self, field: &ParsedField) -> Option<ServiceNetworks> {
7943        match field.value.as_ref() {
7944            Some(YamlNode::Sequence(sequence)) => {
7945                let span = span_from_position(self.source_id, sequence.byte_range());
7946                let names =
7947                    self.parse_scalar_nodes(sequence.values(), field.span, "service network names must be scalars");
7948                Some(ServiceNetworks::Short { span, names })
7949            }
7950            Some(YamlNode::Mapping(mapping)) => {
7951                let span = span_from_position(self.source_id, mapping.byte_range());
7952                let networks = self.parse_service_network_map(mapping);
7953                Some(ServiceNetworks::Long { span, networks })
7954            }
7955            _ => {
7956                self.expected(
7957                    EXPECTED_FIELD_FORM,
7958                    field,
7959                    "service networks must be a sequence or mapping",
7960                );
7961                None
7962            }
7963        }
7964    }
7965
7966    fn parse_service_network_map(&mut self, mapping: &Mapping) -> Vec<ServiceNetwork> {
7967        let mut networks = Vec::new();
7968        let mut seen = BTreeMap::new();
7969        for field in self.fields(mapping) {
7970            if self.record_duplicate(&mut seen, &field) {
7971                continue;
7972            }
7973            if Self::field_is_null(&field) {
7974                networks.push(ServiceNetwork::new(field.name, field.span));
7975                continue;
7976            }
7977            let Some(options) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7978                self.expected(
7979                    EXPECTED_MAPPING,
7980                    &field,
7981                    "service network options must be a mapping or null",
7982                );
7983                continue;
7984            };
7985            networks.push(self.parse_service_network(&field, options));
7986        }
7987        networks
7988    }
7989
7990    fn parse_service_network(&mut self, field: &ParsedField, mapping: &Mapping) -> ServiceNetwork {
7991        let mut network = ServiceNetwork::new(field.name.clone(), field.span);
7992        let mut seen = BTreeMap::new();
7993        for option in self.fields(mapping) {
7994            let duplicate = self.record_duplicate(&mut seen, &option);
7995            match option.name.value.as_str() {
7996                "aliases" if !duplicate => network.set_aliases(self.parse_string_sequence(&option, "network aliases")),
7997                "interface_name" if !duplicate => self
7998                    .parse_string(&option, "network interface name")
7999                    .into_iter()
8000                    .for_each(|value| network.set_interface_name(value)),
8001                "ipv4_address" if !duplicate => self
8002                    .parse_string(&option, "network IPv4 address")
8003                    .into_iter()
8004                    .for_each(|value| network.set_ipv4_address(value)),
8005                "ipv6_address" if !duplicate => self
8006                    .parse_string(&option, "network IPv6 address")
8007                    .into_iter()
8008                    .for_each(|value| network.set_ipv6_address(value)),
8009                "link_local_ips" if !duplicate => {
8010                    network.set_link_local_ips(self.parse_string_sequence(&option, "link-local IP addresses"));
8011                }
8012                "mac_address" if !duplicate => self
8013                    .parse_string(&option, "network MAC address")
8014                    .into_iter()
8015                    .for_each(|value| network.set_mac_address(value)),
8016                "driver_opts" if !duplicate => {
8017                    network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
8018                }
8019                "gw_priority" if !duplicate => self
8020                    .parse_string(&option, "network gateway priority")
8021                    .into_iter()
8022                    .for_each(|value| network.set_gw_priority(value)),
8023                "priority" if !duplicate => self
8024                    .parse_string(&option, "network priority")
8025                    .into_iter()
8026                    .for_each(|value| network.set_priority(value)),
8027                name if name.starts_with("x-") => network.push_extension(option.reference()),
8028                _ if duplicate => {}
8029                _ => network.push_unknown(option.reference()),
8030            }
8031        }
8032        network
8033    }
8034
8035    fn parse_config_grants(&mut self, field: &ParsedField) -> Vec<ConfigGrant> {
8036        self.parse_grants(field)
8037            .unwrap_or_default()
8038            .into_iter()
8039            .map(|grant| match grant {
8040                ParsedGrant::Short(value) => ConfigGrant::Short(value),
8041                ParsedGrant::Long(value) => ConfigGrant::Long(value),
8042            })
8043            .collect()
8044    }
8045
8046    fn parse_secret_grants(&mut self, field: &ParsedField) -> Option<Vec<SecretGrant>> {
8047        Some(
8048            self.parse_grants(field)?
8049                .into_iter()
8050                .map(|grant| match grant {
8051                    ParsedGrant::Short(value) => SecretGrant::Short(value),
8052                    ParsedGrant::Long(value) => SecretGrant::Long(value),
8053                })
8054                .collect(),
8055        )
8056    }
8057
8058    fn parse_grants(&mut self, field: &ParsedField) -> Option<Vec<ParsedGrant>> {
8059        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
8060            self.expected(EXPECTED_SEQUENCE, field, "service grants must be a sequence");
8061            return None;
8062        };
8063        let mut grants = Vec::new();
8064        for value in sequence.values() {
8065            match value {
8066                YamlNode::Scalar(scalar) => {
8067                    let span = span_from_position(self.source_id, scalar.byte_range());
8068                    grants.push(ParsedGrant::Short(Located::new(
8069                        scalar_string_from_source(&self.source, &scalar),
8070                        span,
8071                    )));
8072                }
8073                YamlNode::Mapping(mapping) => {
8074                    grants.push(ParsedGrant::Long(Box::new(self.parse_long_grant(&mapping))));
8075                }
8076                other => self.unsupported_sequence_item(
8077                    GRANT_EXPECTED_FORM,
8078                    &other,
8079                    field.span,
8080                    "grant must use scalar short syntax or mapping long syntax",
8081                ),
8082            }
8083        }
8084        Some(grants)
8085    }
8086
8087    fn parse_long_grant(&mut self, mapping: &Mapping) -> LongGrant {
8088        let span = span_from_position(self.source_id, mapping.byte_range());
8089        let mut grant = LongGrant::new(span);
8090        let mut seen = BTreeMap::new();
8091        for field in self.fields(mapping) {
8092            let duplicate = self.record_duplicate(&mut seen, &field);
8093            match field.name.value.as_str() {
8094                "source" if !duplicate => self
8095                    .parse_string(&field, "grant source")
8096                    .into_iter()
8097                    .for_each(|value| grant.set_source(value)),
8098                "target" if !duplicate => self
8099                    .parse_string(&field, "grant target")
8100                    .into_iter()
8101                    .for_each(|value| grant.set_target(value)),
8102                "uid" if !duplicate => self
8103                    .parse_string(&field, "grant user ID")
8104                    .into_iter()
8105                    .for_each(|value| grant.set_uid(value)),
8106                "gid" if !duplicate => self
8107                    .parse_string(&field, "grant group ID")
8108                    .into_iter()
8109                    .for_each(|value| grant.set_gid(value)),
8110                "mode" if !duplicate => self
8111                    .parse_string(&field, "grant mode")
8112                    .into_iter()
8113                    .for_each(|value| grant.set_mode(value)),
8114                name if name.starts_with("x-") => grant.push_extension(field.reference()),
8115                _ if duplicate => {}
8116                _ => grant.push_unknown(field.reference()),
8117            }
8118        }
8119        if grant.source().is_none() {
8120            self.missing(GRANT_MISSING_SOURCE, span, "long grant is missing `source`");
8121        }
8122        grant
8123    }
8124
8125    fn parse_service_volumes(&mut self, field: &ParsedField) -> Vec<VolumeMount> {
8126        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
8127            self.expected(EXPECTED_SEQUENCE, field, "service volumes must be a sequence");
8128            return Vec::new();
8129        };
8130
8131        sequence
8132            .values()
8133            .filter_map(|value| match value {
8134                YamlNode::Scalar(scalar) => {
8135                    let span = span_from_position(self.source_id, scalar.byte_range());
8136                    let raw = Located::new(scalar_string_from_source(&self.source, &scalar), span);
8137                    Some(VolumeMount::Short(ShortVolumeMount::new(raw)))
8138                }
8139                YamlNode::Mapping(mapping) => Some(VolumeMount::Long(Box::new(self.parse_long_volume(&mapping)))),
8140                other => {
8141                    let span = node_span(self.source_id, &other).unwrap_or(field.span);
8142                    self.diagnostics.push(
8143                        Diagnostic::new(
8144                            VOLUME_EXPECTED_FORM,
8145                            Severity::Error,
8146                            "service volume must use scalar short syntax or mapping long syntax",
8147                        )
8148                        .with_label(DiagnosticLabel::primary(span, "unsupported volume form")),
8149                    );
8150                    None
8151                }
8152            })
8153            .collect()
8154    }
8155
8156    fn parse_long_volume(&mut self, mapping: &Mapping) -> LongVolumeMount {
8157        let span = span_from_position(self.source_id, mapping.byte_range());
8158        let mut mount = LongVolumeMount::new(span);
8159        let mut seen = BTreeMap::new();
8160        for field in self.fields(mapping) {
8161            let duplicate = self.record_duplicate(&mut seen, &field);
8162            match field.name.value.as_str() {
8163                "type" if !duplicate => {
8164                    if let Some(value) = self.parse_string(&field, "volume type") {
8165                        mount.set_mount_type(Located::new(MountType::from_text(value.value), value.span));
8166                    }
8167                }
8168                "source" if !duplicate => {
8169                    if let Some(value) = self.parse_string(&field, "volume source") {
8170                        mount.set_source(value);
8171                    }
8172                }
8173                "target" if !duplicate => {
8174                    if let Some(value) = self.parse_string(&field, "volume target") {
8175                        mount.set_target(value);
8176                    }
8177                }
8178                "read_only" if !duplicate => {
8179                    if let Some(value) = self.parse_boolean(&field, "read_only") {
8180                        mount.set_read_only(value);
8181                    }
8182                }
8183                "consistency" if !duplicate => {
8184                    if let Some(value) = self.parse_string(&field, "volume consistency") {
8185                        mount.set_consistency(value);
8186                    } else {
8187                        mount.push_unknown(field.reference());
8188                    }
8189                }
8190                "bind" if !duplicate => {
8191                    if let Some(value) = self.parse_bind_options(&field) {
8192                        mount.set_bind(value);
8193                    } else {
8194                        mount.push_unknown(field.reference());
8195                    }
8196                }
8197                "image" if !duplicate => {
8198                    if let Some(value) = self.parse_image_mount_options(&field) {
8199                        mount.set_image(value);
8200                    } else {
8201                        mount.push_unknown(field.reference());
8202                    }
8203                }
8204                "tmpfs" if !duplicate => {
8205                    if let Some(value) = self.parse_tmpfs_mount_options(&field) {
8206                        mount.set_tmpfs(value);
8207                    } else {
8208                        mount.push_unknown(field.reference());
8209                    }
8210                }
8211                "volume" if !duplicate => {
8212                    if let Some(value) = self.parse_volume_mount_options(&field) {
8213                        mount.set_volume(value);
8214                    } else {
8215                        mount.push_unknown(field.reference());
8216                    }
8217                }
8218                name if name.starts_with("x-") => mount.push_extension(field.reference()),
8219                _ if duplicate => {}
8220                _ => mount.push_unknown(field.reference()),
8221            }
8222        }
8223
8224        if mount.mount_type().is_none() {
8225            self.missing(VOLUME_MISSING_TYPE, span, "long volume is missing `type`");
8226        }
8227        if mount.target().is_none() {
8228            self.missing(VOLUME_MISSING_TARGET, span, "long volume is missing `target`");
8229        }
8230        mount
8231    }
8232
8233    fn parse_bind_options(&mut self, field: &ParsedField) -> Option<BindOptions> {
8234        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8235            self.expected(EXPECTED_MAPPING, field, "bind options must be a mapping");
8236            return None;
8237        };
8238        let span = span_from_position(self.source_id, mapping.byte_range());
8239        let mut bind = BindOptions::new(span);
8240        let mut seen = BTreeMap::new();
8241        for bind_field in self.fields(mapping) {
8242            let duplicate = self.record_duplicate(&mut seen, &bind_field);
8243            match bind_field.name.value.as_str() {
8244                "propagation" if !duplicate => {
8245                    if let Some(value) = self.parse_string(&bind_field, "bind propagation") {
8246                        bind.set_propagation(value);
8247                    }
8248                }
8249                "create_host_path" if !duplicate => {
8250                    if let Some(value) = self.parse_boolean(&bind_field, "create_host_path") {
8251                        bind.set_create_host_path(value);
8252                    }
8253                }
8254                "selinux" if !duplicate => {
8255                    if let Some(value) = self.parse_string(&bind_field, "SELinux relabel mode") {
8256                        let mode = match value.value.as_str() {
8257                            "z" => Some(SelinuxRelabel::Shared),
8258                            "Z" => Some(SelinuxRelabel::Private),
8259                            _ => None,
8260                        };
8261                        if let Some(mode) = mode {
8262                            bind.set_selinux(Located::new(mode, value.span));
8263                        } else {
8264                            self.diagnostics.push(
8265                                Diagnostic::new(
8266                                    VOLUME_INVALID_SELINUX,
8267                                    Severity::Error,
8268                                    "SELinux relabel mode must be `z` or `Z`",
8269                                )
8270                                .with_label(DiagnosticLabel::primary(value.span, "invalid SELinux mode")),
8271                            );
8272                        }
8273                    }
8274                }
8275                "recursive" if !duplicate => {
8276                    if let Some(value) = self.parse_string(&bind_field, "bind recursive mode") {
8277                        bind.set_recursive(value);
8278                    } else {
8279                        bind.push_unknown(bind_field.reference());
8280                    }
8281                }
8282                name if name.starts_with("x-") => bind.push_extension(bind_field.reference()),
8283                _ if duplicate => {}
8284                _ => bind.push_unknown(bind_field.reference()),
8285            }
8286        }
8287        Some(bind)
8288    }
8289
8290    fn parse_image_mount_options(&mut self, field: &ParsedField) -> Option<ImageMountOptions> {
8291        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8292            self.expected(
8293                VOLUME_OPTION_EXPECTED_MAPPING,
8294                field,
8295                "volume image options must be a mapping",
8296            );
8297            return None;
8298        };
8299        let mut options = ImageMountOptions::new(span_from_position(self.source_id, mapping.byte_range()));
8300        let mut seen = BTreeMap::new();
8301        for option in self.fields(mapping) {
8302            let duplicate = self.record_duplicate(&mut seen, &option);
8303            match option.name.value.as_str() {
8304                "subpath" if !duplicate => {
8305                    if let Some(value) = self.parse_string(&option, "image mount subpath") {
8306                        options.set_subpath(value);
8307                    } else {
8308                        options.push_unknown(option.reference());
8309                    }
8310                }
8311                name if name.starts_with("x-") => options.push_extension(option.reference()),
8312                _ if duplicate => {}
8313                _ => options.push_unknown(option.reference()),
8314            }
8315        }
8316        Some(options)
8317    }
8318
8319    fn parse_tmpfs_mount_options(&mut self, field: &ParsedField) -> Option<TmpfsMountOptions> {
8320        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8321            self.expected(
8322                VOLUME_OPTION_EXPECTED_MAPPING,
8323                field,
8324                "volume tmpfs options must be a mapping",
8325            );
8326            return None;
8327        };
8328        let mut options = TmpfsMountOptions::new(span_from_position(self.source_id, mapping.byte_range()));
8329        let mut seen = BTreeMap::new();
8330        for option in self.fields(mapping) {
8331            let duplicate = self.record_duplicate(&mut seen, &option);
8332            match option.name.value.as_str() {
8333                "size" if !duplicate => {
8334                    if let Some(value) =
8335                        self.parse_string_or_number_scalar(&option, "tmpfs size must be a number or string")
8336                    {
8337                        options.set_size(value);
8338                    } else {
8339                        options.push_unknown(option.reference());
8340                    }
8341                }
8342                "mode" if !duplicate => {
8343                    if let Some(value) =
8344                        self.parse_string_or_number_scalar(&option, "tmpfs mode must be a number or string")
8345                    {
8346                        options.set_mode(value);
8347                    } else {
8348                        options.push_unknown(option.reference());
8349                    }
8350                }
8351                name if name.starts_with("x-") => options.push_extension(option.reference()),
8352                _ if duplicate => {}
8353                _ => options.push_unknown(option.reference()),
8354            }
8355        }
8356        Some(options)
8357    }
8358
8359    fn parse_volume_mount_options(&mut self, field: &ParsedField) -> Option<VolumeMountOptions> {
8360        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8361            self.expected(
8362                VOLUME_OPTION_EXPECTED_MAPPING,
8363                field,
8364                "named-volume options must be a mapping",
8365            );
8366            return None;
8367        };
8368        let mut options = VolumeMountOptions::new(span_from_position(self.source_id, mapping.byte_range()));
8369        let mut seen = BTreeMap::new();
8370        for option in self.fields(mapping) {
8371            let duplicate = self.record_duplicate(&mut seen, &option);
8372            match option.name.value.as_str() {
8373                "nocopy" if !duplicate => {
8374                    if let Some(value) = self.parse_boolean(&option, "volume nocopy") {
8375                        options.set_nocopy(value);
8376                    } else {
8377                        options.push_unknown(option.reference());
8378                    }
8379                }
8380                "subpath" if !duplicate => {
8381                    if let Some(value) = self.parse_string(&option, "volume subpath") {
8382                        options.set_subpath(value);
8383                    } else {
8384                        options.push_unknown(option.reference());
8385                    }
8386                }
8387                "labels" if !duplicate => {
8388                    if let Some(value) = self.parse_labels(&option) {
8389                        options.set_labels(value);
8390                    } else {
8391                        options.push_unknown(option.reference());
8392                    }
8393                }
8394                name if name.starts_with("x-") => options.push_extension(option.reference()),
8395                _ if duplicate => {}
8396                _ => options.push_unknown(option.reference()),
8397            }
8398        }
8399        Some(options)
8400    }
8401
8402    fn parse_network_definitions(&mut self, field: &ParsedField) -> Vec<NetworkDefinition> {
8403        let Some(mapping) = self.resource_collection(field, "networks") else {
8404            return Vec::new();
8405        };
8406        let mut definitions = Vec::new();
8407        let mut seen = BTreeMap::new();
8408        for resource in self.fields(&mapping) {
8409            if self.record_duplicate(&mut seen, &resource) {
8410                continue;
8411            }
8412            if Self::field_is_null(&resource) {
8413                definitions.push(NetworkDefinition::new(resource.name, resource.span));
8414                continue;
8415            }
8416            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
8417                self.expected(
8418                    RESOURCE_EXPECTED_FORM,
8419                    &resource,
8420                    "network definition must be a mapping or null",
8421                );
8422                continue;
8423            };
8424            definitions.push(self.parse_network_definition(&resource, definition));
8425        }
8426        definitions
8427    }
8428
8429    fn parse_network_definition(&mut self, field: &ParsedField, mapping: &Mapping) -> NetworkDefinition {
8430        let mut network = NetworkDefinition::new(field.name.clone(), field.span);
8431        let mut seen = BTreeMap::new();
8432        for option in self.fields(mapping) {
8433            let duplicate = self.record_duplicate(&mut seen, &option);
8434            match option.name.value.as_str() {
8435                "driver" if !duplicate => self
8436                    .parse_string(&option, "network driver")
8437                    .into_iter()
8438                    .for_each(|value| network.set_driver(value)),
8439                "driver_opts" if !duplicate => {
8440                    network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
8441                }
8442                "attachable" if !duplicate => self
8443                    .parse_boolean(&option, "network attachable")
8444                    .into_iter()
8445                    .for_each(|value| network.set_attachable(value)),
8446                "enable_ipv4" if !duplicate => self
8447                    .parse_boolean(&option, "network enable_ipv4")
8448                    .into_iter()
8449                    .for_each(|value| network.set_enable_ipv4(value)),
8450                "enable_ipv6" if !duplicate => self
8451                    .parse_boolean(&option, "network enable_ipv6")
8452                    .into_iter()
8453                    .for_each(|value| network.set_enable_ipv6(value)),
8454                "external" if !duplicate => self
8455                    .parse_resource_external(&option, "network")
8456                    .into_iter()
8457                    .for_each(|value| network.set_external(value)),
8458                "internal" if !duplicate => self
8459                    .parse_boolean(&option, "network internal")
8460                    .into_iter()
8461                    .for_each(|value| network.set_internal(value)),
8462                "ipam" if !duplicate => self
8463                    .parse_ipam(&option)
8464                    .into_iter()
8465                    .for_each(|value| network.set_ipam(value)),
8466                "labels" if !duplicate => self
8467                    .parse_labels(&option)
8468                    .into_iter()
8469                    .for_each(|value| network.set_labels(value)),
8470                "name" if !duplicate => self
8471                    .parse_string(&option, "network custom name")
8472                    .into_iter()
8473                    .for_each(|value| network.set_custom_name(value)),
8474                name if name.starts_with("x-") => network.push_extension(option.reference()),
8475                _ if duplicate => {}
8476                _ => network.push_unknown(option.reference()),
8477            }
8478        }
8479        self.validate_resource_external_name(network.external(), network.custom_name(), network.span());
8480        network
8481    }
8482
8483    fn parse_ipam(&mut self, field: &ParsedField) -> Option<Ipam> {
8484        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8485            self.expected(EXPECTED_MAPPING, field, "network IPAM must be a mapping");
8486            return None;
8487        };
8488        let span = span_from_position(self.source_id, mapping.byte_range());
8489        let mut ipam = Ipam::new(span);
8490        let mut seen = BTreeMap::new();
8491        for option in self.fields(mapping) {
8492            let duplicate = self.record_duplicate(&mut seen, &option);
8493            match option.name.value.as_str() {
8494                "driver" if !duplicate => self
8495                    .parse_string(&option, "IPAM driver")
8496                    .into_iter()
8497                    .for_each(|value| ipam.set_driver(value)),
8498                "config" if !duplicate => ipam.set_config(self.parse_ipam_configs(&option)),
8499                "options" if !duplicate => {
8500                    ipam.set_options(self.parse_scalar_mapping(&option, "IPAM options"));
8501                }
8502                name if name.starts_with("x-") => ipam.push_extension(option.reference()),
8503                _ if duplicate => {}
8504                _ => ipam.push_unknown(option.reference()),
8505            }
8506        }
8507        Some(ipam)
8508    }
8509
8510    fn parse_ipam_configs(&mut self, field: &ParsedField) -> Vec<IpamConfig> {
8511        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
8512            self.expected(EXPECTED_SEQUENCE, field, "IPAM config must be a sequence");
8513            return Vec::new();
8514        };
8515        let mut configs = Vec::new();
8516        for value in sequence.values() {
8517            let YamlNode::Mapping(mapping) = value else {
8518                self.unsupported_sequence_item(
8519                    EXPECTED_MAPPING,
8520                    &value,
8521                    field.span,
8522                    "IPAM config entries must be mappings",
8523                );
8524                continue;
8525            };
8526            configs.push(self.parse_ipam_config(&mapping));
8527        }
8528        configs
8529    }
8530
8531    fn parse_ipam_config(&mut self, mapping: &Mapping) -> IpamConfig {
8532        let span = span_from_position(self.source_id, mapping.byte_range());
8533        let mut config = IpamConfig::new(span);
8534        let mut seen = BTreeMap::new();
8535        for field in self.fields(mapping) {
8536            let duplicate = self.record_duplicate(&mut seen, &field);
8537            match field.name.value.as_str() {
8538                "subnet" if !duplicate => self
8539                    .parse_string(&field, "IPAM subnet")
8540                    .into_iter()
8541                    .for_each(|value| config.set_subnet(value)),
8542                "ip_range" if !duplicate => self
8543                    .parse_string(&field, "IPAM allocation range")
8544                    .into_iter()
8545                    .for_each(|value| config.set_ip_range(value)),
8546                "gateway" if !duplicate => self
8547                    .parse_string(&field, "IPAM gateway")
8548                    .into_iter()
8549                    .for_each(|value| config.set_gateway(value)),
8550                "aux_addresses" if !duplicate => {
8551                    config.set_aux_addresses(self.parse_scalar_mapping(&field, "IPAM auxiliary addresses"));
8552                }
8553                name if name.starts_with("x-") => config.push_extension(field.reference()),
8554                _ if duplicate => {}
8555                _ => config.push_unknown(field.reference()),
8556            }
8557        }
8558        config
8559    }
8560
8561    fn parse_volume_definitions(&mut self, field: &ParsedField) -> Vec<VolumeDefinition> {
8562        let Some(mapping) = self.resource_collection(field, "volumes") else {
8563            return Vec::new();
8564        };
8565        let mut definitions = Vec::new();
8566        let mut seen = BTreeMap::new();
8567        for resource in self.fields(&mapping) {
8568            if self.record_duplicate(&mut seen, &resource) {
8569                continue;
8570            }
8571            let mut volume = VolumeDefinition::new(resource.name.clone(), resource.span);
8572            if Self::field_is_null(&resource) {
8573                definitions.push(volume);
8574                continue;
8575            }
8576            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
8577                self.expected(
8578                    RESOURCE_EXPECTED_FORM,
8579                    &resource,
8580                    "volume definition must be a mapping or null",
8581                );
8582                continue;
8583            };
8584            let mut nested_seen = BTreeMap::new();
8585            for option in self.fields(definition) {
8586                let duplicate = self.record_duplicate(&mut nested_seen, &option);
8587                match option.name.value.as_str() {
8588                    "driver" if !duplicate => self
8589                        .parse_string(&option, "volume driver")
8590                        .into_iter()
8591                        .for_each(|value| volume.set_driver(value)),
8592                    "driver_opts" if !duplicate => {
8593                        volume.set_driver_opts(self.parse_scalar_mapping(&option, "volume driver options"));
8594                    }
8595                    "external" if !duplicate => self
8596                        .parse_resource_external(&option, "volume")
8597                        .into_iter()
8598                        .for_each(|value| volume.set_external(value)),
8599                    "labels" if !duplicate => self
8600                        .parse_labels(&option)
8601                        .into_iter()
8602                        .for_each(|value| volume.set_labels(value)),
8603                    "name" if !duplicate => self
8604                        .parse_string(&option, "volume custom name")
8605                        .into_iter()
8606                        .for_each(|value| volume.set_custom_name(value)),
8607                    name if name.starts_with("x-") => volume.push_extension(option.reference()),
8608                    _ if duplicate => {}
8609                    _ => volume.push_unknown(option.reference()),
8610                }
8611            }
8612            self.validate_external_volume_driver_configuration(&volume);
8613            self.validate_external_volume_labels_configuration(&volume);
8614            self.validate_resource_external_name(volume.external(), volume.custom_name(), volume.span());
8615            definitions.push(volume);
8616        }
8617        definitions
8618    }
8619
8620    fn validate_external_volume_driver_configuration(&mut self, volume: &VolumeDefinition) {
8621        if !volume.external().is_some_and(ResourceExternal::is_explicitly_external)
8622            || (volume.driver().is_none() && volume.driver_opts().is_empty())
8623        {
8624            return;
8625        }
8626        let span = volume
8627            .driver()
8628            .map(Located::span)
8629            .or_else(|| volume.driver_opts().first().map(KeyValueEntry::span))
8630            .unwrap_or_else(|| volume.span());
8631        self.diagnostics.push(
8632            Diagnostic::new(
8633                VOLUME_EXTERNAL_DRIVER_CONFIGURATION,
8634                Severity::Error,
8635                "external volume cannot also configure `driver` or `driver_opts`",
8636            )
8637            .with_label(DiagnosticLabel::primary(
8638                span,
8639                "driver configuration remains retained for review",
8640            )),
8641        );
8642    }
8643
8644    fn validate_external_volume_labels_configuration(&mut self, volume: &VolumeDefinition) {
8645        if !volume.external().is_some_and(ResourceExternal::is_explicitly_external) || volume.labels().is_none() {
8646            return;
8647        }
8648        let span = volume.labels().map_or_else(|| volume.span(), Labels::span);
8649        self.diagnostics.push(
8650            Diagnostic::new(
8651                VOLUME_EXTERNAL_LABELS_CONFIGURATION,
8652                Severity::Error,
8653                "external volume cannot also configure `labels`",
8654            )
8655            .with_label(DiagnosticLabel::primary(span, "labels remain retained for review")),
8656        );
8657    }
8658
8659    fn parse_config_definitions(&mut self, field: &ParsedField) -> Vec<ConfigDefinition> {
8660        let Some(mapping) = self.resource_collection(field, "configs") else {
8661            return Vec::new();
8662        };
8663        let mut definitions = Vec::new();
8664        let mut seen = BTreeMap::new();
8665        for resource in self.fields(&mapping) {
8666            if self.record_duplicate(&mut seen, &resource) {
8667                continue;
8668            }
8669            let mut config = ConfigDefinition::new(resource.name.clone(), resource.span);
8670            if Self::field_is_null(&resource) {
8671                definitions.push(config);
8672                continue;
8673            }
8674            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
8675                self.expected(
8676                    RESOURCE_EXPECTED_FORM,
8677                    &resource,
8678                    "config definition must be a mapping or null",
8679                );
8680                continue;
8681            };
8682            let mut nested_seen = BTreeMap::new();
8683            for option in self.fields(definition) {
8684                let duplicate = self.record_duplicate(&mut nested_seen, &option);
8685                match option.name.value.as_str() {
8686                    "file" if !duplicate => self
8687                        .parse_string(&option, "config file")
8688                        .into_iter()
8689                        .for_each(|value| config.set_file(value)),
8690                    "environment" if !duplicate => self
8691                        .parse_string(&option, "config environment source")
8692                        .into_iter()
8693                        .for_each(|value| config.set_environment(value)),
8694                    "content" if !duplicate => self
8695                        .parse_string(&option, "config content")
8696                        .into_iter()
8697                        .for_each(|value| config.set_content(value)),
8698                    "external" if !duplicate => self
8699                        .parse_resource_external(&option, "config")
8700                        .into_iter()
8701                        .for_each(|value| config.set_external(value)),
8702                    "labels" if !duplicate => self
8703                        .parse_labels(&option)
8704                        .into_iter()
8705                        .for_each(|value| config.set_labels(value)),
8706                    "template_driver" if !duplicate => self
8707                        .parse_extends_string(&option, "config template_driver must be a YAML string scalar")
8708                        .into_iter()
8709                        .for_each(|value| config.set_template_driver(value)),
8710                    "name" if !duplicate => self
8711                        .parse_string(&option, "config custom name")
8712                        .into_iter()
8713                        .for_each(|value| config.set_custom_name(value)),
8714                    name if name.starts_with("x-") => config.push_extension(option.reference()),
8715                    _ if duplicate => {}
8716                    _ => config.push_unknown(option.reference()),
8717                }
8718            }
8719            self.validate_resource_external_name(config.external(), config.custom_name(), config.span());
8720            self.validate_external_creation_configuration(
8721                config.external(),
8722                &[
8723                    config.file().map(Located::span),
8724                    config.environment().map(Located::span),
8725                    config.content().map(Located::span),
8726                    config.labels().map(Labels::span),
8727                    config.template_driver().map(Located::span),
8728                ],
8729                "config",
8730            );
8731            definitions.push(config);
8732        }
8733        definitions
8734    }
8735
8736    fn parse_secret_definitions(&mut self, field: &ParsedField) -> Vec<SecretDefinition> {
8737        let Some(mapping) = self.resource_collection(field, "secrets") else {
8738            return Vec::new();
8739        };
8740        let mut definitions = Vec::new();
8741        let mut seen = BTreeMap::new();
8742        for resource in self.fields(&mapping) {
8743            if self.record_duplicate(&mut seen, &resource) {
8744                continue;
8745            }
8746            let mut secret = SecretDefinition::new(resource.name.clone(), resource.span);
8747            if Self::field_is_null(&resource) {
8748                definitions.push(secret);
8749                continue;
8750            }
8751            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
8752                self.expected(
8753                    RESOURCE_EXPECTED_FORM,
8754                    &resource,
8755                    "secret definition must be a mapping or null",
8756                );
8757                continue;
8758            };
8759            let mut nested_seen = BTreeMap::new();
8760            for option in self.fields(definition) {
8761                let duplicate = self.record_duplicate(&mut nested_seen, &option);
8762                match option.name.value.as_str() {
8763                    "file" if !duplicate => self
8764                        .parse_string(&option, "secret file")
8765                        .into_iter()
8766                        .for_each(|value| secret.set_file(value)),
8767                    "environment" if !duplicate => self
8768                        .parse_string(&option, "secret environment source")
8769                        .into_iter()
8770                        .for_each(|value| secret.set_environment(value)),
8771                    "external" if !duplicate => self
8772                        .parse_resource_external(&option, "secret")
8773                        .into_iter()
8774                        .for_each(|value| secret.set_external(value)),
8775                    "driver" if !duplicate => self
8776                        .parse_extends_string(&option, "secret driver must be a YAML string scalar")
8777                        .into_iter()
8778                        .for_each(|value| secret.set_driver(value)),
8779                    "driver_opts" if !duplicate => {
8780                        secret.set_driver_opts(self.parse_scalar_mapping(&option, "secret driver options"));
8781                    }
8782                    "labels" if !duplicate => self
8783                        .parse_labels(&option)
8784                        .into_iter()
8785                        .for_each(|value| secret.set_labels(value)),
8786                    "template_driver" if !duplicate => self
8787                        .parse_extends_string(&option, "secret template_driver must be a YAML string scalar")
8788                        .into_iter()
8789                        .for_each(|value| secret.set_template_driver(value)),
8790                    "name" if !duplicate => self
8791                        .parse_string(&option, "secret custom name")
8792                        .into_iter()
8793                        .for_each(|value| secret.set_custom_name(value)),
8794                    name if name.starts_with("x-") => secret.push_extension(option.reference()),
8795                    _ if duplicate => {}
8796                    _ => secret.push_unknown(option.reference()),
8797                }
8798            }
8799            self.validate_resource_external_name(secret.external(), secret.custom_name(), secret.span());
8800            self.validate_external_creation_configuration(
8801                secret.external(),
8802                &[
8803                    secret.file().map(Located::span),
8804                    secret.environment().map(Located::span),
8805                    secret.driver().map(Located::span),
8806                    secret.driver_opts().first().map(KeyValueEntry::span),
8807                    secret.labels().map(Labels::span),
8808                    secret.template_driver().map(Located::span),
8809                ],
8810                "secret",
8811            );
8812            definitions.push(secret);
8813        }
8814        definitions
8815    }
8816
8817    fn parse_resource_external(&mut self, field: &ParsedField, kind: &str) -> Option<ResourceExternal> {
8818        let Some(value) = field.value.as_ref() else {
8819            self.expected(
8820                EXPECTED_FIELD_FORM,
8821                field,
8822                format!("{kind} external must be a boolean or mapping"),
8823            );
8824            return None;
8825        };
8826        let Some(mapping) = value.as_mapping() else {
8827            return self
8828                .parse_boolean(field, &format!("{kind} external"))
8829                .map(ResourceExternal::Boolean);
8830        };
8831        let mut name_mapping = ExternalNameMapping::new(span_from_position(self.source_id, mapping.byte_range()));
8832        let mut seen = BTreeMap::new();
8833        for member in self.fields(mapping) {
8834            if self.record_duplicate(&mut seen, &member) {
8835                name_mapping.push_unknown(member.reference());
8836                continue;
8837            }
8838            match member.name.value().as_str() {
8839                "name" => {
8840                    match self.parse_extends_string(&member, "deprecated external name must be a YAML string scalar") {
8841                        Some(value) if !value.value().is_empty() => name_mapping.set_name(value),
8842                        Some(value) => {
8843                            self.expected(
8844                                EXPECTED_SCALAR,
8845                                &member,
8846                                "deprecated external name must be a non-empty YAML string scalar",
8847                            );
8848                            let _ = value;
8849                            name_mapping.push_unknown(member.reference());
8850                        }
8851                        None => name_mapping.push_unknown(member.reference()),
8852                    }
8853                }
8854                name if name.starts_with("x-") => name_mapping.push_extension(member.reference()),
8855                _ => name_mapping.push_unknown(member.reference()),
8856            }
8857        }
8858        if name_mapping.name().is_none() {
8859            self.missing(
8860                EXPECTED_SCALAR,
8861                name_mapping.span(),
8862                "deprecated external mapping requires a non-empty `name` string",
8863            );
8864        }
8865        self.diagnostics.push(
8866            Diagnostic::new(
8867                RESOURCE_EXTERNAL_NAME_MAPPING_DEPRECATED,
8868                Severity::Warning,
8869                format!("{kind} `external.name` mapping syntax is deprecated"),
8870            )
8871            .with_label(DiagnosticLabel::primary(
8872                name_mapping.span(),
8873                "use `name: VALUE` with `external: true` instead",
8874            )),
8875        );
8876        Some(ResourceExternal::NameMapping(Box::new(name_mapping)))
8877    }
8878
8879    fn validate_resource_external_name(
8880        &mut self,
8881        external: Option<&ResourceExternal>,
8882        custom_name: Option<&Located<String>>,
8883        fallback: SourceSpan,
8884    ) {
8885        let (Some(ResourceExternal::NameMapping(name_mapping)), Some(modern)) = (external, custom_name) else {
8886            return;
8887        };
8888        let mapping_span = name_mapping.name().map_or_else(|| name_mapping.span(), Located::span);
8889        self.diagnostics.push(
8890            Diagnostic::new(
8891                RESOURCE_EXTERNAL_NAME_CONFLICT,
8892                Severity::Error,
8893                "resource cannot use both `name` and deprecated `external.name` mapping syntax",
8894            )
8895            .with_label(DiagnosticLabel::primary(modern.span(), "resource name"))
8896            .with_label(DiagnosticLabel::secondary(mapping_span, "deprecated external name"))
8897            .with_label(DiagnosticLabel::secondary(fallback, "both values remain retained")),
8898        );
8899    }
8900
8901    fn validate_external_creation_configuration(
8902        &mut self,
8903        external: Option<&ResourceExternal>,
8904        fields: &[Option<SourceSpan>],
8905        kind: &str,
8906    ) {
8907        if !external.is_some_and(ResourceExternal::is_explicitly_external) {
8908            return;
8909        }
8910        let Some(span) = fields.iter().flatten().next().copied() else {
8911            return;
8912        };
8913        self.diagnostics.push(
8914            Diagnostic::new(
8915                RESOURCE_EXTERNAL_CREATION_CONFIGURATION,
8916                Severity::Error,
8917                format!("external {kind} cannot also configure creation-time metadata"),
8918            )
8919            .with_label(DiagnosticLabel::primary(span, "metadata remains retained for review")),
8920        );
8921    }
8922
8923    fn resource_collection(&mut self, field: &ParsedField, kind: &str) -> Option<Mapping> {
8924        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8925            self.expected(EXPECTED_MAPPING, field, format!("top-level {kind} must be a mapping"));
8926            return None;
8927        };
8928        Some(mapping.clone())
8929    }
8930
8931    fn parse_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
8932        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
8933            self.expected(EXPECTED_SCALAR, field, format!("{description} must be a scalar"));
8934            return None;
8935        };
8936        if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
8937            self.expected(
8938                EXPECTED_SCALAR,
8939                field,
8940                format!("{description} must be a non-null scalar"),
8941            );
8942            return None;
8943        }
8944        Some(Located::new(
8945            scalar_string_from_source(&self.source, scalar),
8946            span_from_position(self.source_id, scalar.byte_range()),
8947        ))
8948    }
8949
8950    fn parse_non_empty_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
8951        let value = self.parse_string(field, description)?;
8952        if value.value().is_empty() {
8953            self.diagnostics.push(
8954                Diagnostic::new(
8955                    BUILD_DOCKERFILE_EXPECTED_NON_EMPTY,
8956                    Severity::Error,
8957                    format!("{description} must be a non-empty scalar"),
8958                )
8959                .with_label(DiagnosticLabel::primary(value.span(), "empty scalar retained")),
8960            );
8961            return None;
8962        }
8963        Some(value)
8964    }
8965
8966    fn parse_build_dockerfile(&mut self, definition: &mut BuildDefinition, field: &ParsedField) -> FieldReference {
8967        if let Some(dockerfile) = self.parse_non_empty_string(field, "build dockerfile") {
8968            definition.set_dockerfile(dockerfile);
8969        }
8970        field.reference()
8971    }
8972
8973    fn set_build_dockerfile_inline(
8974        &mut self,
8975        definition: &mut BuildDefinition,
8976        field: &ParsedField,
8977        dockerfile_inline: &mut Option<FieldReference>,
8978    ) {
8979        *dockerfile_inline = Some(field.reference());
8980        if let Some(value) = self.parse_build_dockerfile_inline(field) {
8981            definition.set_dockerfile_inline(value);
8982        }
8983    }
8984
8985    fn parse_build_dockerfile_inline(&mut self, field: &ParsedField) -> Option<Located<String>> {
8986        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
8987            self.expected(
8988                EXPECTED_SCALAR,
8989                field,
8990                "build dockerfile_inline must be a YAML string scalar",
8991            );
8992            return None;
8993        };
8994        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
8995            self.expected(
8996                EXPECTED_SCALAR,
8997                field,
8998                "build dockerfile_inline must be a YAML string scalar",
8999            );
9000            return None;
9001        }
9002        Some(Located::new(
9003            scalar_string_from_source(&self.source, scalar),
9004            span_from_position(self.source_id, scalar.byte_range()),
9005        ))
9006    }
9007
9008    fn set_build_no_cache(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9009        if let Some(no_cache) = self.parse_build_no_cache(field) {
9010            definition.set_no_cache(no_cache);
9011        }
9012    }
9013
9014    fn set_build_sbom(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9015        if let Some(sbom) = self.parse_build_sbom(field) {
9016            definition.set_sbom(sbom);
9017        }
9018    }
9019
9020    fn set_build_provenance(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9021        if let Some(value) = self.parse_build_provenance(field) {
9022            definition.set_provenance(value);
9023        }
9024    }
9025
9026    fn set_build_isolation(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9027        if let Some(isolation) = self.parse_build_isolation(field) {
9028            definition.set_isolation(isolation);
9029        }
9030    }
9031
9032    fn parse_build_isolation(&mut self, field: &ParsedField) -> Option<Located<String>> {
9033        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9034            self.expected(
9035                BUILD_ISOLATION_EXPECTED_STRING,
9036                field,
9037                "build isolation must be a YAML string scalar",
9038            );
9039            return None;
9040        };
9041        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
9042            self.expected(
9043                BUILD_ISOLATION_EXPECTED_STRING,
9044                field,
9045                "build isolation must be a YAML string scalar",
9046            );
9047            return None;
9048        }
9049        Some(Located::new(
9050            scalar_string_from_source(&self.source, scalar),
9051            span_from_position(self.source_id, scalar.byte_range()),
9052        ))
9053    }
9054
9055    fn parse_boolean(&mut self, field: &ParsedField, description: &str) -> Option<Located<BooleanValue>> {
9056        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9057            self.expected(EXPECTED_BOOLEAN, field, format!("{description} must be a boolean"));
9058            return None;
9059        };
9060        let span = span_from_position(self.source_id, scalar.byte_range());
9061        let scalar_value = ScalarValue::from_scalar(scalar);
9062        if let Some(value) = scalar_value.to_bool() {
9063            return Some(Located::new(BooleanValue::Literal(value), span));
9064        }
9065        let value = scalar_string_from_source(&self.source, scalar);
9066        if value.contains('$') {
9067            return Some(Located::new(BooleanValue::Expression(value), span));
9068        }
9069        self.diagnostics.push(
9070            Diagnostic::new(
9071                EXPECTED_BOOLEAN,
9072                Severity::Error,
9073                format!("{description} must be a boolean or interpolation expression"),
9074            )
9075            .with_label(DiagnosticLabel::primary(span, "not a boolean expression")),
9076        );
9077        None
9078    }
9079
9080    fn parse_build_no_cache(&mut self, field: &ParsedField) -> Option<Located<BuildNoCache>> {
9081        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9082            self.expected(
9083                BUILD_NO_CACHE_EXPECTED_BOOLEAN_OR_STRING,
9084                field,
9085                "build no_cache must be a YAML boolean or string scalar",
9086            );
9087            return None;
9088        };
9089        let span = span_from_position(self.source_id, scalar.byte_range());
9090        let scalar_value = ScalarValue::from_scalar(scalar);
9091        let value = match scalar_value.scalar_type() {
9092            ScalarType::Boolean => BuildNoCache::Boolean(scalar_value.to_bool().unwrap_or(false)),
9093            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9094                BuildNoCache::String(scalar_string_from_source(&self.source, scalar))
9095            }
9096            ScalarType::Null | ScalarType::Integer | ScalarType::Float => {
9097                self.expected(
9098                    BUILD_NO_CACHE_EXPECTED_BOOLEAN_OR_STRING,
9099                    field,
9100                    "build no_cache must be a YAML boolean or string scalar",
9101                );
9102                return None;
9103            }
9104        };
9105        Some(Located::new(value, span))
9106    }
9107    fn parse_build_no_cache_filter(&mut self, field: &ParsedField) -> Option<BuildNoCacheFilter> {
9108        match field.value.as_ref() {
9109            Some(YamlNode::Scalar(s)) if ScalarValue::from_scalar(s).scalar_type() == ScalarType::String => {
9110                Some(BuildNoCacheFilter::Scalar(Located::new(
9111                    scalar_string_from_source(&self.source, s),
9112                    span_from_position(self.source_id, s.byte_range()),
9113                )))
9114            }
9115            Some(YamlNode::Sequence(seq)) => {
9116                let values = self.parse_string_scalar_nodes(
9117                    seq.values(),
9118                    field.span,
9119                    "build no_cache_filter entries must be string scalars",
9120                );
9121                let mut seen = BTreeSet::new();
9122                for value in &values {
9123                    if !seen.insert(value.value().clone()) {
9124                        self.diagnostics.push(
9125                            Diagnostic::new(
9126                                BUILD_NO_CACHE_FILTER_DUPLICATE_ITEM,
9127                                Severity::Warning,
9128                                "build no_cache_filter retains duplicate stage",
9129                            )
9130                            .with_label(DiagnosticLabel::primary(value.span(), "duplicate retained")),
9131                        );
9132                    }
9133                }
9134                Some(BuildNoCacheFilter::List(values))
9135            }
9136            _ => {
9137                self.expected(
9138                    EXPECTED_FIELD_FORM,
9139                    field,
9140                    "build no_cache_filter must be a string scalar or sequence",
9141                );
9142                None
9143            }
9144        }
9145    }
9146    fn set_build_no_cache_filter(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9147        if let Some(value) = self.parse_build_no_cache_filter(field) {
9148            definition.set_no_cache_filter(value);
9149        }
9150    }
9151    fn set_build_privileged(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9152        if let Some(value) = self.parse_boolean(field, "build privileged") {
9153            definition.set_privileged(value);
9154        }
9155    }
9156
9157    fn parse_build_sbom(&mut self, field: &ParsedField) -> Option<Located<BuildSbom>> {
9158        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9159            self.expected(
9160                BUILD_SBOM_EXPECTED_BOOLEAN_OR_STRING,
9161                field,
9162                "build sbom must be a YAML boolean or string scalar",
9163            );
9164            return None;
9165        };
9166        let span = span_from_position(self.source_id, scalar.byte_range());
9167        let scalar_value = ScalarValue::from_scalar(scalar);
9168        let value = match scalar_value.scalar_type() {
9169            ScalarType::Boolean => BuildSbom::Boolean(scalar_value.to_bool().unwrap_or(false)),
9170            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9171                BuildSbom::String(scalar_string_from_source(&self.source, scalar))
9172            }
9173            ScalarType::Null | ScalarType::Integer | ScalarType::Float => {
9174                self.expected(
9175                    BUILD_SBOM_EXPECTED_BOOLEAN_OR_STRING,
9176                    field,
9177                    "build sbom must be a YAML boolean or string scalar",
9178                );
9179                return None;
9180            }
9181        };
9182        Some(Located::new(value, span))
9183    }
9184
9185    fn parse_build_provenance(&mut self, field: &ParsedField) -> Option<Located<BuildProvenance>> {
9186        let scalar = field.value.as_ref().and_then(YamlNode::as_scalar)?;
9187        let span = span_from_position(self.source_id, scalar.byte_range());
9188        let value = match ScalarValue::from_scalar(scalar).scalar_type() {
9189            ScalarType::Boolean => {
9190                BuildProvenance::Boolean(ScalarValue::from_scalar(scalar).to_bool().unwrap_or(false))
9191            }
9192            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9193                BuildProvenance::String(scalar_string_from_source(&self.source, scalar))
9194            }
9195            _ => {
9196                self.expected(
9197                    EXPECTED_SCALAR,
9198                    field,
9199                    "build provenance must be a YAML boolean or string scalar",
9200                );
9201                return None;
9202            }
9203        };
9204        Some(Located::new(value, span))
9205    }
9206
9207    fn parse_string_sequence(&mut self, field: &ParsedField, description: &str) -> Vec<Located<String>> {
9208        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
9209            self.expected(EXPECTED_SEQUENCE, field, format!("{description} must be a sequence"));
9210            return Vec::new();
9211        };
9212        self.parse_scalar_nodes(
9213            sequence.values(),
9214            field.span,
9215            format!("{description} entries must be scalars"),
9216        )
9217    }
9218
9219    fn parse_strict_string_sequence(
9220        &mut self,
9221        field: &ParsedField,
9222        description: &str,
9223    ) -> (Vec<Located<String>>, Vec<InvalidServiceStringItem>) {
9224        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
9225            self.expected(EXPECTED_SEQUENCE, field, format!("{description} must be a sequence"));
9226            return (Vec::new(), Vec::new());
9227        };
9228        let mut values = Vec::new();
9229        let mut invalid = Vec::new();
9230        for node in sequence.values() {
9231            let span = match &node {
9232                YamlNode::Scalar(value) => span_from_position(self.source_id, value.byte_range()),
9233                YamlNode::Sequence(value) => span_from_position(self.source_id, value.byte_range()),
9234                YamlNode::Mapping(value) => span_from_position(self.source_id, value.byte_range()),
9235                _ => field.span,
9236            };
9237            let Some(scalar) = node.as_scalar() else {
9238                self.unsupported_sequence_item(
9239                    EXPECTED_SCALAR,
9240                    &node,
9241                    field.span,
9242                    format!("{description} entries must be YAML string scalars"),
9243                );
9244                invalid.push(InvalidServiceStringItem::new(span));
9245                continue;
9246            };
9247            if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
9248                self.unsupported_sequence_item(
9249                    EXPECTED_SCALAR,
9250                    &node,
9251                    field.span,
9252                    format!("{description} entries must be YAML string scalars"),
9253                );
9254                invalid.push(InvalidServiceStringItem::new(span));
9255                continue;
9256            }
9257            values.push(Located::new(scalar_string_from_source(&self.source, scalar), span));
9258        }
9259        (values, invalid)
9260    }
9261
9262    fn parse_scalar_nodes(
9263        &mut self,
9264        nodes: impl Iterator<Item = YamlNode>,
9265        fallback_span: SourceSpan,
9266        message: impl Into<String>,
9267    ) -> Vec<Located<String>> {
9268        let message = message.into();
9269        let mut values = Vec::new();
9270        for node in nodes {
9271            let YamlNode::Scalar(scalar) = node else {
9272                self.unsupported_sequence_item(EXPECTED_SCALAR, &node, fallback_span, &message);
9273                continue;
9274            };
9275            let scalar_value = ScalarValue::from_scalar(&scalar);
9276            if scalar_value.scalar_type() == ScalarType::Null {
9277                self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar), fallback_span, &message);
9278                continue;
9279            }
9280            let span = span_from_position(self.source_id, scalar.byte_range());
9281            values.push(Located::new(scalar_string_from_source(&self.source, &scalar), span));
9282        }
9283        values
9284    }
9285
9286    fn parse_scalar_mapping(&mut self, field: &ParsedField, description: &str) -> Vec<KeyValueEntry> {
9287        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
9288            self.expected(EXPECTED_MAPPING, field, format!("{description} must be a mapping"));
9289            return Vec::new();
9290        };
9291        let mut entries = Vec::new();
9292        let mut seen = BTreeMap::new();
9293        for entry in self.fields(mapping) {
9294            if self.record_duplicate(&mut seen, &entry) {
9295                continue;
9296            }
9297            if let Some(value) = self.parse_compose_scalar(&entry, format!("{description} values must be scalars")) {
9298                entries.push(KeyValueEntry::new(entry.name, value, entry.span));
9299            }
9300        }
9301        entries
9302    }
9303
9304    fn parse_compose_scalar(
9305        &mut self,
9306        field: &ParsedField,
9307        message: impl Into<String>,
9308    ) -> Option<Located<ComposeScalar>> {
9309        let Some(node) = field.value.as_ref() else {
9310            return Some(Located::new(ComposeScalar::Null, field.name.span));
9311        };
9312        let Some(scalar) = node.as_scalar() else {
9313            self.expected(EXPECTED_SCALAR, field, message);
9314            return None;
9315        };
9316        let span = span_from_position(self.source_id, scalar.byte_range());
9317        let value = ScalarValue::from_scalar(scalar);
9318        let typed = match value.scalar_type() {
9319            ScalarType::Null => ComposeScalar::Null,
9320            ScalarType::Boolean => ComposeScalar::Boolean(value.to_bool().unwrap_or(false)),
9321            ScalarType::Integer | ScalarType::Float => {
9322                ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
9323            }
9324            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9325                ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
9326            }
9327        };
9328        Some(Located::new(typed, span))
9329    }
9330
9331    fn parse_integer_or_string_scalar(
9332        &mut self,
9333        field: &ParsedField,
9334        message: impl Into<String>,
9335    ) -> Option<Located<ComposeScalar>> {
9336        let message = message.into();
9337        let Some(node) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9338            self.expected(EXPECTED_FIELD_FORM, field, message);
9339            return None;
9340        };
9341        let type_ = ScalarValue::from_scalar(node).scalar_type();
9342        if type_ != ScalarType::Integer && type_ != ScalarType::String {
9343            self.expected(EXPECTED_FIELD_FORM, field, message);
9344            return None;
9345        }
9346        self.parse_compose_scalar(field, "integer or string scalar already checked")
9347    }
9348
9349    fn parse_string_or_number_scalar(
9350        &mut self,
9351        field: &ParsedField,
9352        message: impl Into<String>,
9353    ) -> Option<Located<ComposeScalar>> {
9354        let message = message.into();
9355        let Some(node) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9356            self.expected(EXPECTED_FIELD_FORM, field, message);
9357            return None;
9358        };
9359        if !matches!(
9360            ScalarValue::from_scalar(node).scalar_type(),
9361            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex | ScalarType::Integer | ScalarType::Float
9362        ) {
9363            self.expected(EXPECTED_FIELD_FORM, field, message);
9364            return None;
9365        }
9366        self.parse_compose_scalar(field, "number or string scalar already checked")
9367    }
9368
9369    fn parse_integer_scalar(
9370        &mut self,
9371        field: &ParsedField,
9372        message: impl Into<String>,
9373    ) -> Option<Located<ComposeScalar>> {
9374        let message = message.into();
9375        let node = field.value.as_ref().and_then(YamlNode::as_scalar)?;
9376        if ScalarValue::from_scalar(node).scalar_type() != ScalarType::Integer {
9377            self.expected(EXPECTED_FIELD_FORM, field, message);
9378            return None;
9379        }
9380        self.parse_compose_scalar(field, "integer scalar already checked")
9381    }
9382
9383    fn parse_labels(&mut self, field: &ParsedField) -> Option<Labels> {
9384        match field.value.as_ref() {
9385            Some(YamlNode::Sequence(sequence)) => {
9386                let span = span_from_position(self.source_id, sequence.byte_range());
9387                let values = self.parse_string_scalar_nodes(
9388                    sequence.values(),
9389                    field.span,
9390                    "label list entries must be string scalars",
9391                );
9392                Some(Labels::List { span, values })
9393            }
9394            Some(YamlNode::Mapping(mapping)) => {
9395                let span = span_from_position(self.source_id, mapping.byte_range());
9396                let entries = self.parse_scalar_mapping(field, "labels");
9397                Some(Labels::Map { span, entries })
9398            }
9399            _ => {
9400                self.expected(EXPECTED_FIELD_FORM, field, "labels must be a sequence or mapping");
9401                None
9402            }
9403        }
9404    }
9405
9406    fn parse_string_scalar_nodes(
9407        &mut self,
9408        nodes: impl Iterator<Item = YamlNode>,
9409        fallback_span: SourceSpan,
9410        message: impl Into<String>,
9411    ) -> Vec<Located<String>> {
9412        let message = message.into();
9413        let mut values = Vec::new();
9414        for node in nodes {
9415            let YamlNode::Scalar(scalar) = node else {
9416                self.unsupported_sequence_item(EXPECTED_SCALAR, &node, fallback_span, &message);
9417                continue;
9418            };
9419            if !matches!(
9420                ScalarValue::from_scalar(&scalar).scalar_type(),
9421                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
9422            ) {
9423                self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar), fallback_span, &message);
9424                continue;
9425            }
9426            let span = span_from_position(self.source_id, scalar.byte_range());
9427            values.push(Located::new(scalar_string_from_source(&self.source, &scalar), span));
9428        }
9429        values
9430    }
9431
9432    fn parse_annotations(&mut self, field: &ParsedField) -> Option<Annotations> {
9433        match field.value.as_ref() {
9434            Some(YamlNode::Sequence(sequence)) => Some(self.parse_annotation_list(sequence, field.span)),
9435            Some(YamlNode::Mapping(mapping)) => Some(self.parse_annotation_map(mapping)),
9436            _ => {
9437                self.expected(
9438                    ANNOTATIONS_EXPECTED_FORM,
9439                    field,
9440                    "annotations must be a sequence or mapping",
9441                );
9442                None
9443            }
9444        }
9445    }
9446
9447    fn parse_annotation_list(&mut self, sequence: &yaml_edit::Sequence, fallback: SourceSpan) -> Annotations {
9448        let span = span_from_position(self.source_id, sequence.byte_range());
9449        let mut values = Vec::new();
9450        let mut seen = BTreeSet::new();
9451        for node in sequence.values() {
9452            let YamlNode::Scalar(scalar) = node else {
9453                self.unsupported_sequence_item(
9454                    ANNOTATIONS_EXPECTED_STRING,
9455                    &node,
9456                    fallback,
9457                    "annotation list entries must be string scalars",
9458                );
9459                continue;
9460            };
9461            let item_span = span_from_position(self.source_id, scalar.byte_range());
9462            let scalar_value = ScalarValue::from_scalar(&scalar);
9463            let value = match scalar_value.scalar_type() {
9464                ScalarType::Null => ComposeScalar::Null,
9465                ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
9466                ScalarType::Integer | ScalarType::Float => {
9467                    ComposeScalar::Number(scalar_string_from_source(&self.source, &scalar))
9468                }
9469                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9470                    ComposeScalar::String(scalar_string_from_source(&self.source, &scalar))
9471                }
9472            };
9473            self.validate_annotation_list_scalar(&value, item_span, &mut seen);
9474            values.push(Located::new(value, item_span));
9475        }
9476        Annotations::new(span, AnnotationsForm::List(values))
9477    }
9478
9479    fn validate_annotation_list_scalar(
9480        &mut self,
9481        value: &ComposeScalar,
9482        span: SourceSpan,
9483        seen: &mut BTreeSet<String>,
9484    ) {
9485        let ComposeScalar::String(raw) = value else {
9486            self.diagnostics.push(annotation_diagnostic(
9487                ANNOTATIONS_EXPECTED_STRING,
9488                Severity::Error,
9489                span,
9490                "annotation list entries must be string scalars",
9491                "non-string annotation item retained",
9492            ));
9493            return;
9494        };
9495        let name = raw.split_once('=').map_or(raw.as_str(), |(name, _)| name);
9496        if name.is_empty() {
9497            self.diagnostics.push(annotation_diagnostic(
9498                ANNOTATIONS_EMPTY_NAME,
9499                Severity::Error,
9500                span,
9501                "service annotation name must not be empty",
9502                "empty annotation name",
9503            ));
9504        } else if !seen.insert(name.to_owned()) {
9505            self.diagnostics.push(annotation_diagnostic(
9506                ANNOTATIONS_DUPLICATE_NAME,
9507                Severity::Error,
9508                span,
9509                "service annotation names must be unique",
9510                "duplicate annotation name",
9511            ));
9512        }
9513        if !raw.contains('=') {
9514            self.diagnostics.push(annotation_diagnostic(
9515                ANNOTATIONS_KEY_ONLY,
9516                Severity::Warning,
9517                span,
9518                "key-only service annotation has no explicit value",
9519                "ambiguous key-only annotation",
9520            ));
9521        }
9522    }
9523
9524    fn parse_annotation_map(&mut self, mapping: &Mapping) -> Annotations {
9525        let span = span_from_position(self.source_id, mapping.byte_range());
9526        let mut entries = Vec::new();
9527        let mut seen = BTreeMap::new();
9528        for entry in self.fields(mapping) {
9529            let _duplicate = self.record_duplicate(&mut seen, &entry);
9530            if entry.name.value.is_empty() {
9531                self.diagnostics.push(annotation_diagnostic(
9532                    ANNOTATIONS_EMPTY_NAME,
9533                    Severity::Error,
9534                    entry.name.span,
9535                    "service annotation name must not be empty",
9536                    "empty annotation name",
9537                ));
9538            }
9539            if let Some(value) = self.parse_compose_scalar(
9540                &entry,
9541                "annotation mapping values must be scalar strings, numbers, booleans, or null",
9542            ) {
9543                entries.push(KeyValueEntry::new(entry.name, value, entry.span));
9544            }
9545        }
9546        Annotations::new(span, AnnotationsForm::Map(entries))
9547    }
9548
9549    fn field_is_null(field: &ParsedField) -> bool {
9550        field.value.as_ref().is_none_or(|node| {
9551            node.as_scalar()
9552                .is_some_and(|scalar| ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null)
9553        })
9554    }
9555
9556    fn unsupported_sequence_item(
9557        &mut self,
9558        code: DiagnosticCode,
9559        node: &YamlNode,
9560        fallback_span: SourceSpan,
9561        message: impl Into<String>,
9562    ) {
9563        let span = node_span(self.source_id, node).unwrap_or(fallback_span);
9564        self.diagnostics.push(
9565            Diagnostic::new(code, Severity::Error, message)
9566                .with_label(DiagnosticLabel::primary(span, "unsupported value form")),
9567        );
9568    }
9569
9570    fn fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
9571        let fields = self.raw_fields(mapping);
9572        let mut fields = self.flatten_empty_value_continuations(fields);
9573        for field in &mut fields {
9574            field.value = field.value.take().map(|value| self.resolve_alias(value));
9575        }
9576        fields
9577    }
9578
9579    fn raw_fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
9580        mapping
9581            .entries()
9582            .filter_map(|entry| {
9583                let key = entry.key_node()?;
9584                let Some(scalar) = key.as_scalar() else {
9585                    let span = node_span(self.source_id, &key)
9586                        .unwrap_or_else(|| span_from_position(self.source_id, mapping.byte_range()));
9587                    self.diagnostics.push(
9588                        Diagnostic::new(EXPECTED_SCALAR, Severity::Error, "Compose mapping keys must be scalars")
9589                            .with_label(DiagnosticLabel::primary(span, "non-scalar key")),
9590                    );
9591                    return None;
9592                };
9593                let name_span = span_from_position(self.source_id, scalar.byte_range());
9594                let authored_value = entry.value_node();
9595                let value_span = authored_value
9596                    .as_ref()
9597                    .and_then(|value| node_span(self.source_id, value));
9598                let value = authored_value.map(unwrap_processing_tag);
9599                let span = value_span.map_or(name_span, |value_span| union(name_span, value_span));
9600                Some(ParsedField {
9601                    name: Located::new(scalar_string_from_source(&self.source, scalar), name_span),
9602                    value,
9603                    value_span,
9604                    span,
9605                })
9606            })
9607            .collect()
9608    }
9609
9610    fn resolve_alias(&self, node: YamlNode) -> YamlNode {
9611        let mut node = node;
9612        let mut visited = BTreeSet::new();
9613        for _ in 0..64 {
9614            let YamlNode::Alias(alias) = &node else {
9615                return node;
9616            };
9617            if !visited.insert(alias.name()) {
9618                return node;
9619            }
9620            let Some(target) = self.anchors.resolve(&alias.name()).and_then(|target| {
9621                YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
9622            }) else {
9623                return node;
9624            };
9625            node = target;
9626        }
9627        node
9628    }
9629
9630    fn flatten_empty_value_continuations(&mut self, fields: Vec<ParsedField>) -> Vec<ParsedField> {
9631        let Some(target_column) = fields.first().map(|field| self.source_column(field.name.span.start())) else {
9632            return fields;
9633        };
9634        self.recover_fields(fields, target_column)
9635    }
9636
9637    fn recover_fields(&mut self, fields: Vec<ParsedField>, target_column: usize) -> Vec<ParsedField> {
9638        let mut flattened = Vec::new();
9639        for mut field in fields {
9640            let field_column = self.source_column(field.name.span.start());
9641            let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
9642            let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
9643                !self.is_flow_mapping(mapping)
9644                    && mapping
9645                        .entries()
9646                        .find_map(|entry| {
9647                            let key = entry.key_node()?;
9648                            let scalar = key.as_scalar()?;
9649                            Some(scalar.byte_range().start as usize)
9650                        })
9651                        .is_some_and(|key_start| self.source_column(key_start) <= field_column)
9652            });
9653
9654            if continuation {
9655                field.value = None;
9656                field.value_span = None;
9657                field.span = field.name.span;
9658            }
9659            if field_column == target_column {
9660                flattened.push(field);
9661            }
9662            if let Some(mapping) = nested_mapping.filter(|mapping| !self.is_flow_mapping(mapping)) {
9663                let nested = self.raw_fields(&mapping);
9664                flattened.extend(self.recover_fields(nested, target_column));
9665            }
9666        }
9667        flattened
9668    }
9669
9670    fn is_flow_mapping(&self, mapping: &Mapping) -> bool {
9671        let position = mapping.byte_range();
9672        self.source
9673            .get(position.start as usize..position.end as usize)
9674            .is_some_and(|text| text.trim_start().starts_with('{'))
9675    }
9676
9677    fn record_duplicate(&mut self, seen: &mut BTreeMap<String, SourceSpan>, field: &ParsedField) -> bool {
9678        if let Some(first) = seen.get(field.name.value()) {
9679            self.diagnostics.push(
9680                Diagnostic::new(
9681                    DUPLICATE_FIELD,
9682                    Severity::Error,
9683                    "Compose mapping fields must be unique",
9684                )
9685                .with_label(DiagnosticLabel::primary(field.name.span, "duplicate field"))
9686                .with_label(DiagnosticLabel::secondary(*first, "first field")),
9687            );
9688            true
9689        } else {
9690            seen.insert(field.name.value.clone(), field.name.span);
9691            false
9692        }
9693    }
9694
9695    fn expected(&mut self, code: DiagnosticCode, field: &ParsedField, message: impl Into<String>) {
9696        self.diagnostics.push(
9697            Diagnostic::new(code, Severity::Error, message)
9698                .with_label(DiagnosticLabel::primary(field.span, "unexpected value form")),
9699        );
9700    }
9701
9702    fn missing(&mut self, code: DiagnosticCode, span: SourceSpan, message: impl Into<String>) {
9703        self.diagnostics.push(
9704            Diagnostic::new(code, Severity::Error, message)
9705                .with_label(DiagnosticLabel::primary(span, "incomplete long syntax")),
9706        );
9707    }
9708}
9709
9710fn unwrap_processing_tag(node: YamlNode) -> YamlNode {
9711    let YamlNode::TaggedNode(tagged) = &node else {
9712        return node;
9713    };
9714    if !matches!(tagged.tag().as_deref(), Some("!reset" | "!override")) {
9715        return node;
9716    }
9717    tagged
9718        .as_node()
9719        .and_then(|syntax| syntax.children().find_map(YamlNode::from_syntax))
9720        .unwrap_or(node)
9721}
9722
9723fn command_is_non_empty(command: &Command) -> bool {
9724    match command {
9725        Command::Null(_) => false,
9726        Command::String(value) => !value.value().is_empty(),
9727        Command::List { values, .. } => !values.is_empty(),
9728    }
9729}
9730
9731fn gpu_has_capabilities(device: &GpuDevice) -> bool {
9732    device.capabilities().iter().any(|value| !value.value().is_empty())
9733}
9734
9735#[derive(Debug, Clone)]
9736enum ParsedGrant {
9737    Short(Located<String>),
9738    Long(Box<LongGrant>),
9739}
9740
9741#[derive(Debug, Clone)]
9742struct ParsedField {
9743    name: Located<String>,
9744    value: Option<YamlNode>,
9745    value_span: Option<SourceSpan>,
9746    span: SourceSpan,
9747}
9748
9749impl ParsedField {
9750    fn reference(&self) -> FieldReference {
9751        FieldReference {
9752            name: self.name.clone(),
9753            span: self.span,
9754            value_span: self.value_span,
9755        }
9756    }
9757}
9758
9759fn scalar_uses_block_style(source: &str, scalar: &Scalar) -> bool {
9760    let start = scalar.byte_range().start as usize;
9761    source[start..].trim_start().starts_with(['|', '>'])
9762        || source[..start]
9763            .lines()
9764            .rev()
9765            .find(|line| !line.trim().is_empty())
9766            .is_some_and(|header| header.contains(": |") || header.contains(": >"))
9767}
9768
9769fn node_span(source_id: SourceId, node: &YamlNode) -> Option<SourceSpan> {
9770    let position = match node {
9771        YamlNode::Scalar(value) => value.byte_range(),
9772        YamlNode::Mapping(value) => value.byte_range(),
9773        YamlNode::Sequence(value) => value.byte_range(),
9774        YamlNode::Alias(_) | YamlNode::TaggedNode(_) => {
9775            let range = node.as_node()?.text_range();
9776            return Some(SourceSpan::from_valid_offsets(
9777                source_id,
9778                u32::from(range.start()) as usize,
9779                u32::from(range.end()) as usize,
9780            ));
9781        }
9782    };
9783    Some(span_from_position(source_id, position))
9784}
9785
9786fn span_from_position(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
9787    SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
9788}
9789
9790fn union(left: SourceSpan, right: SourceSpan) -> SourceSpan {
9791    SourceSpan::from_valid_offsets(
9792        left.source_id(),
9793        left.start().min(right.start()),
9794        left.end().max(right.end()),
9795    )
9796}