Skip to main content

compose_lens/render/
generated.rs

1//! Deterministic construction of new Compose documents from reviewed native values.
2
3use std::{collections::BTreeSet, error::Error, fmt};
4
5use crate::{
6    model::{
7        ComposeDocument, MemLimitUnit, ShmSizeUnit, StopGracePeriod, valid_generated_device_string,
8        valid_generated_mem_amount, valid_generated_shm_amount, valid_generated_tmpfs_item, valid_hostname,
9        valid_positive_pids_decimal, valid_pull_policy_duration, valid_ulimit_name,
10    },
11    source::SourceId,
12    syntax::SyntaxDocument,
13};
14
15use super::write_quoted;
16
17/// A generated Compose construction request is invalid or cannot be represented safely.
18#[derive(Clone, Debug, Eq, PartialEq)]
19#[non_exhaustive]
20pub enum GenerationError {
21    /// A required value is empty.
22    EmptyValue(&'static str),
23    /// A value contains a NUL byte and cannot represent native container intent safely.
24    ContainsNul(&'static str),
25    /// A value contains a carriage return or line feed where one YAML string item is required.
26    ContainsLineBreak(&'static str),
27    /// An environment name contains Compose list-form's `=` separator.
28    InvalidEnvironmentName,
29    /// A custom container name does not satisfy Compose's portable name grammar.
30    InvalidContainerName,
31    /// A service hostname is empty, deferred, or outside the conservative RFC-1123 grammar.
32    InvalidHostname,
33    /// A custom pull interval does not match the documented Compose duration grammar.
34    InvalidPullPolicyDuration,
35    /// A finite PID limit is not a positive integral decimal.
36    InvalidPidsLimit,
37    /// A service shared-memory amount is not a canonical positive ASCII decimal.
38    InvalidShmSize,
39    /// A service memory-limit amount is not a canonical positive ASCII decimal.
40    InvalidMemLimit,
41    /// A service-level temporary-filesystem item is deferred, malformed, or provider-dependent.
42    InvalidTmpfsItem,
43    /// A generated short device or long-device member is empty where required, multiline, or deferred.
44    InvalidDeviceValue(&'static str),
45    /// A generated sysctl mapping name is empty, multiline, NUL-bearing, or expression-shaped.
46    InvalidSysctlName,
47    /// A generated sysctl value or list item is multiline, NUL-bearing, or expression-shaped.
48    InvalidSysctlValue,
49    /// A generated ulimit name is outside the portable lowercase ASCII grammar.
50    InvalidUlimitName,
51    /// A generated ulimit value is outside the supported portable decimal or unlimited set.
52    InvalidUlimitValue,
53    /// A generated ulimit range omitted its required soft or hard member.
54    MissingUlimitRangeMember(&'static str),
55    /// A stop grace period does not match the raw-preserving policy based on documented Compose units.
56    InvalidStopGracePeriod,
57    /// A short-form component contains its reserved separator.
58    InvalidShortComponent(&'static str),
59    /// A short bind spelling needed for `SELinux` cannot be encoded unambiguously.
60    InvalidSelinuxBind,
61    /// A singleton field was configured more than once.
62    DuplicateField(&'static str),
63    /// A named generated collection contains the same name more than once.
64    DuplicateName {
65        /// Collection whose name collided.
66        kind: &'static str,
67        /// Duplicate non-sensitive name.
68        name: String,
69    },
70    /// A generated sequence contains an exact duplicate item.
71    DuplicateItem(&'static str),
72    /// A generated port used target port zero.
73    InvalidPort,
74    /// An `SCTP` port selected a host address without a published port.
75    UnrepresentableSctpHostIp,
76    /// A generated project contains no services.
77    MissingService,
78    /// `ComposeLens` could not parse its own deterministic generated bytes.
79    InternalInvariant(&'static str),
80}
81
82impl fmt::Display for GenerationError {
83    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Self::EmptyValue(kind) => write!(formatter, "generated {kind} must not be empty"),
86            Self::ContainsNul(kind) => write!(formatter, "generated {kind} must not contain a NUL byte"),
87            Self::ContainsLineBreak(kind) => {
88                write!(formatter, "generated {kind} must not contain a carriage return or line feed")
89            }
90            Self::InvalidEnvironmentName => formatter.write_str("generated environment name must not contain `=`"),
91            Self::InvalidContainerName => {
92                formatter.write_str("generated container name must match `[a-zA-Z0-9][a-zA-Z0-9_.-]+`")
93            }
94            Self::InvalidHostname => formatter.write_str(
95                "generated hostname must be a resolved ASCII RFC-1123 name with labels of 1 to 63 characters and total length at most 253",
96            ),
97            Self::InvalidPullPolicyDuration => formatter.write_str(
98                "generated pull policy duration must match integer `w`, `d`, `h`, `m`, and `s` components",
99            ),
100            Self::InvalidPidsLimit => {
101                formatter.write_str("generated finite PID limit must be a positive integral decimal")
102            }
103            Self::InvalidShmSize => formatter.write_str(
104                "generated shared-memory size must use a canonical positive ASCII-integer amount and an explicit documented lowercase unit",
105            ),
106            Self::InvalidMemLimit => formatter.write_str(
107                "generated memory limit must use a canonical positive ASCII-integer amount and an explicit documented lowercase unit",
108            ),
109            Self::InvalidTmpfsItem => formatter.write_str(
110                "generated tmpfs item must be a non-empty path optionally followed by a colon and non-empty comma-separated raw options",
111            ),
112            Self::InvalidDeviceValue(member) => write!(
113                formatter,
114                "generated device {member} must be a safe resolved single-line string{}",
115                if matches!(*member, "short item" | "source") {
116                    " and must not be empty"
117                } else {
118                    ""
119                }
120            ),
121            Self::InvalidSysctlName => formatter
122                .write_str("generated sysctl name must be a non-empty resolved single-line string"),
123            Self::InvalidSysctlValue => formatter
124                .write_str("generated sysctl value must be a resolved single-line string"),
125            Self::InvalidUlimitName => formatter
126                .write_str("generated ulimit name must match lowercase ASCII `[a-z]+`"),
127            Self::InvalidUlimitValue => formatter
128                .write_str("generated ulimit value must be `-1` or a non-negative ASCII decimal"),
129            Self::MissingUlimitRangeMember(member) => {
130                write!(formatter, "generated ulimit range is missing required `{member}`")
131            }
132            Self::InvalidStopGracePeriod => formatter.write_str(
133                "generated stop grace period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
134            ),
135            Self::InvalidShortComponent(kind) => {
136                write!(formatter, "generated {kind} contains its reserved short-form separator")
137            }
138            Self::InvalidSelinuxBind => formatter
139                .write_str("generated SELinux bind source and target must not contain the short-syntax `:` separator"),
140            Self::DuplicateField(field) => write!(formatter, "generated field `{field}` was configured more than once"),
141            Self::DuplicateName { kind, name } => {
142                write!(formatter, "generated {kind} `{name}` was added more than once")
143            }
144            Self::DuplicateItem(kind) => write!(formatter, "generated {kind} contains an exact duplicate item"),
145            Self::InvalidPort => formatter.write_str("generated container target port must be greater than zero"),
146            Self::UnrepresentableSctpHostIp => formatter.write_str(
147                "generated SCTP port with a host address also requires a published port for Compose short syntax",
148            ),
149            Self::MissingService => formatter.write_str("generated Compose project requires at least one service"),
150            Self::InternalInvariant(stage) => write!(formatter, "generated Compose document failed {stage} validation"),
151        }
152    }
153}
154
155impl Error for GenerationError {}
156
157/// A plain or sensitive string used by generated Compose fields.
158#[derive(Clone, Eq, PartialEq)]
159pub struct GeneratedString {
160    value: String,
161    sensitive: bool,
162}
163
164impl GeneratedString {
165    /// Creates a non-sensitive generated string.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`GenerationError::ContainsNul`] when the value contains a NUL byte.
170    pub fn plain(value: impl Into<String>) -> Result<Self, GenerationError> {
171        Self::new(value.into(), false)
172    }
173
174    /// Creates a sensitive generated string whose debug representation is redacted.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`GenerationError::ContainsNul`] when the value contains a NUL byte.
179    pub fn sensitive(value: impl Into<String>) -> Result<Self, GenerationError> {
180        Self::new(value.into(), true)
181    }
182
183    fn new(value: String, sensitive: bool) -> Result<Self, GenerationError> {
184        if value.contains('\0') {
185            return Err(GenerationError::ContainsNul("string"));
186        }
187        Ok(Self { value, sensitive })
188    }
189
190    /// Returns the generated value through an explicit access boundary.
191    #[must_use]
192    pub fn expose(&self) -> &str {
193        &self.value
194    }
195
196    /// Reports whether debug output must redact this value.
197    #[must_use]
198    pub const fn is_sensitive(&self) -> bool {
199        self.sensitive
200    }
201}
202
203impl fmt::Debug for GeneratedString {
204    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
205        formatter
206            .debug_struct("GeneratedString")
207            .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
208            .field("sensitive", &self.sensitive)
209            .finish()
210    }
211}
212
213/// Compose command form selected for a generated service.
214#[derive(Clone, Debug, Eq, PartialEq)]
215#[non_exhaustive]
216pub enum GeneratedCommand {
217    /// Execute an exact argument vector without Compose shell parsing.
218    Exec(Vec<GeneratedString>),
219    /// Execute one Compose shell-form command.
220    Shell(GeneratedString),
221    /// Explicitly clear the image command.
222    Empty,
223}
224
225/// Compose entrypoint form selected for a generated service.
226#[derive(Clone, Debug, Eq, PartialEq)]
227#[non_exhaustive]
228pub enum GeneratedEntrypoint {
229    /// Emit an exact entrypoint list in authored argument order.
230    List(Vec<GeneratedString>),
231    /// Emit the short scalar string form.
232    String(GeneratedString),
233    /// Explicitly clear the entrypoint declared by the image.
234    Empty,
235}
236
237/// A valid service-level Compose restart policy selected for generated output.
238#[derive(Clone, Copy, Debug, Eq, PartialEq)]
239#[non_exhaustive]
240pub enum GeneratedRestartPolicy {
241    /// Never restart the container automatically.
242    No,
243    /// Always restart the container until it is removed.
244    Always,
245    /// Restart after an error, optionally with a maximum retry count.
246    OnFailure {
247        /// Maximum retries, or `None` for no explicit limit.
248        maximum_retries: Option<u64>,
249    },
250    /// Restart except after an explicit stop or removal.
251    UnlessStopped,
252}
253
254/// A documented service-level Compose image pull policy selected for generated output.
255#[derive(Clone, Debug, Eq, PartialEq)]
256#[non_exhaustive]
257pub enum GeneratedPullPolicy {
258    /// Pull before every service start.
259    Always,
260    /// Never pull and rely on a cached image.
261    Never,
262    /// Pull only when the image is missing.
263    Missing,
264    /// Emit the retained `if_not_present` alias.
265    IfNotPresentAlias,
266    /// Build the image before starting the service.
267    Build,
268    /// Check once per day.
269    Daily,
270    /// Check once per week.
271    Weekly,
272    /// Check after an exact caller-supplied duration spelling.
273    Every(GeneratedString),
274}
275
276/// A service-level Compose PID limit selected for generated output.
277#[derive(Clone, Debug, Eq, PartialEq)]
278#[non_exhaustive]
279pub enum GeneratedPidsLimit {
280    /// Emit the documented unlimited spelling `-1`.
281    Unlimited,
282    /// Emit an exact positive integral decimal without fixed-width integer parsing.
283    Finite(String),
284}
285
286/// A safe explicit service shared-memory size selected for generated Compose output.
287#[derive(Clone, Debug, Eq, PartialEq)]
288#[non_exhaustive]
289pub enum GeneratedShmSize {
290    /// Emit one quoted amount and documented lowercase unit.
291    Explicit {
292        /// Canonical positive ASCII-integer amount without leading zeros.
293        amount: GeneratedString,
294        /// Explicit documented lowercase unit.
295        unit: ShmSizeUnit,
296    },
297}
298
299/// A safe explicit service memory limit selected for generated Compose output.
300#[derive(Clone, Debug, Eq, PartialEq)]
301#[non_exhaustive]
302pub enum GeneratedMemLimit {
303    /// Emit one quoted amount and documented lowercase unit.
304    Explicit {
305        /// Canonical positive ASCII-integer amount without leading zeros.
306        amount: GeneratedString,
307        /// Explicit documented lowercase unit.
308        unit: MemLimitUnit,
309    },
310}
311
312/// The exact service-level `tmpfs` form selected for generated Compose output.
313#[derive(Clone, Debug, Eq, PartialEq)]
314#[non_exhaustive]
315pub enum GeneratedTmpfs {
316    /// Emit one quoted scalar item.
317    Scalar(GeneratedString),
318    /// Emit one quoted ordered list, including an explicit empty list.
319    List(Vec<GeneratedString>),
320}
321
322/// One generated long-syntax service device.
323#[derive(Clone, Debug, Eq, PartialEq)]
324pub struct GeneratedLongDevice {
325    source: GeneratedString,
326    target: Option<GeneratedString>,
327    permissions: Option<GeneratedString>,
328}
329
330impl GeneratedLongDevice {
331    /// Creates a long device from safe resolved strings without interpreting device paths or permissions.
332    ///
333    /// # Errors
334    ///
335    /// Rejects an empty source and any NUL-bearing, multiline, or dollar-bearing member. NUL bytes
336    /// are normally rejected while constructing [`GeneratedString`]. Empty optional target and
337    /// permissions strings remain raw schema strings and are not assigned runtime meaning.
338    pub fn new(
339        source: GeneratedString,
340        target: Option<GeneratedString>,
341        permissions: Option<GeneratedString>,
342    ) -> Result<Self, GenerationError> {
343        validate_generated_device_member("source", &source, true)?;
344        if let Some(target) = &target {
345            validate_generated_device_member("target", target, false)?;
346        }
347        if let Some(permissions) = &permissions {
348            validate_generated_device_member("permissions", permissions, false)?;
349        }
350        Ok(Self {
351            source,
352            target,
353            permissions,
354        })
355    }
356
357    /// Returns the exact generated source through its sensitivity boundary.
358    #[must_use]
359    pub const fn source(&self) -> &GeneratedString {
360        &self.source
361    }
362
363    /// Returns the optional exact generated target.
364    #[must_use]
365    pub const fn target(&self) -> Option<&GeneratedString> {
366        self.target.as_ref()
367    }
368
369    /// Returns the optional exact raw generated permissions string.
370    #[must_use]
371    pub const fn permissions(&self) -> Option<&GeneratedString> {
372        self.permissions.as_ref()
373    }
374
375    fn is_sensitive(&self) -> bool {
376        self.source.is_sensitive()
377            || self.target.as_ref().is_some_and(GeneratedString::is_sensitive)
378            || self.permissions.as_ref().is_some_and(GeneratedString::is_sensitive)
379    }
380}
381
382/// One generated service device with explicit short or long syntax.
383#[derive(Clone, Debug, Eq, PartialEq)]
384#[non_exhaustive]
385pub enum GeneratedDevice {
386    /// Emit one exact quoted raw short item.
387    Short(GeneratedString),
388    /// Emit one ordered long mapping.
389    Long(GeneratedLongDevice),
390}
391
392impl GeneratedDevice {
393    fn is_sensitive(&self) -> bool {
394        match self {
395            Self::Short(value) => value.is_sensitive(),
396            Self::Long(value) => value.is_sensitive(),
397        }
398    }
399}
400
401/// One ordered mapping-form generated sysctl assignment.
402#[derive(Clone, Debug, Eq, PartialEq)]
403pub struct GeneratedSysctl {
404    name: String,
405    value: GeneratedString,
406}
407
408impl GeneratedSysctl {
409    /// Creates one resolved string-valued sysctl assignment.
410    ///
411    /// # Errors
412    ///
413    /// Rejects empty, multiline, NUL-bearing, or dollar-bearing names and multiline or
414    /// dollar-bearing values. Values may be empty. NUL-bearing values are rejected while
415    /// constructing [`GeneratedString`].
416    pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
417        let name = name.into();
418        if name.is_empty()
419            || name.contains(['\0', '\r', '\n'])
420            || name.contains('$')
421            || value.expose().contains(['\r', '\n', '$'])
422        {
423            return Err(if name.is_empty() || name.contains(['\0', '\r', '\n', '$']) {
424                GenerationError::InvalidSysctlName
425            } else {
426                GenerationError::InvalidSysctlValue
427            });
428        }
429        Ok(Self { name, value })
430    }
431
432    /// Returns the exact generated sysctl name.
433    #[must_use]
434    pub fn name(&self) -> &str {
435        &self.name
436    }
437
438    /// Returns the exact quoted-string value through its sensitivity boundary.
439    #[must_use]
440    pub const fn value(&self) -> &GeneratedString {
441        &self.value
442    }
443}
444
445/// The mapping or list form selected for generated service `sysctls`.
446#[derive(Clone, Debug, Eq, PartialEq)]
447#[non_exhaustive]
448pub enum GeneratedSysctls {
449    /// Ordered unique-name mapping assignments, including an explicit empty mapping.
450    Map(Vec<GeneratedSysctl>),
451    /// Ordered unique exact strings, including an explicit empty list.
452    List(Vec<GeneratedString>),
453}
454
455/// The single or soft/hard form selected for one generated service limit.
456#[derive(Clone, Debug, Eq, PartialEq)]
457#[non_exhaustive]
458pub enum GeneratedUlimitValue {
459    /// One value applies to both the soft and hard limit.
460    Single(GeneratedString),
461    /// Separate required soft and hard values.
462    Range {
463        /// Required soft limit; omission is rejected during construction.
464        soft: Option<GeneratedString>,
465        /// Required hard limit; omission is rejected during construction.
466        hard: Option<GeneratedString>,
467    },
468}
469
470/// One ordered generated service limit.
471#[derive(Clone, Debug, Eq, PartialEq)]
472pub struct GeneratedUlimit {
473    name: String,
474    value: GeneratedUlimitValue,
475}
476
477impl GeneratedUlimit {
478    /// Creates one validated named generated limit.
479    ///
480    /// # Errors
481    ///
482    /// Rejects non-lowercase names, missing range members, deferred/multiline/NUL-bearing values,
483    /// and values other than `-1` or non-negative ASCII decimals.
484    pub fn new(name: impl Into<String>, value: GeneratedUlimitValue) -> Result<Self, GenerationError> {
485        let name = name.into();
486        if !valid_ulimit_name(&name) {
487            return Err(GenerationError::InvalidUlimitName);
488        }
489        match &value {
490            GeneratedUlimitValue::Single(value) => validate_generated_ulimit_value(value)?,
491            GeneratedUlimitValue::Range { soft, hard } => {
492                let soft = soft.as_ref().ok_or(GenerationError::MissingUlimitRangeMember("soft"))?;
493                let hard = hard.as_ref().ok_or(GenerationError::MissingUlimitRangeMember("hard"))?;
494                validate_generated_ulimit_value(soft)?;
495                validate_generated_ulimit_value(hard)?;
496            }
497        }
498        Ok(Self { name, value })
499    }
500
501    /// Creates one validated single-form generated limit.
502    ///
503    /// # Errors
504    ///
505    /// Returns the same name and value validation errors as [`Self::new`].
506    pub fn single(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
507        Self::new(name, GeneratedUlimitValue::Single(value))
508    }
509
510    /// Creates one validated soft/hard generated limit.
511    ///
512    /// # Errors
513    ///
514    /// Returns the same name and value validation errors as [`Self::new`].
515    pub fn range(
516        name: impl Into<String>,
517        soft: GeneratedString,
518        hard: GeneratedString,
519    ) -> Result<Self, GenerationError> {
520        Self::new(
521            name,
522            GeneratedUlimitValue::Range {
523                soft: Some(soft),
524                hard: Some(hard),
525            },
526        )
527    }
528
529    /// Returns the lowercase limit name.
530    #[must_use]
531    pub fn name(&self) -> &str {
532        &self.name
533    }
534
535    /// Returns the selected single or soft/hard form.
536    #[must_use]
537    pub const fn value(&self) -> &GeneratedUlimitValue {
538        &self.value
539    }
540
541    fn is_sensitive(&self) -> bool {
542        match &self.value {
543            GeneratedUlimitValue::Single(value) => value.is_sensitive(),
544            GeneratedUlimitValue::Range { soft, hard } => {
545                soft.iter().chain(hard.iter()).any(GeneratedString::is_sensitive)
546            }
547        }
548    }
549}
550
551/// Ordered generated service limits, including an explicit empty mapping.
552#[derive(Clone, Debug, Eq, PartialEq)]
553pub struct GeneratedUlimits {
554    entries: Vec<GeneratedUlimit>,
555}
556
557impl GeneratedUlimits {
558    /// Creates an ordered unique-name limit mapping.
559    ///
560    /// # Errors
561    ///
562    /// Rejects duplicate names without reordering the retained entries.
563    pub fn new(entries: Vec<GeneratedUlimit>) -> Result<Self, GenerationError> {
564        let mut seen = BTreeSet::new();
565        for entry in &entries {
566            if !seen.insert(entry.name()) {
567                return Err(GenerationError::DuplicateName {
568                    kind: "ulimit",
569                    name: entry.name().to_owned(),
570                });
571            }
572        }
573        Ok(Self { entries })
574    }
575
576    /// Returns limits in generated output order.
577    #[must_use]
578    pub fn entries(&self) -> &[GeneratedUlimit] {
579        &self.entries
580    }
581
582    /// Reports whether generation will emit an explicit empty mapping.
583    #[must_use]
584    pub fn is_empty(&self) -> bool {
585        self.entries.is_empty()
586    }
587}
588
589/// A resolved service hostname selected for generated Compose output.
590#[derive(Clone, Debug, Eq, PartialEq)]
591#[non_exhaustive]
592pub enum GeneratedHostname {
593    /// Emit one exact resolved hostname after conservative RFC-1123 validation.
594    Resolved(GeneratedString),
595}
596
597/// One ordered Compose environment entry.
598#[derive(Clone, Debug, Eq, PartialEq)]
599pub struct GeneratedEnvironment {
600    name: String,
601    value: Option<GeneratedString>,
602}
603
604/// Explicit parser mode for one generated long-syntax `env_file` entry.
605#[derive(Clone, Copy, Debug, Eq, PartialEq)]
606#[non_exhaustive]
607pub enum GeneratedEnvironmentFileFormat {
608    /// Preserve raw environment-file values without Compose interpolation or quote processing.
609    Raw,
610}
611
612/// One ordered generated Compose `env_file` declaration.
613#[derive(Clone, Debug, Eq, PartialEq)]
614#[non_exhaustive]
615pub enum GeneratedEnvironmentFile {
616    /// Scalar path syntax with Compose defaults.
617    Short(GeneratedString),
618    /// Mapping syntax with independently selected options.
619    Long {
620        /// Environment-file path.
621        path: GeneratedString,
622        /// Explicit required/optional behavior, or source-format default when omitted.
623        required: Option<bool>,
624        /// Explicit parser mode, or source-format default when omitted.
625        format: Option<GeneratedEnvironmentFileFormat>,
626    },
627}
628
629/// One generated service metadata label.
630#[derive(Clone, Debug, Eq, PartialEq)]
631pub struct GeneratedLabel {
632    name: String,
633    value: GeneratedString,
634}
635
636impl GeneratedLabel {
637    /// Creates a label with an explicit string value, including an empty value.
638    ///
639    /// # Errors
640    ///
641    /// Rejects an empty or NUL-bearing label name. Values are already validated by
642    /// [`GeneratedString`].
643    pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
644        Ok(Self {
645            name: required("label name", name.into())?,
646            value,
647        })
648    }
649
650    /// Returns the label name.
651    #[must_use]
652    pub fn name(&self) -> &str {
653        &self.name
654    }
655
656    /// Returns the label value through its explicit sensitivity boundary.
657    #[must_use]
658    pub const fn value(&self) -> &GeneratedString {
659        &self.value
660    }
661}
662
663impl GeneratedEnvironment {
664    /// Creates a literal `NAME=value` entry.
665    ///
666    /// # Errors
667    ///
668    /// Rejects an empty/NUL-bearing name or a name containing `=`.
669    pub fn literal(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
670        Ok(Self {
671            name: environment_name(name.into())?,
672            value: Some(value),
673        })
674    }
675
676    /// Creates a host-resolved key-only environment entry.
677    ///
678    /// # Errors
679    ///
680    /// Rejects an empty/NUL-bearing name or a name containing `=`.
681    pub fn host(name: impl Into<String>) -> Result<Self, GenerationError> {
682        Ok(Self {
683            name: environment_name(name.into())?,
684            value: None,
685        })
686    }
687
688    /// Returns the environment name.
689    #[must_use]
690    pub fn name(&self) -> &str {
691        &self.name
692    }
693
694    /// Returns the optional literal value.
695    #[must_use]
696    pub const fn value(&self) -> Option<&GeneratedString> {
697        self.value.as_ref()
698    }
699}
700
701impl GeneratedEnvironmentFile {
702    /// Creates one scalar short-syntax declaration.
703    ///
704    /// # Errors
705    ///
706    /// Returns [`GenerationError::EmptyValue`] for an empty path. NUL-bearing paths are rejected
707    /// while constructing [`GeneratedString`].
708    pub fn short(path: GeneratedString) -> Result<Self, GenerationError> {
709        require_generated_string("environment-file path", &path)?;
710        Ok(Self::Short(path))
711    }
712
713    /// Creates one mapping long-syntax declaration.
714    ///
715    /// # Errors
716    ///
717    /// Returns [`GenerationError::EmptyValue`] for an empty path. NUL-bearing paths are rejected
718    /// while constructing [`GeneratedString`].
719    pub fn long(
720        path: GeneratedString,
721        required: Option<bool>,
722        format: Option<GeneratedEnvironmentFileFormat>,
723    ) -> Result<Self, GenerationError> {
724        require_generated_string("environment-file path", &path)?;
725        Ok(Self::Long { path, required, format })
726    }
727
728    /// Returns the environment-file path through its explicit sensitivity boundary.
729    #[must_use]
730    pub const fn path(&self) -> &GeneratedString {
731        match self {
732            Self::Short(path) | Self::Long { path, .. } => path,
733        }
734    }
735
736    /// Returns the explicitly selected required/optional behavior for long syntax.
737    #[must_use]
738    pub const fn required(&self) -> Option<bool> {
739        match self {
740            Self::Short(_) => None,
741            Self::Long { required, .. } => *required,
742        }
743    }
744
745    /// Returns the explicitly selected parser mode for long syntax.
746    #[must_use]
747    pub const fn format(&self) -> Option<GeneratedEnvironmentFileFormat> {
748        match self {
749            Self::Short(_) => None,
750            Self::Long { format, .. } => *format,
751        }
752    }
753
754    /// Reports whether debug output must redact this declaration's path.
755    #[must_use]
756    pub const fn is_sensitive(&self) -> bool {
757        self.path().is_sensitive()
758    }
759}
760
761/// One ordered Compose `extra_hosts` relationship.
762#[derive(Clone, Debug, Eq, PartialEq)]
763pub struct GeneratedExtraHost {
764    hostname: String,
765    address: String,
766}
767
768impl GeneratedExtraHost {
769    /// Creates a short-form `hostname=address` relationship.
770    ///
771    /// # Errors
772    ///
773    /// Rejects empty/NUL-bearing values and the unambiguous short-form separator `=`.
774    pub fn new(hostname: impl Into<String>, address: impl Into<String>) -> Result<Self, GenerationError> {
775        let hostname = short_component("extra-host hostname", hostname.into(), '=')?;
776        let address = short_component("extra-host address", address.into(), '=')?;
777        Ok(Self { hostname, address })
778    }
779
780    /// Returns the hostname.
781    #[must_use]
782    pub fn hostname(&self) -> &str {
783        &self.hostname
784    }
785
786    /// Returns the address or implementation token.
787    #[must_use]
788    pub fn address(&self) -> &str {
789        &self.address
790    }
791}
792
793/// Transport protocol for one generated published port.
794#[derive(Clone, Copy, Debug, Eq, PartialEq)]
795#[non_exhaustive]
796pub enum GeneratedProtocol {
797    /// Transmission Control Protocol.
798    Tcp,
799    /// User Datagram Protocol.
800    Udp,
801    /// Stream Control Transmission Protocol.
802    Sctp,
803}
804
805impl GeneratedProtocol {
806    const fn as_str(self) -> &'static str {
807        match self {
808            Self::Tcp => "tcp",
809            Self::Udp => "udp",
810            Self::Sctp => "sctp",
811        }
812    }
813}
814
815/// One generated Compose port entry with protocol-aware syntax selection.
816#[derive(Clone, Debug, Eq, PartialEq)]
817pub struct GeneratedPort {
818    target: u16,
819    published: Option<u16>,
820    host_ip: Option<String>,
821    protocol: GeneratedProtocol,
822}
823
824impl GeneratedPort {
825    /// Creates a generated port without normalizing its declared transport.
826    ///
827    /// # Errors
828    ///
829    /// Rejects target port zero, an empty/NUL-bearing host address, and an `SCTP` host address
830    /// without a published port. `SCTP` uses Compose short syntax because the specification's
831    /// long form only defines `tcp` and `udp` protocols.
832    pub fn new(
833        target: u16,
834        published: Option<u16>,
835        host_ip: Option<String>,
836        protocol: GeneratedProtocol,
837    ) -> Result<Self, GenerationError> {
838        if target == 0 {
839            return Err(GenerationError::InvalidPort);
840        }
841        if let Some(host_ip) = host_ip.as_deref() {
842            required("port host address", host_ip.to_owned())?;
843            if protocol == GeneratedProtocol::Sctp && published.is_none() {
844                return Err(GenerationError::UnrepresentableSctpHostIp);
845            }
846        }
847        Ok(Self {
848            target,
849            published,
850            host_ip,
851            protocol,
852        })
853    }
854
855    /// Returns the container port.
856    #[must_use]
857    pub const fn target(&self) -> u16 {
858        self.target
859    }
860
861    /// Returns the optional host port.
862    #[must_use]
863    pub const fn published(&self) -> Option<u16> {
864        self.published
865    }
866
867    /// Returns the optional host-address spelling.
868    #[must_use]
869    pub fn host_ip(&self) -> Option<&str> {
870        self.host_ip.as_deref()
871    }
872
873    /// Returns the transport protocol.
874    #[must_use]
875    pub const fn protocol(&self) -> GeneratedProtocol {
876        self.protocol
877    }
878}
879
880/// `SELinux` relabel option that requires Compose short bind syntax.
881#[derive(Clone, Copy, Debug, Eq, PartialEq)]
882#[non_exhaustive]
883pub enum GeneratedSelinux {
884    /// Private unshared relabel (`Z`).
885    Private,
886    /// Shared relabel (`z`).
887    Shared,
888}
889
890impl GeneratedSelinux {
891    const fn as_str(self) -> &'static str {
892        match self {
893            Self::Private => "Z",
894            Self::Shared => "z",
895        }
896    }
897}
898
899#[derive(Clone, Debug, Eq, PartialEq)]
900enum GeneratedMountKind {
901    Volume {
902        source: String,
903    },
904    Bind {
905        source: String,
906        selinux: Option<GeneratedSelinux>,
907    },
908    Anonymous,
909}
910
911/// One generated service mount with deliberate short/long syntax selection.
912#[derive(Clone, Debug, Eq, PartialEq)]
913pub struct GeneratedMount {
914    kind: GeneratedMountKind,
915    target: String,
916    read_only: bool,
917}
918
919impl GeneratedMount {
920    /// Creates a long-form named-volume mount.
921    ///
922    /// # Errors
923    ///
924    /// Rejects empty or NUL-bearing source and target values.
925    pub fn volume(
926        source: impl Into<String>,
927        target: impl Into<String>,
928        read_only: bool,
929    ) -> Result<Self, GenerationError> {
930        Ok(Self {
931            kind: GeneratedMountKind::Volume {
932                source: required("volume source", source.into())?,
933            },
934            target: required("mount target", target.into())?,
935            read_only,
936        })
937    }
938
939    /// Creates a bind mount. `SELinux` relabel intent selects short syntax deliberately.
940    ///
941    /// # Errors
942    ///
943    /// Rejects empty/NUL-bearing values. When `selinux` is present, also rejects `:` in source or
944    /// target because Compose only honors the relabel option in the short form used here.
945    pub fn bind(
946        source: impl Into<String>,
947        target: impl Into<String>,
948        read_only: bool,
949        selinux: Option<GeneratedSelinux>,
950    ) -> Result<Self, GenerationError> {
951        let source = required("bind source", source.into())?;
952        let target = required("mount target", target.into())?;
953        if selinux.is_some() && (source.contains(':') || target.contains(':')) {
954            return Err(GenerationError::InvalidSelinuxBind);
955        }
956        Ok(Self {
957            kind: GeneratedMountKind::Bind { source, selinux },
958            target,
959            read_only,
960        })
961    }
962
963    /// Creates a long-form anonymous-volume mount.
964    ///
965    /// # Errors
966    ///
967    /// Rejects an empty or NUL-bearing target.
968    pub fn anonymous(target: impl Into<String>, read_only: bool) -> Result<Self, GenerationError> {
969        Ok(Self {
970            kind: GeneratedMountKind::Anonymous,
971            target: required("mount target", target.into())?,
972            read_only,
973        })
974    }
975
976    /// Returns the container target path.
977    #[must_use]
978    pub fn target(&self) -> &str {
979        &self.target
980    }
981
982    /// Reports whether the mount is read-only.
983    #[must_use]
984    pub const fn read_only(&self) -> bool {
985        self.read_only
986    }
987}
988
989/// One generated service network attachment and its ordered aliases.
990#[derive(Clone, Debug, Eq, PartialEq)]
991pub struct GeneratedNetworkAttachment {
992    name: String,
993    aliases: Vec<String>,
994}
995
996impl GeneratedNetworkAttachment {
997    /// Creates an attachment without aliases.
998    ///
999    /// # Errors
1000    ///
1001    /// Rejects an empty or NUL-bearing network name.
1002    pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
1003        Ok(Self {
1004            name: required("network name", name.into())?,
1005            aliases: Vec::new(),
1006        })
1007    }
1008
1009    /// Adds one ordered alias.
1010    ///
1011    /// # Errors
1012    ///
1013    /// Rejects an empty or NUL-bearing alias.
1014    pub fn add_alias(&mut self, alias: impl Into<String>) -> Result<(), GenerationError> {
1015        self.aliases.push(required("network alias", alias.into())?);
1016        Ok(())
1017    }
1018
1019    /// Returns the network name.
1020    #[must_use]
1021    pub fn name(&self) -> &str {
1022        &self.name
1023    }
1024
1025    /// Returns aliases in insertion order.
1026    #[must_use]
1027    pub fn aliases(&self) -> &[String] {
1028        &self.aliases
1029    }
1030}
1031
1032/// One top-level network or volume lifecycle definition.
1033#[derive(Clone, Debug, Eq, PartialEq)]
1034pub struct GeneratedResource {
1035    name: String,
1036    external: bool,
1037    custom_name: Option<String>,
1038}
1039
1040impl GeneratedResource {
1041    /// Creates an application-owned resource definition.
1042    ///
1043    /// # Errors
1044    ///
1045    /// Rejects an empty or NUL-bearing name.
1046    pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
1047        Ok(Self {
1048            name: required("resource name", name.into())?,
1049            external: false,
1050            custom_name: None,
1051        })
1052    }
1053
1054    /// Creates an externally managed resource definition.
1055    ///
1056    /// # Errors
1057    ///
1058    /// Rejects an empty or NUL-bearing name.
1059    pub fn external(name: impl Into<String>) -> Result<Self, GenerationError> {
1060        Ok(Self {
1061            name: required("resource name", name.into())?,
1062            external: true,
1063            custom_name: None,
1064        })
1065    }
1066
1067    /// Sets the exact platform-level resource name once.
1068    ///
1069    /// This prevents Compose project scoping from changing a reviewed runtime resource name.
1070    ///
1071    /// # Errors
1072    ///
1073    /// Rejects an empty/NUL-bearing name and duplicate configuration.
1074    pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
1075        let name = required("custom resource name", name.into())?;
1076        set_once(&mut self.custom_name, name, "resource name")
1077    }
1078
1079    /// Returns the resource name.
1080    #[must_use]
1081    pub fn name(&self) -> &str {
1082        &self.name
1083    }
1084
1085    /// Reports whether Compose should reuse an external resource.
1086    #[must_use]
1087    pub const fn is_external(&self) -> bool {
1088        self.external
1089    }
1090
1091    /// Returns the optional exact platform-level resource name.
1092    #[must_use]
1093    pub fn custom_name(&self) -> Option<&str> {
1094        self.custom_name.as_deref()
1095    }
1096}
1097
1098/// A typed generated Compose service definition.
1099#[derive(Clone, Debug, Eq, PartialEq)]
1100pub struct GeneratedService {
1101    name: String,
1102    hostname: Option<GeneratedHostname>,
1103    container_name: Option<GeneratedString>,
1104    image: Option<GeneratedString>,
1105    entrypoint: Option<GeneratedEntrypoint>,
1106    command: Option<GeneratedCommand>,
1107    init: Option<bool>,
1108    environment_files: Vec<GeneratedEnvironmentFile>,
1109    environment: Vec<GeneratedEnvironment>,
1110    labels: Vec<GeneratedLabel>,
1111    user: Option<GeneratedString>,
1112    userns_mode: Option<GeneratedString>,
1113    group_add: Vec<GeneratedString>,
1114    cap_add: Option<Vec<GeneratedString>>,
1115    cap_drop: Option<Vec<GeneratedString>>,
1116    devices: Option<Vec<GeneratedDevice>>,
1117    working_dir: Option<GeneratedString>,
1118    read_only: Option<bool>,
1119    pids_limit: Option<GeneratedPidsLimit>,
1120    shm_size: Option<GeneratedShmSize>,
1121    mem_limit: Option<GeneratedMemLimit>,
1122    tmpfs: Option<GeneratedTmpfs>,
1123    sysctls: Option<GeneratedSysctls>,
1124    ulimits: Option<GeneratedUlimits>,
1125    pull_policy: Option<GeneratedPullPolicy>,
1126    restart: Option<GeneratedRestartPolicy>,
1127    stop_signal: Option<GeneratedString>,
1128    stop_grace_period: Option<GeneratedString>,
1129    extra_hosts: Vec<GeneratedExtraHost>,
1130    ports: Vec<GeneratedPort>,
1131    mounts: Vec<GeneratedMount>,
1132    networks: Vec<GeneratedNetworkAttachment>,
1133}
1134
1135impl GeneratedService {
1136    /// Creates an empty service with a validated name.
1137    ///
1138    /// # Errors
1139    ///
1140    /// Rejects an empty or NUL-bearing name.
1141    pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
1142        Ok(Self {
1143            name: required("service name", name.into())?,
1144            hostname: None,
1145            container_name: None,
1146            image: None,
1147            entrypoint: None,
1148            command: None,
1149            init: None,
1150            environment_files: Vec::new(),
1151            environment: Vec::new(),
1152            labels: Vec::new(),
1153            user: None,
1154            userns_mode: None,
1155            group_add: Vec::new(),
1156            cap_add: None,
1157            cap_drop: None,
1158            devices: None,
1159            working_dir: None,
1160            read_only: None,
1161            pids_limit: None,
1162            shm_size: None,
1163            mem_limit: None,
1164            tmpfs: None,
1165            sysctls: None,
1166            ulimits: None,
1167            pull_policy: None,
1168            restart: None,
1169            stop_signal: None,
1170            stop_grace_period: None,
1171            extra_hosts: Vec::new(),
1172            ports: Vec::new(),
1173            mounts: Vec::new(),
1174            networks: Vec::new(),
1175        })
1176    }
1177
1178    /// Returns the service name.
1179    #[must_use]
1180    pub fn name(&self) -> &str {
1181        &self.name
1182    }
1183
1184    /// Sets one resolved RFC-1123 service hostname exactly once.
1185    ///
1186    /// # Errors
1187    ///
1188    /// Returns [`GenerationError::InvalidHostname`] for an empty, expression-shaped, non-ASCII,
1189    /// overlong, or otherwise invalid hostname, or [`GenerationError::DuplicateField`] when
1190    /// already configured.
1191    pub fn set_hostname(&mut self, hostname: GeneratedHostname) -> Result<(), GenerationError> {
1192        let GeneratedHostname::Resolved(value) = &hostname;
1193        if !valid_hostname(value.expose()) {
1194            return Err(GenerationError::InvalidHostname);
1195        }
1196        set_once(&mut self.hostname, hostname, "hostname")
1197    }
1198
1199    /// Sets the custom runtime container name exactly once.
1200    ///
1201    /// # Errors
1202    ///
1203    /// Returns [`GenerationError::InvalidContainerName`] when the value does not match Compose's
1204    /// portable container-name grammar or [`GenerationError::DuplicateField`] when already
1205    /// configured.
1206    pub fn set_container_name(&mut self, name: GeneratedString) -> Result<(), GenerationError> {
1207        if !valid_container_name(name.expose()) {
1208            return Err(GenerationError::InvalidContainerName);
1209        }
1210        set_once(&mut self.container_name, name, "container_name")
1211    }
1212
1213    /// Sets the service image exactly once.
1214    ///
1215    /// # Errors
1216    ///
1217    /// Returns [`GenerationError::EmptyValue`] for an empty image or
1218    /// [`GenerationError::DuplicateField`] when already configured.
1219    pub fn set_image(&mut self, image: GeneratedString) -> Result<(), GenerationError> {
1220        require_generated_string("service image", &image)?;
1221        set_once(&mut self.image, image, "image")
1222    }
1223
1224    /// Sets the Compose entrypoint form exactly once.
1225    ///
1226    /// # Errors
1227    ///
1228    /// Returns [`GenerationError::DuplicateField`] when already configured.
1229    pub fn set_entrypoint(&mut self, entrypoint: GeneratedEntrypoint) -> Result<(), GenerationError> {
1230        set_once(&mut self.entrypoint, entrypoint, "entrypoint")
1231    }
1232
1233    /// Sets the Compose command form exactly once.
1234    ///
1235    /// # Errors
1236    ///
1237    /// Returns [`GenerationError::DuplicateField`] when already configured.
1238    pub fn set_command(&mut self, command: GeneratedCommand) -> Result<(), GenerationError> {
1239        set_once(&mut self.command, command, "command")
1240    }
1241
1242    /// Sets the Compose init-process choice exactly once.
1243    ///
1244    /// # Errors
1245    ///
1246    /// Returns [`GenerationError::DuplicateField`] when already configured.
1247    pub fn set_init(&mut self, init: bool) -> Result<(), GenerationError> {
1248        set_once(&mut self.init, init, "init")
1249    }
1250
1251    /// Adds one ordered environment-file declaration.
1252    pub fn add_environment_file(&mut self, environment_file: GeneratedEnvironmentFile) {
1253        self.environment_files.push(environment_file);
1254    }
1255
1256    /// Adds one ordered environment entry.
1257    pub fn add_environment(&mut self, environment: GeneratedEnvironment) {
1258        self.environment.push(environment);
1259    }
1260
1261    /// Adds one uniquely named service metadata label.
1262    ///
1263    /// # Errors
1264    ///
1265    /// Returns [`GenerationError::DuplicateName`] when the service already defines the label.
1266    pub fn add_label(&mut self, label: GeneratedLabel) -> Result<(), GenerationError> {
1267        if self.labels.iter().any(|candidate| candidate.name == label.name) {
1268            return Err(GenerationError::DuplicateName {
1269                kind: "service label",
1270                name: label.name,
1271            });
1272        }
1273        self.labels.push(label);
1274        Ok(())
1275    }
1276
1277    /// Sets the combined Compose `user[:group]` value exactly once.
1278    ///
1279    /// # Errors
1280    ///
1281    /// Returns [`GenerationError::DuplicateField`] when already configured.
1282    pub fn set_user(&mut self, user: GeneratedString) -> Result<(), GenerationError> {
1283        set_once(&mut self.user, user, "user")
1284    }
1285
1286    /// Sets the user-namespace mode exactly once.
1287    ///
1288    /// # Errors
1289    ///
1290    /// Returns [`GenerationError::EmptyValue`] for an empty mode or
1291    /// [`GenerationError::DuplicateField`] when already configured.
1292    pub fn set_userns_mode(&mut self, mode: GeneratedString) -> Result<(), GenerationError> {
1293        require_generated_string("user namespace mode", &mode)?;
1294        set_once(&mut self.userns_mode, mode, "userns_mode")
1295    }
1296
1297    /// Adds one ordered supplementary group.
1298    ///
1299    /// # Errors
1300    ///
1301    /// Returns [`GenerationError::EmptyValue`] for an empty group.
1302    pub fn add_supplementary_group(&mut self, group: GeneratedString) -> Result<(), GenerationError> {
1303        require_generated_string("supplementary group", &group)?;
1304        self.group_add.push(group);
1305        Ok(())
1306    }
1307
1308    /// Sets the complete ordered `cap_add` sequence exactly once.
1309    ///
1310    /// An empty vector is retained as explicit `cap_add: []`; never calling this method omits the
1311    /// field. Values preserve exact case and ordering. No capability whitelist is applied.
1312    ///
1313    /// # Errors
1314    ///
1315    /// Returns [`GenerationError::EmptyValue`] for an empty item,
1316    /// [`GenerationError::ContainsLineBreak`] for a carriage return or line feed,
1317    /// [`GenerationError::DuplicateItem`] for an exact case-sensitive duplicate, or
1318    /// [`GenerationError::DuplicateField`] when already configured. NUL bytes are rejected while
1319    /// constructing [`GeneratedString`].
1320    pub fn set_cap_add(&mut self, capabilities: Vec<GeneratedString>) -> Result<(), GenerationError> {
1321        let mut seen = BTreeSet::new();
1322        for capability in &capabilities {
1323            require_generated_string("cap_add item", capability)?;
1324            if capability.expose().contains('\r') || capability.expose().contains('\n') {
1325                return Err(GenerationError::ContainsLineBreak("cap_add item"));
1326            }
1327            if !seen.insert(capability.expose()) {
1328                return Err(GenerationError::DuplicateItem("cap_add"));
1329            }
1330        }
1331        set_once(&mut self.cap_add, capabilities, "cap_add")
1332    }
1333
1334    /// Returns the configured `cap_add` sequence, distinguishing omission from an empty vector.
1335    #[must_use]
1336    pub fn cap_add(&self) -> Option<&[GeneratedString]> {
1337        self.cap_add.as_deref()
1338    }
1339
1340    /// Sets the complete ordered `cap_drop` sequence exactly once.
1341    ///
1342    /// An empty vector is retained as explicit `cap_drop: []`; never calling this method omits the
1343    /// field. Values preserve exact case and ordering. No capability whitelist is applied.
1344    ///
1345    /// # Errors
1346    ///
1347    /// Returns [`GenerationError::EmptyValue`] for an empty item,
1348    /// [`GenerationError::ContainsLineBreak`] for a carriage return or line feed,
1349    /// [`GenerationError::DuplicateItem`] for an exact case-sensitive duplicate, or
1350    /// [`GenerationError::DuplicateField`] when already configured. NUL bytes are rejected while
1351    /// constructing [`GeneratedString`].
1352    pub fn set_cap_drop(&mut self, capabilities: Vec<GeneratedString>) -> Result<(), GenerationError> {
1353        let mut seen = BTreeSet::new();
1354        for capability in &capabilities {
1355            require_generated_string("cap_drop item", capability)?;
1356            if capability.expose().contains('\r') || capability.expose().contains('\n') {
1357                return Err(GenerationError::ContainsLineBreak("cap_drop item"));
1358            }
1359            if !seen.insert(capability.expose()) {
1360                return Err(GenerationError::DuplicateItem("cap_drop"));
1361            }
1362        }
1363        set_once(&mut self.cap_drop, capabilities, "cap_drop")
1364    }
1365
1366    /// Returns the configured `cap_drop` sequence, distinguishing omission from an empty vector.
1367    #[must_use]
1368    pub fn cap_drop(&self) -> Option<&[GeneratedString]> {
1369        self.cap_drop.as_deref()
1370    }
1371
1372    /// Sets the complete ordered mixed short/long `devices` sequence exactly once.
1373    ///
1374    /// An empty vector is emitted as `devices: []`; omission remains distinct. Exact duplicate
1375    /// items and caller order are preserved. This validates only safe resolved YAML output and
1376    /// does not inspect host devices, split colon triples, validate CDI, normalize permissions,
1377    /// or claim runtime access.
1378    ///
1379    /// # Errors
1380    ///
1381    /// Rejects empty short items and empty long sources, plus NUL-bearing, multiline, or
1382    /// dollar-bearing values. NUL bytes are normally rejected while constructing
1383    /// [`GeneratedString`]. Returns [`GenerationError::DuplicateField`] when already configured.
1384    pub fn set_devices(&mut self, devices: Vec<GeneratedDevice>) -> Result<(), GenerationError> {
1385        for device in &devices {
1386            match device {
1387                GeneratedDevice::Short(value) => {
1388                    validate_generated_device_member("short item", value, true)?;
1389                }
1390                GeneratedDevice::Long(value) => {
1391                    validate_generated_device_member("source", value.source(), true)?;
1392                    if let Some(target) = value.target() {
1393                        validate_generated_device_member("target", target, false)?;
1394                    }
1395                    if let Some(permissions) = value.permissions() {
1396                        validate_generated_device_member("permissions", permissions, false)?;
1397                    }
1398                }
1399            }
1400        }
1401        set_once(&mut self.devices, devices, "devices")
1402    }
1403
1404    /// Returns configured devices, distinguishing omission from an explicit empty sequence.
1405    #[must_use]
1406    pub fn devices(&self) -> Option<&[GeneratedDevice]> {
1407        self.devices.as_deref()
1408    }
1409
1410    /// Sets the container working directory exactly once.
1411    ///
1412    /// # Errors
1413    ///
1414    /// Returns [`GenerationError::EmptyValue`] for an empty directory or
1415    /// [`GenerationError::DuplicateField`] when already configured.
1416    pub fn set_working_dir(&mut self, directory: GeneratedString) -> Result<(), GenerationError> {
1417        require_generated_string("working directory", &directory)?;
1418        set_once(&mut self.working_dir, directory, "working_dir")
1419    }
1420
1421    /// Sets the read-only-root choice exactly once.
1422    ///
1423    /// # Errors
1424    ///
1425    /// Returns [`GenerationError::DuplicateField`] when already configured.
1426    pub fn set_read_only(&mut self, read_only: bool) -> Result<(), GenerationError> {
1427        set_once(&mut self.read_only, read_only, "read_only")
1428    }
1429
1430    /// Sets an unlimited or positive finite service PID limit exactly once.
1431    ///
1432    /// # Errors
1433    ///
1434    /// Returns [`GenerationError::InvalidPidsLimit`] when a finite spelling is empty, zero,
1435    /// signed, fractional, exponent-shaped, or otherwise not ASCII decimal, or
1436    /// [`GenerationError::DuplicateField`] when already configured.
1437    pub fn set_pids_limit(&mut self, limit: GeneratedPidsLimit) -> Result<(), GenerationError> {
1438        if let GeneratedPidsLimit::Finite(decimal) = &limit {
1439            if !valid_positive_pids_decimal(decimal) {
1440                return Err(GenerationError::InvalidPidsLimit);
1441            }
1442        }
1443        set_once(&mut self.pids_limit, limit, "pids_limit")
1444    }
1445
1446    /// Sets one explicit positive service shared-memory size exactly once.
1447    ///
1448    /// # Errors
1449    ///
1450    /// Returns [`GenerationError::InvalidShmSize`] when the amount is empty, zero, has leading
1451    /// zeros, a sign, fraction, exponent, whitespace, or non-ASCII digits, or
1452    /// [`GenerationError::DuplicateField`] when already configured.
1453    pub fn set_shm_size(&mut self, size: GeneratedShmSize) -> Result<(), GenerationError> {
1454        let GeneratedShmSize::Explicit { amount, .. } = &size;
1455        if !valid_generated_shm_amount(amount.expose()) {
1456            return Err(GenerationError::InvalidShmSize);
1457        }
1458        set_once(&mut self.shm_size, size, "shm_size")
1459    }
1460
1461    /// Sets one explicit positive service memory limit exactly once.
1462    ///
1463    /// # Errors
1464    ///
1465    /// Returns [`GenerationError::InvalidMemLimit`] when the amount is empty, zero, has leading
1466    /// zeros, a sign, fraction, exponent, whitespace, or non-ASCII digits, or
1467    /// [`GenerationError::DuplicateField`] when already configured.
1468    pub fn set_mem_limit(&mut self, limit: GeneratedMemLimit) -> Result<(), GenerationError> {
1469        let GeneratedMemLimit::Explicit { amount, .. } = &limit;
1470        if !valid_generated_mem_amount(amount.expose()) {
1471            return Err(GenerationError::InvalidMemLimit);
1472        }
1473        set_once(&mut self.mem_limit, limit, "mem_limit")
1474    }
1475
1476    /// Sets the complete scalar or list service-level `tmpfs` form exactly once.
1477    ///
1478    /// An empty list is retained explicitly. Item spelling, ordering, and case remain unchanged.
1479    ///
1480    /// # Errors
1481    ///
1482    /// Rejects empty, multiline, deferred, or structurally malformed items. Documented `mode`,
1483    /// `uid`, and `gid` assignments and other well-shaped raw target options remain exact, including
1484    /// duplicate list entries. NUL bytes are rejected while constructing [`GeneratedString`]. Returns
1485    /// [`GenerationError::DuplicateField`] when already configured.
1486    pub fn set_tmpfs(&mut self, tmpfs: GeneratedTmpfs) -> Result<(), GenerationError> {
1487        let items = match &tmpfs {
1488            GeneratedTmpfs::Scalar(item) => std::slice::from_ref(item),
1489            GeneratedTmpfs::List(items) => items.as_slice(),
1490        };
1491        for item in items {
1492            require_generated_string("tmpfs item", item)?;
1493            if item.expose().contains('\r') || item.expose().contains('\n') {
1494                return Err(GenerationError::ContainsLineBreak("tmpfs item"));
1495            }
1496            if !valid_generated_tmpfs_item(item.expose()) {
1497                return Err(GenerationError::InvalidTmpfsItem);
1498            }
1499        }
1500        set_once(&mut self.tmpfs, tmpfs, "tmpfs")
1501    }
1502
1503    /// Returns the configured scalar or list form, distinguishing omission from an empty list.
1504    #[must_use]
1505    pub const fn tmpfs(&self) -> Option<&GeneratedTmpfs> {
1506        self.tmpfs.as_ref()
1507    }
1508
1509    /// Sets the complete mapping or list `sysctls` form exactly once.
1510    ///
1511    /// Empty collections remain explicit. Mapping names and list strings must be exact-unique;
1512    /// neither form applies namespace validation or runtime coercion.
1513    ///
1514    /// # Errors
1515    ///
1516    /// Rejects duplicate map names, duplicate exact list items, multiline or dollar-bearing list
1517    /// items, and duplicate field configuration. NUL-bearing list items are rejected while
1518    /// constructing [`GeneratedString`].
1519    pub fn set_sysctls(&mut self, sysctls: GeneratedSysctls) -> Result<(), GenerationError> {
1520        let mut seen = BTreeSet::new();
1521        match &sysctls {
1522            GeneratedSysctls::Map(entries) => {
1523                for entry in entries {
1524                    if !seen.insert(entry.name()) {
1525                        return Err(GenerationError::DuplicateName {
1526                            kind: "sysctl",
1527                            name: entry.name().to_owned(),
1528                        });
1529                    }
1530                }
1531            }
1532            GeneratedSysctls::List(items) => {
1533                for item in items {
1534                    if item.expose().contains(['\r', '\n', '$']) {
1535                        return Err(GenerationError::InvalidSysctlValue);
1536                    }
1537                    if !seen.insert(item.expose()) {
1538                        return Err(GenerationError::DuplicateItem("sysctls"));
1539                    }
1540                }
1541            }
1542        }
1543        set_once(&mut self.sysctls, sysctls, "sysctls")
1544    }
1545
1546    /// Returns the configured form, distinguishing omission from explicit empty collections.
1547    #[must_use]
1548    pub const fn sysctls(&self) -> Option<&GeneratedSysctls> {
1549        self.sysctls.as_ref()
1550    }
1551
1552    /// Sets the complete ordered service `ulimits` mapping exactly once.
1553    ///
1554    /// An empty mapping remains explicit. Values are already validated while constructing
1555    /// [`GeneratedUlimit`] and names are unique by construction in [`GeneratedUlimits`].
1556    ///
1557    /// # Errors
1558    ///
1559    /// Returns [`GenerationError::DuplicateField`] when already configured.
1560    pub fn set_ulimits(&mut self, ulimits: GeneratedUlimits) -> Result<(), GenerationError> {
1561        set_once(&mut self.ulimits, ulimits, "ulimits")
1562    }
1563
1564    /// Returns configured ordered limits, distinguishing omission from an explicit empty mapping.
1565    #[must_use]
1566    pub const fn ulimits(&self) -> Option<&GeneratedUlimits> {
1567        self.ulimits.as_ref()
1568    }
1569
1570    /// Sets a documented service image pull policy exactly once.
1571    ///
1572    /// # Errors
1573    ///
1574    /// Returns [`GenerationError::InvalidPullPolicyDuration`] for an invalid custom interval or
1575    /// [`GenerationError::DuplicateField`] when already configured.
1576    pub fn set_pull_policy(&mut self, policy: GeneratedPullPolicy) -> Result<(), GenerationError> {
1577        if let GeneratedPullPolicy::Every(duration) = &policy {
1578            if !valid_pull_policy_duration(duration.expose()) {
1579                return Err(GenerationError::InvalidPullPolicyDuration);
1580            }
1581        }
1582        set_once(&mut self.pull_policy, policy, "pull_policy")
1583    }
1584
1585    /// Sets the service-level restart policy exactly once.
1586    ///
1587    /// # Errors
1588    ///
1589    /// Returns [`GenerationError::DuplicateField`] when already configured.
1590    pub fn set_restart(&mut self, restart: GeneratedRestartPolicy) -> Result<(), GenerationError> {
1591        set_once(&mut self.restart, restart, "restart")
1592    }
1593
1594    /// Sets the service stop signal exactly once without imposing a signal-token grammar.
1595    ///
1596    /// # Errors
1597    ///
1598    /// Returns [`GenerationError::DuplicateField`] when already configured. Quoted empty values
1599    /// are preserved; NUL-bearing values are rejected while constructing [`GeneratedString`].
1600    pub fn set_stop_signal(&mut self, signal: GeneratedString) -> Result<(), GenerationError> {
1601        set_once(&mut self.stop_signal, signal, "stop_signal")
1602    }
1603
1604    /// Sets the raw-preserving service stop grace period exactly once.
1605    ///
1606    /// # Errors
1607    ///
1608    /// Returns [`GenerationError::InvalidStopGracePeriod`] when the value does not match the
1609    /// `ComposeLens` raw-preserving duration policy or dollar-marker convention, or
1610    /// [`GenerationError::DuplicateField`] when already configured.
1611    pub fn set_stop_grace_period(&mut self, period: GeneratedString) -> Result<(), GenerationError> {
1612        if !StopGracePeriod::parse(period.expose().to_owned()).is_valid() {
1613            return Err(GenerationError::InvalidStopGracePeriod);
1614        }
1615        set_once(&mut self.stop_grace_period, period, "stop_grace_period")
1616    }
1617
1618    /// Adds one ordered host mapping.
1619    pub fn add_extra_host(&mut self, host: GeneratedExtraHost) {
1620        self.extra_hosts.push(host);
1621    }
1622
1623    /// Adds one ordered published-port declaration.
1624    pub fn add_port(&mut self, port: GeneratedPort) {
1625        self.ports.push(port);
1626    }
1627
1628    /// Adds one ordered mount.
1629    pub fn add_mount(&mut self, mount: GeneratedMount) {
1630        self.mounts.push(mount);
1631    }
1632
1633    /// Adds one uniquely named network attachment.
1634    ///
1635    /// # Errors
1636    ///
1637    /// Returns [`GenerationError::DuplicateName`] when the service already uses the network.
1638    pub fn add_network(&mut self, network: GeneratedNetworkAttachment) -> Result<(), GenerationError> {
1639        if self.networks.iter().any(|candidate| candidate.name == network.name) {
1640            return Err(GenerationError::DuplicateName {
1641                kind: "service network",
1642                name: network.name,
1643            });
1644        }
1645        self.networks.push(network);
1646        Ok(())
1647    }
1648
1649    fn is_sensitive(&self) -> bool {
1650        matches!(
1651            self.hostname.as_ref(),
1652            Some(GeneratedHostname::Resolved(hostname)) if hostname.is_sensitive()
1653        ) || self.image.as_ref().is_some_and(GeneratedString::is_sensitive)
1654            || self.entrypoint.as_ref().is_some_and(entrypoint_is_sensitive)
1655            || self.command.as_ref().is_some_and(command_is_sensitive)
1656            || self
1657                .environment_files
1658                .iter()
1659                .any(GeneratedEnvironmentFile::is_sensitive)
1660            || self
1661                .environment
1662                .iter()
1663                .filter_map(GeneratedEnvironment::value)
1664                .any(GeneratedString::is_sensitive)
1665            || self.labels.iter().any(|label| label.value.is_sensitive())
1666            || matches!(
1667                self.pull_policy.as_ref(),
1668                Some(GeneratedPullPolicy::Every(duration)) if duration.is_sensitive()
1669            )
1670            || matches!(
1671                self.shm_size.as_ref(),
1672                Some(GeneratedShmSize::Explicit { amount, .. }) if amount.is_sensitive()
1673            )
1674            || matches!(
1675                self.mem_limit.as_ref(),
1676                Some(GeneratedMemLimit::Explicit { amount, .. }) if amount.is_sensitive()
1677            )
1678            || match self.tmpfs.as_ref() {
1679                Some(GeneratedTmpfs::Scalar(item)) => item.is_sensitive(),
1680                Some(GeneratedTmpfs::List(items)) => items.iter().any(GeneratedString::is_sensitive),
1681                None => false,
1682            }
1683            || match self.sysctls.as_ref() {
1684                Some(GeneratedSysctls::Map(entries)) => entries.iter().any(|entry| entry.value.is_sensitive()),
1685                Some(GeneratedSysctls::List(items)) => items.iter().any(GeneratedString::is_sensitive),
1686                None => false,
1687            }
1688            || self
1689                .ulimits
1690                .as_ref()
1691                .is_some_and(|limits| limits.entries.iter().any(GeneratedUlimit::is_sensitive))
1692            || [
1693                self.user.as_ref(),
1694                self.userns_mode.as_ref(),
1695                self.working_dir.as_ref(),
1696                self.stop_signal.as_ref(),
1697                self.stop_grace_period.as_ref(),
1698            ]
1699            .into_iter()
1700            .flatten()
1701            .any(GeneratedString::is_sensitive)
1702            || self.group_add.iter().any(GeneratedString::is_sensitive)
1703            || self
1704                .cap_add
1705                .as_ref()
1706                .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
1707            || self
1708                .cap_drop
1709                .as_ref()
1710                .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
1711            || self
1712                .devices
1713                .as_ref()
1714                .is_some_and(|items| items.iter().any(GeneratedDevice::is_sensitive))
1715    }
1716}
1717
1718/// Builder for one new deterministic Compose document.
1719#[derive(Clone, Debug, Default, Eq, PartialEq)]
1720pub struct ComposeDocumentBuilder {
1721    name: Option<String>,
1722    services: Vec<GeneratedService>,
1723    networks: Vec<GeneratedResource>,
1724    volumes: Vec<GeneratedResource>,
1725}
1726
1727impl ComposeDocumentBuilder {
1728    /// Creates an empty generated project.
1729    #[must_use]
1730    pub const fn new() -> Self {
1731        Self {
1732            name: None,
1733            services: Vec::new(),
1734            networks: Vec::new(),
1735            volumes: Vec::new(),
1736        }
1737    }
1738
1739    /// Sets the optional top-level Compose project name exactly once.
1740    ///
1741    /// # Errors
1742    ///
1743    /// Rejects empty/NUL-bearing names and duplicate configuration.
1744    pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
1745        let name = required("project name", name.into())?;
1746        set_once(&mut self.name, name, "name")
1747    }
1748
1749    /// Adds one uniquely named service in output order.
1750    ///
1751    /// # Errors
1752    ///
1753    /// Returns [`GenerationError::DuplicateName`] for a duplicate service name.
1754    pub fn add_service(&mut self, service: GeneratedService) -> Result<(), GenerationError> {
1755        insert_named(&mut self.services, service, "service", GeneratedService::name)
1756    }
1757
1758    /// Adds one uniquely named top-level network in output order.
1759    ///
1760    /// # Errors
1761    ///
1762    /// Returns [`GenerationError::DuplicateName`] for a duplicate network name.
1763    pub fn add_network(&mut self, network: GeneratedResource) -> Result<(), GenerationError> {
1764        insert_named(&mut self.networks, network, "network", GeneratedResource::name)
1765    }
1766
1767    /// Adds one uniquely named top-level volume in output order.
1768    ///
1769    /// # Errors
1770    ///
1771    /// Returns [`GenerationError::DuplicateName`] for a duplicate volume name.
1772    pub fn add_volume(&mut self, volume: GeneratedResource) -> Result<(), GenerationError> {
1773        insert_named(&mut self.volumes, volume, "volume", GeneratedResource::name)
1774    }
1775
1776    /// Generates YAML and parses it back through `ComposeLens`'s syntax and typed-model boundaries.
1777    ///
1778    /// # Errors
1779    ///
1780    /// Returns [`GenerationError::MissingService`] for an empty project or
1781    /// [`GenerationError::InternalInvariant`] if `ComposeLens` cannot parse its own output.
1782    pub fn build(self, source_id: SourceId) -> Result<GeneratedComposeDocument, GenerationError> {
1783        if self.services.is_empty() {
1784            return Err(GenerationError::MissingService);
1785        }
1786        let sensitive = self.services.iter().any(GeneratedService::is_sensitive);
1787        let text = render_document(&self);
1788        let syntax = SyntaxDocument::parse(source_id, text.clone())
1789            .map_err(|_| GenerationError::InternalInvariant("syntax-tree"))?;
1790        if !syntax.is_valid() {
1791            return Err(GenerationError::InternalInvariant("syntax"));
1792        }
1793        let model = ComposeDocument::parse(syntax.document());
1794        if !model.is_valid() {
1795            return Err(GenerationError::InternalInvariant("typed-model"));
1796        }
1797        let document = model
1798            .document()
1799            .cloned()
1800            .ok_or(GenerationError::InternalInvariant("document-root"))?;
1801        Ok(GeneratedComposeDocument {
1802            text,
1803            sensitive,
1804            document,
1805        })
1806    }
1807}
1808
1809/// Parse-back-validated deterministic generated Compose document.
1810#[derive(Clone, Eq, PartialEq)]
1811pub struct GeneratedComposeDocument {
1812    text: String,
1813    sensitive: bool,
1814    document: ComposeDocument,
1815}
1816
1817impl GeneratedComposeDocument {
1818    /// Returns the deployable generated YAML through an explicit access boundary.
1819    #[must_use]
1820    pub fn text(&self) -> &str {
1821        &self.text
1822    }
1823
1824    /// Returns the parse-back-validated native Compose model.
1825    #[must_use]
1826    pub const fn document(&self) -> &ComposeDocument {
1827        &self.document
1828    }
1829
1830    /// Reports whether generated output contains a caller-marked sensitive value.
1831    #[must_use]
1832    pub const fn is_sensitive(&self) -> bool {
1833        self.sensitive
1834    }
1835}
1836
1837impl fmt::Debug for GeneratedComposeDocument {
1838    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1839        formatter
1840            .debug_struct("GeneratedComposeDocument")
1841            .field("text", &if self.sensitive { "<redacted>" } else { &self.text })
1842            .field("sensitive", &self.sensitive)
1843            .field("document", &if self.sensitive { "<redacted>" } else { "validated" })
1844            .finish()
1845    }
1846}
1847
1848fn render_document(project: &ComposeDocumentBuilder) -> String {
1849    let mut output = String::new();
1850    if let Some(name) = &project.name {
1851        output.push_str("name: ");
1852        write_quoted(&mut output, name);
1853        output.push('\n');
1854    }
1855    output.push_str("services:\n");
1856    for service in &project.services {
1857        write_indent(&mut output, 1);
1858        write_quoted(&mut output, &service.name);
1859        output.push_str(":\n");
1860        render_service(&mut output, service);
1861    }
1862    render_resources(&mut output, "networks", &project.networks);
1863    render_resources(&mut output, "volumes", &project.volumes);
1864    output
1865}
1866
1867fn render_service(output: &mut String, service: &GeneratedService) {
1868    if let Some(GeneratedHostname::Resolved(hostname)) = &service.hostname {
1869        render_optional_string(output, "hostname", Some(hostname));
1870    }
1871    render_optional_string(output, "container_name", service.container_name.as_ref());
1872    render_optional_string(output, "image", service.image.as_ref());
1873    if let Some(entrypoint) = &service.entrypoint {
1874        render_entrypoint(output, entrypoint);
1875    }
1876    if let Some(command) = &service.command {
1877        render_command(output, command);
1878    }
1879    if let Some(init) = service.init {
1880        write_field(output, 2, "init");
1881        output.push_str(if init { "true\n" } else { "false\n" });
1882    }
1883    render_environment_files(output, &service.environment_files);
1884    render_environment(output, &service.environment);
1885    render_labels(output, &service.labels);
1886    render_optional_string(output, "user", service.user.as_ref());
1887    render_optional_string(output, "userns_mode", service.userns_mode.as_ref());
1888    render_string_sequence(output, "group_add", &service.group_add);
1889    if let Some(capabilities) = &service.cap_add {
1890        render_configured_string_sequence(output, "cap_add", capabilities);
1891    }
1892    if let Some(capabilities) = &service.cap_drop {
1893        render_configured_string_sequence(output, "cap_drop", capabilities);
1894    }
1895    render_optional_string(output, "working_dir", service.working_dir.as_ref());
1896    if let Some(read_only) = service.read_only {
1897        write_field(output, 2, "read_only");
1898        output.push_str(if read_only { "true\n" } else { "false\n" });
1899    }
1900    if let Some(pids_limit) = &service.pids_limit {
1901        render_pids_limit(output, pids_limit);
1902    }
1903    if let Some(shm_size) = &service.shm_size {
1904        render_shm_size(output, shm_size);
1905    }
1906    if let Some(mem_limit) = &service.mem_limit {
1907        render_mem_limit(output, mem_limit);
1908    }
1909    if let Some(devices) = &service.devices {
1910        render_devices(output, devices);
1911    }
1912    if let Some(tmpfs) = &service.tmpfs {
1913        render_tmpfs(output, tmpfs);
1914    }
1915    if let Some(sysctls) = &service.sysctls {
1916        render_sysctls(output, sysctls);
1917    }
1918    if let Some(ulimits) = &service.ulimits {
1919        render_ulimits(output, ulimits);
1920    }
1921    if let Some(pull_policy) = &service.pull_policy {
1922        render_pull_policy(output, pull_policy);
1923    }
1924    if let Some(restart) = service.restart {
1925        render_restart(output, restart);
1926    }
1927    render_optional_string(output, "stop_signal", service.stop_signal.as_ref());
1928    render_optional_string(output, "stop_grace_period", service.stop_grace_period.as_ref());
1929    render_extra_hosts(output, &service.extra_hosts);
1930    render_ports(output, &service.ports);
1931    render_mounts(output, &service.mounts);
1932    render_networks(output, &service.networks);
1933}
1934
1935fn render_pids_limit(output: &mut String, limit: &GeneratedPidsLimit) {
1936    write_field(output, 2, "pids_limit");
1937    match limit {
1938        GeneratedPidsLimit::Unlimited => output.push_str("-1\n"),
1939        GeneratedPidsLimit::Finite(decimal) => {
1940            output.push_str(decimal);
1941            output.push('\n');
1942        }
1943    }
1944}
1945
1946fn render_shm_size(output: &mut String, size: &GeneratedShmSize) {
1947    let GeneratedShmSize::Explicit { amount, unit } = size;
1948    write_field(output, 2, "shm_size");
1949    write_quoted(output, &format!("{}{}", amount.expose(), unit.as_str()));
1950    output.push('\n');
1951}
1952
1953fn render_mem_limit(output: &mut String, limit: &GeneratedMemLimit) {
1954    let GeneratedMemLimit::Explicit { amount, unit } = limit;
1955    write_field(output, 2, "mem_limit");
1956    write_quoted(output, &format!("{}{}", amount.expose(), unit.as_str()));
1957    output.push('\n');
1958}
1959
1960fn render_devices(output: &mut String, devices: &[GeneratedDevice]) {
1961    if devices.is_empty() {
1962        output.push_str("    devices: []\n");
1963        return;
1964    }
1965    output.push_str("    devices:\n");
1966    for device in devices {
1967        match device {
1968            GeneratedDevice::Short(value) => {
1969                output.push_str("      - ");
1970                write_quoted(output, value.expose());
1971                output.push('\n');
1972            }
1973            GeneratedDevice::Long(value) => {
1974                output.push_str("      - source: ");
1975                write_quoted(output, value.source().expose());
1976                output.push('\n');
1977                if let Some(target) = value.target() {
1978                    output.push_str("        target: ");
1979                    write_quoted(output, target.expose());
1980                    output.push('\n');
1981                }
1982                if let Some(permissions) = value.permissions() {
1983                    output.push_str("        permissions: ");
1984                    write_quoted(output, permissions.expose());
1985                    output.push('\n');
1986                }
1987            }
1988        }
1989    }
1990}
1991
1992fn render_tmpfs(output: &mut String, tmpfs: &GeneratedTmpfs) {
1993    match tmpfs {
1994        GeneratedTmpfs::Scalar(item) => render_optional_string(output, "tmpfs", Some(item)),
1995        GeneratedTmpfs::List(items) => render_configured_string_sequence(output, "tmpfs", items),
1996    }
1997}
1998
1999fn render_sysctls(output: &mut String, sysctls: &GeneratedSysctls) {
2000    match sysctls {
2001        GeneratedSysctls::Map(entries) if entries.is_empty() => output.push_str("    sysctls: {}\n"),
2002        GeneratedSysctls::Map(entries) => {
2003            output.push_str("    sysctls:\n");
2004            for entry in entries {
2005                write_indent(output, 3);
2006                write_quoted(output, entry.name());
2007                output.push_str(": ");
2008                write_quoted(output, entry.value().expose());
2009                output.push('\n');
2010            }
2011        }
2012        GeneratedSysctls::List(items) => render_configured_string_sequence(output, "sysctls", items),
2013    }
2014}
2015
2016fn render_ulimits(output: &mut String, ulimits: &GeneratedUlimits) {
2017    if ulimits.entries.is_empty() {
2018        output.push_str("    ulimits: {}\n");
2019        return;
2020    }
2021    output.push_str("    ulimits:\n");
2022    for limit in &ulimits.entries {
2023        write_indent(output, 3);
2024        write_quoted(output, limit.name());
2025        match limit.value() {
2026            GeneratedUlimitValue::Single(value) => {
2027                output.push_str(": ");
2028                write_quoted(output, value.expose());
2029                output.push('\n');
2030            }
2031            GeneratedUlimitValue::Range {
2032                soft: Some(soft),
2033                hard: Some(hard),
2034            } => {
2035                output.push_str(":\n");
2036                write_indent(output, 4);
2037                output.push_str("soft: ");
2038                write_quoted(output, soft.expose());
2039                output.push('\n');
2040                write_indent(output, 4);
2041                output.push_str("hard: ");
2042                write_quoted(output, hard.expose());
2043                output.push('\n');
2044            }
2045            GeneratedUlimitValue::Range { .. } => {
2046                unreachable!("generated ulimit ranges are validated during construction")
2047            }
2048        }
2049    }
2050}
2051
2052fn render_pull_policy(output: &mut String, policy: &GeneratedPullPolicy) {
2053    write_field(output, 2, "pull_policy");
2054    let value = match policy {
2055        GeneratedPullPolicy::Always => "always".to_owned(),
2056        GeneratedPullPolicy::Never => "never".to_owned(),
2057        GeneratedPullPolicy::Missing => "missing".to_owned(),
2058        GeneratedPullPolicy::IfNotPresentAlias => "if_not_present".to_owned(),
2059        GeneratedPullPolicy::Build => "build".to_owned(),
2060        GeneratedPullPolicy::Daily => "daily".to_owned(),
2061        GeneratedPullPolicy::Weekly => "weekly".to_owned(),
2062        GeneratedPullPolicy::Every(duration) => format!("every_{}", duration.expose()),
2063    };
2064    write_quoted(output, &value);
2065    output.push('\n');
2066}
2067
2068fn render_entrypoint(output: &mut String, entrypoint: &GeneratedEntrypoint) {
2069    match entrypoint {
2070        GeneratedEntrypoint::List(arguments) if arguments.is_empty() => output.push_str("    entrypoint: []\n"),
2071        GeneratedEntrypoint::List(arguments) => render_string_sequence(output, "entrypoint", arguments),
2072        GeneratedEntrypoint::String(entrypoint) => render_optional_string(output, "entrypoint", Some(entrypoint)),
2073        GeneratedEntrypoint::Empty => output.push_str("    entrypoint: []\n"),
2074    }
2075}
2076
2077fn render_restart(output: &mut String, restart: GeneratedRestartPolicy) {
2078    write_field(output, 2, "restart");
2079    let value = match restart {
2080        GeneratedRestartPolicy::No => "no".to_owned(),
2081        GeneratedRestartPolicy::Always => "always".to_owned(),
2082        GeneratedRestartPolicy::OnFailure { maximum_retries: None } => "on-failure".to_owned(),
2083        GeneratedRestartPolicy::OnFailure {
2084            maximum_retries: Some(maximum_retries),
2085        } => format!("on-failure:{maximum_retries}"),
2086        GeneratedRestartPolicy::UnlessStopped => "unless-stopped".to_owned(),
2087    };
2088    write_quoted(output, &value);
2089    output.push('\n');
2090}
2091
2092fn render_optional_string(output: &mut String, key: &str, value: Option<&GeneratedString>) {
2093    if let Some(value) = value {
2094        write_field(output, 2, key);
2095        write_quoted(output, value.expose());
2096        output.push('\n');
2097    }
2098}
2099
2100fn render_command(output: &mut String, command: &GeneratedCommand) {
2101    match command {
2102        GeneratedCommand::Exec(arguments) if arguments.is_empty() => output.push_str("    command: []\n"),
2103        GeneratedCommand::Exec(arguments) => render_string_sequence(output, "command", arguments),
2104        GeneratedCommand::Shell(command) => render_optional_string(output, "command", Some(command)),
2105        GeneratedCommand::Empty => output.push_str("    command: []\n"),
2106    }
2107}
2108
2109fn render_environment(output: &mut String, environment: &[GeneratedEnvironment]) {
2110    if environment.is_empty() {
2111        return;
2112    }
2113    output.push_str("    environment:\n");
2114    for variable in environment {
2115        output.push_str("      - ");
2116        let value = variable.value.as_ref().map_or_else(
2117            || variable.name.clone(),
2118            |value| format!("{}={}", variable.name, value.expose()),
2119        );
2120        write_quoted(output, &value);
2121        output.push('\n');
2122    }
2123}
2124
2125fn render_environment_files(output: &mut String, environment_files: &[GeneratedEnvironmentFile]) {
2126    if environment_files.is_empty() {
2127        return;
2128    }
2129    output.push_str("    env_file:\n");
2130    for environment_file in environment_files {
2131        match environment_file {
2132            GeneratedEnvironmentFile::Short(path) => {
2133                output.push_str("      - ");
2134                write_quoted(output, path.expose());
2135                output.push('\n');
2136            }
2137            GeneratedEnvironmentFile::Long { path, required, format } => {
2138                output.push_str("      - path: ");
2139                write_quoted(output, path.expose());
2140                output.push('\n');
2141                if let Some(required) = required {
2142                    output.push_str("        required: ");
2143                    output.push_str(if *required { "true\n" } else { "false\n" });
2144                }
2145                if let Some(format) = format {
2146                    output.push_str("        format: ");
2147                    write_quoted(
2148                        output,
2149                        match format {
2150                            GeneratedEnvironmentFileFormat::Raw => "raw",
2151                        },
2152                    );
2153                    output.push('\n');
2154                }
2155            }
2156        }
2157    }
2158}
2159
2160fn render_labels(output: &mut String, labels: &[GeneratedLabel]) {
2161    if labels.is_empty() {
2162        return;
2163    }
2164    output.push_str("    labels:\n");
2165    for label in labels {
2166        output.push_str("      ");
2167        write_quoted(output, &label.name);
2168        output.push_str(": ");
2169        write_quoted(output, label.value.expose());
2170        output.push('\n');
2171    }
2172}
2173
2174fn render_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
2175    if values.is_empty() {
2176        return;
2177    }
2178    write_indent(output, 2);
2179    output.push_str(key);
2180    output.push_str(":\n");
2181    for value in values {
2182        output.push_str("      - ");
2183        write_quoted(output, value.expose());
2184        output.push('\n');
2185    }
2186}
2187
2188fn render_configured_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
2189    if values.is_empty() {
2190        write_indent(output, 2);
2191        output.push_str(key);
2192        output.push_str(": []\n");
2193    } else {
2194        render_string_sequence(output, key, values);
2195    }
2196}
2197
2198fn render_extra_hosts(output: &mut String, hosts: &[GeneratedExtraHost]) {
2199    if hosts.is_empty() {
2200        return;
2201    }
2202    output.push_str("    extra_hosts:\n");
2203    for host in hosts {
2204        output.push_str("      - ");
2205        write_quoted(output, &format!("{}={}", host.hostname, host.address));
2206        output.push('\n');
2207    }
2208}
2209
2210fn render_ports(output: &mut String, ports: &[GeneratedPort]) {
2211    if ports.is_empty() {
2212        return;
2213    }
2214    output.push_str("    ports:\n");
2215    for port in ports {
2216        if port.protocol == GeneratedProtocol::Sctp {
2217            render_short_sctp_port(output, port);
2218            continue;
2219        }
2220        output.push_str("      - target: ");
2221        output.push_str(&port.target.to_string());
2222        output.push('\n');
2223        if let Some(published) = port.published {
2224            output.push_str("        published: ");
2225            write_quoted(output, &published.to_string());
2226            output.push('\n');
2227        }
2228        if let Some(host_ip) = &port.host_ip {
2229            output.push_str("        host_ip: ");
2230            write_quoted(output, host_ip);
2231            output.push('\n');
2232        }
2233        output.push_str("        protocol: ");
2234        write_quoted(output, port.protocol.as_str());
2235        output.push('\n');
2236    }
2237}
2238
2239fn render_short_sctp_port(output: &mut String, port: &GeneratedPort) {
2240    let mut value = String::new();
2241    if let Some(host_ip) = &port.host_ip {
2242        if host_ip.contains(':') && !(host_ip.starts_with('[') && host_ip.ends_with(']')) {
2243            value.push('[');
2244            value.push_str(host_ip);
2245            value.push(']');
2246        } else {
2247            value.push_str(host_ip);
2248        }
2249        value.push(':');
2250    }
2251    if let Some(published) = port.published {
2252        value.push_str(&published.to_string());
2253        value.push(':');
2254    }
2255    value.push_str(&port.target.to_string());
2256    value.push_str("/sctp");
2257
2258    output.push_str("      - ");
2259    write_quoted(output, &value);
2260    output.push('\n');
2261}
2262
2263fn render_mounts(output: &mut String, mounts: &[GeneratedMount]) {
2264    if mounts.is_empty() {
2265        return;
2266    }
2267    output.push_str("    volumes:\n");
2268    for mount in mounts {
2269        match &mount.kind {
2270            GeneratedMountKind::Bind {
2271                source,
2272                selinux: Some(selinux),
2273            } => render_selinux_bind(output, source, mount, *selinux),
2274            kind => render_long_mount(output, kind, mount),
2275        }
2276    }
2277}
2278
2279fn render_selinux_bind(output: &mut String, source: &str, mount: &GeneratedMount, selinux: GeneratedSelinux) {
2280    let mut value = format!("{source}:{}:{}", mount.target, selinux.as_str());
2281    if mount.read_only {
2282        value.push_str(",ro");
2283    }
2284    output.push_str("      - ");
2285    write_quoted(output, &value);
2286    output.push('\n');
2287}
2288
2289fn render_long_mount(output: &mut String, kind: &GeneratedMountKind, mount: &GeneratedMount) {
2290    let (mount_type, source) = match kind {
2291        GeneratedMountKind::Volume { source } => ("volume", Some(source.as_str())),
2292        GeneratedMountKind::Bind { source, selinux: None } => ("bind", Some(source.as_str())),
2293        GeneratedMountKind::Anonymous => ("volume", None),
2294        GeneratedMountKind::Bind { selinux: Some(_), .. } => return,
2295    };
2296    output.push_str("      - type: ");
2297    write_quoted(output, mount_type);
2298    output.push('\n');
2299    if let Some(source) = source {
2300        output.push_str("        source: ");
2301        write_quoted(output, source);
2302        output.push('\n');
2303    }
2304    output.push_str("        target: ");
2305    write_quoted(output, &mount.target);
2306    output.push('\n');
2307    if mount.read_only {
2308        output.push_str("        read_only: true\n");
2309    }
2310}
2311
2312fn render_networks(output: &mut String, networks: &[GeneratedNetworkAttachment]) {
2313    if networks.is_empty() {
2314        return;
2315    }
2316    output.push_str("    networks:\n");
2317    for network in networks {
2318        output.push_str("      ");
2319        write_quoted(output, &network.name);
2320        if network.aliases.is_empty() {
2321            output.push_str(": {}\n");
2322        } else {
2323            output.push_str(":\n        aliases:\n");
2324            for alias in &network.aliases {
2325                output.push_str("          - ");
2326                write_quoted(output, alias);
2327                output.push('\n');
2328            }
2329        }
2330    }
2331}
2332
2333fn render_resources(output: &mut String, section: &str, resources: &[GeneratedResource]) {
2334    if resources.is_empty() {
2335        return;
2336    }
2337    output.push_str(section);
2338    output.push_str(":\n");
2339    for resource in resources {
2340        output.push_str("  ");
2341        write_quoted(output, &resource.name);
2342        if !resource.external && resource.custom_name.is_none() {
2343            output.push_str(": {}\n");
2344            continue;
2345        }
2346        output.push_str(":\n");
2347        if let Some(custom_name) = &resource.custom_name {
2348            output.push_str("    name: ");
2349            write_quoted(output, custom_name);
2350            output.push('\n');
2351        }
2352        if resource.external {
2353            output.push_str("    external: true\n");
2354        }
2355    }
2356}
2357
2358fn write_field(output: &mut String, depth: usize, key: &str) {
2359    write_indent(output, depth);
2360    output.push_str(key);
2361    output.push_str(": ");
2362}
2363
2364fn write_indent(output: &mut String, depth: usize) {
2365    for _ in 0..depth {
2366        output.push_str("  ");
2367    }
2368}
2369
2370fn required(kind: &'static str, value: String) -> Result<String, GenerationError> {
2371    if value.is_empty() {
2372        return Err(GenerationError::EmptyValue(kind));
2373    }
2374    if value.contains('\0') {
2375        return Err(GenerationError::ContainsNul(kind));
2376    }
2377    Ok(value)
2378}
2379
2380fn require_generated_string(kind: &'static str, value: &GeneratedString) -> Result<(), GenerationError> {
2381    if value.expose().is_empty() {
2382        return Err(GenerationError::EmptyValue(kind));
2383    }
2384    Ok(())
2385}
2386
2387fn validate_generated_device_member(
2388    member: &'static str,
2389    value: &GeneratedString,
2390    require_non_empty: bool,
2391) -> Result<(), GenerationError> {
2392    if valid_generated_device_string(value.expose(), require_non_empty) {
2393        Ok(())
2394    } else {
2395        Err(GenerationError::InvalidDeviceValue(member))
2396    }
2397}
2398
2399fn validate_generated_ulimit_value(value: &GeneratedString) -> Result<(), GenerationError> {
2400    let value = value.expose();
2401    if value.contains(['\r', '\n', '$'])
2402        || (value != "-1" && (value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit())))
2403    {
2404        return Err(GenerationError::InvalidUlimitValue);
2405    }
2406    Ok(())
2407}
2408
2409fn environment_name(value: String) -> Result<String, GenerationError> {
2410    let value = required("environment name", value)?;
2411    if value.contains('=') {
2412        return Err(GenerationError::InvalidEnvironmentName);
2413    }
2414    Ok(value)
2415}
2416
2417fn valid_container_name(value: &str) -> bool {
2418    let mut bytes = value.bytes();
2419    bytes.next().is_some_and(|byte| byte.is_ascii_alphanumeric())
2420        && bytes
2421            .next()
2422            .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
2423        && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
2424}
2425
2426fn short_component(kind: &'static str, value: String, separator: char) -> Result<String, GenerationError> {
2427    let value = required(kind, value)?;
2428    if value.contains(separator) {
2429        return Err(GenerationError::InvalidShortComponent(kind));
2430    }
2431    Ok(value)
2432}
2433
2434fn set_once<T>(slot: &mut Option<T>, value: T, field: &'static str) -> Result<(), GenerationError> {
2435    if slot.is_some() {
2436        return Err(GenerationError::DuplicateField(field));
2437    }
2438    *slot = Some(value);
2439    Ok(())
2440}
2441
2442fn insert_named<T>(
2443    values: &mut Vec<T>,
2444    value: T,
2445    kind: &'static str,
2446    name: impl Fn(&T) -> &str,
2447) -> Result<(), GenerationError> {
2448    let value_name = name(&value);
2449    if values.iter().any(|candidate| name(candidate) == value_name) {
2450        return Err(GenerationError::DuplicateName {
2451            kind,
2452            name: value_name.to_owned(),
2453        });
2454    }
2455    values.push(value);
2456    Ok(())
2457}
2458
2459fn command_is_sensitive(command: &GeneratedCommand) -> bool {
2460    match command {
2461        GeneratedCommand::Exec(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
2462        GeneratedCommand::Shell(command) => command.is_sensitive(),
2463        GeneratedCommand::Empty => false,
2464    }
2465}
2466
2467fn entrypoint_is_sensitive(entrypoint: &GeneratedEntrypoint) -> bool {
2468    match entrypoint {
2469        GeneratedEntrypoint::List(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
2470        GeneratedEntrypoint::String(entrypoint) => entrypoint.is_sensitive(),
2471        GeneratedEntrypoint::Empty => false,
2472    }
2473}