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