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