Skip to main content

compose_lens/
project.rs

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