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