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, HealthcheckDuration, HealthcheckRetries,
9    HealthcheckTest, HealthcheckTestKind, HostAddress, ImageReference, Ipam, IpamConfig, KeyValueEntry, Labels,
10    Located, LongPort, LongVolumeMount, MountType, NetworkDefinition, Port, SecretDefinition, SelinuxRelabel,
11    ServiceNetwork, ServiceNetworks, ShortExtraHost, ShortPort, ShortVolumeMount, VolumeDefinition, 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 effective service health check with field-level merge provenance.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct ProjectHealthcheck {
260    test: Option<ProjectValue<HealthcheckTest>>,
261    interval: Option<ProjectValue<HealthcheckDuration>>,
262    timeout: Option<ProjectValue<HealthcheckDuration>>,
263    retries: Option<ProjectValue<HealthcheckRetries>>,
264    start_period: Option<ProjectValue<HealthcheckDuration>>,
265    start_interval: Option<ProjectValue<HealthcheckDuration>>,
266    disable: Option<ProjectValue<BooleanValue>>,
267    unmodeled_fields: Vec<ProjectFieldReference>,
268}
269
270impl ProjectHealthcheck {
271    /// Returns the effective health command without collapsing scalar and list forms.
272    #[must_use]
273    pub const fn test(&self) -> Option<&ProjectValue<HealthcheckTest>> {
274        self.test.as_ref()
275    }
276
277    /// Returns the effective regular-check interval.
278    #[must_use]
279    pub const fn interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
280        self.interval.as_ref()
281    }
282
283    /// Returns the effective per-check timeout.
284    #[must_use]
285    pub const fn timeout(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
286        self.timeout.as_ref()
287    }
288
289    /// Returns the effective unhealthy retry count.
290    #[must_use]
291    pub const fn retries(&self) -> Option<&ProjectValue<HealthcheckRetries>> {
292        self.retries.as_ref()
293    }
294
295    /// Returns the effective startup grace period.
296    #[must_use]
297    pub const fn start_period(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
298        self.start_period.as_ref()
299    }
300
301    /// Returns the effective interval used during the startup grace period.
302    #[must_use]
303    pub const fn start_interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
304        self.start_interval.as_ref()
305    }
306
307    /// Returns whether the image health check is explicitly disabled.
308    #[must_use]
309    pub const fn disable(&self) -> Option<&ProjectValue<BooleanValue>> {
310        self.disable.as_ref()
311    }
312
313    /// Reports whether the effective definition explicitly disables health checks.
314    #[must_use]
315    pub fn is_disabled(&self) -> bool {
316        matches!(
317            self.disable.as_ref().map(ProjectValue::value),
318            Some(BooleanValue::Literal(true))
319        ) || matches!(
320            self.test.as_ref().and_then(|test| test.value().kind()),
321            Some(HealthcheckTestKind::None)
322        )
323    }
324
325    /// Returns retained health-check fields outside the typed project-view boundary.
326    #[must_use]
327    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
328        &self.unmodeled_fields
329    }
330}
331
332/// One selected service with the native fields needed by the first conversion boundary.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct ProjectService {
335    name: ProjectKey,
336    provenance: MergeProvenance,
337    image: Option<ProjectValue<ImageReference>>,
338    command: Option<ProjectValue<Command>>,
339    environment: Option<ProjectValue<ProjectEnvironment>>,
340    extra_hosts: Option<ProjectValue<ProjectExtraHosts>>,
341    healthcheck: Option<ProjectValue<ProjectHealthcheck>>,
342    ports: Option<ProjectValue<Vec<ProjectValue<Port>>>>,
343    volumes: Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>>,
344    networks: Option<ProjectValue<ServiceNetworks>>,
345    profiles: Option<ProjectValue<Vec<ProjectValue<String>>>>,
346    unmodeled_fields: Vec<ProjectFieldReference>,
347}
348
349impl ProjectService {
350    /// Returns the service name and all contributing key spans.
351    #[must_use]
352    pub const fn name(&self) -> &ProjectKey {
353        &self.name
354    }
355
356    /// Returns provenance for the complete effective service mapping.
357    #[must_use]
358    pub const fn provenance(&self) -> &MergeProvenance {
359        &self.provenance
360    }
361
362    /// Returns the effective image reference.
363    #[must_use]
364    pub const fn image(&self) -> Option<&ProjectValue<ImageReference>> {
365        self.image.as_ref()
366    }
367
368    /// Returns the effective command without normalizing scalar and list forms.
369    #[must_use]
370    pub const fn command(&self) -> Option<&ProjectValue<Command>> {
371        self.command.as_ref()
372    }
373
374    /// Returns environment entries normalized by key with per-entry syntax retained.
375    #[must_use]
376    pub const fn environment(&self) -> Option<&ProjectValue<ProjectEnvironment>> {
377        self.environment.as_ref()
378    }
379
380    /// Returns effective service host mappings with per-entry provenance and syntax.
381    #[must_use]
382    pub const fn extra_hosts(&self) -> Option<&ProjectValue<ProjectExtraHosts>> {
383        self.extra_hosts.as_ref()
384    }
385
386    /// Returns the effective health check with per-field merge provenance.
387    #[must_use]
388    pub const fn healthcheck(&self) -> Option<&ProjectValue<ProjectHealthcheck>> {
389        self.healthcheck.as_ref()
390    }
391
392    /// Returns the effective port collection and per-item provenance.
393    #[must_use]
394    pub const fn ports(&self) -> Option<&ProjectValue<Vec<ProjectValue<Port>>>> {
395        self.ports.as_ref()
396    }
397
398    /// Returns the effective volume-mount collection and per-item provenance.
399    #[must_use]
400    pub const fn volumes(&self) -> Option<&ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
401        self.volumes.as_ref()
402    }
403
404    /// Returns effective network attachments with short and long forms retained.
405    #[must_use]
406    pub const fn networks(&self) -> Option<&ProjectValue<ServiceNetworks>> {
407        self.networks.as_ref()
408    }
409
410    /// Returns effective profile names and their individual provenance.
411    #[must_use]
412    pub const fn profiles(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
413        self.profiles.as_ref()
414    }
415
416    /// Returns fields retained outside this initial native project-view boundary.
417    #[must_use]
418    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
419        &self.unmodeled_fields
420    }
421}
422
423/// One named top-level resource with key and definition provenance kept separately.
424#[derive(Debug, Clone, PartialEq, Eq)]
425pub struct ProjectResource<T> {
426    name: ProjectKey,
427    definition: ProjectValue<T>,
428}
429
430impl<T> ProjectResource<T> {
431    /// Returns the model name and all authored key locations.
432    #[must_use]
433    pub const fn name(&self) -> &ProjectKey {
434        &self.name
435    }
436
437    /// Returns the native effective definition and its merge provenance.
438    #[must_use]
439    pub const fn definition(&self) -> &ProjectValue<T> {
440        &self.definition
441    }
442}
443
444/// The native consumer view of one merged and optionally profile-selected Compose project.
445#[derive(Debug, Clone, PartialEq, Eq)]
446pub struct ProjectView {
447    source_ids: Vec<SourceId>,
448    base_directory: PathBuf,
449    provenance: MergeProvenance,
450    name: Option<ProjectValue<String>>,
451    services: Vec<ProjectService>,
452    networks: Vec<ProjectResource<NetworkDefinition>>,
453    volumes: Vec<ProjectResource<VolumeDefinition>>,
454    configs: Vec<ProjectResource<ConfigDefinition>>,
455    secrets: Vec<ProjectResource<SecretDefinition>>,
456    unmodeled_fields: Vec<ProjectFieldReference>,
457}
458
459impl ProjectView {
460    /// Returns source documents in merge order.
461    #[must_use]
462    pub fn source_ids(&self) -> &[SourceId] {
463        &self.source_ids
464    }
465
466    /// Returns the project directory inherited from the first loaded document.
467    #[must_use]
468    pub fn base_directory(&self) -> &Path {
469        &self.base_directory
470    }
471
472    /// Returns provenance for the complete merged root.
473    #[must_use]
474    pub const fn provenance(&self) -> &MergeProvenance {
475        &self.provenance
476    }
477
478    /// Returns the effective explicit project name.
479    #[must_use]
480    pub const fn name(&self) -> Option<&ProjectValue<String>> {
481        self.name.as_ref()
482    }
483
484    /// Returns profile-active services in merged order.
485    #[must_use]
486    pub fn services(&self) -> &[ProjectService] {
487        &self.services
488    }
489
490    /// Finds one profile-active service.
491    #[must_use]
492    pub fn service(&self, name: &str) -> Option<&ProjectService> {
493        self.services.iter().find(|service| service.name.value == name)
494    }
495
496    /// Returns effective top-level network definitions.
497    #[must_use]
498    pub fn networks(&self) -> &[ProjectResource<NetworkDefinition>] {
499        &self.networks
500    }
501
502    /// Returns effective top-level volume definitions.
503    #[must_use]
504    pub fn volumes(&self) -> &[ProjectResource<VolumeDefinition>] {
505        &self.volumes
506    }
507
508    /// Returns effective top-level config definitions.
509    #[must_use]
510    pub fn configs(&self) -> &[ProjectResource<ConfigDefinition>] {
511        &self.configs
512    }
513
514    /// Returns effective top-level secret definitions.
515    #[must_use]
516    pub fn secrets(&self) -> &[ProjectResource<SecretDefinition>] {
517        &self.secrets
518    }
519
520    /// Returns root fields retained outside this initial native project-view boundary.
521    #[must_use]
522    pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
523        &self.unmodeled_fields
524    }
525}
526
527/// Recoverable result of building a typed merged project view.
528#[derive(Debug, Clone, PartialEq, Eq)]
529pub struct ProjectViewResult {
530    view: Option<ProjectView>,
531    diagnostics: Vec<Diagnostic>,
532}
533
534impl ProjectViewResult {
535    /// Returns the typed view when the profile selection belongs to the project.
536    #[must_use]
537    pub const fn view(&self) -> Option<&ProjectView> {
538        self.view.as_ref()
539    }
540
541    /// Returns project-view diagnostics in traversal order.
542    #[must_use]
543    pub fn diagnostics(&self) -> &[Diagnostic] {
544        &self.diagnostics
545    }
546
547    /// Reports whether a view exists and contains no error diagnostics.
548    #[must_use]
549    pub fn is_valid(&self) -> bool {
550        self.view.is_some()
551            && self
552                .diagnostics
553                .iter()
554                .all(|diagnostic| diagnostic.severity() != Severity::Error)
555    }
556
557    /// Separates the view and diagnostics.
558    #[must_use]
559    pub fn into_parts(self) -> (Option<ProjectView>, Vec<Diagnostic>) {
560        (self.view, self.diagnostics)
561    }
562}
563
564/// Builds native values directly from a merged project without canonical rendering or reparsing.
565///
566/// A matching selection filters inactive services. Omitting it includes every service. The
567/// operation performs no file, environment, provider, or runtime access.
568#[must_use]
569pub fn build_project_view(project: &MergedProject, selection: Option<&ProfileSelection>) -> ProjectViewResult {
570    if selection.is_some_and(|selection| !selection.belongs_to(project)) {
571        return ProjectViewResult {
572            view: None,
573            diagnostics: vec![Diagnostic::new(
574                SELECTION_PROJECT_MISMATCH,
575                Severity::Error,
576                "profile selection does not belong to the merged project",
577            )],
578        };
579    }
580
581    Builder::new(project, selection).build()
582}
583
584struct Builder<'a> {
585    project: &'a MergedProject,
586    selection: Option<&'a ProfileSelection>,
587    diagnostics: Vec<Diagnostic>,
588    root_unmodeled: Vec<ProjectFieldReference>,
589    pending_unmodeled: Vec<ProjectFieldReference>,
590}
591
592impl<'a> Builder<'a> {
593    const fn new(project: &'a MergedProject, selection: Option<&'a ProfileSelection>) -> Self {
594        Self {
595            project,
596            selection,
597            diagnostics: Vec::new(),
598            root_unmodeled: Vec::new(),
599            pending_unmodeled: Vec::new(),
600        }
601    }
602
603    fn build(mut self) -> ProjectViewResult {
604        let root = self.project.root();
605        let entries = root.as_mapping().unwrap_or_default();
606        let mut name = None;
607        let mut services = Vec::new();
608        let mut networks = Vec::new();
609        let mut volumes = Vec::new();
610        let mut configs = Vec::new();
611        let mut secrets = Vec::new();
612
613        for entry in entries {
614            match entry.key() {
615                "name" => name = self.project_string(entry.value(), "project name"),
616                "services" => services = self.services(entry.value()),
617                "networks" => networks = self.network_definitions(entry.value()),
618                "volumes" => volumes = self.volume_definitions(entry.value()),
619                "configs" => configs = self.config_definitions(entry.value()),
620                "secrets" => secrets = self.secret_definitions(entry.value()),
621                _ => self.record_root_unmodeled(&[], entry),
622            }
623        }
624
625        ProjectViewResult {
626            view: Some(ProjectView {
627                source_ids: self.project.source_ids().to_vec(),
628                base_directory: self.project.base_directory().to_path_buf(),
629                provenance: root.provenance().clone(),
630                name,
631                services,
632                networks,
633                volumes,
634                configs,
635                secrets,
636                unmodeled_fields: self.root_unmodeled,
637            }),
638            diagnostics: self.diagnostics,
639        }
640    }
641
642    fn services(&mut self, value: &MergedValue) -> Vec<ProjectService> {
643        let Some(entries) = self.mapping(value, "services must be a mapping") else {
644            return Vec::new();
645        };
646        let selection = self.selection;
647        let mut services = Vec::new();
648        for entry in entries {
649            if service_in_scope(selection, entry.key()) {
650                services.extend(self.service(entry));
651            }
652        }
653        services
654    }
655
656    fn service(&mut self, entry: &MergedEntry) -> Option<ProjectService> {
657        let pending_start = self.pending_unmodeled.len();
658        let value = entry.value();
659        let fields = self.mapping(value, "service definition must be a mapping")?;
660        let mut service = ProjectService {
661            name: ProjectKey::from_entry(entry),
662            provenance: value.provenance().clone(),
663            image: None,
664            command: None,
665            environment: None,
666            extra_hosts: None,
667            healthcheck: None,
668            ports: None,
669            volumes: None,
670            networks: None,
671            profiles: None,
672            unmodeled_fields: Vec::new(),
673        };
674        let path = ["services".to_owned(), entry.key().to_owned()];
675
676        for field in fields {
677            match field.key() {
678                "image" => {
679                    service.image = self
680                        .project_string(field.value(), "service image")
681                        .map(|value| ProjectValue {
682                            value: ImageReference::parse(value.value),
683                            provenance: value.provenance,
684                            sensitive: value.sensitive,
685                        });
686                }
687                "command" => service.command = self.command(field.value()),
688                "environment" => service.environment = self.environment(field.value()),
689                "extra_hosts" => service.extra_hosts = self.extra_hosts(field.value()),
690                "healthcheck" => service.healthcheck = self.healthcheck(field.value(), &path),
691                "ports" => service.ports = self.ports(field.value(), &path),
692                "volumes" => service.volumes = self.volumes(field.value(), &path),
693                "networks" => service.networks = self.service_networks(field.value(), &path),
694                "profiles" => service.profiles = self.string_collection(field.value(), "profiles must be a sequence"),
695                _ => service.unmodeled_fields.push(field_reference(&path, field)),
696            }
697        }
698        service
699            .unmodeled_fields
700            .extend(self.pending_unmodeled.drain(pending_start..));
701        Some(service)
702    }
703
704    fn command(&mut self, value: &MergedValue) -> Option<ProjectValue<Command>> {
705        let span = effective_span(value);
706        let command = match value.kind() {
707            MergedValueKind::Null(_) => Command::Null(span),
708            MergedValueKind::Scalar(scalar) => Command::String(Located::new(scalar.value().to_owned(), span)),
709            MergedValueKind::Sequence(values) => {
710                let mut arguments = Vec::new();
711                for value in values {
712                    arguments.push(self.located_string(value, "command list item must be a scalar")?);
713                }
714                Command::List {
715                    span,
716                    values: arguments,
717                }
718            }
719            _ => {
720                self.expected(value, "command must be null, a scalar, or a sequence");
721                return None;
722            }
723        };
724        Some(ProjectValue::new(command, value))
725    }
726
727    fn environment(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectEnvironment>> {
728        let mut entries = Vec::new();
729        match value.kind() {
730            MergedValueKind::Mapping(values) => {
731                for entry in values {
732                    let scalar = self.compose_scalar(entry.value(), "environment value must be a scalar or null")?;
733                    entries.push(ProjectEnvironmentEntry {
734                        name: ProjectKey::from_entry(entry),
735                        value: ProjectValue::new(scalar, entry.value()),
736                        syntax: entry.syntax(),
737                    });
738                }
739            }
740            MergedValueKind::Sequence(values) => {
741                for item in values {
742                    let raw = self.located_string(item, "environment list item must be a scalar")?;
743                    let (name, scalar, syntax) = raw.value().split_once('=').map_or_else(
744                        || (raw.value().clone(), ComposeScalar::Null, EntrySyntax::ListKeyOnly),
745                        |(name, value)| {
746                            (
747                                name.to_owned(),
748                                ComposeScalar::String(value.to_owned()),
749                                EntrySyntax::ListKeyValue,
750                            )
751                        },
752                    );
753                    entries.push(ProjectEnvironmentEntry {
754                        name: ProjectKey {
755                            value: name,
756                            sources: item.provenance().sources().to_vec(),
757                        },
758                        value: ProjectValue::new(scalar, item),
759                        syntax,
760                    });
761                }
762            }
763            _ => {
764                self.expected(value, "environment must be a mapping or sequence");
765                return None;
766            }
767        }
768        Some(ProjectValue::new(ProjectEnvironment { entries }, value))
769    }
770
771    fn healthcheck(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ProjectValue<ProjectHealthcheck>> {
772        let fields = self.mapping(value, "healthcheck must be a mapping")?;
773        let mut healthcheck = ProjectHealthcheck {
774            test: None,
775            interval: None,
776            timeout: None,
777            retries: None,
778            start_period: None,
779            start_interval: None,
780            disable: None,
781            unmodeled_fields: Vec::new(),
782        };
783        let mut path = parent_path.to_vec();
784        path.push("healthcheck".to_owned());
785        for field in fields {
786            match field.key() {
787                "test" => healthcheck.test = self.healthcheck_test(field.value()),
788                "interval" => {
789                    healthcheck.interval =
790                        self.healthcheck_duration(field.value(), "healthcheck interval must be a scalar");
791                }
792                "timeout" => {
793                    healthcheck.timeout =
794                        self.healthcheck_duration(field.value(), "healthcheck timeout must be a scalar");
795                }
796                "retries" => healthcheck.retries = self.healthcheck_retries(field.value()),
797                "start_period" => {
798                    healthcheck.start_period =
799                        self.healthcheck_duration(field.value(), "healthcheck start_period must be a scalar");
800                }
801                "start_interval" => {
802                    healthcheck.start_interval =
803                        self.healthcheck_duration(field.value(), "healthcheck start_interval must be a scalar");
804                }
805                "disable" => {
806                    healthcheck.disable = self
807                        .located_boolean(field.value(), "healthcheck disable must be a boolean")
808                        .map(|value| ProjectValue::new(value.into_value(), field.value()));
809                }
810                _ => healthcheck.unmodeled_fields.push(field_reference(&path, field)),
811            }
812        }
813        Some(ProjectValue::new(healthcheck, value))
814    }
815
816    fn healthcheck_test(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckTest>> {
817        let span = effective_span(value);
818        let test = match value.kind() {
819            MergedValueKind::Scalar(scalar) => HealthcheckTest::String(Located::new(scalar.value().to_owned(), span)),
820            MergedValueKind::Sequence(values) => {
821                let mut items = Vec::new();
822                for value in values {
823                    items.push(self.located_string(value, "healthcheck test item must be a scalar")?);
824                }
825                let kind = items.first().map(|item| HealthcheckTestKind::parse(item.value()));
826                HealthcheckTest::List {
827                    span,
828                    kind,
829                    values: items,
830                }
831            }
832            _ => {
833                self.expected(value, "healthcheck test must be a scalar or sequence");
834                return None;
835            }
836        };
837        Some(ProjectValue::new(test, value))
838    }
839
840    fn healthcheck_duration(
841        &mut self,
842        value: &MergedValue,
843        message: &str,
844    ) -> Option<ProjectValue<HealthcheckDuration>> {
845        let scalar = self.scalar(value, message)?;
846        Some(ProjectValue::new(
847            HealthcheckDuration::parse(scalar.value().to_owned()),
848            value,
849        ))
850    }
851
852    fn healthcheck_retries(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckRetries>> {
853        let scalar = self.scalar(value, "healthcheck retries must be a scalar")?;
854        Some(ProjectValue::new(
855            HealthcheckRetries::parse(scalar.value().to_owned()),
856            value,
857        ))
858    }
859
860    fn extra_hosts(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectExtraHosts>> {
861        let mut entries = Vec::new();
862        match value.kind() {
863            MergedValueKind::Mapping(values) => {
864                for entry in values {
865                    let scalar = self.scalar(entry.value(), "extra_hosts address must be a scalar")?;
866                    entries.push(ProjectExtraHost {
867                        hostname: ProjectKey::from_entry(entry),
868                        address: ProjectValue::new(HostAddress::parse(scalar.value().to_owned()), entry.value()),
869                        syntax: EntrySyntax::Mapping,
870                    });
871                }
872            }
873            MergedValueKind::Sequence(values) => {
874                for item in values {
875                    let raw = self.located_string(item, "extra_hosts list item must be a scalar")?;
876                    let parsed = ShortExtraHost::parse(raw);
877                    let (Some(hostname), Some(address)) = (parsed.hostname(), parsed.address()) else {
878                        self.invalid(
879                            effective_span(item),
880                            "extra_hosts entry must contain a hostname and address",
881                        );
882                        continue;
883                    };
884                    entries.push(ProjectExtraHost {
885                        hostname: ProjectKey {
886                            value: hostname.to_owned(),
887                            sources: item.provenance().sources().to_vec(),
888                        },
889                        address: ProjectValue::new(address.clone(), item),
890                        syntax: EntrySyntax::ListKeyValue,
891                    });
892                }
893            }
894            _ => {
895                self.expected(value, "extra_hosts must be a mapping or sequence");
896                return None;
897            }
898        }
899        Some(ProjectValue::new(ProjectExtraHosts { entries }, value))
900    }
901
902    fn project_string(&mut self, value: &MergedValue, description: &str) -> Option<ProjectValue<String>> {
903        let scalar = self.scalar(value, &format!("{description} must be a non-null scalar"))?;
904        Some(ProjectValue::new(scalar.value().to_owned(), value))
905    }
906
907    fn string_collection(
908        &mut self,
909        value: &MergedValue,
910        message: &str,
911    ) -> Option<ProjectValue<Vec<ProjectValue<String>>>> {
912        let Some(values) = value.as_sequence() else {
913            self.expected(value, message);
914            return None;
915        };
916        let mut strings = Vec::new();
917        for value in values {
918            let scalar = self.scalar(value, "sequence item must be a non-null scalar")?;
919            strings.push(ProjectValue::new(scalar.value().to_owned(), value));
920        }
921        Some(ProjectValue::new(strings, value))
922    }
923
924    fn scalar<'value>(
925        &mut self,
926        value: &'value MergedValue,
927        message: &str,
928    ) -> Option<&'value crate::merge::MergedScalar> {
929        let Some(scalar) = value.as_scalar() else {
930            self.expected(value, message);
931            return None;
932        };
933        Some(scalar)
934    }
935
936    fn located_string(&mut self, value: &MergedValue, message: &str) -> Option<Located<String>> {
937        let scalar = self.scalar(value, message)?;
938        Some(Located::new(scalar.value().to_owned(), effective_span(value)))
939    }
940
941    fn compose_scalar(&mut self, value: &MergedValue, message: &str) -> Option<ComposeScalar> {
942        match value.kind() {
943            MergedValueKind::Null(_) => Some(ComposeScalar::Null),
944            MergedValueKind::Scalar(scalar) => Some(match scalar.kind() {
945                MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
946                MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
947                MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
948            }),
949            _ => {
950                self.expected(value, message);
951                None
952            }
953        }
954    }
955
956    fn mapping<'value>(&mut self, value: &'value MergedValue, message: &str) -> Option<&'value [MergedEntry]> {
957        let Some(entries) = value.as_mapping() else {
958            self.expected(value, message);
959            return None;
960        };
961        Some(entries)
962    }
963
964    fn expected(&mut self, value: &MergedValue, message: &str) {
965        self.diagnostics.push(
966            Diagnostic::new(PROJECT_EXPECTED_FORM, Severity::Error, message).with_label(DiagnosticLabel::primary(
967                effective_span(value),
968                "unexpected merged value form",
969            )),
970        );
971    }
972
973    fn missing(&mut self, value: &MergedValue, message: &str) {
974        self.diagnostics.push(
975            Diagnostic::new(PROJECT_MISSING_FIELD, Severity::Error, message).with_label(DiagnosticLabel::primary(
976                effective_span(value),
977                "required field is missing",
978            )),
979        );
980    }
981
982    fn invalid(&mut self, span: SourceSpan, message: &str) {
983        self.diagnostics.push(
984            Diagnostic::new(PROJECT_INVALID_VALUE, Severity::Error, message)
985                .with_label(DiagnosticLabel::primary(span, "invalid native value")),
986        );
987    }
988
989    fn record_root_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
990        self.root_unmodeled.push(field_reference(path, entry));
991    }
992
993    fn record_pending_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
994        self.pending_unmodeled.push(field_reference(path, entry));
995    }
996}
997
998impl Builder<'_> {
999    fn ports(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<Vec<ProjectValue<Port>>>> {
1000        let Some(values) = value.as_sequence() else {
1001            self.expected(value, "service ports must be a sequence");
1002            return None;
1003        };
1004        let mut ports = Vec::new();
1005        for (index, item) in values.iter().enumerate() {
1006            let mut path = service_path.to_vec();
1007            path.push("ports".to_owned());
1008            path.push(index.to_string());
1009            let port = match item.kind() {
1010                MergedValueKind::Scalar(scalar) => Port::Short(ShortPort::parse(Located::new(
1011                    scalar.value().to_owned(),
1012                    effective_span(item),
1013                ))),
1014                MergedValueKind::Mapping(fields) => Port::Long(Box::new(self.long_port(item, fields, &path))),
1015                _ => {
1016                    self.expected(item, "service port must use scalar short syntax or mapping long syntax");
1017                    continue;
1018                }
1019            };
1020            ports.push(ProjectValue::new(port, item));
1021        }
1022        Some(ProjectValue::new(ports, value))
1023    }
1024
1025    fn long_port(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongPort {
1026        let mut port = LongPort::new(effective_span(value));
1027        let mut has_target = false;
1028        for field in fields {
1029            match field.key() {
1030                "target" => {
1031                    if let Some(value) = self.located_string(field.value(), "port target must be a scalar") {
1032                        port.set_target(value);
1033                        has_target = true;
1034                    }
1035                }
1036                "published" => self
1037                    .located_string(field.value(), "published port must be a scalar")
1038                    .into_iter()
1039                    .for_each(|value| port.set_published(value)),
1040                "host_ip" => self
1041                    .located_string(field.value(), "port host_ip must be a scalar")
1042                    .into_iter()
1043                    .for_each(|value| port.set_host_ip(value)),
1044                "protocol" => self
1045                    .located_string(field.value(), "port protocol must be a scalar")
1046                    .into_iter()
1047                    .for_each(|value| port.set_protocol(value)),
1048                "app_protocol" => self
1049                    .located_string(field.value(), "port app_protocol must be a scalar")
1050                    .into_iter()
1051                    .for_each(|value| port.set_app_protocol(value)),
1052                "mode" => self
1053                    .located_string(field.value(), "port mode must be a scalar")
1054                    .into_iter()
1055                    .for_each(|value| port.set_mode(value)),
1056                "name" => self
1057                    .located_string(field.value(), "port name must be a scalar")
1058                    .into_iter()
1059                    .for_each(|value| port.set_name(value)),
1060                _ => self.record_pending_unmodeled(path, field),
1061            }
1062        }
1063        if !has_target {
1064            self.missing(value, "long-syntax port is missing `target`");
1065        }
1066        port
1067    }
1068
1069    fn volumes(
1070        &mut self,
1071        value: &MergedValue,
1072        service_path: &[String],
1073    ) -> Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
1074        let Some(values) = value.as_sequence() else {
1075            self.expected(value, "service volumes must be a sequence");
1076            return None;
1077        };
1078        let mut mounts = Vec::new();
1079        for (index, item) in values.iter().enumerate() {
1080            let mut path = service_path.to_vec();
1081            path.push("volumes".to_owned());
1082            path.push(index.to_string());
1083            let mount = match item.kind() {
1084                MergedValueKind::Scalar(scalar) => VolumeMount::Short(ShortVolumeMount::new(Located::new(
1085                    scalar.value().to_owned(),
1086                    effective_span(item),
1087                ))),
1088                MergedValueKind::Mapping(fields) => VolumeMount::Long(Box::new(self.long_volume(item, fields, &path))),
1089                _ => {
1090                    self.expected(
1091                        item,
1092                        "service volume must use scalar short syntax or mapping long syntax",
1093                    );
1094                    continue;
1095                }
1096            };
1097            mounts.push(ProjectValue::new(mount, item));
1098        }
1099        Some(ProjectValue::new(mounts, value))
1100    }
1101
1102    fn long_volume(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongVolumeMount {
1103        let mut mount = LongVolumeMount::new(effective_span(value));
1104        let mut has_type = false;
1105        let mut has_target = false;
1106        for field in fields {
1107            match field.key() {
1108                "type" => {
1109                    if let Some(value) = self.located_string(field.value(), "volume type must be a scalar") {
1110                        mount.set_mount_type(Located::new(MountType::from_text(value.value().clone()), value.span()));
1111                        has_type = true;
1112                    }
1113                }
1114                "source" => self
1115                    .located_string(field.value(), "volume source must be a scalar")
1116                    .into_iter()
1117                    .for_each(|value| mount.set_source(value)),
1118                "target" => {
1119                    if let Some(value) = self.located_string(field.value(), "volume target must be a scalar") {
1120                        mount.set_target(value);
1121                        has_target = true;
1122                    }
1123                }
1124                "read_only" => self
1125                    .located_boolean(field.value(), "volume read_only must be a boolean")
1126                    .into_iter()
1127                    .for_each(|value| mount.set_read_only(value)),
1128                "bind" => self
1129                    .bind_options(field.value(), path)
1130                    .into_iter()
1131                    .for_each(|value| mount.set_bind(value)),
1132                _ => self.record_pending_unmodeled(path, field),
1133            }
1134        }
1135        if !has_type {
1136            self.missing(value, "long-syntax volume is missing `type`");
1137        }
1138        if !has_target {
1139            self.missing(value, "long-syntax volume is missing `target`");
1140        }
1141        mount
1142    }
1143
1144    fn bind_options(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<BindOptions> {
1145        let fields = self.mapping(value, "volume bind options must be a mapping")?;
1146        let mut bind = BindOptions::new(effective_span(value));
1147        let mut path = parent_path.to_vec();
1148        path.push("bind".to_owned());
1149        for field in fields {
1150            match field.key() {
1151                "propagation" => self
1152                    .located_string(field.value(), "bind propagation must be a scalar")
1153                    .into_iter()
1154                    .for_each(|value| bind.set_propagation(value)),
1155                "create_host_path" => self
1156                    .located_boolean(field.value(), "bind create_host_path must be a boolean")
1157                    .into_iter()
1158                    .for_each(|value| bind.set_create_host_path(value)),
1159                "selinux" => {
1160                    if let Some(value) = self.located_string(field.value(), "bind SELinux mode must be a scalar") {
1161                        let mode = match value.value().as_str() {
1162                            "z" => Some(SelinuxRelabel::Shared),
1163                            "Z" => Some(SelinuxRelabel::Private),
1164                            _ => None,
1165                        };
1166                        if let Some(mode) = mode {
1167                            bind.set_selinux(Located::new(mode, value.span()));
1168                        } else {
1169                            self.invalid(value.span(), "bind SELinux mode must be `z` or `Z`");
1170                        }
1171                    }
1172                }
1173                _ => self.record_pending_unmodeled(&path, field),
1174            }
1175        }
1176        Some(bind)
1177    }
1178
1179    fn service_networks(
1180        &mut self,
1181        value: &MergedValue,
1182        service_path: &[String],
1183    ) -> Option<ProjectValue<ServiceNetworks>> {
1184        let span = effective_span(value);
1185        let networks = match value.kind() {
1186            MergedValueKind::Sequence(values) => {
1187                let mut names = Vec::new();
1188                for value in values {
1189                    names.push(self.located_string(value, "service network name must be a scalar")?);
1190                }
1191                ServiceNetworks::Short { span, names }
1192            }
1193            MergedValueKind::Mapping(entries) => {
1194                let mut networks = Vec::new();
1195                for entry in entries {
1196                    let mut path = service_path.to_vec();
1197                    path.push("networks".to_owned());
1198                    path.push(entry.key().to_owned());
1199                    networks.push(self.service_network(entry, &path)?);
1200                }
1201                ServiceNetworks::Long { span, networks }
1202            }
1203            _ => {
1204                self.expected(value, "service networks must be a sequence or mapping");
1205                return None;
1206            }
1207        };
1208        Some(ProjectValue::new(networks, value))
1209    }
1210
1211    fn service_network(&mut self, entry: &MergedEntry, path: &[String]) -> Option<ServiceNetwork> {
1212        let value = entry.value();
1213        let span = effective_span(value);
1214        let mut network = ServiceNetwork::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1215        let fields = match value.kind() {
1216            MergedValueKind::Null(_) => return Some(network),
1217            MergedValueKind::Mapping(fields) => fields,
1218            _ => {
1219                self.expected(value, "service network attachment must be a mapping or null");
1220                return None;
1221            }
1222        };
1223        for field in fields {
1224            match field.key() {
1225                "aliases" => self
1226                    .located_string_sequence(field.value(), "network aliases must be a sequence")
1227                    .into_iter()
1228                    .for_each(|value| network.set_aliases(value)),
1229                "interface_name" => self
1230                    .located_string(field.value(), "network interface_name must be a scalar")
1231                    .into_iter()
1232                    .for_each(|value| network.set_interface_name(value)),
1233                "ipv4_address" => self
1234                    .located_string(field.value(), "network ipv4_address must be a scalar")
1235                    .into_iter()
1236                    .for_each(|value| network.set_ipv4_address(value)),
1237                "ipv6_address" => self
1238                    .located_string(field.value(), "network ipv6_address must be a scalar")
1239                    .into_iter()
1240                    .for_each(|value| network.set_ipv6_address(value)),
1241                "link_local_ips" => self
1242                    .located_string_sequence(field.value(), "link_local_ips must be a sequence")
1243                    .into_iter()
1244                    .for_each(|value| network.set_link_local_ips(value)),
1245                "mac_address" => self
1246                    .located_string(field.value(), "network mac_address must be a scalar")
1247                    .into_iter()
1248                    .for_each(|value| network.set_mac_address(value)),
1249                "driver_opts" => self
1250                    .key_value_mapping(field.value(), "network driver_opts must be a mapping")
1251                    .into_iter()
1252                    .for_each(|value| network.set_driver_opts(value)),
1253                "gw_priority" => self
1254                    .located_string(field.value(), "network gw_priority must be a scalar")
1255                    .into_iter()
1256                    .for_each(|value| network.set_gw_priority(value)),
1257                "priority" => self
1258                    .located_string(field.value(), "network priority must be a scalar")
1259                    .into_iter()
1260                    .for_each(|value| network.set_priority(value)),
1261                _ => self.record_pending_unmodeled(path, field),
1262            }
1263        }
1264        Some(network)
1265    }
1266
1267    fn located_boolean(&mut self, value: &MergedValue, message: &str) -> Option<Located<BooleanValue>> {
1268        let scalar = self.scalar(value, message)?;
1269        let boolean = if scalar.kind() == MergedScalarKind::Boolean {
1270            BooleanValue::Literal(scalar.value().eq_ignore_ascii_case("true"))
1271        } else if scalar.value().contains('$') {
1272            BooleanValue::Expression(scalar.value().to_owned())
1273        } else {
1274            self.invalid(effective_span(value), message);
1275            return None;
1276        };
1277        Some(Located::new(boolean, effective_span(value)))
1278    }
1279
1280    fn located_string_sequence(&mut self, value: &MergedValue, message: &str) -> Option<Vec<Located<String>>> {
1281        let Some(values) = value.as_sequence() else {
1282            self.expected(value, message);
1283            return None;
1284        };
1285        let mut strings = Vec::new();
1286        for value in values {
1287            strings.push(self.located_string(value, "sequence item must be a scalar")?);
1288        }
1289        Some(strings)
1290    }
1291
1292    fn key_value_mapping(&mut self, value: &MergedValue, message: &str) -> Option<Vec<KeyValueEntry>> {
1293        let Some(entries) = value.as_mapping() else {
1294            self.expected(value, message);
1295            return None;
1296        };
1297        let mut values = Vec::new();
1298        for entry in entries {
1299            let scalar = self.compose_scalar(entry.value(), "mapping value must be a scalar or null")?;
1300            let value_span = effective_span(entry.value());
1301            values.push(KeyValueEntry::new(
1302                Located::new(entry.key().to_owned(), entry_span(entry)),
1303                Located::new(scalar, value_span),
1304                value_span,
1305            ));
1306        }
1307        Some(values)
1308    }
1309
1310    fn network_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<NetworkDefinition>> {
1311        let Some(entries) = self.mapping(value, "top-level networks must be a mapping") else {
1312            return Vec::new();
1313        };
1314        entries
1315            .iter()
1316            .filter_map(|entry| {
1317                let definition = self.network_definition(entry)?;
1318                Some(ProjectResource {
1319                    name: ProjectKey::from_entry(entry),
1320                    definition: ProjectValue::new(definition, entry.value()),
1321                })
1322            })
1323            .collect()
1324    }
1325
1326    fn network_definition(&mut self, entry: &MergedEntry) -> Option<NetworkDefinition> {
1327        let value = entry.value();
1328        let span = effective_span(value);
1329        let mut network = NetworkDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1330        let fields = match value.kind() {
1331            MergedValueKind::Null(_) => return Some(network),
1332            MergedValueKind::Mapping(fields) => fields,
1333            _ => {
1334                self.expected(value, "network definition must be a mapping or null");
1335                return None;
1336            }
1337        };
1338        let path = ["networks".to_owned(), entry.key().to_owned()];
1339        for field in fields {
1340            match field.key() {
1341                "driver" => self
1342                    .located_string(field.value(), "network driver must be a scalar")
1343                    .into_iter()
1344                    .for_each(|value| network.set_driver(value)),
1345                "driver_opts" => self
1346                    .key_value_mapping(field.value(), "network driver_opts must be a mapping")
1347                    .into_iter()
1348                    .for_each(|value| network.set_driver_opts(value)),
1349                "attachable" => self
1350                    .located_boolean(field.value(), "network attachable must be a boolean")
1351                    .into_iter()
1352                    .for_each(|value| network.set_attachable(value)),
1353                "enable_ipv4" => self
1354                    .located_boolean(field.value(), "network enable_ipv4 must be a boolean")
1355                    .into_iter()
1356                    .for_each(|value| network.set_enable_ipv4(value)),
1357                "enable_ipv6" => self
1358                    .located_boolean(field.value(), "network enable_ipv6 must be a boolean")
1359                    .into_iter()
1360                    .for_each(|value| network.set_enable_ipv6(value)),
1361                "external" => self
1362                    .located_boolean(field.value(), "network external must be a boolean")
1363                    .into_iter()
1364                    .for_each(|value| network.set_external(value)),
1365                "internal" => self
1366                    .located_boolean(field.value(), "network internal must be a boolean")
1367                    .into_iter()
1368                    .for_each(|value| network.set_internal(value)),
1369                "ipam" => self
1370                    .ipam(field.value(), &path)
1371                    .into_iter()
1372                    .for_each(|value| network.set_ipam(value)),
1373                "labels" => self
1374                    .labels(field.value())
1375                    .into_iter()
1376                    .for_each(|value| network.set_labels(value)),
1377                "name" => self
1378                    .located_string(field.value(), "network custom name must be a scalar")
1379                    .into_iter()
1380                    .for_each(|value| network.set_custom_name(value)),
1381                _ => self.record_root_unmodeled(&path, field),
1382            }
1383        }
1384        Some(network)
1385    }
1386
1387    fn ipam(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Ipam> {
1388        let fields = self.mapping(value, "network IPAM must be a mapping")?;
1389        let mut ipam = Ipam::new(effective_span(value));
1390        let mut path = parent_path.to_vec();
1391        path.push("ipam".to_owned());
1392        for field in fields {
1393            match field.key() {
1394                "driver" => self
1395                    .located_string(field.value(), "IPAM driver must be a scalar")
1396                    .into_iter()
1397                    .for_each(|value| ipam.set_driver(value)),
1398                "config" => self
1399                    .ipam_configs(field.value(), &path)
1400                    .into_iter()
1401                    .for_each(|value| ipam.set_config(value)),
1402                "options" => self
1403                    .key_value_mapping(field.value(), "IPAM options must be a mapping")
1404                    .into_iter()
1405                    .for_each(|value| ipam.set_options(value)),
1406                _ => self.record_root_unmodeled(&path, field),
1407            }
1408        }
1409        Some(ipam)
1410    }
1411
1412    fn ipam_configs(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Vec<IpamConfig>> {
1413        let Some(values) = value.as_sequence() else {
1414            self.expected(value, "IPAM config must be a sequence");
1415            return None;
1416        };
1417        let mut configs = Vec::new();
1418        for (index, value) in values.iter().enumerate() {
1419            let Some(fields) = value.as_mapping() else {
1420                self.expected(value, "IPAM config entry must be a mapping");
1421                continue;
1422            };
1423            let mut config = IpamConfig::new(effective_span(value));
1424            let mut path = parent_path.to_vec();
1425            path.push("config".to_owned());
1426            path.push(index.to_string());
1427            for field in fields {
1428                match field.key() {
1429                    "subnet" => self
1430                        .located_string(field.value(), "IPAM subnet must be a scalar")
1431                        .into_iter()
1432                        .for_each(|value| config.set_subnet(value)),
1433                    "ip_range" => self
1434                        .located_string(field.value(), "IPAM ip_range must be a scalar")
1435                        .into_iter()
1436                        .for_each(|value| config.set_ip_range(value)),
1437                    "gateway" => self
1438                        .located_string(field.value(), "IPAM gateway must be a scalar")
1439                        .into_iter()
1440                        .for_each(|value| config.set_gateway(value)),
1441                    "aux_addresses" => self
1442                        .key_value_mapping(field.value(), "IPAM aux_addresses must be a mapping")
1443                        .into_iter()
1444                        .for_each(|value| config.set_aux_addresses(value)),
1445                    _ => self.record_root_unmodeled(&path, field),
1446                }
1447            }
1448            configs.push(config);
1449        }
1450        Some(configs)
1451    }
1452
1453    fn volume_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<VolumeDefinition>> {
1454        let Some(entries) = self.mapping(value, "top-level volumes must be a mapping") else {
1455            return Vec::new();
1456        };
1457        entries
1458            .iter()
1459            .filter_map(|entry| {
1460                let definition = self.volume_definition(entry)?;
1461                Some(ProjectResource {
1462                    name: ProjectKey::from_entry(entry),
1463                    definition: ProjectValue::new(definition, entry.value()),
1464                })
1465            })
1466            .collect()
1467    }
1468
1469    fn volume_definition(&mut self, entry: &MergedEntry) -> Option<VolumeDefinition> {
1470        let value = entry.value();
1471        let span = effective_span(value);
1472        let mut volume = VolumeDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1473        let fields = match value.kind() {
1474            MergedValueKind::Null(_) => return Some(volume),
1475            MergedValueKind::Mapping(fields) => fields,
1476            _ => {
1477                self.expected(value, "volume definition must be a mapping or null");
1478                return None;
1479            }
1480        };
1481        let path = ["volumes".to_owned(), entry.key().to_owned()];
1482        for field in fields {
1483            match field.key() {
1484                "driver" => self
1485                    .located_string(field.value(), "volume driver must be a scalar")
1486                    .into_iter()
1487                    .for_each(|value| volume.set_driver(value)),
1488                "driver_opts" => self
1489                    .key_value_mapping(field.value(), "volume driver_opts must be a mapping")
1490                    .into_iter()
1491                    .for_each(|value| volume.set_driver_opts(value)),
1492                "external" => self
1493                    .located_boolean(field.value(), "volume external must be a boolean")
1494                    .into_iter()
1495                    .for_each(|value| volume.set_external(value)),
1496                "labels" => self
1497                    .labels(field.value())
1498                    .into_iter()
1499                    .for_each(|value| volume.set_labels(value)),
1500                "name" => self
1501                    .located_string(field.value(), "volume custom name must be a scalar")
1502                    .into_iter()
1503                    .for_each(|value| volume.set_custom_name(value)),
1504                _ => self.record_root_unmodeled(&path, field),
1505            }
1506        }
1507        Some(volume)
1508    }
1509
1510    fn config_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<ConfigDefinition>> {
1511        let Some(entries) = self.mapping(value, "top-level configs must be a mapping") else {
1512            return Vec::new();
1513        };
1514        entries
1515            .iter()
1516            .filter_map(|entry| {
1517                let definition = self.config_definition(entry)?;
1518                Some(ProjectResource {
1519                    name: ProjectKey::from_entry(entry),
1520                    definition: ProjectValue::new(definition, entry.value()),
1521                })
1522            })
1523            .collect()
1524    }
1525
1526    fn config_definition(&mut self, entry: &MergedEntry) -> Option<ConfigDefinition> {
1527        let value = entry.value();
1528        let span = effective_span(value);
1529        let mut config = ConfigDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1530        let fields = match value.kind() {
1531            MergedValueKind::Null(_) => return Some(config),
1532            MergedValueKind::Mapping(fields) => fields,
1533            _ => {
1534                self.expected(value, "config definition must be a mapping or null");
1535                return None;
1536            }
1537        };
1538        let path = ["configs".to_owned(), entry.key().to_owned()];
1539        for field in fields {
1540            match field.key() {
1541                "file" => self
1542                    .located_string(field.value(), "config file must be a scalar")
1543                    .into_iter()
1544                    .for_each(|value| config.set_file(value)),
1545                "environment" => self
1546                    .located_string(field.value(), "config environment must be a scalar")
1547                    .into_iter()
1548                    .for_each(|value| config.set_environment(value)),
1549                "content" => self
1550                    .located_string(field.value(), "config content must be a scalar")
1551                    .into_iter()
1552                    .for_each(|value| config.set_content(value)),
1553                "external" => self
1554                    .located_boolean(field.value(), "config external must be a boolean")
1555                    .into_iter()
1556                    .for_each(|value| config.set_external(value)),
1557                "name" => self
1558                    .located_string(field.value(), "config custom name must be a scalar")
1559                    .into_iter()
1560                    .for_each(|value| config.set_custom_name(value)),
1561                _ => self.record_root_unmodeled(&path, field),
1562            }
1563        }
1564        Some(config)
1565    }
1566
1567    fn secret_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<SecretDefinition>> {
1568        let Some(entries) = self.mapping(value, "top-level secrets must be a mapping") else {
1569            return Vec::new();
1570        };
1571        entries
1572            .iter()
1573            .filter_map(|entry| {
1574                let definition = self.secret_definition(entry)?;
1575                Some(ProjectResource {
1576                    name: ProjectKey::from_entry(entry),
1577                    definition: ProjectValue::new(definition, entry.value()),
1578                })
1579            })
1580            .collect()
1581    }
1582
1583    fn secret_definition(&mut self, entry: &MergedEntry) -> Option<SecretDefinition> {
1584        let value = entry.value();
1585        let span = effective_span(value);
1586        let mut secret = SecretDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1587        let fields = match value.kind() {
1588            MergedValueKind::Null(_) => return Some(secret),
1589            MergedValueKind::Mapping(fields) => fields,
1590            _ => {
1591                self.expected(value, "secret definition must be a mapping or null");
1592                return None;
1593            }
1594        };
1595        let path = ["secrets".to_owned(), entry.key().to_owned()];
1596        for field in fields {
1597            match field.key() {
1598                "file" => self
1599                    .located_string(field.value(), "secret file must be a scalar")
1600                    .into_iter()
1601                    .for_each(|value| secret.set_file(value)),
1602                "environment" => self
1603                    .located_string(field.value(), "secret environment must be a scalar")
1604                    .into_iter()
1605                    .for_each(|value| secret.set_environment(value)),
1606                "external" => self
1607                    .located_boolean(field.value(), "secret external must be a boolean")
1608                    .into_iter()
1609                    .for_each(|value| secret.set_external(value)),
1610                "name" => self
1611                    .located_string(field.value(), "secret custom name must be a scalar")
1612                    .into_iter()
1613                    .for_each(|value| secret.set_custom_name(value)),
1614                _ => self.record_root_unmodeled(&path, field),
1615            }
1616        }
1617        Some(secret)
1618    }
1619
1620    fn labels(&mut self, value: &MergedValue) -> Option<Labels> {
1621        let span = effective_span(value);
1622        match value.kind() {
1623            MergedValueKind::Sequence(_) => self
1624                .located_string_sequence(value, "labels must be a scalar sequence")
1625                .map(|values| Labels::List { span, values }),
1626            MergedValueKind::Mapping(_) => self
1627                .key_value_mapping(value, "labels must be a scalar mapping")
1628                .map(|entries| Labels::Map { span, entries }),
1629            _ => {
1630                self.expected(value, "labels must be a sequence or mapping");
1631                None
1632            }
1633        }
1634    }
1635}
1636
1637fn field_reference(path: &[String], entry: &MergedEntry) -> ProjectFieldReference {
1638    let mut complete_path = path.to_vec();
1639    complete_path.push(entry.key().to_owned());
1640    ProjectFieldReference {
1641        path: complete_path,
1642        key: ProjectKey::from_entry(entry),
1643        provenance: entry.value().provenance().clone(),
1644        extension: entry.key().starts_with("x-"),
1645        sensitive: entry.value().is_sensitive(),
1646    }
1647}
1648
1649fn effective_span(value: &MergedValue) -> SourceSpan {
1650    value
1651        .provenance()
1652        .effective_source()
1653        .or_else(|| value.provenance().sources().first().copied())
1654        .unwrap_or_else(|| SourceSpan::from_valid_offsets(SourceId::new(0), 0, 0))
1655}
1656
1657fn entry_span(entry: &MergedEntry) -> SourceSpan {
1658    entry
1659        .key_sources()
1660        .last()
1661        .copied()
1662        .or_else(|| entry.key_sources().first().copied())
1663        .unwrap_or_else(|| effective_span(entry.value()))
1664}