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