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 scalar_type_for_value(&scalar) == 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)) => scalar_type_for_value(scalar) == ScalarType::String,
2431            Some(YamlNode::Sequence(sequence)) => sequence.values().all(|item| {
2432                item.as_scalar()
2433                    .is_some_and(|scalar| scalar_type_for_value(scalar) == 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 scalar_type_for_value(scalar) != 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 scalar_type_for_value(scalar) != 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 scalar_type_for_value(scalar) != 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)) if scalar_type_for_value(scalar) == ScalarType::Null => {}
2615                        Some(YamlNode::Mapping(value)) => {
2616                            let mut seen = BTreeMap::new();
2617                            for member in self.fields(value) {
2618                                if self.record_duplicate(&mut seen, &member) {
2619                                    continue;
2620                                }
2621                                match member.name.value().as_str() {
2622                                    "endpoint_var" => self
2623                                        .parse_extends_string(
2624                                            &member,
2625                                            "model endpoint_var must be a YAML string scalar",
2626                                        )
2627                                        .into_iter()
2628                                        .for_each(|value| binding.set_endpoint_var(value)),
2629                                    "model_var" => self
2630                                        .parse_extends_string(&member, "model model_var must be a YAML string scalar")
2631                                        .into_iter()
2632                                        .for_each(|value| binding.set_model_var(value)),
2633                                    _ => binding.push_unmodeled(member.reference()),
2634                                }
2635                            }
2636                        }
2637                        Some(_) => {
2638                            self.expected(
2639                                EXPECTED_FIELD_FORM,
2640                                &binding_field,
2641                                "service model binding must be null or a mapping",
2642                            );
2643                            binding.push_unmodeled(binding_field.reference());
2644                        }
2645                    }
2646                    bindings.push(binding);
2647                }
2648            }
2649            _ => return None,
2650        }
2651        Some(ServiceModels::new(span, bindings, unmodeled_fields))
2652    }
2653
2654    #[expect(
2655        clippy::too_many_lines,
2656        reason = "GPU selectors retain supported members and every rejected source member"
2657    )]
2658    fn parse_gpus(&mut self, field: &ParsedField) -> Option<Gpus> {
2659        match field.value.as_ref()? {
2660            YamlNode::Scalar(_) => self
2661                .parse_extends_string(field, "service gpus must be a YAML string scalar or sequence")
2662                .and_then(|value| {
2663                    if value.value() == "all" {
2664                        Some(Gpus::All(value))
2665                    } else {
2666                        self.expected(EXPECTED_FIELD_FORM, field, "service gpus scalar must be exactly `all`");
2667                        None
2668                    }
2669                }),
2670            YamlNode::Sequence(sequence) => {
2671                let span = span_from_position(self.source_id, sequence.byte_range());
2672                let mut devices = Vec::new();
2673                let mut unmodeled_items = Vec::new();
2674                for node in sequence.values() {
2675                    let Some(mapping) = node.as_mapping() else {
2676                        self.unsupported_sequence_item(
2677                            EXPECTED_MAPPING,
2678                            &node,
2679                            field.span,
2680                            "GPU selector must be a mapping",
2681                        );
2682                        unmodeled_items.push(node_span(self.source_id, &node).unwrap_or(field.span));
2683                        continue;
2684                    };
2685                    let mut device = GpuDevice::new(node_span(self.source_id, &node).unwrap_or(field.span));
2686                    let mut seen = BTreeMap::new();
2687                    for member in self.fields(mapping) {
2688                        if self.record_duplicate(&mut seen, &member) {
2689                            continue;
2690                        }
2691                        match member.name.value().as_str() {
2692                            "capabilities" => {
2693                                let (values, invalid_items) =
2694                                    self.parse_strict_string_sequence(&member, "GPU capabilities");
2695                                if !matches!(member.value.as_ref(), Some(YamlNode::Sequence(_)))
2696                                    || !invalid_items.is_empty()
2697                                {
2698                                    device.push_unmodeled(member.reference());
2699                                }
2700                                device.set_capabilities(values);
2701                            }
2702                            "count" => {
2703                                let parsed = self.parse_integer_or_string_scalar(
2704                                    &member,
2705                                    "GPU count must be a YAML integer or string",
2706                                );
2707                                if let Some(value) = parsed {
2708                                    device.set_count(value);
2709                                } else {
2710                                    device.push_unmodeled(member.reference());
2711                                }
2712                            }
2713                            "device_ids" => {
2714                                let values = self
2715                                    .string_or_sequence(&member, "GPU device_ids must be a YAML string or sequence");
2716                                if !Self::is_strict_string_or_sequence(&member) {
2717                                    device.push_unmodeled(member.reference());
2718                                }
2719                                device.set_device_ids(values);
2720                            }
2721                            "driver" => {
2722                                let parsed =
2723                                    self.parse_extends_string(&member, "GPU driver must be a YAML string scalar");
2724                                if let Some(value) = parsed {
2725                                    device.set_driver(value);
2726                                } else {
2727                                    device.push_unmodeled(member.reference());
2728                                }
2729                            }
2730                            "options" => match member.value.as_ref() {
2731                                Some(YamlNode::Mapping(_)) => {
2732                                    device.set_options(GpuOptions::Mapping(
2733                                        self.parse_scalar_mapping(&member, "GPU options"),
2734                                    ));
2735                                }
2736                                Some(YamlNode::Sequence(_)) => {
2737                                    let options = self.parse_string_sequence(&member, "GPU options");
2738                                    if !Self::is_strict_string_or_sequence(&member) {
2739                                        device.push_unmodeled(member.reference());
2740                                    }
2741                                    device.set_options(GpuOptions::List(options));
2742                                }
2743                                _ => {
2744                                    self.expected(
2745                                        EXPECTED_FIELD_FORM,
2746                                        &member,
2747                                        "GPU options must be a mapping or sequence",
2748                                    );
2749                                    device.push_unmodeled(member.reference());
2750                                }
2751                            },
2752                            _ => device.push_unmodeled(member.reference()),
2753                        }
2754                    }
2755                    if device.count().is_some() && !device.device_ids().is_empty() {
2756                        self.diagnostics.push(
2757                            Diagnostic::new(
2758                                GPU_COUNT_DEVICE_IDS_CONFLICT,
2759                                Severity::Warning,
2760                                "GPU selector cannot use count and device_ids together",
2761                            )
2762                            .with_label(DiagnosticLabel::primary(
2763                                device.span(),
2764                                "conflicting GPU allocation selectors",
2765                            )),
2766                        );
2767                    }
2768                    if !gpu_has_capabilities(&device) {
2769                        self.diagnostics.push(
2770                            Diagnostic::new(
2771                                GPU_MISSING_CAPABILITIES,
2772                                Severity::Error,
2773                                "GPU selector requires a non-empty capabilities declaration",
2774                            )
2775                            .with_label(DiagnosticLabel::primary(
2776                                device.span(),
2777                                "capabilities are missing, empty, or malformed",
2778                            )),
2779                        );
2780                    }
2781                    devices.push(device);
2782                }
2783                Some(Gpus::Devices {
2784                    span,
2785                    devices,
2786                    unmodeled_items,
2787                })
2788            }
2789            _ => None,
2790        }
2791    }
2792
2793    #[expect(
2794        clippy::too_many_lines,
2795        reason = "develop watch retains supported members and every rejected source member"
2796    )]
2797    fn parse_develop(&mut self, field: &ParsedField) -> Option<Develop> {
2798        let mapping = field.value.as_ref().and_then(YamlNode::as_mapping)?;
2799        let span = span_from_position(self.source_id, mapping.byte_range());
2800        let mut watch = Vec::new();
2801        let mut unmodeled = Vec::new();
2802        let mut unmodeled_items = Vec::new();
2803        let mut seen = BTreeMap::new();
2804        for member in self.fields(mapping) {
2805            if self.record_duplicate(&mut seen, &member) {
2806                unmodeled.push(member.reference());
2807                continue;
2808            }
2809            if member.name.value() != "watch" {
2810                unmodeled.push(member.reference());
2811                continue;
2812            }
2813            let Some(sequence) = member.value.as_ref().and_then(YamlNode::as_sequence) else {
2814                self.expected(EXPECTED_SEQUENCE, &member, "develop watch must be a sequence");
2815                unmodeled.push(member.reference());
2816                continue;
2817            };
2818            for node in sequence.values() {
2819                let Some(watch_mapping) = node.as_mapping() else {
2820                    self.unsupported_sequence_item(
2821                        EXPECTED_MAPPING,
2822                        &node,
2823                        member.span,
2824                        "develop watch items must be mappings",
2825                    );
2826                    unmodeled_items.push(node_span(self.source_id, &node).unwrap_or(member.span));
2827                    continue;
2828                };
2829                let mut item = DevelopWatch::new(node_span(self.source_id, &node).unwrap_or(member.span));
2830                let mut item_seen = BTreeMap::new();
2831                for watch_member in self.fields(watch_mapping) {
2832                    if self.record_duplicate(&mut item_seen, &watch_member) {
2833                        item.push_unmodeled(watch_member.reference());
2834                        continue;
2835                    }
2836                    match watch_member.name.value().as_str() {
2837                        "action" => {
2838                            let parsed = self.parse_extends_string(
2839                                &watch_member,
2840                                "develop watch action must be a YAML string scalar",
2841                            );
2842                            if let Some(value) = parsed {
2843                                item.set_action(value);
2844                            } else {
2845                                item.push_unmodeled(watch_member.reference());
2846                            }
2847                        }
2848                        "path" => {
2849                            let parsed = self
2850                                .parse_extends_string(&watch_member, "develop watch path must be a YAML string scalar");
2851                            if let Some(value) = parsed {
2852                                item.set_path(value);
2853                            } else {
2854                                item.push_unmodeled(watch_member.reference());
2855                            }
2856                        }
2857                        "target" => {
2858                            let parsed = self.parse_extends_string(
2859                                &watch_member,
2860                                "develop watch target must be a YAML string scalar",
2861                            );
2862                            if let Some(value) = parsed {
2863                                item.set_target(value);
2864                            } else {
2865                                item.push_unmodeled(watch_member.reference());
2866                            }
2867                        }
2868                        "ignore" => {
2869                            let values = self.string_or_sequence(
2870                                &watch_member,
2871                                "develop watch ignore must be a YAML string or sequence",
2872                            );
2873                            if !Self::is_strict_string_or_sequence(&watch_member) {
2874                                item.push_unmodeled(watch_member.reference());
2875                            }
2876                            item.set_ignore(values);
2877                        }
2878                        "include" => {
2879                            let values = self.string_or_sequence(
2880                                &watch_member,
2881                                "develop watch include must be a YAML string or sequence",
2882                            );
2883                            if !Self::is_strict_string_or_sequence(&watch_member) {
2884                                item.push_unmodeled(watch_member.reference());
2885                            }
2886                            item.set_include(values);
2887                        }
2888                        "initial_sync" => {
2889                            let parsed =
2890                                self.parse_boolean(&watch_member, "develop watch initial_sync must be a boolean");
2891                            if let Some(value) = parsed {
2892                                item.set_initial_sync(value);
2893                            } else {
2894                                item.push_unmodeled(watch_member.reference());
2895                            }
2896                        }
2897                        "exec" if matches!(watch_member.value.as_ref(), Some(YamlNode::Mapping(_))) => {
2898                            let exec = self.parse_develop_exec(&watch_member);
2899                            if !exec.command().is_some_and(command_is_non_empty) {
2900                                self.diagnostics.push(
2901                                    Diagnostic::new(
2902                                        DEVELOP_WATCH_EXEC_MISSING_COMMAND,
2903                                        Severity::Error,
2904                                        "develop watch exec mapping requires a non-empty command member",
2905                                    )
2906                                    .with_label(DiagnosticLabel::primary(watch_member.span, "exec command is missing")),
2907                                );
2908                            }
2909                            item.set_exec(exec);
2910                        }
2911                        "exec" => {
2912                            self.expected(EXPECTED_MAPPING, &watch_member, "develop watch exec must be a mapping");
2913                            item.push_unmodeled(watch_member.reference());
2914                        }
2915                        _ => item.push_unmodeled(watch_member.reference()),
2916                    }
2917                }
2918                self.validate_develop_watch(&item);
2919                watch.push(item);
2920            }
2921        }
2922        if watch.is_empty() {
2923            self.diagnostics.push(
2924                Diagnostic::new(
2925                    DEVELOP_MISSING_WATCH,
2926                    Severity::Error,
2927                    "develop requires a watch sequence",
2928                )
2929                .with_label(DiagnosticLabel::primary(span, "watch is missing")),
2930            );
2931        }
2932        Some(Develop::new(span, watch, unmodeled, unmodeled_items))
2933    }
2934
2935    fn validate_develop_watch(&mut self, item: &DevelopWatch) {
2936        if item.action().is_none() {
2937            self.diagnostics.push(
2938                Diagnostic::new(
2939                    DEVELOP_WATCH_MISSING_ACTION,
2940                    Severity::Error,
2941                    "develop watch item requires an action",
2942                )
2943                .with_label(DiagnosticLabel::primary(item.span(), "action is missing")),
2944            );
2945        }
2946        if item.path().is_none() {
2947            self.diagnostics.push(
2948                Diagnostic::new(
2949                    DEVELOP_WATCH_MISSING_PATH,
2950                    Severity::Error,
2951                    "develop watch item requires a path",
2952                )
2953                .with_label(DiagnosticLabel::primary(item.span(), "path is missing")),
2954            );
2955        }
2956        let Some(action) = item.action() else {
2957            return;
2958        };
2959        let action_value = action.value();
2960        let synchronizes = matches!(action_value.as_str(), "sync" | "sync+restart" | "sync+exec");
2961        if !matches!(
2962            action_value.as_str(),
2963            "rebuild" | "sync" | "restart" | "sync+restart" | "sync+exec"
2964        ) {
2965            self.diagnostics.push(
2966                Diagnostic::new(
2967                    DEVELOP_WATCH_INVALID_ACTION,
2968                    Severity::Error,
2969                    "develop watch action is not one of rebuild, sync, restart, sync+restart, or sync+exec",
2970                )
2971                .with_label(DiagnosticLabel::primary(action.span(), "unknown watch action")),
2972            );
2973        }
2974        if synchronizes && item.target().is_none() {
2975            self.diagnostics.push(
2976                Diagnostic::new(
2977                    DEVELOP_WATCH_MISSING_TARGET,
2978                    Severity::Error,
2979                    "synchronizing develop watch action requires a target",
2980                )
2981                .with_label(DiagnosticLabel::primary(item.span(), "target is missing")),
2982            );
2983        }
2984        if action_value == "sync+exec" && item.exec().is_none() {
2985            self.diagnostics.push(
2986                Diagnostic::new(
2987                    DEVELOP_WATCH_MISSING_EXEC,
2988                    Severity::Error,
2989                    "sync+exec develop watch action requires an exec command mapping",
2990                )
2991                .with_label(DiagnosticLabel::primary(item.span(), "exec is missing")),
2992            );
2993        }
2994    }
2995
2996    fn parse_develop_exec(&mut self, field: &ParsedField) -> DevelopWatchExec {
2997        let Some(YamlNode::Mapping(mapping)) = field.value.as_ref() else {
2998            unreachable!("caller checks the mapping form");
2999        };
3000        let mut exec = DevelopWatchExec::new(field.span);
3001        let mut seen = BTreeMap::new();
3002        for member in self.fields(mapping) {
3003            if self.record_duplicate(&mut seen, &member) {
3004                exec.push_unmodeled(member.reference());
3005                continue;
3006            }
3007            match member.name.value().as_str() {
3008                "command" => match self.parse_command(&member) {
3009                    Some(value) => exec.set_command(value),
3010                    None => exec.push_unmodeled(member.reference()),
3011                },
3012                "user" => match self.parse_extends_string(&member, "develop exec user must be a YAML string scalar") {
3013                    Some(value) => exec.set_user(value),
3014                    None => exec.push_unmodeled(member.reference()),
3015                },
3016                "privileged" => match self.parse_boolean(&member, "develop exec privileged must be a boolean") {
3017                    Some(value) => exec.set_privileged(value),
3018                    None => exec.push_unmodeled(member.reference()),
3019                },
3020                "working_dir" => {
3021                    match self.parse_extends_string(&member, "develop exec working_dir must be a YAML string scalar") {
3022                        Some(value) => exec.set_working_dir(value),
3023                        None => exec.push_unmodeled(member.reference()),
3024                    }
3025                }
3026                "environment" => match self.parse_environment(&member) {
3027                    Some(value) => exec.set_environment(value),
3028                    None => exec.push_unmodeled(member.reference()),
3029                },
3030                _ => exec.push_unmodeled(member.reference()),
3031            }
3032        }
3033        exec
3034    }
3035
3036    fn parse_service_read_only(&mut self, field: &ParsedField) -> Option<Located<BooleanValue>> {
3037        self.parse_boolean(field, "service read_only")
3038    }
3039
3040    fn parse_service_group_add(&mut self, field: &ParsedField) -> Vec<Located<String>> {
3041        self.parse_string_sequence(field, "service supplementary groups")
3042    }
3043
3044    fn set_service_count(&mut self, service: &mut Service, field: &ParsedField) {
3045        match field.name.value().as_str() {
3046            "cpu_count" => {
3047                service.cpu_count = self.parse_cpu_count(field);
3048                if service.cpu_count.is_none() {
3049                    service.unknown_fields.push(field.reference());
3050                }
3051            }
3052            "cpu_percent" => {
3053                service.cpu_percent = self.parse_cpu_percent(field);
3054                if service.cpu_percent.is_none() {
3055                    service.unknown_fields.push(field.reference());
3056                }
3057            }
3058            "cpu_period" => {
3059                service.cpu_period = self.parse_cpu_period(field);
3060                if service.cpu_period.is_none() {
3061                    service.unknown_fields.push(field.reference());
3062                }
3063            }
3064            "cpu_quota" => {
3065                service.cpu_quota = self.parse_cpu_quota(field);
3066                if service.cpu_quota.is_none() {
3067                    service.unknown_fields.push(field.reference());
3068                }
3069            }
3070            "cpu_rt_period" => {
3071                service.cpu_rt_period = self.parse_cpu_rt_period(field);
3072                if service.cpu_rt_period.is_none() {
3073                    service.unknown_fields.push(field.reference());
3074                }
3075            }
3076            "cpu_rt_runtime" => {
3077                service.cpu_rt_runtime = self.parse_cpu_rt_runtime(field);
3078                if service.cpu_rt_runtime.is_none() {
3079                    service.unknown_fields.push(field.reference());
3080                }
3081            }
3082            "cpu_shares" => {
3083                service.cpu_shares = self.parse_service_integer(field, 0, i128::MAX, "cpu_shares");
3084                if service.cpu_shares.is_none() {
3085                    service.unknown_fields.push(field.reference());
3086                }
3087            }
3088            "cpus" => {
3089                service.cpus = self.parse_cpus(field);
3090                if service.cpus.is_none() {
3091                    service.unknown_fields.push(field.reference());
3092                }
3093            }
3094            "mem_swappiness" => {
3095                service.mem_swappiness = self.parse_service_integer(field, 0, 100, "mem_swappiness");
3096                if service.mem_swappiness.is_none() {
3097                    service.unknown_fields.push(field.reference());
3098                }
3099            }
3100            "oom_score_adj" => {
3101                service.oom_score_adj = self.parse_service_integer(field, -1000, 1000, "oom_score_adj");
3102                if service.oom_score_adj.is_none() {
3103                    service.unknown_fields.push(field.reference());
3104                }
3105            }
3106            "scale" => {
3107                service.scale = self.parse_service_integer(field, 0, i128::MAX, "scale");
3108                if service.scale.is_none() {
3109                    service.unknown_fields.push(field.reference());
3110                }
3111            }
3112            "pids_limit" => service.pids_limit = self.parse_pids_limit(field),
3113            _ => {}
3114        }
3115    }
3116
3117    fn set_service_runtime(&mut self, service: &mut Service, field: &ParsedField) {
3118        service.runtime = self.parse_extends_string(field, "service runtime must be a YAML string scalar");
3119        if service.runtime.is_none() {
3120            service.unknown_fields.push(field.reference());
3121        }
3122    }
3123
3124    fn set_service_pull_refresh_after(&mut self, service: &mut Service, field: &ParsedField) {
3125        service.pull_refresh_after =
3126            self.parse_extends_string(field, "service pull_refresh_after must be a YAML string scalar");
3127        if service.pull_refresh_after.is_none() {
3128            service.unknown_fields.push(field.reference());
3129        }
3130    }
3131
3132    fn set_service_platform(&mut self, service: &mut Service, field: &ParsedField) {
3133        service.platform = self.parse_extends_string(field, "service platform must be a YAML string scalar");
3134        if service.platform.is_none() {
3135            service.unknown_fields.push(field.reference());
3136        }
3137    }
3138
3139    fn set_service_attach(&mut self, service: &mut Service, field: &ParsedField) {
3140        service.attach = self.parse_boolean(field, "service attach");
3141        if service.attach.is_none() {
3142            service.unknown_fields.push(field.reference());
3143        }
3144    }
3145
3146    fn set_service_boolean(&mut self, service: &mut Service, field: &ParsedField) {
3147        match field.name.value().as_str() {
3148            "attach" => self.set_service_attach(service, field),
3149            "init" => service.init = self.parse_boolean(field, "service init"),
3150            "stdin_open" => service.stdin_open = self.parse_boolean(field, "stdin_open"),
3151            "tty" => service.tty = self.parse_boolean(field, "tty"),
3152            "privileged" => service.privileged = self.parse_boolean(field, "privileged"),
3153            "use_api_socket" => service.use_api_socket = self.parse_boolean(field, "use_api_socket"),
3154            _ => {}
3155        }
3156    }
3157
3158    fn set_service_blkio_config(&mut self, service: &mut Service, field: &ParsedField) {
3159        service.blkio_config = self.parse_blkio_config(field);
3160        if service.blkio_config.is_none() {
3161            service.unknown_fields.push(field.reference());
3162        }
3163    }
3164
3165    fn set_service_cgroup(&mut self, service: &mut Service, field: &ParsedField) {
3166        match field.name.value().as_str() {
3167            "cgroup" => {
3168                service.cgroup = self.parse_cgroup_namespace(field);
3169                if service.cgroup.is_none() {
3170                    service.unknown_fields.push(field.reference());
3171                }
3172            }
3173            "cgroup_parent" => {
3174                service.cgroup_parent =
3175                    self.parse_extends_string(field, "service cgroup_parent must be a YAML string scalar");
3176                if service.cgroup_parent.is_none() {
3177                    service.unknown_fields.push(field.reference());
3178                }
3179            }
3180            _ => {}
3181        }
3182    }
3183
3184    fn parse_cgroup_namespace(&mut self, field: &ParsedField) -> Option<CgroupNamespace> {
3185        let raw = self.parse_extends_string(field, "service cgroup must be a YAML string scalar")?;
3186        let cgroup = CgroupNamespace::parse(raw);
3187        if !cgroup.is_valid() {
3188            self.diagnostics.push(
3189                Diagnostic::new(
3190                    CGROUP_NAMESPACE_INVALID,
3191                    Severity::Warning,
3192                    "service cgroup must be `host`, `private`, or a deferred expression",
3193                )
3194                .with_label(DiagnosticLabel::primary(
3195                    cgroup.raw().span(),
3196                    "retained unsupported cgroup namespace",
3197                )),
3198            );
3199        }
3200        Some(cgroup)
3201    }
3202
3203    fn parse_blkio_config(&mut self, field: &ParsedField) -> Option<BlkioConfig> {
3204        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3205            self.expected(EXPECTED_MAPPING, field, "blkio_config must be a mapping");
3206            return None;
3207        };
3208        let mut config = BlkioConfig::new(span_from_position(self.source_id, mapping.byte_range()));
3209        let mut seen = BTreeMap::new();
3210        for option in self.fields(mapping) {
3211            if self.record_duplicate(&mut seen, &option) {
3212                continue;
3213            }
3214            match option.name.value().as_str() {
3215                name if name.starts_with("x-") => config.push_extension(option.reference()),
3216                "weight" => match self.parse_blkio_scalar(&option) {
3217                    Some(value) => config.set_weight(value),
3218                    None => config.push_unknown(option.reference()),
3219                },
3220                "device_read_bps" | "device_read_iops" | "device_write_bps" | "device_write_iops" => {
3221                    if let Some(values) = self.parse_blkio_device_rates(&option) {
3222                        if let Some(items) = config.device_rates_mut(option.name.value()) {
3223                            items.extend(values);
3224                        }
3225                    } else {
3226                        config.push_unknown(option.reference());
3227                    }
3228                }
3229                "weight_device" => {
3230                    if let Some(values) = self.parse_blkio_weight_devices(&option) {
3231                        config.weight_devices_mut().extend(values);
3232                    } else {
3233                        config.push_unknown(option.reference());
3234                    }
3235                }
3236                _ => config.push_unknown(option.reference()),
3237            }
3238        }
3239        Some(config)
3240    }
3241
3242    fn parse_blkio_scalar(&mut self, field: &ParsedField) -> Option<Located<BlkioScalar>> {
3243        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3244            self.expected(
3245                EXPECTED_SCALAR,
3246                field,
3247                "blkio value must be a YAML integer or string scalar",
3248            );
3249            return None;
3250        };
3251        let value = match scalar_type_for_value(scalar) {
3252            ScalarType::Integer => BlkioScalar::YamlInteger(scalar_string_from_source(&self.source, scalar)),
3253            ScalarType::String => BlkioScalar::String(scalar_string_from_source(&self.source, scalar)),
3254            _ => {
3255                self.expected(
3256                    EXPECTED_SCALAR,
3257                    field,
3258                    "blkio value must be a YAML integer or string scalar",
3259                );
3260                return None;
3261            }
3262        };
3263        Some(Located::new(
3264            value,
3265            span_from_position(self.source_id, scalar.byte_range()),
3266        ))
3267    }
3268
3269    fn parse_blkio_device_rates(&mut self, field: &ParsedField) -> Option<Vec<BlkioDeviceRate>> {
3270        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3271            self.expected(EXPECTED_SEQUENCE, field, "blkio device rates must be sequences");
3272            return None;
3273        };
3274        let mut items = Vec::new();
3275        for node in sequence.values() {
3276            let Some(mapping) = node.as_mapping() else {
3277                self.unsupported_sequence_item(
3278                    EXPECTED_MAPPING,
3279                    &node,
3280                    field.span,
3281                    "blkio device rate entries must be mappings",
3282                );
3283                items.push(BlkioDeviceRate::unmodeled(
3284                    node_span(self.source_id, &node).unwrap_or(field.span),
3285                ));
3286                continue;
3287            };
3288            let mut item = BlkioDeviceRate::new(span_from_position(self.source_id, mapping.byte_range()));
3289            let mut seen = BTreeMap::new();
3290            for option in self.fields(mapping) {
3291                if self.record_duplicate(&mut seen, &option) {
3292                    continue;
3293                }
3294                match option.name.value().as_str() {
3295                    name if name.starts_with("x-") => item.push_extension(option.reference()),
3296                    "path" => match self.parse_string(&option, "blkio device path") {
3297                        Some(value) => item.set_path(value),
3298                        None => item.push_unknown(option.reference()),
3299                    },
3300                    "rate" => match self.parse_blkio_scalar(&option) {
3301                        Some(value) => item.set_rate(value),
3302                        None => item.push_unknown(option.reference()),
3303                    },
3304                    _ => item.push_unknown(option.reference()),
3305                }
3306            }
3307            items.push(item);
3308        }
3309        Some(items)
3310    }
3311
3312    fn parse_blkio_weight_devices(&mut self, field: &ParsedField) -> Option<Vec<BlkioWeightDevice>> {
3313        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3314            self.expected(EXPECTED_SEQUENCE, field, "blkio weight devices must be sequences");
3315            return None;
3316        };
3317        let mut items = Vec::new();
3318        for node in sequence.values() {
3319            let Some(mapping) = node.as_mapping() else {
3320                self.unsupported_sequence_item(
3321                    EXPECTED_MAPPING,
3322                    &node,
3323                    field.span,
3324                    "blkio weight device entries must be mappings",
3325                );
3326                items.push(BlkioWeightDevice::unmodeled(
3327                    node_span(self.source_id, &node).unwrap_or(field.span),
3328                ));
3329                continue;
3330            };
3331            let mut item = BlkioWeightDevice::new(span_from_position(self.source_id, mapping.byte_range()));
3332            let mut seen = BTreeMap::new();
3333            for option in self.fields(mapping) {
3334                if self.record_duplicate(&mut seen, &option) {
3335                    continue;
3336                }
3337                match option.name.value().as_str() {
3338                    name if name.starts_with("x-") => item.push_extension(option.reference()),
3339                    "path" => match self.parse_string(&option, "blkio weight device path") {
3340                        Some(value) => item.set_path(value),
3341                        None => item.push_unknown(option.reference()),
3342                    },
3343                    "weight" => match self.parse_blkio_scalar(&option) {
3344                        Some(value) => item.set_weight(value),
3345                        None => item.push_unknown(option.reference()),
3346                    },
3347                    _ => item.push_unknown(option.reference()),
3348                }
3349            }
3350            items.push(item);
3351        }
3352        Some(items)
3353    }
3354
3355    fn parse_hostname(&mut self, field: &ParsedField) -> Option<Hostname> {
3356        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3357            self.expected(HOSTNAME_EXPECTED_STRING, field, "hostname must be a YAML string scalar");
3358            return None;
3359        };
3360        if scalar_type_for_value(scalar) != ScalarType::String {
3361            self.expected(HOSTNAME_EXPECTED_STRING, field, "hostname must be a YAML string scalar");
3362            return None;
3363        }
3364        let span = span_from_position(self.source_id, scalar.byte_range());
3365        let hostname = Hostname::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
3366        if hostname.kind() == &HostnameKind::Invalid {
3367            self.diagnostics.push(
3368                Diagnostic::new(
3369                    HOSTNAME_INVALID,
3370                    Severity::Error,
3371                    "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",
3372                )
3373                .with_label(DiagnosticLabel::primary(span, "invalid service hostname"))
3374                .with_note("each label must start and end with an ASCII letter or digit"),
3375            );
3376        }
3377        Some(hostname)
3378    }
3379
3380    fn parse_image(&mut self, field: &ParsedField) -> Option<Located<ImageReference>> {
3381        self.parse_string(field, "service image")
3382            .map(|value| Located::new(ImageReference::parse(value.value), value.span))
3383    }
3384
3385    fn parse_cap_drop(&mut self, field: &ParsedField) -> Option<CapabilityDrop> {
3386        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3387            self.expected(
3388                CAP_DROP_EXPECTED_SEQUENCE,
3389                field,
3390                "cap_drop must be a sequence of string scalars",
3391            );
3392            return None;
3393        };
3394        let span = span_from_position(self.source_id, sequence.byte_range());
3395        let mut items = Vec::new();
3396        let mut seen = BTreeMap::new();
3397        for node in sequence.values() {
3398            let YamlNode::Scalar(scalar) = node else {
3399                self.unsupported_sequence_item(
3400                    CAP_DROP_EXPECTED_STRING,
3401                    &node,
3402                    field.span,
3403                    "cap_drop entries must be string scalars",
3404                );
3405                continue;
3406            };
3407            let scalar_type = scalar_type_for_value(&scalar);
3408            if !matches!(
3409                scalar_type,
3410                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3411            ) {
3412                self.unsupported_sequence_item(
3413                    CAP_DROP_EXPECTED_STRING,
3414                    &YamlNode::Scalar(scalar),
3415                    field.span,
3416                    "cap_drop entries must be string scalars",
3417                );
3418                continue;
3419            }
3420            let item_span = span_from_position(self.source_id, scalar.byte_range());
3421            let value = scalar_string_from_source(&self.source, &scalar);
3422            if let Some(first) = seen.get(&value) {
3423                self.diagnostics.push(
3424                    Diagnostic::new(
3425                        CAP_DROP_DUPLICATE_ITEM,
3426                        Severity::Error,
3427                        "cap_drop entries must be unique exact strings",
3428                    )
3429                    .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
3430                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
3431                );
3432            } else {
3433                seen.insert(value.clone(), item_span);
3434            }
3435            items.push(CapabilityDropItem::new(Located::new(value, item_span)));
3436        }
3437        Some(CapabilityDrop::new(span, items))
3438    }
3439
3440    fn parse_cap_add(&mut self, field: &ParsedField) -> Option<CapabilityAdd> {
3441        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3442            self.expected(
3443                CAP_ADD_EXPECTED_SEQUENCE,
3444                field,
3445                "cap_add must be a sequence of string scalars",
3446            );
3447            return None;
3448        };
3449        let span = span_from_position(self.source_id, sequence.byte_range());
3450        let mut items = Vec::new();
3451        let mut seen = BTreeMap::new();
3452        for node in sequence.values() {
3453            let YamlNode::Scalar(scalar) = node else {
3454                self.unsupported_sequence_item(
3455                    CAP_ADD_EXPECTED_STRING,
3456                    &node,
3457                    field.span,
3458                    "cap_add entries must be string scalars",
3459                );
3460                continue;
3461            };
3462            let scalar_type = scalar_type_for_value(&scalar);
3463            if !matches!(
3464                scalar_type,
3465                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3466            ) {
3467                self.unsupported_sequence_item(
3468                    CAP_ADD_EXPECTED_STRING,
3469                    &YamlNode::Scalar(scalar),
3470                    field.span,
3471                    "cap_add entries must be string scalars",
3472                );
3473                continue;
3474            }
3475            let item_span = span_from_position(self.source_id, scalar.byte_range());
3476            let value = scalar_string_from_source(&self.source, &scalar);
3477            if let Some(first) = seen.get(&value) {
3478                self.diagnostics.push(
3479                    Diagnostic::new(
3480                        CAP_ADD_DUPLICATE_ITEM,
3481                        Severity::Error,
3482                        "cap_add entries must be unique exact strings",
3483                    )
3484                    .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
3485                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
3486                );
3487            } else {
3488                seen.insert(value.clone(), item_span);
3489            }
3490            items.push(CapabilityAddItem::new(Located::new(value, item_span)));
3491        }
3492        Some(CapabilityAdd::new(span, items))
3493    }
3494
3495    fn parse_devices(&mut self, field: &ParsedField) -> Option<Devices> {
3496        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3497            self.expected(
3498                DEVICES_EXPECTED_SEQUENCE,
3499                field,
3500                "service devices must be a sequence of string scalars or mappings",
3501            );
3502            return None;
3503        };
3504        let span = span_from_position(self.source_id, sequence.byte_range());
3505        let mut devices = Vec::new();
3506        for node in sequence.values() {
3507            match node {
3508                YamlNode::Scalar(scalar)
3509                    if matches!(
3510                        scalar_type_for_value(&scalar),
3511                        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3512                    ) =>
3513                {
3514                    let item_span = span_from_position(self.source_id, scalar.byte_range());
3515                    let raw = Located::new(scalar_string_from_source(&self.source, &scalar), item_span);
3516                    devices.push(Device::Short(ShortDevice::new(raw)));
3517                }
3518                YamlNode::Mapping(mapping) => devices.push(Device::Long(self.parse_long_device(&mapping))),
3519                other => self.unsupported_sequence_item(
3520                    DEVICE_EXPECTED_FORM,
3521                    &other,
3522                    field.span,
3523                    "service device must use string short syntax or mapping long syntax",
3524                ),
3525            }
3526        }
3527        Some(Devices::new(span, devices))
3528    }
3529
3530    fn parse_long_device(&mut self, mapping: &Mapping) -> LongDevice {
3531        let span = span_from_position(self.source_id, mapping.byte_range());
3532        let mut device = LongDevice::new(span);
3533        let mut seen = BTreeMap::new();
3534        for field in self.fields(mapping) {
3535            let duplicate = self.record_duplicate(&mut seen, &field);
3536            match field.name.value.as_str() {
3537                "source" if !duplicate => self
3538                    .parse_device_string(&field, "device source")
3539                    .into_iter()
3540                    .for_each(|value| device.set_source(value)),
3541                "target" if !duplicate => self
3542                    .parse_device_string(&field, "device target")
3543                    .into_iter()
3544                    .for_each(|value| device.set_target(value)),
3545                "permissions" if !duplicate => self
3546                    .parse_device_string(&field, "device permissions")
3547                    .into_iter()
3548                    .for_each(|value| device.set_permissions(value)),
3549                name if name.starts_with("x-") => device.push_extension(field.reference()),
3550                _ if duplicate => {}
3551                _ => device.push_unknown(field.reference()),
3552            }
3553        }
3554        if device.source().is_none() {
3555            self.missing(
3556                DEVICE_MISSING_SOURCE,
3557                span,
3558                "long service device is missing required string `source`",
3559            );
3560        }
3561        device
3562    }
3563
3564    fn parse_device_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
3565        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3566            self.expected(
3567                DEVICE_EXPECTED_STRING,
3568                field,
3569                format!("{description} must be a string scalar"),
3570            );
3571            return None;
3572        };
3573        if !matches!(
3574            scalar_type_for_value(scalar),
3575            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3576        ) {
3577            self.expected(
3578                DEVICE_EXPECTED_STRING,
3579                field,
3580                format!("{description} must be a string scalar"),
3581            );
3582            return None;
3583        }
3584        Some(Located::new(
3585            scalar_string_from_source(&self.source, scalar),
3586            span_from_position(self.source_id, scalar.byte_range()),
3587        ))
3588    }
3589
3590    fn parse_dns(&mut self, field: &ParsedField) -> Option<Dns> {
3591        let value = field.value.as_ref()?;
3592        if let Some(scalar) = value.as_scalar() {
3593            if !matches!(
3594                scalar_type_for_value(scalar),
3595                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3596            ) {
3597                self.expected(
3598                    DNS_EXPECTED_FORM,
3599                    field,
3600                    "dns must be a string scalar or a sequence of string scalars",
3601                );
3602                return None;
3603            }
3604            let span = span_from_position(self.source_id, scalar.byte_range());
3605            return Some(Dns::new(
3606                span,
3607                DnsForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
3608            ));
3609        }
3610
3611        let Some(sequence) = value.as_sequence() else {
3612            self.expected(
3613                DNS_EXPECTED_FORM,
3614                field,
3615                "dns must be a string scalar or a sequence of string scalars",
3616            );
3617            return None;
3618        };
3619        let span = span_from_position(self.source_id, sequence.byte_range());
3620        let mut items = Vec::new();
3621        for node in sequence.values() {
3622            let YamlNode::Scalar(scalar) = node else {
3623                self.unsupported_sequence_item(
3624                    DNS_EXPECTED_STRING,
3625                    &node,
3626                    field.span,
3627                    "dns entries must be string scalars",
3628                );
3629                continue;
3630            };
3631            if !matches!(
3632                scalar_type_for_value(&scalar),
3633                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3634            ) {
3635                self.unsupported_sequence_item(
3636                    DNS_EXPECTED_STRING,
3637                    &YamlNode::Scalar(scalar),
3638                    field.span,
3639                    "dns entries must be string scalars",
3640                );
3641                continue;
3642            }
3643            let item_span = span_from_position(self.source_id, scalar.byte_range());
3644            items.push(Located::new(
3645                scalar_string_from_source(&self.source, &scalar),
3646                item_span,
3647            ));
3648        }
3649        Some(Dns::new(span, DnsForm::List(items)))
3650    }
3651
3652    fn parse_dns_options(&mut self, field: &ParsedField) -> Option<DnsOptions> {
3653        let value = field.value.as_ref()?;
3654        let Some(sequence) = value.as_sequence() else {
3655            self.expected(
3656                DNS_OPT_EXPECTED_SEQUENCE,
3657                field,
3658                "dns_opt must be a sequence of string scalars",
3659            );
3660            return None;
3661        };
3662        let span = span_from_position(self.source_id, sequence.byte_range());
3663        let mut items = Vec::new();
3664        let mut seen = BTreeSet::new();
3665        for node in sequence.values() {
3666            let YamlNode::Scalar(scalar) = node else {
3667                self.unsupported_sequence_item(
3668                    DNS_OPT_EXPECTED_STRING,
3669                    &node,
3670                    field.span,
3671                    "dns_opt entries must be string scalars",
3672                );
3673                continue;
3674            };
3675            if !matches!(
3676                scalar_type_for_value(&scalar),
3677                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3678            ) {
3679                self.unsupported_sequence_item(
3680                    DNS_OPT_EXPECTED_STRING,
3681                    &YamlNode::Scalar(scalar),
3682                    field.span,
3683                    "dns_opt entries must be string scalars",
3684                );
3685                continue;
3686            }
3687            let item_span = span_from_position(self.source_id, scalar.byte_range());
3688            let option = scalar_string_from_source(&self.source, &scalar);
3689            if !seen.insert(option.clone()) {
3690                self.diagnostics.push(
3691                    Diagnostic::new(
3692                        DNS_OPT_DUPLICATE_ITEM,
3693                        Severity::Warning,
3694                        "dns_opt entries must be unique exact strings",
3695                    )
3696                    .with_label(DiagnosticLabel::primary(item_span, "duplicate DNS option retained")),
3697                );
3698            }
3699            items.push(Located::new(option, item_span));
3700        }
3701        Some(DnsOptions::new(span, items))
3702    }
3703
3704    fn parse_dns_search(&mut self, field: &ParsedField) -> Option<DnsSearch> {
3705        let value = field.value.as_ref()?;
3706        if let Some(scalar) = value.as_scalar() {
3707            if !matches!(
3708                scalar_type_for_value(scalar),
3709                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3710            ) {
3711                self.expected(
3712                    DNS_SEARCH_EXPECTED_FORM,
3713                    field,
3714                    "dns_search must be a string scalar or a sequence of string scalars",
3715                );
3716                return None;
3717            }
3718            let span = span_from_position(self.source_id, scalar.byte_range());
3719            return Some(DnsSearch::new(
3720                span,
3721                DnsSearchForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
3722            ));
3723        }
3724
3725        let Some(sequence) = value.as_sequence() else {
3726            self.expected(
3727                DNS_SEARCH_EXPECTED_FORM,
3728                field,
3729                "dns_search must be a string scalar or a sequence of string scalars",
3730            );
3731            return None;
3732        };
3733        let span = span_from_position(self.source_id, sequence.byte_range());
3734        let mut items = Vec::new();
3735        let mut seen = BTreeSet::new();
3736        for node in sequence.values() {
3737            let YamlNode::Scalar(scalar) = node else {
3738                self.unsupported_sequence_item(
3739                    DNS_SEARCH_EXPECTED_STRING,
3740                    &node,
3741                    field.span,
3742                    "dns_search entries must be string scalars",
3743                );
3744                continue;
3745            };
3746            if !matches!(
3747                scalar_type_for_value(&scalar),
3748                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3749            ) {
3750                self.unsupported_sequence_item(
3751                    DNS_SEARCH_EXPECTED_STRING,
3752                    &YamlNode::Scalar(scalar),
3753                    field.span,
3754                    "dns_search entries must be string scalars",
3755                );
3756                continue;
3757            }
3758            let item_span = span_from_position(self.source_id, scalar.byte_range());
3759            let search = scalar_string_from_source(&self.source, &scalar);
3760            if !seen.insert(search.clone()) {
3761                self.diagnostics.push(
3762                    Diagnostic::new(
3763                        DNS_SEARCH_DUPLICATE_ITEM,
3764                        Severity::Warning,
3765                        "dns_search schema entries are unique, but duplicate merge behavior is ambiguous",
3766                    )
3767                    .with_label(DiagnosticLabel::primary(
3768                        item_span,
3769                        "duplicate DNS search domain retained",
3770                    )),
3771                );
3772            }
3773            items.push(Located::new(search, item_span));
3774        }
3775        Some(DnsSearch::new(span, DnsSearchForm::List(items)))
3776    }
3777
3778    fn parse_expose(&mut self, field: &ParsedField) -> Option<Expose> {
3779        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3780            self.expected(
3781                EXPOSE_EXPECTED_SEQUENCE,
3782                field,
3783                "expose must be a sequence of string or number scalars",
3784            );
3785            return None;
3786        };
3787        let span = span_from_position(self.source_id, sequence.byte_range());
3788        let mut items = Vec::new();
3789        let mut seen = Vec::new();
3790        for node in sequence.values() {
3791            let YamlNode::Scalar(scalar) = node else {
3792                self.unsupported_sequence_item(
3793                    EXPOSE_EXPECTED_SCALAR,
3794                    &node,
3795                    field.span,
3796                    "expose entries must be string or number scalars",
3797                );
3798                continue;
3799            };
3800            let scalar_kind = match scalar_type_for_value(&scalar) {
3801                ScalarType::Integer | ScalarType::Float => ExposeScalarKind::Number,
3802                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => ExposeScalarKind::String,
3803                ScalarType::Null | ScalarType::Boolean => {
3804                    self.unsupported_sequence_item(
3805                        EXPOSE_EXPECTED_SCALAR,
3806                        &YamlNode::Scalar(scalar),
3807                        field.span,
3808                        "expose entries must be string or number scalars",
3809                    );
3810                    continue;
3811                }
3812            };
3813            let item_span = span_from_position(self.source_id, scalar.byte_range());
3814            let raw = scalar_string_from_source(&self.source, &scalar);
3815            if seen.contains(&(scalar_kind, raw.clone())) {
3816                self.diagnostics.push(
3817                    Diagnostic::new(
3818                        EXPOSE_DUPLICATE_ITEM,
3819                        Severity::Warning,
3820                        "expose entries must be unique by exact scalar identity",
3821                    )
3822                    .with_label(DiagnosticLabel::primary(
3823                        item_span,
3824                        "duplicate exposed-port item retained",
3825                    )),
3826                );
3827            } else {
3828                seen.push((scalar_kind, raw.clone()));
3829            }
3830            let item = ExposeItem::parse(Located::new(raw, item_span), scalar_kind);
3831            self.diagnose_expose_item(&item);
3832            items.push(item);
3833        }
3834        Some(Expose::new(span, items))
3835    }
3836
3837    fn diagnose_expose_item(&mut self, item: &ExposeItem) {
3838        match item.kind() {
3839            ExposeItemKind::Documented { .. } | ExposeItemKind::Expression => {}
3840            ExposeItemKind::Sctp { .. } | ExposeItemKind::UnknownProtocol { .. } => {
3841                self.diagnostics.push(
3842                    Diagnostic::new(
3843                        EXPOSE_PROVIDER_DEPENDENT,
3844                        Severity::Warning,
3845                        "expose protocol is outside the documented portable `tcp` and `udp` set",
3846                    )
3847                    .with_label(DiagnosticLabel::primary(
3848                        item.span(),
3849                        "provider-dependent exposed-port protocol retained",
3850                    ))
3851                    .with_note("ComposeLens does not normalize or reject the raw protocol spelling"),
3852                );
3853            }
3854            ExposeItemKind::Malformed => {
3855                self.diagnostics.push(
3856                    Diagnostic::new(
3857                        EXPOSE_INVALID_ITEM,
3858                        Severity::Error,
3859                        "expose item must be a decimal port or range with an optional protocol",
3860                    )
3861                    .with_label(DiagnosticLabel::primary(
3862                        item.span(),
3863                        "malformed exposed-port item retained",
3864                    ))
3865                    .with_note("use `PORT`, `START-END`, `PORT/tcp`, or `PORT/udp` for documented portable syntax"),
3866                );
3867            }
3868        }
3869    }
3870
3871    fn parse_security_options(&mut self, field: &ParsedField) -> Option<SecurityOptions> {
3872        let value = field.value.as_ref()?;
3873        let Some(sequence) = value.as_sequence() else {
3874            self.expected(
3875                SECURITY_OPT_EXPECTED_SEQUENCE,
3876                field,
3877                "security_opt must be a sequence of string scalars",
3878            );
3879            return None;
3880        };
3881        let span = span_from_position(self.source_id, sequence.byte_range());
3882        let mut items = Vec::new();
3883        let mut candidates = SecurityOptionCandidateCounts::default();
3884        for node in sequence.values() {
3885            let YamlNode::Scalar(scalar) = node else {
3886                self.unsupported_sequence_item(
3887                    SECURITY_OPT_EXPECTED_STRING,
3888                    &node,
3889                    field.span,
3890                    "security_opt entries must be string scalars",
3891                );
3892                continue;
3893            };
3894            if !matches!(
3895                scalar_type_for_value(&scalar),
3896                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3897            ) {
3898                self.unsupported_sequence_item(
3899                    SECURITY_OPT_EXPECTED_STRING,
3900                    &YamlNode::Scalar(scalar),
3901                    field.span,
3902                    "security_opt entries must be string scalars",
3903                );
3904                continue;
3905            }
3906            let item_span = span_from_position(self.source_id, scalar.byte_range());
3907            let raw = scalar_string_from_source(&self.source, &scalar);
3908            let item = SecurityOptionItem::parse(Located::new(raw, item_span));
3909            self.diagnose_security_option_item(item.kind(), item_span, &mut candidates);
3910            items.push(item);
3911        }
3912        Some(SecurityOptions::new(span, items))
3913    }
3914
3915    fn diagnose_security_option_item(
3916        &mut self,
3917        kind: &SecurityOptionKind,
3918        span: SourceSpan,
3919        candidates: &mut SecurityOptionCandidateCounts,
3920    ) {
3921        let diagnostic = match kind {
3922            SecurityOptionKind::AppArmor { .. } => {
3923                candidates.apparmor += 1;
3924                (candidates.apparmor > 1).then(|| {
3925                    Diagnostic::new(
3926                        SECURITY_OPT_APPARMOR_CONFLICT,
3927                        Severity::Warning,
3928                        "multiple AppArmor candidates are retained; a consumer must resolve the conflict explicitly",
3929                    )
3930                    .with_label(DiagnosticLabel::primary(span, "additional AppArmor candidate retained"))
3931                })
3932            }
3933            SecurityOptionKind::AppArmorNearMiss => Some(
3934                Diagnostic::new(
3935                    SECURITY_OPT_APPARMOR_NEAR_MISS,
3936                    Severity::Warning,
3937                    "AppArmor candidates require exact lowercase `apparmor=<profile>` spelling without whitespace",
3938                )
3939                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3940            ),
3941            SecurityOptionKind::Seccomp { .. } => {
3942                candidates.seccomp += 1;
3943                (candidates.seccomp > 1).then(|| {
3944                    Diagnostic::new(
3945                        SECURITY_OPT_SECCOMP_CONFLICT,
3946                        Severity::Warning,
3947                        "multiple seccomp candidates are retained; a consumer must resolve the conflict explicitly",
3948                    )
3949                    .with_label(DiagnosticLabel::primary(span, "additional seccomp candidate retained"))
3950                })
3951            }
3952            SecurityOptionKind::SeccompNearMiss => Some(
3953                Diagnostic::new(
3954                    SECURITY_OPT_SECCOMP_NEAR_MISS,
3955                    Severity::Warning,
3956                    "seccomp candidates require exact lowercase `seccomp=<profile>` spelling without whitespace",
3957                )
3958                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3959            ),
3960            SecurityOptionKind::NoNewPrivileges { .. } => {
3961                candidates.no_new_privileges += 1;
3962                (candidates.no_new_privileges > 1).then(|| {
3963                    Diagnostic::new(
3964                        SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT,
3965                        Severity::Warning,
3966                        "multiple no-new-privileges candidates are retained; a consumer must resolve the conflict explicitly",
3967                    )
3968                    .with_label(DiagnosticLabel::primary(
3969                        span,
3970                        "additional no-new-privileges candidate retained",
3971                    ))
3972                })
3973            }
3974            SecurityOptionKind::NoNewPrivilegesNearMiss => Some(
3975                Diagnostic::new(
3976                    SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS,
3977                    Severity::Warning,
3978                    "no-new-privileges candidates require exact lowercase `no-new-privileges:true` or `no-new-privileges:false` spelling without whitespace",
3979                )
3980                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3981            ),
3982            SecurityOptionKind::Mask { .. }
3983            | SecurityOptionKind::MaskNearMiss
3984            | SecurityOptionKind::Unmask { .. }
3985            | SecurityOptionKind::UnmaskNearMiss => security_path_option_diagnostic(kind, span),
3986            SecurityOptionKind::SecurityLabelDisable { .. }
3987            | SecurityOptionKind::SecurityLabelDisableNearMiss
3988            | SecurityOptionKind::SecurityLabelFileType { .. }
3989            | SecurityOptionKind::SecurityLabelFileTypeNearMiss
3990            | SecurityOptionKind::SecurityLabelLevel { .. }
3991            | SecurityOptionKind::SecurityLabelLevelNearMiss
3992            | SecurityOptionKind::SecurityLabelNested { .. }
3993            | SecurityOptionKind::SecurityLabelNestedNearMiss
3994            | SecurityOptionKind::SecurityLabelType { .. }
3995            | SecurityOptionKind::SecurityLabelTypeNearMiss => {
3996                authored_security_label_diagnostic(kind, span, candidates)
3997            }
3998            SecurityOptionKind::Empty => Some(
3999                Diagnostic::new(
4000                    SECURITY_OPT_EMPTY_ITEM,
4001                    Severity::Error,
4002                    "security_opt entries must not be empty strings",
4003                )
4004                .with_label(DiagnosticLabel::primary(span, "empty security option retained")),
4005            ),
4006            SecurityOptionKind::Expression | SecurityOptionKind::Other => None,
4007        };
4008        if let Some(diagnostic) = diagnostic {
4009            self.diagnostics.push(diagnostic);
4010        }
4011    }
4012
4013    fn parse_tmpfs(&mut self, field: &ParsedField) -> Option<Tmpfs> {
4014        let value = field.value.as_ref()?;
4015        if let Some(scalar) = value.as_scalar() {
4016            if !matches!(
4017                scalar_type_for_value(scalar),
4018                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4019            ) {
4020                self.expected(
4021                    TMPFS_EXPECTED_FORM,
4022                    field,
4023                    "tmpfs must be a string scalar or a sequence of string scalars",
4024                );
4025                return None;
4026            }
4027            let span = span_from_position(self.source_id, scalar.byte_range());
4028            let item = TmpfsItem::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
4029            self.diagnose_tmpfs_item(&item);
4030            return Some(Tmpfs::new(span, TmpfsForm::Scalar(item)));
4031        }
4032
4033        let Some(sequence) = value.as_sequence() else {
4034            self.expected(
4035                TMPFS_EXPECTED_FORM,
4036                field,
4037                "tmpfs must be a string scalar or a sequence of string scalars",
4038            );
4039            return None;
4040        };
4041        let span = span_from_position(self.source_id, sequence.byte_range());
4042        let mut items = Vec::new();
4043        for node in sequence.values() {
4044            let YamlNode::Scalar(scalar) = node else {
4045                self.unsupported_sequence_item(
4046                    TMPFS_EXPECTED_STRING,
4047                    &node,
4048                    field.span,
4049                    "tmpfs entries must be string scalars",
4050                );
4051                continue;
4052            };
4053            if !matches!(
4054                scalar_type_for_value(&scalar),
4055                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4056            ) {
4057                self.unsupported_sequence_item(
4058                    TMPFS_EXPECTED_STRING,
4059                    &YamlNode::Scalar(scalar),
4060                    field.span,
4061                    "tmpfs entries must be string scalars",
4062                );
4063                continue;
4064            }
4065            let item_span = span_from_position(self.source_id, scalar.byte_range());
4066            let raw = scalar_string_from_source(&self.source, &scalar);
4067            let item = TmpfsItem::parse(Located::new(raw, item_span));
4068            self.diagnose_tmpfs_item(&item);
4069            items.push(item);
4070        }
4071        Some(Tmpfs::new(span, TmpfsForm::List(items)))
4072    }
4073
4074    fn diagnose_tmpfs_item(&mut self, item: &TmpfsItem) {
4075        if item.kind() != TmpfsItemKind::ProviderDependent {
4076            return;
4077        }
4078        self.diagnostics.push(
4079            Diagnostic::new(
4080                TMPFS_PROVIDER_DEPENDENT,
4081                Severity::Warning,
4082                "tmpfs item is malformed or uses provider- or target-specific options",
4083            )
4084            .with_label(DiagnosticLabel::primary(
4085                item.span(),
4086                "provider-dependent temporary-filesystem item",
4087            ))
4088            .with_note("use a non-empty path with only non-empty `mode`, `uid`, or `gid` assignments for documented portable syntax"),
4089        );
4090    }
4091
4092    fn parse_sysctls(&mut self, field: &ParsedField) -> Option<Sysctls> {
4093        match field.value.as_ref() {
4094            Some(YamlNode::Mapping(mapping)) => {
4095                let span = span_from_position(self.source_id, mapping.byte_range());
4096                let mut entries = Vec::new();
4097                let mut seen = BTreeMap::new();
4098                for entry in self.fields(mapping) {
4099                    if self.record_duplicate(&mut seen, &entry) {
4100                        continue;
4101                    }
4102                    if entry.name.value.is_empty() {
4103                        self.diagnostics.push(
4104                            Diagnostic::new(
4105                                SYSCTLS_EMPTY_KEY,
4106                                Severity::Error,
4107                                "sysctls mapping keys must not be empty",
4108                            )
4109                            .with_label(DiagnosticLabel::primary(entry.name.span, "empty sysctl name")),
4110                        );
4111                        continue;
4112                    }
4113                    if entry.value.as_ref().is_some_and(|value| value.as_scalar().is_none()) {
4114                        self.diagnostics.push(
4115                            Diagnostic::new(
4116                                SYSCTLS_EXPECTED_SCALAR,
4117                                Severity::Error,
4118                                "sysctls mapping values must be scalar strings, numbers, booleans, or null",
4119                            )
4120                            .with_label(DiagnosticLabel::primary(
4121                                entry.value_span.unwrap_or(entry.span),
4122                                "non-scalar sysctl value",
4123                            )),
4124                        );
4125                        continue;
4126                    }
4127                    let Some(value) = self.parse_compose_scalar(&entry, "sysctls mapping values must be scalars")
4128                    else {
4129                        continue;
4130                    };
4131                    entries.push(KeyValueEntry::new(entry.name, value, entry.span));
4132                }
4133                Some(Sysctls::new(span, SysctlsForm::Map(entries)))
4134            }
4135            Some(YamlNode::Sequence(sequence)) => {
4136                let span = span_from_position(self.source_id, sequence.byte_range());
4137                let mut items = Vec::new();
4138                let mut seen = BTreeMap::new();
4139                for node in sequence.values() {
4140                    let YamlNode::Scalar(scalar) = node else {
4141                        self.unsupported_sequence_item(
4142                            SYSCTLS_EXPECTED_STRING,
4143                            &node,
4144                            field.span,
4145                            "sysctls list entries must be YAML string scalars",
4146                        );
4147                        continue;
4148                    };
4149                    if !matches!(
4150                        scalar_type_for_value(&scalar),
4151                        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4152                    ) {
4153                        self.unsupported_sequence_item(
4154                            SYSCTLS_EXPECTED_STRING,
4155                            &YamlNode::Scalar(scalar),
4156                            field.span,
4157                            "sysctls list entries must be YAML string scalars",
4158                        );
4159                        continue;
4160                    }
4161                    let item_span = span_from_position(self.source_id, scalar.byte_range());
4162                    let value = scalar_string_from_source(&self.source, &scalar);
4163                    if let Some(first) = seen.get(&value) {
4164                        self.diagnostics.push(
4165                            Diagnostic::new(
4166                                SYSCTLS_DUPLICATE_ITEM,
4167                                Severity::Error,
4168                                "sysctls list entries must be unique exact strings",
4169                            )
4170                            .with_label(DiagnosticLabel::primary(item_span, "duplicate sysctl string"))
4171                            .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
4172                        );
4173                    } else {
4174                        seen.insert(value.clone(), item_span);
4175                    }
4176                    items.push(Located::new(value, item_span));
4177                }
4178                Some(Sysctls::new(span, SysctlsForm::List(items)))
4179            }
4180            _ => {
4181                self.expected(
4182                    SYSCTLS_EXPECTED_FORM,
4183                    field,
4184                    "sysctls must be a mapping or a sequence of string scalars",
4185                );
4186                None
4187            }
4188        }
4189    }
4190
4191    fn parse_logging(&mut self, field: &ParsedField) -> Option<Logging> {
4192        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4193            self.expected(
4194                LOGGING_EXPECTED_MAPPING,
4195                field,
4196                "logging must be a mapping with optional driver and options fields",
4197            );
4198            return None;
4199        };
4200        let span = span_from_position(self.source_id, mapping.byte_range());
4201        let mut logging = Logging::new(span);
4202        let mut seen = BTreeMap::new();
4203        for member in self.fields(mapping) {
4204            let duplicate = self.record_duplicate(&mut seen, &member);
4205            match member.name.value.as_str() {
4206                "driver" if !duplicate => {
4207                    if let Some(driver) = self.parse_logging_driver(&member) {
4208                        logging.set_driver(driver);
4209                    }
4210                }
4211                "options" if !duplicate => {
4212                    if let Some(options) = self.parse_logging_options(&member) {
4213                        logging.set_options(options);
4214                    }
4215                }
4216                name if name.starts_with("x-") => logging.push_extension(member.reference()),
4217                _ if duplicate => {}
4218                _ => logging.push_unknown(member.reference()),
4219            }
4220        }
4221        Some(logging)
4222    }
4223
4224    fn parse_credential_spec(&mut self, field: &ParsedField) -> Option<CredentialSpec> {
4225        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4226            self.expected(EXPECTED_MAPPING, field, "credential_spec must be a mapping");
4227            return None;
4228        };
4229        let mut credential_spec = CredentialSpec::new(span_from_position(self.source_id, mapping.byte_range()));
4230        let mut seen = BTreeMap::new();
4231        for member in self.fields(mapping) {
4232            let duplicate = self.record_duplicate(&mut seen, &member);
4233            match member.name.value().as_str() {
4234                "config" if !duplicate => {
4235                    if let Some(value) = self
4236                        .parse_credential_spec_string(&member, "credential_spec config must be a YAML string scalar")
4237                    {
4238                        credential_spec.set_config(value);
4239                    } else {
4240                        credential_spec.push_unknown(member.reference());
4241                    }
4242                }
4243                "file" if !duplicate => {
4244                    if let Some(value) =
4245                        self.parse_credential_spec_string(&member, "credential_spec file must be a YAML string scalar")
4246                    {
4247                        credential_spec.set_file(value);
4248                    } else {
4249                        credential_spec.push_unknown(member.reference());
4250                    }
4251                }
4252                "registry" if !duplicate => {
4253                    if let Some(value) = self
4254                        .parse_credential_spec_string(&member, "credential_spec registry must be a YAML string scalar")
4255                    {
4256                        credential_spec.set_registry(value);
4257                    } else {
4258                        credential_spec.push_unknown(member.reference());
4259                    }
4260                }
4261                name if name.starts_with("x-") => credential_spec.push_extension(member.reference()),
4262                _ if duplicate => {}
4263                _ => credential_spec.push_unknown(member.reference()),
4264            }
4265        }
4266        Some(credential_spec)
4267    }
4268
4269    fn parse_credential_spec_string(&mut self, field: &ParsedField, message: &'static str) -> Option<Located<String>> {
4270        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4271            self.expected(EXPECTED_SCALAR, field, message);
4272            return None;
4273        };
4274        if scalar_type_for_value(scalar) != ScalarType::String {
4275            self.expected(EXPECTED_SCALAR, field, message);
4276            return None;
4277        }
4278        Some(Located::new(
4279            scalar_string_from_source(&self.source, scalar),
4280            span_from_position(self.source_id, scalar.byte_range()),
4281        ))
4282    }
4283
4284    fn parse_extends(&mut self, field: &ParsedField) -> Option<Extends> {
4285        match field.value.as_ref() {
4286            Some(YamlNode::Scalar(scalar)) if scalar_type_for_value(scalar) == ScalarType::String => {
4287                Some(Extends::Short(Located::new(
4288                    scalar_string_from_source(&self.source, scalar),
4289                    span_from_position(self.source_id, scalar.byte_range()),
4290                )))
4291            }
4292            Some(YamlNode::Mapping(mapping)) => {
4293                let span = span_from_position(self.source_id, mapping.byte_range());
4294                let mut reference = ExtendsReference::new(span);
4295                let mut seen = BTreeMap::new();
4296                for member in self.fields(mapping) {
4297                    let duplicate = self.record_duplicate(&mut seen, &member);
4298                    match member.name.value().as_str() {
4299                        "service" if !duplicate => {
4300                            if let Some(value) =
4301                                self.parse_extends_string(&member, "extends service must be a YAML string scalar")
4302                            {
4303                                reference.set_service(value);
4304                            } else {
4305                                reference.push_unknown(member.reference());
4306                            }
4307                        }
4308                        "file" if !duplicate => {
4309                            if let Some(value) =
4310                                self.parse_extends_string(&member, "extends file must be a YAML string scalar")
4311                            {
4312                                reference.set_file(value);
4313                            } else {
4314                                reference.push_unknown(member.reference());
4315                            }
4316                        }
4317                        name if name.starts_with("x-") => reference.push_extension(member.reference()),
4318                        _ if duplicate => {}
4319                        _ => reference.push_unknown(member.reference()),
4320                    }
4321                }
4322                if reference.service().is_none() {
4323                    self.missing(
4324                        EXTENDS_MISSING_SERVICE,
4325                        span,
4326                        "long extends is missing required `service`",
4327                    );
4328                }
4329                Some(Extends::Long(reference))
4330            }
4331            _ => {
4332                self.expected(
4333                    EXPECTED_FIELD_FORM,
4334                    field,
4335                    "extends must be a YAML string scalar or mapping",
4336                );
4337                None
4338            }
4339        }
4340    }
4341
4342    fn parse_extends_string(&mut self, field: &ParsedField, message: impl Into<String>) -> Option<Located<String>> {
4343        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4344            self.expected(EXPECTED_SCALAR, field, message);
4345            return None;
4346        };
4347        if scalar_type_for_value(scalar) != ScalarType::String {
4348            self.expected(EXPECTED_SCALAR, field, message);
4349            return None;
4350        }
4351        Some(Located::new(
4352            scalar_string_from_source(&self.source, scalar),
4353            span_from_position(self.source_id, scalar.byte_range()),
4354        ))
4355    }
4356
4357    fn parse_provider(&mut self, field: &ParsedField) -> Option<Provider> {
4358        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4359            self.expected(EXPECTED_MAPPING, field, "provider must be a mapping");
4360            return None;
4361        };
4362        let span = span_from_position(self.source_id, mapping.byte_range());
4363        let mut provider = Provider::new(span);
4364        let mut seen = BTreeMap::new();
4365        for member in self.fields(mapping) {
4366            let duplicate = self.record_duplicate(&mut seen, &member);
4367            match member.name.value().as_str() {
4368                "type" if !duplicate => {
4369                    if let Some(value) = self.parse_provider_type(&member) {
4370                        provider.set_type(value);
4371                    } else {
4372                        provider.push_unknown(member.reference());
4373                    }
4374                }
4375                "options" if !duplicate => {
4376                    if let Some(options) = self.parse_provider_options(&member) {
4377                        provider.set_options(options);
4378                    } else {
4379                        provider.push_unknown(member.reference());
4380                    }
4381                }
4382                name if name.starts_with("x-") => provider.push_extension(member.reference()),
4383                _ if duplicate => {}
4384                _ => provider.push_unknown(member.reference()),
4385            }
4386        }
4387        if provider.type_().is_none() {
4388            self.missing(PROVIDER_MISSING_TYPE, span, "provider is missing required `type`");
4389        }
4390        Some(provider)
4391    }
4392
4393    fn parse_provider_type(&mut self, field: &ParsedField) -> Option<Located<String>> {
4394        self.parse_extends_string(field, "provider type must be a YAML string scalar")
4395    }
4396
4397    fn parse_post_start(&mut self, field: &ParsedField) -> Option<PostStartHooks> {
4398        let (span, entries) = self.parse_lifecycle_hooks(
4399            field,
4400            "post_start",
4401            POST_START_MISSING_COMMAND,
4402            PostStartHook::Hook,
4403            |span| PostStartHook::Unmodeled { span },
4404        )?;
4405        Some(PostStartHooks::new(span, entries))
4406    }
4407
4408    fn parse_pre_stop(&mut self, field: &ParsedField) -> Option<PreStopHooks> {
4409        let (span, entries) =
4410            self.parse_lifecycle_hooks(field, "pre_stop", PRE_STOP_MISSING_COMMAND, PreStopHook::Hook, |span| {
4411                PreStopHook::Unmodeled { span }
4412            })?;
4413        Some(PreStopHooks::new(span, entries))
4414    }
4415
4416    fn parse_pre_start(&mut self, field: &ParsedField) -> Option<PreStartHooks> {
4417        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
4418            self.expected(
4419                EXPECTED_SEQUENCE,
4420                field,
4421                "pre_start must be a sequence of hook mappings",
4422            );
4423            return None;
4424        };
4425        let span = span_from_position(self.source_id, sequence.byte_range());
4426        let mut entries = Vec::new();
4427        for item in sequence.values() {
4428            let Some(mapping) = item.as_mapping() else {
4429                self.unsupported_sequence_item(
4430                    EXPECTED_MAPPING,
4431                    &item,
4432                    field.span,
4433                    "pre_start items must be hook mappings",
4434                );
4435                entries.push(PreStartHook::Unmodeled {
4436                    span: node_span(self.source_id, &item).unwrap_or(field.span),
4437                });
4438                continue;
4439            };
4440            entries.push(PreStartHook::Hook(Box::new(self.parse_pre_start_service_hook(mapping))));
4441        }
4442        Some(PreStartHooks::new(span, entries))
4443    }
4444
4445    fn parse_lifecycle_hooks<T>(
4446        &mut self,
4447        field: &ParsedField,
4448        name: &str,
4449        missing_code: DiagnosticCode,
4450        hook_entry: fn(Box<ServiceHook>) -> T,
4451        unmodeled_entry: fn(SourceSpan) -> T,
4452    ) -> Option<(SourceSpan, Vec<T>)> {
4453        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
4454            self.expected(
4455                EXPECTED_SEQUENCE,
4456                field,
4457                format!("{name} must be a sequence of hook mappings"),
4458            );
4459            return None;
4460        };
4461        let span = span_from_position(self.source_id, sequence.byte_range());
4462        let mut entries = Vec::new();
4463        for item in sequence.values() {
4464            let Some(mapping) = item.as_mapping() else {
4465                self.unsupported_sequence_item(
4466                    EXPECTED_MAPPING,
4467                    &item,
4468                    field.span,
4469                    format!("{name} items must be hook mappings"),
4470                );
4471                entries.push(unmodeled_entry(node_span(self.source_id, &item).unwrap_or(field.span)));
4472                continue;
4473            };
4474            entries.push(hook_entry(Box::new(self.parse_service_hook(
4475                mapping,
4476                name,
4477                missing_code,
4478            ))));
4479        }
4480        Some((span, entries))
4481    }
4482
4483    fn parse_service_hook(&mut self, mapping: &Mapping, name: &str, missing_code: DiagnosticCode) -> ServiceHook {
4484        let span = span_from_position(self.source_id, mapping.byte_range());
4485        let mut hook = ServiceHook::new(span);
4486        let mut seen = BTreeMap::new();
4487        for member in self.fields(mapping) {
4488            let duplicate = self.record_duplicate(&mut seen, &member);
4489            if duplicate {
4490                hook.push_unknown(member.reference());
4491                continue;
4492            }
4493            match member.name.value().as_str() {
4494                "command" => {
4495                    if let Some(value) = self.parse_command(&member) {
4496                        hook.set_command(value);
4497                    } else {
4498                        hook.push_unknown(member.reference());
4499                    }
4500                }
4501                "environment" => {
4502                    if let Some(value) = self.parse_environment(&member) {
4503                        hook.set_environment(value);
4504                    } else {
4505                        hook.push_unknown(member.reference());
4506                    }
4507                }
4508                "privileged" => {
4509                    if let Some(value) = self.parse_boolean(&member, &format!("{name} privileged")) {
4510                        hook.set_privileged(value);
4511                    } else {
4512                        hook.push_unknown(member.reference());
4513                    }
4514                }
4515                "user" => {
4516                    if let Some(value) =
4517                        self.parse_extends_string(&member, format!("{name} user must be a YAML string scalar"))
4518                    {
4519                        hook.set_user(value);
4520                    } else {
4521                        hook.push_unknown(member.reference());
4522                    }
4523                }
4524                "working_dir" => {
4525                    if let Some(value) =
4526                        self.parse_extends_string(&member, format!("{name} working_dir must be a YAML string scalar"))
4527                    {
4528                        hook.set_working_dir(value);
4529                    } else {
4530                        hook.push_unknown(member.reference());
4531                    }
4532                }
4533                name if name.starts_with("x-") => hook.push_extension(member.reference()),
4534                _ => hook.push_unknown(member.reference()),
4535            }
4536        }
4537        if hook.command().is_none() {
4538            self.missing(missing_code, span, format!("{name} hook is missing required `command`"));
4539        }
4540        hook
4541    }
4542
4543    fn parse_pre_start_service_hook(&mut self, mapping: &Mapping) -> PreStartServiceHook {
4544        let span = span_from_position(self.source_id, mapping.byte_range());
4545        let mut hook = PreStartServiceHook::new(span);
4546        let mut seen = BTreeMap::new();
4547        for member in self.fields(mapping) {
4548            let duplicate = self.record_duplicate(&mut seen, &member);
4549            if duplicate {
4550                hook.push_unknown(member.reference());
4551                continue;
4552            }
4553            match member.name.value().as_str() {
4554                "command" => {
4555                    if let Some(value) = self.parse_command(&member) {
4556                        hook.set_command(value);
4557                    } else {
4558                        hook.push_unknown(member.reference());
4559                    }
4560                }
4561                "image" => {
4562                    if let Some(value) =
4563                        self.parse_extends_string(&member, "pre_start image must be a YAML string scalar")
4564                    {
4565                        hook.set_image(value);
4566                    } else {
4567                        hook.push_unknown(member.reference());
4568                    }
4569                }
4570                "environment" => {
4571                    if let Some(value) = self.parse_environment(&member) {
4572                        hook.set_environment(value);
4573                    } else {
4574                        hook.push_unknown(member.reference());
4575                    }
4576                }
4577                "privileged" => {
4578                    if let Some(value) = self.parse_boolean(&member, "pre_start privileged") {
4579                        hook.set_privileged(value);
4580                    } else {
4581                        hook.push_unknown(member.reference());
4582                    }
4583                }
4584                "per_replica" => {
4585                    if let Some(value) = self.parse_boolean(&member, "pre_start per_replica") {
4586                        hook.set_per_replica(value);
4587                    } else {
4588                        hook.push_unknown(member.reference());
4589                    }
4590                }
4591                "user" => {
4592                    if let Some(value) =
4593                        self.parse_extends_string(&member, "pre_start user must be a YAML string scalar")
4594                    {
4595                        hook.set_user(value);
4596                    } else {
4597                        hook.push_unknown(member.reference());
4598                    }
4599                }
4600                "working_dir" => {
4601                    if let Some(value) =
4602                        self.parse_extends_string(&member, "pre_start working_dir must be a YAML string scalar")
4603                    {
4604                        hook.set_working_dir(value);
4605                    } else {
4606                        hook.push_unknown(member.reference());
4607                    }
4608                }
4609                name if name.starts_with("x-") => hook.push_extension(member.reference()),
4610                _ => hook.push_unknown(member.reference()),
4611            }
4612        }
4613        hook
4614    }
4615
4616    fn parse_provider_options(&mut self, field: &ParsedField) -> Option<ProviderOptions> {
4617        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4618            self.expected(EXPECTED_MAPPING, field, "provider options must be a mapping");
4619            return None;
4620        };
4621        let span = span_from_position(self.source_id, mapping.byte_range());
4622        let mut entries = Vec::new();
4623        let mut unmodeled_entries = Vec::new();
4624        let mut seen = BTreeMap::new();
4625        for option in self.fields(mapping) {
4626            if self.record_duplicate(&mut seen, &option) {
4627                unmodeled_entries.push(option.reference());
4628                continue;
4629            }
4630            if option.name.value().is_empty() {
4631                self.expected(EXPECTED_FIELD_FORM, &option, "provider option keys must not be empty");
4632                unmodeled_entries.push(option.reference());
4633                continue;
4634            }
4635            let Some(value) = self.parse_provider_option_value(&option) else {
4636                unmodeled_entries.push(option.reference());
4637                continue;
4638            };
4639            entries.push(ProviderOption::new(option.name, value, option.span));
4640        }
4641        Some(ProviderOptions::new(span, entries, unmodeled_entries))
4642    }
4643
4644    fn parse_provider_option_value(&mut self, field: &ParsedField) -> Option<ProviderOptionValue> {
4645        let Some(value) = field.value.as_ref() else {
4646            self.expected(
4647                EXPECTED_FIELD_FORM,
4648                field,
4649                "provider option values must be YAML string, number, or boolean scalars or sequences of them",
4650            );
4651            return None;
4652        };
4653        if let Some(scalar) = value.as_scalar() {
4654            return self
4655                .provider_option_scalar(scalar)
4656                .map(ProviderOptionValue::Scalar)
4657                .or_else(|| {
4658                    self.expected(
4659                        EXPECTED_FIELD_FORM,
4660                        field,
4661                        "provider option values must be YAML string, number, or boolean scalars or sequences of them",
4662                    );
4663                    None
4664                });
4665        }
4666        let Some(sequence) = value.as_sequence() else {
4667            self.expected(
4668                EXPECTED_FIELD_FORM,
4669                field,
4670                "provider option values must be YAML string, number, or boolean scalars or sequences of them",
4671            );
4672            return None;
4673        };
4674        let span = span_from_position(self.source_id, sequence.byte_range());
4675        let mut items = Vec::new();
4676        for item in sequence.values() {
4677            let Some(scalar) = item.as_scalar() else {
4678                self.unsupported_sequence_item(
4679                    EXPECTED_SCALAR,
4680                    &item,
4681                    field.span,
4682                    "provider option sequence items must be YAML string, number, or boolean scalars",
4683                );
4684                items.push(ProviderOptionItem::Unmodeled {
4685                    span: node_span(self.source_id, &item).unwrap_or(field.span),
4686                });
4687                continue;
4688            };
4689            if let Some(value) = self.provider_option_scalar(scalar) {
4690                items.push(ProviderOptionItem::Scalar(value));
4691            } else {
4692                self.unsupported_sequence_item(
4693                    EXPECTED_SCALAR,
4694                    &item,
4695                    field.span,
4696                    "provider option sequence items must be YAML string, number, or boolean scalars",
4697                );
4698                items.push(ProviderOptionItem::Unmodeled {
4699                    span: node_span(self.source_id, &item).unwrap_or(field.span),
4700                });
4701            }
4702        }
4703        Some(ProviderOptionValue::Sequence { span, items })
4704    }
4705
4706    fn provider_option_scalar(&self, scalar: &Scalar) -> Option<Located<ComposeScalar>> {
4707        let scalar_value = ScalarValue::from_scalar(scalar);
4708        let value = match scalar_type_for_value(scalar) {
4709            ScalarType::String => ComposeScalar::String(scalar_string_from_source(&self.source, scalar)),
4710            ScalarType::Integer | ScalarType::Float => {
4711                ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
4712            }
4713            ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
4714            ScalarType::Null | ScalarType::Timestamp | ScalarType::Regex => return None,
4715        };
4716        Some(Located::new(
4717            value,
4718            span_from_position(self.source_id, scalar.byte_range()),
4719        ))
4720    }
4721
4722    fn parse_logging_driver(&mut self, field: &ParsedField) -> Option<Located<String>> {
4723        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4724            self.expected(
4725                LOGGING_DRIVER_EXPECTED_STRING,
4726                field,
4727                "logging driver must be a YAML string scalar",
4728            );
4729            return None;
4730        };
4731        if !matches!(
4732            scalar_type_for_value(scalar),
4733            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4734        ) {
4735            self.expected(
4736                LOGGING_DRIVER_EXPECTED_STRING,
4737                field,
4738                "logging driver must be a YAML string scalar",
4739            );
4740            return None;
4741        }
4742        let span = span_from_position(self.source_id, scalar.byte_range());
4743        Some(Located::new(scalar_string_from_source(&self.source, scalar), span))
4744    }
4745
4746    fn parse_logging_options(&mut self, field: &ParsedField) -> Option<LoggingOptions> {
4747        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4748            self.expected(
4749                LOGGING_OPTIONS_EXPECTED_MAPPING,
4750                field,
4751                "logging options must be a mapping",
4752            );
4753            return None;
4754        };
4755        let span = span_from_position(self.source_id, mapping.byte_range());
4756        let mut entries = Vec::new();
4757        let mut unmodeled_entries = Vec::new();
4758        let mut seen = BTreeMap::new();
4759        for option in self.fields(mapping) {
4760            if self.record_duplicate(&mut seen, &option) {
4761                continue;
4762            }
4763            if option.name.value.is_empty() {
4764                self.diagnostics.push(
4765                    Diagnostic::new(
4766                        LOGGING_OPTION_EMPTY_KEY,
4767                        Severity::Error,
4768                        "logging option keys must not be empty",
4769                    )
4770                    .with_label(DiagnosticLabel::primary(option.name.span, "empty logging option key")),
4771                );
4772                unmodeled_entries.push(option.reference());
4773                continue;
4774            }
4775            let Some(value) = self.parse_logging_option_scalar(&option) else {
4776                unmodeled_entries.push(option.reference());
4777                continue;
4778            };
4779            entries.push(LoggingOption::new(option.name, value, option.span));
4780        }
4781        Some(LoggingOptions::new(span, entries, unmodeled_entries))
4782    }
4783
4784    fn parse_logging_option_scalar(&mut self, field: &ParsedField) -> Option<Located<LoggingOptionValue>> {
4785        let Some(node) = field.value.as_ref() else {
4786            return Some(Located::new(LoggingOptionValue::Null, field.name.span));
4787        };
4788        let Some(scalar) = node.as_scalar() else {
4789            self.expected(
4790                LOGGING_OPTION_EXPECTED_SCALAR,
4791                field,
4792                "logging option values must be YAML string, number, or null scalars",
4793            );
4794            return None;
4795        };
4796        let span = span_from_position(self.source_id, scalar.byte_range());
4797        let value = match scalar_type_for_value(scalar) {
4798            ScalarType::Null => LoggingOptionValue::Null,
4799            ScalarType::Integer | ScalarType::Float => {
4800                LoggingOptionValue::Number(scalar_string_from_source(&self.source, scalar))
4801            }
4802            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
4803                LoggingOptionValue::String(scalar_string_from_source(&self.source, scalar))
4804            }
4805            ScalarType::Boolean => {
4806                self.diagnostics.push(
4807                    Diagnostic::new(
4808                        LOGGING_OPTION_EXPECTED_SCALAR,
4809                        Severity::Error,
4810                        "logging option values must be YAML string, number, or null scalars",
4811                    )
4812                    .with_label(DiagnosticLabel::primary(
4813                        span,
4814                        "boolean logging option retained as malformed",
4815                    )),
4816                );
4817                return None;
4818            }
4819        };
4820        Some(Located::new(value, span))
4821    }
4822
4823    fn parse_restart_policy(&mut self, field: &ParsedField) -> Option<RestartPolicy> {
4824        let value = self.parse_string(field, "service restart policy")?;
4825        let policy = RestartPolicy::parse(value);
4826        if !policy.is_valid() {
4827            self.diagnostics.push(
4828                Diagnostic::new(
4829                    RESTART_INVALID_POLICY,
4830                    Severity::Error,
4831                    "restart must be `no`, `always`, `on-failure[:max-retries]`, `unless-stopped`, or interpolation",
4832                )
4833                .with_label(DiagnosticLabel::primary(
4834                    policy.raw().span(),
4835                    "invalid service restart policy",
4836                )),
4837            );
4838        }
4839        Some(policy)
4840    }
4841
4842    fn parse_pids_limit(&mut self, field: &ParsedField) -> Option<PidsLimit> {
4843        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4844            self.expected(
4845                PIDS_LIMIT_EXPECTED_VALUE,
4846                field,
4847                "pids_limit must be a number or string scalar",
4848            );
4849            return None;
4850        };
4851        if matches!(scalar_type_for_value(scalar), ScalarType::Boolean | ScalarType::Null) {
4852            self.expected(
4853                PIDS_LIMIT_EXPECTED_VALUE,
4854                field,
4855                "pids_limit must be a number or string scalar",
4856            );
4857            return None;
4858        }
4859        let span = span_from_position(self.source_id, scalar.byte_range());
4860        let limit = PidsLimit::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
4861        match limit.kind() {
4862            PidsLimitKind::Zero => self.diagnostics.push(
4863                Diagnostic::new(
4864                    PIDS_LIMIT_AMBIGUOUS_ZERO,
4865                    Severity::Warning,
4866                    "pids_limit zero is preserved as an ambiguous and unportable native state",
4867                )
4868                .with_label(DiagnosticLabel::primary(span, "ambiguous zero PID limit")),
4869            ),
4870            PidsLimitKind::Other => self.diagnostics.push(
4871                Diagnostic::new(
4872                    PIDS_LIMIT_INVALID,
4873                    Severity::Error,
4874                    "pids_limit must be `-1`, a positive integral decimal, or interpolation",
4875                )
4876                .with_label(DiagnosticLabel::primary(span, "unsupported service PID limit")),
4877            ),
4878            _ => {}
4879        }
4880        Some(limit)
4881    }
4882
4883    fn parse_cpu_count(&mut self, field: &ParsedField) -> Option<Located<CpuCount>> {
4884        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4885            self.expected(
4886                CPU_COUNT_EXPECTED_VALUE,
4887                field,
4888                "cpu_count must be a YAML integer or string scalar",
4889            );
4890            return None;
4891        };
4892        let span = span_from_position(self.source_id, scalar.byte_range());
4893        let scalar_value = ScalarValue::from_scalar(scalar);
4894        let raw = scalar_string_from_source(&self.source, scalar);
4895        let count = match scalar_type_for_value(scalar) {
4896            ScalarType::Integer | ScalarType::Float if CpuCount::yaml_integer_spelling(&raw) => {
4897                CpuCount::yaml_integer(raw)
4898            }
4899            ScalarType::String
4900                if scalar_value.style() == ScalarStyle::Plain
4901                    && !scalar_uses_block_style(&self.source, scalar)
4902                    && CpuCount::yaml_integer_spelling(&raw) =>
4903            {
4904                CpuCount::yaml_integer(raw)
4905            }
4906            ScalarType::String => CpuCount::String(scalar_string_from_source(&self.source, scalar)),
4907            _ => {
4908                self.expected(
4909                    CPU_COUNT_EXPECTED_VALUE,
4910                    field,
4911                    "cpu_count must be a YAML integer or string scalar",
4912                );
4913                return None;
4914            }
4915        };
4916        if !count.is_valid() {
4917            self.diagnostics.push(
4918                Diagnostic::new(
4919                    CPU_COUNT_NEGATIVE,
4920                    Severity::Error,
4921                    "cpu_count YAML integers must be nonnegative",
4922                )
4923                .with_label(DiagnosticLabel::primary(
4924                    span,
4925                    "negative CPU count retained as invalid evidence",
4926                )),
4927            );
4928        }
4929        Some(Located::new(count, span))
4930    }
4931
4932    fn parse_cpu_percent(&mut self, field: &ParsedField) -> Option<Located<CpuPercent>> {
4933        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4934            self.expected(
4935                CPU_PERCENT_EXPECTED_VALUE,
4936                field,
4937                "cpu_percent must be a YAML integer or string scalar",
4938            );
4939            return None;
4940        };
4941        let span = span_from_position(self.source_id, scalar.byte_range());
4942        let scalar_value = ScalarValue::from_scalar(scalar);
4943        let raw = scalar_string_from_source(&self.source, scalar);
4944        let percent = match scalar_type_for_value(scalar) {
4945            ScalarType::Integer | ScalarType::Float if CpuPercent::yaml_integer_spelling(&raw) => {
4946                CpuPercent::yaml_integer(raw)
4947            }
4948            ScalarType::String
4949                if scalar_value.style() == ScalarStyle::Plain
4950                    && !scalar_uses_block_style(&self.source, scalar)
4951                    && CpuPercent::yaml_integer_spelling(&raw) =>
4952            {
4953                CpuPercent::yaml_integer(raw)
4954            }
4955            ScalarType::String => CpuPercent::String(scalar_string_from_source(&self.source, scalar)),
4956            _ => {
4957                self.expected(
4958                    CPU_PERCENT_EXPECTED_VALUE,
4959                    field,
4960                    "cpu_percent must be a YAML integer or string scalar",
4961                );
4962                return None;
4963            }
4964        };
4965        if !percent.is_valid() {
4966            self.diagnostics.push(
4967                Diagnostic::new(
4968                    CPU_PERCENT_OUT_OF_RANGE,
4969                    Severity::Error,
4970                    "cpu_percent YAML integers must be between 0 and 100 inclusive",
4971                )
4972                .with_label(DiagnosticLabel::primary(
4973                    span,
4974                    "out-of-range CPU percentage retained as invalid evidence",
4975                )),
4976            );
4977        }
4978        Some(Located::new(percent, span))
4979    }
4980
4981    fn parse_cpu_period(&mut self, field: &ParsedField) -> Option<Located<CpuPeriod>> {
4982        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4983            self.expected(
4984                CPU_PERIOD_EXPECTED_VALUE,
4985                field,
4986                "cpu_period must be a YAML number or string scalar",
4987            );
4988            return None;
4989        };
4990        let span = span_from_position(self.source_id, scalar.byte_range());
4991        let scalar_value = ScalarValue::from_scalar(scalar);
4992        let raw = scalar_string_from_source(&self.source, scalar);
4993        let period = match scalar_type_for_value(scalar) {
4994            ScalarType::Integer | ScalarType::Float => CpuPeriod::YamlNumber(raw),
4995            ScalarType::String
4996                if scalar_value.style() == ScalarStyle::Plain
4997                    && !scalar_uses_block_style(&self.source, scalar)
4998                    && CpuPeriod::yaml_number_spelling(&raw) =>
4999            {
5000                CpuPeriod::YamlNumber(raw)
5001            }
5002            ScalarType::String => CpuPeriod::String(raw),
5003            _ => {
5004                self.expected(
5005                    CPU_PERIOD_EXPECTED_VALUE,
5006                    field,
5007                    "cpu_period must be a YAML number or string scalar",
5008                );
5009                return None;
5010            }
5011        };
5012        Some(Located::new(period, span))
5013    }
5014
5015    fn parse_cpu_quota(&mut self, field: &ParsedField) -> Option<Located<CpuQuota>> {
5016        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5017            self.expected(
5018                CPU_QUOTA_EXPECTED_VALUE,
5019                field,
5020                "cpu_quota must be a YAML number or string scalar",
5021            );
5022            return None;
5023        };
5024        let span = span_from_position(self.source_id, scalar.byte_range());
5025        let scalar_value = ScalarValue::from_scalar(scalar);
5026        let raw = scalar_string_from_source(&self.source, scalar);
5027        let quota = match scalar_type_for_value(scalar) {
5028            ScalarType::Integer | ScalarType::Float => CpuQuota::YamlNumber(raw),
5029            ScalarType::String
5030                if scalar_value.style() == ScalarStyle::Plain
5031                    && !scalar_uses_block_style(&self.source, scalar)
5032                    && CpuPeriod::yaml_number_spelling(&raw) =>
5033            {
5034                CpuQuota::YamlNumber(raw)
5035            }
5036            ScalarType::String => CpuQuota::String(raw),
5037            _ => {
5038                self.expected(
5039                    CPU_QUOTA_EXPECTED_VALUE,
5040                    field,
5041                    "cpu_quota must be a YAML number or string scalar",
5042                );
5043                return None;
5044            }
5045        };
5046        Some(Located::new(quota, span))
5047    }
5048
5049    fn parse_cpu_rt_period(&mut self, field: &ParsedField) -> Option<Located<CpuRtPeriod>> {
5050        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5051            self.expected(
5052                CPU_RT_PERIOD_EXPECTED_VALUE,
5053                field,
5054                "cpu_rt_period must be a YAML number or string scalar",
5055            );
5056            return None;
5057        };
5058        let span = span_from_position(self.source_id, scalar.byte_range());
5059        let scalar_value = ScalarValue::from_scalar(scalar);
5060        let raw = scalar_string_from_source(&self.source, scalar);
5061        let period = match scalar_type_for_value(scalar) {
5062            ScalarType::Integer | ScalarType::Float => CpuRtPeriod::YamlNumber(raw),
5063            ScalarType::String
5064                if scalar_value.style() == ScalarStyle::Plain
5065                    && !scalar_uses_block_style(&self.source, scalar)
5066                    && CpuPeriod::yaml_number_spelling(&raw) =>
5067            {
5068                CpuRtPeriod::YamlNumber(raw)
5069            }
5070            ScalarType::String => CpuRtPeriod::parse_string(raw),
5071            _ => {
5072                self.expected(
5073                    CPU_RT_PERIOD_EXPECTED_VALUE,
5074                    field,
5075                    "cpu_rt_period must be a YAML number or string scalar",
5076                );
5077                return None;
5078            }
5079        };
5080        if !period.is_valid() {
5081            self.diagnostics.push(
5082                Diagnostic::new(
5083                    CPU_RT_PERIOD_INVALID,
5084                    Severity::Error,
5085                    "cpu_rt_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
5086                )
5087                .with_label(DiagnosticLabel::primary(span, "invalid service real-time CPU period")),
5088            );
5089        }
5090        Some(Located::new(period, span))
5091    }
5092
5093    fn parse_cpu_rt_runtime(&mut self, field: &ParsedField) -> Option<Located<CpuRtRuntime>> {
5094        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5095            self.expected(
5096                EXPECTED_SCALAR,
5097                field,
5098                "cpu_rt_runtime must be an integer microsecond or duration string scalar",
5099            );
5100            return None;
5101        };
5102        let span = span_from_position(self.source_id, scalar.byte_range());
5103        let raw = scalar_string_from_source(&self.source, scalar);
5104        let runtime = match scalar_type_for_value(scalar) {
5105            ScalarType::Integer => CpuRtRuntime::parse_number(raw, true),
5106            ScalarType::Float => CpuRtRuntime::parse_number(raw, false),
5107            ScalarType::String => CpuRtRuntime::parse_string(raw),
5108            _ => {
5109                self.expected(
5110                    EXPECTED_SCALAR,
5111                    field,
5112                    "cpu_rt_runtime must be an integer microsecond or duration string scalar",
5113                );
5114                return None;
5115            }
5116        };
5117        if !runtime.is_valid() {
5118            self.diagnostics.push(Diagnostic::new(CPU_RT_RUNTIME_INVALID, Severity::Error,
5119                "cpu_rt_runtime must be an integer microsecond value, a Compose duration, or an interpolation expression")
5120                .with_label(DiagnosticLabel::primary(span, "invalid service real-time CPU runtime")));
5121        }
5122        Some(Located::new(runtime, span))
5123    }
5124
5125    fn parse_service_integer(
5126        &mut self,
5127        field: &ParsedField,
5128        min: i128,
5129        max: i128,
5130        description: &str,
5131    ) -> Option<Located<ServiceInteger>> {
5132        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5133            self.expected(
5134                EXPECTED_SCALAR,
5135                field,
5136                format!("{description} must be an integer scalar"),
5137            );
5138            return None;
5139        };
5140        let span = span_from_position(self.source_id, scalar.byte_range());
5141        if matches!(
5142            scalar_type_for_value(scalar),
5143            ScalarType::Boolean | ScalarType::Timestamp | ScalarType::Regex
5144        ) {
5145            self.expected(
5146                EXPECTED_SCALAR,
5147                field,
5148                format!("{description} must be an integer scalar"),
5149            );
5150            return None;
5151        }
5152        let parsed = ServiceInteger::parse(scalar_string_from_source(&self.source, scalar), min, max);
5153        if !parsed.is_valid() {
5154            self.diagnostics.push(
5155                Diagnostic::new(
5156                    EXPECTED_FIELD_FORM,
5157                    Severity::Error,
5158                    format!("{description} must be an integer in its documented range"),
5159                )
5160                .with_label(DiagnosticLabel::primary(span, "invalid integer spelling retained")),
5161            );
5162        }
5163        Some(Located::new(parsed, span))
5164    }
5165
5166    fn parse_cpus(&mut self, field: &ParsedField) -> Option<Located<Cpus>> {
5167        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5168            self.expected(EXPECTED_SCALAR, field, "cpus must be a decimal scalar");
5169            return None;
5170        };
5171        let span = span_from_position(self.source_id, scalar.byte_range());
5172        let kind = scalar_type_for_value(scalar);
5173        if matches!(kind, ScalarType::Boolean | ScalarType::Timestamp | ScalarType::Regex) {
5174            self.expected(EXPECTED_SCALAR, field, "cpus must be a decimal scalar");
5175            return None;
5176        }
5177        let value = Cpus::parse(scalar_string_from_source(&self.source, scalar));
5178        if !value.is_valid() {
5179            self.diagnostics.push(
5180                Diagnostic::new(
5181                    EXPECTED_FIELD_FORM,
5182                    Severity::Error,
5183                    "cpus must be a decimal allocation or interpolation expression",
5184                )
5185                .with_label(DiagnosticLabel::primary(span, "invalid CPU allocation retained")),
5186            );
5187        }
5188        Some(Located::new(value, span))
5189    }
5190
5191    fn parse_shm_size(&mut self, field: &ParsedField) -> Option<ShmSize> {
5192        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5193            self.expected(
5194                SHM_SIZE_EXPECTED_VALUE,
5195                field,
5196                "shm_size must be a YAML number or string scalar",
5197            );
5198            return None;
5199        };
5200        let scalar_kind = match scalar_type_for_value(scalar) {
5201            ScalarType::Integer | ScalarType::Float => ShmSizeScalarKind::Number,
5202            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => ShmSizeScalarKind::String,
5203            ScalarType::Boolean | ScalarType::Null => {
5204                self.expected(
5205                    SHM_SIZE_EXPECTED_VALUE,
5206                    field,
5207                    "shm_size must be a YAML number or string scalar",
5208                );
5209                return None;
5210            }
5211        };
5212        let span = span_from_position(self.source_id, scalar.byte_range());
5213        let size = ShmSize::parse(
5214            Located::new(scalar_string_from_source(&self.source, scalar), span),
5215            scalar_kind,
5216        );
5217        self.diagnose_shm_size(&size);
5218        Some(size)
5219    }
5220
5221    fn diagnose_shm_size(&mut self, size: &ShmSize) {
5222        let (code, message, label, note) = match size.kind() {
5223            ShmSizeKind::Zero { .. } => (
5224                SHM_SIZE_AMBIGUOUS_ZERO,
5225                "shm_size zero is preserved because Compose does not define its semantics",
5226                "ambiguous zero shared-memory size",
5227                "choose a positive size with an explicit documented lowercase unit",
5228            ),
5229            ShmSizeKind::ProviderDependentNumber => (
5230                SHM_SIZE_PROVIDER_DEPENDENT_NUMBER,
5231                "numeric shm_size is schema-accepted but lacks a documented explicit unit",
5232                "provider-dependent numeric shared-memory size",
5233                "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for portable intent",
5234            ),
5235            ShmSizeKind::ProviderDependentString => (
5236                SHM_SIZE_PROVIDER_DEPENDENT_STRING,
5237                "string shm_size is schema-accepted but falls outside the documented lowercase suffix family",
5238                "provider-dependent string shared-memory size",
5239                "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
5240            ),
5241            ShmSizeKind::Documented { .. } | ShmSizeKind::Expression => return,
5242        };
5243        self.diagnostics.push(
5244            Diagnostic::new(code, Severity::Warning, message)
5245                .with_label(DiagnosticLabel::primary(size.raw().span(), label))
5246                .with_note(note),
5247        );
5248    }
5249
5250    fn parse_mem_limit(&mut self, field: &ParsedField) -> Option<MemLimit> {
5251        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5252            self.expected(
5253                MEM_LIMIT_EXPECTED_VALUE,
5254                field,
5255                "mem_limit must be a YAML number or string scalar",
5256            );
5257            return None;
5258        };
5259        let scalar_kind = match scalar_type_for_value(scalar) {
5260            ScalarType::Integer | ScalarType::Float => MemLimitScalarKind::Number,
5261            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MemLimitScalarKind::String,
5262            ScalarType::Boolean | ScalarType::Null => {
5263                self.expected(
5264                    MEM_LIMIT_EXPECTED_VALUE,
5265                    field,
5266                    "mem_limit must be a YAML number or string scalar",
5267                );
5268                return None;
5269            }
5270        };
5271        let span = span_from_position(self.source_id, scalar.byte_range());
5272        let limit = MemLimit::parse(
5273            Located::new(scalar_string_from_source(&self.source, scalar), span),
5274            scalar_kind,
5275        );
5276        self.diagnose_mem_limit(&limit);
5277        Some(limit)
5278    }
5279
5280    fn parse_memswap_limit(&mut self, field: &ParsedField) -> Option<MemswapLimit> {
5281        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5282            self.expected(
5283                MEMSWAP_LIMIT_EXPECTED_VALUE,
5284                field,
5285                "memswap_limit must be a YAML number or string scalar",
5286            );
5287            return None;
5288        };
5289        let scalar_kind = match scalar_type_for_value(scalar) {
5290            ScalarType::Integer | ScalarType::Float => MemswapLimitScalarKind::Number,
5291            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MemswapLimitScalarKind::String,
5292            ScalarType::Boolean | ScalarType::Null => {
5293                self.expected(
5294                    MEMSWAP_LIMIT_EXPECTED_VALUE,
5295                    field,
5296                    "memswap_limit must be a YAML number or string scalar",
5297                );
5298                return None;
5299            }
5300        };
5301        let span = span_from_position(self.source_id, scalar.byte_range());
5302        let limit = MemswapLimit::parse(
5303            Located::new(scalar_string_from_source(&self.source, scalar), span),
5304            scalar_kind,
5305        );
5306        if matches!(limit.kind(), MemswapLimitKind::Other(_)) {
5307            self.diagnostics.push(
5308                Diagnostic::new(
5309                    MEMSWAP_LIMIT_INVALID,
5310                    Severity::Error,
5311                    "memswap_limit must be `-1`, a decimal byte quantity, or an interpolation expression",
5312                )
5313                .with_label(DiagnosticLabel::primary(
5314                    span,
5315                    "invalid memory-plus-swap limit retained",
5316                )),
5317            );
5318        }
5319        Some(limit)
5320    }
5321
5322    fn diagnose_mem_limit(&mut self, limit: &MemLimit) {
5323        let (code, message, label, note) = match limit.kind() {
5324            MemLimitKind::Zero { .. } => (
5325                MEM_LIMIT_AMBIGUOUS_ZERO,
5326                "mem_limit zero is preserved without inferring portable runtime behavior",
5327                "ambiguous zero memory limit",
5328                "choose a positive size with an explicit documented lowercase unit",
5329            ),
5330            MemLimitKind::SchemaNumber => (
5331                MEM_LIMIT_SCHEMA_NUMBER,
5332                "numeric mem_limit is schema-accepted but lacks a documented explicit unit",
5333                "schema-only numeric memory limit",
5334                "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for explicit intent",
5335            ),
5336            MemLimitKind::ProviderDependentString => (
5337                MEM_LIMIT_PROVIDER_DEPENDENT_STRING,
5338                "string mem_limit is schema-accepted but falls outside the documented lowercase suffix family",
5339                "provider-dependent string memory limit",
5340                "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
5341            ),
5342            MemLimitKind::Documented { .. } | MemLimitKind::Expression => return,
5343        };
5344        self.diagnostics.push(
5345            Diagnostic::new(code, Severity::Warning, message)
5346                .with_label(DiagnosticLabel::primary(limit.raw().span(), label))
5347                .with_note(note),
5348        );
5349    }
5350
5351    fn parse_pull_policy(&mut self, field: &ParsedField) -> Option<PullPolicy> {
5352        let value = self.parse_string(field, "service pull policy")?;
5353        let policy = PullPolicy::parse(value);
5354        if !policy.is_recognized() {
5355            self.diagnostics.push(
5356                Diagnostic::new(
5357                    PULL_POLICY_INVALID,
5358                    Severity::Error,
5359                    "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",
5360                )
5361                .with_label(DiagnosticLabel::primary(
5362                    policy.raw().span(),
5363                    "invalid or provider-specific service pull policy",
5364                )),
5365            );
5366        }
5367        Some(policy)
5368    }
5369
5370    fn parse_stop_grace_period(&mut self, field: &ParsedField) -> Option<Located<StopGracePeriod>> {
5371        let value = self.parse_string(field, "service stop grace period")?;
5372        let period = StopGracePeriod::parse(value.value);
5373        if !period.is_valid() {
5374            self.diagnostics.push(
5375                Diagnostic::new(
5376                    STOP_GRACE_PERIOD_INVALID,
5377                    Severity::Error,
5378                    "stop_grace_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
5379                )
5380                .with_label(DiagnosticLabel::primary(
5381                    value.span,
5382                    "invalid service stop grace period",
5383                )),
5384            );
5385        }
5386        Some(Located::new(period, value.span))
5387    }
5388
5389    fn parse_command(&mut self, field: &ParsedField) -> Option<Command> {
5390        match field.value.as_ref() {
5391            None => Some(Command::Null(field.name.span)),
5392            Some(YamlNode::Scalar(scalar)) => {
5393                let span = span_from_position(self.source_id, scalar.byte_range());
5394                if scalar_is_null(scalar) {
5395                    Some(Command::Null(span))
5396                } else {
5397                    Some(Command::String(Located::new(
5398                        scalar_string_from_source(&self.source, scalar),
5399                        span,
5400                    )))
5401                }
5402            }
5403            Some(YamlNode::Sequence(sequence)) => {
5404                let span = span_from_position(self.source_id, sequence.byte_range());
5405                let values =
5406                    self.parse_scalar_nodes(sequence.values(), field.span, "command list items must be scalars");
5407                Some(Command::List { span, values })
5408            }
5409            _ => {
5410                self.expected(
5411                    EXPECTED_FIELD_FORM,
5412                    field,
5413                    "command must be null, a scalar, or a sequence",
5414                );
5415                None
5416            }
5417        }
5418    }
5419
5420    fn parse_service_user(&mut self, field: &ParsedField) -> Option<UserSpec> {
5421        self.parse_string(field, "service user").map(UserSpec::parse)
5422    }
5423
5424    fn parse_entrypoint(&mut self, field: &ParsedField) -> Option<Entrypoint> {
5425        match field.value.as_ref() {
5426            None => Some(Entrypoint::Null(field.name.span)),
5427            Some(YamlNode::Scalar(scalar)) => {
5428                let span = span_from_position(self.source_id, scalar.byte_range());
5429                if scalar_is_null(scalar) {
5430                    Some(Entrypoint::Null(span))
5431                } else {
5432                    Some(Entrypoint::String(Located::new(
5433                        scalar_string_from_source(&self.source, scalar),
5434                        span,
5435                    )))
5436                }
5437            }
5438            Some(YamlNode::Sequence(sequence)) => {
5439                let span = span_from_position(self.source_id, sequence.byte_range());
5440                let values =
5441                    self.parse_scalar_nodes(sequence.values(), field.span, "entrypoint list items must be scalars");
5442                Some(Entrypoint::List { span, values })
5443            }
5444            _ => {
5445                self.expected(
5446                    EXPECTED_FIELD_FORM,
5447                    field,
5448                    "entrypoint must be null, a scalar, or a sequence",
5449                );
5450                None
5451            }
5452        }
5453    }
5454
5455    fn parse_environment(&mut self, field: &ParsedField) -> Option<Environment> {
5456        match field.value.as_ref() {
5457            Some(YamlNode::Sequence(sequence)) => {
5458                let span = span_from_position(self.source_id, sequence.byte_range());
5459                let entries = self
5460                    .parse_scalar_nodes(sequence.values(), field.span, "environment list items must be scalars")
5461                    .into_iter()
5462                    .map(EnvironmentListEntry::parse)
5463                    .collect();
5464                Some(Environment::List { span, entries })
5465            }
5466            Some(YamlNode::Mapping(mapping)) => {
5467                let span = span_from_position(self.source_id, mapping.byte_range());
5468                let entries = self.parse_environment_map(mapping);
5469                Some(Environment::Map { span, entries })
5470            }
5471            _ => {
5472                self.expected(EXPECTED_FIELD_FORM, field, "environment must be a sequence or mapping");
5473                None
5474            }
5475        }
5476    }
5477
5478    fn parse_environment_map(&mut self, mapping: &Mapping) -> Vec<EnvironmentMapEntry> {
5479        let mut entries = Vec::new();
5480        let mut seen = BTreeMap::new();
5481        for field in self.fields(mapping) {
5482            if self.record_duplicate(&mut seen, &field) {
5483                continue;
5484            }
5485            let value = self.parse_compose_scalar(&field, "environment values must be scalars");
5486            if let Some(value) = value {
5487                entries.push(EnvironmentMapEntry::new(field.name, value, field.span));
5488            }
5489        }
5490        entries
5491    }
5492
5493    fn parse_environment_files(&mut self, field: &ParsedField) -> Vec<EnvironmentFile> {
5494        match field.value.as_ref() {
5495            Some(YamlNode::Scalar(_)) => self
5496                .parse_string(field, "service environment-file path")
5497                .map(EnvironmentFile::Short)
5498                .into_iter()
5499                .collect(),
5500            Some(YamlNode::Sequence(sequence)) => sequence
5501                .values()
5502                .filter_map(|value| match value {
5503                    YamlNode::Scalar(scalar) => {
5504                        let span = span_from_position(self.source_id, scalar.byte_range());
5505                        Some(EnvironmentFile::Short(Located::new(
5506                            scalar_string_from_source(&self.source, &scalar),
5507                            span,
5508                        )))
5509                    }
5510                    YamlNode::Mapping(mapping) => Some(EnvironmentFile::Long(Box::new(
5511                        self.parse_long_environment_file(&mapping),
5512                    ))),
5513                    _ => {
5514                        self.diagnostics.push(
5515                            Diagnostic::new(
5516                                ENVIRONMENT_FILE_EXPECTED_FORM,
5517                                Severity::Error,
5518                                "env_file item must use scalar short syntax or mapping long syntax",
5519                            )
5520                            .with_label(DiagnosticLabel::primary(
5521                                node_span(self.source_id, &value).unwrap_or(field.span),
5522                                "invalid environment-file item",
5523                            )),
5524                        );
5525                        None
5526                    }
5527                })
5528                .collect(),
5529            _ => {
5530                self.expected(
5531                    EXPECTED_FIELD_FORM,
5532                    field,
5533                    "env_file must be a scalar path or a sequence of short/long entries",
5534                );
5535                Vec::new()
5536            }
5537        }
5538    }
5539
5540    fn parse_long_environment_file(&mut self, mapping: &Mapping) -> LongEnvironmentFile {
5541        let span = span_from_position(self.source_id, mapping.byte_range());
5542        let mut environment_file = LongEnvironmentFile::new(span);
5543        let mut seen = BTreeMap::new();
5544        for field in self.fields(mapping) {
5545            let duplicate = self.record_duplicate(&mut seen, &field);
5546            match field.name.value.as_str() {
5547                "path" if !duplicate => self
5548                    .parse_string(&field, "environment-file path")
5549                    .into_iter()
5550                    .for_each(|value| environment_file.set_path(value)),
5551                "required" if !duplicate => self
5552                    .parse_boolean(&field, "environment-file required option")
5553                    .into_iter()
5554                    .for_each(|value| environment_file.set_required(value)),
5555                "format" if !duplicate => {
5556                    if let Some(raw) = self.parse_string(&field, "environment-file format") {
5557                        let format = EnvironmentFileFormat::parse(raw);
5558                        if !format.is_valid() {
5559                            self.diagnostics.push(
5560                                Diagnostic::new(
5561                                    ENVIRONMENT_FILE_INVALID_FORMAT,
5562                                    Severity::Error,
5563                                    "environment-file format must be `raw` or interpolation",
5564                                )
5565                                .with_label(DiagnosticLabel::primary(format.raw().span(), "invalid format")),
5566                            );
5567                        }
5568                        environment_file.set_format(format);
5569                    }
5570                }
5571                name if name.starts_with("x-") => environment_file.push_extension(field.reference()),
5572                _ if duplicate => {}
5573                _ => environment_file.push_unknown(field.reference()),
5574            }
5575        }
5576        if environment_file.path().is_none() {
5577            self.missing(
5578                ENVIRONMENT_FILE_MISSING_PATH,
5579                span,
5580                "long environment-file entry is missing `path`",
5581            );
5582        }
5583        environment_file
5584    }
5585
5586    fn parse_extra_hosts(&mut self, field: &ParsedField) -> Option<ExtraHosts> {
5587        match field.value.as_ref() {
5588            Some(YamlNode::Sequence(sequence)) => {
5589                let span = span_from_position(self.source_id, sequence.byte_range());
5590                let entries = self
5591                    .parse_scalar_nodes(sequence.values(), field.span, "extra_hosts entries must be scalars")
5592                    .into_iter()
5593                    .map(|raw| {
5594                        let entry = ShortExtraHost::parse(raw);
5595                        if !entry.is_complete() {
5596                            self.diagnostics.push(
5597                                Diagnostic::new(
5598                                    EXTRA_HOST_INVALID_ENTRY,
5599                                    Severity::Error,
5600                                    "short extra_hosts entry must contain a hostname and address",
5601                                )
5602                                .with_label(DiagnosticLabel::primary(
5603                                    entry.raw().span(),
5604                                    "missing separator or value",
5605                                )),
5606                            );
5607                        }
5608                        entry
5609                    })
5610                    .collect();
5611                Some(ExtraHosts::Short { span, entries })
5612            }
5613            Some(YamlNode::Mapping(mapping)) => {
5614                let span = span_from_position(self.source_id, mapping.byte_range());
5615                let mut entries = Vec::new();
5616                let mut seen = BTreeMap::new();
5617                for host in self.fields(mapping) {
5618                    if self.record_duplicate(&mut seen, &host) {
5619                        continue;
5620                    }
5621                    if let Some(address) = self.parse_string(&host, "extra host address") {
5622                        let address = Located::new(HostAddress::parse(address.value), address.span);
5623                        entries.push(LongExtraHost::new(host.name, address, host.span));
5624                    }
5625                }
5626                Some(ExtraHosts::Long { span, entries })
5627            }
5628            _ => {
5629                self.expected(EXPECTED_FIELD_FORM, field, "extra_hosts must be a sequence or mapping");
5630                None
5631            }
5632        }
5633    }
5634
5635    fn parse_ulimits(&mut self, field: &ParsedField) -> Option<Ulimits> {
5636        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5637            self.expected(EXPECTED_MAPPING, field, "ulimits must be a mapping");
5638            return None;
5639        };
5640        let span = span_from_position(self.source_id, mapping.byte_range());
5641        let mut entries = Vec::new();
5642        let mut seen = BTreeMap::new();
5643        for limit in self.fields(mapping) {
5644            if self.record_duplicate(&mut seen, &limit) {
5645                continue;
5646            }
5647            if !valid_ulimit_name(limit.name.value()) {
5648                self.diagnostics.push(
5649                    Diagnostic::new(
5650                        ULIMIT_INVALID_NAME,
5651                        Severity::Error,
5652                        "ulimit names must contain only lowercase ASCII letters",
5653                    )
5654                    .with_label(DiagnosticLabel::primary(limit.name.span, "invalid ulimit name")),
5655                );
5656            }
5657            let value = match limit.value.as_ref() {
5658                Some(YamlNode::Scalar(_)) => self.parse_limit_value(&limit, "ulimit value").map(UlimitValue::Single),
5659                Some(YamlNode::Mapping(range)) => Some(UlimitValue::Range(self.parse_ulimit_range(range))),
5660                _ => {
5661                    self.expected(
5662                        EXPECTED_FIELD_FORM,
5663                        &limit,
5664                        "ulimit must be a scalar or soft/hard mapping",
5665                    );
5666                    None
5667                }
5668            };
5669            if let Some(value) = value {
5670                entries.push(Ulimit::new(limit.name, limit.span, value));
5671            }
5672        }
5673        Some(Ulimits::new(span, entries))
5674    }
5675
5676    fn parse_ulimit_range(&mut self, mapping: &Mapping) -> UlimitRange {
5677        let span = span_from_position(self.source_id, mapping.byte_range());
5678        let mut range = UlimitRange::new(span);
5679        let mut seen = BTreeMap::new();
5680        for field in self.fields(mapping) {
5681            let duplicate = self.record_duplicate(&mut seen, &field);
5682            match field.name.value.as_str() {
5683                "soft" if !duplicate => self
5684                    .parse_limit_value(&field, "ulimit soft value")
5685                    .into_iter()
5686                    .for_each(|value| range.set_soft(value)),
5687                "hard" if !duplicate => self
5688                    .parse_limit_value(&field, "ulimit hard value")
5689                    .into_iter()
5690                    .for_each(|value| range.set_hard(value)),
5691                name if name.starts_with("x-") => range.push_extension(field.reference()),
5692                _ if duplicate => {}
5693                _ => range.push_unknown(field.reference()),
5694            }
5695        }
5696        if range.soft().is_none() {
5697            self.missing(
5698                ULIMIT_MISSING_RANGE_MEMBER,
5699                span,
5700                "ulimit range is missing required `soft`",
5701            );
5702        }
5703        if range.hard().is_none() {
5704            self.missing(
5705                ULIMIT_MISSING_RANGE_MEMBER,
5706                span,
5707                "ulimit range is missing required `hard`",
5708            );
5709        }
5710        range
5711    }
5712
5713    fn parse_limit_value(&mut self, field: &ParsedField, description: &str) -> Option<Located<LimitValue>> {
5714        let value = self.parse_string(field, description)?;
5715        let parsed = LimitValue::parse(value.value);
5716        if !parsed.is_valid() {
5717            self.diagnostics.push(
5718                Diagnostic::new(
5719                    ULIMIT_INVALID_VALUE,
5720                    Severity::Error,
5721                    "ulimit must be -1, a non-negative integer, or an interpolation expression",
5722                )
5723                .with_label(DiagnosticLabel::primary(value.span, "invalid ulimit value")),
5724            );
5725        }
5726        Some(Located::new(parsed, value.span))
5727    }
5728
5729    fn parse_depends_on(&mut self, field: &ParsedField) -> Option<DependsOn> {
5730        match field.value.as_ref() {
5731            Some(YamlNode::Sequence(sequence)) => {
5732                let span = span_from_position(self.source_id, sequence.byte_range());
5733                let services = self.parse_scalar_nodes(
5734                    sequence.values(),
5735                    field.span,
5736                    "dependency service names must be scalars",
5737                );
5738                Some(DependsOn::Short { span, services })
5739            }
5740            Some(YamlNode::Mapping(mapping)) => {
5741                let span = span_from_position(self.source_id, mapping.byte_range());
5742                let mut services = Vec::new();
5743                let mut seen = BTreeMap::new();
5744                for dependency in self.fields(mapping) {
5745                    if self.record_duplicate(&mut seen, &dependency) {
5746                        continue;
5747                    }
5748                    let mut parsed = ServiceDependency::new(dependency.name.clone(), dependency.span);
5749                    if Self::field_is_null(&dependency) {
5750                        services.push(parsed);
5751                        continue;
5752                    }
5753                    let Some(options) = dependency.value.as_ref().and_then(YamlNode::as_mapping) else {
5754                        self.expected(
5755                            EXPECTED_MAPPING,
5756                            &dependency,
5757                            "long dependency options must be a mapping or null",
5758                        );
5759                        continue;
5760                    };
5761                    let mut option_seen = BTreeMap::new();
5762                    for option in self.fields(options) {
5763                        let duplicate = self.record_duplicate(&mut option_seen, &option);
5764                        match option.name.value.as_str() {
5765                            "condition" if !duplicate => {
5766                                if let Some(value) = self.parse_string(&option, "dependency condition") {
5767                                    let condition = DependencyCondition::parse(value.value);
5768                                    if !condition.is_known() {
5769                                        self.diagnostics.push(
5770                                            Diagnostic::new(
5771                                                DEPENDENCY_INVALID_CONDITION,
5772                                                Severity::Error,
5773                                                "dependency condition is not defined by Compose",
5774                                            )
5775                                            .with_label(
5776                                                DiagnosticLabel::primary(value.span, "unknown dependency condition"),
5777                                            ),
5778                                        );
5779                                    }
5780                                    parsed.set_condition(Located::new(condition, value.span));
5781                                }
5782                            }
5783                            "restart" if !duplicate => self
5784                                .parse_boolean(&option, "dependency restart")
5785                                .into_iter()
5786                                .for_each(|value| parsed.set_restart(value)),
5787                            "required" if !duplicate => self
5788                                .parse_boolean(&option, "dependency required")
5789                                .into_iter()
5790                                .for_each(|value| parsed.set_required(value)),
5791                            name if name.starts_with("x-") => parsed.push_extension(option.reference()),
5792                            _ if duplicate => {}
5793                            _ => parsed.push_unknown(option.reference()),
5794                        }
5795                    }
5796                    services.push(parsed);
5797                }
5798                Some(DependsOn::Long { span, services })
5799            }
5800            _ => {
5801                self.expected(EXPECTED_FIELD_FORM, field, "depends_on must be a sequence or mapping");
5802                None
5803            }
5804        }
5805    }
5806
5807    fn parse_healthcheck(&mut self, field: &ParsedField) -> Option<Healthcheck> {
5808        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5809            self.expected(EXPECTED_MAPPING, field, "healthcheck must be a mapping");
5810            return None;
5811        };
5812        let span = span_from_position(self.source_id, mapping.byte_range());
5813        let mut healthcheck = Healthcheck::new(span);
5814        let mut seen = BTreeMap::new();
5815        for option in self.fields(mapping) {
5816            let duplicate = self.record_duplicate(&mut seen, &option);
5817            match option.name.value.as_str() {
5818                "test" if !duplicate => self
5819                    .parse_healthcheck_test(&option)
5820                    .into_iter()
5821                    .for_each(|value| healthcheck.set_test(value)),
5822                "interval" if !duplicate => self
5823                    .parse_healthcheck_duration(&option, "healthcheck interval")
5824                    .into_iter()
5825                    .for_each(|value| healthcheck.set_interval(value)),
5826                "timeout" if !duplicate => self
5827                    .parse_healthcheck_duration(&option, "healthcheck timeout")
5828                    .into_iter()
5829                    .for_each(|value| healthcheck.set_timeout(value)),
5830                "retries" if !duplicate => self
5831                    .parse_healthcheck_retries(&option)
5832                    .into_iter()
5833                    .for_each(|value| healthcheck.set_retries(value)),
5834                "start_period" if !duplicate => self
5835                    .parse_healthcheck_duration(&option, "healthcheck start period")
5836                    .into_iter()
5837                    .for_each(|value| healthcheck.set_start_period(value)),
5838                "start_interval" if !duplicate => self
5839                    .parse_healthcheck_duration(&option, "healthcheck start interval")
5840                    .into_iter()
5841                    .for_each(|value| healthcheck.set_start_interval(value)),
5842                "disable" if !duplicate => self
5843                    .parse_boolean(&option, "healthcheck disable")
5844                    .into_iter()
5845                    .for_each(|value| healthcheck.set_disable(value)),
5846                name if name.starts_with("x-") => healthcheck.push_extension(option.reference()),
5847                _ if duplicate => {}
5848                _ => healthcheck.push_unknown(option.reference()),
5849            }
5850        }
5851        Some(healthcheck)
5852    }
5853
5854    fn parse_healthcheck_duration(
5855        &mut self,
5856        field: &ParsedField,
5857        description: &str,
5858    ) -> Option<Located<HealthcheckDuration>> {
5859        let value = self.parse_string(field, description)?;
5860        let duration = HealthcheckDuration::parse(value.value);
5861        if !duration.is_valid() {
5862            self.diagnostics.push(
5863                Diagnostic::new(
5864                    HEALTHCHECK_INVALID_DURATION,
5865                    Severity::Error,
5866                    "healthcheck duration must use Compose duration syntax or interpolation",
5867                )
5868                .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck duration")),
5869            );
5870        }
5871        Some(Located::new(duration, value.span))
5872    }
5873
5874    fn parse_healthcheck_retries(&mut self, field: &ParsedField) -> Option<Located<HealthcheckRetries>> {
5875        let value = self.parse_string(field, "healthcheck retries")?;
5876        let retries = HealthcheckRetries::parse(value.value);
5877        if !retries.is_valid() {
5878            self.diagnostics.push(
5879                Diagnostic::new(
5880                    HEALTHCHECK_INVALID_RETRIES,
5881                    Severity::Error,
5882                    "healthcheck retries must be a non-negative integer or interpolation expression",
5883                )
5884                .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck retry count")),
5885            );
5886        }
5887        Some(Located::new(retries, value.span))
5888    }
5889
5890    fn parse_healthcheck_test(&mut self, field: &ParsedField) -> Option<HealthcheckTest> {
5891        match field.value.as_ref() {
5892            Some(YamlNode::Scalar(_)) => self
5893                .parse_string(field, "healthcheck test")
5894                .map(HealthcheckTest::String),
5895            Some(YamlNode::Sequence(sequence)) => {
5896                let span = span_from_position(self.source_id, sequence.byte_range());
5897                let values =
5898                    self.parse_scalar_nodes(sequence.values(), field.span, "healthcheck test items must be scalars");
5899                let kind = values.first().map(|value| HealthcheckTestKind::parse(value.value()));
5900                if kind.is_none()
5901                    || kind == Some(HealthcheckTestKind::Other)
5902                    || (kind == Some(HealthcheckTestKind::None) && values.len() != 1)
5903                {
5904                    self.diagnostics.push(
5905                        Diagnostic::new(
5906                            HEALTHCHECK_INVALID_TEST,
5907                            Severity::Error,
5908                            "healthcheck list must begin with NONE, CMD, or CMD-SHELL",
5909                        )
5910                        .with_label(DiagnosticLabel::primary(span, "invalid healthcheck command mode")),
5911                    );
5912                }
5913                Some(HealthcheckTest::List { span, kind, values })
5914            }
5915            _ => {
5916                self.expected(
5917                    EXPECTED_FIELD_FORM,
5918                    field,
5919                    "healthcheck test must be a scalar or sequence",
5920                );
5921                None
5922            }
5923        }
5924    }
5925
5926    fn parse_build(&mut self, field: &ParsedField) -> Option<Build> {
5927        match field.value.as_ref() {
5928            Some(YamlNode::Scalar(_)) => self.parse_string(field, "build context").map(Build::Context),
5929            Some(YamlNode::Mapping(mapping)) => {
5930                let mut definition = BuildDefinition::new(span_from_position(self.source_id, mapping.byte_range()));
5931                let mut seen = BTreeMap::new();
5932                let (mut dockerfile, mut dockerfile_inline) = (None, None);
5933                for option in self.fields(mapping) {
5934                    if self.record_duplicate(&mut seen, &option) {
5935                        continue;
5936                    }
5937                    if let Some(kind) = BuildFieldKind::from_name(option.name.value()) {
5938                        definition.push_field(BuildField::new(kind, option.reference()));
5939                        match kind {
5940                            BuildFieldKind::AdditionalContexts => {
5941                                definition.set_additional_contexts(self.parse_build_additional_contexts(&option));
5942                            }
5943                            BuildFieldKind::Args => {
5944                                if let Some(args) = self.parse_build_args(&option) {
5945                                    definition.set_args(args);
5946                                }
5947                            }
5948                            BuildFieldKind::CacheFrom | BuildFieldKind::CacheTo => {
5949                                self.set_build_cache_locations(&mut definition, &option, kind);
5950                            }
5951                            BuildFieldKind::Entitlements => {
5952                                if let Some(entitlements) = self.parse_build_entitlements(&option) {
5953                                    definition.set_entitlements(entitlements);
5954                                }
5955                            }
5956                            BuildFieldKind::ExtraHosts => {
5957                                if let Some(extra_hosts) = self.parse_build_extra_hosts(&option) {
5958                                    definition.set_extra_hosts(extra_hosts);
5959                                }
5960                            }
5961                            BuildFieldKind::Context => {
5962                                if let Some(context) = self.parse_string(&option, "build context") {
5963                                    definition.set_context(context);
5964                                }
5965                            }
5966                            BuildFieldKind::Dockerfile => {
5967                                dockerfile = Some(self.parse_build_dockerfile(&mut definition, &option));
5968                            }
5969                            BuildFieldKind::DockerfileInline => {
5970                                self.set_build_dockerfile_inline(&mut definition, &option, &mut dockerfile_inline);
5971                            }
5972                            BuildFieldKind::Target => {
5973                                if let Some(target) = self.parse_string(&option, "build target") {
5974                                    definition.set_target(target);
5975                                }
5976                            }
5977                            BuildFieldKind::Network => {
5978                                if let Some(network) = self.parse_string(&option, "build network") {
5979                                    definition.set_network(network);
5980                                }
5981                            }
5982                            BuildFieldKind::Isolation => self.set_build_isolation(&mut definition, &option),
5983                            BuildFieldKind::Platforms => {
5984                                if let Some(platforms) = self.parse_build_platforms(&option) {
5985                                    definition.set_platforms(platforms);
5986                                }
5987                            }
5988                            BuildFieldKind::NoCache => self.set_build_no_cache(&mut definition, &option),
5989                            BuildFieldKind::NoCacheFilter => self.set_build_no_cache_filter(&mut definition, &option),
5990                            BuildFieldKind::Privileged => self.set_build_privileged(&mut definition, &option),
5991                            BuildFieldKind::Sbom => self.set_build_sbom(&mut definition, &option),
5992                            BuildFieldKind::Provenance => self.set_build_provenance(&mut definition, &option),
5993                            BuildFieldKind::Pull => {
5994                                if let Some(pull) = self.parse_boolean(&option, "build pull") {
5995                                    definition.set_pull(pull);
5996                                }
5997                            }
5998                            BuildFieldKind::ShmSize => self.set_build_shm_size(&mut definition, &option),
5999                            BuildFieldKind::Tags => {
6000                                if let Some(tags) = self.parse_build_tags(&option) {
6001                                    definition.set_tags(tags);
6002                                }
6003                            }
6004                            BuildFieldKind::Labels => {
6005                                if let Some(labels) = self.parse_labels(&option) {
6006                                    definition.set_labels(labels);
6007                                }
6008                            }
6009                            BuildFieldKind::Secrets => self
6010                                .parse_secret_grants(&option)
6011                                .into_iter()
6012                                .for_each(|secrets| definition.set_secrets(secrets)),
6013                            BuildFieldKind::Ssh => self.set_build_ssh(&mut definition, &option),
6014                            BuildFieldKind::Ulimits => self.set_build_ulimits(&mut definition, &option),
6015                        }
6016                    } else if option.name.value().starts_with("x-") {
6017                        definition.push_extension(option.reference());
6018                    } else {
6019                        definition.push_unknown(option.reference());
6020                    }
6021                }
6022                self.report_build_dockerfile_conflict(dockerfile, dockerfile_inline);
6023                Some(Build::Definition(definition))
6024            }
6025            _ => self.invalid_build_form(field),
6026        }
6027    }
6028
6029    fn invalid_build_form(&mut self, field: &ParsedField) -> Option<Build> {
6030        self.expected(EXPECTED_FIELD_FORM, field, "build must be a scalar context or mapping");
6031        None
6032    }
6033
6034    fn report_build_dockerfile_conflict(
6035        &mut self,
6036        dockerfile: Option<FieldReference>,
6037        dockerfile_inline: Option<FieldReference>,
6038    ) {
6039        let (Some(dockerfile), Some(dockerfile_inline)) = (dockerfile, dockerfile_inline) else {
6040            return;
6041        };
6042        self.diagnostics.push(
6043            Diagnostic::new(
6044                BUILD_DOCKERFILE_INLINE_CONFLICT,
6045                Severity::Error,
6046                "build `dockerfile` and `dockerfile_inline` are mutually exclusive",
6047            )
6048            .with_label(DiagnosticLabel::primary(dockerfile.span(), "dockerfile retained"))
6049            .with_label(DiagnosticLabel::secondary(
6050                dockerfile_inline.span(),
6051                "dockerfile_inline retained",
6052            )),
6053        );
6054    }
6055
6056    fn set_build_shm_size(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
6057        if let Some(shm_size) = self.parse_shm_size(field) {
6058            definition.set_shm_size(shm_size);
6059        }
6060    }
6061
6062    fn set_build_ulimits(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
6063        if let Some(ulimits) = self.parse_ulimits(field) {
6064            definition.set_ulimits(ulimits);
6065        }
6066    }
6067
6068    fn parse_build_tags(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
6069        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6070            self.expected(EXPECTED_SEQUENCE, field, "build tags must be a sequence of scalars");
6071            return None;
6072        };
6073        Some(self.parse_scalar_nodes(
6074            sequence.values(),
6075            field.span,
6076            "build tag entries must be non-null scalars",
6077        ))
6078    }
6079
6080    fn parse_build_entitlements(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
6081        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6082            self.expected(
6083                EXPECTED_SEQUENCE,
6084                field,
6085                "build entitlements must be a sequence of string scalars",
6086            );
6087            return None;
6088        };
6089        Some(self.parse_string_scalar_nodes(
6090            sequence.values(),
6091            field.span,
6092            "build entitlement entries must be string scalars",
6093        ))
6094    }
6095
6096    fn parse_build_cache_locations(&mut self, field: &ParsedField, name: &str) -> Option<Vec<Located<String>>> {
6097        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6098            self.expected(
6099                EXPECTED_SEQUENCE,
6100                field,
6101                format!("build {name} must be a sequence of string scalars"),
6102            );
6103            return None;
6104        };
6105        Some(self.parse_string_scalar_nodes(
6106            sequence.values(),
6107            field.span,
6108            format!("build {name} entries must be string scalars"),
6109        ))
6110    }
6111
6112    fn set_build_cache_locations(
6113        &mut self,
6114        definition: &mut BuildDefinition,
6115        field: &ParsedField,
6116        kind: BuildFieldKind,
6117    ) {
6118        let name = if kind == BuildFieldKind::CacheFrom {
6119            "cache_from"
6120        } else {
6121            "cache_to"
6122        };
6123        if let Some(locations) = self.parse_build_cache_locations(field, name) {
6124            if kind == BuildFieldKind::CacheFrom {
6125                definition.set_cache_from(locations);
6126            } else {
6127                definition.set_cache_to(locations);
6128            }
6129        }
6130    }
6131
6132    fn parse_build_extra_hosts(&mut self, field: &ParsedField) -> Option<BuildExtraHosts> {
6133        match field.value.as_ref() {
6134            Some(YamlNode::Sequence(sequence)) => {
6135                let span = span_from_position(self.source_id, sequence.byte_range());
6136                let mut values = Vec::new();
6137                let mut seen = BTreeSet::new();
6138                for node in sequence.values() {
6139                    let YamlNode::Scalar(scalar) = node else {
6140                        self.unsupported_sequence_item(
6141                            BUILD_EXTRA_HOSTS_EXPECTED_STRING,
6142                            &node,
6143                            field.span,
6144                            "build extra_hosts list entries must be string scalars",
6145                        );
6146                        continue;
6147                    };
6148                    if !matches!(
6149                        scalar_type_for_value(&scalar),
6150                        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
6151                    ) {
6152                        self.unsupported_sequence_item(
6153                            BUILD_EXTRA_HOSTS_EXPECTED_STRING,
6154                            &YamlNode::Scalar(scalar),
6155                            field.span,
6156                            "build extra_hosts list entries must be string scalars",
6157                        );
6158                        continue;
6159                    }
6160                    let raw = scalar_string_from_source(&self.source, &scalar);
6161                    let item_span = span_from_position(self.source_id, scalar.byte_range());
6162                    if !seen.insert(raw.clone()) {
6163                        self.diagnostics.push(
6164                            Diagnostic::new(
6165                                BUILD_EXTRA_HOSTS_DUPLICATE_ITEM,
6166                                Severity::Error,
6167                                "build extra_hosts list entries must be unique raw strings",
6168                            )
6169                            .with_label(DiagnosticLabel::primary(item_span, "duplicate entry retained")),
6170                        );
6171                    }
6172                    values.push(Located::new(raw, item_span));
6173                }
6174                Some(BuildExtraHosts::List { span, values })
6175            }
6176            Some(YamlNode::Mapping(mapping)) => {
6177                let span = span_from_position(self.source_id, mapping.byte_range());
6178                let mut entries = Vec::new();
6179                let mut seen = BTreeMap::new();
6180                for entry in self.fields(mapping) {
6181                    if self.record_duplicate(&mut seen, &entry) {
6182                        continue;
6183                    }
6184                    let Some(addresses) = self.parse_build_extra_host_addresses(&entry) else {
6185                        continue;
6186                    };
6187                    entries.push(BuildExtraHostEntry::new(entry.name, addresses, entry.span));
6188                }
6189                Some(BuildExtraHosts::Map { span, entries })
6190            }
6191            _ => {
6192                self.expected(
6193                    BUILD_EXTRA_HOSTS_EXPECTED_FORM,
6194                    field,
6195                    "build extra_hosts must be a sequence or mapping",
6196                );
6197                None
6198            }
6199        }
6200    }
6201
6202    fn parse_build_extra_host_addresses(&mut self, field: &ParsedField) -> Option<BuildExtraHostAddresses> {
6203        match field.value.as_ref() {
6204            Some(YamlNode::Scalar(scalar)) => {
6205                if !matches!(
6206                    scalar_type_for_value(scalar),
6207                    ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
6208                ) {
6209                    self.expected(
6210                        BUILD_EXTRA_HOSTS_EXPECTED_STRING,
6211                        field,
6212                        "build extra_hosts mapping addresses must be string scalars or sequences of string scalars",
6213                    );
6214                    return None;
6215                }
6216                let span = span_from_position(self.source_id, scalar.byte_range());
6217                Some(BuildExtraHostAddresses::Scalar(Located::new(
6218                    scalar_string_from_source(&self.source, scalar),
6219                    span,
6220                )))
6221            }
6222            Some(YamlNode::Sequence(sequence)) => {
6223                let span = span_from_position(self.source_id, sequence.byte_range());
6224                let values = self.parse_string_scalar_nodes(
6225                    sequence.values(),
6226                    field.span,
6227                    "build extra_hosts mapping address lists must contain string scalars",
6228                );
6229                Some(BuildExtraHostAddresses::List { span, values })
6230            }
6231            _ => {
6232                self.expected(
6233                    BUILD_EXTRA_HOSTS_EXPECTED_STRING,
6234                    field,
6235                    "build extra_hosts mapping addresses must be string scalars or sequences of string scalars",
6236                );
6237                None
6238            }
6239        }
6240    }
6241
6242    fn parse_build_additional_contexts(&mut self, field: &ParsedField) -> Option<BuildAdditionalContexts> {
6243        match field.value.as_ref() {
6244            Some(YamlNode::Sequence(sequence)) => {
6245                let span = span_from_position(self.source_id, sequence.byte_range());
6246                let values = self.parse_string_scalar_nodes(
6247                    sequence.values(),
6248                    field.span,
6249                    "build additional context list entries must be string scalars",
6250                );
6251                Some(BuildAdditionalContexts::List { span, values })
6252            }
6253            Some(YamlNode::Mapping(mapping)) => {
6254                let span = span_from_position(self.source_id, mapping.byte_range());
6255                let entries = self.parse_scalar_mapping(field, "build additional contexts");
6256                Some(BuildAdditionalContexts::Map { span, entries })
6257            }
6258            _ => {
6259                self.expected(
6260                    EXPECTED_FIELD_FORM,
6261                    field,
6262                    "build additional_contexts must be a sequence or mapping",
6263                );
6264                None
6265            }
6266        }
6267    }
6268
6269    fn parse_build_platforms(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
6270        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6271            self.expected(
6272                EXPECTED_SEQUENCE,
6273                field,
6274                "build platforms must be a sequence of scalars",
6275            );
6276            return None;
6277        };
6278        Some(self.parse_scalar_nodes(
6279            sequence.values(),
6280            field.span,
6281            "build platform entries must be non-null scalars",
6282        ))
6283    }
6284
6285    fn parse_build_args(&mut self, field: &ParsedField) -> Option<BuildArgs> {
6286        match field.value.as_ref() {
6287            Some(YamlNode::Sequence(sequence)) => {
6288                let span = span_from_position(self.source_id, sequence.byte_range());
6289                let values = self.parse_string_scalar_nodes(
6290                    sequence.values(),
6291                    field.span,
6292                    "build argument list entries must be string scalars",
6293                );
6294                Some(BuildArgs::List { span, values })
6295            }
6296            Some(YamlNode::Mapping(mapping)) => {
6297                let span = span_from_position(self.source_id, mapping.byte_range());
6298                let entries = self.parse_scalar_mapping(field, "build arguments");
6299                Some(BuildArgs::Map { span, entries })
6300            }
6301            _ => {
6302                self.expected(EXPECTED_FIELD_FORM, field, "build args must be a sequence or mapping");
6303                None
6304            }
6305        }
6306    }
6307
6308    fn parse_build_ssh(&mut self, field: &ParsedField) -> Option<BuildSsh> {
6309        match field.value.as_ref() {
6310            Some(YamlNode::Sequence(sequence)) => {
6311                let span = span_from_position(self.source_id, sequence.byte_range());
6312                let mut values = Vec::new();
6313                let mut seen = BTreeSet::new();
6314                for node in sequence.values() {
6315                    let YamlNode::Scalar(scalar) = node else {
6316                        self.unsupported_sequence_item(
6317                            BUILD_SSH_EXPECTED_FORM,
6318                            &node,
6319                            field.span,
6320                            "build ssh list entries must be string scalars",
6321                        );
6322                        continue;
6323                    };
6324                    if !matches!(
6325                        scalar_type_for_value(&scalar),
6326                        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
6327                    ) {
6328                        self.unsupported_sequence_item(
6329                            BUILD_SSH_EXPECTED_FORM,
6330                            &YamlNode::Scalar(scalar),
6331                            field.span,
6332                            "build ssh list entries must be string scalars",
6333                        );
6334                        continue;
6335                    }
6336                    let value = scalar_string_from_source(&self.source, &scalar);
6337                    let span = span_from_position(self.source_id, scalar.byte_range());
6338                    if !seen.insert(value.clone()) {
6339                        self.diagnostics.push(
6340                            Diagnostic::new(
6341                                BUILD_SSH_DUPLICATE_ITEM,
6342                                Severity::Error,
6343                                "build ssh list entries must be unique",
6344                            )
6345                            .with_label(DiagnosticLabel::primary(span, "duplicate SSH entry retained")),
6346                        );
6347                    }
6348                    values.push(Located::new(value, span));
6349                }
6350                Some(BuildSsh::list(span, values))
6351            }
6352            Some(YamlNode::Mapping(mapping)) => {
6353                let span = span_from_position(self.source_id, mapping.byte_range());
6354                let mut entries = Vec::new();
6355                let mut seen = BTreeMap::new();
6356                for entry in self.fields(mapping) {
6357                    if self.record_duplicate(&mut seen, &entry) {
6358                        continue;
6359                    }
6360                    let key_span = entry.name.span;
6361                    let Some(value) = entry.value.as_ref() else {
6362                        entries.push(KeyValueEntry::new(
6363                            entry.name,
6364                            Located::new(ComposeScalar::Null, key_span),
6365                            entry.span,
6366                        ));
6367                        continue;
6368                    };
6369                    let Some(scalar) = value.as_scalar() else {
6370                        self.expected(
6371                            BUILD_SSH_EXPECTED_FORM,
6372                            &entry,
6373                            "build ssh mapping values must be scalars or null",
6374                        );
6375                        continue;
6376                    };
6377                    let scalar_span = span_from_position(self.source_id, scalar.byte_range());
6378                    let scalar_value = ScalarValue::from_scalar(scalar);
6379                    let value = match scalar_type_for_value(scalar) {
6380                        ScalarType::Null => ComposeScalar::Null,
6381                        ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
6382                        ScalarType::Integer | ScalarType::Float => {
6383                            ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
6384                        }
6385                        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
6386                            ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
6387                        }
6388                    };
6389                    entries.push(KeyValueEntry::new(
6390                        entry.name,
6391                        Located::new(value, scalar_span),
6392                        entry.span,
6393                    ));
6394                }
6395                Some(BuildSsh::map(span, entries))
6396            }
6397            _ => {
6398                self.expected(
6399                    BUILD_SSH_EXPECTED_FORM,
6400                    field,
6401                    "build ssh must be a sequence or mapping",
6402                );
6403                None
6404            }
6405        }
6406    }
6407
6408    fn set_build_ssh(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
6409        if let Some(ssh) = self.parse_build_ssh(field) {
6410            definition.set_ssh(ssh);
6411        }
6412    }
6413
6414    fn parse_deploy(&mut self, field: &ParsedField) -> Option<DeployDefinition> {
6415        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6416            self.expected(EXPECTED_MAPPING, field, "deploy must be a mapping");
6417            return None;
6418        };
6419        let span = span_from_position(self.source_id, mapping.byte_range());
6420        let mut definition = DeployDefinition::new(span);
6421        let mut seen = BTreeMap::new();
6422        for option in self.fields(mapping) {
6423            let duplicate = self.record_duplicate(&mut seen, &option);
6424            if duplicate {
6425                continue;
6426            }
6427            if let Some(kind) = DeployFieldKind::from_name(option.name.value()) {
6428                definition.push_field(DeployField::new(kind, option.reference()));
6429                match kind {
6430                    DeployFieldKind::EndpointMode => self.set_deploy_endpoint_mode(&mut definition, &option),
6431                    DeployFieldKind::Labels => self
6432                        .parse_labels(&option)
6433                        .into_iter()
6434                        .for_each(|labels| definition.set_labels(labels)),
6435                    DeployFieldKind::Mode => self.set_deploy_mode(&mut definition, &option),
6436                    DeployFieldKind::Placement => self
6437                        .parse_deploy_placement(&option)
6438                        .into_iter()
6439                        .for_each(|value| definition.set_placement(value)),
6440                    DeployFieldKind::Replicas => self.set_deploy_replicas(&mut definition, &option),
6441                    DeployFieldKind::Resources => self
6442                        .parse_deploy_resources(&option)
6443                        .into_iter()
6444                        .for_each(|value| definition.set_resources(value)),
6445                    DeployFieldKind::RestartPolicy => self
6446                        .parse_deploy_restart_policy(&option)
6447                        .into_iter()
6448                        .for_each(|value| definition.set_restart_policy(value)),
6449                    DeployFieldKind::RollbackConfig => self
6450                        .parse_deploy_rollback_config(&option)
6451                        .into_iter()
6452                        .for_each(|value| definition.set_rollback_config(value)),
6453                    DeployFieldKind::UpdateConfig => self
6454                        .parse_deploy_update_config(&option)
6455                        .into_iter()
6456                        .for_each(|value| definition.set_update_config(value)),
6457                }
6458            } else if option.name.value().starts_with("x-") {
6459                definition.push_extension(option.reference());
6460            } else {
6461                definition.push_unknown(option.reference());
6462            }
6463        }
6464        Some(definition)
6465    }
6466
6467    fn set_deploy_endpoint_mode(&mut self, definition: &mut DeployDefinition, field: &ParsedField) {
6468        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6469            self.expected(
6470                EXPECTED_SCALAR,
6471                field,
6472                "deploy endpoint_mode must be a YAML string scalar",
6473            );
6474            return;
6475        };
6476        if scalar_type_for_value(scalar) != ScalarType::String {
6477            self.expected(
6478                EXPECTED_SCALAR,
6479                field,
6480                "deploy endpoint_mode must be a YAML string scalar",
6481            );
6482            return;
6483        }
6484        let endpoint_mode = Located::new(
6485            DeployEndpointMode::parse(scalar_string_from_source(&self.source, scalar)),
6486            span_from_position(self.source_id, scalar.byte_range()),
6487        );
6488        if !endpoint_mode.value().is_documented() {
6489            self.diagnostics.push(
6490                Diagnostic::new(
6491                    DEPLOY_ENDPOINT_MODE_PORTABILITY,
6492                    Severity::Warning,
6493                    "deploy endpoint_mode is outside Compose's documented portable values",
6494                )
6495                .with_label(DiagnosticLabel::primary(
6496                    endpoint_mode.span(),
6497                    "retained provider-specific endpoint mode",
6498                )),
6499            );
6500        }
6501        definition.set_endpoint_mode(endpoint_mode);
6502    }
6503
6504    fn set_deploy_mode(&mut self, definition: &mut DeployDefinition, field: &ParsedField) {
6505        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6506            self.expected(EXPECTED_SCALAR, field, "deploy mode must be a YAML string scalar");
6507            return;
6508        };
6509        if scalar_type_for_value(scalar) != ScalarType::String {
6510            self.expected(EXPECTED_SCALAR, field, "deploy mode must be a YAML string scalar");
6511            return;
6512        }
6513        let mode = Located::new(
6514            DeployMode::parse(scalar_string_from_source(&self.source, scalar)),
6515            span_from_position(self.source_id, scalar.byte_range()),
6516        );
6517        if !mode.value().is_documented() {
6518            self.diagnostics.push(
6519                Diagnostic::new(
6520                    DEPLOY_MODE_PORTABILITY,
6521                    Severity::Warning,
6522                    "deploy mode is outside Compose's documented portable values",
6523                )
6524                .with_label(DiagnosticLabel::primary(
6525                    mode.span(),
6526                    "retained provider-specific deploy mode",
6527                )),
6528            );
6529        }
6530        definition.set_mode(mode);
6531    }
6532
6533    fn set_deploy_replicas(&mut self, definition: &mut DeployDefinition, field: &ParsedField) {
6534        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6535            self.expected(
6536                EXPECTED_SCALAR,
6537                field,
6538                "deploy replicas must be a YAML number or string scalar",
6539            );
6540            return;
6541        };
6542        let value = match scalar_type_for_value(scalar) {
6543            ScalarType::Integer | ScalarType::Float => {
6544                DeployReplicas::YamlNumber(scalar_string_from_source(&self.source, scalar))
6545            }
6546            ScalarType::String => DeployReplicas::String(scalar_string_from_source(&self.source, scalar)),
6547            ScalarType::Boolean | ScalarType::Null | ScalarType::Timestamp | ScalarType::Regex => {
6548                self.expected(
6549                    EXPECTED_SCALAR,
6550                    field,
6551                    "deploy replicas must be a YAML number or string scalar",
6552                );
6553                return;
6554            }
6555        };
6556        definition.set_replicas(Located::new(
6557            value,
6558            span_from_position(self.source_id, scalar.byte_range()),
6559        ));
6560    }
6561
6562    fn parse_deploy_restart_policy(&mut self, field: &ParsedField) -> Option<DeployRestartPolicy> {
6563        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6564            self.expected(EXPECTED_MAPPING, field, "deploy restart_policy must be a mapping");
6565            return None;
6566        };
6567        let mut policy = DeployRestartPolicy::new(span_from_position(self.source_id, mapping.byte_range()));
6568        let mut seen = BTreeMap::new();
6569        for option in self.fields(mapping) {
6570            if self.record_duplicate(&mut seen, &option) {
6571                continue;
6572            }
6573            match option.name.value().as_str() {
6574                name if name.starts_with("x-") => {
6575                    policy.push_extension(option.reference());
6576                    continue;
6577                }
6578                "condition" | "delay" | "max_attempts" | "window" => {}
6579                _ => {
6580                    policy.push_unknown(option.reference());
6581                    continue;
6582                }
6583            }
6584            let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) else {
6585                self.expected(EXPECTED_SCALAR, &option, "deploy restart-policy members must be scalar");
6586                continue;
6587            };
6588            let span = span_from_position(self.source_id, scalar.byte_range());
6589            match option.name.value().as_str() {
6590                "condition" if scalar_type_for_value(scalar) == ScalarType::String => {
6591                    policy.set_condition(Located::new(
6592                        DeployRestartCondition::parse(scalar_string_from_source(&self.source, scalar)),
6593                        span,
6594                    ));
6595                }
6596                "delay" | "window" if scalar_type_for_value(scalar) == ScalarType::String => {
6597                    let value = Located::new(
6598                        DeployRestartDuration::new(scalar_string_from_source(&self.source, scalar)),
6599                        span,
6600                    );
6601                    if option.name.value() == "delay" {
6602                        policy.set_delay(value);
6603                    } else {
6604                        policy.set_window(value);
6605                    }
6606                }
6607                "max_attempts" => match scalar_type_for_value(scalar) {
6608                    ScalarType::Integer => policy.set_max_attempts(Located::new(
6609                        DeployRestartMaxAttempts::YamlNumber(scalar_string_from_source(&self.source, scalar)),
6610                        span,
6611                    )),
6612                    ScalarType::String => policy.set_max_attempts(Located::new(
6613                        DeployRestartMaxAttempts::String(scalar_string_from_source(&self.source, scalar)),
6614                        span,
6615                    )),
6616                    _ => self.expected(
6617                        EXPECTED_SCALAR,
6618                        &option,
6619                        "deploy restart-policy max_attempts must be a YAML integer or string scalar",
6620                    ),
6621                },
6622                "condition" | "delay" | "window" => self.expected(
6623                    EXPECTED_SCALAR,
6624                    &option,
6625                    "deploy restart-policy condition, delay, and window must be YAML string scalars",
6626                ),
6627                _ => unreachable!("recognized deploy restart-policy field already matched"),
6628            }
6629        }
6630        Some(policy)
6631    }
6632
6633    fn parse_deploy_update_config(&mut self, field: &ParsedField) -> Option<DeployUpdateConfig> {
6634        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6635            self.expected(EXPECTED_MAPPING, field, "deploy update_config must be a mapping");
6636            return None;
6637        };
6638        let mut config = DeployUpdateConfig::new(span_from_position(self.source_id, mapping.byte_range()));
6639        let mut seen = BTreeMap::new();
6640        for member in self.fields(mapping) {
6641            if self.record_duplicate(&mut seen, &member) {
6642                continue;
6643            }
6644            let parsed = match member.name.value().as_str() {
6645                name if name.starts_with("x-") => {
6646                    config.push_extension(member.reference());
6647                    true
6648                }
6649                "parallelism" => self
6650                    .parse_deploy_update_parallelism(&member)
6651                    .map(|value| {
6652                        config.set_parallelism(value);
6653                    })
6654                    .is_some(),
6655                "delay" => self
6656                    .parse_deploy_update_string(&member, "deploy update_config delay must be a YAML string scalar")
6657                    .map(|value| {
6658                        config.set_delay(value);
6659                    })
6660                    .is_some(),
6661                "monitor" => self
6662                    .parse_deploy_update_string(&member, "deploy update_config monitor must be a YAML string scalar")
6663                    .map(|value| {
6664                        config.set_monitor(value);
6665                    })
6666                    .is_some(),
6667                "failure_action" => self
6668                    .parse_deploy_update_string(
6669                        &member,
6670                        "deploy update_config failure_action must be a YAML string scalar",
6671                    )
6672                    .map(|value| {
6673                        config.set_failure_action(value);
6674                    })
6675                    .is_some(),
6676                "max_failure_ratio" => self
6677                    .parse_deploy_update_max_failure_ratio(&member)
6678                    .map(|value| {
6679                        config.set_max_failure_ratio(value);
6680                    })
6681                    .is_some(),
6682                "order" => self
6683                    .parse_deploy_update_order(&member)
6684                    .map(|value| {
6685                        config.set_order(value);
6686                    })
6687                    .is_some(),
6688                _ => {
6689                    config.push_unknown(member.reference());
6690                    true
6691                }
6692            };
6693            if !parsed {
6694                config.push_unknown(member.reference());
6695            }
6696        }
6697        Some(config)
6698    }
6699
6700    fn parse_deploy_rollback_config(&mut self, field: &ParsedField) -> Option<DeployRollbackConfig> {
6701        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6702            self.expected(EXPECTED_MAPPING, field, "deploy rollback_config must be a mapping");
6703            return None;
6704        };
6705        let mut config = DeployRollbackConfig::new(span_from_position(self.source_id, mapping.byte_range()));
6706        let mut seen = BTreeMap::new();
6707        for member in self.fields(mapping) {
6708            if self.record_duplicate(&mut seen, &member) {
6709                continue;
6710            }
6711            let parsed = match member.name.value().as_str() {
6712                name if name.starts_with("x-") => {
6713                    config.push_extension(member.reference());
6714                    true
6715                }
6716                "parallelism" => self
6717                    .parse_deploy_rollback_parallelism(&member)
6718                    .map(|value| config.set_parallelism(value))
6719                    .is_some(),
6720                "delay" => self
6721                    .parse_deploy_rollback_string(&member, "deploy rollback_config delay must be a YAML string scalar")
6722                    .map(|value| config.set_delay(value))
6723                    .is_some(),
6724                "monitor" => self
6725                    .parse_deploy_rollback_string(
6726                        &member,
6727                        "deploy rollback_config monitor must be a YAML string scalar",
6728                    )
6729                    .map(|value| config.set_monitor(value))
6730                    .is_some(),
6731                "failure_action" => self
6732                    .parse_deploy_rollback_string(
6733                        &member,
6734                        "deploy rollback_config failure_action must be a YAML string scalar",
6735                    )
6736                    .map(|value| config.set_failure_action(value))
6737                    .is_some(),
6738                "max_failure_ratio" => self
6739                    .parse_deploy_rollback_max_failure_ratio(&member)
6740                    .map(|value| config.set_max_failure_ratio(value))
6741                    .is_some(),
6742                "order" => self
6743                    .parse_deploy_rollback_order(&member)
6744                    .map(|value| config.set_order(value))
6745                    .is_some(),
6746                _ => {
6747                    config.push_unknown(member.reference());
6748                    true
6749                }
6750            };
6751            if !parsed {
6752                config.push_unknown(member.reference());
6753            }
6754        }
6755        Some(config)
6756    }
6757
6758    fn parse_deploy_rollback_string(&mut self, field: &ParsedField, message: &'static str) -> Option<Located<String>> {
6759        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6760            self.expected(EXPECTED_SCALAR, field, message);
6761            return None;
6762        };
6763        if scalar_type_for_value(scalar) != ScalarType::String {
6764            self.expected(EXPECTED_SCALAR, field, message);
6765            return None;
6766        }
6767        Some(Located::new(
6768            scalar_string_from_source(&self.source, scalar),
6769            span_from_position(self.source_id, scalar.byte_range()),
6770        ))
6771    }
6772
6773    fn parse_deploy_rollback_parallelism(&mut self, field: &ParsedField) -> Option<Located<DeployRollbackParallelism>> {
6774        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6775            self.expected(
6776                EXPECTED_SCALAR,
6777                field,
6778                "deploy rollback_config parallelism must be a YAML integer or string scalar",
6779            );
6780            return None;
6781        };
6782        let value = match scalar_type_for_value(scalar) {
6783            ScalarType::Integer => {
6784                DeployRollbackParallelism::YamlInteger(scalar_string_from_source(&self.source, scalar))
6785            }
6786            ScalarType::String => DeployRollbackParallelism::String(scalar_string_from_source(&self.source, scalar)),
6787            _ => {
6788                self.expected(
6789                    EXPECTED_SCALAR,
6790                    field,
6791                    "deploy rollback_config parallelism must be a YAML integer or string scalar",
6792                );
6793                return None;
6794            }
6795        };
6796        Some(Located::new(
6797            value,
6798            span_from_position(self.source_id, scalar.byte_range()),
6799        ))
6800    }
6801
6802    fn parse_deploy_rollback_max_failure_ratio(
6803        &mut self,
6804        field: &ParsedField,
6805    ) -> Option<Located<DeployRollbackMaxFailureRatio>> {
6806        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6807            self.expected(
6808                EXPECTED_SCALAR,
6809                field,
6810                "deploy rollback_config max_failure_ratio must be a YAML number or string scalar",
6811            );
6812            return None;
6813        };
6814        let value = match scalar_type_for_value(scalar) {
6815            ScalarType::Integer | ScalarType::Float => {
6816                DeployRollbackMaxFailureRatio::YamlNumber(scalar_string_from_source(&self.source, scalar))
6817            }
6818            ScalarType::String => {
6819                DeployRollbackMaxFailureRatio::String(scalar_string_from_source(&self.source, scalar))
6820            }
6821            _ => {
6822                self.expected(
6823                    EXPECTED_SCALAR,
6824                    field,
6825                    "deploy rollback_config max_failure_ratio must be a YAML number or string scalar",
6826                );
6827                return None;
6828            }
6829        };
6830        Some(Located::new(
6831            value,
6832            span_from_position(self.source_id, scalar.byte_range()),
6833        ))
6834    }
6835
6836    fn parse_deploy_rollback_order(&mut self, field: &ParsedField) -> Option<Located<DeployRollbackOrder>> {
6837        let raw =
6838            self.parse_deploy_rollback_string(field, "deploy rollback_config order must be a YAML string scalar")?;
6839        let order = DeployRollbackOrder::parse(raw.value().clone());
6840        if !order.is_documented() {
6841            self.diagnostics.push(
6842                Diagnostic::new(
6843                    DEPLOY_ROLLBACK_CONFIG_ORDER_PORTABILITY,
6844                    Severity::Warning,
6845                    "deploy rollback_config order is outside Compose's documented portable values",
6846                )
6847                .with_label(DiagnosticLabel::primary(
6848                    raw.span(),
6849                    "retained provider-specific rollback order",
6850                )),
6851            );
6852        }
6853        Some(Located::new(order, raw.span()))
6854    }
6855
6856    fn parse_deploy_update_string(&mut self, field: &ParsedField, message: &'static str) -> Option<Located<String>> {
6857        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6858            self.expected(EXPECTED_SCALAR, field, message);
6859            return None;
6860        };
6861        if scalar_type_for_value(scalar) != ScalarType::String {
6862            self.expected(EXPECTED_SCALAR, field, message);
6863            return None;
6864        }
6865        Some(Located::new(
6866            scalar_string_from_source(&self.source, scalar),
6867            span_from_position(self.source_id, scalar.byte_range()),
6868        ))
6869    }
6870
6871    fn parse_deploy_update_parallelism(&mut self, field: &ParsedField) -> Option<Located<DeployUpdateParallelism>> {
6872        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6873            self.expected(
6874                EXPECTED_SCALAR,
6875                field,
6876                "deploy update_config parallelism must be a YAML integer or string scalar",
6877            );
6878            return None;
6879        };
6880        let value = match scalar_type_for_value(scalar) {
6881            ScalarType::Integer => {
6882                DeployUpdateParallelism::YamlInteger(scalar_string_from_source(&self.source, scalar))
6883            }
6884            ScalarType::String => DeployUpdateParallelism::String(scalar_string_from_source(&self.source, scalar)),
6885            _ => {
6886                self.expected(
6887                    EXPECTED_SCALAR,
6888                    field,
6889                    "deploy update_config parallelism must be a YAML integer or string scalar",
6890                );
6891                return None;
6892            }
6893        };
6894        Some(Located::new(
6895            value,
6896            span_from_position(self.source_id, scalar.byte_range()),
6897        ))
6898    }
6899    fn parse_deploy_update_max_failure_ratio(
6900        &mut self,
6901        field: &ParsedField,
6902    ) -> Option<Located<DeployUpdateMaxFailureRatio>> {
6903        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6904            self.expected(
6905                EXPECTED_SCALAR,
6906                field,
6907                "deploy update_config max_failure_ratio must be a YAML number or string scalar",
6908            );
6909            return None;
6910        };
6911        let value = match scalar_type_for_value(scalar) {
6912            ScalarType::Integer | ScalarType::Float => {
6913                DeployUpdateMaxFailureRatio::YamlNumber(scalar_string_from_source(&self.source, scalar))
6914            }
6915            ScalarType::String => DeployUpdateMaxFailureRatio::String(scalar_string_from_source(&self.source, scalar)),
6916            _ => {
6917                self.expected(
6918                    EXPECTED_SCALAR,
6919                    field,
6920                    "deploy update_config max_failure_ratio must be a YAML number or string scalar",
6921                );
6922                return None;
6923            }
6924        };
6925        Some(Located::new(
6926            value,
6927            span_from_position(self.source_id, scalar.byte_range()),
6928        ))
6929    }
6930    fn parse_deploy_update_order(&mut self, field: &ParsedField) -> Option<Located<DeployUpdateOrder>> {
6931        let raw = self.parse_deploy_update_string(field, "deploy update_config order must be a YAML string scalar")?;
6932        let order = DeployUpdateOrder::parse(raw.value().clone());
6933        if !order.is_documented() {
6934            self.diagnostics.push(
6935                Diagnostic::new(
6936                    DEPLOY_UPDATE_CONFIG_ORDER_PORTABILITY,
6937                    Severity::Warning,
6938                    "deploy update_config order is outside Compose's documented portable values",
6939                )
6940                .with_label(DiagnosticLabel::primary(
6941                    raw.span(),
6942                    "retained provider-specific update order",
6943                )),
6944            );
6945        }
6946        Some(Located::new(order, raw.span()))
6947    }
6948
6949    fn parse_deploy_placement(&mut self, field: &ParsedField) -> Option<DeployPlacement> {
6950        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6951            self.expected(EXPECTED_MAPPING, field, "deploy placement must be a mapping");
6952            return None;
6953        };
6954        let mut placement = DeployPlacement::new(span_from_position(self.source_id, mapping.byte_range()));
6955        let mut seen = BTreeMap::new();
6956        for option in self.fields(mapping) {
6957            if self.record_duplicate(&mut seen, &option) {
6958                continue;
6959            }
6960            match option.name.value().as_str() {
6961                name if name.starts_with("x-") => placement.push_extension(option.reference()),
6962                "constraints" => self
6963                    .parse_deploy_placement_constraints(&option)
6964                    .into_iter()
6965                    .for_each(|value| placement.set_constraints(value)),
6966                "preferences" => self
6967                    .parse_deploy_placement_preferences(&option)
6968                    .into_iter()
6969                    .for_each(|value| placement.set_preferences(value)),
6970                "max_replicas_per_node" => self
6971                    .parse_deploy_placement_max_replicas_per_node(&option)
6972                    .into_iter()
6973                    .for_each(|value| placement.set_max_replicas_per_node(value)),
6974                _ => placement.push_unknown(option.reference()),
6975            }
6976        }
6977        Some(placement)
6978    }
6979
6980    fn parse_deploy_placement_constraints(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
6981        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6982            self.expected(
6983                EXPECTED_SEQUENCE,
6984                field,
6985                "deploy placement constraints must be a sequence",
6986            );
6987            return None;
6988        };
6989        let mut constraints = Vec::new();
6990        for value in sequence.values() {
6991            let YamlNode::Scalar(scalar) = value else {
6992                self.unsupported_sequence_item(
6993                    EXPECTED_SCALAR,
6994                    &value,
6995                    field.span,
6996                    "deploy placement constraints must contain YAML string scalars",
6997                );
6998                continue;
6999            };
7000            if scalar_type_for_value(&scalar) != ScalarType::String {
7001                self.unsupported_sequence_item(
7002                    EXPECTED_SCALAR,
7003                    &YamlNode::Scalar(scalar),
7004                    field.span,
7005                    "deploy placement constraints must contain YAML string scalars",
7006                );
7007                continue;
7008            }
7009            constraints.push(Located::new(
7010                scalar_string_from_source(&self.source, &scalar),
7011                span_from_position(self.source_id, scalar.byte_range()),
7012            ));
7013        }
7014        Some(constraints)
7015    }
7016
7017    fn parse_deploy_placement_preferences(&mut self, field: &ParsedField) -> Option<Vec<DeployPlacementPreference>> {
7018        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7019            self.expected(
7020                EXPECTED_SEQUENCE,
7021                field,
7022                "deploy placement preferences must be a sequence",
7023            );
7024            return None;
7025        };
7026        let mut preferences = Vec::new();
7027        for value in sequence.values() {
7028            let YamlNode::Mapping(mapping) = value else {
7029                self.unsupported_sequence_item(
7030                    EXPECTED_MAPPING,
7031                    &value,
7032                    field.span,
7033                    "deploy placement preferences must contain mappings",
7034                );
7035                continue;
7036            };
7037            let mut preference =
7038                DeployPlacementPreference::new(span_from_position(self.source_id, mapping.byte_range()));
7039            let mut seen = BTreeMap::new();
7040            for member in self.fields(&mapping) {
7041                if self.record_duplicate(&mut seen, &member) {
7042                    continue;
7043                }
7044                match member.name.value().as_str() {
7045                    name if name.starts_with("x-") => preference.push_extension(member.reference()),
7046                    "spread" => self
7047                        .parse_deploy_placement_string(&member, "deploy placement preference spread")
7048                        .into_iter()
7049                        .for_each(|value| preference.set_spread(value)),
7050                    _ => preference.push_unknown(member.reference()),
7051                }
7052            }
7053            preferences.push(preference);
7054        }
7055        Some(preferences)
7056    }
7057
7058    fn parse_deploy_placement_max_replicas_per_node(
7059        &mut self,
7060        field: &ParsedField,
7061    ) -> Option<Located<DeployPlacementMaxReplicasPerNode>> {
7062        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7063            self.expected(
7064                EXPECTED_SCALAR,
7065                field,
7066                "deploy placement max_replicas_per_node must be a YAML integer or string scalar",
7067            );
7068            return None;
7069        };
7070        let value = match scalar_type_for_value(scalar) {
7071            ScalarType::Integer => {
7072                DeployPlacementMaxReplicasPerNode::YamlInteger(scalar_string_from_source(&self.source, scalar))
7073            }
7074            ScalarType::String => {
7075                DeployPlacementMaxReplicasPerNode::String(scalar_string_from_source(&self.source, scalar))
7076            }
7077            _ => {
7078                self.expected(
7079                    EXPECTED_SCALAR,
7080                    field,
7081                    "deploy placement max_replicas_per_node must be a YAML integer or string scalar",
7082                );
7083                return None;
7084            }
7085        };
7086        Some(Located::new(
7087            value,
7088            span_from_position(self.source_id, scalar.byte_range()),
7089        ))
7090    }
7091
7092    fn parse_deploy_placement_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
7093        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7094            self.expected(
7095                EXPECTED_SCALAR,
7096                field,
7097                format!("{description} must be a YAML string scalar"),
7098            );
7099            return None;
7100        };
7101        if scalar_type_for_value(scalar) != ScalarType::String {
7102            self.expected(
7103                EXPECTED_SCALAR,
7104                field,
7105                format!("{description} must be a YAML string scalar"),
7106            );
7107            return None;
7108        }
7109        Some(Located::new(
7110            scalar_string_from_source(&self.source, scalar),
7111            span_from_position(self.source_id, scalar.byte_range()),
7112        ))
7113    }
7114
7115    fn parse_deploy_resources(&mut self, field: &ParsedField) -> Option<DeployResources> {
7116        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7117            self.expected(EXPECTED_MAPPING, field, "deploy resources must be a mapping");
7118            return None;
7119        };
7120        let mut resources = DeployResources::new(span_from_position(self.source_id, mapping.byte_range()));
7121        let mut seen = BTreeMap::new();
7122        for option in self.fields(mapping) {
7123            if self.record_duplicate(&mut seen, &option) {
7124                continue;
7125            }
7126            match option.name.value().as_str() {
7127                name if name.starts_with("x-") => resources.push_extension(option.reference()),
7128                "limits" => self
7129                    .parse_deploy_resource_limits(&option)
7130                    .into_iter()
7131                    .for_each(|value| resources.set_limits(value)),
7132                "reservations" => self
7133                    .parse_deploy_resource_reservations(&option)
7134                    .into_iter()
7135                    .for_each(|value| resources.set_reservations(value)),
7136                _ => resources.push_unknown(option.reference()),
7137            }
7138        }
7139        Some(resources)
7140    }
7141
7142    fn parse_deploy_resource_limits(&mut self, field: &ParsedField) -> Option<DeployResourceLimits> {
7143        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7144            self.expected(EXPECTED_MAPPING, field, "deploy resource limits must be a mapping");
7145            return None;
7146        };
7147        let mut limits = DeployResourceLimits::new(span_from_position(self.source_id, mapping.byte_range()));
7148        let mut seen = BTreeMap::new();
7149        for option in self.fields(mapping) {
7150            if self.record_duplicate(&mut seen, &option) {
7151                continue;
7152            }
7153            match option.name.value().as_str() {
7154                name if name.starts_with("x-") => limits.push_extension(option.reference()),
7155                "cpus" => self
7156                    .parse_deploy_resource_cpus(&option, "deploy resource limits cpus")
7157                    .into_iter()
7158                    .for_each(|value| limits.set_cpus(value)),
7159                "memory" => self
7160                    .parse_deploy_resource_memory(&option, "deploy resource limits memory must be a YAML string scalar")
7161                    .into_iter()
7162                    .for_each(|value| limits.set_memory(value)),
7163                "pids" => self
7164                    .parse_deploy_resource_pids(&option)
7165                    .into_iter()
7166                    .for_each(|value| limits.set_pids(value)),
7167                _ => limits.push_unknown(option.reference()),
7168            }
7169        }
7170        Some(limits)
7171    }
7172
7173    fn parse_deploy_resource_reservations(&mut self, field: &ParsedField) -> Option<DeployResourceReservations> {
7174        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7175            self.expected(
7176                EXPECTED_MAPPING,
7177                field,
7178                "deploy resource reservations must be a mapping",
7179            );
7180            return None;
7181        };
7182        let mut reservations =
7183            DeployResourceReservations::new(span_from_position(self.source_id, mapping.byte_range()));
7184        let mut seen = BTreeMap::new();
7185        for option in self.fields(mapping) {
7186            if self.record_duplicate(&mut seen, &option) {
7187                continue;
7188            }
7189            match option.name.value().as_str() {
7190                name if name.starts_with("x-") => reservations.push_extension(option.reference()),
7191                "cpus" => self
7192                    .parse_deploy_resource_cpus(&option, "deploy resource reservations cpus")
7193                    .into_iter()
7194                    .for_each(|value| reservations.set_cpus(value)),
7195                "memory" => self
7196                    .parse_deploy_resource_memory(
7197                        &option,
7198                        "deploy resource reservations memory must be a YAML string scalar",
7199                    )
7200                    .into_iter()
7201                    .for_each(|value| reservations.set_memory(value)),
7202                "generic_resources" => {
7203                    if let Some(value) = self.parse_deploy_generic_resources(&option) {
7204                        reservations.set_generic_resources(value);
7205                    } else {
7206                        reservations.push_unknown(option.reference());
7207                    }
7208                }
7209                "devices" => {
7210                    if let Some(value) = self.parse_deploy_reservation_devices(&option) {
7211                        reservations.set_devices(value);
7212                    } else {
7213                        reservations.push_unknown(option.reference());
7214                    }
7215                }
7216                _ => reservations.push_unknown(option.reference()),
7217            }
7218        }
7219        Some(reservations)
7220    }
7221
7222    fn parse_deploy_resource_pids(&mut self, field: &ParsedField) -> Option<Located<DeployResourcePids>> {
7223        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7224            self.expected(
7225                EXPECTED_SCALAR,
7226                field,
7227                "deploy resource limits pids must be a YAML integer or string scalar",
7228            );
7229            return None;
7230        };
7231        let value = match scalar_type_for_value(scalar) {
7232            ScalarType::Integer => DeployResourcePids::YamlInteger(scalar_string_from_source(&self.source, scalar)),
7233            ScalarType::String => DeployResourcePids::String(scalar_string_from_source(&self.source, scalar)),
7234            _ => {
7235                self.expected(
7236                    EXPECTED_SCALAR,
7237                    field,
7238                    "deploy resource limits pids must be a YAML integer or string scalar",
7239                );
7240                return None;
7241            }
7242        };
7243        Some(Located::new(
7244            value,
7245            span_from_position(self.source_id, scalar.byte_range()),
7246        ))
7247    }
7248
7249    fn parse_deploy_resource_cpus(
7250        &mut self,
7251        field: &ParsedField,
7252        context: &'static str,
7253    ) -> Option<Located<DeployResourceCpus>> {
7254        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7255            self.expected(
7256                EXPECTED_SCALAR,
7257                field,
7258                format!("{context} must be a YAML number or string scalar"),
7259            );
7260            return None;
7261        };
7262        let value = match scalar_type_for_value(scalar) {
7263            ScalarType::Integer | ScalarType::Float => {
7264                DeployResourceCpus::YamlNumber(scalar_string_from_source(&self.source, scalar))
7265            }
7266            ScalarType::String => DeployResourceCpus::String(scalar_string_from_source(&self.source, scalar)),
7267            _ => {
7268                self.expected(
7269                    EXPECTED_SCALAR,
7270                    field,
7271                    format!("{context} must be a YAML number or string scalar"),
7272                );
7273                return None;
7274            }
7275        };
7276        Some(Located::new(
7277            value,
7278            span_from_position(self.source_id, scalar.byte_range()),
7279        ))
7280    }
7281
7282    fn parse_deploy_resource_memory(
7283        &mut self,
7284        field: &ParsedField,
7285        message: &'static str,
7286    ) -> Option<Located<DeployResourceMemory>> {
7287        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7288            self.expected(EXPECTED_SCALAR, field, message);
7289            return None;
7290        };
7291        if scalar_type_for_value(scalar) != ScalarType::String {
7292            self.expected(EXPECTED_SCALAR, field, message);
7293            return None;
7294        }
7295        Some(Located::new(
7296            DeployResourceMemory::parse(scalar_string_from_source(&self.source, scalar)),
7297            span_from_position(self.source_id, scalar.byte_range()),
7298        ))
7299    }
7300
7301    fn parse_deploy_generic_resources(&mut self, field: &ParsedField) -> Option<DeployGenericResources> {
7302        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7303            self.expected(
7304                EXPECTED_SEQUENCE,
7305                field,
7306                "deploy resource reservations generic_resources must be a sequence",
7307            );
7308            return None;
7309        };
7310        let mut items = Vec::new();
7311        for node in sequence.values() {
7312            let Some(mapping) = node.as_mapping() else {
7313                self.unsupported_sequence_item(
7314                    EXPECTED_MAPPING,
7315                    &node,
7316                    field.span,
7317                    "deploy resource generic-resource entries must be mappings",
7318                );
7319                items.push(DeployGenericResource::unmodeled(
7320                    node_span(self.source_id, &node).unwrap_or(field.span),
7321                ));
7322                continue;
7323            };
7324            let mut item = DeployGenericResource::new(span_from_position(self.source_id, mapping.byte_range()));
7325            let mut seen = BTreeMap::new();
7326            for option in self.fields(mapping) {
7327                if self.record_duplicate(&mut seen, &option) {
7328                    continue;
7329                }
7330                match option.name.value().as_str() {
7331                    name if name.starts_with("x-") => item.push_extension(option.reference()),
7332                    "discrete_resource_spec" => {
7333                        if let Some(value) = self.parse_deploy_discrete_resource_spec(&option) {
7334                            item.set_discrete_resource_spec(value);
7335                        } else {
7336                            item.push_unknown(option.reference());
7337                        }
7338                    }
7339                    _ => item.push_unknown(option.reference()),
7340                }
7341            }
7342            items.push(item);
7343        }
7344        Some(DeployGenericResources::new(
7345            span_from_position(self.source_id, sequence.byte_range()),
7346            items,
7347        ))
7348    }
7349
7350    fn parse_deploy_reservation_devices(&mut self, field: &ParsedField) -> Option<DeployReservationDevices> {
7351        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7352            self.expected(
7353                EXPECTED_SEQUENCE,
7354                field,
7355                "deploy resource reservation devices must be a sequence",
7356            );
7357            return None;
7358        };
7359        let mut items = Vec::new();
7360        for node in sequence.values() {
7361            let Some(mapping) = node.as_mapping() else {
7362                self.unsupported_sequence_item(
7363                    EXPECTED_MAPPING,
7364                    &node,
7365                    field.span,
7366                    "deploy resource reservation device entries must be mappings",
7367                );
7368                items.push(DeployReservationDevice::unmodeled(
7369                    node_span(self.source_id, &node).unwrap_or(field.span),
7370                ));
7371                continue;
7372            };
7373            let item_span = span_from_position(self.source_id, mapping.byte_range());
7374            let mut item = DeployReservationDevice::new(item_span);
7375            let mut seen = BTreeMap::new();
7376            let mut has_capabilities = false;
7377            let mut count_field = None;
7378            let mut device_ids_field = None;
7379            for option in self.fields(mapping) {
7380                if self.record_duplicate(&mut seen, &option) {
7381                    continue;
7382                }
7383                match option.name.value().as_str() {
7384                    name if name.starts_with("x-") => item.push_extension(option.reference()),
7385                    "capabilities" => {
7386                        has_capabilities = true;
7387                        if let Some(value) = self.parse_deploy_reservation_device_capabilities(&option) {
7388                            item.set_capabilities(value);
7389                        } else {
7390                            item.push_unknown(option.reference());
7391                        }
7392                    }
7393                    "driver" => {
7394                        let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) else {
7395                            self.expected(
7396                                EXPECTED_SCALAR,
7397                                &option,
7398                                "deploy resource reservation device driver must be a YAML string scalar",
7399                            );
7400                            item.push_unknown(option.reference());
7401                            continue;
7402                        };
7403                        if scalar_type_for_value(scalar) != ScalarType::String {
7404                            self.expected(
7405                                EXPECTED_SCALAR,
7406                                &option,
7407                                "deploy resource reservation device driver must be a YAML string scalar",
7408                            );
7409                            item.push_unknown(option.reference());
7410                            continue;
7411                        }
7412                        item.set_driver(Located::new(
7413                            scalar_string_from_source(&self.source, scalar),
7414                            span_from_position(self.source_id, scalar.byte_range()),
7415                        ));
7416                    }
7417                    "count" => {
7418                        count_field.get_or_insert(option.span);
7419                        if let Some(value) = self.parse_deploy_reservation_device_count(&option) {
7420                            item.set_count(value);
7421                        } else {
7422                            item.push_unknown(option.reference());
7423                        }
7424                    }
7425                    "device_ids" => {
7426                        device_ids_field.get_or_insert(option.span);
7427                        if let Some(value) = self.parse_deploy_reservation_device_ids(&option) {
7428                            item.set_device_ids(value);
7429                        } else {
7430                            item.push_unknown(option.reference());
7431                        }
7432                    }
7433                    "options" => self.set_deploy_reservation_device_options(&mut item, &option),
7434                    _ => item.push_unknown(option.reference()),
7435                }
7436            }
7437            self.reservation_device_allocation_selector_conflict(count_field, device_ids_field);
7438            if !has_capabilities {
7439                self.missing(
7440                    DEPLOY_RESERVATION_DEVICE_MISSING_CAPABILITIES,
7441                    item_span,
7442                    "deploy resource reservation device is missing required `capabilities`",
7443                );
7444            }
7445            items.push(item);
7446        }
7447        Some(DeployReservationDevices::new(
7448            span_from_position(self.source_id, sequence.byte_range()),
7449            items,
7450        ))
7451    }
7452
7453    fn reservation_device_allocation_selector_conflict(
7454        &mut self,
7455        count: Option<SourceSpan>,
7456        device_ids: Option<SourceSpan>,
7457    ) {
7458        let (Some(count), Some(device_ids)) = (count, device_ids) else {
7459            return;
7460        };
7461        self.diagnostics.push(
7462            Diagnostic::new(
7463                DEPLOY_RESERVATION_DEVICE_ALLOCATION_SELECTOR_CONFLICT,
7464                Severity::Error,
7465                "deploy resource reservation device count and device_ids are mutually exclusive",
7466            )
7467            .with_label(DiagnosticLabel::primary(count, "count retained"))
7468            .with_label(DiagnosticLabel::secondary(device_ids, "device_ids retained")),
7469        );
7470    }
7471
7472    fn parse_deploy_reservation_device_count(
7473        &mut self,
7474        field: &ParsedField,
7475    ) -> Option<Located<DeployReservationDeviceCount>> {
7476        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7477            self.expected(
7478                EXPECTED_SCALAR,
7479                field,
7480                "deploy resource reservation device count must be a YAML integer or string scalar",
7481            );
7482            return None;
7483        };
7484        let value = match scalar_type_for_value(scalar) {
7485            ScalarType::Integer => {
7486                DeployReservationDeviceCount::YamlInteger(scalar_string_from_source(&self.source, scalar))
7487            }
7488            ScalarType::String => DeployReservationDeviceCount::String(scalar_string_from_source(&self.source, scalar)),
7489            _ => {
7490                self.expected(
7491                    EXPECTED_SCALAR,
7492                    field,
7493                    "deploy resource reservation device count must be a YAML integer or string scalar",
7494                );
7495                return None;
7496            }
7497        };
7498        Some(Located::new(
7499            value,
7500            span_from_position(self.source_id, scalar.byte_range()),
7501        ))
7502    }
7503
7504    fn parse_deploy_reservation_device_ids(&mut self, field: &ParsedField) -> Option<DeployReservationDeviceIds> {
7505        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7506            self.expected(
7507                EXPECTED_SEQUENCE,
7508                field,
7509                "deploy resource reservation device device_ids must be a sequence",
7510            );
7511            return None;
7512        };
7513        let mut items = Vec::new();
7514        for node in sequence.values() {
7515            let Some(scalar) = node.as_scalar() else {
7516                self.unsupported_sequence_item(
7517                    EXPECTED_SCALAR,
7518                    &node,
7519                    field.span,
7520                    "deploy resource reservation device device_ids must be string scalars",
7521                );
7522                items.push(DeployReservationDeviceId::unmodeled(
7523                    node_span(self.source_id, &node).unwrap_or(field.span),
7524                ));
7525                continue;
7526            };
7527            let item_span = span_from_position(self.source_id, scalar.byte_range());
7528            if scalar_type_for_value(scalar) != ScalarType::String {
7529                self.unsupported_sequence_item(
7530                    EXPECTED_SCALAR,
7531                    &YamlNode::Scalar(scalar.clone()),
7532                    field.span,
7533                    "deploy resource reservation device device_ids must be string scalars",
7534                );
7535                items.push(DeployReservationDeviceId::unmodeled(item_span));
7536                continue;
7537            }
7538            items.push(DeployReservationDeviceId::string(Located::new(
7539                scalar_string_from_source(&self.source, scalar),
7540                item_span,
7541            )));
7542        }
7543        Some(DeployReservationDeviceIds::new(
7544            span_from_position(self.source_id, sequence.byte_range()),
7545            items,
7546        ))
7547    }
7548
7549    fn parse_deploy_reservation_device_options(
7550        &mut self,
7551        field: &ParsedField,
7552    ) -> Option<DeployReservationDeviceOptions> {
7553        match field.value.as_ref() {
7554            Some(YamlNode::Mapping(mapping)) => Some(self.parse_deploy_reservation_device_options_map(mapping)),
7555            Some(YamlNode::Sequence(sequence)) => {
7556                let mut items = Vec::new();
7557                let mut seen = BTreeMap::new();
7558                for node in sequence.values() {
7559                    let Some(scalar) = node.as_scalar() else {
7560                        self.unsupported_sequence_item(EXPECTED_SCALAR, &node, field.span,
7561                            "deploy resource reservation device options list entries must be strict YAML string scalars");
7562                        items.push(DeployReservationDeviceOptionItem::unmodeled(
7563                            node_span(self.source_id, &node).unwrap_or(field.span),
7564                        ));
7565                        continue;
7566                    };
7567                    let span = span_from_position(self.source_id, scalar.byte_range());
7568                    if scalar_type_for_value(scalar) != ScalarType::String {
7569                        self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar.clone()), field.span,
7570                            "deploy resource reservation device options list entries must be strict YAML string scalars");
7571                        items.push(DeployReservationDeviceOptionItem::unmodeled(span));
7572                        continue;
7573                    }
7574                    let value = scalar_string_from_source(&self.source, scalar);
7575                    if let Some(first) = seen.get(&value) {
7576                        self.diagnostics.push(
7577                            Diagnostic::new(
7578                                DEPLOY_RESERVATION_DEVICE_OPTIONS_DUPLICATE_ITEM,
7579                                Severity::Error,
7580                                "deploy resource reservation device options list entries must be unique exact strings",
7581                            )
7582                            .with_label(DiagnosticLabel::primary(span, "duplicate option string"))
7583                            .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
7584                        );
7585                    } else {
7586                        seen.insert(value.clone(), span);
7587                    }
7588                    items.push(DeployReservationDeviceOptionItem::string(Located::new(value, span)));
7589                }
7590                Some(DeployReservationDeviceOptions::List {
7591                    span: span_from_position(self.source_id, sequence.byte_range()),
7592                    items,
7593                })
7594            }
7595            _ => {
7596                self.expected(
7597                    DEPLOY_RESERVATION_DEVICE_OPTIONS_EXPECTED_FORM,
7598                    field,
7599                    "deploy resource reservation device options must be a mapping or sequence",
7600                );
7601                None
7602            }
7603        }
7604    }
7605
7606    fn set_deploy_reservation_device_options(&mut self, device: &mut DeployReservationDevice, field: &ParsedField) {
7607        if let Some(options) = self.parse_deploy_reservation_device_options(field) {
7608            device.set_options(options);
7609        } else {
7610            device.push_unknown(field.reference());
7611        }
7612    }
7613
7614    fn parse_deploy_reservation_device_options_map(&mut self, mapping: &Mapping) -> DeployReservationDeviceOptions {
7615        let mut entries = Vec::new();
7616        let mut unmodeled_entries = Vec::new();
7617        let mut seen = BTreeMap::new();
7618        for entry in mapping.entries() {
7619            let Some(key) = entry.key_node() else { continue };
7620            let key_span = node_span(self.source_id, &key)
7621                .unwrap_or_else(|| span_from_position(self.source_id, mapping.byte_range()));
7622            let authored_value = entry.value_node();
7623            let value_span = authored_value
7624                .as_ref()
7625                .and_then(|value| node_span(self.source_id, value));
7626            let field_span = value_span.map_or(key_span, |value| union(key_span, value));
7627            let Some(scalar) = key.as_scalar() else {
7628                self.diagnostics.push(
7629                    Diagnostic::new(
7630                        DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY,
7631                        Severity::Error,
7632                        "deploy resource reservation device options mapping keys must be non-empty strict YAML strings",
7633                    )
7634                    .with_label(DiagnosticLabel::primary(key_span, "invalid option key")),
7635                );
7636                unmodeled_entries.push(FieldReference {
7637                    name: Located::new("<unmodeled-key>".to_owned(), key_span),
7638                    span: field_span,
7639                    value_span,
7640                });
7641                continue;
7642            };
7643            let name = Located::new(
7644                scalar_string_from_source(&self.source, scalar),
7645                span_from_position(self.source_id, scalar.byte_range()),
7646            );
7647            let field_span = value_span.map_or(name.span(), |value| union(name.span(), value));
7648            let parsed = ParsedField {
7649                name,
7650                value: authored_value
7651                    .map(unwrap_processing_tag)
7652                    .map(|value| self.resolve_alias(value)),
7653                value_span,
7654                span: field_span,
7655            };
7656            if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String || parsed.name.value().is_empty() {
7657                self.diagnostics.push(
7658                    Diagnostic::new(
7659                        DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY,
7660                        Severity::Error,
7661                        "deploy resource reservation device options mapping keys must be non-empty strict YAML strings",
7662                    )
7663                    .with_label(DiagnosticLabel::primary(parsed.name.span(), "invalid option key")),
7664                );
7665                unmodeled_entries.push(parsed.reference());
7666                continue;
7667            }
7668            if self.record_duplicate(&mut seen, &parsed) {
7669                unmodeled_entries.push(parsed.reference());
7670                continue;
7671            }
7672            let Some(value) = self.compose_scalar_from_field(&parsed,
7673                "deploy resource reservation device options mapping values must be scalar strings, numbers, booleans, or null") else {
7674                unmodeled_entries.push(parsed.reference()); continue;
7675            };
7676            entries.push(KeyValueEntry::new(parsed.name, value, parsed.span));
7677        }
7678        DeployReservationDeviceOptions::Map {
7679            span: span_from_position(self.source_id, mapping.byte_range()),
7680            entries,
7681            unmodeled_entries,
7682        }
7683    }
7684
7685    fn compose_scalar_from_field(
7686        &mut self,
7687        field: &ParsedField,
7688        message: &'static str,
7689    ) -> Option<Located<ComposeScalar>> {
7690        let Some(node) = field.value.as_ref() else {
7691            return Some(Located::new(ComposeScalar::Null, field.name.span()));
7692        };
7693        let Some(scalar) = node.as_scalar() else {
7694            self.expected(EXPECTED_SCALAR, field, message);
7695            return None;
7696        };
7697        let value = match scalar_type_for_value(scalar) {
7698            ScalarType::Null => ComposeScalar::Null,
7699            ScalarType::Boolean => ComposeScalar::Boolean(ScalarValue::from_scalar(scalar).to_bool().unwrap_or(false)),
7700            ScalarType::Integer | ScalarType::Float => {
7701                ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
7702            }
7703            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
7704                ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
7705            }
7706        };
7707        Some(Located::new(
7708            value,
7709            span_from_position(self.source_id, scalar.byte_range()),
7710        ))
7711    }
7712
7713    fn parse_deploy_reservation_device_capabilities(
7714        &mut self,
7715        field: &ParsedField,
7716    ) -> Option<DeployReservationDeviceCapabilities> {
7717        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7718            self.expected(
7719                EXPECTED_SEQUENCE,
7720                field,
7721                "deploy resource reservation device capabilities must be a sequence of strings",
7722            );
7723            return None;
7724        };
7725        let mut items = Vec::new();
7726        let mut seen = BTreeMap::new();
7727        for node in sequence.values() {
7728            let Some(scalar) = node.as_scalar() else {
7729                self.unsupported_sequence_item(
7730                    EXPECTED_SCALAR,
7731                    &node,
7732                    field.span,
7733                    "deploy resource reservation device capabilities must be string scalars",
7734                );
7735                items.push(DeployReservationDeviceCapability::unmodeled(
7736                    node_span(self.source_id, &node).unwrap_or(field.span),
7737                ));
7738                continue;
7739            };
7740            let item_span = span_from_position(self.source_id, scalar.byte_range());
7741            if scalar_type_for_value(scalar) != ScalarType::String {
7742                self.unsupported_sequence_item(
7743                    EXPECTED_SCALAR,
7744                    &YamlNode::Scalar(scalar.clone()),
7745                    field.span,
7746                    "deploy resource reservation device capabilities must be string scalars",
7747                );
7748                items.push(DeployReservationDeviceCapability::unmodeled(item_span));
7749                continue;
7750            }
7751            let value = scalar_string_from_source(&self.source, scalar);
7752            if let Some(first) = seen.get(&value) {
7753                self.diagnostics.push(
7754                    Diagnostic::new(
7755                        DEPLOY_RESERVATION_DEVICE_CAPABILITY_DUPLICATE_ITEM,
7756                        Severity::Error,
7757                        "deploy resource reservation device capabilities must be unique exact strings",
7758                    )
7759                    .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
7760                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
7761                );
7762            } else {
7763                seen.insert(value.clone(), item_span);
7764            }
7765            items.push(DeployReservationDeviceCapability::string(Located::new(
7766                value, item_span,
7767            )));
7768        }
7769        Some(DeployReservationDeviceCapabilities::new(
7770            span_from_position(self.source_id, sequence.byte_range()),
7771            items,
7772        ))
7773    }
7774
7775    fn parse_deploy_discrete_resource_spec(&mut self, field: &ParsedField) -> Option<DeployDiscreteResourceSpec> {
7776        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7777            self.expected(
7778                EXPECTED_MAPPING,
7779                field,
7780                "deploy discrete_resource_spec must be a mapping",
7781            );
7782            return None;
7783        };
7784        let mut spec = DeployDiscreteResourceSpec::new(span_from_position(self.source_id, mapping.byte_range()));
7785        let mut seen = BTreeMap::new();
7786        for option in self.fields(mapping) {
7787            if self.record_duplicate(&mut seen, &option) {
7788                continue;
7789            }
7790            match option.name.value().as_str() {
7791                name if name.starts_with("x-") => spec.push_extension(option.reference()),
7792                "kind" => {
7793                    if let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) {
7794                        if scalar_type_for_value(scalar) == ScalarType::String {
7795                            spec.set_kind(Located::new(
7796                                scalar_string_from_source(&self.source, scalar),
7797                                span_from_position(self.source_id, scalar.byte_range()),
7798                            ));
7799                        } else {
7800                            self.expected(
7801                                EXPECTED_SCALAR,
7802                                &option,
7803                                "deploy discrete_resource_spec kind must be a YAML string scalar",
7804                            );
7805                            spec.push_unknown(option.reference());
7806                        }
7807                    } else {
7808                        self.expected(
7809                            EXPECTED_SCALAR,
7810                            &option,
7811                            "deploy discrete_resource_spec kind must be a YAML string scalar",
7812                        );
7813                        spec.push_unknown(option.reference());
7814                    }
7815                }
7816                "value" => {
7817                    if let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) {
7818                        let value = match scalar_type_for_value(scalar) {
7819                            ScalarType::Integer | ScalarType::Float => Some(DeployDiscreteResourceValue::YamlNumber(
7820                                scalar_string_from_source(&self.source, scalar),
7821                            )),
7822                            ScalarType::String => Some(DeployDiscreteResourceValue::String(scalar_string_from_source(
7823                                &self.source,
7824                                scalar,
7825                            ))),
7826                            _ => None,
7827                        };
7828                        if let Some(value) = value {
7829                            spec.set_value(Located::new(
7830                                value,
7831                                span_from_position(self.source_id, scalar.byte_range()),
7832                            ));
7833                        } else {
7834                            self.expected(
7835                                EXPECTED_SCALAR,
7836                                &option,
7837                                "deploy discrete_resource_spec value must be a YAML number or string scalar",
7838                            );
7839                            spec.push_unknown(option.reference());
7840                        }
7841                    } else {
7842                        self.expected(
7843                            EXPECTED_SCALAR,
7844                            &option,
7845                            "deploy discrete_resource_spec value must be a YAML number or string scalar",
7846                        );
7847                        spec.push_unknown(option.reference());
7848                    }
7849                }
7850                _ => spec.push_unknown(option.reference()),
7851            }
7852        }
7853        Some(spec)
7854    }
7855
7856    fn source_column(&self, offset: usize) -> usize {
7857        let prefix = self.source.get(..offset).unwrap_or_default();
7858        let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
7859        self.source[line_start..offset].chars().count()
7860    }
7861
7862    fn parse_service_ports(&mut self, field: &ParsedField) -> Vec<Port> {
7863        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7864            self.expected(EXPECTED_SEQUENCE, field, "service ports must be a sequence");
7865            return Vec::new();
7866        };
7867
7868        let mut ports = Vec::new();
7869        for value in sequence.values() {
7870            match value {
7871                YamlNode::Scalar(scalar) => {
7872                    let span = span_from_position(self.source_id, scalar.byte_range());
7873                    ports.push(Port::Short(ShortPort::parse(Located::new(
7874                        scalar_string_from_source(&self.source, &scalar),
7875                        span,
7876                    ))));
7877                }
7878                YamlNode::Mapping(mapping) => {
7879                    ports.push(Port::Long(Box::new(self.parse_long_port(&mapping))));
7880                }
7881                other => self.unsupported_sequence_item(
7882                    PORT_EXPECTED_FORM,
7883                    &other,
7884                    field.span,
7885                    "service port must use scalar short syntax or mapping long syntax",
7886                ),
7887            }
7888        }
7889        ports
7890    }
7891
7892    fn parse_long_port(&mut self, mapping: &Mapping) -> LongPort {
7893        let span = span_from_position(self.source_id, mapping.byte_range());
7894        let mut port = LongPort::new(span);
7895        let mut seen = BTreeMap::new();
7896        for field in self.fields(mapping) {
7897            let duplicate = self.record_duplicate(&mut seen, &field);
7898            match field.name.value.as_str() {
7899                "target" if !duplicate => self
7900                    .parse_string(&field, "port target")
7901                    .into_iter()
7902                    .for_each(|value| port.set_target(value)),
7903                "published" if !duplicate => self
7904                    .parse_string(&field, "published port")
7905                    .into_iter()
7906                    .for_each(|value| port.set_published(value)),
7907                "host_ip" if !duplicate => self
7908                    .parse_string(&field, "port host IP")
7909                    .into_iter()
7910                    .for_each(|value| port.set_host_ip(value)),
7911                "protocol" if !duplicate => self
7912                    .parse_string(&field, "port protocol")
7913                    .into_iter()
7914                    .for_each(|value| port.set_protocol(value)),
7915                "app_protocol" if !duplicate => self
7916                    .parse_string(&field, "port application protocol")
7917                    .into_iter()
7918                    .for_each(|value| port.set_app_protocol(value)),
7919                "mode" if !duplicate => self
7920                    .parse_string(&field, "port mode")
7921                    .into_iter()
7922                    .for_each(|value| port.set_mode(value)),
7923                "name" if !duplicate => self
7924                    .parse_string(&field, "port name")
7925                    .into_iter()
7926                    .for_each(|value| port.set_name(value)),
7927                name if name.starts_with("x-") => port.push_extension(field.reference()),
7928                _ if duplicate => {}
7929                _ => port.push_unknown(field.reference()),
7930            }
7931        }
7932        if port.target().is_none() {
7933            self.missing(PORT_MISSING_TARGET, span, "long port is missing `target`");
7934        }
7935        port
7936    }
7937
7938    fn parse_service_networks(&mut self, field: &ParsedField) -> Option<ServiceNetworks> {
7939        match field.value.as_ref() {
7940            Some(YamlNode::Sequence(sequence)) => {
7941                let span = span_from_position(self.source_id, sequence.byte_range());
7942                let names =
7943                    self.parse_scalar_nodes(sequence.values(), field.span, "service network names must be scalars");
7944                Some(ServiceNetworks::Short { span, names })
7945            }
7946            Some(YamlNode::Mapping(mapping)) => {
7947                let span = span_from_position(self.source_id, mapping.byte_range());
7948                let networks = self.parse_service_network_map(mapping);
7949                Some(ServiceNetworks::Long { span, networks })
7950            }
7951            _ => {
7952                self.expected(
7953                    EXPECTED_FIELD_FORM,
7954                    field,
7955                    "service networks must be a sequence or mapping",
7956                );
7957                None
7958            }
7959        }
7960    }
7961
7962    fn parse_service_network_map(&mut self, mapping: &Mapping) -> Vec<ServiceNetwork> {
7963        let mut networks = Vec::new();
7964        let mut seen = BTreeMap::new();
7965        for field in self.fields(mapping) {
7966            if self.record_duplicate(&mut seen, &field) {
7967                continue;
7968            }
7969            if Self::field_is_null(&field) {
7970                networks.push(ServiceNetwork::new(field.name, field.span));
7971                continue;
7972            }
7973            let Some(options) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7974                self.expected(
7975                    EXPECTED_MAPPING,
7976                    &field,
7977                    "service network options must be a mapping or null",
7978                );
7979                continue;
7980            };
7981            networks.push(self.parse_service_network(&field, options));
7982        }
7983        networks
7984    }
7985
7986    fn parse_service_network(&mut self, field: &ParsedField, mapping: &Mapping) -> ServiceNetwork {
7987        let mut network = ServiceNetwork::new(field.name.clone(), field.span);
7988        let mut seen = BTreeMap::new();
7989        for option in self.fields(mapping) {
7990            let duplicate = self.record_duplicate(&mut seen, &option);
7991            match option.name.value.as_str() {
7992                "aliases" if !duplicate => network.set_aliases(self.parse_string_sequence(&option, "network aliases")),
7993                "interface_name" if !duplicate => self
7994                    .parse_string(&option, "network interface name")
7995                    .into_iter()
7996                    .for_each(|value| network.set_interface_name(value)),
7997                "ipv4_address" if !duplicate => self
7998                    .parse_string(&option, "network IPv4 address")
7999                    .into_iter()
8000                    .for_each(|value| network.set_ipv4_address(value)),
8001                "ipv6_address" if !duplicate => self
8002                    .parse_string(&option, "network IPv6 address")
8003                    .into_iter()
8004                    .for_each(|value| network.set_ipv6_address(value)),
8005                "link_local_ips" if !duplicate => {
8006                    network.set_link_local_ips(self.parse_string_sequence(&option, "link-local IP addresses"));
8007                }
8008                "mac_address" if !duplicate => self
8009                    .parse_string(&option, "network MAC address")
8010                    .into_iter()
8011                    .for_each(|value| network.set_mac_address(value)),
8012                "driver_opts" if !duplicate => {
8013                    network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
8014                }
8015                "gw_priority" if !duplicate => self
8016                    .parse_string(&option, "network gateway priority")
8017                    .into_iter()
8018                    .for_each(|value| network.set_gw_priority(value)),
8019                "priority" if !duplicate => self
8020                    .parse_string(&option, "network priority")
8021                    .into_iter()
8022                    .for_each(|value| network.set_priority(value)),
8023                name if name.starts_with("x-") => network.push_extension(option.reference()),
8024                _ if duplicate => {}
8025                _ => network.push_unknown(option.reference()),
8026            }
8027        }
8028        network
8029    }
8030
8031    fn parse_config_grants(&mut self, field: &ParsedField) -> Vec<ConfigGrant> {
8032        self.parse_grants(field)
8033            .unwrap_or_default()
8034            .into_iter()
8035            .map(|grant| match grant {
8036                ParsedGrant::Short(value) => ConfigGrant::Short(value),
8037                ParsedGrant::Long(value) => ConfigGrant::Long(value),
8038            })
8039            .collect()
8040    }
8041
8042    fn parse_secret_grants(&mut self, field: &ParsedField) -> Option<Vec<SecretGrant>> {
8043        Some(
8044            self.parse_grants(field)?
8045                .into_iter()
8046                .map(|grant| match grant {
8047                    ParsedGrant::Short(value) => SecretGrant::Short(value),
8048                    ParsedGrant::Long(value) => SecretGrant::Long(value),
8049                })
8050                .collect(),
8051        )
8052    }
8053
8054    fn parse_grants(&mut self, field: &ParsedField) -> Option<Vec<ParsedGrant>> {
8055        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
8056            self.expected(EXPECTED_SEQUENCE, field, "service grants must be a sequence");
8057            return None;
8058        };
8059        let mut grants = Vec::new();
8060        for value in sequence.values() {
8061            match value {
8062                YamlNode::Scalar(scalar) => {
8063                    let span = span_from_position(self.source_id, scalar.byte_range());
8064                    grants.push(ParsedGrant::Short(Located::new(
8065                        scalar_string_from_source(&self.source, &scalar),
8066                        span,
8067                    )));
8068                }
8069                YamlNode::Mapping(mapping) => {
8070                    grants.push(ParsedGrant::Long(Box::new(self.parse_long_grant(&mapping))));
8071                }
8072                other => self.unsupported_sequence_item(
8073                    GRANT_EXPECTED_FORM,
8074                    &other,
8075                    field.span,
8076                    "grant must use scalar short syntax or mapping long syntax",
8077                ),
8078            }
8079        }
8080        Some(grants)
8081    }
8082
8083    fn parse_long_grant(&mut self, mapping: &Mapping) -> LongGrant {
8084        let span = span_from_position(self.source_id, mapping.byte_range());
8085        let mut grant = LongGrant::new(span);
8086        let mut seen = BTreeMap::new();
8087        for field in self.fields(mapping) {
8088            let duplicate = self.record_duplicate(&mut seen, &field);
8089            match field.name.value.as_str() {
8090                "source" if !duplicate => self
8091                    .parse_string(&field, "grant source")
8092                    .into_iter()
8093                    .for_each(|value| grant.set_source(value)),
8094                "target" if !duplicate => self
8095                    .parse_string(&field, "grant target")
8096                    .into_iter()
8097                    .for_each(|value| grant.set_target(value)),
8098                "uid" if !duplicate => self
8099                    .parse_string(&field, "grant user ID")
8100                    .into_iter()
8101                    .for_each(|value| grant.set_uid(value)),
8102                "gid" if !duplicate => self
8103                    .parse_string(&field, "grant group ID")
8104                    .into_iter()
8105                    .for_each(|value| grant.set_gid(value)),
8106                "mode" if !duplicate => self
8107                    .parse_string(&field, "grant mode")
8108                    .into_iter()
8109                    .for_each(|value| grant.set_mode(value)),
8110                name if name.starts_with("x-") => grant.push_extension(field.reference()),
8111                _ if duplicate => {}
8112                _ => grant.push_unknown(field.reference()),
8113            }
8114        }
8115        if grant.source().is_none() {
8116            self.missing(GRANT_MISSING_SOURCE, span, "long grant is missing `source`");
8117        }
8118        grant
8119    }
8120
8121    fn parse_service_volumes(&mut self, field: &ParsedField) -> Vec<VolumeMount> {
8122        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
8123            self.expected(EXPECTED_SEQUENCE, field, "service volumes must be a sequence");
8124            return Vec::new();
8125        };
8126
8127        sequence
8128            .values()
8129            .filter_map(|value| match value {
8130                YamlNode::Scalar(scalar) => {
8131                    let span = span_from_position(self.source_id, scalar.byte_range());
8132                    let raw = Located::new(scalar_string_from_source(&self.source, &scalar), span);
8133                    Some(VolumeMount::Short(ShortVolumeMount::new(raw)))
8134                }
8135                YamlNode::Mapping(mapping) => Some(VolumeMount::Long(Box::new(self.parse_long_volume(&mapping)))),
8136                other => {
8137                    let span = node_span(self.source_id, &other).unwrap_or(field.span);
8138                    self.diagnostics.push(
8139                        Diagnostic::new(
8140                            VOLUME_EXPECTED_FORM,
8141                            Severity::Error,
8142                            "service volume must use scalar short syntax or mapping long syntax",
8143                        )
8144                        .with_label(DiagnosticLabel::primary(span, "unsupported volume form")),
8145                    );
8146                    None
8147                }
8148            })
8149            .collect()
8150    }
8151
8152    fn parse_long_volume(&mut self, mapping: &Mapping) -> LongVolumeMount {
8153        let span = span_from_position(self.source_id, mapping.byte_range());
8154        let mut mount = LongVolumeMount::new(span);
8155        let mut seen = BTreeMap::new();
8156        for field in self.fields(mapping) {
8157            let duplicate = self.record_duplicate(&mut seen, &field);
8158            match field.name.value.as_str() {
8159                "type" if !duplicate => {
8160                    if let Some(value) = self.parse_string(&field, "volume type") {
8161                        mount.set_mount_type(Located::new(MountType::from_text(value.value), value.span));
8162                    }
8163                }
8164                "source" if !duplicate => {
8165                    if let Some(value) = self.parse_string(&field, "volume source") {
8166                        mount.set_source(value);
8167                    }
8168                }
8169                "target" if !duplicate => {
8170                    if let Some(value) = self.parse_string(&field, "volume target") {
8171                        mount.set_target(value);
8172                    }
8173                }
8174                "read_only" if !duplicate => {
8175                    if let Some(value) = self.parse_boolean(&field, "read_only") {
8176                        mount.set_read_only(value);
8177                    }
8178                }
8179                "consistency" if !duplicate => {
8180                    if let Some(value) = self.parse_string(&field, "volume consistency") {
8181                        mount.set_consistency(value);
8182                    } else {
8183                        mount.push_unknown(field.reference());
8184                    }
8185                }
8186                "bind" if !duplicate => {
8187                    if let Some(value) = self.parse_bind_options(&field) {
8188                        mount.set_bind(value);
8189                    } else {
8190                        mount.push_unknown(field.reference());
8191                    }
8192                }
8193                "image" if !duplicate => {
8194                    if let Some(value) = self.parse_image_mount_options(&field) {
8195                        mount.set_image(value);
8196                    } else {
8197                        mount.push_unknown(field.reference());
8198                    }
8199                }
8200                "tmpfs" if !duplicate => {
8201                    if let Some(value) = self.parse_tmpfs_mount_options(&field) {
8202                        mount.set_tmpfs(value);
8203                    } else {
8204                        mount.push_unknown(field.reference());
8205                    }
8206                }
8207                "volume" if !duplicate => {
8208                    if let Some(value) = self.parse_volume_mount_options(&field) {
8209                        mount.set_volume(value);
8210                    } else {
8211                        mount.push_unknown(field.reference());
8212                    }
8213                }
8214                name if name.starts_with("x-") => mount.push_extension(field.reference()),
8215                _ if duplicate => {}
8216                _ => mount.push_unknown(field.reference()),
8217            }
8218        }
8219
8220        if mount.mount_type().is_none() {
8221            self.missing(VOLUME_MISSING_TYPE, span, "long volume is missing `type`");
8222        }
8223        if mount.target().is_none() {
8224            self.missing(VOLUME_MISSING_TARGET, span, "long volume is missing `target`");
8225        }
8226        mount
8227    }
8228
8229    fn parse_bind_options(&mut self, field: &ParsedField) -> Option<BindOptions> {
8230        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8231            self.expected(EXPECTED_MAPPING, field, "bind options must be a mapping");
8232            return None;
8233        };
8234        let span = span_from_position(self.source_id, mapping.byte_range());
8235        let mut bind = BindOptions::new(span);
8236        let mut seen = BTreeMap::new();
8237        for bind_field in self.fields(mapping) {
8238            let duplicate = self.record_duplicate(&mut seen, &bind_field);
8239            match bind_field.name.value.as_str() {
8240                "propagation" if !duplicate => {
8241                    if let Some(value) = self.parse_string(&bind_field, "bind propagation") {
8242                        bind.set_propagation(value);
8243                    }
8244                }
8245                "create_host_path" if !duplicate => {
8246                    if let Some(value) = self.parse_boolean(&bind_field, "create_host_path") {
8247                        bind.set_create_host_path(value);
8248                    }
8249                }
8250                "selinux" if !duplicate => {
8251                    if let Some(value) = self.parse_string(&bind_field, "SELinux relabel mode") {
8252                        let mode = match value.value.as_str() {
8253                            "z" => Some(SelinuxRelabel::Shared),
8254                            "Z" => Some(SelinuxRelabel::Private),
8255                            _ => None,
8256                        };
8257                        if let Some(mode) = mode {
8258                            bind.set_selinux(Located::new(mode, value.span));
8259                        } else {
8260                            self.diagnostics.push(
8261                                Diagnostic::new(
8262                                    VOLUME_INVALID_SELINUX,
8263                                    Severity::Error,
8264                                    "SELinux relabel mode must be `z` or `Z`",
8265                                )
8266                                .with_label(DiagnosticLabel::primary(value.span, "invalid SELinux mode")),
8267                            );
8268                        }
8269                    }
8270                }
8271                "recursive" if !duplicate => {
8272                    if let Some(value) = self.parse_string(&bind_field, "bind recursive mode") {
8273                        bind.set_recursive(value);
8274                    } else {
8275                        bind.push_unknown(bind_field.reference());
8276                    }
8277                }
8278                name if name.starts_with("x-") => bind.push_extension(bind_field.reference()),
8279                _ if duplicate => {}
8280                _ => bind.push_unknown(bind_field.reference()),
8281            }
8282        }
8283        Some(bind)
8284    }
8285
8286    fn parse_image_mount_options(&mut self, field: &ParsedField) -> Option<ImageMountOptions> {
8287        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8288            self.expected(
8289                VOLUME_OPTION_EXPECTED_MAPPING,
8290                field,
8291                "volume image options must be a mapping",
8292            );
8293            return None;
8294        };
8295        let mut options = ImageMountOptions::new(span_from_position(self.source_id, mapping.byte_range()));
8296        let mut seen = BTreeMap::new();
8297        for option in self.fields(mapping) {
8298            let duplicate = self.record_duplicate(&mut seen, &option);
8299            match option.name.value.as_str() {
8300                "subpath" if !duplicate => {
8301                    if let Some(value) = self.parse_string(&option, "image mount subpath") {
8302                        options.set_subpath(value);
8303                    } else {
8304                        options.push_unknown(option.reference());
8305                    }
8306                }
8307                name if name.starts_with("x-") => options.push_extension(option.reference()),
8308                _ if duplicate => {}
8309                _ => options.push_unknown(option.reference()),
8310            }
8311        }
8312        Some(options)
8313    }
8314
8315    fn parse_tmpfs_mount_options(&mut self, field: &ParsedField) -> Option<TmpfsMountOptions> {
8316        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8317            self.expected(
8318                VOLUME_OPTION_EXPECTED_MAPPING,
8319                field,
8320                "volume tmpfs options must be a mapping",
8321            );
8322            return None;
8323        };
8324        let mut options = TmpfsMountOptions::new(span_from_position(self.source_id, mapping.byte_range()));
8325        let mut seen = BTreeMap::new();
8326        for option in self.fields(mapping) {
8327            let duplicate = self.record_duplicate(&mut seen, &option);
8328            match option.name.value.as_str() {
8329                "size" if !duplicate => {
8330                    if let Some(value) =
8331                        self.parse_string_or_number_scalar(&option, "tmpfs size must be a number or string")
8332                    {
8333                        options.set_size(value);
8334                    } else {
8335                        options.push_unknown(option.reference());
8336                    }
8337                }
8338                "mode" if !duplicate => {
8339                    if let Some(value) =
8340                        self.parse_string_or_number_scalar(&option, "tmpfs mode must be a number or string")
8341                    {
8342                        options.set_mode(value);
8343                    } else {
8344                        options.push_unknown(option.reference());
8345                    }
8346                }
8347                name if name.starts_with("x-") => options.push_extension(option.reference()),
8348                _ if duplicate => {}
8349                _ => options.push_unknown(option.reference()),
8350            }
8351        }
8352        Some(options)
8353    }
8354
8355    fn parse_volume_mount_options(&mut self, field: &ParsedField) -> Option<VolumeMountOptions> {
8356        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8357            self.expected(
8358                VOLUME_OPTION_EXPECTED_MAPPING,
8359                field,
8360                "named-volume options must be a mapping",
8361            );
8362            return None;
8363        };
8364        let mut options = VolumeMountOptions::new(span_from_position(self.source_id, mapping.byte_range()));
8365        let mut seen = BTreeMap::new();
8366        for option in self.fields(mapping) {
8367            let duplicate = self.record_duplicate(&mut seen, &option);
8368            match option.name.value.as_str() {
8369                "nocopy" if !duplicate => {
8370                    if let Some(value) = self.parse_boolean(&option, "volume nocopy") {
8371                        options.set_nocopy(value);
8372                    } else {
8373                        options.push_unknown(option.reference());
8374                    }
8375                }
8376                "subpath" if !duplicate => {
8377                    if let Some(value) = self.parse_string(&option, "volume subpath") {
8378                        options.set_subpath(value);
8379                    } else {
8380                        options.push_unknown(option.reference());
8381                    }
8382                }
8383                "labels" if !duplicate => {
8384                    if let Some(value) = self.parse_labels(&option) {
8385                        options.set_labels(value);
8386                    } else {
8387                        options.push_unknown(option.reference());
8388                    }
8389                }
8390                name if name.starts_with("x-") => options.push_extension(option.reference()),
8391                _ if duplicate => {}
8392                _ => options.push_unknown(option.reference()),
8393            }
8394        }
8395        Some(options)
8396    }
8397
8398    fn parse_network_definitions(&mut self, field: &ParsedField) -> Vec<NetworkDefinition> {
8399        let Some(mapping) = self.resource_collection(field, "networks") else {
8400            return Vec::new();
8401        };
8402        let mut definitions = Vec::new();
8403        let mut seen = BTreeMap::new();
8404        for resource in self.fields(&mapping) {
8405            if self.record_duplicate(&mut seen, &resource) {
8406                continue;
8407            }
8408            if Self::field_is_null(&resource) {
8409                definitions.push(NetworkDefinition::new(resource.name, resource.span));
8410                continue;
8411            }
8412            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
8413                self.expected(
8414                    RESOURCE_EXPECTED_FORM,
8415                    &resource,
8416                    "network definition must be a mapping or null",
8417                );
8418                continue;
8419            };
8420            definitions.push(self.parse_network_definition(&resource, definition));
8421        }
8422        definitions
8423    }
8424
8425    fn parse_network_definition(&mut self, field: &ParsedField, mapping: &Mapping) -> NetworkDefinition {
8426        let mut network = NetworkDefinition::new(field.name.clone(), field.span);
8427        let mut seen = BTreeMap::new();
8428        for option in self.fields(mapping) {
8429            let duplicate = self.record_duplicate(&mut seen, &option);
8430            match option.name.value.as_str() {
8431                "driver" if !duplicate => self
8432                    .parse_string(&option, "network driver")
8433                    .into_iter()
8434                    .for_each(|value| network.set_driver(value)),
8435                "driver_opts" if !duplicate => {
8436                    network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
8437                }
8438                "attachable" if !duplicate => self
8439                    .parse_boolean(&option, "network attachable")
8440                    .into_iter()
8441                    .for_each(|value| network.set_attachable(value)),
8442                "enable_ipv4" if !duplicate => self
8443                    .parse_boolean(&option, "network enable_ipv4")
8444                    .into_iter()
8445                    .for_each(|value| network.set_enable_ipv4(value)),
8446                "enable_ipv6" if !duplicate => self
8447                    .parse_boolean(&option, "network enable_ipv6")
8448                    .into_iter()
8449                    .for_each(|value| network.set_enable_ipv6(value)),
8450                "external" if !duplicate => self
8451                    .parse_resource_external(&option, "network")
8452                    .into_iter()
8453                    .for_each(|value| network.set_external(value)),
8454                "internal" if !duplicate => self
8455                    .parse_boolean(&option, "network internal")
8456                    .into_iter()
8457                    .for_each(|value| network.set_internal(value)),
8458                "ipam" if !duplicate => self
8459                    .parse_ipam(&option)
8460                    .into_iter()
8461                    .for_each(|value| network.set_ipam(value)),
8462                "labels" if !duplicate => self
8463                    .parse_labels(&option)
8464                    .into_iter()
8465                    .for_each(|value| network.set_labels(value)),
8466                "name" if !duplicate => self
8467                    .parse_string(&option, "network custom name")
8468                    .into_iter()
8469                    .for_each(|value| network.set_custom_name(value)),
8470                name if name.starts_with("x-") => network.push_extension(option.reference()),
8471                _ if duplicate => {}
8472                _ => network.push_unknown(option.reference()),
8473            }
8474        }
8475        self.validate_resource_external_name(network.external(), network.custom_name(), network.span());
8476        network
8477    }
8478
8479    fn parse_ipam(&mut self, field: &ParsedField) -> Option<Ipam> {
8480        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8481            self.expected(EXPECTED_MAPPING, field, "network IPAM must be a mapping");
8482            return None;
8483        };
8484        let span = span_from_position(self.source_id, mapping.byte_range());
8485        let mut ipam = Ipam::new(span);
8486        let mut seen = BTreeMap::new();
8487        for option in self.fields(mapping) {
8488            let duplicate = self.record_duplicate(&mut seen, &option);
8489            match option.name.value.as_str() {
8490                "driver" if !duplicate => self
8491                    .parse_string(&option, "IPAM driver")
8492                    .into_iter()
8493                    .for_each(|value| ipam.set_driver(value)),
8494                "config" if !duplicate => ipam.set_config(self.parse_ipam_configs(&option)),
8495                "options" if !duplicate => {
8496                    ipam.set_options(self.parse_scalar_mapping(&option, "IPAM options"));
8497                }
8498                name if name.starts_with("x-") => ipam.push_extension(option.reference()),
8499                _ if duplicate => {}
8500                _ => ipam.push_unknown(option.reference()),
8501            }
8502        }
8503        Some(ipam)
8504    }
8505
8506    fn parse_ipam_configs(&mut self, field: &ParsedField) -> Vec<IpamConfig> {
8507        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
8508            self.expected(EXPECTED_SEQUENCE, field, "IPAM config must be a sequence");
8509            return Vec::new();
8510        };
8511        let mut configs = Vec::new();
8512        for value in sequence.values() {
8513            let YamlNode::Mapping(mapping) = value else {
8514                self.unsupported_sequence_item(
8515                    EXPECTED_MAPPING,
8516                    &value,
8517                    field.span,
8518                    "IPAM config entries must be mappings",
8519                );
8520                continue;
8521            };
8522            configs.push(self.parse_ipam_config(&mapping));
8523        }
8524        configs
8525    }
8526
8527    fn parse_ipam_config(&mut self, mapping: &Mapping) -> IpamConfig {
8528        let span = span_from_position(self.source_id, mapping.byte_range());
8529        let mut config = IpamConfig::new(span);
8530        let mut seen = BTreeMap::new();
8531        for field in self.fields(mapping) {
8532            let duplicate = self.record_duplicate(&mut seen, &field);
8533            match field.name.value.as_str() {
8534                "subnet" if !duplicate => self
8535                    .parse_string(&field, "IPAM subnet")
8536                    .into_iter()
8537                    .for_each(|value| config.set_subnet(value)),
8538                "ip_range" if !duplicate => self
8539                    .parse_string(&field, "IPAM allocation range")
8540                    .into_iter()
8541                    .for_each(|value| config.set_ip_range(value)),
8542                "gateway" if !duplicate => self
8543                    .parse_string(&field, "IPAM gateway")
8544                    .into_iter()
8545                    .for_each(|value| config.set_gateway(value)),
8546                "aux_addresses" if !duplicate => {
8547                    config.set_aux_addresses(self.parse_scalar_mapping(&field, "IPAM auxiliary addresses"));
8548                }
8549                name if name.starts_with("x-") => config.push_extension(field.reference()),
8550                _ if duplicate => {}
8551                _ => config.push_unknown(field.reference()),
8552            }
8553        }
8554        config
8555    }
8556
8557    fn parse_volume_definitions(&mut self, field: &ParsedField) -> Vec<VolumeDefinition> {
8558        let Some(mapping) = self.resource_collection(field, "volumes") else {
8559            return Vec::new();
8560        };
8561        let mut definitions = Vec::new();
8562        let mut seen = BTreeMap::new();
8563        for resource in self.fields(&mapping) {
8564            if self.record_duplicate(&mut seen, &resource) {
8565                continue;
8566            }
8567            let mut volume = VolumeDefinition::new(resource.name.clone(), resource.span);
8568            if Self::field_is_null(&resource) {
8569                definitions.push(volume);
8570                continue;
8571            }
8572            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
8573                self.expected(
8574                    RESOURCE_EXPECTED_FORM,
8575                    &resource,
8576                    "volume definition must be a mapping or null",
8577                );
8578                continue;
8579            };
8580            let mut nested_seen = BTreeMap::new();
8581            for option in self.fields(definition) {
8582                let duplicate = self.record_duplicate(&mut nested_seen, &option);
8583                match option.name.value.as_str() {
8584                    "driver" if !duplicate => self
8585                        .parse_string(&option, "volume driver")
8586                        .into_iter()
8587                        .for_each(|value| volume.set_driver(value)),
8588                    "driver_opts" if !duplicate => {
8589                        volume.set_driver_opts(self.parse_scalar_mapping(&option, "volume driver options"));
8590                    }
8591                    "external" if !duplicate => self
8592                        .parse_resource_external(&option, "volume")
8593                        .into_iter()
8594                        .for_each(|value| volume.set_external(value)),
8595                    "labels" if !duplicate => self
8596                        .parse_labels(&option)
8597                        .into_iter()
8598                        .for_each(|value| volume.set_labels(value)),
8599                    "name" if !duplicate => self
8600                        .parse_string(&option, "volume custom name")
8601                        .into_iter()
8602                        .for_each(|value| volume.set_custom_name(value)),
8603                    name if name.starts_with("x-") => volume.push_extension(option.reference()),
8604                    _ if duplicate => {}
8605                    _ => volume.push_unknown(option.reference()),
8606                }
8607            }
8608            self.validate_external_volume_driver_configuration(&volume);
8609            self.validate_external_volume_labels_configuration(&volume);
8610            self.validate_resource_external_name(volume.external(), volume.custom_name(), volume.span());
8611            definitions.push(volume);
8612        }
8613        definitions
8614    }
8615
8616    fn validate_external_volume_driver_configuration(&mut self, volume: &VolumeDefinition) {
8617        if !volume.external().is_some_and(ResourceExternal::is_explicitly_external)
8618            || (volume.driver().is_none() && volume.driver_opts().is_empty())
8619        {
8620            return;
8621        }
8622        let span = volume
8623            .driver()
8624            .map(Located::span)
8625            .or_else(|| volume.driver_opts().first().map(KeyValueEntry::span))
8626            .unwrap_or_else(|| volume.span());
8627        self.diagnostics.push(
8628            Diagnostic::new(
8629                VOLUME_EXTERNAL_DRIVER_CONFIGURATION,
8630                Severity::Error,
8631                "external volume cannot also configure `driver` or `driver_opts`",
8632            )
8633            .with_label(DiagnosticLabel::primary(
8634                span,
8635                "driver configuration remains retained for review",
8636            )),
8637        );
8638    }
8639
8640    fn validate_external_volume_labels_configuration(&mut self, volume: &VolumeDefinition) {
8641        if !volume.external().is_some_and(ResourceExternal::is_explicitly_external) || volume.labels().is_none() {
8642            return;
8643        }
8644        let span = volume.labels().map_or_else(|| volume.span(), Labels::span);
8645        self.diagnostics.push(
8646            Diagnostic::new(
8647                VOLUME_EXTERNAL_LABELS_CONFIGURATION,
8648                Severity::Error,
8649                "external volume cannot also configure `labels`",
8650            )
8651            .with_label(DiagnosticLabel::primary(span, "labels remain retained for review")),
8652        );
8653    }
8654
8655    fn parse_config_definitions(&mut self, field: &ParsedField) -> Vec<ConfigDefinition> {
8656        let Some(mapping) = self.resource_collection(field, "configs") else {
8657            return Vec::new();
8658        };
8659        let mut definitions = Vec::new();
8660        let mut seen = BTreeMap::new();
8661        for resource in self.fields(&mapping) {
8662            if self.record_duplicate(&mut seen, &resource) {
8663                continue;
8664            }
8665            let mut config = ConfigDefinition::new(resource.name.clone(), resource.span);
8666            if Self::field_is_null(&resource) {
8667                definitions.push(config);
8668                continue;
8669            }
8670            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
8671                self.expected(
8672                    RESOURCE_EXPECTED_FORM,
8673                    &resource,
8674                    "config definition must be a mapping or null",
8675                );
8676                continue;
8677            };
8678            let mut nested_seen = BTreeMap::new();
8679            for option in self.fields(definition) {
8680                let duplicate = self.record_duplicate(&mut nested_seen, &option);
8681                match option.name.value.as_str() {
8682                    "file" if !duplicate => self
8683                        .parse_string(&option, "config file")
8684                        .into_iter()
8685                        .for_each(|value| config.set_file(value)),
8686                    "environment" if !duplicate => self
8687                        .parse_string(&option, "config environment source")
8688                        .into_iter()
8689                        .for_each(|value| config.set_environment(value)),
8690                    "content" if !duplicate => self
8691                        .parse_string(&option, "config content")
8692                        .into_iter()
8693                        .for_each(|value| config.set_content(value)),
8694                    "external" if !duplicate => self
8695                        .parse_resource_external(&option, "config")
8696                        .into_iter()
8697                        .for_each(|value| config.set_external(value)),
8698                    "labels" if !duplicate => self
8699                        .parse_labels(&option)
8700                        .into_iter()
8701                        .for_each(|value| config.set_labels(value)),
8702                    "template_driver" if !duplicate => self
8703                        .parse_extends_string(&option, "config template_driver must be a YAML string scalar")
8704                        .into_iter()
8705                        .for_each(|value| config.set_template_driver(value)),
8706                    "name" if !duplicate => self
8707                        .parse_string(&option, "config custom name")
8708                        .into_iter()
8709                        .for_each(|value| config.set_custom_name(value)),
8710                    name if name.starts_with("x-") => config.push_extension(option.reference()),
8711                    _ if duplicate => {}
8712                    _ => config.push_unknown(option.reference()),
8713                }
8714            }
8715            self.validate_resource_external_name(config.external(), config.custom_name(), config.span());
8716            self.validate_external_creation_configuration(
8717                config.external(),
8718                &[
8719                    config.file().map(Located::span),
8720                    config.environment().map(Located::span),
8721                    config.content().map(Located::span),
8722                    config.labels().map(Labels::span),
8723                    config.template_driver().map(Located::span),
8724                ],
8725                "config",
8726            );
8727            definitions.push(config);
8728        }
8729        definitions
8730    }
8731
8732    fn parse_secret_definitions(&mut self, field: &ParsedField) -> Vec<SecretDefinition> {
8733        let Some(mapping) = self.resource_collection(field, "secrets") else {
8734            return Vec::new();
8735        };
8736        let mut definitions = Vec::new();
8737        let mut seen = BTreeMap::new();
8738        for resource in self.fields(&mapping) {
8739            if self.record_duplicate(&mut seen, &resource) {
8740                continue;
8741            }
8742            let mut secret = SecretDefinition::new(resource.name.clone(), resource.span);
8743            if Self::field_is_null(&resource) {
8744                definitions.push(secret);
8745                continue;
8746            }
8747            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
8748                self.expected(
8749                    RESOURCE_EXPECTED_FORM,
8750                    &resource,
8751                    "secret definition must be a mapping or null",
8752                );
8753                continue;
8754            };
8755            let mut nested_seen = BTreeMap::new();
8756            for option in self.fields(definition) {
8757                let duplicate = self.record_duplicate(&mut nested_seen, &option);
8758                match option.name.value.as_str() {
8759                    "file" if !duplicate => self
8760                        .parse_string(&option, "secret file")
8761                        .into_iter()
8762                        .for_each(|value| secret.set_file(value)),
8763                    "environment" if !duplicate => self
8764                        .parse_string(&option, "secret environment source")
8765                        .into_iter()
8766                        .for_each(|value| secret.set_environment(value)),
8767                    "external" if !duplicate => self
8768                        .parse_resource_external(&option, "secret")
8769                        .into_iter()
8770                        .for_each(|value| secret.set_external(value)),
8771                    "driver" if !duplicate => self
8772                        .parse_extends_string(&option, "secret driver must be a YAML string scalar")
8773                        .into_iter()
8774                        .for_each(|value| secret.set_driver(value)),
8775                    "driver_opts" if !duplicate => {
8776                        secret.set_driver_opts(self.parse_scalar_mapping(&option, "secret driver options"));
8777                    }
8778                    "labels" if !duplicate => self
8779                        .parse_labels(&option)
8780                        .into_iter()
8781                        .for_each(|value| secret.set_labels(value)),
8782                    "template_driver" if !duplicate => self
8783                        .parse_extends_string(&option, "secret template_driver must be a YAML string scalar")
8784                        .into_iter()
8785                        .for_each(|value| secret.set_template_driver(value)),
8786                    "name" if !duplicate => self
8787                        .parse_string(&option, "secret custom name")
8788                        .into_iter()
8789                        .for_each(|value| secret.set_custom_name(value)),
8790                    name if name.starts_with("x-") => secret.push_extension(option.reference()),
8791                    _ if duplicate => {}
8792                    _ => secret.push_unknown(option.reference()),
8793                }
8794            }
8795            self.validate_resource_external_name(secret.external(), secret.custom_name(), secret.span());
8796            self.validate_external_creation_configuration(
8797                secret.external(),
8798                &[
8799                    secret.file().map(Located::span),
8800                    secret.environment().map(Located::span),
8801                    secret.driver().map(Located::span),
8802                    secret.driver_opts().first().map(KeyValueEntry::span),
8803                    secret.labels().map(Labels::span),
8804                    secret.template_driver().map(Located::span),
8805                ],
8806                "secret",
8807            );
8808            definitions.push(secret);
8809        }
8810        definitions
8811    }
8812
8813    fn parse_resource_external(&mut self, field: &ParsedField, kind: &str) -> Option<ResourceExternal> {
8814        let Some(value) = field.value.as_ref() else {
8815            self.expected(
8816                EXPECTED_FIELD_FORM,
8817                field,
8818                format!("{kind} external must be a boolean or mapping"),
8819            );
8820            return None;
8821        };
8822        if value.as_scalar().is_some_and(scalar_is_null) {
8823            self.expected(
8824                EXPECTED_FIELD_FORM,
8825                field,
8826                format!("{kind} external must be a boolean or mapping"),
8827            );
8828            return None;
8829        }
8830        let Some(mapping) = value.as_mapping() else {
8831            return self
8832                .parse_boolean(field, &format!("{kind} external"))
8833                .map(ResourceExternal::Boolean);
8834        };
8835        let mut name_mapping = ExternalNameMapping::new(span_from_position(self.source_id, mapping.byte_range()));
8836        let mut seen = BTreeMap::new();
8837        for member in self.fields(mapping) {
8838            if self.record_duplicate(&mut seen, &member) {
8839                name_mapping.push_unknown(member.reference());
8840                continue;
8841            }
8842            match member.name.value().as_str() {
8843                "name" => {
8844                    match self.parse_extends_string(&member, "deprecated external name must be a YAML string scalar") {
8845                        Some(value) if !value.value().is_empty() => name_mapping.set_name(value),
8846                        Some(value) => {
8847                            self.expected(
8848                                EXPECTED_SCALAR,
8849                                &member,
8850                                "deprecated external name must be a non-empty YAML string scalar",
8851                            );
8852                            let _ = value;
8853                            name_mapping.push_unknown(member.reference());
8854                        }
8855                        None => name_mapping.push_unknown(member.reference()),
8856                    }
8857                }
8858                name if name.starts_with("x-") => name_mapping.push_extension(member.reference()),
8859                _ => name_mapping.push_unknown(member.reference()),
8860            }
8861        }
8862        if name_mapping.name().is_none() {
8863            self.missing(
8864                EXPECTED_SCALAR,
8865                name_mapping.span(),
8866                "deprecated external mapping requires a non-empty `name` string",
8867            );
8868        }
8869        self.diagnostics.push(
8870            Diagnostic::new(
8871                RESOURCE_EXTERNAL_NAME_MAPPING_DEPRECATED,
8872                Severity::Warning,
8873                format!("{kind} `external.name` mapping syntax is deprecated"),
8874            )
8875            .with_label(DiagnosticLabel::primary(
8876                name_mapping.span(),
8877                "use `name: VALUE` with `external: true` instead",
8878            )),
8879        );
8880        Some(ResourceExternal::NameMapping(Box::new(name_mapping)))
8881    }
8882
8883    fn validate_resource_external_name(
8884        &mut self,
8885        external: Option<&ResourceExternal>,
8886        custom_name: Option<&Located<String>>,
8887        fallback: SourceSpan,
8888    ) {
8889        let (Some(ResourceExternal::NameMapping(name_mapping)), Some(modern)) = (external, custom_name) else {
8890            return;
8891        };
8892        let mapping_span = name_mapping.name().map_or_else(|| name_mapping.span(), Located::span);
8893        self.diagnostics.push(
8894            Diagnostic::new(
8895                RESOURCE_EXTERNAL_NAME_CONFLICT,
8896                Severity::Error,
8897                "resource cannot use both `name` and deprecated `external.name` mapping syntax",
8898            )
8899            .with_label(DiagnosticLabel::primary(modern.span(), "resource name"))
8900            .with_label(DiagnosticLabel::secondary(mapping_span, "deprecated external name"))
8901            .with_label(DiagnosticLabel::secondary(fallback, "both values remain retained")),
8902        );
8903    }
8904
8905    fn validate_external_creation_configuration(
8906        &mut self,
8907        external: Option<&ResourceExternal>,
8908        fields: &[Option<SourceSpan>],
8909        kind: &str,
8910    ) {
8911        if !external.is_some_and(ResourceExternal::is_explicitly_external) {
8912            return;
8913        }
8914        let Some(span) = fields.iter().flatten().next().copied() else {
8915            return;
8916        };
8917        self.diagnostics.push(
8918            Diagnostic::new(
8919                RESOURCE_EXTERNAL_CREATION_CONFIGURATION,
8920                Severity::Error,
8921                format!("external {kind} cannot also configure creation-time metadata"),
8922            )
8923            .with_label(DiagnosticLabel::primary(span, "metadata remains retained for review")),
8924        );
8925    }
8926
8927    fn resource_collection(&mut self, field: &ParsedField, kind: &str) -> Option<Mapping> {
8928        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8929            self.expected(EXPECTED_MAPPING, field, format!("top-level {kind} must be a mapping"));
8930            return None;
8931        };
8932        Some(mapping.clone())
8933    }
8934
8935    fn parse_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
8936        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
8937            self.expected(EXPECTED_SCALAR, field, format!("{description} must be a scalar"));
8938            return None;
8939        };
8940        if scalar_is_null(scalar) {
8941            self.expected(
8942                EXPECTED_SCALAR,
8943                field,
8944                format!("{description} must be a non-null scalar"),
8945            );
8946            return None;
8947        }
8948        Some(Located::new(
8949            scalar_string_from_source(&self.source, scalar),
8950            span_from_position(self.source_id, scalar.byte_range()),
8951        ))
8952    }
8953
8954    fn parse_non_empty_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
8955        let value = self.parse_string(field, description)?;
8956        if value.value().is_empty() {
8957            self.diagnostics.push(
8958                Diagnostic::new(
8959                    BUILD_DOCKERFILE_EXPECTED_NON_EMPTY,
8960                    Severity::Error,
8961                    format!("{description} must be a non-empty scalar"),
8962                )
8963                .with_label(DiagnosticLabel::primary(value.span(), "empty scalar retained")),
8964            );
8965            return None;
8966        }
8967        Some(value)
8968    }
8969
8970    fn parse_build_dockerfile(&mut self, definition: &mut BuildDefinition, field: &ParsedField) -> FieldReference {
8971        if let Some(dockerfile) = self.parse_non_empty_string(field, "build dockerfile") {
8972            definition.set_dockerfile(dockerfile);
8973        }
8974        field.reference()
8975    }
8976
8977    fn set_build_dockerfile_inline(
8978        &mut self,
8979        definition: &mut BuildDefinition,
8980        field: &ParsedField,
8981        dockerfile_inline: &mut Option<FieldReference>,
8982    ) {
8983        *dockerfile_inline = Some(field.reference());
8984        if let Some(value) = self.parse_build_dockerfile_inline(field) {
8985            definition.set_dockerfile_inline(value);
8986        }
8987    }
8988
8989    fn parse_build_dockerfile_inline(&mut self, field: &ParsedField) -> Option<Located<String>> {
8990        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
8991            self.expected(
8992                EXPECTED_SCALAR,
8993                field,
8994                "build dockerfile_inline must be a YAML string scalar",
8995            );
8996            return None;
8997        };
8998        if scalar_type_for_value(scalar) != ScalarType::String {
8999            self.expected(
9000                EXPECTED_SCALAR,
9001                field,
9002                "build dockerfile_inline must be a YAML string scalar",
9003            );
9004            return None;
9005        }
9006        Some(Located::new(
9007            scalar_string_from_source(&self.source, scalar),
9008            span_from_position(self.source_id, scalar.byte_range()),
9009        ))
9010    }
9011
9012    fn set_build_no_cache(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9013        if let Some(no_cache) = self.parse_build_no_cache(field) {
9014            definition.set_no_cache(no_cache);
9015        }
9016    }
9017
9018    fn set_build_sbom(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9019        if let Some(sbom) = self.parse_build_sbom(field) {
9020            definition.set_sbom(sbom);
9021        }
9022    }
9023
9024    fn set_build_provenance(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9025        if let Some(value) = self.parse_build_provenance(field) {
9026            definition.set_provenance(value);
9027        }
9028    }
9029
9030    fn set_build_isolation(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9031        if let Some(isolation) = self.parse_build_isolation(field) {
9032            definition.set_isolation(isolation);
9033        }
9034    }
9035
9036    fn parse_build_isolation(&mut self, field: &ParsedField) -> Option<Located<String>> {
9037        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9038            self.expected(
9039                BUILD_ISOLATION_EXPECTED_STRING,
9040                field,
9041                "build isolation must be a YAML string scalar",
9042            );
9043            return None;
9044        };
9045        if scalar_type_for_value(scalar) != ScalarType::String {
9046            self.expected(
9047                BUILD_ISOLATION_EXPECTED_STRING,
9048                field,
9049                "build isolation must be a YAML string scalar",
9050            );
9051            return None;
9052        }
9053        Some(Located::new(
9054            scalar_string_from_source(&self.source, scalar),
9055            span_from_position(self.source_id, scalar.byte_range()),
9056        ))
9057    }
9058
9059    fn parse_boolean(&mut self, field: &ParsedField, description: &str) -> Option<Located<BooleanValue>> {
9060        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9061            self.expected(EXPECTED_BOOLEAN, field, format!("{description} must be a boolean"));
9062            return None;
9063        };
9064        let span = span_from_position(self.source_id, scalar.byte_range());
9065        let scalar_value = ScalarValue::from_scalar(scalar);
9066        if let Some(value) = scalar_value.to_bool() {
9067            return Some(Located::new(BooleanValue::Literal(value), span));
9068        }
9069        let value = scalar_string_from_source(&self.source, scalar);
9070        if value.contains('$') {
9071            return Some(Located::new(BooleanValue::Expression(value), span));
9072        }
9073        self.diagnostics.push(
9074            Diagnostic::new(
9075                EXPECTED_BOOLEAN,
9076                Severity::Error,
9077                format!("{description} must be a boolean or interpolation expression"),
9078            )
9079            .with_label(DiagnosticLabel::primary(span, "not a boolean expression")),
9080        );
9081        None
9082    }
9083
9084    fn parse_build_no_cache(&mut self, field: &ParsedField) -> Option<Located<BuildNoCache>> {
9085        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9086            self.expected(
9087                BUILD_NO_CACHE_EXPECTED_BOOLEAN_OR_STRING,
9088                field,
9089                "build no_cache must be a YAML boolean or string scalar",
9090            );
9091            return None;
9092        };
9093        let span = span_from_position(self.source_id, scalar.byte_range());
9094        let scalar_value = ScalarValue::from_scalar(scalar);
9095        let value = match scalar_type_for_value(scalar) {
9096            ScalarType::Boolean => BuildNoCache::Boolean(scalar_value.to_bool().unwrap_or(false)),
9097            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9098                BuildNoCache::String(scalar_string_from_source(&self.source, scalar))
9099            }
9100            ScalarType::Null | ScalarType::Integer | ScalarType::Float => {
9101                self.expected(
9102                    BUILD_NO_CACHE_EXPECTED_BOOLEAN_OR_STRING,
9103                    field,
9104                    "build no_cache must be a YAML boolean or string scalar",
9105                );
9106                return None;
9107            }
9108        };
9109        Some(Located::new(value, span))
9110    }
9111    fn parse_build_no_cache_filter(&mut self, field: &ParsedField) -> Option<BuildNoCacheFilter> {
9112        match field.value.as_ref() {
9113            Some(YamlNode::Scalar(s)) if scalar_type_for_value(s) == ScalarType::String => {
9114                Some(BuildNoCacheFilter::Scalar(Located::new(
9115                    scalar_string_from_source(&self.source, s),
9116                    span_from_position(self.source_id, s.byte_range()),
9117                )))
9118            }
9119            Some(YamlNode::Sequence(seq)) => {
9120                let values = self.parse_string_scalar_nodes(
9121                    seq.values(),
9122                    field.span,
9123                    "build no_cache_filter entries must be string scalars",
9124                );
9125                let mut seen = BTreeSet::new();
9126                for value in &values {
9127                    if !seen.insert(value.value().clone()) {
9128                        self.diagnostics.push(
9129                            Diagnostic::new(
9130                                BUILD_NO_CACHE_FILTER_DUPLICATE_ITEM,
9131                                Severity::Warning,
9132                                "build no_cache_filter retains duplicate stage",
9133                            )
9134                            .with_label(DiagnosticLabel::primary(value.span(), "duplicate retained")),
9135                        );
9136                    }
9137                }
9138                Some(BuildNoCacheFilter::List(values))
9139            }
9140            _ => {
9141                self.expected(
9142                    EXPECTED_FIELD_FORM,
9143                    field,
9144                    "build no_cache_filter must be a string scalar or sequence",
9145                );
9146                None
9147            }
9148        }
9149    }
9150    fn set_build_no_cache_filter(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9151        if let Some(value) = self.parse_build_no_cache_filter(field) {
9152            definition.set_no_cache_filter(value);
9153        }
9154    }
9155    fn set_build_privileged(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9156        if let Some(value) = self.parse_boolean(field, "build privileged") {
9157            definition.set_privileged(value);
9158        }
9159    }
9160
9161    fn parse_build_sbom(&mut self, field: &ParsedField) -> Option<Located<BuildSbom>> {
9162        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9163            self.expected(
9164                BUILD_SBOM_EXPECTED_BOOLEAN_OR_STRING,
9165                field,
9166                "build sbom must be a YAML boolean or string scalar",
9167            );
9168            return None;
9169        };
9170        let span = span_from_position(self.source_id, scalar.byte_range());
9171        let scalar_value = ScalarValue::from_scalar(scalar);
9172        let value = match scalar_type_for_value(scalar) {
9173            ScalarType::Boolean => BuildSbom::Boolean(scalar_value.to_bool().unwrap_or(false)),
9174            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9175                BuildSbom::String(scalar_string_from_source(&self.source, scalar))
9176            }
9177            ScalarType::Null | ScalarType::Integer | ScalarType::Float => {
9178                self.expected(
9179                    BUILD_SBOM_EXPECTED_BOOLEAN_OR_STRING,
9180                    field,
9181                    "build sbom must be a YAML boolean or string scalar",
9182                );
9183                return None;
9184            }
9185        };
9186        Some(Located::new(value, span))
9187    }
9188
9189    fn parse_build_provenance(&mut self, field: &ParsedField) -> Option<Located<BuildProvenance>> {
9190        let scalar = field.value.as_ref().and_then(YamlNode::as_scalar)?;
9191        let span = span_from_position(self.source_id, scalar.byte_range());
9192        let value = match scalar_type_for_value(scalar) {
9193            ScalarType::Boolean => {
9194                BuildProvenance::Boolean(ScalarValue::from_scalar(scalar).to_bool().unwrap_or(false))
9195            }
9196            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9197                BuildProvenance::String(scalar_string_from_source(&self.source, scalar))
9198            }
9199            _ => {
9200                self.expected(
9201                    EXPECTED_SCALAR,
9202                    field,
9203                    "build provenance must be a YAML boolean or string scalar",
9204                );
9205                return None;
9206            }
9207        };
9208        Some(Located::new(value, span))
9209    }
9210
9211    fn parse_string_sequence(&mut self, field: &ParsedField, description: &str) -> Vec<Located<String>> {
9212        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
9213            self.expected(EXPECTED_SEQUENCE, field, format!("{description} must be a sequence"));
9214            return Vec::new();
9215        };
9216        self.parse_scalar_nodes(
9217            sequence.values(),
9218            field.span,
9219            format!("{description} entries must be scalars"),
9220        )
9221    }
9222
9223    fn parse_strict_string_sequence(
9224        &mut self,
9225        field: &ParsedField,
9226        description: &str,
9227    ) -> (Vec<Located<String>>, Vec<InvalidServiceStringItem>) {
9228        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
9229            self.expected(EXPECTED_SEQUENCE, field, format!("{description} must be a sequence"));
9230            return (Vec::new(), Vec::new());
9231        };
9232        let mut values = Vec::new();
9233        let mut invalid = Vec::new();
9234        for node in sequence.values() {
9235            let span = match &node {
9236                YamlNode::Scalar(value) => span_from_position(self.source_id, value.byte_range()),
9237                YamlNode::Sequence(value) => span_from_position(self.source_id, value.byte_range()),
9238                YamlNode::Mapping(value) => span_from_position(self.source_id, value.byte_range()),
9239                _ => field.span,
9240            };
9241            let Some(scalar) = node.as_scalar() else {
9242                self.unsupported_sequence_item(
9243                    EXPECTED_SCALAR,
9244                    &node,
9245                    field.span,
9246                    format!("{description} entries must be YAML string scalars"),
9247                );
9248                invalid.push(InvalidServiceStringItem::new(span));
9249                continue;
9250            };
9251            if scalar_type_for_value(scalar) != ScalarType::String {
9252                self.unsupported_sequence_item(
9253                    EXPECTED_SCALAR,
9254                    &node,
9255                    field.span,
9256                    format!("{description} entries must be YAML string scalars"),
9257                );
9258                invalid.push(InvalidServiceStringItem::new(span));
9259                continue;
9260            }
9261            values.push(Located::new(scalar_string_from_source(&self.source, scalar), span));
9262        }
9263        (values, invalid)
9264    }
9265
9266    fn parse_scalar_nodes(
9267        &mut self,
9268        nodes: impl Iterator<Item = YamlNode>,
9269        fallback_span: SourceSpan,
9270        message: impl Into<String>,
9271    ) -> Vec<Located<String>> {
9272        let message = message.into();
9273        let mut values = Vec::new();
9274        for node in nodes {
9275            let YamlNode::Scalar(scalar) = node else {
9276                self.unsupported_sequence_item(EXPECTED_SCALAR, &node, fallback_span, &message);
9277                continue;
9278            };
9279            if scalar_is_null(&scalar) {
9280                self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar), fallback_span, &message);
9281                continue;
9282            }
9283            let span = span_from_position(self.source_id, scalar.byte_range());
9284            values.push(Located::new(scalar_string_from_source(&self.source, &scalar), span));
9285        }
9286        values
9287    }
9288
9289    fn parse_scalar_mapping(&mut self, field: &ParsedField, description: &str) -> Vec<KeyValueEntry> {
9290        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
9291            self.expected(EXPECTED_MAPPING, field, format!("{description} must be a mapping"));
9292            return Vec::new();
9293        };
9294        let mut entries = Vec::new();
9295        let mut seen = BTreeMap::new();
9296        for entry in self.fields(mapping) {
9297            if self.record_duplicate(&mut seen, &entry) {
9298                continue;
9299            }
9300            if let Some(value) = self.parse_compose_scalar(&entry, format!("{description} values must be scalars")) {
9301                entries.push(KeyValueEntry::new(entry.name, value, entry.span));
9302            }
9303        }
9304        entries
9305    }
9306
9307    fn parse_compose_scalar(
9308        &mut self,
9309        field: &ParsedField,
9310        message: impl Into<String>,
9311    ) -> Option<Located<ComposeScalar>> {
9312        let Some(node) = field.value.as_ref() else {
9313            return Some(Located::new(ComposeScalar::Null, field.name.span));
9314        };
9315        let Some(scalar) = node.as_scalar() else {
9316            self.expected(EXPECTED_SCALAR, field, message);
9317            return None;
9318        };
9319        let span = span_from_position(self.source_id, scalar.byte_range());
9320        if scalar_is_null(scalar) {
9321            return Some(Located::new(ComposeScalar::Null, span));
9322        }
9323        let value = ScalarValue::from_scalar(scalar);
9324        let typed = match scalar_type_for_value(scalar) {
9325            ScalarType::Null => ComposeScalar::Null,
9326            ScalarType::Boolean => ComposeScalar::Boolean(value.to_bool().unwrap_or(false)),
9327            ScalarType::Integer | ScalarType::Float => {
9328                ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
9329            }
9330            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9331                ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
9332            }
9333        };
9334        Some(Located::new(typed, span))
9335    }
9336
9337    fn parse_integer_or_string_scalar(
9338        &mut self,
9339        field: &ParsedField,
9340        message: impl Into<String>,
9341    ) -> Option<Located<ComposeScalar>> {
9342        let message = message.into();
9343        let Some(node) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9344            self.expected(EXPECTED_FIELD_FORM, field, message);
9345            return None;
9346        };
9347        let type_ = scalar_type_for_value(node);
9348        if type_ != ScalarType::Integer && type_ != ScalarType::String {
9349            self.expected(EXPECTED_FIELD_FORM, field, message);
9350            return None;
9351        }
9352        self.parse_compose_scalar(field, "integer or string scalar already checked")
9353    }
9354
9355    fn parse_string_or_number_scalar(
9356        &mut self,
9357        field: &ParsedField,
9358        message: impl Into<String>,
9359    ) -> Option<Located<ComposeScalar>> {
9360        let message = message.into();
9361        let Some(node) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9362            self.expected(EXPECTED_FIELD_FORM, field, message);
9363            return None;
9364        };
9365        if !matches!(
9366            scalar_type_for_value(node),
9367            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex | ScalarType::Integer | ScalarType::Float
9368        ) {
9369            self.expected(EXPECTED_FIELD_FORM, field, message);
9370            return None;
9371        }
9372        self.parse_compose_scalar(field, "number or string scalar already checked")
9373    }
9374
9375    fn parse_integer_scalar(
9376        &mut self,
9377        field: &ParsedField,
9378        message: impl Into<String>,
9379    ) -> Option<Located<ComposeScalar>> {
9380        let message = message.into();
9381        let node = field.value.as_ref().and_then(YamlNode::as_scalar)?;
9382        if scalar_type_for_value(node) != ScalarType::Integer {
9383            self.expected(EXPECTED_FIELD_FORM, field, message);
9384            return None;
9385        }
9386        self.parse_compose_scalar(field, "integer scalar already checked")
9387    }
9388
9389    fn parse_labels(&mut self, field: &ParsedField) -> Option<Labels> {
9390        match field.value.as_ref() {
9391            Some(YamlNode::Sequence(sequence)) => {
9392                let span = span_from_position(self.source_id, sequence.byte_range());
9393                let values = self.parse_string_scalar_nodes(
9394                    sequence.values(),
9395                    field.span,
9396                    "label list entries must be string scalars",
9397                );
9398                Some(Labels::List { span, values })
9399            }
9400            Some(YamlNode::Mapping(mapping)) => {
9401                let span = span_from_position(self.source_id, mapping.byte_range());
9402                let entries = self.parse_scalar_mapping(field, "labels");
9403                Some(Labels::Map { span, entries })
9404            }
9405            _ => {
9406                self.expected(EXPECTED_FIELD_FORM, field, "labels must be a sequence or mapping");
9407                None
9408            }
9409        }
9410    }
9411
9412    fn parse_string_scalar_nodes(
9413        &mut self,
9414        nodes: impl Iterator<Item = YamlNode>,
9415        fallback_span: SourceSpan,
9416        message: impl Into<String>,
9417    ) -> Vec<Located<String>> {
9418        let message = message.into();
9419        let mut values = Vec::new();
9420        for node in nodes {
9421            let YamlNode::Scalar(scalar) = node else {
9422                self.unsupported_sequence_item(EXPECTED_SCALAR, &node, fallback_span, &message);
9423                continue;
9424            };
9425            if !matches!(
9426                scalar_type_for_value(&scalar),
9427                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
9428            ) {
9429                self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar), fallback_span, &message);
9430                continue;
9431            }
9432            let span = span_from_position(self.source_id, scalar.byte_range());
9433            values.push(Located::new(scalar_string_from_source(&self.source, &scalar), span));
9434        }
9435        values
9436    }
9437
9438    fn parse_annotations(&mut self, field: &ParsedField) -> Option<Annotations> {
9439        match field.value.as_ref() {
9440            Some(YamlNode::Sequence(sequence)) => Some(self.parse_annotation_list(sequence, field.span)),
9441            Some(YamlNode::Mapping(mapping)) => Some(self.parse_annotation_map(mapping)),
9442            _ => {
9443                self.expected(
9444                    ANNOTATIONS_EXPECTED_FORM,
9445                    field,
9446                    "annotations must be a sequence or mapping",
9447                );
9448                None
9449            }
9450        }
9451    }
9452
9453    fn parse_annotation_list(&mut self, sequence: &yaml_edit::Sequence, fallback: SourceSpan) -> Annotations {
9454        let span = span_from_position(self.source_id, sequence.byte_range());
9455        let mut values = Vec::new();
9456        let mut seen = BTreeSet::new();
9457        for node in sequence.values() {
9458            let YamlNode::Scalar(scalar) = node else {
9459                self.unsupported_sequence_item(
9460                    ANNOTATIONS_EXPECTED_STRING,
9461                    &node,
9462                    fallback,
9463                    "annotation list entries must be string scalars",
9464                );
9465                continue;
9466            };
9467            let item_span = span_from_position(self.source_id, scalar.byte_range());
9468            let scalar_value = ScalarValue::from_scalar(&scalar);
9469            let value = match scalar_type_for_value(&scalar) {
9470                ScalarType::Null => ComposeScalar::Null,
9471                ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
9472                ScalarType::Integer | ScalarType::Float => {
9473                    ComposeScalar::Number(scalar_string_from_source(&self.source, &scalar))
9474                }
9475                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9476                    ComposeScalar::String(scalar_string_from_source(&self.source, &scalar))
9477                }
9478            };
9479            self.validate_annotation_list_scalar(&value, item_span, &mut seen);
9480            values.push(Located::new(value, item_span));
9481        }
9482        Annotations::new(span, AnnotationsForm::List(values))
9483    }
9484
9485    fn validate_annotation_list_scalar(
9486        &mut self,
9487        value: &ComposeScalar,
9488        span: SourceSpan,
9489        seen: &mut BTreeSet<String>,
9490    ) {
9491        let ComposeScalar::String(raw) = value else {
9492            self.diagnostics.push(annotation_diagnostic(
9493                ANNOTATIONS_EXPECTED_STRING,
9494                Severity::Error,
9495                span,
9496                "annotation list entries must be string scalars",
9497                "non-string annotation item retained",
9498            ));
9499            return;
9500        };
9501        let name = raw.split_once('=').map_or(raw.as_str(), |(name, _)| name);
9502        if name.is_empty() {
9503            self.diagnostics.push(annotation_diagnostic(
9504                ANNOTATIONS_EMPTY_NAME,
9505                Severity::Error,
9506                span,
9507                "service annotation name must not be empty",
9508                "empty annotation name",
9509            ));
9510        } else if !seen.insert(name.to_owned()) {
9511            self.diagnostics.push(annotation_diagnostic(
9512                ANNOTATIONS_DUPLICATE_NAME,
9513                Severity::Error,
9514                span,
9515                "service annotation names must be unique",
9516                "duplicate annotation name",
9517            ));
9518        }
9519        if !raw.contains('=') {
9520            self.diagnostics.push(annotation_diagnostic(
9521                ANNOTATIONS_KEY_ONLY,
9522                Severity::Warning,
9523                span,
9524                "key-only service annotation has no explicit value",
9525                "ambiguous key-only annotation",
9526            ));
9527        }
9528    }
9529
9530    fn parse_annotation_map(&mut self, mapping: &Mapping) -> Annotations {
9531        let span = span_from_position(self.source_id, mapping.byte_range());
9532        let mut entries = Vec::new();
9533        let mut seen = BTreeMap::new();
9534        for entry in self.fields(mapping) {
9535            let _duplicate = self.record_duplicate(&mut seen, &entry);
9536            if entry.name.value.is_empty() {
9537                self.diagnostics.push(annotation_diagnostic(
9538                    ANNOTATIONS_EMPTY_NAME,
9539                    Severity::Error,
9540                    entry.name.span,
9541                    "service annotation name must not be empty",
9542                    "empty annotation name",
9543                ));
9544            }
9545            if let Some(value) = self.parse_compose_scalar(
9546                &entry,
9547                "annotation mapping values must be scalar strings, numbers, booleans, or null",
9548            ) {
9549                entries.push(KeyValueEntry::new(entry.name, value, entry.span));
9550            }
9551        }
9552        Annotations::new(span, AnnotationsForm::Map(entries))
9553    }
9554
9555    fn field_is_null(field: &ParsedField) -> bool {
9556        field
9557            .value
9558            .as_ref()
9559            .is_none_or(|node| node.as_scalar().is_some_and(scalar_is_null))
9560    }
9561
9562    fn unsupported_sequence_item(
9563        &mut self,
9564        code: DiagnosticCode,
9565        node: &YamlNode,
9566        fallback_span: SourceSpan,
9567        message: impl Into<String>,
9568    ) {
9569        let span = node_span(self.source_id, node).unwrap_or(fallback_span);
9570        self.diagnostics.push(
9571            Diagnostic::new(code, Severity::Error, message)
9572                .with_label(DiagnosticLabel::primary(span, "unsupported value form")),
9573        );
9574    }
9575
9576    fn fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
9577        let fields = self.raw_fields(mapping);
9578        let mut fields = self.flatten_empty_value_continuations(fields);
9579        for field in &mut fields {
9580            field.value = field.value.take().map(|value| self.resolve_alias(value));
9581        }
9582        fields
9583    }
9584
9585    fn raw_fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
9586        mapping
9587            .entries()
9588            .filter_map(|entry| {
9589                let key = entry.key_node()?;
9590                let Some(scalar) = key.as_scalar() else {
9591                    let span = node_span(self.source_id, &key)
9592                        .unwrap_or_else(|| span_from_position(self.source_id, mapping.byte_range()));
9593                    self.diagnostics.push(
9594                        Diagnostic::new(EXPECTED_SCALAR, Severity::Error, "Compose mapping keys must be scalars")
9595                            .with_label(DiagnosticLabel::primary(span, "non-scalar key")),
9596                    );
9597                    return None;
9598                };
9599                let name_span = span_from_position(self.source_id, scalar.byte_range());
9600                let authored_value = entry.value_node();
9601                let value_span = authored_value
9602                    .as_ref()
9603                    .and_then(|value| node_span(self.source_id, value));
9604                let value = authored_value.map(unwrap_processing_tag);
9605                let span = value_span.map_or(name_span, |value_span| union(name_span, value_span));
9606                Some(ParsedField {
9607                    name: Located::new(scalar_string_from_source(&self.source, scalar), name_span),
9608                    value,
9609                    value_span,
9610                    span,
9611                })
9612            })
9613            .collect()
9614    }
9615
9616    fn resolve_alias(&self, node: YamlNode) -> YamlNode {
9617        let mut node = node;
9618        let mut visited = BTreeSet::new();
9619        for _ in 0..64 {
9620            let YamlNode::Alias(alias) = &node else {
9621                return node;
9622            };
9623            if !visited.insert(alias.name()) {
9624                return node;
9625            }
9626            let Some(target) = self.anchors.resolve(&alias.name()).and_then(|target| {
9627                YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
9628            }) else {
9629                return node;
9630            };
9631            node = target;
9632        }
9633        node
9634    }
9635
9636    fn flatten_empty_value_continuations(&mut self, fields: Vec<ParsedField>) -> Vec<ParsedField> {
9637        let Some(target_column) = fields.first().map(|field| self.source_column(field.name.span.start())) else {
9638            return fields;
9639        };
9640        self.recover_fields(fields, target_column)
9641    }
9642
9643    fn recover_fields(&mut self, fields: Vec<ParsedField>, target_column: usize) -> Vec<ParsedField> {
9644        let mut flattened = Vec::new();
9645        for mut field in fields {
9646            let field_column = self.source_column(field.name.span.start());
9647            let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
9648            let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
9649                !self.is_flow_mapping(mapping)
9650                    && mapping
9651                        .entries()
9652                        .find_map(|entry| {
9653                            let key = entry.key_node()?;
9654                            let scalar = key.as_scalar()?;
9655                            Some(scalar.byte_range().start as usize)
9656                        })
9657                        .is_some_and(|key_start| self.source_column(key_start) <= field_column)
9658            });
9659
9660            if continuation {
9661                field.value = None;
9662                field.value_span = None;
9663                field.span = field.name.span;
9664            }
9665            if field_column == target_column {
9666                flattened.push(field);
9667            }
9668            if let Some(mapping) = nested_mapping.filter(|mapping| !self.is_flow_mapping(mapping)) {
9669                let nested = self.raw_fields(&mapping);
9670                flattened.extend(self.recover_fields(nested, target_column));
9671            }
9672        }
9673        flattened
9674    }
9675
9676    fn is_flow_mapping(&self, mapping: &Mapping) -> bool {
9677        let position = mapping.byte_range();
9678        self.source
9679            .get(position.start as usize..position.end as usize)
9680            .is_some_and(|text| text.trim_start().starts_with('{'))
9681    }
9682
9683    fn record_duplicate(&mut self, seen: &mut BTreeMap<String, SourceSpan>, field: &ParsedField) -> bool {
9684        if let Some(first) = seen.get(field.name.value()) {
9685            self.diagnostics.push(
9686                Diagnostic::new(
9687                    DUPLICATE_FIELD,
9688                    Severity::Error,
9689                    "Compose mapping fields must be unique",
9690                )
9691                .with_label(DiagnosticLabel::primary(field.name.span, "duplicate field"))
9692                .with_label(DiagnosticLabel::secondary(*first, "first field")),
9693            );
9694            true
9695        } else {
9696            seen.insert(field.name.value.clone(), field.name.span);
9697            false
9698        }
9699    }
9700
9701    fn expected(&mut self, code: DiagnosticCode, field: &ParsedField, message: impl Into<String>) {
9702        self.diagnostics.push(
9703            Diagnostic::new(code, Severity::Error, message)
9704                .with_label(DiagnosticLabel::primary(field.span, "unexpected value form")),
9705        );
9706    }
9707
9708    fn missing(&mut self, code: DiagnosticCode, span: SourceSpan, message: impl Into<String>) {
9709        self.diagnostics.push(
9710            Diagnostic::new(code, Severity::Error, message)
9711                .with_label(DiagnosticLabel::primary(span, "incomplete long syntax")),
9712        );
9713    }
9714}
9715
9716fn unwrap_processing_tag(node: YamlNode) -> YamlNode {
9717    let YamlNode::TaggedNode(tagged) = &node else {
9718        return node;
9719    };
9720    if !matches!(tagged.tag().as_deref(), Some("!reset" | "!override")) {
9721        return node;
9722    }
9723    tagged
9724        .as_node()
9725        .and_then(|syntax| syntax.children().find_map(YamlNode::from_syntax))
9726        .unwrap_or(node)
9727}
9728
9729fn command_is_non_empty(command: &Command) -> bool {
9730    match command {
9731        Command::Null(_) => false,
9732        Command::String(value) => !value.value().is_empty(),
9733        Command::List { values, .. } => !values.is_empty(),
9734    }
9735}
9736
9737fn gpu_has_capabilities(device: &GpuDevice) -> bool {
9738    device.capabilities().iter().any(|value| !value.value().is_empty())
9739}
9740
9741#[derive(Debug, Clone)]
9742enum ParsedGrant {
9743    Short(Located<String>),
9744    Long(Box<LongGrant>),
9745}
9746
9747#[derive(Debug, Clone)]
9748struct ParsedField {
9749    name: Located<String>,
9750    value: Option<YamlNode>,
9751    value_span: Option<SourceSpan>,
9752    span: SourceSpan,
9753}
9754
9755impl ParsedField {
9756    fn reference(&self) -> FieldReference {
9757        FieldReference {
9758            name: self.name.clone(),
9759            span: self.span,
9760            value_span: self.value_span,
9761        }
9762    }
9763}
9764
9765fn scalar_uses_block_style(source: &str, scalar: &Scalar) -> bool {
9766    let start = scalar.byte_range().start as usize;
9767    source[start..].trim_start().starts_with(['|', '>'])
9768        || source[..start]
9769            .lines()
9770            .rev()
9771            .find(|line| !line.trim().is_empty())
9772            .is_some_and(|header| header.contains(": |") || header.contains(": >"))
9773}
9774
9775fn node_span(source_id: SourceId, node: &YamlNode) -> Option<SourceSpan> {
9776    let position = match node {
9777        YamlNode::Scalar(value) => value.byte_range(),
9778        YamlNode::Mapping(value) => value.byte_range(),
9779        YamlNode::Sequence(value) => value.byte_range(),
9780        YamlNode::Alias(_) | YamlNode::TaggedNode(_) => {
9781            let range = node.as_node()?.text_range();
9782            return Some(SourceSpan::from_valid_offsets(
9783                source_id,
9784                u32::from(range.start()) as usize,
9785                u32::from(range.end()) as usize,
9786            ));
9787        }
9788    };
9789    Some(span_from_position(source_id, position))
9790}
9791
9792fn span_from_position(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
9793    SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
9794}
9795
9796fn scalar_is_null(scalar: &Scalar) -> bool {
9797    scalar_type_for_value(scalar) == ScalarType::Null
9798}
9799
9800fn scalar_type_for_value(scalar: &Scalar) -> ScalarType {
9801    if scalar.byte_range().is_empty() {
9802        ScalarType::Null
9803    } else {
9804        ScalarValue::from_scalar(scalar).scalar_type()
9805    }
9806}
9807
9808fn union(left: SourceSpan, right: SourceSpan) -> SourceSpan {
9809    SourceSpan::from_valid_offsets(
9810        left.source_id(),
9811        left.start().min(right.start()),
9812        left.end().max(right.end()),
9813    )
9814}