Skip to main content

boxferry_compose/
export.rs

1//! Neutral-application-to-Compose planning through `ComposeLens`'s validated generator.
2
3use std::convert::TryFrom;
4
5use boxferry_engine::{
6    ConversionKind, ConversionOutcome, ConversionPlan, Diagnostic, DiagnosticCode, DiagnosticField, DiagnosticValue,
7    ExportAdapter, InvalidDiagnosticCode, PlanError, PlatformVersion, Severity, TargetProfile,
8};
9use boxferry_model::{
10    Application, Command, Device, Entrypoint, EnvironmentFileFormat, EnvironmentFileSyntax, EnvironmentValue,
11    HostAddressKind, MountSource, Network, ProtectedString, Protocol, Provenance, ProvenanceKind, PullPolicy,
12    ResourceOwnership, RestartPolicy, SecurityOption, SelinuxRelabel, Service, Sourced, Volume,
13};
14use compose_lens::{
15    model::{MemLimitUnit, ShmSizeUnit},
16    render::{
17        ComposeDocumentBuilder, GeneratedAnnotation, GeneratedCommand, GeneratedComposeDocument, GeneratedDevice,
18        GeneratedDns, GeneratedDnsSearch, GeneratedEntrypoint, GeneratedEnvironment, GeneratedEnvironmentFile,
19        GeneratedEnvironmentFileFormat, GeneratedExtraHost, GeneratedHostname, GeneratedLabel, GeneratedLogging,
20        GeneratedLoggingOption, GeneratedLoggingOptionValue, GeneratedLongDevice, GeneratedMemLimit, GeneratedMount,
21        GeneratedNetworkAttachment, GeneratedNetworkDefinition, GeneratedNetworkDriverOption,
22        GeneratedNetworkDriverOptionValue, GeneratedPidsLimit, GeneratedPort, GeneratedProtocol, GeneratedPullPolicy,
23        GeneratedResource, GeneratedRestartPolicy, GeneratedSelinux, GeneratedService, GeneratedShmSize,
24        GeneratedString, GeneratedSysctl, GeneratedSysctls, GeneratedTmpfs, GeneratedUlimit, GeneratedUlimits,
25        GeneratedVolumeDefinition, GeneratedVolumeDriverOption, GeneratedVolumeDriverOptionValue, GenerationError,
26    },
27    source::SourceId,
28    validation::{
29        CompatibilityClassification, CompatibilityFeature, CompatibilityProfile, ContainerRuntime,
30        ImplementationVersion,
31    },
32};
33
34/// Target implementation name for Docker Compose output.
35pub const DOCKER_COMPOSE_TARGET: &str = "docker-compose";
36
37/// Target implementation name for the independent `containers/podman-compose` provider.
38pub const PODMAN_COMPOSE_TARGET: &str = "podman-compose";
39
40/// Exact backend runtime used by the selected Compose provider.
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42#[non_exhaustive]
43pub enum ComposeRuntime {
44    /// Docker Engine at an exact version.
45    DockerEngine(PlatformVersion),
46    /// Podman at an exact version.
47    Podman(PlatformVersion),
48}
49
50/// Loss-aware exporter for deterministic, parse-back-validated Compose YAML.
51#[derive(Clone, Debug)]
52pub struct ComposeExporter {
53    codes: Codes,
54    runtime: Option<ComposeRuntime>,
55}
56
57impl ComposeExporter {
58    /// Creates an exporter with no assumed backend runtime.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`InvalidDiagnosticCode`] only when an embedded stable code is malformed.
63    pub fn new() -> Result<Self, InvalidDiagnosticCode> {
64        Ok(Self {
65            codes: Codes {
66                invalid_target: DiagnosticCode::new("BFC0006")?,
67                unsupported: DiagnosticCode::new("BFC0007")?,
68                generation: DiagnosticCode::new("BFC0008")?,
69                compatibility: DiagnosticCode::new("BFC0009")?,
70            },
71            runtime: None,
72        })
73    }
74
75    /// Attaches the exact backend runtime selected by the caller.
76    #[must_use]
77    pub const fn with_runtime(mut self, runtime: ComposeRuntime) -> Self {
78        self.runtime = Some(runtime);
79        self
80    }
81
82    /// Returns the explicitly selected backend runtime, when present.
83    #[must_use]
84    pub const fn runtime(&self) -> Option<ComposeRuntime> {
85        self.runtime
86    }
87}
88
89impl ExportAdapter for ComposeExporter {
90    type Output = GeneratedComposeDocument;
91
92    fn plan(
93        &self,
94        application: &Application,
95        target: &TargetProfile,
96    ) -> Result<ConversionPlan<Self::Output>, PlanError> {
97        let mut mapping = Mapping::new(self, application, target);
98        if mapping.validate_target() {
99            mapping.map_application();
100        }
101        let (candidate, outcomes, diagnostics) = mapping.finish();
102        ConversionPlan::new(candidate, outcomes, diagnostics)
103    }
104}
105
106#[derive(Clone, Debug)]
107struct Codes {
108    invalid_target: DiagnosticCode,
109    unsupported: DiagnosticCode,
110    generation: DiagnosticCode,
111    compatibility: DiagnosticCode,
112}
113
114struct Mapping<'a> {
115    exporter: &'a ComposeExporter,
116    application: &'a Application,
117    target: &'a TargetProfile,
118    compatibility: Option<CompatibilityProfile>,
119    builder: Option<ComposeDocumentBuilder>,
120    outcomes: Vec<ConversionOutcome>,
121    diagnostics: Vec<Diagnostic>,
122    generation_failed: bool,
123}
124
125impl<'a> Mapping<'a> {
126    fn new(exporter: &'a ComposeExporter, application: &'a Application, target: &'a TargetProfile) -> Self {
127        Self {
128            exporter,
129            application,
130            target,
131            compatibility: None,
132            builder: Some(ComposeDocumentBuilder::new()),
133            outcomes: Vec::new(),
134            diagnostics: Vec::new(),
135            generation_failed: false,
136        }
137    }
138
139    fn validate_target(&mut self) -> bool {
140        let versions = self.target.versions();
141        let Some(maximum) = versions.maximum() else {
142            self.invalid(
143                self.exporter.codes.invalid_target.clone(),
144                "target.versions",
145                "Compose output requires one exact provider version",
146                "set the minimum and maximum to the same exact Compose provider version",
147                &[],
148            );
149            return false;
150        };
151        if maximum != versions.minimum() {
152            self.invalid(
153                self.exporter.codes.invalid_target.clone(),
154                "target.versions",
155                "Compose output requires one exact provider version",
156                "ComposeLens compatibility evidence is evaluated for exact provider versions, not a range",
157                &[],
158            );
159            return false;
160        }
161        let Some(version) = implementation_version(versions.minimum()) else {
162            self.invalid(
163                self.exporter.codes.invalid_target.clone(),
164                "target.versions",
165                "Compose provider version is outside the supported numeric range",
166                "each version component must fit into an unsigned 32-bit integer",
167                &[],
168            );
169            return false;
170        };
171        let mut profile = match self.target.implementation() {
172            DOCKER_COMPOSE_TARGET => CompatibilityProfile::docker_compose(version),
173            PODMAN_COMPOSE_TARGET => CompatibilityProfile::podman_compose(version),
174            _ => {
175                self.invalid(
176                    self.exporter.codes.invalid_target.clone(),
177                    "target.implementation",
178                    "Compose output requires a recognized provider",
179                    "select `docker-compose` or `podman-compose`; `podman compose` is a wrapper, not a provider",
180                    &[],
181                );
182                return false;
183            }
184        };
185        if let Some(runtime) = self.exporter.runtime {
186            let runtime = match runtime {
187                ComposeRuntime::DockerEngine(version) => {
188                    implementation_version(version).map(ContainerRuntime::DockerEngine)
189                }
190                ComposeRuntime::Podman(version) => implementation_version(version).map(ContainerRuntime::Podman),
191            };
192            let Some(runtime) = runtime else {
193                self.invalid(
194                    self.exporter.codes.invalid_target.clone(),
195                    "target.runtime.version",
196                    "Compose runtime version is outside the supported numeric range",
197                    "each version component must fit into an unsigned 32-bit integer",
198                    &[],
199                );
200                return false;
201            };
202            profile = profile.with_runtime(runtime);
203        }
204        self.compatibility = Some(profile);
205        true
206    }
207
208    fn map_application(&mut self) {
209        let mut builder = self.builder.take().unwrap_or_default();
210        if let Err(error) = builder.set_name(self.application.name().as_str()) {
211            self.generation_error("application.name", &error, &[]);
212        } else {
213            self.exact("application.name", &[]);
214        }
215
216        for network in self.application.networks() {
217            self.map_network_definition(network, &mut builder);
218        }
219        for volume in self.application.volumes() {
220            self.map_volume_definition(volume, &mut builder);
221        }
222
223        for acquisition in self.application.image_acquisitions() {
224            self.unsupported(
225                &format!("image_acquisitions.{}", acquisition.value().name().as_str()),
226                "ComposeLens 0.1.16 generation does not expose image-acquisition declarations",
227                acquisition.origins(),
228            );
229        }
230        for build in self.application.image_builds() {
231            self.unsupported(
232                &format!("image_builds.{}", build.value().name().as_str()),
233                "ComposeLens 0.1.16 generation does not expose build declarations",
234                build.origins(),
235            );
236        }
237
238        for config in self.application.configs() {
239            self.unsupported(
240                &format!("configs.{}", config.value().name().as_str()),
241                "the current Compose generation boundary does not yet expose top-level config definitions",
242                config.origins(),
243            );
244        }
245        for secret in self.application.secrets() {
246            self.unsupported(
247                &format!("secrets.{}", secret.value().name().as_str()),
248                "the current Compose generation boundary does not yet expose top-level secret definitions",
249                secret.origins(),
250            );
251        }
252        for group in self.application.service_groups() {
253            self.report_service_group_loss(group);
254        }
255
256        for service in self.application.services() {
257            if let Some(generated) = self.map_service(service) {
258                let subject = format!("services.{}", service.value().name().as_str());
259                if let Err(error) = builder.add_service(generated) {
260                    self.generation_error(&subject, &error, service.origins());
261                }
262            }
263        }
264        self.builder = Some(builder);
265    }
266
267    fn map_resource(
268        &mut self,
269        name: &str,
270        ownership: ResourceOwnership,
271        origins: &[Provenance],
272        subject: &str,
273    ) -> Option<GeneratedResource> {
274        let resource = match ownership {
275            ResourceOwnership::Application => GeneratedResource::application(name),
276            ResourceOwnership::External => GeneratedResource::external(name),
277            ResourceOwnership::Implicit => {
278                self.unsupported(
279                    subject,
280                    "implicit source lifecycle is emitted as an externally managed Compose resource",
281                    origins,
282                );
283                GeneratedResource::external(name)
284            }
285            ResourceOwnership::Uncertain => {
286                self.unsupported(
287                    subject,
288                    "runtime inspection did not determine resource ownership; output conservatively reuses the existing resource",
289                    origins,
290                );
291                GeneratedResource::external(name)
292            }
293            _ => {
294                self.unsupported(
295                    subject,
296                    "resource ownership variant is newer than this Compose adapter",
297                    origins,
298                );
299                GeneratedResource::external(name)
300            }
301        };
302        let mut resource = match resource {
303            Ok(resource) => resource,
304            Err(error) => {
305                self.generation_error(subject, &error, origins);
306                return None;
307            }
308        };
309        if origins
310            .iter()
311            .any(|origin| origin.kind() == ProvenanceKind::RuntimeObservation)
312        {
313            if let Err(error) = resource.set_custom_name(name) {
314                self.generation_error(subject, &error, origins);
315                return None;
316            }
317        }
318        if matches!(ownership, ResourceOwnership::Application | ResourceOwnership::External) {
319            self.exact(subject, origins);
320        }
321        Some(resource)
322    }
323
324    #[allow(clippy::too_many_lines)]
325    fn map_network_definition(&mut self, sourced: &Sourced<Network>, builder: &mut ComposeDocumentBuilder) {
326        let network = sourced.value();
327        let subject = format!("networks.{}", network.name().as_str());
328        if network.ownership() != ResourceOwnership::Application {
329            let Some(mut generated) = self.map_resource(
330                network.name().as_str(),
331                network.ownership(),
332                sourced.origins(),
333                &subject,
334            ) else {
335                self.generation_failed = true;
336                return;
337            };
338            if let Some(runtime_name) = network.runtime_name() {
339                if runtime_name.value().is_sensitive() {
340                    self.unsupported(
341                        &format!("{subject}.name"),
342                        "sensitive runtime network names cannot be passed to ComposeLens' plain-string generator",
343                        runtime_name.origins(),
344                    );
345                } else if let Err(error) = generated.set_custom_name(runtime_name.value().expose()) {
346                    self.generation_error(&format!("{subject}.name"), &error, runtime_name.origins());
347                } else {
348                    self.exact(format!("{subject}.name"), runtime_name.origins());
349                }
350            }
351            self.report_external_network_configuration(network, &subject);
352            if let Err(error) = builder.add_network(generated) {
353                self.generation_error(&subject, &error, sourced.origins());
354            }
355            return;
356        }
357
358        let mut generated = match GeneratedNetworkDefinition::application(network.name().as_str()) {
359            Ok(value) => value,
360            Err(error) => {
361                self.generation_error(&subject, &error, sourced.origins());
362                return;
363            }
364        };
365        let mut valid = true;
366        if let Some(runtime_name) = network.runtime_name() {
367            if runtime_name.value().is_sensitive() {
368                self.unsupported(
369                    &format!("{subject}.name"),
370                    "sensitive runtime network names cannot be passed to ComposeLens' plain-string generator",
371                    runtime_name.origins(),
372                );
373            } else {
374                match generated.set_custom_name(runtime_name.value().expose()) {
375                    Ok(()) => self.exact(format!("{subject}.name"), runtime_name.origins()),
376                    Err(error) => {
377                        self.generation_error(&format!("{subject}.name"), &error, runtime_name.origins());
378                        valid = false;
379                    }
380                }
381            }
382        }
383        if let Some(driver) = network.driver() {
384            match generated_string(driver.value()).and_then(|value| generated.set_driver(value)) {
385                Ok(()) => self.exact(format!("{subject}.driver"), driver.origins()),
386                Err(error) => {
387                    self.generation_error(&format!("{subject}.driver"), &error, driver.origins());
388                    valid = false;
389                }
390            }
391        }
392        if let Some(options) = network.driver_options() {
393            let origins = collection_or_item_origins(options, network.driver_options_origins());
394            let values: Result<Vec<_>, _> = options
395                .iter()
396                .map(|option| {
397                    GeneratedNetworkDriverOption::new(
398                        option.value().name().value().as_str(),
399                        GeneratedNetworkDriverOptionValue::String(generated_string(option.value().value().value())?),
400                    )
401                })
402                .collect();
403            match values.and_then(|values| generated.set_driver_opts(values)) {
404                Ok(()) => self.exact(format!("{subject}.driver_opts"), &origins),
405                Err(error) => {
406                    self.generation_error(&format!("{subject}.driver_opts"), &error, &origins);
407                    valid = false;
408                }
409            }
410        }
411        if let Some(ipv6) = network.ipv6() {
412            match generated.set_enable_ipv6(*ipv6.value()) {
413                Ok(()) => self.exact(format!("{subject}.enable_ipv6"), ipv6.origins()),
414                Err(error) => {
415                    self.generation_error(&format!("{subject}.enable_ipv6"), &error, ipv6.origins());
416                    valid = false;
417                }
418            }
419        }
420        if let Some(internal) = network.internal() {
421            match generated.set_internal(*internal.value()) {
422                Ok(()) => self.exact(format!("{subject}.internal"), internal.origins()),
423                Err(error) => {
424                    self.generation_error(&format!("{subject}.internal"), &error, internal.origins());
425                    valid = false;
426                }
427            }
428        }
429        if let Some(labels) = network.labels() {
430            let origins = collection_or_item_origins(labels, network.labels_origins());
431            let values: Result<Vec<_>, _> = labels
432                .iter()
433                .map(|label| {
434                    GeneratedLabel::new(label.value().name().as_str(), generated_string(label.value().value())?)
435                })
436                .collect();
437            match values.and_then(|values| generated.set_labels(values)) {
438                Ok(()) => self.exact(format!("{subject}.labels"), &origins),
439                Err(error) => {
440                    self.generation_error(&format!("{subject}.labels"), &error, &origins);
441                    valid = false;
442                }
443            }
444        }
445        if let Some(driver) = network.ipam_driver() {
446            self.unsupported(
447                &format!("{subject}.ipam.driver"),
448                "ComposeLens 0.1.16 has no generated IPAM definition API",
449                driver.origins(),
450            );
451        }
452        if let Some(configs) = network.ipam_configs() {
453            self.unsupported(
454                &format!("{subject}.ipam.config"),
455                "ComposeLens 0.1.16 has no generated IPAM definition API",
456                &collection_or_item_origins(configs, network.ipam_configs_origins()),
457            );
458        }
459        if valid {
460            if let Err(error) = builder.add_network_definition(generated) {
461                self.generation_error(&subject, &error, sourced.origins());
462            }
463        }
464    }
465
466    fn report_external_network_configuration(&mut self, network: &Network, subject: &str) {
467        let reason = "Compose external networks may only declare their platform name";
468        if let Some(driver) = network.driver() {
469            self.unsupported(&format!("{subject}.driver"), reason, driver.origins());
470        }
471        if let Some(values) = network.driver_options() {
472            self.unsupported(
473                &format!("{subject}.driver_opts"),
474                reason,
475                &collection_or_item_origins(values, network.driver_options_origins()),
476            );
477        }
478        if let Some(value) = network.internal() {
479            self.unsupported(&format!("{subject}.internal"), reason, value.origins());
480        }
481        if let Some(value) = network.ipv6() {
482            self.unsupported(&format!("{subject}.enable_ipv6"), reason, value.origins());
483        }
484        if let Some(driver) = network.ipam_driver() {
485            self.unsupported(&format!("{subject}.ipam.driver"), reason, driver.origins());
486        }
487        if let Some(values) = network.ipam_configs() {
488            self.unsupported(
489                &format!("{subject}.ipam.config"),
490                reason,
491                &collection_or_item_origins(values, network.ipam_configs_origins()),
492            );
493        }
494        if let Some(values) = network.labels() {
495            self.unsupported(
496                &format!("{subject}.labels"),
497                reason,
498                &collection_or_item_origins(values, network.labels_origins()),
499            );
500        }
501    }
502
503    fn map_volume_definition(&mut self, sourced: &Sourced<Volume>, builder: &mut ComposeDocumentBuilder) {
504        let volume = sourced.value();
505        let subject = format!("volumes.{}", volume.name().as_str());
506        self.report_compose_native_only_volume_fields(volume, &subject);
507        if volume.ownership() != ResourceOwnership::Application {
508            let Some(mut generated) = self.map_external_volume_resource(volume, sourced.origins(), &subject) else {
509                self.generation_failed = true;
510                return;
511            };
512            if let Some(runtime_name) = volume.runtime_name() {
513                if runtime_name.value().is_sensitive() {
514                    self.unsupported(
515                        &format!("{subject}.name"),
516                        "sensitive runtime volume names cannot be passed to ComposeLens' plain-string generator",
517                        runtime_name.origins(),
518                    );
519                } else {
520                    match generated.set_custom_name(runtime_name.value().expose()) {
521                        Ok(()) => self.exact(format!("{subject}.name"), runtime_name.origins()),
522                        Err(error) => {
523                            self.generation_error(&format!("{subject}.name"), &error, runtime_name.origins());
524                        }
525                    }
526                }
527            }
528            self.report_external_volume_configuration(volume, &subject);
529            if let Err(error) = builder.add_volume(generated) {
530                self.generation_error(&subject, &error, sourced.origins());
531            }
532            return;
533        }
534
535        let mut generated = match GeneratedVolumeDefinition::application(volume.name().as_str()) {
536            Ok(value) => value,
537            Err(error) => {
538                self.generation_error(&subject, &error, sourced.origins());
539                return;
540            }
541        };
542        let mut valid = true;
543        if let Some(runtime_name) = volume.runtime_name() {
544            if runtime_name.value().is_sensitive() {
545                self.unsupported(
546                    &format!("{subject}.name"),
547                    "sensitive runtime volume names cannot be passed to ComposeLens' plain-string generator",
548                    runtime_name.origins(),
549                );
550            } else {
551                match generated.set_custom_name(runtime_name.value().expose()) {
552                    Ok(()) => self.exact(format!("{subject}.name"), runtime_name.origins()),
553                    Err(error) => {
554                        self.generation_error(&format!("{subject}.name"), &error, runtime_name.origins());
555                        valid = false;
556                    }
557                }
558            }
559        }
560        if let Some(driver) = volume.driver() {
561            match generated_string(driver.value()).and_then(|value| generated.set_driver(value)) {
562                Ok(()) => self.exact(format!("{subject}.driver"), driver.origins()),
563                Err(error) => {
564                    self.generation_error(&format!("{subject}.driver"), &error, driver.origins());
565                    valid = false;
566                }
567            }
568        }
569        self.map_local_volume_driver_options(volume, &mut generated, &subject, &mut valid);
570        if let Some(labels) = volume.labels() {
571            let origins = collection_or_item_origins(labels, volume.labels_origins());
572            let values: Result<Vec<_>, _> = labels
573                .iter()
574                .map(|label| {
575                    GeneratedLabel::new(label.value().name().as_str(), generated_string(label.value().value())?)
576                })
577                .collect();
578            match values.and_then(|values| generated.set_labels(values)) {
579                Ok(()) => self.exact(format!("{subject}.labels"), &origins),
580                Err(error) => {
581                    self.generation_error(&format!("{subject}.labels"), &error, &origins);
582                    valid = false;
583                }
584            }
585        }
586        if valid {
587            if let Err(error) = builder.add_volume_definition(generated) {
588                self.generation_error(&subject, &error, sourced.origins());
589            }
590        }
591    }
592
593    fn map_external_volume_resource(
594        &mut self,
595        volume: &Volume,
596        origins: &[Provenance],
597        subject: &str,
598    ) -> Option<GeneratedResource> {
599        let generated = match volume.ownership() {
600            ResourceOwnership::External => GeneratedResource::external(volume.name().as_str()),
601            ResourceOwnership::Implicit => {
602                self.unsupported(
603                    subject,
604                    "implicit source lifecycle is emitted as an externally managed Compose resource",
605                    origins,
606                );
607                GeneratedResource::external(volume.name().as_str())
608            }
609            ResourceOwnership::Uncertain => {
610                self.unsupported(
611                    subject,
612                    "runtime inspection did not determine volume ownership; output conservatively reuses the existing volume",
613                    origins,
614                );
615                GeneratedResource::external(volume.name().as_str())
616            }
617            ResourceOwnership::Application => return None,
618            _ => {
619                self.unsupported(
620                    subject,
621                    "volume ownership variant is newer than this Compose adapter",
622                    origins,
623                );
624                GeneratedResource::external(volume.name().as_str())
625            }
626        };
627        let mut generated = match generated {
628            Ok(generated) => generated,
629            Err(error) => {
630                self.generation_error(subject, &error, origins);
631                return None;
632            }
633        };
634        if origins
635            .iter()
636            .any(|origin| origin.kind() == ProvenanceKind::RuntimeObservation)
637            && volume.runtime_name().is_none()
638        {
639            if let Err(error) = generated.set_custom_name(volume.name().as_str()) {
640                self.generation_error(subject, &error, origins);
641                return None;
642            }
643        }
644        if matches!(volume.ownership(), ResourceOwnership::External) {
645            self.exact(subject, origins);
646        }
647        Some(generated)
648    }
649
650    fn map_local_volume_driver_options(
651        &mut self,
652        volume: &Volume,
653        generated: &mut GeneratedVolumeDefinition,
654        subject: &str,
655        valid: &mut bool,
656    ) {
657        let fields = [
658            ("type", volume.volume_type()),
659            ("device", volume.device()),
660            ("o", volume.options()),
661        ];
662        if fields.iter().all(|(_, value)| value.is_none()) {
663            return;
664        }
665        let local_driver = volume.driver().is_some_and(|driver| driver.value().expose() == "local");
666        if !local_driver {
667            for (field, value) in fields {
668                if let Some(value) = value {
669                    self.unsupported(
670                        &format!("{subject}.driver_opts.{field}"),
671                        "Compose local-driver options require an explicit `driver: local`",
672                        value.origins(),
673                    );
674                }
675            }
676            return;
677        }
678        let origins = fields
679            .iter()
680            .filter_map(|(_, value)| value.map(Sourced::origins))
681            .flatten()
682            .cloned()
683            .collect::<Vec<_>>();
684        let values: Result<Vec<_>, _> = fields
685            .into_iter()
686            .filter_map(|(name, value)| {
687                value.map(|value| {
688                    GeneratedVolumeDriverOption::new(
689                        name,
690                        GeneratedVolumeDriverOptionValue::String(generated_string(value.value())?),
691                    )
692                })
693            })
694            .collect();
695        match values.and_then(|values| generated.set_driver_opts(values)) {
696            Ok(()) => self.exact(format!("{subject}.driver_opts"), &origins),
697            Err(error) => {
698                self.generation_error(&format!("{subject}.driver_opts"), &error, &origins);
699                *valid = false;
700            }
701        }
702    }
703
704    fn report_external_volume_configuration(&mut self, volume: &Volume, subject: &str) {
705        let reason = "Compose external volumes may only declare platform name";
706        if let Some(driver) = volume.driver() {
707            self.unsupported(&format!("{subject}.driver"), reason, driver.origins());
708        }
709        for (field, value) in [
710            ("type", volume.volume_type()),
711            ("device", volume.device()),
712            ("o", volume.options()),
713        ] {
714            if let Some(value) = value {
715                self.unsupported(&format!("{subject}.driver_opts.{field}"), reason, value.origins());
716            }
717        }
718        if let Some(values) = volume.labels() {
719            self.unsupported(
720                &format!("{subject}.labels"),
721                reason,
722                &collection_or_item_origins(values, volume.labels_origins()),
723            );
724        }
725    }
726
727    fn report_compose_native_only_volume_fields(&mut self, volume: &Volume, subject: &str) {
728        for (name, value) in [
729            ("service_name", volume.service_name()),
730            ("user", volume.user()),
731            ("group", volume.group()),
732            ("uid", volume.uid()),
733            ("gid", volume.gid()),
734        ] {
735            if let Some(value) = value {
736                self.unsupported(
737                    &format!("{subject}.{name}"),
738                    "Quadlet-only volume setting has no reviewed Compose volume-definition mapping",
739                    value.origins(),
740                );
741            }
742        }
743        if let Some(value) = volume.copy() {
744            self.unsupported(
745                &format!("{subject}.copy"),
746                "Quadlet-only volume setting has no reviewed Compose volume-definition mapping",
747                value.origins(),
748            );
749        }
750        for (name, values, origins) in [
751            (
752                "containers_conf_modules",
753                volume.containers_conf_modules(),
754                volume.containers_conf_modules_origins(),
755            ),
756            ("global_args", volume.global_args(), volume.global_args_origins()),
757            ("podman_args", volume.podman_args(), volume.podman_args_origins()),
758        ] {
759            if let Some(values) = values {
760                self.unsupported(
761                    &format!("{subject}.{name}"),
762                    "Quadlet-only volume setting has no reviewed Compose volume-definition mapping",
763                    &collection_or_item_origins(values, origins),
764                );
765            }
766        }
767        if let Some(image) = volume.image_source() {
768            self.unsupported(
769                &format!("{subject}.image"),
770                "Quadlet volume image source has no reviewed Compose volume-definition mapping",
771                image.origins(),
772            );
773        }
774    }
775
776    fn map_service(&mut self, sourced: &Sourced<Service>) -> Option<GeneratedService> {
777        let service = sourced.value();
778        let service_name = service.name().as_str();
779        let service_subject = format!("services.{service_name}");
780        let mut generated = match GeneratedService::new(service_name) {
781            Ok(generated) => generated,
782            Err(error) => {
783                self.generation_error(&service_subject, &error, sourced.origins());
784                return None;
785            }
786        };
787        if let Some(runtime_name) = service.runtime_name() {
788            match generated_string(runtime_name.value()).and_then(|name| generated.set_container_name(name)) {
789                Ok(()) => self.exact(format!("{service_subject}.container_name"), runtime_name.origins()),
790                Err(error) => {
791                    self.generation_error(
792                        &format!("{service_subject}.container_name"),
793                        &error,
794                        runtime_name.origins(),
795                    );
796                }
797            }
798        }
799        self.report_compose_native_only_service_fields(service, &service_subject);
800        if !self.map_image(service, sourced.origins(), &mut generated, &service_subject) {
801            return None;
802        }
803        self.map_restart_policy(service, &mut generated, &service_subject);
804        self.map_command(service, &mut generated, &service_subject);
805        self.map_released_container_settings(service, &mut generated, &service_subject);
806        self.map_identity(service, &mut generated, &service_subject);
807        self.map_execution_context(service, &mut generated, &service_subject);
808        self.map_dns(service, &mut generated, &service_subject);
809        self.map_security_options(service, &mut generated, &service_subject);
810        self.map_environment_files(service, &mut generated, &service_subject);
811        self.map_environment(service, &mut generated, &service_subject);
812        self.map_labels(service, &mut generated, &service_subject);
813        self.map_host_mappings(service, &mut generated, &service_subject);
814        self.map_ports(service, &mut generated, &service_subject);
815        self.map_mounts(service, &mut generated, &service_subject);
816        self.map_network_attachments(service, &mut generated, &service_subject);
817        self.report_unimplemented_service_fields(service, &service_subject);
818        Some(generated)
819    }
820
821    fn map_restart_policy(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
822        let Some(restart) = service.restart_policy() else {
823            return;
824        };
825        let subject = format!("{service_subject}.restart_policy");
826        let policy = match restart.value() {
827            RestartPolicy::Never => GeneratedRestartPolicy::No,
828            RestartPolicy::Always => GeneratedRestartPolicy::Always,
829            RestartPolicy::OnFailure { maximum_retries } => GeneratedRestartPolicy::OnFailure {
830                maximum_retries: maximum_retries.map(std::num::NonZeroU64::get),
831            },
832            RestartPolicy::UnlessStopped => GeneratedRestartPolicy::UnlessStopped,
833            _ => {
834                self.unsupported(
835                    &subject,
836                    "restart-policy variant is newer than this Compose adapter",
837                    restart.origins(),
838                );
839                return;
840            }
841        };
842        match generated.set_restart(policy) {
843            Ok(()) => self.exact(subject, restart.origins()),
844            Err(error) => self.generation_error(&subject, &error, restart.origins()),
845        }
846    }
847
848    fn map_dns(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
849        if let Some(values) = service.dns_servers() {
850            let subject = format!("{service_subject}.dns");
851            let origins = collection_or_item_origins(values, service.dns_servers_origins());
852            let generated_values: Result<Vec<_>, _> =
853                values.iter().map(|value| generated_string(value.value())).collect();
854            match generated_values.and_then(|values| generated.set_dns(GeneratedDns::List(values))) {
855                Ok(()) if values.is_empty() => self.exact(subject, &origins),
856                Ok(()) if values.iter().any(|value| value.value().expose() == "none") => {
857                    self.unsupported(&subject, "DNS `none` has target-specific resolver semantics", &origins);
858                }
859                Ok(()) => self.exact(subject, &origins),
860                Err(error) => self.generation_error(&subject, &error, &origins),
861            }
862        }
863        if let Some(values) = service.dns_options() {
864            let subject = format!("{service_subject}.dns_opt");
865            let origins = collection_or_item_origins(values, service.dns_options_origins());
866            let generated_values: Result<Vec<_>, _> =
867                values.iter().map(|value| generated_string(value.value())).collect();
868            match generated_values.and_then(|values| generated.set_dns_options(values)) {
869                Ok(()) if values.is_empty() => self.exact(subject, &origins),
870                Ok(()) => self.exact(subject, &origins),
871                Err(error) => self.generation_error(&subject, &error, &origins),
872            }
873        }
874        if let Some(values) = service.dns_search_domains() {
875            let subject = format!("{service_subject}.dns_search");
876            let origins = collection_or_item_origins(values, service.dns_search_domains_origins());
877            let generated_values: Result<Vec<_>, _> =
878                values.iter().map(|value| generated_string(value.value())).collect();
879            match generated_values.and_then(|values| generated.set_dns_search(GeneratedDnsSearch::List(values))) {
880                Ok(()) if values.is_empty() => self.exact(subject, &origins),
881                Ok(()) if values.iter().any(|value| value.value().expose() == ".") => self.unsupported(
882                    &subject,
883                    "DNS search `.` has target-specific resolver semantics",
884                    &origins,
885                ),
886                Ok(()) => self.exact(subject, &origins),
887                Err(error) => self.generation_error(&subject, &error, &origins),
888            }
889        }
890    }
891
892    fn map_security_options(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
893        let Some(options) = service.security_options() else {
894            return;
895        };
896        let subject = format!("{service_subject}.security_opt");
897        let origins = collection_or_item_origins(options, service.security_options_origins());
898        let mut generated_options = Vec::with_capacity(options.len());
899        let mut all_exact = true;
900        for (index, option) in options.iter().enumerate() {
901            match security_option_string(option.value()) {
902                Ok(SecurityOptionGeneration::Generated(value)) => generated_options.push(value),
903                Ok(SecurityOptionGeneration::Unsupported(reason)) => {
904                    all_exact = false;
905                    self.unsupported(&format!("{subject}[{index}]"), reason, option.origins());
906                }
907                Err(error) => {
908                    self.generation_error(&subject, &error, &origins);
909                    return;
910                }
911            }
912        }
913        match generated.set_security_options(generated_options) {
914            Ok(()) if all_exact => self.exact(subject, &origins),
915            Ok(()) => {}
916            Err(error) => self.generation_error(&subject, &error, &origins),
917        }
918    }
919
920    fn map_image(
921        &mut self,
922        service: &Service,
923        service_origins: &[Provenance],
924        generated: &mut GeneratedService,
925        service_subject: &str,
926    ) -> bool {
927        let Some(image) = service.image() else {
928            self.invalid(
929                self.exporter.codes.generation.clone(),
930                &format!("{service_subject}.image"),
931                "generated Compose service has no runnable source",
932                "the neutral service has neither an image nor a representable build definition",
933                service_origins,
934            );
935            self.generation_failed = true;
936            return false;
937        };
938        let result = GeneratedString::plain(image.value().as_str()).and_then(|image| generated.set_image(image));
939        if let Err(error) = result {
940            self.generation_error(&format!("{service_subject}.image"), &error, image.origins());
941            return false;
942        }
943        if image.value().tag().is_some() && image.value().digest().is_some() {
944            self.compatibility(
945                &format!("{service_subject}.image"),
946                CompatibilityFeature::ImageTagAndDigest,
947                image.origins(),
948            );
949        } else {
950            self.exact(format!("{service_subject}.image"), image.origins());
951        }
952        true
953    }
954
955    fn map_command(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
956        let Some(command) = service.command() else {
957            return;
958        };
959        let command_value = match command.value() {
960            Command::Exec(arguments) => Some(
961                arguments
962                    .iter()
963                    .map(generated_string)
964                    .collect::<Result<Vec<_>, _>>()
965                    .map(GeneratedCommand::Exec),
966            ),
967            Command::Shell(value) => Some(generated_string(value).map(GeneratedCommand::Shell)),
968            Command::Empty => Some(Ok(GeneratedCommand::Empty)),
969            _ => {
970                self.unsupported(
971                    &format!("{service_subject}.command"),
972                    "command variant is newer than this Compose adapter",
973                    command.origins(),
974                );
975                None
976            }
977        };
978        if let Some(command_value) = command_value {
979            match command_value.and_then(|command_value| generated.set_command(command_value)) {
980                Ok(()) => self.exact(format!("{service_subject}.command"), command.origins()),
981                Err(error) => self.generation_error(&format!("{service_subject}.command"), &error, command.origins()),
982            }
983        }
984    }
985
986    fn map_execution_context(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
987        if let Some(working_directory) = service.working_directory() {
988            let field = format!("{subject}.working_directory");
989            match generated_string(working_directory.value()).and_then(|value| generated.set_working_dir(value)) {
990                Ok(()) => self.exact(field, working_directory.origins()),
991                Err(error) => self.generation_error(&field, &error, working_directory.origins()),
992            }
993        }
994        if let Some(read_only) = service.read_only_root_filesystem() {
995            let field = format!("{subject}.read_only_root_filesystem");
996            match generated.set_read_only(*read_only.value()) {
997                Ok(()) => self.exact(field, read_only.origins()),
998                Err(error) => self.generation_error(&field, &error, read_only.origins()),
999            }
1000        }
1001    }
1002
1003    fn map_environment(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1004        for environment in service.environment() {
1005            let subject = format!("{service_subject}.environment.{}", environment.value().name().as_str());
1006            match environment.value().value() {
1007                EnvironmentValue::Literal(value) => match generated_string(value)
1008                    .and_then(|value| GeneratedEnvironment::literal(environment.value().name().as_str(), value))
1009                {
1010                    Ok(value) => {
1011                        generated.add_environment(value);
1012                        self.exact(subject, environment.origins());
1013                    }
1014                    Err(error) => self.generation_error(&subject, &error, environment.origins()),
1015                },
1016                EnvironmentValue::Host => match GeneratedEnvironment::host(environment.value().name().as_str()) {
1017                    Ok(value) => {
1018                        generated.add_environment(value);
1019                        self.exact(subject, environment.origins());
1020                    }
1021                    Err(error) => self.generation_error(&subject, &error, environment.origins()),
1022                },
1023                EnvironmentValue::Unset => self.unsupported(
1024                    &subject,
1025                    "Compose environment syntax cannot guarantee that a variable is absent from the container",
1026                    environment.origins(),
1027                ),
1028                _ => self.unsupported(
1029                    &subject,
1030                    "environment value variant is newer than this Compose adapter",
1031                    environment.origins(),
1032                ),
1033            }
1034        }
1035    }
1036
1037    fn map_environment_files(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1038        for (index, sourced) in service.environment_files().iter().enumerate() {
1039            let subject = format!("{service_subject}.environment_files[{index}]");
1040            let environment_file = sourced.value();
1041            let path = match generated_string(environment_file.path()) {
1042                Ok(path) => path,
1043                Err(error) => {
1044                    self.generation_error(&subject, &error, sourced.origins());
1045                    continue;
1046                }
1047            };
1048            let value = match environment_file.syntax() {
1049                EnvironmentFileSyntax::Short => {
1050                    if environment_file.required().is_some() || environment_file.format().is_some() {
1051                        self.unsupported(
1052                            &subject,
1053                            "short-syntax environment files cannot carry long-syntax options",
1054                            sourced.origins(),
1055                        );
1056                        continue;
1057                    }
1058                    GeneratedEnvironmentFile::short(path)
1059                }
1060                EnvironmentFileSyntax::Long => {
1061                    let format = match environment_file.format().map(Sourced::value) {
1062                        Some(EnvironmentFileFormat::Raw) => Some(GeneratedEnvironmentFileFormat::Raw),
1063                        Some(_) => {
1064                            self.unsupported(
1065                                &subject,
1066                                "environment-file format variant is newer than this Compose adapter",
1067                                sourced.origins(),
1068                            );
1069                            continue;
1070                        }
1071                        None => None,
1072                    };
1073                    GeneratedEnvironmentFile::long(
1074                        path,
1075                        environment_file.required().map(|required| *required.value()),
1076                        format,
1077                    )
1078                }
1079                _ => {
1080                    self.unsupported(
1081                        &subject,
1082                        "environment-file syntax variant is newer than this Compose adapter",
1083                        sourced.origins(),
1084                    );
1085                    continue;
1086                }
1087            };
1088            match value {
1089                Ok(value) => {
1090                    generated.add_environment_file(value);
1091                    self.exact(subject, sourced.origins());
1092                }
1093                Err(error) => self.generation_error(&subject, &error, sourced.origins()),
1094            }
1095        }
1096    }
1097
1098    fn map_labels(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1099        for label in service.labels() {
1100            let name = label.value().name().as_str();
1101            let subject = format!("{service_subject}.labels.{name}");
1102            if is_compose_managed_label(name) {
1103                self.unsupported(
1104                    &subject,
1105                    "Compose-managed labels cannot be safely re-authored as application metadata",
1106                    label.origins(),
1107                );
1108                continue;
1109            }
1110            match generated_string(label.value().value())
1111                .and_then(|value| GeneratedLabel::new(name, value))
1112                .and_then(|value| generated.add_label(value))
1113            {
1114                Ok(()) => self.exact(subject, label.origins()),
1115                Err(error) => self.generation_error(&subject, &error, label.origins()),
1116            }
1117        }
1118    }
1119
1120    fn map_host_mappings(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1121        for (index, host) in service.host_mappings().iter().enumerate() {
1122            let subject = format!("{service_subject}.extra_hosts[{index}]");
1123            match GeneratedExtraHost::new(host.value().hostname().as_str(), host.value().address().raw()) {
1124                Ok(value) => {
1125                    generated.add_extra_host(value);
1126                    if host.value().address().kind() == HostAddressKind::HostGateway {
1127                        self.compatibility(&subject, CompatibilityFeature::HostGatewayToken, host.origins());
1128                    } else {
1129                        self.exact(subject, host.origins());
1130                    }
1131                }
1132                Err(error) => self.generation_error(&subject, &error, host.origins()),
1133            }
1134        }
1135    }
1136
1137    fn map_ports(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1138        for (index, port) in service.ports().iter().enumerate() {
1139            let subject = format!("{service_subject}.ports[{index}]");
1140            let protocol = match port.value().protocol() {
1141                Protocol::Tcp => GeneratedProtocol::Tcp,
1142                Protocol::Udp => GeneratedProtocol::Udp,
1143                Protocol::Sctp => GeneratedProtocol::Sctp,
1144                Protocol::Other(protocol) => {
1145                    self.unsupported(
1146                        &subject,
1147                        &format!("Compose generation does not support protocol `{protocol}`"),
1148                        port.origins(),
1149                    );
1150                    continue;
1151                }
1152                _ => {
1153                    self.unsupported(
1154                        &subject,
1155                        "port protocol variant is newer than this Compose adapter",
1156                        port.origins(),
1157                    );
1158                    continue;
1159                }
1160            };
1161            match GeneratedPort::new(
1162                port.value().container(),
1163                port.value().published(),
1164                port.value().host_address().map(str::to_owned),
1165                protocol,
1166            ) {
1167                Ok(value) => {
1168                    generated.add_port(value);
1169                    if matches!(port.value().protocol(), Protocol::Sctp) {
1170                        self.unsupported(
1171                            &subject,
1172                            "SCTP syntax is preserved, but the selected provider/runtime pair has no reviewed compatibility evidence",
1173                            port.origins(),
1174                        );
1175                    } else {
1176                        self.exact(subject, port.origins());
1177                    }
1178                }
1179                Err(error) => self.generation_error(&subject, &error, port.origins()),
1180            }
1181        }
1182    }
1183
1184    fn map_mounts(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1185        for (index, mount) in service.mounts().iter().enumerate() {
1186            let subject = format!("{service_subject}.mounts[{index}]");
1187            let selinux = match mount.value().selinux_relabel() {
1188                Some(SelinuxRelabel::Shared) => Some(GeneratedSelinux::Shared),
1189                Some(SelinuxRelabel::Private) => Some(GeneratedSelinux::Private),
1190                Some(_) => {
1191                    self.unsupported(
1192                        &subject,
1193                        "SELinux relabel variant is newer than this Compose adapter",
1194                        mount.origins(),
1195                    );
1196                    continue;
1197                }
1198                None => None,
1199            };
1200            let value = match mount.value().source() {
1201                MountSource::Volume(source) => {
1202                    GeneratedMount::volume(source.as_str(), mount.value().target(), mount.value().read_only())
1203                }
1204                MountSource::HostPath(source) => {
1205                    GeneratedMount::bind(source, mount.value().target(), mount.value().read_only(), selinux)
1206                }
1207                MountSource::Anonymous => GeneratedMount::anonymous(mount.value().target(), mount.value().read_only()),
1208                _ => {
1209                    self.unsupported(
1210                        &subject,
1211                        "mount source variant is newer than this Compose adapter",
1212                        mount.origins(),
1213                    );
1214                    continue;
1215                }
1216            };
1217            match value {
1218                Ok(value) => {
1219                    generated.add_mount(value);
1220                    if mount.value().selinux_relabel().is_some() {
1221                        self.compatibility(&subject, CompatibilityFeature::ShortBindSelinuxRelabel, mount.origins());
1222                    } else {
1223                        self.exact(subject, mount.origins());
1224                    }
1225                }
1226                Err(error) => self.generation_error(&subject, &error, mount.origins()),
1227            }
1228        }
1229    }
1230
1231    fn map_network_attachments(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1232        for network in service.networks() {
1233            let subject = format!("{service_subject}.networks.{}", network.value().network().as_str());
1234            match GeneratedNetworkAttachment::new(network.value().network().as_str()) {
1235                Ok(mut value) => {
1236                    let mut valid = true;
1237                    for (index, alias) in network.value().aliases().iter().enumerate() {
1238                        if network
1239                            .value()
1240                            .alias_sensitivities()
1241                            .get(index)
1242                            .copied()
1243                            .unwrap_or(false)
1244                        {
1245                            self.unsupported(
1246                                &subject,
1247                                "sensitive network aliases cannot be passed to ComposeLens' plain-string generator",
1248                                network.origins(),
1249                            );
1250                            valid = false;
1251                            break;
1252                        }
1253                        if let Err(error) = value.add_alias(alias) {
1254                            self.generation_error(&subject, &error, network.origins());
1255                            valid = false;
1256                            break;
1257                        }
1258                    }
1259                    if let Some(address) = network.value().ipv4_address() {
1260                        if let Err(error) =
1261                            generated_string(address.value()).and_then(|address| value.set_ipv4_address(address))
1262                        {
1263                            self.generation_error(&subject, &error, address.origins());
1264                            valid = false;
1265                        }
1266                    }
1267                    if let Some(address) = network.value().ipv6_address() {
1268                        if let Err(error) =
1269                            generated_string(address.value()).and_then(|address| value.set_ipv6_address(address))
1270                        {
1271                            self.generation_error(&subject, &error, address.origins());
1272                            valid = false;
1273                        }
1274                    }
1275                    if valid {
1276                        match generated.add_network(value) {
1277                            Ok(()) => self.exact(subject, network.origins()),
1278                            Err(error) => self.generation_error(&subject, &error, network.origins()),
1279                        }
1280                    }
1281                }
1282                Err(error) => self.generation_error(&subject, &error, network.origins()),
1283            }
1284        }
1285    }
1286
1287    fn map_released_container_settings(
1288        &mut self,
1289        service: &Service,
1290        generated: &mut GeneratedService,
1291        service_subject: &str,
1292    ) {
1293        self.map_entrypoint_lifecycle_and_pull(service, generated, service_subject);
1294        self.map_hostname_pids_shm_memory(service, generated, service_subject);
1295        self.map_collections(service, generated, service_subject);
1296        self.map_annotations_logging_and_reload(service, generated, service_subject);
1297    }
1298
1299    fn map_entrypoint_lifecycle_and_pull(
1300        &mut self,
1301        service: &Service,
1302        generated: &mut GeneratedService,
1303        subject: &str,
1304    ) {
1305        if let Some(entrypoint) = service.entrypoint() {
1306            let field = format!("{subject}.entrypoint");
1307            let value = match entrypoint.value() {
1308                Entrypoint::Exec(values) => values
1309                    .iter()
1310                    .map(generated_string)
1311                    .collect::<Result<Vec<_>, _>>()
1312                    .map(GeneratedEntrypoint::List),
1313                Entrypoint::Shell(value) => generated_string(value).map(GeneratedEntrypoint::String),
1314                Entrypoint::Empty => Ok(GeneratedEntrypoint::Empty),
1315                _ => {
1316                    self.unsupported(
1317                        &field,
1318                        "entrypoint variant is newer than this Compose adapter",
1319                        entrypoint.origins(),
1320                    );
1321                    return;
1322                }
1323            };
1324            match value.and_then(|value| generated.set_entrypoint(value)) {
1325                Ok(()) => self.exact(field, entrypoint.origins()),
1326                Err(error) => self.generation_error(&field, &error, entrypoint.origins()),
1327            }
1328        }
1329        if let Some(init) = service.run_init() {
1330            let field = format!("{subject}.init");
1331            match generated.set_init(*init.value()) {
1332                Ok(()) => self.exact(field, init.origins()),
1333                Err(error) => self.generation_error(&field, &error, init.origins()),
1334            }
1335        }
1336        if let Some(timeout) = service.stop_timeout() {
1337            let field = format!("{subject}.stop_grace_period");
1338            match generated_string(&ProtectedString::plain(timeout.value().as_str()))
1339                .and_then(|value| generated.set_stop_grace_period(value))
1340            {
1341                Ok(()) => self.exact(field, timeout.origins()),
1342                Err(error) => self.generation_error(&field, &error, timeout.origins()),
1343            }
1344        }
1345        if let Some(policy) = service.pull_policy() {
1346            let field = format!("{subject}.pull_policy");
1347            let value = match policy.value() {
1348                PullPolicy::Always => Ok(GeneratedPullPolicy::Always),
1349                PullPolicy::Missing => Ok(GeneratedPullPolicy::Missing),
1350                PullPolicy::Never => Ok(GeneratedPullPolicy::Never),
1351                PullPolicy::IfNotPresent => Ok(GeneratedPullPolicy::IfNotPresentAlias),
1352                PullPolicy::Build => Ok(GeneratedPullPolicy::Build),
1353                PullPolicy::Daily => Ok(GeneratedPullPolicy::Daily),
1354                PullPolicy::Weekly => Ok(GeneratedPullPolicy::Weekly),
1355                PullPolicy::Every(value) => generated_string(value).map(GeneratedPullPolicy::Every),
1356                PullPolicy::Raw(_) => {
1357                    self.unsupported(
1358                        &field,
1359                        "provider-specific pull policy cannot be silently normalized for Compose output",
1360                        policy.origins(),
1361                    );
1362                    return;
1363                }
1364                _ => {
1365                    self.unsupported(
1366                        &field,
1367                        "pull-policy variant is newer than this Compose adapter",
1368                        policy.origins(),
1369                    );
1370                    return;
1371                }
1372            };
1373            match value.and_then(|value| generated.set_pull_policy(value)) {
1374                Ok(()) => self.exact(field, policy.origins()),
1375                Err(error) => self.generation_error(&field, &error, policy.origins()),
1376            }
1377        }
1378    }
1379
1380    fn map_hostname_pids_shm_memory(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1381        if let Some(value) = service.hostname() {
1382            let field = format!("{subject}.hostname");
1383            match generated_string(value.value())
1384                .map(GeneratedHostname::Resolved)
1385                .and_then(|value| generated.set_hostname(value))
1386            {
1387                Ok(()) => self.exact(field, value.origins()),
1388                Err(error) => self.generation_error(&field, &error, value.origins()),
1389            }
1390        }
1391        if let Some(value) = service.pids_limit() {
1392            let field = format!("{subject}.pids_limit");
1393            let limit = if value.value().expose() == "-1" {
1394                GeneratedPidsLimit::Unlimited
1395            } else {
1396                GeneratedPidsLimit::Finite(value.value().expose().to_owned())
1397            };
1398            match generated.set_pids_limit(limit) {
1399                Ok(()) => self.exact(field, value.origins()),
1400                Err(error) => self.generation_error(&field, &error, value.origins()),
1401            }
1402        }
1403        if let Some(value) = service.shm_size() {
1404            self.map_sized_value(value.value(), value.origins(), &format!("{subject}.shm_size"), |size| {
1405                generated.set_shm_size(size)
1406            });
1407        }
1408        if let Some(value) = service.memory_limit() {
1409            self.map_memory_value(
1410                value.value(),
1411                value.origins(),
1412                &format!("{subject}.mem_limit"),
1413                |limit| generated.set_mem_limit(limit),
1414            );
1415        }
1416    }
1417
1418    fn map_sized_value<F>(&mut self, value: &ProtectedString, origins: &[Provenance], subject: &str, set: F)
1419    where
1420        F: FnOnce(GeneratedShmSize) -> Result<(), GenerationError>,
1421    {
1422        let Some((amount, unit)) = split_size(value.expose()) else {
1423            self.unsupported(
1424                subject,
1425                "shared-memory size requires a documented lowercase unit",
1426                origins,
1427            );
1428            return;
1429        };
1430        let amount = if value.is_sensitive() {
1431            GeneratedString::sensitive(amount)
1432        } else {
1433            GeneratedString::plain(amount)
1434        };
1435        let Ok(amount) = amount else {
1436            self.unsupported(subject, "invalid shared-memory size", origins);
1437            return;
1438        };
1439        match set(GeneratedShmSize::Explicit { amount, unit }) {
1440            Ok(()) => self.exact(subject, origins),
1441            Err(error) => self.generation_error(subject, &error, origins),
1442        }
1443    }
1444
1445    fn map_memory_value<F>(&mut self, value: &ProtectedString, origins: &[Provenance], subject: &str, set: F)
1446    where
1447        F: FnOnce(GeneratedMemLimit) -> Result<(), GenerationError>,
1448    {
1449        let Some((amount, unit)) = split_memory(value.expose()) else {
1450            self.unsupported(subject, "memory limit requires a documented lowercase unit", origins);
1451            return;
1452        };
1453        let amount = if value.is_sensitive() {
1454            GeneratedString::sensitive(amount)
1455        } else {
1456            GeneratedString::plain(amount)
1457        };
1458        match amount.and_then(|amount| set(GeneratedMemLimit::Explicit { amount, unit })) {
1459            Ok(()) => self.exact(subject, origins),
1460            Err(error) => self.generation_error(subject, &error, origins),
1461        }
1462    }
1463
1464    fn map_collections(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1465        self.map_capabilities(service, generated, subject);
1466        self.map_resource_collections(service, generated, subject);
1467        self.map_devices_signals_and_expose(service, generated, subject);
1468    }
1469
1470    fn map_capabilities(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1471        for (name, values, origins, set) in [
1472            (
1473                "cap_add",
1474                service.cap_add(),
1475                service.cap_add_origins(),
1476                GeneratedService::set_cap_add as fn(&mut GeneratedService, Vec<GeneratedString>) -> _,
1477            ),
1478            (
1479                "cap_drop",
1480                service.cap_drop(),
1481                service.cap_drop_origins(),
1482                GeneratedService::set_cap_drop as fn(&mut GeneratedService, Vec<GeneratedString>) -> _,
1483            ),
1484        ] {
1485            if let Some(values) = values {
1486                let field = format!("{subject}.{name}");
1487                let all_origins = collection_or_item_origins(values, origins);
1488                match values
1489                    .iter()
1490                    .map(|value| generated_string(value.value()))
1491                    .collect::<Result<Vec<_>, _>>()
1492                    .and_then(|values| set(generated, values))
1493                {
1494                    Ok(()) => self.exact(field, &all_origins),
1495                    Err(error) => self.generation_error(&field, &error, &all_origins),
1496                }
1497            }
1498        }
1499    }
1500
1501    fn map_resource_collections(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1502        if let Some(values) = service.tmpfs() {
1503            let field = format!("{subject}.tmpfs");
1504            let origins = collection_or_item_origins(values, service.tmpfs_origins());
1505            match values
1506                .iter()
1507                .map(|value| generated_string(value.value()))
1508                .collect::<Result<Vec<_>, _>>()
1509                .and_then(|values| generated.set_tmpfs(GeneratedTmpfs::List(values)))
1510            {
1511                Ok(()) => self.exact(field, &origins),
1512                Err(error) => self.generation_error(&field, &error, &origins),
1513            }
1514        }
1515        if let Some(values) = service.sysctls() {
1516            let field = format!("{subject}.sysctls");
1517            let origins = collection_or_item_origins(values, service.sysctls_origins());
1518            let values = values
1519                .iter()
1520                .map(|value| {
1521                    GeneratedSysctl::new(value.value().name().expose(), generated_string(value.value().value())?)
1522                })
1523                .collect::<Result<Vec<_>, _>>();
1524            match values.and_then(|values| generated.set_sysctls(GeneratedSysctls::Map(values))) {
1525                Ok(()) => self.exact(field, &origins),
1526                Err(error) => self.generation_error(&field, &error, &origins),
1527            }
1528        }
1529        if let Some(values) = service.ulimits() {
1530            let field = format!("{subject}.ulimits");
1531            let origins = resource_limit_collection_origins(values, service.ulimits_origins());
1532            let values = values
1533                .iter()
1534                .map(|value| {
1535                    let limit = value.value();
1536                    match (limit.soft(), limit.hard()) {
1537                        (Some(soft), Some(hard)) if soft.value().expose() == hard.value().expose() => {
1538                            GeneratedUlimit::single(limit.name().expose(), generated_string(soft.value())?)
1539                        }
1540                        (Some(soft), Some(hard)) => GeneratedUlimit::range(
1541                            limit.name().expose(),
1542                            generated_string(soft.value())?,
1543                            generated_string(hard.value())?,
1544                        ),
1545                        _ => Err(GenerationError::MissingUlimitRangeMember("soft/hard")),
1546                    }
1547                })
1548                .collect::<Result<Vec<_>, _>>();
1549            match values
1550                .and_then(GeneratedUlimits::new)
1551                .and_then(|values| generated.set_ulimits(values))
1552            {
1553                Ok(()) => self.exact(field, &origins),
1554                Err(error) => self.generation_error(&field, &error, &origins),
1555            }
1556        }
1557    }
1558
1559    fn map_devices_signals_and_expose(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1560        if let Some(values) = service.devices() {
1561            let field = format!("{subject}.devices");
1562            let origins = device_collection_origins(values, service.devices_origins());
1563            let values = values
1564                .iter()
1565                .map(|value| match value.value() {
1566                    Device::Short(value) => generated_string(value).map(GeneratedDevice::Short),
1567                    Device::Long {
1568                        source,
1569                        target,
1570                        permissions,
1571                    } => {
1572                        let source = source.as_ref().ok_or(GenerationError::InvalidDeviceValue("source"))?;
1573                        GeneratedLongDevice::new(
1574                            generated_string(source.value())?,
1575                            target
1576                                .as_ref()
1577                                .map(|value| generated_string(value.value()))
1578                                .transpose()?,
1579                            permissions
1580                                .as_ref()
1581                                .map(|value| generated_string(value.value()))
1582                                .transpose()?,
1583                        )
1584                        .map(GeneratedDevice::Long)
1585                    }
1586                    _ => Err(GenerationError::InvalidDeviceValue("variant")),
1587                })
1588                .collect::<Result<Vec<_>, _>>();
1589            match values.and_then(|values| generated.set_devices(values)) {
1590                Ok(()) => self.exact(field, &origins),
1591                Err(error) => self.generation_error(&field, &error, &origins),
1592            }
1593        }
1594        if let Some(value) = service.stop_signal() {
1595            let field = format!("{subject}.stop_signal");
1596            match generated_string(value.value()).and_then(|value| generated.set_stop_signal(value)) {
1597                Ok(()) => self.exact(field, value.origins()),
1598                Err(error) => self.generation_error(&field, &error, value.origins()),
1599            }
1600        }
1601        if let Some(values) = service.exposed_ports() {
1602            let field = format!("{subject}.expose");
1603            let origins = collection_or_item_origins(values, service.exposed_ports_origins());
1604            let values = values
1605                .iter()
1606                .map(|value| {
1607                    GeneratedString::plain(format!(
1608                        "{}/{}",
1609                        value.value().container(),
1610                        match value.value().protocol() {
1611                            Protocol::Tcp => "tcp",
1612                            Protocol::Udp => "udp",
1613                            _ => "unsupported",
1614                        }
1615                    ))
1616                })
1617                .collect::<Result<Vec<_>, _>>();
1618            match values.and_then(|values| generated.set_expose(values)) {
1619                Ok(()) => self.exact(field, &origins),
1620                Err(error) => self.generation_error(&field, &error, &origins),
1621            }
1622        }
1623    }
1624
1625    fn map_annotations_logging_and_reload(
1626        &mut self,
1627        service: &Service,
1628        generated: &mut GeneratedService,
1629        subject: &str,
1630    ) {
1631        if let Some(values) = service.annotations() {
1632            let field = format!("{subject}.annotations");
1633            let origins = collection_or_item_origins(values, service.annotations_origins());
1634            let values = values
1635                .iter()
1636                .map(|value| {
1637                    GeneratedAnnotation::new(
1638                        value.value().name().value().as_str(),
1639                        generated_string(value.value().value().value())?,
1640                    )
1641                })
1642                .collect::<Result<Vec<_>, _>>();
1643            match values.and_then(|values| generated.set_annotations(values)) {
1644                Ok(()) => self.exact(field, &origins),
1645                Err(error) => self.generation_error(&field, &error, &origins),
1646            }
1647        }
1648        if let Some(logging) = service.logging() {
1649            let field = format!("{subject}.logging");
1650            let value = logging.value();
1651            let Some(driver) = value.driver() else {
1652                self.unsupported(
1653                    &field,
1654                    "Compose logging requires a driver; neutral logging retains its absence",
1655                    logging.origins(),
1656                );
1657                return;
1658            };
1659            let options = value
1660                .options()
1661                .unwrap_or_default()
1662                .iter()
1663                .map(|option| {
1664                    GeneratedLoggingOption::new(
1665                        option.value().name().value().as_str(),
1666                        GeneratedLoggingOptionValue::String(generated_string(option.value().value().value())?),
1667                    )
1668                })
1669                .collect::<Result<Vec<_>, _>>();
1670            match generated_string(driver.value())
1671                .and_then(|driver| options.and_then(|options| GeneratedLogging::new(driver, options)))
1672                .and_then(|value| generated.set_logging(value))
1673            {
1674                Ok(()) if value.options().is_some() => self.exact(field, logging.origins()),
1675                Ok(()) => self.loss(
1676                    self.exporter.codes.unsupported.clone(),
1677                    &field,
1678                    ConversionKind::Approximate,
1679                    "generated Compose logging adds an explicit empty options mapping",
1680                    "neutral logging omitted options, but ComposeLens GeneratedLogging requires an options collection",
1681                    logging.origins(),
1682                ),
1683                Err(error) => self.generation_error(&field, &error, logging.origins()),
1684            }
1685        }
1686        if let Some(reload) = service.reload_action() {
1687            self.unsupported(
1688                &format!("{subject}.reload_action"),
1689                "Compose has no reload command or signal field; reload intent is never emitted as a lifecycle hook",
1690                reload.origins(),
1691            );
1692        }
1693    }
1694
1695    fn report_unimplemented_service_fields(&mut self, service: &Service, service_subject: &str) {
1696        if let Some(healthcheck) = service.healthcheck() {
1697            self.unsupported(
1698                &format!("{service_subject}.healthcheck"),
1699                "the current Compose generation boundary does not yet expose health-check fields",
1700                healthcheck.origins(),
1701            );
1702        }
1703        for (index, dependency) in service.dependencies().iter().enumerate() {
1704            self.unsupported(
1705                &format!("{service_subject}.dependencies[{index}]"),
1706                "the current Compose generation boundary does not yet expose service dependencies",
1707                dependency.origins(),
1708            );
1709        }
1710        for (index, grant) in service.config_grants().iter().enumerate() {
1711            self.unsupported(
1712                &format!("{service_subject}.config_grants[{index}]"),
1713                "the current Compose generation boundary does not yet expose service config grants",
1714                grant.origins(),
1715            );
1716        }
1717        for (index, grant) in service.secret_grants().iter().enumerate() {
1718            self.unsupported(
1719                &format!("{service_subject}.secret_grants[{index}]"),
1720                "the current Compose generation boundary does not yet expose service secret grants",
1721                grant.origins(),
1722            );
1723        }
1724    }
1725
1726    fn report_compose_native_only_service_fields(&mut self, service: &Service, service_subject: &str) {
1727        if let Some(notification) = service.startup_notification() {
1728            self.unsupported(
1729                &format!("{service_subject}.startup_notification"),
1730                "Compose has no service startup-notification field",
1731                notification.origins(),
1732            );
1733        }
1734        if let Some(rootfs) = service.rootfs() {
1735            self.unsupported(
1736                &format!("{service_subject}.rootfs"),
1737                "Compose has no root-filesystem source field; rootfs is never rewritten as an image",
1738                rootfs.origins(),
1739            );
1740        }
1741        if let Some(arguments) = service.podman_args() {
1742            for (index, argument) in arguments.iter().enumerate() {
1743                self.unsupported(
1744                    &format!("{service_subject}.podman_args[{index}]"),
1745                    "Compose has no native Podman-argument field; authored arguments are never synthesized",
1746                    argument.origins(),
1747                );
1748            }
1749            if arguments.is_empty() {
1750                self.unsupported(
1751                    &format!("{service_subject}.podman_args"),
1752                    "Compose has no native Podman-argument field; authored arguments are never synthesized",
1753                    service.podman_args_origins(),
1754                );
1755            }
1756        }
1757    }
1758
1759    fn report_service_group_loss(&mut self, group: &Sourced<boxferry_model::ServiceGroup>) {
1760        let group_subject = format!("service_groups.{}", group.value().name().as_str());
1761        self.unsupported(
1762            &group_subject,
1763            "Compose has no native structural equivalent for a runtime pod or shared namespace group",
1764            group.origins(),
1765        );
1766        let Some(runtime) = group.value().runtime() else {
1767            return;
1768        };
1769        let runtime_subject = format!("{group_subject}.runtime");
1770        self.unsupported(
1771            &runtime_subject,
1772            "Compose has no native service-group runtime; it is never assigned to a member service",
1773            runtime.origins(),
1774        );
1775        let runtime = runtime.value();
1776        if let Some(value) = runtime.runtime_name() {
1777            self.unsupported(
1778                &format!("{runtime_subject}.runtime_name"),
1779                "Compose has no native service-group runtime name",
1780                value.origins(),
1781            );
1782        }
1783        if let Some(value) = runtime.service_name() {
1784            self.unsupported(
1785                &format!("{runtime_subject}.service_name"),
1786                "Compose has no native service-group systemd service name",
1787                value.origins(),
1788            );
1789        }
1790        self.report_group_runtime_collection(
1791            &format!("{runtime_subject}.host_mappings"),
1792            runtime.host_mappings(),
1793            runtime.host_mappings_origins(),
1794        );
1795        self.report_group_runtime_collection(
1796            &format!("{runtime_subject}.ports"),
1797            runtime.ports(),
1798            runtime.ports_origins(),
1799        );
1800        self.report_group_runtime_collection(
1801            &format!("{runtime_subject}.networks"),
1802            runtime.networks(),
1803            runtime.networks_origins(),
1804        );
1805        if let Some(value) = runtime.user_namespace() {
1806            self.unsupported(
1807                &format!("{runtime_subject}.user_namespace"),
1808                "Compose has no native service-group user-namespace field",
1809                value.origins(),
1810            );
1811        }
1812        self.report_group_runtime_collection(
1813            &format!("{runtime_subject}.mounts"),
1814            runtime.mounts(),
1815            runtime.mounts_origins(),
1816        );
1817        if let Some(value) = runtime.shm_size() {
1818            self.unsupported(
1819                &format!("{runtime_subject}.shm_size"),
1820                "Compose has no native service-group shared-memory-size field",
1821                value.origins(),
1822            );
1823        }
1824        if let Some(value) = runtime.exit_policy() {
1825            self.unsupported(
1826                &format!("{runtime_subject}.exit_policy"),
1827                "Compose has no native service-group exit-policy field",
1828                value.origins(),
1829            );
1830        }
1831        if let Some(value) = runtime.stop_timeout() {
1832            self.unsupported(
1833                &format!("{runtime_subject}.stop_timeout"),
1834                "Compose has no native service-group stop-timeout field",
1835                value.origins(),
1836            );
1837        }
1838    }
1839
1840    fn report_group_runtime_collection<T>(
1841        &mut self,
1842        subject: &str,
1843        values: Option<&[Sourced<T>]>,
1844        collection_origins: &[Provenance],
1845    ) {
1846        let Some(values) = values else {
1847            return;
1848        };
1849        if values.is_empty() {
1850            self.unsupported(
1851                subject,
1852                "Compose has no native service-group runtime collection; it is never assigned to a member service",
1853                collection_origins,
1854            );
1855            return;
1856        }
1857        for (index, value) in values.iter().enumerate() {
1858            self.unsupported(
1859                &format!("{subject}[{index}]"),
1860                "Compose has no native service-group runtime collection; it is never assigned to a member service",
1861                value.origins(),
1862            );
1863        }
1864    }
1865
1866    fn map_identity(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1867        match (service.user(), service.group()) {
1868            (Some(user), group) => {
1869                let mut value = user.value().expose().to_owned();
1870                let mut sensitive = user.value().is_sensitive();
1871                let mut origins = user.origins().to_vec();
1872                if let Some(group) = group {
1873                    value.push(':');
1874                    value.push_str(group.value().expose());
1875                    sensitive |= group.value().is_sensitive();
1876                    origins.extend_from_slice(group.origins());
1877                }
1878                let value = if sensitive {
1879                    GeneratedString::sensitive(value)
1880                } else {
1881                    GeneratedString::plain(value)
1882                };
1883                match value.and_then(|value| generated.set_user(value)) {
1884                    Ok(()) => self.exact(format!("{service_subject}.user"), &origins),
1885                    Err(error) => self.generation_error(&format!("{service_subject}.user"), &error, &origins),
1886                }
1887            }
1888            (None, Some(group)) => self.unsupported(
1889                &format!("{service_subject}.group"),
1890                "Compose combines user and primary group in one field and cannot encode a group without a user",
1891                group.origins(),
1892            ),
1893            (None, None) => {}
1894        }
1895
1896        if let Some(user_namespace) = service.user_namespace() {
1897            let subject = format!("{service_subject}.user_namespace");
1898            match generated_string(user_namespace.value()).and_then(|value| generated.set_userns_mode(value)) {
1899                Ok(()) if is_podman_user_namespace(user_namespace.value().expose()) => self.compatibility(
1900                    &subject,
1901                    CompatibilityFeature::PodmanUserNamespaceMode,
1902                    user_namespace.origins(),
1903                ),
1904                Ok(()) => self.exact(subject, user_namespace.origins()),
1905                Err(error) => self.generation_error(&subject, &error, user_namespace.origins()),
1906            }
1907        }
1908        for (index, group) in service.supplementary_groups().iter().enumerate() {
1909            let subject = format!("{service_subject}.supplementary_groups[{index}]");
1910            match generated_string(group.value()).and_then(|value| generated.add_supplementary_group(value)) {
1911                Ok(()) => self.exact(subject, group.origins()),
1912                Err(error) => self.generation_error(&subject, &error, group.origins()),
1913            }
1914        }
1915    }
1916
1917    fn compatibility(&mut self, subject: &str, feature: CompatibilityFeature, origins: &[Provenance]) {
1918        let Some(profile) = self.compatibility else {
1919            self.invalid(
1920                self.exporter.codes.invalid_target.clone(),
1921                subject,
1922                "Compose compatibility profile is unavailable",
1923                "validate the target before mapping application fields",
1924                origins,
1925            );
1926            return;
1927        };
1928        let rule = profile.classify(feature);
1929        let evidence: Vec<_> = rule.evidence().iter().map(|evidence| evidence.source()).collect();
1930        match rule.classification() {
1931            CompatibilityClassification::Supported | CompatibilityClassification::Extension => {
1932                self.exact(subject, origins);
1933            }
1934            CompatibilityClassification::ImplementationSpecific | CompatibilityClassification::Deprecated => {
1935                self.compatibility_loss(
1936                    subject,
1937                    ConversionKind::Approximate,
1938                    rule.classification(),
1939                    rule.explanation(),
1940                    &evidence,
1941                    origins,
1942                );
1943            }
1944            CompatibilityClassification::Unsupported | CompatibilityClassification::Unknown => {
1945                self.compatibility_loss(
1946                    subject,
1947                    ConversionKind::Unsupported,
1948                    rule.classification(),
1949                    rule.explanation(),
1950                    &evidence,
1951                    origins,
1952                );
1953            }
1954            _ => self.compatibility_loss(
1955                subject,
1956                ConversionKind::Unsupported,
1957                rule.classification(),
1958                rule.explanation(),
1959                &evidence,
1960                origins,
1961            ),
1962        }
1963    }
1964
1965    fn compatibility_loss(
1966        &mut self,
1967        subject: &str,
1968        kind: ConversionKind,
1969        classification: CompatibilityClassification,
1970        reason: &str,
1971        evidence: &[&str],
1972        origins: &[Provenance],
1973    ) {
1974        let classification = format!("{classification:?}").to_ascii_lowercase();
1975        let mut diagnostic = Diagnostic::new(
1976            self.exporter.codes.compatibility.clone(),
1977            Severity::Warning,
1978            "generated Compose construct has a target-specific compatibility constraint",
1979        )
1980        .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject)))
1981        .with_field(DiagnosticField::new(
1982            "classification",
1983            DiagnosticValue::plain(classification),
1984        ))
1985        .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(reason)));
1986        if !evidence.is_empty() {
1987            diagnostic = diagnostic.with_field(DiagnosticField::new(
1988                "evidence",
1989                DiagnosticValue::plain(evidence.join(",")),
1990            ));
1991        }
1992        self.diagnostics.push(diagnostic);
1993        self.push_loss(subject, kind, self.exporter.codes.compatibility.clone(), origins);
1994    }
1995
1996    fn exact(&mut self, subject: impl Into<String>, origins: &[Provenance]) {
1997        let mut outcome = ConversionOutcome::exact(subject);
1998        for origin in origins {
1999            outcome = outcome.with_origin(origin.clone());
2000        }
2001        self.outcomes.push(outcome);
2002    }
2003
2004    fn unsupported(&mut self, subject: &str, reason: &str, origins: &[Provenance]) {
2005        self.loss(
2006            self.exporter.codes.unsupported.clone(),
2007            subject,
2008            ConversionKind::Unsupported,
2009            "neutral application intent is not represented in generated Compose output",
2010            reason,
2011            origins,
2012        );
2013    }
2014
2015    fn generation_error(&mut self, subject: &str, error: &GenerationError, origins: &[Provenance]) {
2016        self.generation_failed = true;
2017        self.invalid(
2018            self.exporter.codes.generation.clone(),
2019            subject,
2020            "Compose document generation failed",
2021            &error.to_string(),
2022            origins,
2023        );
2024    }
2025
2026    fn invalid(&mut self, code: DiagnosticCode, subject: &str, summary: &str, reason: &str, origins: &[Provenance]) {
2027        self.loss(code, subject, ConversionKind::Invalid, summary, reason, origins);
2028    }
2029
2030    fn loss(
2031        &mut self,
2032        code: DiagnosticCode,
2033        subject: &str,
2034        kind: ConversionKind,
2035        summary: &str,
2036        reason: &str,
2037        origins: &[Provenance],
2038    ) {
2039        let severity = if kind == ConversionKind::Invalid {
2040            Severity::Error
2041        } else {
2042            Severity::Warning
2043        };
2044        self.diagnostics.push(
2045            Diagnostic::new(code.clone(), severity, summary)
2046                .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject)))
2047                .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(reason))),
2048        );
2049        self.push_loss(subject, kind, code, origins);
2050    }
2051
2052    fn push_loss(&mut self, subject: &str, kind: ConversionKind, code: DiagnosticCode, origins: &[Provenance]) {
2053        if let Ok(mut outcome) = ConversionOutcome::loss(subject, kind, code) {
2054            for origin in origins {
2055                outcome = outcome.with_origin(origin.clone());
2056            }
2057            self.outcomes.push(outcome);
2058        }
2059    }
2060
2061    fn finish(
2062        mut self,
2063    ) -> (
2064        Option<GeneratedComposeDocument>,
2065        Vec<ConversionOutcome>,
2066        Vec<Diagnostic>,
2067    ) {
2068        let candidate = if self.compatibility.is_some() && !self.generation_failed {
2069            match self.builder.take().unwrap_or_default().build(SourceId::new(1)) {
2070                Ok(document) => Some(document),
2071                Err(error) => {
2072                    self.generation_error("application", &error, &[]);
2073                    None
2074                }
2075            }
2076        } else {
2077            None
2078        };
2079        (candidate, self.outcomes, self.diagnostics)
2080    }
2081}
2082
2083fn generated_string(value: &ProtectedString) -> Result<GeneratedString, GenerationError> {
2084    if value.is_sensitive() {
2085        GeneratedString::sensitive(value.expose())
2086    } else {
2087        GeneratedString::plain(value.expose())
2088    }
2089}
2090
2091fn split_size(value: &str) -> Option<(&str, ShmSizeUnit)> {
2092    for (suffix, unit) in [
2093        ("b", ShmSizeUnit::B),
2094        ("k", ShmSizeUnit::K),
2095        ("m", ShmSizeUnit::M),
2096        ("g", ShmSizeUnit::G),
2097    ] {
2098        if let Some(amount) = value.strip_suffix(suffix) {
2099            return Some((amount, unit));
2100        }
2101    }
2102    None
2103}
2104
2105fn split_memory(value: &str) -> Option<(&str, MemLimitUnit)> {
2106    for (suffix, unit) in [
2107        ("kb", MemLimitUnit::Kb),
2108        ("mb", MemLimitUnit::Mb),
2109        ("gb", MemLimitUnit::Gb),
2110        ("b", MemLimitUnit::B),
2111        ("k", MemLimitUnit::K),
2112        ("m", MemLimitUnit::M),
2113        ("g", MemLimitUnit::G),
2114    ] {
2115        if let Some(amount) = value.strip_suffix(suffix) {
2116            return Some((amount, unit));
2117        }
2118    }
2119    None
2120}
2121
2122enum SecurityOptionGeneration {
2123    Generated(GeneratedString),
2124    Unsupported(&'static str),
2125}
2126
2127fn security_option_string(option: &SecurityOption) -> Result<SecurityOptionGeneration, GenerationError> {
2128    Ok(match option {
2129        SecurityOption::AppArmor(profile) => {
2130            SecurityOptionGeneration::Generated(prefixed_generated_string("apparmor=", profile)?)
2131        }
2132        SecurityOption::NoNewPrivileges(enabled) => {
2133            SecurityOptionGeneration::Generated(GeneratedString::plain(format!("no-new-privileges:{enabled}"))?)
2134        }
2135        SecurityOption::SeccompProfile(profile) => {
2136            SecurityOptionGeneration::Generated(prefixed_generated_string("seccomp=", profile)?)
2137        }
2138        SecurityOption::SecurityLabelDisable(enabled) => {
2139            if *enabled {
2140                SecurityOptionGeneration::Generated(GeneratedString::plain("label:disable")?)
2141            } else {
2142                SecurityOptionGeneration::Unsupported(
2143                    "Compose has no exact `security_opt` spelling that re-enables SELinux label separation",
2144                )
2145            }
2146        }
2147        SecurityOption::SecurityLabelFileType(file_type) => {
2148            SecurityOptionGeneration::Generated(prefixed_generated_string("label:filetype:", file_type)?)
2149        }
2150        SecurityOption::SecurityLabelLevel(level) => {
2151            SecurityOptionGeneration::Generated(prefixed_generated_string("label:level:", level)?)
2152        }
2153        SecurityOption::SecurityLabelNested(enabled) => {
2154            if *enabled {
2155                SecurityOptionGeneration::Generated(GeneratedString::plain("label:nested")?)
2156            } else {
2157                SecurityOptionGeneration::Unsupported(
2158                    "Compose has no exact `security_opt` spelling that disables nested SELinux labeling",
2159                )
2160            }
2161        }
2162        SecurityOption::SecurityLabelType(label_type) => {
2163            SecurityOptionGeneration::Generated(prefixed_generated_string("label:type:", label_type)?)
2164        }
2165        SecurityOption::Mask(paths) => SecurityOptionGeneration::Generated(prefixed_generated_string("mask=", paths)?),
2166        SecurityOption::Unmask(paths) => {
2167            SecurityOptionGeneration::Generated(prefixed_generated_string("unmask=", paths)?)
2168        }
2169        _ => SecurityOptionGeneration::Unsupported("security-option variant is newer than this Compose adapter"),
2170    })
2171}
2172
2173fn prefixed_generated_string(prefix: &str, value: &ProtectedString) -> Result<GeneratedString, GenerationError> {
2174    let raw = format!("{prefix}{}", value.expose());
2175    if value.is_sensitive() {
2176        GeneratedString::sensitive(raw)
2177    } else {
2178        GeneratedString::plain(raw)
2179    }
2180}
2181
2182fn collection_or_item_origins<T>(values: &[Sourced<T>], collection_origins: &[Provenance]) -> Vec<Provenance> {
2183    let mut origins = collection_origins.to_vec();
2184    for value in values {
2185        extend_origins(&mut origins, value.origins());
2186    }
2187    origins
2188}
2189
2190fn resource_limit_collection_origins(
2191    values: &[Sourced<boxferry_model::ResourceLimit>],
2192    collection_origins: &[Provenance],
2193) -> Vec<Provenance> {
2194    let mut origins = collection_or_item_origins(values, collection_origins);
2195    for value in values {
2196        for nested in [value.value().soft(), value.value().hard()].into_iter().flatten() {
2197            extend_origins(&mut origins, nested.origins());
2198        }
2199    }
2200    origins
2201}
2202
2203fn device_collection_origins(values: &[Sourced<Device>], collection_origins: &[Provenance]) -> Vec<Provenance> {
2204    let mut origins = collection_or_item_origins(values, collection_origins);
2205    for value in values {
2206        if let Device::Long {
2207            source,
2208            target,
2209            permissions,
2210        } = value.value()
2211        {
2212            for nested in [source.as_ref(), target.as_ref(), permissions.as_ref()]
2213                .into_iter()
2214                .flatten()
2215            {
2216                extend_origins(&mut origins, nested.origins());
2217            }
2218        }
2219    }
2220    origins
2221}
2222
2223fn extend_origins(origins: &mut Vec<Provenance>, additional: &[Provenance]) {
2224    for origin in additional {
2225        if !origins.contains(origin) {
2226            origins.push(origin.clone());
2227        }
2228    }
2229}
2230
2231fn is_compose_managed_label(name: &str) -> bool {
2232    name.starts_with("com.docker.compose.")
2233}
2234
2235fn is_podman_user_namespace(value: &str) -> bool {
2236    ["keep-id", "auto", "nomap"]
2237        .iter()
2238        .any(|mode| value == *mode || value.strip_prefix(mode).is_some_and(|suffix| suffix.starts_with(':')))
2239}
2240
2241fn implementation_version(version: PlatformVersion) -> Option<ImplementationVersion> {
2242    Some(ImplementationVersion::new(
2243        u32::try_from(version.major()).ok()?,
2244        u32::try_from(version.minor()).ok()?,
2245        u32::try_from(version.patch()).ok()?,
2246    ))
2247}