Skip to main content

compose_lens/render/
generated.rs

1//! Deterministic construction of new Compose documents from reviewed native values.
2
3use std::{error::Error, fmt};
4
5use crate::{model::ComposeDocument, source::SourceId, syntax::SyntaxDocument};
6
7use super::write_quoted;
8
9/// A generated Compose value is empty or contains a NUL byte.
10#[derive(Clone, Debug, Eq, PartialEq)]
11#[non_exhaustive]
12pub enum GenerationError {
13    /// A required value is empty.
14    EmptyValue(&'static str),
15    /// A value contains a NUL byte and cannot represent native container intent safely.
16    ContainsNul(&'static str),
17    /// An environment name contains Compose list-form's `=` separator.
18    InvalidEnvironmentName,
19    /// A short-form component contains its reserved separator.
20    InvalidShortComponent(&'static str),
21    /// A short bind spelling needed for `SELinux` cannot be encoded unambiguously.
22    InvalidSelinuxBind,
23    /// A singleton field was configured more than once.
24    DuplicateField(&'static str),
25    /// A named generated collection contains the same name more than once.
26    DuplicateName {
27        /// Collection whose name collided.
28        kind: &'static str,
29        /// Duplicate non-sensitive name.
30        name: String,
31    },
32    /// A generated port used target port zero.
33    InvalidPort,
34    /// An `SCTP` port selected a host address without a published port.
35    UnrepresentableSctpHostIp,
36    /// A generated project contains no services.
37    MissingService,
38    /// `ComposeLens` could not parse its own deterministic generated bytes.
39    InternalInvariant(&'static str),
40}
41
42impl fmt::Display for GenerationError {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::EmptyValue(kind) => write!(formatter, "generated {kind} must not be empty"),
46            Self::ContainsNul(kind) => write!(formatter, "generated {kind} must not contain a NUL byte"),
47            Self::InvalidEnvironmentName => formatter.write_str("generated environment name must not contain `=`"),
48            Self::InvalidShortComponent(kind) => {
49                write!(formatter, "generated {kind} contains its reserved short-form separator")
50            }
51            Self::InvalidSelinuxBind => formatter
52                .write_str("generated SELinux bind source and target must not contain the short-syntax `:` separator"),
53            Self::DuplicateField(field) => write!(formatter, "generated field `{field}` was configured more than once"),
54            Self::DuplicateName { kind, name } => {
55                write!(formatter, "generated {kind} `{name}` was added more than once")
56            }
57            Self::InvalidPort => formatter.write_str("generated container target port must be greater than zero"),
58            Self::UnrepresentableSctpHostIp => formatter.write_str(
59                "generated SCTP port with a host address also requires a published port for Compose short syntax",
60            ),
61            Self::MissingService => formatter.write_str("generated Compose project requires at least one service"),
62            Self::InternalInvariant(stage) => write!(formatter, "generated Compose document failed {stage} validation"),
63        }
64    }
65}
66
67impl Error for GenerationError {}
68
69/// A plain or sensitive string used by generated Compose fields.
70#[derive(Clone, Eq, PartialEq)]
71pub struct GeneratedString {
72    value: String,
73    sensitive: bool,
74}
75
76impl GeneratedString {
77    /// Creates a non-sensitive generated string.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`GenerationError::ContainsNul`] when the value contains a NUL byte.
82    pub fn plain(value: impl Into<String>) -> Result<Self, GenerationError> {
83        Self::new(value.into(), false)
84    }
85
86    /// Creates a sensitive generated string whose debug representation is redacted.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`GenerationError::ContainsNul`] when the value contains a NUL byte.
91    pub fn sensitive(value: impl Into<String>) -> Result<Self, GenerationError> {
92        Self::new(value.into(), true)
93    }
94
95    fn new(value: String, sensitive: bool) -> Result<Self, GenerationError> {
96        if value.contains('\0') {
97            return Err(GenerationError::ContainsNul("string"));
98        }
99        Ok(Self { value, sensitive })
100    }
101
102    /// Returns the generated value through an explicit access boundary.
103    #[must_use]
104    pub fn expose(&self) -> &str {
105        &self.value
106    }
107
108    /// Reports whether debug output must redact this value.
109    #[must_use]
110    pub const fn is_sensitive(&self) -> bool {
111        self.sensitive
112    }
113}
114
115impl fmt::Debug for GeneratedString {
116    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117        formatter
118            .debug_struct("GeneratedString")
119            .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
120            .field("sensitive", &self.sensitive)
121            .finish()
122    }
123}
124
125/// Compose command form selected for a generated service.
126#[derive(Clone, Debug, Eq, PartialEq)]
127#[non_exhaustive]
128pub enum GeneratedCommand {
129    /// Execute an exact argument vector without Compose shell parsing.
130    Exec(Vec<GeneratedString>),
131    /// Execute one Compose shell-form command.
132    Shell(GeneratedString),
133    /// Explicitly clear the image command.
134    Empty,
135}
136
137/// One ordered Compose environment entry.
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub struct GeneratedEnvironment {
140    name: String,
141    value: Option<GeneratedString>,
142}
143
144impl GeneratedEnvironment {
145    /// Creates a literal `NAME=value` entry.
146    ///
147    /// # Errors
148    ///
149    /// Rejects an empty/NUL-bearing name or a name containing `=`.
150    pub fn literal(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
151        Ok(Self {
152            name: environment_name(name.into())?,
153            value: Some(value),
154        })
155    }
156
157    /// Creates a host-resolved key-only environment entry.
158    ///
159    /// # Errors
160    ///
161    /// Rejects an empty/NUL-bearing name or a name containing `=`.
162    pub fn host(name: impl Into<String>) -> Result<Self, GenerationError> {
163        Ok(Self {
164            name: environment_name(name.into())?,
165            value: None,
166        })
167    }
168
169    /// Returns the environment name.
170    #[must_use]
171    pub fn name(&self) -> &str {
172        &self.name
173    }
174
175    /// Returns the optional literal value.
176    #[must_use]
177    pub const fn value(&self) -> Option<&GeneratedString> {
178        self.value.as_ref()
179    }
180}
181
182/// One ordered Compose `extra_hosts` relationship.
183#[derive(Clone, Debug, Eq, PartialEq)]
184pub struct GeneratedExtraHost {
185    hostname: String,
186    address: String,
187}
188
189impl GeneratedExtraHost {
190    /// Creates a short-form `hostname=address` relationship.
191    ///
192    /// # Errors
193    ///
194    /// Rejects empty/NUL-bearing values and the unambiguous short-form separator `=`.
195    pub fn new(hostname: impl Into<String>, address: impl Into<String>) -> Result<Self, GenerationError> {
196        let hostname = short_component("extra-host hostname", hostname.into(), '=')?;
197        let address = short_component("extra-host address", address.into(), '=')?;
198        Ok(Self { hostname, address })
199    }
200
201    /// Returns the hostname.
202    #[must_use]
203    pub fn hostname(&self) -> &str {
204        &self.hostname
205    }
206
207    /// Returns the address or implementation token.
208    #[must_use]
209    pub fn address(&self) -> &str {
210        &self.address
211    }
212}
213
214/// Transport protocol for one generated published port.
215#[derive(Clone, Copy, Debug, Eq, PartialEq)]
216#[non_exhaustive]
217pub enum GeneratedProtocol {
218    /// Transmission Control Protocol.
219    Tcp,
220    /// User Datagram Protocol.
221    Udp,
222    /// Stream Control Transmission Protocol.
223    Sctp,
224}
225
226impl GeneratedProtocol {
227    const fn as_str(self) -> &'static str {
228        match self {
229            Self::Tcp => "tcp",
230            Self::Udp => "udp",
231            Self::Sctp => "sctp",
232        }
233    }
234}
235
236/// One generated Compose port entry with protocol-aware syntax selection.
237#[derive(Clone, Debug, Eq, PartialEq)]
238pub struct GeneratedPort {
239    target: u16,
240    published: Option<u16>,
241    host_ip: Option<String>,
242    protocol: GeneratedProtocol,
243}
244
245impl GeneratedPort {
246    /// Creates a generated port without normalizing its declared transport.
247    ///
248    /// # Errors
249    ///
250    /// Rejects target port zero, an empty/NUL-bearing host address, and an `SCTP` host address
251    /// without a published port. `SCTP` uses Compose short syntax because the specification's
252    /// long form only defines `tcp` and `udp` protocols.
253    pub fn new(
254        target: u16,
255        published: Option<u16>,
256        host_ip: Option<String>,
257        protocol: GeneratedProtocol,
258    ) -> Result<Self, GenerationError> {
259        if target == 0 {
260            return Err(GenerationError::InvalidPort);
261        }
262        if let Some(host_ip) = host_ip.as_deref() {
263            required("port host address", host_ip.to_owned())?;
264            if protocol == GeneratedProtocol::Sctp && published.is_none() {
265                return Err(GenerationError::UnrepresentableSctpHostIp);
266            }
267        }
268        Ok(Self {
269            target,
270            published,
271            host_ip,
272            protocol,
273        })
274    }
275
276    /// Returns the container port.
277    #[must_use]
278    pub const fn target(&self) -> u16 {
279        self.target
280    }
281
282    /// Returns the optional host port.
283    #[must_use]
284    pub const fn published(&self) -> Option<u16> {
285        self.published
286    }
287
288    /// Returns the optional host-address spelling.
289    #[must_use]
290    pub fn host_ip(&self) -> Option<&str> {
291        self.host_ip.as_deref()
292    }
293
294    /// Returns the transport protocol.
295    #[must_use]
296    pub const fn protocol(&self) -> GeneratedProtocol {
297        self.protocol
298    }
299}
300
301/// `SELinux` relabel option that requires Compose short bind syntax.
302#[derive(Clone, Copy, Debug, Eq, PartialEq)]
303#[non_exhaustive]
304pub enum GeneratedSelinux {
305    /// Private unshared relabel (`Z`).
306    Private,
307    /// Shared relabel (`z`).
308    Shared,
309}
310
311impl GeneratedSelinux {
312    const fn as_str(self) -> &'static str {
313        match self {
314            Self::Private => "Z",
315            Self::Shared => "z",
316        }
317    }
318}
319
320#[derive(Clone, Debug, Eq, PartialEq)]
321enum GeneratedMountKind {
322    Volume {
323        source: String,
324    },
325    Bind {
326        source: String,
327        selinux: Option<GeneratedSelinux>,
328    },
329    Anonymous,
330}
331
332/// One generated service mount with deliberate short/long syntax selection.
333#[derive(Clone, Debug, Eq, PartialEq)]
334pub struct GeneratedMount {
335    kind: GeneratedMountKind,
336    target: String,
337    read_only: bool,
338}
339
340impl GeneratedMount {
341    /// Creates a long-form named-volume mount.
342    ///
343    /// # Errors
344    ///
345    /// Rejects empty or NUL-bearing source and target values.
346    pub fn volume(
347        source: impl Into<String>,
348        target: impl Into<String>,
349        read_only: bool,
350    ) -> Result<Self, GenerationError> {
351        Ok(Self {
352            kind: GeneratedMountKind::Volume {
353                source: required("volume source", source.into())?,
354            },
355            target: required("mount target", target.into())?,
356            read_only,
357        })
358    }
359
360    /// Creates a bind mount. `SELinux` relabel intent selects short syntax deliberately.
361    ///
362    /// # Errors
363    ///
364    /// Rejects empty/NUL-bearing values. When `selinux` is present, also rejects `:` in source or
365    /// target because Compose only honors the relabel option in the short form used here.
366    pub fn bind(
367        source: impl Into<String>,
368        target: impl Into<String>,
369        read_only: bool,
370        selinux: Option<GeneratedSelinux>,
371    ) -> Result<Self, GenerationError> {
372        let source = required("bind source", source.into())?;
373        let target = required("mount target", target.into())?;
374        if selinux.is_some() && (source.contains(':') || target.contains(':')) {
375            return Err(GenerationError::InvalidSelinuxBind);
376        }
377        Ok(Self {
378            kind: GeneratedMountKind::Bind { source, selinux },
379            target,
380            read_only,
381        })
382    }
383
384    /// Creates a long-form anonymous-volume mount.
385    ///
386    /// # Errors
387    ///
388    /// Rejects an empty or NUL-bearing target.
389    pub fn anonymous(target: impl Into<String>, read_only: bool) -> Result<Self, GenerationError> {
390        Ok(Self {
391            kind: GeneratedMountKind::Anonymous,
392            target: required("mount target", target.into())?,
393            read_only,
394        })
395    }
396
397    /// Returns the container target path.
398    #[must_use]
399    pub fn target(&self) -> &str {
400        &self.target
401    }
402
403    /// Reports whether the mount is read-only.
404    #[must_use]
405    pub const fn read_only(&self) -> bool {
406        self.read_only
407    }
408}
409
410/// One generated service network attachment and its ordered aliases.
411#[derive(Clone, Debug, Eq, PartialEq)]
412pub struct GeneratedNetworkAttachment {
413    name: String,
414    aliases: Vec<String>,
415}
416
417impl GeneratedNetworkAttachment {
418    /// Creates an attachment without aliases.
419    ///
420    /// # Errors
421    ///
422    /// Rejects an empty or NUL-bearing network name.
423    pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
424        Ok(Self {
425            name: required("network name", name.into())?,
426            aliases: Vec::new(),
427        })
428    }
429
430    /// Adds one ordered alias.
431    ///
432    /// # Errors
433    ///
434    /// Rejects an empty or NUL-bearing alias.
435    pub fn add_alias(&mut self, alias: impl Into<String>) -> Result<(), GenerationError> {
436        self.aliases.push(required("network alias", alias.into())?);
437        Ok(())
438    }
439
440    /// Returns the network name.
441    #[must_use]
442    pub fn name(&self) -> &str {
443        &self.name
444    }
445
446    /// Returns aliases in insertion order.
447    #[must_use]
448    pub fn aliases(&self) -> &[String] {
449        &self.aliases
450    }
451}
452
453/// One top-level network or volume lifecycle definition.
454#[derive(Clone, Debug, Eq, PartialEq)]
455pub struct GeneratedResource {
456    name: String,
457    external: bool,
458    custom_name: Option<String>,
459}
460
461impl GeneratedResource {
462    /// Creates an application-owned resource definition.
463    ///
464    /// # Errors
465    ///
466    /// Rejects an empty or NUL-bearing name.
467    pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
468        Ok(Self {
469            name: required("resource name", name.into())?,
470            external: false,
471            custom_name: None,
472        })
473    }
474
475    /// Creates an externally managed resource definition.
476    ///
477    /// # Errors
478    ///
479    /// Rejects an empty or NUL-bearing name.
480    pub fn external(name: impl Into<String>) -> Result<Self, GenerationError> {
481        Ok(Self {
482            name: required("resource name", name.into())?,
483            external: true,
484            custom_name: None,
485        })
486    }
487
488    /// Sets the exact platform-level resource name once.
489    ///
490    /// This prevents Compose project scoping from changing a reviewed runtime resource name.
491    ///
492    /// # Errors
493    ///
494    /// Rejects an empty/NUL-bearing name and duplicate configuration.
495    pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
496        let name = required("custom resource name", name.into())?;
497        set_once(&mut self.custom_name, name, "resource name")
498    }
499
500    /// Returns the resource name.
501    #[must_use]
502    pub fn name(&self) -> &str {
503        &self.name
504    }
505
506    /// Reports whether Compose should reuse an external resource.
507    #[must_use]
508    pub const fn is_external(&self) -> bool {
509        self.external
510    }
511
512    /// Returns the optional exact platform-level resource name.
513    #[must_use]
514    pub fn custom_name(&self) -> Option<&str> {
515        self.custom_name.as_deref()
516    }
517}
518
519/// A typed generated Compose service definition.
520#[derive(Clone, Debug, Eq, PartialEq)]
521pub struct GeneratedService {
522    name: String,
523    image: Option<GeneratedString>,
524    command: Option<GeneratedCommand>,
525    environment: Vec<GeneratedEnvironment>,
526    user: Option<GeneratedString>,
527    userns_mode: Option<GeneratedString>,
528    group_add: Vec<GeneratedString>,
529    working_dir: Option<GeneratedString>,
530    read_only: Option<bool>,
531    extra_hosts: Vec<GeneratedExtraHost>,
532    ports: Vec<GeneratedPort>,
533    mounts: Vec<GeneratedMount>,
534    networks: Vec<GeneratedNetworkAttachment>,
535}
536
537impl GeneratedService {
538    /// Creates an empty service with a validated name.
539    ///
540    /// # Errors
541    ///
542    /// Rejects an empty or NUL-bearing name.
543    pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
544        Ok(Self {
545            name: required("service name", name.into())?,
546            image: None,
547            command: None,
548            environment: Vec::new(),
549            user: None,
550            userns_mode: None,
551            group_add: Vec::new(),
552            working_dir: None,
553            read_only: None,
554            extra_hosts: Vec::new(),
555            ports: Vec::new(),
556            mounts: Vec::new(),
557            networks: Vec::new(),
558        })
559    }
560
561    /// Returns the service name.
562    #[must_use]
563    pub fn name(&self) -> &str {
564        &self.name
565    }
566
567    /// Sets the service image exactly once.
568    ///
569    /// # Errors
570    ///
571    /// Returns [`GenerationError::EmptyValue`] for an empty image or
572    /// [`GenerationError::DuplicateField`] when already configured.
573    pub fn set_image(&mut self, image: GeneratedString) -> Result<(), GenerationError> {
574        require_generated_string("service image", &image)?;
575        set_once(&mut self.image, image, "image")
576    }
577
578    /// Sets the Compose command form exactly once.
579    ///
580    /// # Errors
581    ///
582    /// Returns [`GenerationError::DuplicateField`] when already configured.
583    pub fn set_command(&mut self, command: GeneratedCommand) -> Result<(), GenerationError> {
584        set_once(&mut self.command, command, "command")
585    }
586
587    /// Adds one ordered environment entry.
588    pub fn add_environment(&mut self, environment: GeneratedEnvironment) {
589        self.environment.push(environment);
590    }
591
592    /// Sets the combined Compose `user[:group]` value exactly once.
593    ///
594    /// # Errors
595    ///
596    /// Returns [`GenerationError::DuplicateField`] when already configured.
597    pub fn set_user(&mut self, user: GeneratedString) -> Result<(), GenerationError> {
598        set_once(&mut self.user, user, "user")
599    }
600
601    /// Sets the user-namespace mode exactly once.
602    ///
603    /// # Errors
604    ///
605    /// Returns [`GenerationError::EmptyValue`] for an empty mode or
606    /// [`GenerationError::DuplicateField`] when already configured.
607    pub fn set_userns_mode(&mut self, mode: GeneratedString) -> Result<(), GenerationError> {
608        require_generated_string("user namespace mode", &mode)?;
609        set_once(&mut self.userns_mode, mode, "userns_mode")
610    }
611
612    /// Adds one ordered supplementary group.
613    ///
614    /// # Errors
615    ///
616    /// Returns [`GenerationError::EmptyValue`] for an empty group.
617    pub fn add_supplementary_group(&mut self, group: GeneratedString) -> Result<(), GenerationError> {
618        require_generated_string("supplementary group", &group)?;
619        self.group_add.push(group);
620        Ok(())
621    }
622
623    /// Sets the container working directory exactly once.
624    ///
625    /// # Errors
626    ///
627    /// Returns [`GenerationError::EmptyValue`] for an empty directory or
628    /// [`GenerationError::DuplicateField`] when already configured.
629    pub fn set_working_dir(&mut self, directory: GeneratedString) -> Result<(), GenerationError> {
630        require_generated_string("working directory", &directory)?;
631        set_once(&mut self.working_dir, directory, "working_dir")
632    }
633
634    /// Sets the read-only-root choice exactly once.
635    ///
636    /// # Errors
637    ///
638    /// Returns [`GenerationError::DuplicateField`] when already configured.
639    pub fn set_read_only(&mut self, read_only: bool) -> Result<(), GenerationError> {
640        set_once(&mut self.read_only, read_only, "read_only")
641    }
642
643    /// Adds one ordered host mapping.
644    pub fn add_extra_host(&mut self, host: GeneratedExtraHost) {
645        self.extra_hosts.push(host);
646    }
647
648    /// Adds one ordered published-port declaration.
649    pub fn add_port(&mut self, port: GeneratedPort) {
650        self.ports.push(port);
651    }
652
653    /// Adds one ordered mount.
654    pub fn add_mount(&mut self, mount: GeneratedMount) {
655        self.mounts.push(mount);
656    }
657
658    /// Adds one uniquely named network attachment.
659    ///
660    /// # Errors
661    ///
662    /// Returns [`GenerationError::DuplicateName`] when the service already uses the network.
663    pub fn add_network(&mut self, network: GeneratedNetworkAttachment) -> Result<(), GenerationError> {
664        if self.networks.iter().any(|candidate| candidate.name == network.name) {
665            return Err(GenerationError::DuplicateName {
666                kind: "service network",
667                name: network.name,
668            });
669        }
670        self.networks.push(network);
671        Ok(())
672    }
673
674    fn is_sensitive(&self) -> bool {
675        self.image.as_ref().is_some_and(GeneratedString::is_sensitive)
676            || self.command.as_ref().is_some_and(command_is_sensitive)
677            || self
678                .environment
679                .iter()
680                .filter_map(GeneratedEnvironment::value)
681                .any(GeneratedString::is_sensitive)
682            || [self.user.as_ref(), self.userns_mode.as_ref(), self.working_dir.as_ref()]
683                .into_iter()
684                .flatten()
685                .any(GeneratedString::is_sensitive)
686            || self.group_add.iter().any(GeneratedString::is_sensitive)
687    }
688}
689
690/// Builder for one new deterministic Compose document.
691#[derive(Clone, Debug, Default, Eq, PartialEq)]
692pub struct ComposeDocumentBuilder {
693    name: Option<String>,
694    services: Vec<GeneratedService>,
695    networks: Vec<GeneratedResource>,
696    volumes: Vec<GeneratedResource>,
697}
698
699impl ComposeDocumentBuilder {
700    /// Creates an empty generated project.
701    #[must_use]
702    pub const fn new() -> Self {
703        Self {
704            name: None,
705            services: Vec::new(),
706            networks: Vec::new(),
707            volumes: Vec::new(),
708        }
709    }
710
711    /// Sets the optional top-level Compose project name exactly once.
712    ///
713    /// # Errors
714    ///
715    /// Rejects empty/NUL-bearing names and duplicate configuration.
716    pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
717        let name = required("project name", name.into())?;
718        set_once(&mut self.name, name, "name")
719    }
720
721    /// Adds one uniquely named service in output order.
722    ///
723    /// # Errors
724    ///
725    /// Returns [`GenerationError::DuplicateName`] for a duplicate service name.
726    pub fn add_service(&mut self, service: GeneratedService) -> Result<(), GenerationError> {
727        insert_named(&mut self.services, service, "service", GeneratedService::name)
728    }
729
730    /// Adds one uniquely named top-level network in output order.
731    ///
732    /// # Errors
733    ///
734    /// Returns [`GenerationError::DuplicateName`] for a duplicate network name.
735    pub fn add_network(&mut self, network: GeneratedResource) -> Result<(), GenerationError> {
736        insert_named(&mut self.networks, network, "network", GeneratedResource::name)
737    }
738
739    /// Adds one uniquely named top-level volume in output order.
740    ///
741    /// # Errors
742    ///
743    /// Returns [`GenerationError::DuplicateName`] for a duplicate volume name.
744    pub fn add_volume(&mut self, volume: GeneratedResource) -> Result<(), GenerationError> {
745        insert_named(&mut self.volumes, volume, "volume", GeneratedResource::name)
746    }
747
748    /// Generates YAML and parses it back through `ComposeLens`'s syntax and typed-model boundaries.
749    ///
750    /// # Errors
751    ///
752    /// Returns [`GenerationError::MissingService`] for an empty project or
753    /// [`GenerationError::InternalInvariant`] if `ComposeLens` cannot parse its own output.
754    pub fn build(self, source_id: SourceId) -> Result<GeneratedComposeDocument, GenerationError> {
755        if self.services.is_empty() {
756            return Err(GenerationError::MissingService);
757        }
758        let sensitive = self.services.iter().any(GeneratedService::is_sensitive);
759        let text = render_document(&self);
760        let syntax = SyntaxDocument::parse(source_id, text.clone())
761            .map_err(|_| GenerationError::InternalInvariant("syntax-tree"))?;
762        if !syntax.is_valid() {
763            return Err(GenerationError::InternalInvariant("syntax"));
764        }
765        let model = ComposeDocument::parse(syntax.document());
766        if !model.is_valid() {
767            return Err(GenerationError::InternalInvariant("typed-model"));
768        }
769        let document = model
770            .document()
771            .cloned()
772            .ok_or(GenerationError::InternalInvariant("document-root"))?;
773        Ok(GeneratedComposeDocument {
774            text,
775            sensitive,
776            document,
777        })
778    }
779}
780
781/// Parse-back-validated deterministic generated Compose document.
782#[derive(Clone, Eq, PartialEq)]
783pub struct GeneratedComposeDocument {
784    text: String,
785    sensitive: bool,
786    document: ComposeDocument,
787}
788
789impl GeneratedComposeDocument {
790    /// Returns the deployable generated YAML through an explicit access boundary.
791    #[must_use]
792    pub fn text(&self) -> &str {
793        &self.text
794    }
795
796    /// Returns the parse-back-validated native Compose model.
797    #[must_use]
798    pub const fn document(&self) -> &ComposeDocument {
799        &self.document
800    }
801
802    /// Reports whether generated output contains a caller-marked sensitive value.
803    #[must_use]
804    pub const fn is_sensitive(&self) -> bool {
805        self.sensitive
806    }
807}
808
809impl fmt::Debug for GeneratedComposeDocument {
810    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
811        formatter
812            .debug_struct("GeneratedComposeDocument")
813            .field("text", &if self.sensitive { "<redacted>" } else { &self.text })
814            .field("sensitive", &self.sensitive)
815            .field("document", &if self.sensitive { "<redacted>" } else { "validated" })
816            .finish()
817    }
818}
819
820fn render_document(project: &ComposeDocumentBuilder) -> String {
821    let mut output = String::new();
822    if let Some(name) = &project.name {
823        output.push_str("name: ");
824        write_quoted(&mut output, name);
825        output.push('\n');
826    }
827    output.push_str("services:\n");
828    for service in &project.services {
829        write_indent(&mut output, 1);
830        write_quoted(&mut output, &service.name);
831        output.push_str(":\n");
832        render_service(&mut output, service);
833    }
834    render_resources(&mut output, "networks", &project.networks);
835    render_resources(&mut output, "volumes", &project.volumes);
836    output
837}
838
839fn render_service(output: &mut String, service: &GeneratedService) {
840    render_optional_string(output, "image", service.image.as_ref());
841    if let Some(command) = &service.command {
842        render_command(output, command);
843    }
844    render_environment(output, &service.environment);
845    render_optional_string(output, "user", service.user.as_ref());
846    render_optional_string(output, "userns_mode", service.userns_mode.as_ref());
847    render_string_sequence(output, "group_add", &service.group_add);
848    render_optional_string(output, "working_dir", service.working_dir.as_ref());
849    if let Some(read_only) = service.read_only {
850        write_field(output, 2, "read_only");
851        output.push_str(if read_only { "true\n" } else { "false\n" });
852    }
853    render_extra_hosts(output, &service.extra_hosts);
854    render_ports(output, &service.ports);
855    render_mounts(output, &service.mounts);
856    render_networks(output, &service.networks);
857}
858
859fn render_optional_string(output: &mut String, key: &str, value: Option<&GeneratedString>) {
860    if let Some(value) = value {
861        write_field(output, 2, key);
862        write_quoted(output, value.expose());
863        output.push('\n');
864    }
865}
866
867fn render_command(output: &mut String, command: &GeneratedCommand) {
868    match command {
869        GeneratedCommand::Exec(arguments) if arguments.is_empty() => output.push_str("    command: []\n"),
870        GeneratedCommand::Exec(arguments) => render_string_sequence(output, "command", arguments),
871        GeneratedCommand::Shell(command) => render_optional_string(output, "command", Some(command)),
872        GeneratedCommand::Empty => output.push_str("    command: []\n"),
873    }
874}
875
876fn render_environment(output: &mut String, environment: &[GeneratedEnvironment]) {
877    if environment.is_empty() {
878        return;
879    }
880    output.push_str("    environment:\n");
881    for variable in environment {
882        output.push_str("      - ");
883        let value = variable.value.as_ref().map_or_else(
884            || variable.name.clone(),
885            |value| format!("{}={}", variable.name, value.expose()),
886        );
887        write_quoted(output, &value);
888        output.push('\n');
889    }
890}
891
892fn render_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
893    if values.is_empty() {
894        return;
895    }
896    write_indent(output, 2);
897    output.push_str(key);
898    output.push_str(":\n");
899    for value in values {
900        output.push_str("      - ");
901        write_quoted(output, value.expose());
902        output.push('\n');
903    }
904}
905
906fn render_extra_hosts(output: &mut String, hosts: &[GeneratedExtraHost]) {
907    if hosts.is_empty() {
908        return;
909    }
910    output.push_str("    extra_hosts:\n");
911    for host in hosts {
912        output.push_str("      - ");
913        write_quoted(output, &format!("{}={}", host.hostname, host.address));
914        output.push('\n');
915    }
916}
917
918fn render_ports(output: &mut String, ports: &[GeneratedPort]) {
919    if ports.is_empty() {
920        return;
921    }
922    output.push_str("    ports:\n");
923    for port in ports {
924        if port.protocol == GeneratedProtocol::Sctp {
925            render_short_sctp_port(output, port);
926            continue;
927        }
928        output.push_str("      - target: ");
929        output.push_str(&port.target.to_string());
930        output.push('\n');
931        if let Some(published) = port.published {
932            output.push_str("        published: ");
933            write_quoted(output, &published.to_string());
934            output.push('\n');
935        }
936        if let Some(host_ip) = &port.host_ip {
937            output.push_str("        host_ip: ");
938            write_quoted(output, host_ip);
939            output.push('\n');
940        }
941        output.push_str("        protocol: ");
942        write_quoted(output, port.protocol.as_str());
943        output.push('\n');
944    }
945}
946
947fn render_short_sctp_port(output: &mut String, port: &GeneratedPort) {
948    let mut value = String::new();
949    if let Some(host_ip) = &port.host_ip {
950        if host_ip.contains(':') && !(host_ip.starts_with('[') && host_ip.ends_with(']')) {
951            value.push('[');
952            value.push_str(host_ip);
953            value.push(']');
954        } else {
955            value.push_str(host_ip);
956        }
957        value.push(':');
958    }
959    if let Some(published) = port.published {
960        value.push_str(&published.to_string());
961        value.push(':');
962    }
963    value.push_str(&port.target.to_string());
964    value.push_str("/sctp");
965
966    output.push_str("      - ");
967    write_quoted(output, &value);
968    output.push('\n');
969}
970
971fn render_mounts(output: &mut String, mounts: &[GeneratedMount]) {
972    if mounts.is_empty() {
973        return;
974    }
975    output.push_str("    volumes:\n");
976    for mount in mounts {
977        match &mount.kind {
978            GeneratedMountKind::Bind {
979                source,
980                selinux: Some(selinux),
981            } => render_selinux_bind(output, source, mount, *selinux),
982            kind => render_long_mount(output, kind, mount),
983        }
984    }
985}
986
987fn render_selinux_bind(output: &mut String, source: &str, mount: &GeneratedMount, selinux: GeneratedSelinux) {
988    let mut value = format!("{source}:{}:{}", mount.target, selinux.as_str());
989    if mount.read_only {
990        value.push_str(",ro");
991    }
992    output.push_str("      - ");
993    write_quoted(output, &value);
994    output.push('\n');
995}
996
997fn render_long_mount(output: &mut String, kind: &GeneratedMountKind, mount: &GeneratedMount) {
998    let (mount_type, source) = match kind {
999        GeneratedMountKind::Volume { source } => ("volume", Some(source.as_str())),
1000        GeneratedMountKind::Bind { source, selinux: None } => ("bind", Some(source.as_str())),
1001        GeneratedMountKind::Anonymous => ("volume", None),
1002        GeneratedMountKind::Bind { selinux: Some(_), .. } => return,
1003    };
1004    output.push_str("      - type: ");
1005    write_quoted(output, mount_type);
1006    output.push('\n');
1007    if let Some(source) = source {
1008        output.push_str("        source: ");
1009        write_quoted(output, source);
1010        output.push('\n');
1011    }
1012    output.push_str("        target: ");
1013    write_quoted(output, &mount.target);
1014    output.push('\n');
1015    if mount.read_only {
1016        output.push_str("        read_only: true\n");
1017    }
1018}
1019
1020fn render_networks(output: &mut String, networks: &[GeneratedNetworkAttachment]) {
1021    if networks.is_empty() {
1022        return;
1023    }
1024    output.push_str("    networks:\n");
1025    for network in networks {
1026        output.push_str("      ");
1027        write_quoted(output, &network.name);
1028        if network.aliases.is_empty() {
1029            output.push_str(": {}\n");
1030        } else {
1031            output.push_str(":\n        aliases:\n");
1032            for alias in &network.aliases {
1033                output.push_str("          - ");
1034                write_quoted(output, alias);
1035                output.push('\n');
1036            }
1037        }
1038    }
1039}
1040
1041fn render_resources(output: &mut String, section: &str, resources: &[GeneratedResource]) {
1042    if resources.is_empty() {
1043        return;
1044    }
1045    output.push_str(section);
1046    output.push_str(":\n");
1047    for resource in resources {
1048        output.push_str("  ");
1049        write_quoted(output, &resource.name);
1050        if !resource.external && resource.custom_name.is_none() {
1051            output.push_str(": {}\n");
1052            continue;
1053        }
1054        output.push_str(":\n");
1055        if let Some(custom_name) = &resource.custom_name {
1056            output.push_str("    name: ");
1057            write_quoted(output, custom_name);
1058            output.push('\n');
1059        }
1060        if resource.external {
1061            output.push_str("    external: true\n");
1062        }
1063    }
1064}
1065
1066fn write_field(output: &mut String, depth: usize, key: &str) {
1067    write_indent(output, depth);
1068    output.push_str(key);
1069    output.push_str(": ");
1070}
1071
1072fn write_indent(output: &mut String, depth: usize) {
1073    for _ in 0..depth {
1074        output.push_str("  ");
1075    }
1076}
1077
1078fn required(kind: &'static str, value: String) -> Result<String, GenerationError> {
1079    if value.is_empty() {
1080        return Err(GenerationError::EmptyValue(kind));
1081    }
1082    if value.contains('\0') {
1083        return Err(GenerationError::ContainsNul(kind));
1084    }
1085    Ok(value)
1086}
1087
1088fn require_generated_string(kind: &'static str, value: &GeneratedString) -> Result<(), GenerationError> {
1089    if value.expose().is_empty() {
1090        return Err(GenerationError::EmptyValue(kind));
1091    }
1092    Ok(())
1093}
1094
1095fn environment_name(value: String) -> Result<String, GenerationError> {
1096    let value = required("environment name", value)?;
1097    if value.contains('=') {
1098        return Err(GenerationError::InvalidEnvironmentName);
1099    }
1100    Ok(value)
1101}
1102
1103fn short_component(kind: &'static str, value: String, separator: char) -> Result<String, GenerationError> {
1104    let value = required(kind, value)?;
1105    if value.contains(separator) {
1106        return Err(GenerationError::InvalidShortComponent(kind));
1107    }
1108    Ok(value)
1109}
1110
1111fn set_once<T>(slot: &mut Option<T>, value: T, field: &'static str) -> Result<(), GenerationError> {
1112    if slot.is_some() {
1113        return Err(GenerationError::DuplicateField(field));
1114    }
1115    *slot = Some(value);
1116    Ok(())
1117}
1118
1119fn insert_named<T>(
1120    values: &mut Vec<T>,
1121    value: T,
1122    kind: &'static str,
1123    name: impl Fn(&T) -> &str,
1124) -> Result<(), GenerationError> {
1125    let value_name = name(&value);
1126    if values.iter().any(|candidate| name(candidate) == value_name) {
1127        return Err(GenerationError::DuplicateName {
1128            kind,
1129            name: value_name.to_owned(),
1130        });
1131    }
1132    values.push(value);
1133    Ok(())
1134}
1135
1136fn command_is_sensitive(command: &GeneratedCommand) -> bool {
1137    match command {
1138        GeneratedCommand::Exec(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
1139        GeneratedCommand::Shell(command) => command.is_sensitive(),
1140        GeneratedCommand::Empty => false,
1141    }
1142}