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