Skip to main content

compose_lens/
project.rs

1//! Source-aware native values from a merged and optionally profile-selected Compose project.
2
3use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::merge::{
5    EntrySyntax, MergeProvenance, MergedEntry, MergedProject, MergedScalarKind, MergedValue, MergedValueKind,
6};
7use crate::model::{
8    BindOptions, BooleanValue, Command, ComposeScalar, ConfigDefinition, DependencyCondition, HealthcheckDuration,
9    HealthcheckRetries, HealthcheckTest, HealthcheckTestKind, HostAddress, ImageReference, Ipam, IpamConfig,
10    KeyValueEntry, Labels, Located, LongPort, LongVolumeMount, MountType, NetworkDefinition, Port, SecretDefinition,
11    SelinuxRelabel, ServiceNetwork, ServiceNetworks, ShortExtraHost, ShortPort, ShortVolumeMount, UserNamespaceMode,
12    UserSpec, VolumeDefinition, VolumeMount,
13};
14use crate::profiles::ProfileSelection;
15use crate::resolution::{SELECTION_PROJECT_MISMATCH, service_in_scope};
16use crate::source::{SourceId, SourceSpan};
17use std::fmt;
18use std::path::{Path, PathBuf};
19
20/// A value in the merged project has an unexpected mapping, sequence, scalar, or null form.
21pub const PROJECT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.project.expected-form");
22
23/// A required field is absent from a merged native value.
24pub const PROJECT_MISSING_FIELD: DiagnosticCode = DiagnosticCode::new("compose.project.missing-field");
25
26/// A scalar cannot be represented by the requested native value type.
27pub const PROJECT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.project.invalid-value");
28
29/// A typed value together with every source span that contributed to it during merging.
30#[derive(Clone, PartialEq, Eq)]
31pub struct ProjectValue<T> {
32    value: T,
33    provenance: MergeProvenance,
34    sensitive: bool,
35}
36
37impl<T: fmt::Debug> fmt::Debug for ProjectValue<T> {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        let mut debug = formatter.debug_struct("ProjectValue");
40        if self.sensitive {
41            debug.field("value", &"<redacted>");
42        } else {
43            debug.field("value", &self.value);
44        }
45        debug
46            .field("provenance", &self.provenance)
47            .field("sensitive", &self.sensitive)
48            .finish()
49    }
50}
51
52impl<T> ProjectValue<T> {
53    fn new(value: T, source: &MergedValue) -> Self {
54        Self {
55            value,
56            provenance: source.provenance().clone(),
57            sensitive: source.is_sensitive(),
58        }
59    }
60
61    /// Returns the typed effective value.
62    #[must_use]
63    pub const fn value(&self) -> &T {
64        &self.value
65    }
66
67    /// Returns the merge operation and contributing spans in processing order.
68    #[must_use]
69    pub const fn provenance(&self) -> &MergeProvenance {
70        &self.provenance
71    }
72
73    /// Returns the most recent source contributing to this value.
74    #[must_use]
75    pub fn effective_source(&self) -> Option<SourceSpan> {
76        self.provenance.effective_source()
77    }
78
79    /// Reports whether this value contains sensitive interpolation output.
80    #[must_use]
81    pub const fn is_sensitive(&self) -> bool {
82        self.sensitive
83    }
84
85    /// Removes the provenance wrapper and returns the typed value.
86    #[must_use]
87    pub fn into_value(self) -> T {
88        self.value
89    }
90}
91
92/// A merged mapping key and every location at which that key was authored.
93#[derive(Clone, PartialEq, Eq)]
94pub struct ProjectKey {
95    value: String,
96    sources: Vec<SourceSpan>,
97    sensitive: bool,
98}
99
100impl fmt::Debug for ProjectKey {
101    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102        formatter
103            .debug_struct("ProjectKey")
104            .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
105            .field("sources", &self.sources)
106            .field("sensitive", &self.sensitive)
107            .finish()
108    }
109}
110
111impl ProjectKey {
112    fn from_entry(entry: &MergedEntry) -> Self {
113        Self {
114            value: entry.key().to_owned(),
115            sources: entry.key_sources().to_vec(),
116            sensitive: entry.is_key_sensitive(),
117        }
118    }
119
120    fn from_value(value: String, source: &MergedValue) -> Self {
121        Self {
122            value,
123            sources: source.provenance().sources().to_vec(),
124            sensitive: source.is_sensitive(),
125        }
126    }
127
128    /// Returns the semantic key text.
129    #[must_use]
130    pub fn value(&self) -> &str {
131        &self.value
132    }
133
134    /// Returns authored key locations in merge order.
135    #[must_use]
136    pub fn sources(&self) -> &[SourceSpan] {
137        &self.sources
138    }
139
140    /// Returns the effective key location.
141    #[must_use]
142    pub fn effective_source(&self) -> Option<SourceSpan> {
143        self.sources.last().copied()
144    }
145
146    /// Reports whether interpolation inserted sensitive content into this semantic key.
147    #[must_use]
148    pub const fn is_sensitive(&self) -> bool {
149        self.sensitive
150    }
151}
152
153/// One effective service dependency with source-aware long-form options.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct ProjectServiceDependency {
156    service: ProjectKey,
157    condition: Option<ProjectValue<DependencyCondition>>,
158    restart: Option<ProjectValue<BooleanValue>>,
159    required: Option<ProjectValue<BooleanValue>>,
160    unmodeled_fields: Vec<ProjectFieldReference>,
161}
162
163impl ProjectServiceDependency {
164    /// Returns the referenced service name and all contributing name locations.
165    #[must_use]
166    pub const fn service(&self) -> &ProjectKey {
167        &self.service
168    }
169
170    /// Returns the explicitly authored readiness condition.
171    #[must_use]
172    pub const fn condition(&self) -> Option<&ProjectValue<DependencyCondition>> {
173        self.condition.as_ref()
174    }
175
176    /// Returns whether Compose-controlled dependency updates restart this service.
177    #[must_use]
178    pub const fn restart(&self) -> Option<&ProjectValue<BooleanValue>> {
179        self.restart.as_ref()
180    }
181
182    /// Returns whether the dependency is required.
183    #[must_use]
184    pub const fn required(&self) -> Option<&ProjectValue<BooleanValue>> {
185        self.required.as_ref()
186    }
187
188    /// Returns retained long-form fields outside the typed dependency boundary.
189    #[must_use]
190    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
191        &self.unmodeled_fields
192    }
193}
194
195/// Effective service dependencies with the short or long Compose form retained.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum ProjectDependsOn {
198    /// A sequence of service names using Compose defaults.
199    Short(Vec<ProjectValue<ProjectServiceDependency>>),
200    /// A mapping of service names to dependency options.
201    Long(Vec<ProjectValue<ProjectServiceDependency>>),
202}
203
204impl ProjectDependsOn {
205    /// Returns dependencies in effective merge order.
206    #[must_use]
207    pub fn services(&self) -> &[ProjectValue<ProjectServiceDependency>] {
208        match self {
209            Self::Short(services) | Self::Long(services) => services,
210        }
211    }
212
213    /// Reports whether the effective field uses long mapping syntax.
214    #[must_use]
215    pub const fn is_long(&self) -> bool {
216        matches!(self, Self::Long(_))
217    }
218}
219
220/// A field retained by the merged tree but outside the first native project-view boundary.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct ProjectFieldReference {
223    path: Vec<String>,
224    key: ProjectKey,
225    provenance: MergeProvenance,
226    extension: bool,
227    sensitive: bool,
228}
229
230impl ProjectFieldReference {
231    /// Returns the semantic path including the field name.
232    #[must_use]
233    pub fn path(&self) -> &[String] {
234        &self.path
235    }
236
237    /// Returns the retained mapping key and all of its source locations.
238    #[must_use]
239    pub const fn key(&self) -> &ProjectKey {
240        &self.key
241    }
242
243    /// Returns the field value's complete merge provenance.
244    #[must_use]
245    pub const fn provenance(&self) -> &MergeProvenance {
246        &self.provenance
247    }
248
249    /// Reports whether the field name starts with `x-`.
250    #[must_use]
251    pub const fn is_extension(&self) -> bool {
252        self.extension
253    }
254
255    /// Reports whether the retained value contains sensitive interpolation output.
256    #[must_use]
257    pub const fn is_sensitive(&self) -> bool {
258        self.sensitive
259    }
260}
261
262/// One effective environment variable after field-specific multi-file merging.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct ProjectEnvironmentEntry {
265    name: ProjectKey,
266    value: ProjectValue<ComposeScalar>,
267    syntax: EntrySyntax,
268}
269
270impl ProjectEnvironmentEntry {
271    /// Returns the variable name and its contributing key spans.
272    #[must_use]
273    pub const fn name(&self) -> &ProjectKey {
274        &self.name
275    }
276
277    /// Returns the effective scalar, including a distinct host-environment null value.
278    #[must_use]
279    pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
280        &self.value
281    }
282
283    /// Returns the most recent mapping or list syntax contributing this entry.
284    #[must_use]
285    pub const fn syntax(&self) -> EntrySyntax {
286        self.syntax
287    }
288}
289
290/// A normalized-by-key environment view that retains each entry's authored syntax form.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub struct ProjectEnvironment {
293    entries: Vec<ProjectEnvironmentEntry>,
294}
295
296/// One effective service metadata label after field-specific multi-file merging.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct ProjectLabelEntry {
299    name: ProjectKey,
300    value: ProjectValue<ComposeScalar>,
301    syntax: EntrySyntax,
302}
303
304impl ProjectLabelEntry {
305    /// Returns the label name and its contributing key spans.
306    #[must_use]
307    pub const fn name(&self) -> &ProjectKey {
308        &self.name
309    }
310
311    /// Returns the effective label scalar.
312    ///
313    /// A key-only list entry has an explicit empty-string value while retaining
314    /// [`EntrySyntax::ListKeyOnly`] as its authored form.
315    #[must_use]
316    pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
317        &self.value
318    }
319
320    /// Returns the most recent mapping or list syntax contributing this entry.
321    #[must_use]
322    pub const fn syntax(&self) -> EntrySyntax {
323        self.syntax
324    }
325}
326
327/// A normalized-by-key service-label view retaining each entry's effective syntax.
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct ProjectLabels {
330    entries: Vec<ProjectLabelEntry>,
331}
332
333impl ProjectLabels {
334    /// Returns labels in effective merge order.
335    #[must_use]
336    pub fn entries(&self) -> &[ProjectLabelEntry] {
337        &self.entries
338    }
339
340    /// Finds an effective label by name.
341    #[must_use]
342    pub fn get(&self, name: &str) -> Option<&ProjectLabelEntry> {
343        self.entries.iter().find(|entry| entry.name.value == name)
344    }
345}
346
347/// One effective hostname-to-address mapping after field-specific project merging.
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct ProjectExtraHost {
350    hostname: ProjectKey,
351    address: ProjectValue<HostAddress>,
352    syntax: EntrySyntax,
353}
354
355impl ProjectExtraHost {
356    /// Returns the hostname and every contributing source location.
357    #[must_use]
358    pub const fn hostname(&self) -> &ProjectKey {
359        &self.hostname
360    }
361
362    /// Returns the raw-preserving IP address or implementation token.
363    #[must_use]
364    pub const fn address(&self) -> &ProjectValue<HostAddress> {
365        &self.address
366    }
367
368    /// Returns the most recent mapping or list syntax contributing this entry.
369    #[must_use]
370    pub const fn syntax(&self) -> EntrySyntax {
371        self.syntax
372    }
373}
374
375/// Ordered effective `extra_hosts` entries with field and item provenance.
376#[derive(Debug, Clone, PartialEq, Eq)]
377pub struct ProjectExtraHosts {
378    entries: Vec<ProjectExtraHost>,
379}
380
381impl ProjectExtraHosts {
382    /// Returns host mappings in effective merge order.
383    #[must_use]
384    pub fn entries(&self) -> &[ProjectExtraHost] {
385        &self.entries
386    }
387}
388
389impl ProjectEnvironment {
390    /// Returns environment variables in effective merge order.
391    #[must_use]
392    pub fn entries(&self) -> &[ProjectEnvironmentEntry] {
393        &self.entries
394    }
395
396    /// Finds an effective environment variable by name.
397    #[must_use]
398    pub fn get(&self, name: &str) -> Option<&ProjectEnvironmentEntry> {
399        self.entries.iter().find(|entry| entry.name.value == name)
400    }
401}
402
403/// One effective service health check with field-level merge provenance.
404#[derive(Debug, Clone, PartialEq, Eq)]
405pub struct ProjectHealthcheck {
406    test: Option<ProjectValue<HealthcheckTest>>,
407    interval: Option<ProjectValue<HealthcheckDuration>>,
408    timeout: Option<ProjectValue<HealthcheckDuration>>,
409    retries: Option<ProjectValue<HealthcheckRetries>>,
410    start_period: Option<ProjectValue<HealthcheckDuration>>,
411    start_interval: Option<ProjectValue<HealthcheckDuration>>,
412    disable: Option<ProjectValue<BooleanValue>>,
413    unmodeled_fields: Vec<ProjectFieldReference>,
414}
415
416/// Effective long-form service config or secret grant with field-level merge provenance.
417#[derive(Debug, Clone, PartialEq, Eq)]
418pub struct ProjectLongGrant {
419    source: Option<ProjectValue<String>>,
420    target: Option<ProjectValue<String>>,
421    uid: Option<ProjectValue<String>>,
422    gid: Option<ProjectValue<String>>,
423    mode: Option<ProjectValue<String>>,
424    unmodeled_fields: Vec<ProjectFieldReference>,
425}
426
427impl ProjectLongGrant {
428    /// Returns the referenced top-level resource name.
429    #[must_use]
430    pub const fn source(&self) -> Option<&ProjectValue<String>> {
431        self.source.as_ref()
432    }
433
434    /// Returns the requested container path or name.
435    #[must_use]
436    pub const fn target(&self) -> Option<&ProjectValue<String>> {
437        self.target.as_ref()
438    }
439
440    /// Returns the requested container user-ID spelling.
441    #[must_use]
442    pub const fn uid(&self) -> Option<&ProjectValue<String>> {
443        self.uid.as_ref()
444    }
445
446    /// Returns the requested container group-ID spelling.
447    #[must_use]
448    pub const fn gid(&self) -> Option<&ProjectValue<String>> {
449        self.gid.as_ref()
450    }
451
452    /// Returns the requested permission-mode spelling.
453    #[must_use]
454    pub const fn mode(&self) -> Option<&ProjectValue<String>> {
455        self.mode.as_ref()
456    }
457
458    /// Returns retained long-form fields outside the typed project-view boundary.
459    #[must_use]
460    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
461        &self.unmodeled_fields
462    }
463}
464
465/// One effective service config or secret grant with its Compose syntax form retained.
466#[derive(Debug, Clone, PartialEq, Eq)]
467pub enum ProjectGrant {
468    /// Resource-name short syntax.
469    Short(String),
470    /// Mapping-based long syntax.
471    Long(Box<ProjectLongGrant>),
472}
473
474impl ProjectHealthcheck {
475    /// Returns the effective health command without collapsing scalar and list forms.
476    #[must_use]
477    pub const fn test(&self) -> Option<&ProjectValue<HealthcheckTest>> {
478        self.test.as_ref()
479    }
480
481    /// Returns the effective regular-check interval.
482    #[must_use]
483    pub const fn interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
484        self.interval.as_ref()
485    }
486
487    /// Returns the effective per-check timeout.
488    #[must_use]
489    pub const fn timeout(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
490        self.timeout.as_ref()
491    }
492
493    /// Returns the effective unhealthy retry count.
494    #[must_use]
495    pub const fn retries(&self) -> Option<&ProjectValue<HealthcheckRetries>> {
496        self.retries.as_ref()
497    }
498
499    /// Returns the effective startup grace period.
500    #[must_use]
501    pub const fn start_period(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
502        self.start_period.as_ref()
503    }
504
505    /// Returns the effective interval used during the startup grace period.
506    #[must_use]
507    pub const fn start_interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
508        self.start_interval.as_ref()
509    }
510
511    /// Returns whether the image health check is explicitly disabled.
512    #[must_use]
513    pub const fn disable(&self) -> Option<&ProjectValue<BooleanValue>> {
514        self.disable.as_ref()
515    }
516
517    /// Reports whether the effective definition explicitly disables health checks.
518    #[must_use]
519    pub fn is_disabled(&self) -> bool {
520        matches!(
521            self.disable.as_ref().map(ProjectValue::value),
522            Some(BooleanValue::Literal(true))
523        ) || matches!(
524            self.test.as_ref().and_then(|test| test.value().kind()),
525            Some(HealthcheckTestKind::None)
526        )
527    }
528
529    /// Returns retained health-check fields outside the typed project-view boundary.
530    #[must_use]
531    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
532        &self.unmodeled_fields
533    }
534}
535
536/// One selected service with the native fields needed by the first conversion boundary.
537#[derive(Debug, Clone, PartialEq, Eq)]
538pub struct ProjectService {
539    name: ProjectKey,
540    provenance: MergeProvenance,
541    image: Option<ProjectValue<ImageReference>>,
542    command: Option<ProjectValue<Command>>,
543    environment: Option<ProjectValue<ProjectEnvironment>>,
544    labels: Option<ProjectValue<ProjectLabels>>,
545    extra_hosts: Option<ProjectValue<ProjectExtraHosts>>,
546    user: Option<ProjectValue<UserSpec>>,
547    userns_mode: Option<ProjectValue<UserNamespaceMode>>,
548    group_add: Option<ProjectValue<Vec<ProjectValue<String>>>>,
549    working_dir: Option<ProjectValue<String>>,
550    read_only: Option<ProjectValue<BooleanValue>>,
551    healthcheck: Option<ProjectValue<ProjectHealthcheck>>,
552    depends_on: Option<ProjectValue<ProjectDependsOn>>,
553    ports: Option<ProjectValue<Vec<ProjectValue<Port>>>>,
554    volumes: Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>>,
555    configs: Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>,
556    secrets: Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>,
557    networks: Option<ProjectValue<ServiceNetworks>>,
558    profiles: Option<ProjectValue<Vec<ProjectValue<String>>>>,
559    unmodeled_fields: Vec<ProjectFieldReference>,
560}
561
562impl ProjectService {
563    /// Returns the service name and all contributing key spans.
564    #[must_use]
565    pub const fn name(&self) -> &ProjectKey {
566        &self.name
567    }
568
569    /// Returns provenance for the complete effective service mapping.
570    #[must_use]
571    pub const fn provenance(&self) -> &MergeProvenance {
572        &self.provenance
573    }
574
575    /// Returns the effective image reference.
576    #[must_use]
577    pub const fn image(&self) -> Option<&ProjectValue<ImageReference>> {
578        self.image.as_ref()
579    }
580
581    /// Returns the effective command without normalizing scalar and list forms.
582    #[must_use]
583    pub const fn command(&self) -> Option<&ProjectValue<Command>> {
584        self.command.as_ref()
585    }
586
587    /// Returns environment entries normalized by key with per-entry syntax retained.
588    #[must_use]
589    pub const fn environment(&self) -> Option<&ProjectValue<ProjectEnvironment>> {
590        self.environment.as_ref()
591    }
592
593    /// Returns effective service labels normalized by key with entry syntax retained.
594    #[must_use]
595    pub const fn labels(&self) -> Option<&ProjectValue<ProjectLabels>> {
596        self.labels.as_ref()
597    }
598
599    /// Returns effective service host mappings with per-entry provenance and syntax.
600    #[must_use]
601    pub const fn extra_hosts(&self) -> Option<&ProjectValue<ProjectExtraHosts>> {
602        self.extra_hosts.as_ref()
603    }
604
605    /// Returns the effective container user and optional group spelling.
606    #[must_use]
607    pub const fn user(&self) -> Option<&ProjectValue<UserSpec>> {
608        self.user.as_ref()
609    }
610
611    /// Returns the effective user-namespace mode.
612    #[must_use]
613    pub const fn userns_mode(&self) -> Option<&ProjectValue<UserNamespaceMode>> {
614        self.userns_mode.as_ref()
615    }
616
617    /// Returns supplementary groups in effective merge order.
618    #[must_use]
619    pub const fn group_add(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
620        self.group_add.as_ref()
621    }
622
623    /// Returns the effective container working-directory override.
624    #[must_use]
625    pub const fn working_dir(&self) -> Option<&ProjectValue<String>> {
626        self.working_dir.as_ref()
627    }
628
629    /// Returns the effective read-only root-filesystem choice.
630    #[must_use]
631    pub const fn read_only(&self) -> Option<&ProjectValue<BooleanValue>> {
632        self.read_only.as_ref()
633    }
634
635    /// Returns the effective health check with per-field merge provenance.
636    #[must_use]
637    pub const fn healthcheck(&self) -> Option<&ProjectValue<ProjectHealthcheck>> {
638        self.healthcheck.as_ref()
639    }
640
641    /// Returns effective service dependencies with authored form and field-level provenance.
642    #[must_use]
643    pub const fn depends_on(&self) -> Option<&ProjectValue<ProjectDependsOn>> {
644        self.depends_on.as_ref()
645    }
646
647    /// Returns the effective port collection and per-item provenance.
648    #[must_use]
649    pub const fn ports(&self) -> Option<&ProjectValue<Vec<ProjectValue<Port>>>> {
650        self.ports.as_ref()
651    }
652
653    /// Returns the effective volume-mount collection and per-item provenance.
654    #[must_use]
655    pub const fn volumes(&self) -> Option<&ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
656        self.volumes.as_ref()
657    }
658
659    /// Returns effective service config grants with syntax and field-level provenance retained.
660    #[must_use]
661    pub const fn configs(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
662        self.configs.as_ref()
663    }
664
665    /// Returns effective service secret grants with syntax and field-level provenance retained.
666    #[must_use]
667    pub const fn secrets(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
668        self.secrets.as_ref()
669    }
670
671    /// Returns effective network attachments with short and long forms retained.
672    #[must_use]
673    pub const fn networks(&self) -> Option<&ProjectValue<ServiceNetworks>> {
674        self.networks.as_ref()
675    }
676
677    /// Returns effective profile names and their individual provenance.
678    #[must_use]
679    pub const fn profiles(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
680        self.profiles.as_ref()
681    }
682
683    /// Returns fields retained outside this initial native project-view boundary.
684    #[must_use]
685    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
686        &self.unmodeled_fields
687    }
688}
689
690/// One named top-level resource with key and definition provenance kept separately.
691#[derive(Debug, Clone, PartialEq, Eq)]
692pub struct ProjectResource<T> {
693    name: ProjectKey,
694    definition: ProjectValue<T>,
695}
696
697impl<T> ProjectResource<T> {
698    /// Returns the model name and all authored key locations.
699    #[must_use]
700    pub const fn name(&self) -> &ProjectKey {
701        &self.name
702    }
703
704    /// Returns the native effective definition and its merge provenance.
705    #[must_use]
706    pub const fn definition(&self) -> &ProjectValue<T> {
707        &self.definition
708    }
709}
710
711/// The native consumer view of one merged and optionally profile-selected Compose project.
712#[derive(Debug, Clone, PartialEq, Eq)]
713pub struct ProjectView {
714    source_ids: Vec<SourceId>,
715    base_directory: PathBuf,
716    provenance: MergeProvenance,
717    name: Option<ProjectValue<String>>,
718    services: Vec<ProjectService>,
719    networks: Vec<ProjectResource<NetworkDefinition>>,
720    volumes: Vec<ProjectResource<VolumeDefinition>>,
721    configs: Vec<ProjectResource<ConfigDefinition>>,
722    secrets: Vec<ProjectResource<SecretDefinition>>,
723    unmodeled_fields: Vec<ProjectFieldReference>,
724}
725
726impl ProjectView {
727    /// Returns source documents in merge order.
728    #[must_use]
729    pub fn source_ids(&self) -> &[SourceId] {
730        &self.source_ids
731    }
732
733    /// Returns the project directory inherited from the first loaded document.
734    #[must_use]
735    pub fn base_directory(&self) -> &Path {
736        &self.base_directory
737    }
738
739    /// Returns provenance for the complete merged root.
740    #[must_use]
741    pub const fn provenance(&self) -> &MergeProvenance {
742        &self.provenance
743    }
744
745    /// Returns the effective explicit project name.
746    #[must_use]
747    pub const fn name(&self) -> Option<&ProjectValue<String>> {
748        self.name.as_ref()
749    }
750
751    /// Returns profile-active services in merged order.
752    #[must_use]
753    pub fn services(&self) -> &[ProjectService] {
754        &self.services
755    }
756
757    /// Finds one profile-active service.
758    #[must_use]
759    pub fn service(&self, name: &str) -> Option<&ProjectService> {
760        self.services.iter().find(|service| service.name.value == name)
761    }
762
763    /// Returns effective top-level network definitions.
764    #[must_use]
765    pub fn networks(&self) -> &[ProjectResource<NetworkDefinition>] {
766        &self.networks
767    }
768
769    /// Returns effective top-level volume definitions.
770    #[must_use]
771    pub fn volumes(&self) -> &[ProjectResource<VolumeDefinition>] {
772        &self.volumes
773    }
774
775    /// Returns effective top-level config definitions.
776    #[must_use]
777    pub fn configs(&self) -> &[ProjectResource<ConfigDefinition>] {
778        &self.configs
779    }
780
781    /// Returns effective top-level secret definitions.
782    #[must_use]
783    pub fn secrets(&self) -> &[ProjectResource<SecretDefinition>] {
784        &self.secrets
785    }
786
787    /// Returns root fields retained outside this initial native project-view boundary.
788    #[must_use]
789    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
790        &self.unmodeled_fields
791    }
792}
793
794/// Recoverable result of building a typed merged project view.
795#[derive(Debug, Clone, PartialEq, Eq)]
796pub struct ProjectViewResult {
797    view: Option<ProjectView>,
798    diagnostics: Vec<Diagnostic>,
799}
800
801impl ProjectViewResult {
802    /// Returns the typed view when the profile selection belongs to the project.
803    #[must_use]
804    pub const fn view(&self) -> Option<&ProjectView> {
805        self.view.as_ref()
806    }
807
808    /// Returns project-view diagnostics in traversal order.
809    #[must_use]
810    pub fn diagnostics(&self) -> &[Diagnostic] {
811        &self.diagnostics
812    }
813
814    /// Reports whether a view exists and contains no error diagnostics.
815    #[must_use]
816    pub fn is_valid(&self) -> bool {
817        self.view.is_some()
818            && self
819                .diagnostics
820                .iter()
821                .all(|diagnostic| diagnostic.severity() != Severity::Error)
822    }
823
824    /// Separates the view and diagnostics.
825    #[must_use]
826    pub fn into_parts(self) -> (Option<ProjectView>, Vec<Diagnostic>) {
827        (self.view, self.diagnostics)
828    }
829}
830
831/// Builds native values directly from a merged project without canonical rendering or reparsing.
832///
833/// A matching selection filters inactive services. Omitting it includes every service. The
834/// operation performs no file, environment, provider, or runtime access.
835#[must_use]
836pub fn build_project_view(project: &MergedProject, selection: Option<&ProfileSelection>) -> ProjectViewResult {
837    if selection.is_some_and(|selection| !selection.belongs_to(project)) {
838        return ProjectViewResult {
839            view: None,
840            diagnostics: vec![Diagnostic::new(
841                SELECTION_PROJECT_MISMATCH,
842                Severity::Error,
843                "profile selection does not belong to the merged project",
844            )],
845        };
846    }
847
848    Builder::new(project, selection).build()
849}
850
851struct Builder<'a> {
852    project: &'a MergedProject,
853    selection: Option<&'a ProfileSelection>,
854    diagnostics: Vec<Diagnostic>,
855    root_unmodeled: Vec<ProjectFieldReference>,
856    pending_unmodeled: Vec<ProjectFieldReference>,
857}
858
859impl<'a> Builder<'a> {
860    const fn new(project: &'a MergedProject, selection: Option<&'a ProfileSelection>) -> Self {
861        Self {
862            project,
863            selection,
864            diagnostics: Vec::new(),
865            root_unmodeled: Vec::new(),
866            pending_unmodeled: Vec::new(),
867        }
868    }
869
870    fn build(mut self) -> ProjectViewResult {
871        let root = self.project.root();
872        let entries = root.as_mapping().unwrap_or_default();
873        let mut name = None;
874        let mut services = Vec::new();
875        let mut networks = Vec::new();
876        let mut volumes = Vec::new();
877        let mut configs = Vec::new();
878        let mut secrets = Vec::new();
879
880        for entry in entries {
881            match entry.key() {
882                "name" => name = self.project_string(entry.value(), "project name"),
883                "services" => services = self.services(entry.value()),
884                "networks" => networks = self.network_definitions(entry.value()),
885                "volumes" => volumes = self.volume_definitions(entry.value()),
886                "configs" => configs = self.config_definitions(entry.value()),
887                "secrets" => secrets = self.secret_definitions(entry.value()),
888                _ => self.record_root_unmodeled(&[], entry),
889            }
890        }
891
892        ProjectViewResult {
893            view: Some(ProjectView {
894                source_ids: self.project.source_ids().to_vec(),
895                base_directory: self.project.base_directory().to_path_buf(),
896                provenance: root.provenance().clone(),
897                name,
898                services,
899                networks,
900                volumes,
901                configs,
902                secrets,
903                unmodeled_fields: self.root_unmodeled,
904            }),
905            diagnostics: self.diagnostics,
906        }
907    }
908
909    fn services(&mut self, value: &MergedValue) -> Vec<ProjectService> {
910        let Some(entries) = self.mapping(value, "services must be a mapping") else {
911            return Vec::new();
912        };
913        let selection = self.selection;
914        let mut services = Vec::new();
915        for entry in entries {
916            if service_in_scope(selection, entry.key()) {
917                services.extend(self.service(entry));
918            }
919        }
920        services
921    }
922
923    fn service(&mut self, entry: &MergedEntry) -> Option<ProjectService> {
924        let pending_start = self.pending_unmodeled.len();
925        let value = entry.value();
926        let fields = self.mapping(value, "service definition must be a mapping")?;
927        let mut service = ProjectService {
928            name: ProjectKey::from_entry(entry),
929            provenance: value.provenance().clone(),
930            image: None,
931            command: None,
932            environment: None,
933            labels: None,
934            extra_hosts: None,
935            user: None,
936            userns_mode: None,
937            group_add: None,
938            working_dir: None,
939            read_only: None,
940            healthcheck: None,
941            depends_on: None,
942            ports: None,
943            volumes: None,
944            configs: None,
945            secrets: None,
946            networks: None,
947            profiles: None,
948            unmodeled_fields: Vec::new(),
949        };
950        let path = ["services".to_owned(), entry.key().to_owned()];
951
952        for field in fields {
953            match field.key() {
954                "image" => {
955                    service.image = self
956                        .project_string(field.value(), "service image")
957                        .map(|value| ProjectValue {
958                            value: ImageReference::parse(value.value),
959                            provenance: value.provenance,
960                            sensitive: value.sensitive,
961                        });
962                }
963                "command" => service.command = self.command(field.value()),
964                "environment" => service.environment = self.environment(field.value()),
965                "labels" => service.labels = self.service_labels(field.value()),
966                "extra_hosts" => service.extra_hosts = self.extra_hosts(field.value()),
967                "user" => service.user = self.user(field.value()),
968                "userns_mode" => service.userns_mode = self.userns_mode(field.value()),
969                "group_add" => {
970                    service.group_add = self.string_collection(field.value(), "group_add must be a sequence");
971                }
972                "working_dir" => {
973                    service.working_dir = self.project_string(field.value(), "service working directory");
974                }
975                "read_only" => {
976                    service.read_only = self
977                        .located_boolean(field.value(), "service read_only must be a boolean")
978                        .map(|value| ProjectValue::new(value.into_value(), field.value()));
979                }
980                "healthcheck" => service.healthcheck = self.healthcheck(field.value(), &path),
981                "depends_on" => service.depends_on = self.depends_on(field.value(), &path),
982                "ports" => service.ports = self.ports(field.value(), &path),
983                "volumes" => service.volumes = self.volumes(field.value(), &path),
984                "configs" => service.configs = self.grants(field.value(), &path, "config"),
985                "secrets" => service.secrets = self.grants(field.value(), &path, "secret"),
986                "networks" => service.networks = self.service_networks(field.value(), &path),
987                "profiles" => service.profiles = self.string_collection(field.value(), "profiles must be a sequence"),
988                _ => service.unmodeled_fields.push(field_reference(&path, field)),
989            }
990        }
991        service
992            .unmodeled_fields
993            .extend(self.pending_unmodeled.drain(pending_start..));
994        Some(service)
995    }
996
997    fn command(&mut self, value: &MergedValue) -> Option<ProjectValue<Command>> {
998        let span = effective_span(value);
999        let command = match value.kind() {
1000            MergedValueKind::Null(_) => Command::Null(span),
1001            MergedValueKind::Scalar(scalar) => Command::String(Located::new(scalar.value().to_owned(), span)),
1002            MergedValueKind::Sequence(values) => {
1003                let mut arguments = Vec::new();
1004                for value in values {
1005                    arguments.push(self.located_string(value, "command list item must be a scalar")?);
1006                }
1007                Command::List {
1008                    span,
1009                    values: arguments,
1010                }
1011            }
1012            _ => {
1013                self.expected(value, "command must be null, a scalar, or a sequence");
1014                return None;
1015            }
1016        };
1017        Some(ProjectValue::new(command, value))
1018    }
1019
1020    fn user(&mut self, value: &MergedValue) -> Option<ProjectValue<UserSpec>> {
1021        let raw = self.project_string(value, "service user")?;
1022        Some(ProjectValue {
1023            value: UserSpec::parse(Located::new(raw.value, effective_span(value))),
1024            provenance: raw.provenance,
1025            sensitive: raw.sensitive,
1026        })
1027    }
1028
1029    fn userns_mode(&mut self, value: &MergedValue) -> Option<ProjectValue<UserNamespaceMode>> {
1030        let raw = self.project_string(value, "service user namespace mode")?;
1031        Some(ProjectValue {
1032            value: UserNamespaceMode::parse(Located::new(raw.value, effective_span(value))),
1033            provenance: raw.provenance,
1034            sensitive: raw.sensitive,
1035        })
1036    }
1037
1038    fn environment(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectEnvironment>> {
1039        let mut entries = Vec::new();
1040        match value.kind() {
1041            MergedValueKind::Mapping(values) => {
1042                for entry in values {
1043                    let scalar = self.compose_scalar(entry.value(), "environment value must be a scalar or null")?;
1044                    entries.push(ProjectEnvironmentEntry {
1045                        name: ProjectKey::from_entry(entry),
1046                        value: ProjectValue::new(scalar, entry.value()),
1047                        syntax: entry.syntax(),
1048                    });
1049                }
1050            }
1051            MergedValueKind::Sequence(values) => {
1052                for item in values {
1053                    let raw = self.located_string(item, "environment list item must be a scalar")?;
1054                    let (name, scalar, syntax) = raw.value().split_once('=').map_or_else(
1055                        || (raw.value().clone(), ComposeScalar::Null, EntrySyntax::ListKeyOnly),
1056                        |(name, value)| {
1057                            (
1058                                name.to_owned(),
1059                                ComposeScalar::String(value.to_owned()),
1060                                EntrySyntax::ListKeyValue,
1061                            )
1062                        },
1063                    );
1064                    entries.push(ProjectEnvironmentEntry {
1065                        name: ProjectKey {
1066                            value: name,
1067                            sources: item.provenance().sources().to_vec(),
1068                            sensitive: item.is_sensitive(),
1069                        },
1070                        value: ProjectValue::new(scalar, item),
1071                        syntax,
1072                    });
1073                }
1074            }
1075            _ => {
1076                self.expected(value, "environment must be a mapping or sequence");
1077                return None;
1078            }
1079        }
1080        Some(ProjectValue::new(ProjectEnvironment { entries }, value))
1081    }
1082
1083    fn service_labels(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectLabels>> {
1084        let mut entries = Vec::new();
1085        match value.kind() {
1086            MergedValueKind::Mapping(values) => {
1087                for entry in values {
1088                    let scalar = if entry.syntax() == EntrySyntax::ListKeyOnly {
1089                        ComposeScalar::String(String::new())
1090                    } else {
1091                        self.compose_scalar(entry.value(), "label value must be a scalar or null")?
1092                    };
1093                    entries.push(ProjectLabelEntry {
1094                        name: ProjectKey::from_entry(entry),
1095                        value: ProjectValue::new(scalar, entry.value()),
1096                        syntax: entry.syntax(),
1097                    });
1098                }
1099            }
1100            MergedValueKind::Sequence(values) => {
1101                for item in values {
1102                    let raw = self.located_string(item, "label list item must be a scalar")?;
1103                    let (name, value, syntax) = raw.value().split_once('=').map_or_else(
1104                        || (raw.value().clone(), String::new(), EntrySyntax::ListKeyOnly),
1105                        |(name, value)| (name.to_owned(), value.to_owned(), EntrySyntax::ListKeyValue),
1106                    );
1107                    entries.push(ProjectLabelEntry {
1108                        name: ProjectKey::from_value(name, item),
1109                        value: ProjectValue::new(ComposeScalar::String(value), item),
1110                        syntax,
1111                    });
1112                }
1113            }
1114            _ => {
1115                self.expected(value, "labels must be a mapping or sequence");
1116                return None;
1117            }
1118        }
1119        Some(ProjectValue::new(ProjectLabels { entries }, value))
1120    }
1121
1122    fn healthcheck(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ProjectValue<ProjectHealthcheck>> {
1123        let fields = self.mapping(value, "healthcheck must be a mapping")?;
1124        let mut healthcheck = ProjectHealthcheck {
1125            test: None,
1126            interval: None,
1127            timeout: None,
1128            retries: None,
1129            start_period: None,
1130            start_interval: None,
1131            disable: None,
1132            unmodeled_fields: Vec::new(),
1133        };
1134        let mut path = parent_path.to_vec();
1135        path.push("healthcheck".to_owned());
1136        for field in fields {
1137            match field.key() {
1138                "test" => healthcheck.test = self.healthcheck_test(field.value()),
1139                "interval" => {
1140                    healthcheck.interval =
1141                        self.healthcheck_duration(field.value(), "healthcheck interval must be a scalar");
1142                }
1143                "timeout" => {
1144                    healthcheck.timeout =
1145                        self.healthcheck_duration(field.value(), "healthcheck timeout must be a scalar");
1146                }
1147                "retries" => healthcheck.retries = self.healthcheck_retries(field.value()),
1148                "start_period" => {
1149                    healthcheck.start_period =
1150                        self.healthcheck_duration(field.value(), "healthcheck start_period must be a scalar");
1151                }
1152                "start_interval" => {
1153                    healthcheck.start_interval =
1154                        self.healthcheck_duration(field.value(), "healthcheck start_interval must be a scalar");
1155                }
1156                "disable" => {
1157                    healthcheck.disable = self
1158                        .located_boolean(field.value(), "healthcheck disable must be a boolean")
1159                        .map(|value| ProjectValue::new(value.into_value(), field.value()));
1160                }
1161                _ => healthcheck.unmodeled_fields.push(field_reference(&path, field)),
1162            }
1163        }
1164        Some(ProjectValue::new(healthcheck, value))
1165    }
1166
1167    fn depends_on(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ProjectValue<ProjectDependsOn>> {
1168        let dependencies = match value.kind() {
1169            MergedValueKind::Sequence(values) => {
1170                let mut dependencies = Vec::new();
1171                for value in values {
1172                    let Some(service) = self.project_string(value, "dependency service name") else {
1173                        continue;
1174                    };
1175                    let dependency = ProjectServiceDependency {
1176                        service: ProjectKey::from_value(service.value, value),
1177                        condition: None,
1178                        restart: None,
1179                        required: None,
1180                        unmodeled_fields: Vec::new(),
1181                    };
1182                    dependencies.push(ProjectValue::new(dependency, value));
1183                }
1184                ProjectDependsOn::Short(dependencies)
1185            }
1186            MergedValueKind::Mapping(entries) => {
1187                let mut dependencies = Vec::new();
1188                let mut path = parent_path.to_vec();
1189                path.push("depends_on".to_owned());
1190                for entry in entries {
1191                    let mut dependency = ProjectServiceDependency {
1192                        service: ProjectKey::from_entry(entry),
1193                        condition: None,
1194                        restart: None,
1195                        required: None,
1196                        unmodeled_fields: Vec::new(),
1197                    };
1198                    let fields = match entry.value().kind() {
1199                        MergedValueKind::Null(_) => &[][..],
1200                        MergedValueKind::Mapping(fields) => fields.as_slice(),
1201                        _ => {
1202                            self.expected(entry.value(), "long dependency options must be a mapping or null");
1203                            continue;
1204                        }
1205                    };
1206                    let mut dependency_path = path.clone();
1207                    dependency_path.push(entry.key().to_owned());
1208                    for field in fields {
1209                        match field.key() {
1210                            "condition" => {
1211                                let Some(condition) = self.project_string(field.value(), "dependency condition") else {
1212                                    continue;
1213                                };
1214                                let parsed = DependencyCondition::parse(condition.value);
1215                                if !parsed.is_known() {
1216                                    self.invalid(
1217                                        effective_span(field.value()),
1218                                        "dependency condition is not defined by Compose",
1219                                    );
1220                                }
1221                                dependency.condition = Some(ProjectValue {
1222                                    value: parsed,
1223                                    provenance: condition.provenance,
1224                                    sensitive: condition.sensitive,
1225                                });
1226                            }
1227                            "restart" => {
1228                                dependency.restart = self
1229                                    .located_boolean(field.value(), "dependency restart must be a boolean")
1230                                    .map(|value| ProjectValue::new(value.into_value(), field.value()));
1231                            }
1232                            "required" => {
1233                                dependency.required = self
1234                                    .located_boolean(field.value(), "dependency required must be a boolean")
1235                                    .map(|value| ProjectValue::new(value.into_value(), field.value()));
1236                            }
1237                            _ => dependency
1238                                .unmodeled_fields
1239                                .push(field_reference(&dependency_path, field)),
1240                        }
1241                    }
1242                    dependencies.push(ProjectValue::new(dependency, entry.value()));
1243                }
1244                ProjectDependsOn::Long(dependencies)
1245            }
1246            _ => {
1247                self.expected(value, "depends_on must be a sequence or mapping");
1248                return None;
1249            }
1250        };
1251        Some(ProjectValue::new(dependencies, value))
1252    }
1253
1254    fn healthcheck_test(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckTest>> {
1255        let span = effective_span(value);
1256        let test = match value.kind() {
1257            MergedValueKind::Scalar(scalar) => HealthcheckTest::String(Located::new(scalar.value().to_owned(), span)),
1258            MergedValueKind::Sequence(values) => {
1259                let mut items = Vec::new();
1260                for value in values {
1261                    items.push(self.located_string(value, "healthcheck test item must be a scalar")?);
1262                }
1263                let kind = items.first().map(|item| HealthcheckTestKind::parse(item.value()));
1264                HealthcheckTest::List {
1265                    span,
1266                    kind,
1267                    values: items,
1268                }
1269            }
1270            _ => {
1271                self.expected(value, "healthcheck test must be a scalar or sequence");
1272                return None;
1273            }
1274        };
1275        Some(ProjectValue::new(test, value))
1276    }
1277
1278    fn healthcheck_duration(
1279        &mut self,
1280        value: &MergedValue,
1281        message: &str,
1282    ) -> Option<ProjectValue<HealthcheckDuration>> {
1283        let scalar = self.scalar(value, message)?;
1284        Some(ProjectValue::new(
1285            HealthcheckDuration::parse(scalar.value().to_owned()),
1286            value,
1287        ))
1288    }
1289
1290    fn healthcheck_retries(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckRetries>> {
1291        let scalar = self.scalar(value, "healthcheck retries must be a scalar")?;
1292        Some(ProjectValue::new(
1293            HealthcheckRetries::parse(scalar.value().to_owned()),
1294            value,
1295        ))
1296    }
1297
1298    fn extra_hosts(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectExtraHosts>> {
1299        let mut entries = Vec::new();
1300        match value.kind() {
1301            MergedValueKind::Mapping(values) => {
1302                for entry in values {
1303                    let scalar = self.scalar(entry.value(), "extra_hosts address must be a scalar")?;
1304                    entries.push(ProjectExtraHost {
1305                        hostname: ProjectKey::from_entry(entry),
1306                        address: ProjectValue::new(HostAddress::parse(scalar.value().to_owned()), entry.value()),
1307                        syntax: EntrySyntax::Mapping,
1308                    });
1309                }
1310            }
1311            MergedValueKind::Sequence(values) => {
1312                for item in values {
1313                    let raw = self.located_string(item, "extra_hosts list item must be a scalar")?;
1314                    let parsed = ShortExtraHost::parse(raw);
1315                    let (Some(hostname), Some(address)) = (parsed.hostname(), parsed.address()) else {
1316                        self.invalid(
1317                            effective_span(item),
1318                            "extra_hosts entry must contain a hostname and address",
1319                        );
1320                        continue;
1321                    };
1322                    entries.push(ProjectExtraHost {
1323                        hostname: ProjectKey {
1324                            value: hostname.to_owned(),
1325                            sources: item.provenance().sources().to_vec(),
1326                            sensitive: item.is_sensitive(),
1327                        },
1328                        address: ProjectValue::new(address.clone(), item),
1329                        syntax: EntrySyntax::ListKeyValue,
1330                    });
1331                }
1332            }
1333            _ => {
1334                self.expected(value, "extra_hosts must be a mapping or sequence");
1335                return None;
1336            }
1337        }
1338        Some(ProjectValue::new(ProjectExtraHosts { entries }, value))
1339    }
1340
1341    fn project_string(&mut self, value: &MergedValue, description: &str) -> Option<ProjectValue<String>> {
1342        let scalar = self.scalar(value, &format!("{description} must be a non-null scalar"))?;
1343        Some(ProjectValue::new(scalar.value().to_owned(), value))
1344    }
1345
1346    fn string_collection(
1347        &mut self,
1348        value: &MergedValue,
1349        message: &str,
1350    ) -> Option<ProjectValue<Vec<ProjectValue<String>>>> {
1351        let Some(values) = value.as_sequence() else {
1352            self.expected(value, message);
1353            return None;
1354        };
1355        let mut strings = Vec::new();
1356        for value in values {
1357            let scalar = self.scalar(value, "sequence item must be a non-null scalar")?;
1358            strings.push(ProjectValue::new(scalar.value().to_owned(), value));
1359        }
1360        Some(ProjectValue::new(strings, value))
1361    }
1362
1363    fn scalar<'value>(
1364        &mut self,
1365        value: &'value MergedValue,
1366        message: &str,
1367    ) -> Option<&'value crate::merge::MergedScalar> {
1368        let Some(scalar) = value.as_scalar() else {
1369            self.expected(value, message);
1370            return None;
1371        };
1372        Some(scalar)
1373    }
1374
1375    fn located_string(&mut self, value: &MergedValue, message: &str) -> Option<Located<String>> {
1376        let scalar = self.scalar(value, message)?;
1377        Some(Located::new(scalar.value().to_owned(), effective_span(value)))
1378    }
1379
1380    fn compose_scalar(&mut self, value: &MergedValue, message: &str) -> Option<ComposeScalar> {
1381        match value.kind() {
1382            MergedValueKind::Null(_) => Some(ComposeScalar::Null),
1383            MergedValueKind::Scalar(scalar) => Some(match scalar.kind() {
1384                MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
1385                MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
1386                MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
1387            }),
1388            _ => {
1389                self.expected(value, message);
1390                None
1391            }
1392        }
1393    }
1394
1395    fn mapping<'value>(&mut self, value: &'value MergedValue, message: &str) -> Option<&'value [MergedEntry]> {
1396        let Some(entries) = value.as_mapping() else {
1397            self.expected(value, message);
1398            return None;
1399        };
1400        Some(entries)
1401    }
1402
1403    fn expected(&mut self, value: &MergedValue, message: &str) {
1404        self.diagnostics.push(
1405            Diagnostic::new(PROJECT_EXPECTED_FORM, Severity::Error, message).with_label(DiagnosticLabel::primary(
1406                effective_span(value),
1407                "unexpected merged value form",
1408            )),
1409        );
1410    }
1411
1412    fn missing(&mut self, value: &MergedValue, message: &str) {
1413        self.diagnostics.push(
1414            Diagnostic::new(PROJECT_MISSING_FIELD, Severity::Error, message).with_label(DiagnosticLabel::primary(
1415                effective_span(value),
1416                "required field is missing",
1417            )),
1418        );
1419    }
1420
1421    fn invalid(&mut self, span: SourceSpan, message: &str) {
1422        self.diagnostics.push(
1423            Diagnostic::new(PROJECT_INVALID_VALUE, Severity::Error, message)
1424                .with_label(DiagnosticLabel::primary(span, "invalid native value")),
1425        );
1426    }
1427
1428    fn record_root_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
1429        self.root_unmodeled.push(field_reference(path, entry));
1430    }
1431
1432    fn record_pending_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
1433        self.pending_unmodeled.push(field_reference(path, entry));
1434    }
1435}
1436
1437impl Builder<'_> {
1438    fn ports(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<Vec<ProjectValue<Port>>>> {
1439        let Some(values) = value.as_sequence() else {
1440            self.expected(value, "service ports must be a sequence");
1441            return None;
1442        };
1443        let mut ports = Vec::new();
1444        for (index, item) in values.iter().enumerate() {
1445            let mut path = service_path.to_vec();
1446            path.push("ports".to_owned());
1447            path.push(index.to_string());
1448            let port = match item.kind() {
1449                MergedValueKind::Scalar(scalar) => Port::Short(ShortPort::parse(Located::new(
1450                    scalar.value().to_owned(),
1451                    effective_span(item),
1452                ))),
1453                MergedValueKind::Mapping(fields) => Port::Long(Box::new(self.long_port(item, fields, &path))),
1454                _ => {
1455                    self.expected(item, "service port must use scalar short syntax or mapping long syntax");
1456                    continue;
1457                }
1458            };
1459            ports.push(ProjectValue::new(port, item));
1460        }
1461        Some(ProjectValue::new(ports, value))
1462    }
1463
1464    fn long_port(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongPort {
1465        let mut port = LongPort::new(effective_span(value));
1466        let mut has_target = false;
1467        for field in fields {
1468            match field.key() {
1469                "target" => {
1470                    if let Some(value) = self.located_string(field.value(), "port target must be a scalar") {
1471                        port.set_target(value);
1472                        has_target = true;
1473                    }
1474                }
1475                "published" => self
1476                    .located_string(field.value(), "published port must be a scalar")
1477                    .into_iter()
1478                    .for_each(|value| port.set_published(value)),
1479                "host_ip" => self
1480                    .located_string(field.value(), "port host_ip must be a scalar")
1481                    .into_iter()
1482                    .for_each(|value| port.set_host_ip(value)),
1483                "protocol" => self
1484                    .located_string(field.value(), "port protocol must be a scalar")
1485                    .into_iter()
1486                    .for_each(|value| port.set_protocol(value)),
1487                "app_protocol" => self
1488                    .located_string(field.value(), "port app_protocol must be a scalar")
1489                    .into_iter()
1490                    .for_each(|value| port.set_app_protocol(value)),
1491                "mode" => self
1492                    .located_string(field.value(), "port mode must be a scalar")
1493                    .into_iter()
1494                    .for_each(|value| port.set_mode(value)),
1495                "name" => self
1496                    .located_string(field.value(), "port name must be a scalar")
1497                    .into_iter()
1498                    .for_each(|value| port.set_name(value)),
1499                _ => self.record_pending_unmodeled(path, field),
1500            }
1501        }
1502        if !has_target {
1503            self.missing(value, "long-syntax port is missing `target`");
1504        }
1505        port
1506    }
1507
1508    fn volumes(
1509        &mut self,
1510        value: &MergedValue,
1511        service_path: &[String],
1512    ) -> Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
1513        let Some(values) = value.as_sequence() else {
1514            self.expected(value, "service volumes must be a sequence");
1515            return None;
1516        };
1517        let mut mounts = Vec::new();
1518        for (index, item) in values.iter().enumerate() {
1519            let mut path = service_path.to_vec();
1520            path.push("volumes".to_owned());
1521            path.push(index.to_string());
1522            let mount = match item.kind() {
1523                MergedValueKind::Scalar(scalar) => VolumeMount::Short(ShortVolumeMount::new(Located::new(
1524                    scalar.value().to_owned(),
1525                    effective_span(item),
1526                ))),
1527                MergedValueKind::Mapping(fields) => VolumeMount::Long(Box::new(self.long_volume(item, fields, &path))),
1528                _ => {
1529                    self.expected(
1530                        item,
1531                        "service volume must use scalar short syntax or mapping long syntax",
1532                    );
1533                    continue;
1534                }
1535            };
1536            mounts.push(ProjectValue::new(mount, item));
1537        }
1538        Some(ProjectValue::new(mounts, value))
1539    }
1540
1541    fn long_volume(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongVolumeMount {
1542        let mut mount = LongVolumeMount::new(effective_span(value));
1543        let mut has_type = false;
1544        let mut has_target = false;
1545        for field in fields {
1546            match field.key() {
1547                "type" => {
1548                    if let Some(value) = self.located_string(field.value(), "volume type must be a scalar") {
1549                        mount.set_mount_type(Located::new(MountType::from_text(value.value().clone()), value.span()));
1550                        has_type = true;
1551                    }
1552                }
1553                "source" => self
1554                    .located_string(field.value(), "volume source must be a scalar")
1555                    .into_iter()
1556                    .for_each(|value| mount.set_source(value)),
1557                "target" => {
1558                    if let Some(value) = self.located_string(field.value(), "volume target must be a scalar") {
1559                        mount.set_target(value);
1560                        has_target = true;
1561                    }
1562                }
1563                "read_only" => self
1564                    .located_boolean(field.value(), "volume read_only must be a boolean")
1565                    .into_iter()
1566                    .for_each(|value| mount.set_read_only(value)),
1567                "bind" => self
1568                    .bind_options(field.value(), path)
1569                    .into_iter()
1570                    .for_each(|value| mount.set_bind(value)),
1571                _ => self.record_pending_unmodeled(path, field),
1572            }
1573        }
1574        if !has_type {
1575            self.missing(value, "long-syntax volume is missing `type`");
1576        }
1577        if !has_target {
1578            self.missing(value, "long-syntax volume is missing `target`");
1579        }
1580        mount
1581    }
1582
1583    fn grants(
1584        &mut self,
1585        value: &MergedValue,
1586        service_path: &[String],
1587        kind: &str,
1588    ) -> Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
1589        let Some(values) = value.as_sequence() else {
1590            self.expected(value, &format!("service {kind}s must be a sequence"));
1591            return None;
1592        };
1593        let mut grants = Vec::new();
1594        for (index, item) in values.iter().enumerate() {
1595            let mut path = service_path.to_vec();
1596            path.push(format!("{kind}s"));
1597            path.push(index.to_string());
1598            let grant = match item.kind() {
1599                MergedValueKind::Scalar(scalar) => ProjectGrant::Short(scalar.value().to_owned()),
1600                MergedValueKind::Mapping(fields) => {
1601                    ProjectGrant::Long(Box::new(self.long_grant(item, fields, &path, kind)))
1602                }
1603                _ => {
1604                    self.expected(
1605                        item,
1606                        &format!("service {kind} must use scalar short syntax or mapping long syntax"),
1607                    );
1608                    continue;
1609                }
1610            };
1611            grants.push(ProjectValue::new(grant, item));
1612        }
1613        Some(ProjectValue::new(grants, value))
1614    }
1615
1616    fn long_grant(
1617        &mut self,
1618        value: &MergedValue,
1619        fields: &[MergedEntry],
1620        path: &[String],
1621        kind: &str,
1622    ) -> ProjectLongGrant {
1623        let mut grant = ProjectLongGrant {
1624            source: None,
1625            target: None,
1626            uid: None,
1627            gid: None,
1628            mode: None,
1629            unmodeled_fields: Vec::new(),
1630        };
1631        for field in fields {
1632            let parsed = match field.key() {
1633                "source" => self.project_string(field.value(), &format!("{kind} source")),
1634                "target" => self.project_string(field.value(), &format!("{kind} target")),
1635                "uid" => self.project_string(field.value(), &format!("{kind} uid")),
1636                "gid" => self.project_string(field.value(), &format!("{kind} gid")),
1637                "mode" => self.project_string(field.value(), &format!("{kind} mode")),
1638                _ => {
1639                    grant.unmodeled_fields.push(field_reference(path, field));
1640                    continue;
1641                }
1642            };
1643            match field.key() {
1644                "source" => grant.source = parsed,
1645                "target" => grant.target = parsed,
1646                "uid" => grant.uid = parsed,
1647                "gid" => grant.gid = parsed,
1648                "mode" => grant.mode = parsed,
1649                _ => unreachable!("unrecognized grant fields continue before assignment"),
1650            }
1651        }
1652        if grant.source.is_none() {
1653            self.missing(value, &format!("long-syntax {kind} is missing `source`"));
1654        }
1655        grant
1656    }
1657
1658    fn bind_options(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<BindOptions> {
1659        let fields = self.mapping(value, "volume bind options must be a mapping")?;
1660        let mut bind = BindOptions::new(effective_span(value));
1661        let mut path = parent_path.to_vec();
1662        path.push("bind".to_owned());
1663        for field in fields {
1664            match field.key() {
1665                "propagation" => self
1666                    .located_string(field.value(), "bind propagation must be a scalar")
1667                    .into_iter()
1668                    .for_each(|value| bind.set_propagation(value)),
1669                "create_host_path" => self
1670                    .located_boolean(field.value(), "bind create_host_path must be a boolean")
1671                    .into_iter()
1672                    .for_each(|value| bind.set_create_host_path(value)),
1673                "selinux" => {
1674                    if let Some(value) = self.located_string(field.value(), "bind SELinux mode must be a scalar") {
1675                        let mode = match value.value().as_str() {
1676                            "z" => Some(SelinuxRelabel::Shared),
1677                            "Z" => Some(SelinuxRelabel::Private),
1678                            _ => None,
1679                        };
1680                        if let Some(mode) = mode {
1681                            bind.set_selinux(Located::new(mode, value.span()));
1682                        } else {
1683                            self.invalid(value.span(), "bind SELinux mode must be `z` or `Z`");
1684                        }
1685                    }
1686                }
1687                _ => self.record_pending_unmodeled(&path, field),
1688            }
1689        }
1690        Some(bind)
1691    }
1692
1693    fn service_networks(
1694        &mut self,
1695        value: &MergedValue,
1696        service_path: &[String],
1697    ) -> Option<ProjectValue<ServiceNetworks>> {
1698        let span = effective_span(value);
1699        let networks = match value.kind() {
1700            MergedValueKind::Sequence(values) => {
1701                let mut names = Vec::new();
1702                for value in values {
1703                    names.push(self.located_string(value, "service network name must be a scalar")?);
1704                }
1705                ServiceNetworks::Short { span, names }
1706            }
1707            MergedValueKind::Mapping(entries) => {
1708                let mut networks = Vec::new();
1709                for entry in entries {
1710                    let mut path = service_path.to_vec();
1711                    path.push("networks".to_owned());
1712                    path.push(entry.key().to_owned());
1713                    networks.push(self.service_network(entry, &path)?);
1714                }
1715                ServiceNetworks::Long { span, networks }
1716            }
1717            _ => {
1718                self.expected(value, "service networks must be a sequence or mapping");
1719                return None;
1720            }
1721        };
1722        Some(ProjectValue::new(networks, value))
1723    }
1724
1725    fn service_network(&mut self, entry: &MergedEntry, path: &[String]) -> Option<ServiceNetwork> {
1726        let value = entry.value();
1727        let span = effective_span(value);
1728        let mut network = ServiceNetwork::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1729        let fields = match value.kind() {
1730            MergedValueKind::Null(_) => return Some(network),
1731            MergedValueKind::Mapping(fields) => fields,
1732            _ => {
1733                self.expected(value, "service network attachment must be a mapping or null");
1734                return None;
1735            }
1736        };
1737        for field in fields {
1738            match field.key() {
1739                "aliases" => self
1740                    .located_string_sequence(field.value(), "network aliases must be a sequence")
1741                    .into_iter()
1742                    .for_each(|value| network.set_aliases(value)),
1743                "interface_name" => self
1744                    .located_string(field.value(), "network interface_name must be a scalar")
1745                    .into_iter()
1746                    .for_each(|value| network.set_interface_name(value)),
1747                "ipv4_address" => self
1748                    .located_string(field.value(), "network ipv4_address must be a scalar")
1749                    .into_iter()
1750                    .for_each(|value| network.set_ipv4_address(value)),
1751                "ipv6_address" => self
1752                    .located_string(field.value(), "network ipv6_address must be a scalar")
1753                    .into_iter()
1754                    .for_each(|value| network.set_ipv6_address(value)),
1755                "link_local_ips" => self
1756                    .located_string_sequence(field.value(), "link_local_ips must be a sequence")
1757                    .into_iter()
1758                    .for_each(|value| network.set_link_local_ips(value)),
1759                "mac_address" => self
1760                    .located_string(field.value(), "network mac_address must be a scalar")
1761                    .into_iter()
1762                    .for_each(|value| network.set_mac_address(value)),
1763                "driver_opts" => self
1764                    .key_value_mapping(field.value(), "network driver_opts must be a mapping")
1765                    .into_iter()
1766                    .for_each(|value| network.set_driver_opts(value)),
1767                "gw_priority" => self
1768                    .located_string(field.value(), "network gw_priority must be a scalar")
1769                    .into_iter()
1770                    .for_each(|value| network.set_gw_priority(value)),
1771                "priority" => self
1772                    .located_string(field.value(), "network priority must be a scalar")
1773                    .into_iter()
1774                    .for_each(|value| network.set_priority(value)),
1775                _ => self.record_pending_unmodeled(path, field),
1776            }
1777        }
1778        Some(network)
1779    }
1780
1781    fn located_boolean(&mut self, value: &MergedValue, message: &str) -> Option<Located<BooleanValue>> {
1782        let scalar = self.scalar(value, message)?;
1783        let boolean = if scalar.kind() == MergedScalarKind::Boolean {
1784            BooleanValue::Literal(scalar.value().eq_ignore_ascii_case("true"))
1785        } else if scalar.value().contains('$') {
1786            BooleanValue::Expression(scalar.value().to_owned())
1787        } else {
1788            self.invalid(effective_span(value), message);
1789            return None;
1790        };
1791        Some(Located::new(boolean, effective_span(value)))
1792    }
1793
1794    fn located_string_sequence(&mut self, value: &MergedValue, message: &str) -> Option<Vec<Located<String>>> {
1795        let Some(values) = value.as_sequence() else {
1796            self.expected(value, message);
1797            return None;
1798        };
1799        let mut strings = Vec::new();
1800        for value in values {
1801            strings.push(self.located_string(value, "sequence item must be a scalar")?);
1802        }
1803        Some(strings)
1804    }
1805
1806    fn key_value_mapping(&mut self, value: &MergedValue, message: &str) -> Option<Vec<KeyValueEntry>> {
1807        let Some(entries) = value.as_mapping() else {
1808            self.expected(value, message);
1809            return None;
1810        };
1811        let mut values = Vec::new();
1812        for entry in entries {
1813            let scalar = self.compose_scalar(entry.value(), "mapping value must be a scalar or null")?;
1814            let value_span = effective_span(entry.value());
1815            values.push(KeyValueEntry::new(
1816                Located::new(entry.key().to_owned(), entry_span(entry)),
1817                Located::new(scalar, value_span),
1818                value_span,
1819            ));
1820        }
1821        Some(values)
1822    }
1823
1824    fn network_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<NetworkDefinition>> {
1825        let Some(entries) = self.mapping(value, "top-level networks must be a mapping") else {
1826            return Vec::new();
1827        };
1828        entries
1829            .iter()
1830            .filter_map(|entry| {
1831                let definition = self.network_definition(entry)?;
1832                Some(ProjectResource {
1833                    name: ProjectKey::from_entry(entry),
1834                    definition: ProjectValue::new(definition, entry.value()),
1835                })
1836            })
1837            .collect()
1838    }
1839
1840    fn network_definition(&mut self, entry: &MergedEntry) -> Option<NetworkDefinition> {
1841        let value = entry.value();
1842        let span = effective_span(value);
1843        let mut network = NetworkDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1844        let fields = match value.kind() {
1845            MergedValueKind::Null(_) => return Some(network),
1846            MergedValueKind::Mapping(fields) => fields,
1847            _ => {
1848                self.expected(value, "network definition must be a mapping or null");
1849                return None;
1850            }
1851        };
1852        let path = ["networks".to_owned(), entry.key().to_owned()];
1853        for field in fields {
1854            match field.key() {
1855                "driver" => self
1856                    .located_string(field.value(), "network driver must be a scalar")
1857                    .into_iter()
1858                    .for_each(|value| network.set_driver(value)),
1859                "driver_opts" => self
1860                    .key_value_mapping(field.value(), "network driver_opts must be a mapping")
1861                    .into_iter()
1862                    .for_each(|value| network.set_driver_opts(value)),
1863                "attachable" => self
1864                    .located_boolean(field.value(), "network attachable must be a boolean")
1865                    .into_iter()
1866                    .for_each(|value| network.set_attachable(value)),
1867                "enable_ipv4" => self
1868                    .located_boolean(field.value(), "network enable_ipv4 must be a boolean")
1869                    .into_iter()
1870                    .for_each(|value| network.set_enable_ipv4(value)),
1871                "enable_ipv6" => self
1872                    .located_boolean(field.value(), "network enable_ipv6 must be a boolean")
1873                    .into_iter()
1874                    .for_each(|value| network.set_enable_ipv6(value)),
1875                "external" => self
1876                    .located_boolean(field.value(), "network external must be a boolean")
1877                    .into_iter()
1878                    .for_each(|value| network.set_external(value)),
1879                "internal" => self
1880                    .located_boolean(field.value(), "network internal must be a boolean")
1881                    .into_iter()
1882                    .for_each(|value| network.set_internal(value)),
1883                "ipam" => self
1884                    .ipam(field.value(), &path)
1885                    .into_iter()
1886                    .for_each(|value| network.set_ipam(value)),
1887                "labels" => self
1888                    .labels(field.value())
1889                    .into_iter()
1890                    .for_each(|value| network.set_labels(value)),
1891                "name" => self
1892                    .located_string(field.value(), "network custom name must be a scalar")
1893                    .into_iter()
1894                    .for_each(|value| network.set_custom_name(value)),
1895                _ => self.record_root_unmodeled(&path, field),
1896            }
1897        }
1898        Some(network)
1899    }
1900
1901    fn ipam(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Ipam> {
1902        let fields = self.mapping(value, "network IPAM must be a mapping")?;
1903        let mut ipam = Ipam::new(effective_span(value));
1904        let mut path = parent_path.to_vec();
1905        path.push("ipam".to_owned());
1906        for field in fields {
1907            match field.key() {
1908                "driver" => self
1909                    .located_string(field.value(), "IPAM driver must be a scalar")
1910                    .into_iter()
1911                    .for_each(|value| ipam.set_driver(value)),
1912                "config" => self
1913                    .ipam_configs(field.value(), &path)
1914                    .into_iter()
1915                    .for_each(|value| ipam.set_config(value)),
1916                "options" => self
1917                    .key_value_mapping(field.value(), "IPAM options must be a mapping")
1918                    .into_iter()
1919                    .for_each(|value| ipam.set_options(value)),
1920                _ => self.record_root_unmodeled(&path, field),
1921            }
1922        }
1923        Some(ipam)
1924    }
1925
1926    fn ipam_configs(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Vec<IpamConfig>> {
1927        let Some(values) = value.as_sequence() else {
1928            self.expected(value, "IPAM config must be a sequence");
1929            return None;
1930        };
1931        let mut configs = Vec::new();
1932        for (index, value) in values.iter().enumerate() {
1933            let Some(fields) = value.as_mapping() else {
1934                self.expected(value, "IPAM config entry must be a mapping");
1935                continue;
1936            };
1937            let mut config = IpamConfig::new(effective_span(value));
1938            let mut path = parent_path.to_vec();
1939            path.push("config".to_owned());
1940            path.push(index.to_string());
1941            for field in fields {
1942                match field.key() {
1943                    "subnet" => self
1944                        .located_string(field.value(), "IPAM subnet must be a scalar")
1945                        .into_iter()
1946                        .for_each(|value| config.set_subnet(value)),
1947                    "ip_range" => self
1948                        .located_string(field.value(), "IPAM ip_range must be a scalar")
1949                        .into_iter()
1950                        .for_each(|value| config.set_ip_range(value)),
1951                    "gateway" => self
1952                        .located_string(field.value(), "IPAM gateway must be a scalar")
1953                        .into_iter()
1954                        .for_each(|value| config.set_gateway(value)),
1955                    "aux_addresses" => self
1956                        .key_value_mapping(field.value(), "IPAM aux_addresses must be a mapping")
1957                        .into_iter()
1958                        .for_each(|value| config.set_aux_addresses(value)),
1959                    _ => self.record_root_unmodeled(&path, field),
1960                }
1961            }
1962            configs.push(config);
1963        }
1964        Some(configs)
1965    }
1966
1967    fn volume_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<VolumeDefinition>> {
1968        let Some(entries) = self.mapping(value, "top-level volumes must be a mapping") else {
1969            return Vec::new();
1970        };
1971        entries
1972            .iter()
1973            .filter_map(|entry| {
1974                let definition = self.volume_definition(entry)?;
1975                Some(ProjectResource {
1976                    name: ProjectKey::from_entry(entry),
1977                    definition: ProjectValue::new(definition, entry.value()),
1978                })
1979            })
1980            .collect()
1981    }
1982
1983    fn volume_definition(&mut self, entry: &MergedEntry) -> Option<VolumeDefinition> {
1984        let value = entry.value();
1985        let span = effective_span(value);
1986        let mut volume = VolumeDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1987        let fields = match value.kind() {
1988            MergedValueKind::Null(_) => return Some(volume),
1989            MergedValueKind::Mapping(fields) => fields,
1990            _ => {
1991                self.expected(value, "volume definition must be a mapping or null");
1992                return None;
1993            }
1994        };
1995        let path = ["volumes".to_owned(), entry.key().to_owned()];
1996        for field in fields {
1997            match field.key() {
1998                "driver" => self
1999                    .located_string(field.value(), "volume driver must be a scalar")
2000                    .into_iter()
2001                    .for_each(|value| volume.set_driver(value)),
2002                "driver_opts" => self
2003                    .key_value_mapping(field.value(), "volume driver_opts must be a mapping")
2004                    .into_iter()
2005                    .for_each(|value| volume.set_driver_opts(value)),
2006                "external" => self
2007                    .located_boolean(field.value(), "volume external must be a boolean")
2008                    .into_iter()
2009                    .for_each(|value| volume.set_external(value)),
2010                "labels" => self
2011                    .labels(field.value())
2012                    .into_iter()
2013                    .for_each(|value| volume.set_labels(value)),
2014                "name" => self
2015                    .located_string(field.value(), "volume custom name must be a scalar")
2016                    .into_iter()
2017                    .for_each(|value| volume.set_custom_name(value)),
2018                _ => self.record_root_unmodeled(&path, field),
2019            }
2020        }
2021        Some(volume)
2022    }
2023
2024    fn config_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<ConfigDefinition>> {
2025        let Some(entries) = self.mapping(value, "top-level configs must be a mapping") else {
2026            return Vec::new();
2027        };
2028        entries
2029            .iter()
2030            .filter_map(|entry| {
2031                let definition = self.config_definition(entry)?;
2032                Some(ProjectResource {
2033                    name: ProjectKey::from_entry(entry),
2034                    definition: ProjectValue::new(definition, entry.value()),
2035                })
2036            })
2037            .collect()
2038    }
2039
2040    fn config_definition(&mut self, entry: &MergedEntry) -> Option<ConfigDefinition> {
2041        let value = entry.value();
2042        let span = effective_span(value);
2043        let mut config = ConfigDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
2044        let fields = match value.kind() {
2045            MergedValueKind::Null(_) => return Some(config),
2046            MergedValueKind::Mapping(fields) => fields,
2047            _ => {
2048                self.expected(value, "config definition must be a mapping or null");
2049                return None;
2050            }
2051        };
2052        let path = ["configs".to_owned(), entry.key().to_owned()];
2053        for field in fields {
2054            match field.key() {
2055                "file" => self
2056                    .located_string(field.value(), "config file must be a scalar")
2057                    .into_iter()
2058                    .for_each(|value| config.set_file(value)),
2059                "environment" => self
2060                    .located_string(field.value(), "config environment must be a scalar")
2061                    .into_iter()
2062                    .for_each(|value| config.set_environment(value)),
2063                "content" => self
2064                    .located_string(field.value(), "config content must be a scalar")
2065                    .into_iter()
2066                    .for_each(|value| config.set_content(value)),
2067                "external" => self
2068                    .located_boolean(field.value(), "config external must be a boolean")
2069                    .into_iter()
2070                    .for_each(|value| config.set_external(value)),
2071                "name" => self
2072                    .located_string(field.value(), "config custom name must be a scalar")
2073                    .into_iter()
2074                    .for_each(|value| config.set_custom_name(value)),
2075                _ => self.record_root_unmodeled(&path, field),
2076            }
2077        }
2078        Some(config)
2079    }
2080
2081    fn secret_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<SecretDefinition>> {
2082        let Some(entries) = self.mapping(value, "top-level secrets must be a mapping") else {
2083            return Vec::new();
2084        };
2085        entries
2086            .iter()
2087            .filter_map(|entry| {
2088                let definition = self.secret_definition(entry)?;
2089                Some(ProjectResource {
2090                    name: ProjectKey::from_entry(entry),
2091                    definition: ProjectValue::new(definition, entry.value()),
2092                })
2093            })
2094            .collect()
2095    }
2096
2097    fn secret_definition(&mut self, entry: &MergedEntry) -> Option<SecretDefinition> {
2098        let value = entry.value();
2099        let span = effective_span(value);
2100        let mut secret = SecretDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
2101        let fields = match value.kind() {
2102            MergedValueKind::Null(_) => return Some(secret),
2103            MergedValueKind::Mapping(fields) => fields,
2104            _ => {
2105                self.expected(value, "secret definition must be a mapping or null");
2106                return None;
2107            }
2108        };
2109        let path = ["secrets".to_owned(), entry.key().to_owned()];
2110        for field in fields {
2111            match field.key() {
2112                "file" => self
2113                    .located_string(field.value(), "secret file must be a scalar")
2114                    .into_iter()
2115                    .for_each(|value| secret.set_file(value)),
2116                "environment" => self
2117                    .located_string(field.value(), "secret environment must be a scalar")
2118                    .into_iter()
2119                    .for_each(|value| secret.set_environment(value)),
2120                "external" => self
2121                    .located_boolean(field.value(), "secret external must be a boolean")
2122                    .into_iter()
2123                    .for_each(|value| secret.set_external(value)),
2124                "name" => self
2125                    .located_string(field.value(), "secret custom name must be a scalar")
2126                    .into_iter()
2127                    .for_each(|value| secret.set_custom_name(value)),
2128                _ => self.record_root_unmodeled(&path, field),
2129            }
2130        }
2131        Some(secret)
2132    }
2133
2134    fn labels(&mut self, value: &MergedValue) -> Option<Labels> {
2135        let span = effective_span(value);
2136        match value.kind() {
2137            MergedValueKind::Sequence(_) => self
2138                .located_string_sequence(value, "labels must be a scalar sequence")
2139                .map(|values| Labels::List { span, values }),
2140            MergedValueKind::Mapping(_) => self
2141                .key_value_mapping(value, "labels must be a scalar mapping")
2142                .map(|entries| Labels::Map { span, entries }),
2143            _ => {
2144                self.expected(value, "labels must be a sequence or mapping");
2145                None
2146            }
2147        }
2148    }
2149}
2150
2151fn field_reference(path: &[String], entry: &MergedEntry) -> ProjectFieldReference {
2152    let mut complete_path = path.to_vec();
2153    complete_path.push(entry.key().to_owned());
2154    ProjectFieldReference {
2155        path: complete_path,
2156        key: ProjectKey::from_entry(entry),
2157        provenance: entry.value().provenance().clone(),
2158        extension: entry.key().starts_with("x-"),
2159        sensitive: entry.value().is_sensitive(),
2160    }
2161}
2162
2163fn effective_span(value: &MergedValue) -> SourceSpan {
2164    value
2165        .provenance()
2166        .effective_source()
2167        .or_else(|| value.provenance().sources().first().copied())
2168        .unwrap_or_else(|| SourceSpan::from_valid_offsets(SourceId::new(0), 0, 0))
2169}
2170
2171fn entry_span(entry: &MergedEntry) -> SourceSpan {
2172    entry
2173        .key_sources()
2174        .last()
2175        .copied()
2176        .or_else(|| entry.key_sources().first().copied())
2177        .unwrap_or_else(|| effective_span(entry.value()))
2178}