Skip to main content

compose_lens/
project.rs

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