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