Skip to main content

compose_lens/
project.rs

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