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