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