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