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_expose_item, valid_generated_mem_amount, valid_generated_shm_amount,
9        valid_generated_tmpfs_item, valid_hostname, valid_positive_pids_decimal, valid_pull_policy_duration,
10        valid_ulimit_name,
11    },
12    source::SourceId,
13    syntax::SyntaxDocument,
14};
15use yaml_edit::{ScalarType, ScalarValue, YamlFile};
16
17use super::write_quoted;
18
19/// A generated Compose construction request is invalid or cannot be represented safely.
20#[derive(Clone, Debug, Eq, PartialEq)]
21#[non_exhaustive]
22pub enum GenerationError {
23    /// A required value is empty.
24    EmptyValue(&'static str),
25    /// A value contains a NUL byte and cannot represent native container intent safely.
26    ContainsNul(&'static str),
27    /// A value contains a carriage return or line feed where one YAML string item is required.
28    ContainsLineBreak(&'static str),
29    /// An environment name contains Compose list-form's `=` separator.
30    InvalidEnvironmentName,
31    /// A custom container name does not satisfy Compose's portable name grammar.
32    InvalidContainerName,
33    /// A service hostname is empty, deferred, or outside the conservative RFC-1123 grammar.
34    InvalidHostname,
35    /// A custom pull interval does not match the documented Compose duration grammar.
36    InvalidPullPolicyDuration,
37    /// A finite PID limit is not a positive integral decimal.
38    InvalidPidsLimit,
39    /// A service shared-memory amount is not a canonical positive ASCII decimal.
40    InvalidShmSize,
41    /// A service memory-limit amount is not a canonical positive ASCII decimal.
42    InvalidMemLimit,
43    /// A generated DNS server is empty, multiline, NUL-bearing, or expression-shaped.
44    InvalidDnsValue,
45    /// A generated DNS resolver option is empty, multiline, NUL-bearing, or expression-shaped.
46    InvalidDnsOptionValue,
47    /// A generated DNS search domain is empty, multiline, NUL-bearing, or expression-shaped.
48    InvalidDnsSearchValue,
49    /// A generated exposed-port item is unsafe or outside the documented decimal grammar.
50    InvalidExposeValue,
51    /// A generated security option is empty, deferred, multiline, or NUL-bearing.
52    InvalidSecurityOptionValue,
53    /// A generated annotation name is empty, deferred, multiline, or NUL-bearing.
54    InvalidAnnotationName,
55    /// A generated annotation value is deferred, multiline, or NUL-bearing.
56    InvalidAnnotationValue,
57    /// A service-level temporary-filesystem item is deferred, malformed, or provider-dependent.
58    InvalidTmpfsItem,
59    /// A generated short device or long-device member is empty where required, multiline, or deferred.
60    InvalidDeviceValue(&'static str),
61    /// A generated sysctl mapping name is empty, multiline, NUL-bearing, or expression-shaped.
62    InvalidSysctlName,
63    /// A generated sysctl value or list item is multiline, NUL-bearing, or expression-shaped.
64    InvalidSysctlValue,
65    /// A generated logging option number is not one complete YAML number scalar.
66    InvalidLoggingOptionNumber,
67    /// A generated network driver option number is not one complete YAML number scalar.
68    InvalidNetworkDriverOptionNumber,
69    /// A generated volume driver option number is not one complete YAML number scalar.
70    InvalidVolumeDriverOptionNumber,
71    /// A generated ulimit name is outside the portable lowercase ASCII grammar.
72    InvalidUlimitName,
73    /// A generated ulimit value is outside the supported portable decimal or unlimited set.
74    InvalidUlimitValue,
75    /// A generated ulimit range omitted its required soft or hard member.
76    MissingUlimitRangeMember(&'static str),
77    /// A stop grace period does not match the raw-preserving policy based on documented Compose units.
78    InvalidStopGracePeriod,
79    /// A short-form component contains its reserved separator.
80    InvalidShortComponent(&'static str),
81    /// A short bind spelling needed for `SELinux` cannot be encoded unambiguously.
82    InvalidSelinuxBind,
83    /// A singleton field was configured more than once.
84    DuplicateField(&'static str),
85    /// A named generated collection contains the same name more than once.
86    DuplicateName {
87        /// Collection whose name collided.
88        kind: &'static str,
89        /// Duplicate non-sensitive name.
90        name: String,
91    },
92    /// A generated sequence contains an exact duplicate item.
93    DuplicateItem(&'static str),
94    /// A generated port used target port zero.
95    InvalidPort,
96    /// An `SCTP` port selected a host address without a published port.
97    UnrepresentableSctpHostIp,
98    /// A generated project contains no services.
99    MissingService,
100    /// `ComposeLens` could not parse its own deterministic generated bytes.
101    InternalInvariant(&'static str),
102}
103
104impl fmt::Display for GenerationError {
105    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106        match self {
107            Self::EmptyValue(kind) => write!(formatter, "generated {kind} must not be empty"),
108            Self::ContainsNul(kind) => write!(formatter, "generated {kind} must not contain a NUL byte"),
109            Self::ContainsLineBreak(kind) => {
110                write!(formatter, "generated {kind} must not contain a carriage return or line feed")
111            }
112            Self::InvalidEnvironmentName => formatter.write_str("generated environment name must not contain `=`"),
113            Self::InvalidContainerName => {
114                formatter.write_str("generated container name must match `[a-zA-Z0-9][a-zA-Z0-9_.-]+`")
115            }
116            Self::InvalidHostname => formatter.write_str(
117                "generated hostname must be a resolved ASCII RFC-1123 name with labels of 1 to 63 characters and total length at most 253",
118            ),
119            Self::InvalidPullPolicyDuration => formatter.write_str(
120                "generated pull policy duration must match integer `w`, `d`, `h`, `m`, and `s` components",
121            ),
122            Self::InvalidPidsLimit => {
123                formatter.write_str("generated finite PID limit must be a positive integral decimal")
124            }
125            Self::InvalidShmSize => formatter.write_str(
126                "generated shared-memory size must use a canonical positive ASCII-integer amount and an explicit documented lowercase unit",
127            ),
128            Self::InvalidMemLimit => formatter.write_str(
129                "generated memory limit must use a canonical positive ASCII-integer amount and an explicit documented lowercase unit",
130            ),
131            Self::InvalidDnsValue => {
132                formatter.write_str("generated DNS server must be a non-empty resolved single-line string")
133            }
134            Self::InvalidDnsOptionValue => {
135                formatter.write_str("generated DNS option must be a non-empty resolved single-line string")
136            }
137            Self::InvalidDnsSearchValue => {
138                formatter.write_str("generated DNS search domain must be a non-empty resolved single-line string")
139            }
140            Self::InvalidExposeValue => formatter.write_str(
141                "generated expose item must be a resolved decimal port or range with an optional `tcp` or `udp` suffix",
142            ),
143            Self::InvalidSecurityOptionValue => {
144                formatter.write_str("generated security option must be a non-empty resolved single-line string")
145            }
146            Self::InvalidAnnotationName => formatter
147                .write_str("generated annotation name must be a non-empty resolved single-line string"),
148            Self::InvalidAnnotationValue => formatter
149                .write_str("generated annotation value must be a resolved single-line string"),
150            Self::InvalidTmpfsItem => formatter.write_str(
151                "generated tmpfs item must be a non-empty path optionally followed by a colon and non-empty comma-separated raw options",
152            ),
153            Self::InvalidDeviceValue(member) => write!(
154                formatter,
155                "generated device {member} must be a safe resolved single-line string{}",
156                if matches!(*member, "short item" | "source") {
157                    " and must not be empty"
158                } else {
159                    ""
160                }
161            ),
162            Self::InvalidSysctlName => formatter
163                .write_str("generated sysctl name must be a non-empty resolved single-line string"),
164            Self::InvalidSysctlValue => formatter
165                .write_str("generated sysctl value must be a resolved single-line string"),
166            Self::InvalidLoggingOptionNumber => formatter
167                .write_str("generated logging option number must be one complete YAML number scalar"),
168            Self::InvalidNetworkDriverOptionNumber => formatter
169                .write_str("generated network driver option number must be one complete YAML number scalar"),
170            Self::InvalidVolumeDriverOptionNumber => formatter
171                .write_str("generated volume driver option number must be one complete YAML number scalar"),
172            Self::InvalidUlimitName => formatter
173                .write_str("generated ulimit name must match lowercase ASCII `[a-z]+`"),
174            Self::InvalidUlimitValue => formatter
175                .write_str("generated ulimit value must be `-1` or a non-negative ASCII decimal"),
176            Self::MissingUlimitRangeMember(member) => {
177                write!(formatter, "generated ulimit range is missing required `{member}`")
178            }
179            Self::InvalidStopGracePeriod => formatter.write_str(
180                "generated stop grace period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
181            ),
182            Self::InvalidShortComponent(kind) => {
183                write!(formatter, "generated {kind} contains its reserved short-form separator")
184            }
185            Self::InvalidSelinuxBind => formatter
186                .write_str("generated SELinux bind source and target must not contain the short-syntax `:` separator"),
187            Self::DuplicateField(field) => write!(formatter, "generated field `{field}` was configured more than once"),
188            Self::DuplicateName { kind, name } => {
189                write!(formatter, "generated {kind} `{name}` was added more than once")
190            }
191            Self::DuplicateItem(kind) => write!(formatter, "generated {kind} contains an exact duplicate item"),
192            Self::InvalidPort => formatter.write_str("generated container target port must be greater than zero"),
193            Self::UnrepresentableSctpHostIp => formatter.write_str(
194                "generated SCTP port with a host address also requires a published port for Compose short syntax",
195            ),
196            Self::MissingService => formatter.write_str("generated Compose project requires at least one service"),
197            Self::InternalInvariant(stage) => write!(formatter, "generated Compose document failed {stage} validation"),
198        }
199    }
200}
201
202impl Error for GenerationError {}
203
204/// A plain or sensitive string used by generated Compose fields.
205#[derive(Clone, Eq, PartialEq)]
206pub struct GeneratedString {
207    value: String,
208    sensitive: bool,
209}
210
211impl GeneratedString {
212    /// Creates a non-sensitive generated string.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`GenerationError::ContainsNul`] when the value contains a NUL byte.
217    pub fn plain(value: impl Into<String>) -> Result<Self, GenerationError> {
218        Self::new(value.into(), false)
219    }
220
221    /// Creates a sensitive generated string whose debug representation is redacted.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`GenerationError::ContainsNul`] when the value contains a NUL byte.
226    pub fn sensitive(value: impl Into<String>) -> Result<Self, GenerationError> {
227        Self::new(value.into(), true)
228    }
229
230    fn new(value: String, sensitive: bool) -> Result<Self, GenerationError> {
231        if value.contains('\0') {
232            return Err(GenerationError::ContainsNul("string"));
233        }
234        Ok(Self { value, sensitive })
235    }
236
237    /// Returns the generated value through an explicit access boundary.
238    #[must_use]
239    pub fn expose(&self) -> &str {
240        &self.value
241    }
242
243    /// Reports whether debug output must redact this value.
244    #[must_use]
245    pub const fn is_sensitive(&self) -> bool {
246        self.sensitive
247    }
248}
249
250impl fmt::Debug for GeneratedString {
251    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
252        formatter
253            .debug_struct("GeneratedString")
254            .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
255            .field("sensitive", &self.sensitive)
256            .finish()
257    }
258}
259
260/// Compose command form selected for a generated service.
261#[derive(Clone, Debug, Eq, PartialEq)]
262#[non_exhaustive]
263pub enum GeneratedCommand {
264    /// Execute an exact argument vector without Compose shell parsing.
265    Exec(Vec<GeneratedString>),
266    /// Execute one Compose shell-form command.
267    Shell(GeneratedString),
268    /// Explicitly clear the image command.
269    Empty,
270}
271
272/// Compose entrypoint form selected for a generated service.
273#[derive(Clone, Debug, Eq, PartialEq)]
274#[non_exhaustive]
275pub enum GeneratedEntrypoint {
276    /// Emit an exact entrypoint list in authored argument order.
277    List(Vec<GeneratedString>),
278    /// Emit the short scalar string form.
279    String(GeneratedString),
280    /// Explicitly clear the entrypoint declared by the image.
281    Empty,
282}
283
284/// A valid service-level Compose restart policy selected for generated output.
285#[derive(Clone, Copy, Debug, Eq, PartialEq)]
286#[non_exhaustive]
287pub enum GeneratedRestartPolicy {
288    /// Never restart the container automatically.
289    No,
290    /// Always restart the container until it is removed.
291    Always,
292    /// Restart after an error, optionally with a maximum retry count.
293    OnFailure {
294        /// Maximum retries, or `None` for no explicit limit.
295        maximum_retries: Option<u64>,
296    },
297    /// Restart except after an explicit stop or removal.
298    UnlessStopped,
299}
300
301/// A documented service-level Compose image pull policy selected for generated output.
302#[derive(Clone, Debug, Eq, PartialEq)]
303#[non_exhaustive]
304pub enum GeneratedPullPolicy {
305    /// Pull before every service start.
306    Always,
307    /// Never pull and rely on a cached image.
308    Never,
309    /// Pull only when the image is missing.
310    Missing,
311    /// Emit the retained `if_not_present` alias.
312    IfNotPresentAlias,
313    /// Build the image before starting the service.
314    Build,
315    /// Check once per day.
316    Daily,
317    /// Check once per week.
318    Weekly,
319    /// Check after an exact caller-supplied duration spelling.
320    Every(GeneratedString),
321}
322
323/// A service-level Compose PID limit selected for generated output.
324#[derive(Clone, Debug, Eq, PartialEq)]
325#[non_exhaustive]
326pub enum GeneratedPidsLimit {
327    /// Emit the documented unlimited spelling `-1`.
328    Unlimited,
329    /// Emit an exact positive integral decimal without fixed-width integer parsing.
330    Finite(String),
331}
332
333/// A safe explicit service shared-memory size selected for generated Compose output.
334#[derive(Clone, Debug, Eq, PartialEq)]
335#[non_exhaustive]
336pub enum GeneratedShmSize {
337    /// Emit one quoted amount and documented lowercase unit.
338    Explicit {
339        /// Canonical positive ASCII-integer amount without leading zeros.
340        amount: GeneratedString,
341        /// Explicit documented lowercase unit.
342        unit: ShmSizeUnit,
343    },
344}
345
346/// A safe explicit service memory limit selected for generated Compose output.
347#[derive(Clone, Debug, Eq, PartialEq)]
348#[non_exhaustive]
349pub enum GeneratedMemLimit {
350    /// Emit one quoted amount and documented lowercase unit.
351    Explicit {
352        /// Canonical positive ASCII-integer amount without leading zeros.
353        amount: GeneratedString,
354        /// Explicit documented lowercase unit.
355        unit: MemLimitUnit,
356    },
357}
358
359/// The exact service-level `tmpfs` form selected for generated Compose output.
360#[derive(Clone, Debug, Eq, PartialEq)]
361#[non_exhaustive]
362pub enum GeneratedTmpfs {
363    /// Emit one quoted scalar item.
364    Scalar(GeneratedString),
365    /// Emit one quoted ordered list, including an explicit empty list.
366    List(Vec<GeneratedString>),
367}
368
369/// The exact service `dns` form selected for generated Compose output.
370#[derive(Clone, Debug, Eq, PartialEq)]
371#[non_exhaustive]
372pub enum GeneratedDns {
373    /// Emit one quoted raw DNS server string.
374    Scalar(GeneratedString),
375    /// Emit one quoted ordered list, including an explicit empty list.
376    List(Vec<GeneratedString>),
377}
378
379/// The exact service `dns_search` form selected for generated Compose output.
380#[derive(Clone, Debug, Eq, PartialEq)]
381#[non_exhaustive]
382pub enum GeneratedDnsSearch {
383    /// Emit one quoted raw DNS search-domain string.
384    Scalar(GeneratedString),
385    /// Emit one quoted ordered list, including an explicit empty list.
386    List(Vec<GeneratedString>),
387}
388
389/// One generated long-syntax service device.
390#[derive(Clone, Debug, Eq, PartialEq)]
391pub struct GeneratedLongDevice {
392    source: GeneratedString,
393    target: Option<GeneratedString>,
394    permissions: Option<GeneratedString>,
395}
396
397impl GeneratedLongDevice {
398    /// Creates a long device from safe resolved strings without interpreting device paths or permissions.
399    ///
400    /// # Errors
401    ///
402    /// Rejects an empty source and any NUL-bearing, multiline, or dollar-bearing member. NUL bytes
403    /// are normally rejected while constructing [`GeneratedString`]. Empty optional target and
404    /// permissions strings remain raw schema strings and are not assigned runtime meaning.
405    pub fn new(
406        source: GeneratedString,
407        target: Option<GeneratedString>,
408        permissions: Option<GeneratedString>,
409    ) -> Result<Self, GenerationError> {
410        validate_generated_device_member("source", &source, true)?;
411        if let Some(target) = &target {
412            validate_generated_device_member("target", target, false)?;
413        }
414        if let Some(permissions) = &permissions {
415            validate_generated_device_member("permissions", permissions, false)?;
416        }
417        Ok(Self {
418            source,
419            target,
420            permissions,
421        })
422    }
423
424    /// Returns the exact generated source through its sensitivity boundary.
425    #[must_use]
426    pub const fn source(&self) -> &GeneratedString {
427        &self.source
428    }
429
430    /// Returns the optional exact generated target.
431    #[must_use]
432    pub const fn target(&self) -> Option<&GeneratedString> {
433        self.target.as_ref()
434    }
435
436    /// Returns the optional exact raw generated permissions string.
437    #[must_use]
438    pub const fn permissions(&self) -> Option<&GeneratedString> {
439        self.permissions.as_ref()
440    }
441
442    fn is_sensitive(&self) -> bool {
443        self.source.is_sensitive()
444            || self.target.as_ref().is_some_and(GeneratedString::is_sensitive)
445            || self.permissions.as_ref().is_some_and(GeneratedString::is_sensitive)
446    }
447}
448
449/// One generated service device with explicit short or long syntax.
450#[derive(Clone, Debug, Eq, PartialEq)]
451#[non_exhaustive]
452pub enum GeneratedDevice {
453    /// Emit one exact quoted raw short item.
454    Short(GeneratedString),
455    /// Emit one ordered long mapping.
456    Long(GeneratedLongDevice),
457}
458
459/// A generated logging-option value with an explicit YAML scalar kind.
460#[derive(Clone, Debug, Eq, PartialEq)]
461#[non_exhaustive]
462pub enum GeneratedLoggingOptionValue {
463    /// Emit one quoted YAML string.
464    String(GeneratedString),
465    /// Emit one validated unquoted YAML number with exact spelling retained.
466    Number(GeneratedString),
467    /// Emit an explicit YAML null.
468    Null,
469}
470
471impl GeneratedLoggingOptionValue {
472    fn is_sensitive(&self) -> bool {
473        match self {
474            Self::String(value) | Self::Number(value) => value.is_sensitive(),
475            Self::Null => false,
476        }
477    }
478}
479
480/// One ordered generated logging option.
481#[derive(Clone, Debug, Eq, PartialEq)]
482pub struct GeneratedLoggingOption {
483    name: String,
484    value: GeneratedLoggingOptionValue,
485}
486
487impl GeneratedLoggingOption {
488    /// Creates one option with a non-empty key and a string, number, or null value.
489    ///
490    /// # Errors
491    ///
492    /// Rejects an empty or NUL-bearing key and number spellings that are not exactly one YAML
493    /// number scalar. No driver-specific option semantics are applied.
494    pub fn new(name: impl Into<String>, value: GeneratedLoggingOptionValue) -> Result<Self, GenerationError> {
495        let name = required("logging option key", name.into())?;
496        if let GeneratedLoggingOptionValue::Number(number) = &value {
497            if !valid_yaml_number(number.expose()) {
498                return Err(GenerationError::InvalidLoggingOptionNumber);
499            }
500        }
501        Ok(Self { name, value })
502    }
503
504    /// Returns the exact non-empty option key.
505    #[must_use]
506    pub fn name(&self) -> &str {
507        &self.name
508    }
509
510    /// Returns the selected string, number, or null value.
511    #[must_use]
512    pub const fn value(&self) -> &GeneratedLoggingOptionValue {
513        &self.value
514    }
515}
516
517/// Explicit service logging configuration for generated output.
518#[derive(Clone, Debug, Eq, PartialEq)]
519pub struct GeneratedLogging {
520    driver: GeneratedString,
521    options: Vec<GeneratedLoggingOption>,
522}
523
524impl GeneratedLogging {
525    /// Creates an explicit uninterpreted string driver and ordered unique-key options mapping.
526    ///
527    /// An empty options vector is retained as `options: {}`. Driver and option values are not
528    /// normalized, defaulted, or interpreted for any provider.
529    ///
530    /// # Errors
531    ///
532    /// Rejects duplicate option keys.
533    pub fn new(driver: GeneratedString, options: Vec<GeneratedLoggingOption>) -> Result<Self, GenerationError> {
534        let mut seen = BTreeSet::new();
535        for option in &options {
536            if !seen.insert(option.name()) {
537                return Err(GenerationError::DuplicateName {
538                    kind: "logging option",
539                    name: option.name().to_owned(),
540                });
541            }
542        }
543        Ok(Self { driver, options })
544    }
545
546    /// Returns the exact uninterpreted string driver.
547    #[must_use]
548    pub const fn driver(&self) -> &GeneratedString {
549        &self.driver
550    }
551
552    /// Returns options in generated mapping order.
553    #[must_use]
554    pub fn options(&self) -> &[GeneratedLoggingOption] {
555        &self.options
556    }
557
558    fn is_sensitive(&self) -> bool {
559        self.driver.is_sensitive() || self.options.iter().any(|option| option.value.is_sensitive())
560    }
561}
562
563/// A generated network driver-option value with an explicit YAML scalar kind.
564#[derive(Clone, Debug, Eq, PartialEq)]
565#[non_exhaustive]
566pub enum GeneratedNetworkDriverOptionValue {
567    /// Emit one quoted YAML string.
568    String(GeneratedString),
569    /// Emit one validated unquoted YAML number with exact spelling retained.
570    Number(GeneratedString),
571}
572
573impl GeneratedNetworkDriverOptionValue {
574    fn is_sensitive(&self) -> bool {
575        match self {
576            Self::String(value) | Self::Number(value) => value.is_sensitive(),
577        }
578    }
579}
580
581/// One ordered generated network driver option.
582#[derive(Clone, Debug, Eq, PartialEq)]
583pub struct GeneratedNetworkDriverOption {
584    name: String,
585    value: GeneratedNetworkDriverOptionValue,
586}
587
588impl GeneratedNetworkDriverOption {
589    /// Creates one driver option with a non-empty key and a string or number value.
590    ///
591    /// # Errors
592    ///
593    /// Rejects an empty or NUL-bearing key and number spellings that are not exactly one YAML
594    /// number scalar. Driver-option semantics remain uninterpreted.
595    pub fn new(name: impl Into<String>, value: GeneratedNetworkDriverOptionValue) -> Result<Self, GenerationError> {
596        let name = required("network driver option key", name.into())?;
597        if let GeneratedNetworkDriverOptionValue::Number(number) = &value {
598            if !valid_yaml_number(number.expose()) {
599                return Err(GenerationError::InvalidNetworkDriverOptionNumber);
600            }
601        }
602        Ok(Self { name, value })
603    }
604
605    /// Returns the exact non-empty option key.
606    #[must_use]
607    pub fn name(&self) -> &str {
608        &self.name
609    }
610
611    /// Returns the selected string or number value.
612    #[must_use]
613    pub const fn value(&self) -> &GeneratedNetworkDriverOptionValue {
614        &self.value
615    }
616}
617
618/// A generated volume driver-option value with an explicit YAML scalar kind.
619#[derive(Clone, Debug, Eq, PartialEq)]
620#[non_exhaustive]
621pub enum GeneratedVolumeDriverOptionValue {
622    /// Emit one quoted YAML string.
623    String(GeneratedString),
624    /// Emit one validated unquoted YAML number with exact spelling retained.
625    Number(GeneratedString),
626}
627
628impl GeneratedVolumeDriverOptionValue {
629    fn is_sensitive(&self) -> bool {
630        match self {
631            Self::String(value) | Self::Number(value) => value.is_sensitive(),
632        }
633    }
634}
635
636/// One ordered generated volume driver option.
637#[derive(Clone, Debug, Eq, PartialEq)]
638pub struct GeneratedVolumeDriverOption {
639    name: String,
640    value: GeneratedVolumeDriverOptionValue,
641}
642
643impl GeneratedVolumeDriverOption {
644    /// Creates one driver option with a non-empty key and a string or number value.
645    ///
646    /// # Errors
647    ///
648    /// Rejects an empty or NUL-bearing key and number spellings that are not exactly one YAML
649    /// number scalar. Driver-option semantics remain uninterpreted.
650    pub fn new(name: impl Into<String>, value: GeneratedVolumeDriverOptionValue) -> Result<Self, GenerationError> {
651        let name = required("volume driver option key", name.into())?;
652        if let GeneratedVolumeDriverOptionValue::Number(number) = &value {
653            if !valid_yaml_number(number.expose()) {
654                return Err(GenerationError::InvalidVolumeDriverOptionNumber);
655            }
656        }
657        Ok(Self { name, value })
658    }
659
660    /// Returns the exact non-empty option key.
661    #[must_use]
662    pub fn name(&self) -> &str {
663        &self.name
664    }
665
666    /// Returns the selected string or number value.
667    #[must_use]
668    pub const fn value(&self) -> &GeneratedVolumeDriverOptionValue {
669        &self.value
670    }
671}
672
673/// A top-level application-owned volume definition with optional driver configuration.
674///
675/// This type is intentionally distinct from [`GeneratedResource`], which remains the compatible
676/// basic/external lifecycle API shared by top-level networks and volumes. External volumes cannot
677/// use this driver-configured API.
678#[derive(Clone, Debug, Eq, PartialEq)]
679pub struct GeneratedVolumeDefinition {
680    name: String,
681    custom_name: Option<String>,
682    driver: Option<GeneratedString>,
683    driver_opts: Option<Vec<GeneratedVolumeDriverOption>>,
684    labels: Option<Vec<GeneratedLabel>>,
685}
686
687impl GeneratedVolumeDefinition {
688    /// Creates an application-owned volume definition.
689    ///
690    /// # Errors
691    ///
692    /// Rejects an empty or NUL-bearing name.
693    pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
694        Ok(Self {
695            name: required("volume name", name.into())?,
696            custom_name: None,
697            driver: None,
698            driver_opts: None,
699            labels: None,
700        })
701    }
702
703    /// Sets the exact platform-level volume name once.
704    ///
705    /// # Errors
706    ///
707    /// Rejects an empty/NUL-bearing name and duplicate configuration.
708    pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
709        let name = required("custom volume name", name.into())?;
710        set_once(&mut self.custom_name, name, "volume name")
711    }
712
713    /// Sets one opaque volume driver exactly once.
714    ///
715    /// No driver, plugin, provider, runtime, default, or image semantics validation is applied.
716    ///
717    /// # Errors
718    ///
719    /// Returns [`GenerationError::DuplicateField`] when already configured.
720    pub fn set_driver(&mut self, driver: GeneratedString) -> Result<(), GenerationError> {
721        set_once(&mut self.driver, driver, "volume driver")
722    }
723
724    /// Sets the complete ordered unique volume driver-options mapping exactly once.
725    ///
726    /// An empty mapping remains explicit. String and number YAML scalar identities are selected
727    /// by [`GeneratedVolumeDriverOptionValue`] and are never inferred from their text.
728    ///
729    /// # Errors
730    ///
731    /// Rejects duplicate option names and duplicate field configuration. No driver-specific
732    /// option, plugin, provider, runtime, default, or image semantics are validated.
733    pub fn set_driver_opts(&mut self, driver_opts: Vec<GeneratedVolumeDriverOption>) -> Result<(), GenerationError> {
734        let mut seen = BTreeSet::new();
735        for option in &driver_opts {
736            if !seen.insert(option.name()) {
737                return Err(GenerationError::DuplicateName {
738                    kind: "volume driver option",
739                    name: option.name().to_owned(),
740                });
741            }
742        }
743        set_once(&mut self.driver_opts, driver_opts, "volume driver_opts")
744    }
745
746    /// Sets the complete ordered unique volume-label mapping exactly once.
747    ///
748    /// An empty mapping remains explicit. Labels use the same explicit string-value contract as
749    /// generated service labels, so neither key-only nor non-string label forms are generated.
750    ///
751    /// # Errors
752    ///
753    /// Rejects duplicate label names and duplicate field configuration. No provider, runtime, or
754    /// injected-label equivalence is inferred.
755    pub fn set_labels(&mut self, labels: Vec<GeneratedLabel>) -> Result<(), GenerationError> {
756        let mut seen = BTreeSet::new();
757        for label in &labels {
758            if !seen.insert(label.name()) {
759                return Err(GenerationError::DuplicateName {
760                    kind: "volume label",
761                    name: label.name().to_owned(),
762                });
763            }
764        }
765        set_once(&mut self.labels, labels, "volume labels")
766    }
767
768    /// Returns the generated volume name.
769    #[must_use]
770    pub fn name(&self) -> &str {
771        &self.name
772    }
773
774    /// Returns the optional exact platform-level volume name.
775    #[must_use]
776    pub fn custom_name(&self) -> Option<&str> {
777        self.custom_name.as_deref()
778    }
779
780    /// Returns the optional opaque volume driver.
781    #[must_use]
782    pub const fn driver(&self) -> Option<&GeneratedString> {
783        self.driver.as_ref()
784    }
785
786    /// Returns the optional ordered driver-options mapping, including an explicit empty map.
787    #[must_use]
788    pub fn driver_opts(&self) -> Option<&[GeneratedVolumeDriverOption]> {
789        self.driver_opts.as_deref()
790    }
791
792    /// Returns the optional ordered volume-label mapping, including an explicit empty map.
793    #[must_use]
794    pub fn labels(&self) -> Option<&[GeneratedLabel]> {
795        self.labels.as_deref()
796    }
797
798    fn is_sensitive(&self) -> bool {
799        self.driver.as_ref().is_some_and(GeneratedString::is_sensitive)
800            || self
801                .driver_opts
802                .as_ref()
803                .is_some_and(|options| options.iter().any(|option| option.value.is_sensitive()))
804            || self
805                .labels
806                .as_ref()
807                .is_some_and(|labels| labels.iter().any(|label| label.value.is_sensitive()))
808    }
809}
810
811/// A top-level network definition with optional driver configuration.
812///
813/// This type is intentionally distinct from [`GeneratedResource`], which remains the compatible
814/// basic/external lifecycle API shared by top-level networks and volumes.
815#[derive(Clone, Debug, Eq, PartialEq)]
816pub struct GeneratedNetworkDefinition {
817    name: String,
818    custom_name: Option<String>,
819    driver: Option<GeneratedString>,
820    driver_opts: Option<Vec<GeneratedNetworkDriverOption>>,
821    enable_ipv6: Option<bool>,
822    internal: Option<bool>,
823    labels: Option<Vec<GeneratedLabel>>,
824}
825
826impl GeneratedNetworkDefinition {
827    /// Creates an application-owned network definition.
828    ///
829    /// # Errors
830    ///
831    /// Rejects an empty or NUL-bearing name.
832    pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
833        Ok(Self {
834            name: required("network name", name.into())?,
835            custom_name: None,
836            driver: None,
837            driver_opts: None,
838            enable_ipv6: None,
839            internal: None,
840            labels: None,
841        })
842    }
843
844    /// Sets the exact platform-level network name once.
845    ///
846    /// # Errors
847    ///
848    /// Rejects an empty/NUL-bearing name and duplicate configuration.
849    pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
850        let name = required("custom network name", name.into())?;
851        set_once(&mut self.custom_name, name, "network name")
852    }
853
854    /// Sets one opaque network driver exactly once.
855    ///
856    /// No driver, plugin, provider, or runtime availability validation is applied.
857    ///
858    /// # Errors
859    ///
860    /// Returns [`GenerationError::DuplicateField`] when already configured.
861    pub fn set_driver(&mut self, driver: GeneratedString) -> Result<(), GenerationError> {
862        set_once(&mut self.driver, driver, "network driver")
863    }
864
865    /// Sets the complete ordered unique network driver-options mapping exactly once.
866    ///
867    /// An empty mapping remains explicit. String and number YAML scalar identities are selected
868    /// by [`GeneratedNetworkDriverOptionValue`] and are never inferred from their text.
869    ///
870    /// # Errors
871    ///
872    /// Rejects duplicate option names and duplicate field configuration. No driver-specific
873    /// option, plugin, provider, or runtime semantics are validated.
874    pub fn set_driver_opts(&mut self, driver_opts: Vec<GeneratedNetworkDriverOption>) -> Result<(), GenerationError> {
875        let mut seen = BTreeSet::new();
876        for option in &driver_opts {
877            if !seen.insert(option.name()) {
878                return Err(GenerationError::DuplicateName {
879                    kind: "network driver option",
880                    name: option.name().to_owned(),
881                });
882            }
883        }
884        set_once(&mut self.driver_opts, driver_opts, "network driver_opts")
885    }
886
887    /// Sets the literal IPv6-enable choice exactly once.
888    ///
889    /// Omission remains distinct from an explicit `false` or `true`. Generation does not infer
890    /// defaults or validate driver, IPAM, provider, or runtime behavior.
891    ///
892    /// # Errors
893    ///
894    /// Returns [`GenerationError::DuplicateField`] when already configured.
895    pub fn set_enable_ipv6(&mut self, enable_ipv6: bool) -> Result<(), GenerationError> {
896        set_once(&mut self.enable_ipv6, enable_ipv6, "network enable_ipv6")
897    }
898
899    /// Sets the literal internal-network choice exactly once.
900    ///
901    /// Omission remains distinct from an explicit `false` or `true`. Generation does not infer
902    /// defaults or validate driver, IPAM, provider, or runtime behavior.
903    ///
904    /// # Errors
905    ///
906    /// Returns [`GenerationError::DuplicateField`] when already configured.
907    pub fn set_internal(&mut self, internal: bool) -> Result<(), GenerationError> {
908        set_once(&mut self.internal, internal, "network internal")
909    }
910
911    /// Sets the complete ordered unique network-label mapping exactly once.
912    ///
913    /// An empty mapping remains explicit. Labels use the same explicit string-value contract as
914    /// generated service labels, so neither key-only nor non-string label forms are generated.
915    ///
916    /// # Errors
917    ///
918    /// Rejects duplicate label names and duplicate field configuration. No provider, runtime, or
919    /// injected-label equivalence is inferred.
920    pub fn set_labels(&mut self, labels: Vec<GeneratedLabel>) -> Result<(), GenerationError> {
921        let mut seen = BTreeSet::new();
922        for label in &labels {
923            if !seen.insert(label.name()) {
924                return Err(GenerationError::DuplicateName {
925                    kind: "network label",
926                    name: label.name().to_owned(),
927                });
928            }
929        }
930        set_once(&mut self.labels, labels, "network labels")
931    }
932
933    /// Returns the generated network name.
934    #[must_use]
935    pub fn name(&self) -> &str {
936        &self.name
937    }
938
939    /// Returns the optional exact platform-level network name.
940    #[must_use]
941    pub fn custom_name(&self) -> Option<&str> {
942        self.custom_name.as_deref()
943    }
944
945    /// Returns the optional opaque network driver.
946    #[must_use]
947    pub const fn driver(&self) -> Option<&GeneratedString> {
948        self.driver.as_ref()
949    }
950
951    /// Returns the optional ordered driver-options mapping, including an explicit empty map.
952    #[must_use]
953    pub fn driver_opts(&self) -> Option<&[GeneratedNetworkDriverOption]> {
954        self.driver_opts.as_deref()
955    }
956
957    /// Returns the explicitly selected IPv6-enable choice.
958    #[must_use]
959    pub const fn enable_ipv6(&self) -> Option<bool> {
960        self.enable_ipv6
961    }
962
963    /// Returns the explicitly selected internal-network choice.
964    #[must_use]
965    pub const fn internal(&self) -> Option<bool> {
966        self.internal
967    }
968
969    /// Returns the optional ordered network-label mapping, including an explicit empty map.
970    #[must_use]
971    pub fn labels(&self) -> Option<&[GeneratedLabel]> {
972        self.labels.as_deref()
973    }
974
975    fn is_sensitive(&self) -> bool {
976        self.driver.as_ref().is_some_and(GeneratedString::is_sensitive)
977            || self
978                .driver_opts
979                .as_ref()
980                .is_some_and(|options| options.iter().any(|option| option.value.is_sensitive()))
981            || self
982                .labels
983                .as_ref()
984                .is_some_and(|labels| labels.iter().any(|label| label.value.is_sensitive()))
985    }
986}
987
988impl GeneratedDevice {
989    fn is_sensitive(&self) -> bool {
990        match self {
991            Self::Short(value) => value.is_sensitive(),
992            Self::Long(value) => value.is_sensitive(),
993        }
994    }
995}
996
997/// One ordered mapping-form generated sysctl assignment.
998#[derive(Clone, Debug, Eq, PartialEq)]
999pub struct GeneratedSysctl {
1000    name: String,
1001    value: GeneratedString,
1002}
1003
1004impl GeneratedSysctl {
1005    /// Creates one resolved string-valued sysctl assignment.
1006    ///
1007    /// # Errors
1008    ///
1009    /// Rejects empty, multiline, NUL-bearing, or dollar-bearing names and multiline or
1010    /// dollar-bearing values. Values may be empty. NUL-bearing values are rejected while
1011    /// constructing [`GeneratedString`].
1012    pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
1013        let name = name.into();
1014        if name.is_empty()
1015            || name.contains(['\0', '\r', '\n'])
1016            || name.contains('$')
1017            || value.expose().contains(['\r', '\n', '$'])
1018        {
1019            return Err(if name.is_empty() || name.contains(['\0', '\r', '\n', '$']) {
1020                GenerationError::InvalidSysctlName
1021            } else {
1022                GenerationError::InvalidSysctlValue
1023            });
1024        }
1025        Ok(Self { name, value })
1026    }
1027
1028    /// Returns the exact generated sysctl name.
1029    #[must_use]
1030    pub fn name(&self) -> &str {
1031        &self.name
1032    }
1033
1034    /// Returns the exact quoted-string value through its sensitivity boundary.
1035    #[must_use]
1036    pub const fn value(&self) -> &GeneratedString {
1037        &self.value
1038    }
1039}
1040
1041/// The mapping or list form selected for generated service `sysctls`.
1042#[derive(Clone, Debug, Eq, PartialEq)]
1043#[non_exhaustive]
1044pub enum GeneratedSysctls {
1045    /// Ordered unique-name mapping assignments, including an explicit empty mapping.
1046    Map(Vec<GeneratedSysctl>),
1047    /// Ordered unique exact strings, including an explicit empty list.
1048    List(Vec<GeneratedString>),
1049}
1050
1051/// The single or soft/hard form selected for one generated service limit.
1052#[derive(Clone, Debug, Eq, PartialEq)]
1053#[non_exhaustive]
1054pub enum GeneratedUlimitValue {
1055    /// One value applies to both the soft and hard limit.
1056    Single(GeneratedString),
1057    /// Separate required soft and hard values.
1058    Range {
1059        /// Required soft limit; omission is rejected during construction.
1060        soft: Option<GeneratedString>,
1061        /// Required hard limit; omission is rejected during construction.
1062        hard: Option<GeneratedString>,
1063    },
1064}
1065
1066/// One ordered generated service limit.
1067#[derive(Clone, Debug, Eq, PartialEq)]
1068pub struct GeneratedUlimit {
1069    name: String,
1070    value: GeneratedUlimitValue,
1071}
1072
1073impl GeneratedUlimit {
1074    /// Creates one validated named generated limit.
1075    ///
1076    /// # Errors
1077    ///
1078    /// Rejects non-lowercase names, missing range members, deferred/multiline/NUL-bearing values,
1079    /// and values other than `-1` or non-negative ASCII decimals.
1080    pub fn new(name: impl Into<String>, value: GeneratedUlimitValue) -> Result<Self, GenerationError> {
1081        let name = name.into();
1082        if !valid_ulimit_name(&name) {
1083            return Err(GenerationError::InvalidUlimitName);
1084        }
1085        match &value {
1086            GeneratedUlimitValue::Single(value) => validate_generated_ulimit_value(value)?,
1087            GeneratedUlimitValue::Range { soft, hard } => {
1088                let soft = soft.as_ref().ok_or(GenerationError::MissingUlimitRangeMember("soft"))?;
1089                let hard = hard.as_ref().ok_or(GenerationError::MissingUlimitRangeMember("hard"))?;
1090                validate_generated_ulimit_value(soft)?;
1091                validate_generated_ulimit_value(hard)?;
1092            }
1093        }
1094        Ok(Self { name, value })
1095    }
1096
1097    /// Creates one validated single-form generated limit.
1098    ///
1099    /// # Errors
1100    ///
1101    /// Returns the same name and value validation errors as [`Self::new`].
1102    pub fn single(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
1103        Self::new(name, GeneratedUlimitValue::Single(value))
1104    }
1105
1106    /// Creates one validated soft/hard generated limit.
1107    ///
1108    /// # Errors
1109    ///
1110    /// Returns the same name and value validation errors as [`Self::new`].
1111    pub fn range(
1112        name: impl Into<String>,
1113        soft: GeneratedString,
1114        hard: GeneratedString,
1115    ) -> Result<Self, GenerationError> {
1116        Self::new(
1117            name,
1118            GeneratedUlimitValue::Range {
1119                soft: Some(soft),
1120                hard: Some(hard),
1121            },
1122        )
1123    }
1124
1125    /// Returns the lowercase limit name.
1126    #[must_use]
1127    pub fn name(&self) -> &str {
1128        &self.name
1129    }
1130
1131    /// Returns the selected single or soft/hard form.
1132    #[must_use]
1133    pub const fn value(&self) -> &GeneratedUlimitValue {
1134        &self.value
1135    }
1136
1137    fn is_sensitive(&self) -> bool {
1138        match &self.value {
1139            GeneratedUlimitValue::Single(value) => value.is_sensitive(),
1140            GeneratedUlimitValue::Range { soft, hard } => {
1141                soft.iter().chain(hard.iter()).any(GeneratedString::is_sensitive)
1142            }
1143        }
1144    }
1145}
1146
1147/// Ordered generated service limits, including an explicit empty mapping.
1148#[derive(Clone, Debug, Eq, PartialEq)]
1149pub struct GeneratedUlimits {
1150    entries: Vec<GeneratedUlimit>,
1151}
1152
1153impl GeneratedUlimits {
1154    /// Creates an ordered unique-name limit mapping.
1155    ///
1156    /// # Errors
1157    ///
1158    /// Rejects duplicate names without reordering the retained entries.
1159    pub fn new(entries: Vec<GeneratedUlimit>) -> Result<Self, GenerationError> {
1160        let mut seen = BTreeSet::new();
1161        for entry in &entries {
1162            if !seen.insert(entry.name()) {
1163                return Err(GenerationError::DuplicateName {
1164                    kind: "ulimit",
1165                    name: entry.name().to_owned(),
1166                });
1167            }
1168        }
1169        Ok(Self { entries })
1170    }
1171
1172    /// Returns limits in generated output order.
1173    #[must_use]
1174    pub fn entries(&self) -> &[GeneratedUlimit] {
1175        &self.entries
1176    }
1177
1178    /// Reports whether generation will emit an explicit empty mapping.
1179    #[must_use]
1180    pub fn is_empty(&self) -> bool {
1181        self.entries.is_empty()
1182    }
1183}
1184
1185/// A resolved service hostname selected for generated Compose output.
1186#[derive(Clone, Debug, Eq, PartialEq)]
1187#[non_exhaustive]
1188pub enum GeneratedHostname {
1189    /// Emit one exact resolved hostname after conservative RFC-1123 validation.
1190    Resolved(GeneratedString),
1191}
1192
1193/// One ordered Compose environment entry.
1194#[derive(Clone, Debug, Eq, PartialEq)]
1195pub struct GeneratedEnvironment {
1196    name: String,
1197    value: Option<GeneratedString>,
1198}
1199
1200/// Explicit parser mode for one generated long-syntax `env_file` entry.
1201#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1202#[non_exhaustive]
1203pub enum GeneratedEnvironmentFileFormat {
1204    /// Preserve raw environment-file values without Compose interpolation or quote processing.
1205    Raw,
1206}
1207
1208/// One ordered generated Compose `env_file` declaration.
1209#[derive(Clone, Debug, Eq, PartialEq)]
1210#[non_exhaustive]
1211pub enum GeneratedEnvironmentFile {
1212    /// Scalar path syntax with Compose defaults.
1213    Short(GeneratedString),
1214    /// Mapping syntax with independently selected options.
1215    Long {
1216        /// Environment-file path.
1217        path: GeneratedString,
1218        /// Explicit required/optional behavior, or source-format default when omitted.
1219        required: Option<bool>,
1220        /// Explicit parser mode, or source-format default when omitted.
1221        format: Option<GeneratedEnvironmentFileFormat>,
1222    },
1223}
1224
1225/// One generated Compose metadata label.
1226#[derive(Clone, Debug, Eq, PartialEq)]
1227pub struct GeneratedLabel {
1228    name: String,
1229    value: GeneratedString,
1230}
1231
1232/// One generated service annotation with an explicit string value.
1233#[derive(Clone, Debug, Eq, PartialEq)]
1234pub struct GeneratedAnnotation {
1235    name: String,
1236    value: GeneratedString,
1237}
1238
1239impl GeneratedAnnotation {
1240    /// Creates one resolved mapping-form annotation.
1241    ///
1242    /// # Errors
1243    ///
1244    /// Rejects empty/deferred/multiline/NUL-bearing names and deferred/multiline/NUL-bearing
1245    /// values. Empty explicit values remain representable.
1246    pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
1247        let name = name.into();
1248        if name.is_empty() || name.contains(['$', '\r', '\n', '\0']) {
1249            return Err(GenerationError::InvalidAnnotationName);
1250        }
1251        if value.expose().contains(['$', '\r', '\n', '\0']) {
1252            return Err(GenerationError::InvalidAnnotationValue);
1253        }
1254        Ok(Self { name, value })
1255    }
1256
1257    /// Returns the exact resolved annotation name.
1258    #[must_use]
1259    pub fn name(&self) -> &str {
1260        &self.name
1261    }
1262
1263    /// Returns the explicit annotation value through its sensitivity boundary.
1264    #[must_use]
1265    pub const fn value(&self) -> &GeneratedString {
1266        &self.value
1267    }
1268}
1269
1270impl GeneratedLabel {
1271    /// Creates a label with an explicit string value, including an empty value.
1272    ///
1273    /// # Errors
1274    ///
1275    /// Rejects an empty or NUL-bearing label name. Values are already validated by
1276    /// [`GeneratedString`].
1277    pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
1278        Ok(Self {
1279            name: required("label name", name.into())?,
1280            value,
1281        })
1282    }
1283
1284    /// Returns the label name.
1285    #[must_use]
1286    pub fn name(&self) -> &str {
1287        &self.name
1288    }
1289
1290    /// Returns the label value through its explicit sensitivity boundary.
1291    #[must_use]
1292    pub const fn value(&self) -> &GeneratedString {
1293        &self.value
1294    }
1295}
1296
1297impl GeneratedEnvironment {
1298    /// Creates a literal `NAME=value` entry.
1299    ///
1300    /// # Errors
1301    ///
1302    /// Rejects an empty/NUL-bearing name or a name containing `=`.
1303    pub fn literal(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
1304        Ok(Self {
1305            name: environment_name(name.into())?,
1306            value: Some(value),
1307        })
1308    }
1309
1310    /// Creates a host-resolved key-only environment entry.
1311    ///
1312    /// # Errors
1313    ///
1314    /// Rejects an empty/NUL-bearing name or a name containing `=`.
1315    pub fn host(name: impl Into<String>) -> Result<Self, GenerationError> {
1316        Ok(Self {
1317            name: environment_name(name.into())?,
1318            value: None,
1319        })
1320    }
1321
1322    /// Returns the environment name.
1323    #[must_use]
1324    pub fn name(&self) -> &str {
1325        &self.name
1326    }
1327
1328    /// Returns the optional literal value.
1329    #[must_use]
1330    pub const fn value(&self) -> Option<&GeneratedString> {
1331        self.value.as_ref()
1332    }
1333}
1334
1335impl GeneratedEnvironmentFile {
1336    /// Creates one scalar short-syntax declaration.
1337    ///
1338    /// # Errors
1339    ///
1340    /// Returns [`GenerationError::EmptyValue`] for an empty path. NUL-bearing paths are rejected
1341    /// while constructing [`GeneratedString`].
1342    pub fn short(path: GeneratedString) -> Result<Self, GenerationError> {
1343        require_generated_string("environment-file path", &path)?;
1344        Ok(Self::Short(path))
1345    }
1346
1347    /// Creates one mapping long-syntax declaration.
1348    ///
1349    /// # Errors
1350    ///
1351    /// Returns [`GenerationError::EmptyValue`] for an empty path. NUL-bearing paths are rejected
1352    /// while constructing [`GeneratedString`].
1353    pub fn long(
1354        path: GeneratedString,
1355        required: Option<bool>,
1356        format: Option<GeneratedEnvironmentFileFormat>,
1357    ) -> Result<Self, GenerationError> {
1358        require_generated_string("environment-file path", &path)?;
1359        Ok(Self::Long { path, required, format })
1360    }
1361
1362    /// Returns the environment-file path through its explicit sensitivity boundary.
1363    #[must_use]
1364    pub const fn path(&self) -> &GeneratedString {
1365        match self {
1366            Self::Short(path) | Self::Long { path, .. } => path,
1367        }
1368    }
1369
1370    /// Returns the explicitly selected required/optional behavior for long syntax.
1371    #[must_use]
1372    pub const fn required(&self) -> Option<bool> {
1373        match self {
1374            Self::Short(_) => None,
1375            Self::Long { required, .. } => *required,
1376        }
1377    }
1378
1379    /// Returns the explicitly selected parser mode for long syntax.
1380    #[must_use]
1381    pub const fn format(&self) -> Option<GeneratedEnvironmentFileFormat> {
1382        match self {
1383            Self::Short(_) => None,
1384            Self::Long { format, .. } => *format,
1385        }
1386    }
1387
1388    /// Reports whether debug output must redact this declaration's path.
1389    #[must_use]
1390    pub const fn is_sensitive(&self) -> bool {
1391        self.path().is_sensitive()
1392    }
1393}
1394
1395/// One ordered Compose `extra_hosts` relationship.
1396#[derive(Clone, Debug, Eq, PartialEq)]
1397pub struct GeneratedExtraHost {
1398    hostname: String,
1399    address: String,
1400}
1401
1402impl GeneratedExtraHost {
1403    /// Creates a short-form `hostname=address` relationship.
1404    ///
1405    /// # Errors
1406    ///
1407    /// Rejects empty/NUL-bearing values and the unambiguous short-form separator `=`.
1408    pub fn new(hostname: impl Into<String>, address: impl Into<String>) -> Result<Self, GenerationError> {
1409        let hostname = short_component("extra-host hostname", hostname.into(), '=')?;
1410        let address = short_component("extra-host address", address.into(), '=')?;
1411        Ok(Self { hostname, address })
1412    }
1413
1414    /// Returns the hostname.
1415    #[must_use]
1416    pub fn hostname(&self) -> &str {
1417        &self.hostname
1418    }
1419
1420    /// Returns the address or implementation token.
1421    #[must_use]
1422    pub fn address(&self) -> &str {
1423        &self.address
1424    }
1425}
1426
1427/// Transport protocol for one generated published port.
1428#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1429#[non_exhaustive]
1430pub enum GeneratedProtocol {
1431    /// Transmission Control Protocol.
1432    Tcp,
1433    /// User Datagram Protocol.
1434    Udp,
1435    /// Stream Control Transmission Protocol.
1436    Sctp,
1437}
1438
1439impl GeneratedProtocol {
1440    const fn as_str(self) -> &'static str {
1441        match self {
1442            Self::Tcp => "tcp",
1443            Self::Udp => "udp",
1444            Self::Sctp => "sctp",
1445        }
1446    }
1447}
1448
1449/// One generated Compose port entry with protocol-aware syntax selection.
1450#[derive(Clone, Debug, Eq, PartialEq)]
1451pub struct GeneratedPort {
1452    target: u16,
1453    published: Option<u16>,
1454    host_ip: Option<String>,
1455    protocol: GeneratedProtocol,
1456}
1457
1458impl GeneratedPort {
1459    /// Creates a generated port without normalizing its declared transport.
1460    ///
1461    /// # Errors
1462    ///
1463    /// Rejects target port zero, an empty/NUL-bearing host address, and an `SCTP` host address
1464    /// without a published port. `SCTP` uses Compose short syntax because the specification's
1465    /// long form only defines `tcp` and `udp` protocols.
1466    pub fn new(
1467        target: u16,
1468        published: Option<u16>,
1469        host_ip: Option<String>,
1470        protocol: GeneratedProtocol,
1471    ) -> Result<Self, GenerationError> {
1472        if target == 0 {
1473            return Err(GenerationError::InvalidPort);
1474        }
1475        if let Some(host_ip) = host_ip.as_deref() {
1476            required("port host address", host_ip.to_owned())?;
1477            if protocol == GeneratedProtocol::Sctp && published.is_none() {
1478                return Err(GenerationError::UnrepresentableSctpHostIp);
1479            }
1480        }
1481        Ok(Self {
1482            target,
1483            published,
1484            host_ip,
1485            protocol,
1486        })
1487    }
1488
1489    /// Returns the container port.
1490    #[must_use]
1491    pub const fn target(&self) -> u16 {
1492        self.target
1493    }
1494
1495    /// Returns the optional host port.
1496    #[must_use]
1497    pub const fn published(&self) -> Option<u16> {
1498        self.published
1499    }
1500
1501    /// Returns the optional host-address spelling.
1502    #[must_use]
1503    pub fn host_ip(&self) -> Option<&str> {
1504        self.host_ip.as_deref()
1505    }
1506
1507    /// Returns the transport protocol.
1508    #[must_use]
1509    pub const fn protocol(&self) -> GeneratedProtocol {
1510        self.protocol
1511    }
1512}
1513
1514/// `SELinux` relabel option that requires Compose short bind syntax.
1515#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1516#[non_exhaustive]
1517pub enum GeneratedSelinux {
1518    /// Private unshared relabel (`Z`).
1519    Private,
1520    /// Shared relabel (`z`).
1521    Shared,
1522}
1523
1524impl GeneratedSelinux {
1525    const fn as_str(self) -> &'static str {
1526        match self {
1527            Self::Private => "Z",
1528            Self::Shared => "z",
1529        }
1530    }
1531}
1532
1533#[derive(Clone, Debug, Eq, PartialEq)]
1534enum GeneratedMountKind {
1535    Volume {
1536        source: String,
1537    },
1538    Bind {
1539        source: String,
1540        selinux: Option<GeneratedSelinux>,
1541    },
1542    Anonymous,
1543}
1544
1545/// One generated service mount with deliberate short/long syntax selection.
1546#[derive(Clone, Debug, Eq, PartialEq)]
1547pub struct GeneratedMount {
1548    kind: GeneratedMountKind,
1549    target: String,
1550    read_only: bool,
1551}
1552
1553impl GeneratedMount {
1554    /// Creates a long-form named-volume mount.
1555    ///
1556    /// # Errors
1557    ///
1558    /// Rejects empty or NUL-bearing source and target values.
1559    pub fn volume(
1560        source: impl Into<String>,
1561        target: impl Into<String>,
1562        read_only: bool,
1563    ) -> Result<Self, GenerationError> {
1564        Ok(Self {
1565            kind: GeneratedMountKind::Volume {
1566                source: required("volume source", source.into())?,
1567            },
1568            target: required("mount target", target.into())?,
1569            read_only,
1570        })
1571    }
1572
1573    /// Creates a bind mount. `SELinux` relabel intent selects short syntax deliberately.
1574    ///
1575    /// # Errors
1576    ///
1577    /// Rejects empty/NUL-bearing values. When `selinux` is present, also rejects `:` in source or
1578    /// target because Compose only honors the relabel option in the short form used here.
1579    pub fn bind(
1580        source: impl Into<String>,
1581        target: impl Into<String>,
1582        read_only: bool,
1583        selinux: Option<GeneratedSelinux>,
1584    ) -> Result<Self, GenerationError> {
1585        let source = required("bind source", source.into())?;
1586        let target = required("mount target", target.into())?;
1587        if selinux.is_some() && (source.contains(':') || target.contains(':')) {
1588            return Err(GenerationError::InvalidSelinuxBind);
1589        }
1590        Ok(Self {
1591            kind: GeneratedMountKind::Bind { source, selinux },
1592            target,
1593            read_only,
1594        })
1595    }
1596
1597    /// Creates a long-form anonymous-volume mount.
1598    ///
1599    /// # Errors
1600    ///
1601    /// Rejects an empty or NUL-bearing target.
1602    pub fn anonymous(target: impl Into<String>, read_only: bool) -> Result<Self, GenerationError> {
1603        Ok(Self {
1604            kind: GeneratedMountKind::Anonymous,
1605            target: required("mount target", target.into())?,
1606            read_only,
1607        })
1608    }
1609
1610    /// Returns the container target path.
1611    #[must_use]
1612    pub fn target(&self) -> &str {
1613        &self.target
1614    }
1615
1616    /// Reports whether the mount is read-only.
1617    #[must_use]
1618    pub const fn read_only(&self) -> bool {
1619        self.read_only
1620    }
1621}
1622
1623/// One generated service network attachment and its ordered aliases.
1624#[derive(Clone, Debug, Eq, PartialEq)]
1625pub struct GeneratedNetworkAttachment {
1626    name: String,
1627    aliases: Vec<String>,
1628    ipv4_address: Option<GeneratedString>,
1629    ipv6_address: Option<GeneratedString>,
1630}
1631
1632impl GeneratedNetworkAttachment {
1633    /// Creates an attachment without aliases or per-network addresses.
1634    ///
1635    /// # Errors
1636    ///
1637    /// Rejects an empty or NUL-bearing network name.
1638    pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
1639        Ok(Self {
1640            name: required("network name", name.into())?,
1641            aliases: Vec::new(),
1642            ipv4_address: None,
1643            ipv6_address: None,
1644        })
1645    }
1646
1647    /// Adds one ordered alias.
1648    ///
1649    /// # Errors
1650    ///
1651    /// Rejects an empty or NUL-bearing alias.
1652    pub fn add_alias(&mut self, alias: impl Into<String>) -> Result<(), GenerationError> {
1653        self.aliases.push(required("network alias", alias.into())?);
1654        Ok(())
1655    }
1656
1657    /// Sets one raw per-attachment IPv4 address exactly once.
1658    ///
1659    /// No IP grammar, top-level IPAM pool, provider, or runtime validation is applied.
1660    ///
1661    /// # Errors
1662    ///
1663    /// Returns [`GenerationError::DuplicateField`] when already configured.
1664    pub fn set_ipv4_address(&mut self, address: GeneratedString) -> Result<(), GenerationError> {
1665        set_once(&mut self.ipv4_address, address, "ipv4_address")
1666    }
1667
1668    /// Sets one raw per-attachment IPv6 address exactly once.
1669    ///
1670    /// No IP grammar, top-level IPAM pool, provider, or runtime validation is applied.
1671    ///
1672    /// # Errors
1673    ///
1674    /// Returns [`GenerationError::DuplicateField`] when already configured.
1675    pub fn set_ipv6_address(&mut self, address: GeneratedString) -> Result<(), GenerationError> {
1676        set_once(&mut self.ipv6_address, address, "ipv6_address")
1677    }
1678
1679    /// Returns the network name.
1680    #[must_use]
1681    pub fn name(&self) -> &str {
1682        &self.name
1683    }
1684
1685    /// Returns aliases in insertion order.
1686    #[must_use]
1687    pub fn aliases(&self) -> &[String] {
1688        &self.aliases
1689    }
1690
1691    /// Returns the optional raw per-attachment IPv4 address.
1692    #[must_use]
1693    pub const fn ipv4_address(&self) -> Option<&GeneratedString> {
1694        self.ipv4_address.as_ref()
1695    }
1696
1697    /// Returns the optional raw per-attachment IPv6 address.
1698    #[must_use]
1699    pub const fn ipv6_address(&self) -> Option<&GeneratedString> {
1700        self.ipv6_address.as_ref()
1701    }
1702
1703    fn is_sensitive(&self) -> bool {
1704        self.ipv4_address.as_ref().is_some_and(GeneratedString::is_sensitive)
1705            || self.ipv6_address.as_ref().is_some_and(GeneratedString::is_sensitive)
1706    }
1707}
1708
1709/// One top-level network or volume lifecycle definition.
1710#[derive(Clone, Debug, Eq, PartialEq)]
1711pub struct GeneratedResource {
1712    name: String,
1713    external: bool,
1714    custom_name: Option<String>,
1715}
1716
1717impl GeneratedResource {
1718    /// Creates an application-owned resource definition.
1719    ///
1720    /// # Errors
1721    ///
1722    /// Rejects an empty or NUL-bearing name.
1723    pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
1724        Ok(Self {
1725            name: required("resource name", name.into())?,
1726            external: false,
1727            custom_name: None,
1728        })
1729    }
1730
1731    /// Creates an externally managed resource definition.
1732    ///
1733    /// # Errors
1734    ///
1735    /// Rejects an empty or NUL-bearing name.
1736    pub fn external(name: impl Into<String>) -> Result<Self, GenerationError> {
1737        Ok(Self {
1738            name: required("resource name", name.into())?,
1739            external: true,
1740            custom_name: None,
1741        })
1742    }
1743
1744    /// Sets the exact platform-level resource name once.
1745    ///
1746    /// This prevents Compose project scoping from changing a reviewed runtime resource name.
1747    ///
1748    /// # Errors
1749    ///
1750    /// Rejects an empty/NUL-bearing name and duplicate configuration.
1751    pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
1752        let name = required("custom resource name", name.into())?;
1753        set_once(&mut self.custom_name, name, "resource name")
1754    }
1755
1756    /// Returns the resource name.
1757    #[must_use]
1758    pub fn name(&self) -> &str {
1759        &self.name
1760    }
1761
1762    /// Reports whether Compose should reuse an external resource.
1763    #[must_use]
1764    pub const fn is_external(&self) -> bool {
1765        self.external
1766    }
1767
1768    /// Returns the optional exact platform-level resource name.
1769    #[must_use]
1770    pub fn custom_name(&self) -> Option<&str> {
1771        self.custom_name.as_deref()
1772    }
1773}
1774
1775/// A typed generated Compose service definition.
1776#[derive(Clone, Debug, Eq, PartialEq)]
1777pub struct GeneratedService {
1778    name: String,
1779    hostname: Option<GeneratedHostname>,
1780    container_name: Option<GeneratedString>,
1781    image: Option<GeneratedString>,
1782    entrypoint: Option<GeneratedEntrypoint>,
1783    command: Option<GeneratedCommand>,
1784    init: Option<bool>,
1785    stdin_open: Option<bool>,
1786    tty: Option<bool>,
1787    privileged: Option<bool>,
1788    environment_files: Vec<GeneratedEnvironmentFile>,
1789    environment: Vec<GeneratedEnvironment>,
1790    labels: Vec<GeneratedLabel>,
1791    annotations: Option<Vec<GeneratedAnnotation>>,
1792    user: Option<GeneratedString>,
1793    userns_mode: Option<GeneratedString>,
1794    group_add: Vec<GeneratedString>,
1795    cap_add: Option<Vec<GeneratedString>>,
1796    cap_drop: Option<Vec<GeneratedString>>,
1797    devices: Option<Vec<GeneratedDevice>>,
1798    dns: Option<GeneratedDns>,
1799    dns_options: Option<Vec<GeneratedString>>,
1800    dns_search: Option<GeneratedDnsSearch>,
1801    expose: Option<Vec<GeneratedString>>,
1802    security_options: Option<Vec<GeneratedString>>,
1803    working_dir: Option<GeneratedString>,
1804    read_only: Option<bool>,
1805    pids_limit: Option<GeneratedPidsLimit>,
1806    shm_size: Option<GeneratedShmSize>,
1807    mem_limit: Option<GeneratedMemLimit>,
1808    tmpfs: Option<GeneratedTmpfs>,
1809    sysctls: Option<GeneratedSysctls>,
1810    logging: Option<GeneratedLogging>,
1811    ulimits: Option<GeneratedUlimits>,
1812    pull_policy: Option<GeneratedPullPolicy>,
1813    restart: Option<GeneratedRestartPolicy>,
1814    stop_signal: Option<GeneratedString>,
1815    stop_grace_period: Option<GeneratedString>,
1816    extra_hosts: Vec<GeneratedExtraHost>,
1817    ports: Vec<GeneratedPort>,
1818    mounts: Vec<GeneratedMount>,
1819    networks: Vec<GeneratedNetworkAttachment>,
1820}
1821
1822impl GeneratedService {
1823    /// Creates an empty service with a validated name.
1824    ///
1825    /// # Errors
1826    ///
1827    /// Rejects an empty or NUL-bearing name.
1828    pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
1829        Ok(Self {
1830            name: required("service name", name.into())?,
1831            hostname: None,
1832            container_name: None,
1833            image: None,
1834            entrypoint: None,
1835            command: None,
1836            init: None,
1837            stdin_open: None,
1838            tty: None,
1839            privileged: None,
1840            environment_files: Vec::new(),
1841            environment: Vec::new(),
1842            labels: Vec::new(),
1843            annotations: None,
1844            user: None,
1845            userns_mode: None,
1846            group_add: Vec::new(),
1847            cap_add: None,
1848            cap_drop: None,
1849            devices: None,
1850            dns: None,
1851            dns_options: None,
1852            dns_search: None,
1853            expose: None,
1854            security_options: None,
1855            working_dir: None,
1856            read_only: None,
1857            pids_limit: None,
1858            shm_size: None,
1859            mem_limit: None,
1860            tmpfs: None,
1861            sysctls: None,
1862            logging: None,
1863            ulimits: None,
1864            pull_policy: None,
1865            restart: None,
1866            stop_signal: None,
1867            stop_grace_period: None,
1868            extra_hosts: Vec::new(),
1869            ports: Vec::new(),
1870            mounts: Vec::new(),
1871            networks: Vec::new(),
1872        })
1873    }
1874
1875    /// Returns the service name.
1876    #[must_use]
1877    pub fn name(&self) -> &str {
1878        &self.name
1879    }
1880
1881    /// Sets one resolved RFC-1123 service hostname exactly once.
1882    ///
1883    /// # Errors
1884    ///
1885    /// Returns [`GenerationError::InvalidHostname`] for an empty, expression-shaped, non-ASCII,
1886    /// overlong, or otherwise invalid hostname, or [`GenerationError::DuplicateField`] when
1887    /// already configured.
1888    pub fn set_hostname(&mut self, hostname: GeneratedHostname) -> Result<(), GenerationError> {
1889        let GeneratedHostname::Resolved(value) = &hostname;
1890        if !valid_hostname(value.expose()) {
1891            return Err(GenerationError::InvalidHostname);
1892        }
1893        set_once(&mut self.hostname, hostname, "hostname")
1894    }
1895
1896    /// Sets the custom runtime container name exactly once.
1897    ///
1898    /// # Errors
1899    ///
1900    /// Returns [`GenerationError::InvalidContainerName`] when the value does not match Compose's
1901    /// portable container-name grammar or [`GenerationError::DuplicateField`] when already
1902    /// configured.
1903    pub fn set_container_name(&mut self, name: GeneratedString) -> Result<(), GenerationError> {
1904        if !valid_container_name(name.expose()) {
1905            return Err(GenerationError::InvalidContainerName);
1906        }
1907        set_once(&mut self.container_name, name, "container_name")
1908    }
1909
1910    /// Sets the service image exactly once.
1911    ///
1912    /// # Errors
1913    ///
1914    /// Returns [`GenerationError::EmptyValue`] for an empty image or
1915    /// [`GenerationError::DuplicateField`] when already configured.
1916    pub fn set_image(&mut self, image: GeneratedString) -> Result<(), GenerationError> {
1917        require_generated_string("service image", &image)?;
1918        set_once(&mut self.image, image, "image")
1919    }
1920
1921    /// Sets the Compose entrypoint form exactly once.
1922    ///
1923    /// # Errors
1924    ///
1925    /// Returns [`GenerationError::DuplicateField`] when already configured.
1926    pub fn set_entrypoint(&mut self, entrypoint: GeneratedEntrypoint) -> Result<(), GenerationError> {
1927        set_once(&mut self.entrypoint, entrypoint, "entrypoint")
1928    }
1929
1930    /// Sets the Compose command form exactly once.
1931    ///
1932    /// # Errors
1933    ///
1934    /// Returns [`GenerationError::DuplicateField`] when already configured.
1935    pub fn set_command(&mut self, command: GeneratedCommand) -> Result<(), GenerationError> {
1936        set_once(&mut self.command, command, "command")
1937    }
1938
1939    /// Sets the Compose init-process choice exactly once.
1940    ///
1941    /// # Errors
1942    ///
1943    /// Returns [`GenerationError::DuplicateField`] when already configured.
1944    pub fn set_init(&mut self, init: bool) -> Result<(), GenerationError> {
1945        set_once(&mut self.init, init, "init")
1946    }
1947
1948    /// Sets the Compose standard-input-open choice exactly once.
1949    ///
1950    /// # Errors
1951    ///
1952    /// Returns [`GenerationError::DuplicateField`] when already configured.
1953    pub fn set_stdin_open(&mut self, stdin_open: bool) -> Result<(), GenerationError> {
1954        set_once(&mut self.stdin_open, stdin_open, "stdin_open")
1955    }
1956
1957    /// Sets the Compose terminal-allocation choice exactly once.
1958    ///
1959    /// # Errors
1960    ///
1961    /// Returns [`GenerationError::DuplicateField`] when already configured.
1962    pub fn set_tty(&mut self, tty: bool) -> Result<(), GenerationError> {
1963        set_once(&mut self.tty, tty, "tty")
1964    }
1965
1966    /// Sets the Compose privileged choice exactly once.
1967    ///
1968    /// # Errors
1969    ///
1970    /// Returns [`GenerationError::DuplicateField`] when already configured.
1971    pub fn set_privileged(&mut self, privileged: bool) -> Result<(), GenerationError> {
1972        set_once(&mut self.privileged, privileged, "privileged")
1973    }
1974
1975    /// Adds one ordered environment-file declaration.
1976    pub fn add_environment_file(&mut self, environment_file: GeneratedEnvironmentFile) {
1977        self.environment_files.push(environment_file);
1978    }
1979
1980    /// Adds one ordered environment entry.
1981    pub fn add_environment(&mut self, environment: GeneratedEnvironment) {
1982        self.environment.push(environment);
1983    }
1984
1985    /// Adds one uniquely named service metadata label.
1986    ///
1987    /// # Errors
1988    ///
1989    /// Returns [`GenerationError::DuplicateName`] when the service already defines the label.
1990    pub fn add_label(&mut self, label: GeneratedLabel) -> Result<(), GenerationError> {
1991        if self.labels.iter().any(|candidate| candidate.name == label.name) {
1992            return Err(GenerationError::DuplicateName {
1993                kind: "service label",
1994                name: label.name,
1995            });
1996        }
1997        self.labels.push(label);
1998        Ok(())
1999    }
2000
2001    /// Sets the complete ordered mapping-form annotation collection exactly once.
2002    ///
2003    /// Omission remains distinct from an explicit empty mapping. Names must be unique and all
2004    /// entries carry explicit resolved string values; key-only and null forms cannot enter this API.
2005    ///
2006    /// # Errors
2007    ///
2008    /// Returns [`GenerationError::DuplicateName`] for duplicate names,
2009    /// [`GenerationError::InvalidAnnotationName`] or [`GenerationError::InvalidAnnotationValue`]
2010    /// for unsafe values, or [`GenerationError::DuplicateField`] when already configured.
2011    pub fn set_annotations(&mut self, annotations: Vec<GeneratedAnnotation>) -> Result<(), GenerationError> {
2012        let mut seen = BTreeSet::new();
2013        for annotation in &annotations {
2014            if annotation.name.is_empty() || annotation.name.contains(['$', '\r', '\n', '\0']) {
2015                return Err(GenerationError::InvalidAnnotationName);
2016            }
2017            if annotation.value.expose().contains(['$', '\r', '\n', '\0']) {
2018                return Err(GenerationError::InvalidAnnotationValue);
2019            }
2020            if !seen.insert(annotation.name.as_str()) {
2021                return Err(GenerationError::DuplicateName {
2022                    kind: "service annotation",
2023                    name: annotation.name.clone(),
2024                });
2025            }
2026        }
2027        set_once(&mut self.annotations, annotations, "annotations")
2028    }
2029
2030    /// Returns configured annotations, distinguishing omission from an explicit empty mapping.
2031    #[must_use]
2032    pub fn annotations(&self) -> Option<&[GeneratedAnnotation]> {
2033        self.annotations.as_deref()
2034    }
2035
2036    /// Sets the combined Compose `user[:group]` value exactly once.
2037    ///
2038    /// # Errors
2039    ///
2040    /// Returns [`GenerationError::DuplicateField`] when already configured.
2041    pub fn set_user(&mut self, user: GeneratedString) -> Result<(), GenerationError> {
2042        set_once(&mut self.user, user, "user")
2043    }
2044
2045    /// Sets the user-namespace mode exactly once.
2046    ///
2047    /// # Errors
2048    ///
2049    /// Returns [`GenerationError::EmptyValue`] for an empty mode or
2050    /// [`GenerationError::DuplicateField`] when already configured.
2051    pub fn set_userns_mode(&mut self, mode: GeneratedString) -> Result<(), GenerationError> {
2052        require_generated_string("user namespace mode", &mode)?;
2053        set_once(&mut self.userns_mode, mode, "userns_mode")
2054    }
2055
2056    /// Adds one ordered supplementary group.
2057    ///
2058    /// # Errors
2059    ///
2060    /// Returns [`GenerationError::EmptyValue`] for an empty group.
2061    pub fn add_supplementary_group(&mut self, group: GeneratedString) -> Result<(), GenerationError> {
2062        require_generated_string("supplementary group", &group)?;
2063        self.group_add.push(group);
2064        Ok(())
2065    }
2066
2067    /// Sets the complete ordered `cap_add` sequence exactly once.
2068    ///
2069    /// An empty vector is retained as explicit `cap_add: []`; never calling this method omits the
2070    /// field. Values preserve exact case and ordering. No capability whitelist is applied.
2071    ///
2072    /// # Errors
2073    ///
2074    /// Returns [`GenerationError::EmptyValue`] for an empty item,
2075    /// [`GenerationError::ContainsLineBreak`] for a carriage return or line feed,
2076    /// [`GenerationError::DuplicateItem`] for an exact case-sensitive duplicate, or
2077    /// [`GenerationError::DuplicateField`] when already configured. NUL bytes are rejected while
2078    /// constructing [`GeneratedString`].
2079    pub fn set_cap_add(&mut self, capabilities: Vec<GeneratedString>) -> Result<(), GenerationError> {
2080        let mut seen = BTreeSet::new();
2081        for capability in &capabilities {
2082            require_generated_string("cap_add item", capability)?;
2083            if capability.expose().contains('\r') || capability.expose().contains('\n') {
2084                return Err(GenerationError::ContainsLineBreak("cap_add item"));
2085            }
2086            if !seen.insert(capability.expose()) {
2087                return Err(GenerationError::DuplicateItem("cap_add"));
2088            }
2089        }
2090        set_once(&mut self.cap_add, capabilities, "cap_add")
2091    }
2092
2093    /// Returns the configured `cap_add` sequence, distinguishing omission from an empty vector.
2094    #[must_use]
2095    pub fn cap_add(&self) -> Option<&[GeneratedString]> {
2096        self.cap_add.as_deref()
2097    }
2098
2099    /// Sets the complete ordered `cap_drop` sequence exactly once.
2100    ///
2101    /// An empty vector is retained as explicit `cap_drop: []`; never calling this method omits the
2102    /// field. Values preserve exact case and ordering. No capability whitelist is applied.
2103    ///
2104    /// # Errors
2105    ///
2106    /// Returns [`GenerationError::EmptyValue`] for an empty item,
2107    /// [`GenerationError::ContainsLineBreak`] for a carriage return or line feed,
2108    /// [`GenerationError::DuplicateItem`] for an exact case-sensitive duplicate, or
2109    /// [`GenerationError::DuplicateField`] when already configured. NUL bytes are rejected while
2110    /// constructing [`GeneratedString`].
2111    pub fn set_cap_drop(&mut self, capabilities: Vec<GeneratedString>) -> Result<(), GenerationError> {
2112        let mut seen = BTreeSet::new();
2113        for capability in &capabilities {
2114            require_generated_string("cap_drop item", capability)?;
2115            if capability.expose().contains('\r') || capability.expose().contains('\n') {
2116                return Err(GenerationError::ContainsLineBreak("cap_drop item"));
2117            }
2118            if !seen.insert(capability.expose()) {
2119                return Err(GenerationError::DuplicateItem("cap_drop"));
2120            }
2121        }
2122        set_once(&mut self.cap_drop, capabilities, "cap_drop")
2123    }
2124
2125    /// Returns the configured `cap_drop` sequence, distinguishing omission from an empty vector.
2126    #[must_use]
2127    pub fn cap_drop(&self) -> Option<&[GeneratedString]> {
2128        self.cap_drop.as_deref()
2129    }
2130
2131    /// Sets the complete ordered mixed short/long `devices` sequence exactly once.
2132    ///
2133    /// An empty vector is emitted as `devices: []`; omission remains distinct. Exact duplicate
2134    /// items and caller order are preserved. This validates only safe resolved YAML output and
2135    /// does not inspect host devices, split colon triples, validate CDI, normalize permissions,
2136    /// or claim runtime access.
2137    ///
2138    /// # Errors
2139    ///
2140    /// Rejects empty short items and empty long sources, plus NUL-bearing, multiline, or
2141    /// dollar-bearing values. NUL bytes are normally rejected while constructing
2142    /// [`GeneratedString`]. Returns [`GenerationError::DuplicateField`] when already configured.
2143    pub fn set_devices(&mut self, devices: Vec<GeneratedDevice>) -> Result<(), GenerationError> {
2144        for device in &devices {
2145            match device {
2146                GeneratedDevice::Short(value) => {
2147                    validate_generated_device_member("short item", value, true)?;
2148                }
2149                GeneratedDevice::Long(value) => {
2150                    validate_generated_device_member("source", value.source(), true)?;
2151                    if let Some(target) = value.target() {
2152                        validate_generated_device_member("target", target, false)?;
2153                    }
2154                    if let Some(permissions) = value.permissions() {
2155                        validate_generated_device_member("permissions", permissions, false)?;
2156                    }
2157                }
2158            }
2159        }
2160        set_once(&mut self.devices, devices, "devices")
2161    }
2162
2163    /// Sets the complete scalar or ordered-list service `dns` form exactly once.
2164    ///
2165    /// An empty list remains explicit. Values are retained as raw server strings: this API does
2166    /// not require an IP address, parse a resolver grammar, or perform network access.
2167    ///
2168    /// # Errors
2169    ///
2170    /// Rejects empty, multiline, NUL-bearing, or dollar-bearing values and duplicate field
2171    /// configuration. NUL bytes are normally rejected while constructing [`GeneratedString`].
2172    pub fn set_dns(&mut self, dns: GeneratedDns) -> Result<(), GenerationError> {
2173        let values = match &dns {
2174            GeneratedDns::Scalar(value) => std::slice::from_ref(value),
2175            GeneratedDns::List(values) => values.as_slice(),
2176        };
2177        for value in values {
2178            if value.expose().is_empty()
2179                || value.expose().contains('$')
2180                || value.expose().contains('\r')
2181                || value.expose().contains('\n')
2182            {
2183                return Err(GenerationError::InvalidDnsValue);
2184            }
2185        }
2186        set_once(&mut self.dns, dns, "dns")
2187    }
2188
2189    /// Returns the configured scalar or ordered-list DNS form.
2190    #[must_use]
2191    pub const fn dns(&self) -> Option<&GeneratedDns> {
2192        self.dns.as_ref()
2193    }
2194
2195    /// Sets the complete ordered service `dns_opt` sequence exactly once.
2196    ///
2197    /// An empty vector remains explicit while leaving this setter unused omits the field. Values
2198    /// are treated as raw resolver-option strings; no option grammar or runtime behavior is
2199    /// inferred.
2200    ///
2201    /// # Errors
2202    ///
2203    /// Rejects empty, multiline, NUL-bearing, dollar-bearing, or exact-duplicate values and
2204    /// duplicate field configuration. NUL bytes are normally rejected while constructing
2205    /// [`GeneratedString`].
2206    pub fn set_dns_options(&mut self, options: Vec<GeneratedString>) -> Result<(), GenerationError> {
2207        let mut seen = BTreeSet::new();
2208        for option in &options {
2209            if option.expose().is_empty()
2210                || option.expose().contains('$')
2211                || option.expose().contains('\r')
2212                || option.expose().contains('\n')
2213                || option.expose().contains('\0')
2214            {
2215                return Err(GenerationError::InvalidDnsOptionValue);
2216            }
2217            if !seen.insert(option.expose()) {
2218                return Err(GenerationError::DuplicateItem("dns_opt"));
2219            }
2220        }
2221        set_once(&mut self.dns_options, options, "dns_opt")
2222    }
2223
2224    /// Returns configured DNS resolver options, distinguishing omission from an empty sequence.
2225    #[must_use]
2226    pub fn dns_options(&self) -> Option<&[GeneratedString]> {
2227        self.dns_options.as_deref()
2228    }
2229
2230    /// Sets the complete scalar or ordered-list service `dns_search` form exactly once.
2231    ///
2232    /// An empty list remains explicit, exact duplicates and `.` are retained, and no domain,
2233    /// resolver, provider, or runtime validation is performed.
2234    ///
2235    /// # Errors
2236    ///
2237    /// Rejects empty, multiline, NUL-bearing, or dollar-bearing values and duplicate field
2238    /// configuration. NUL bytes are normally rejected while constructing [`GeneratedString`].
2239    pub fn set_dns_search(&mut self, search: GeneratedDnsSearch) -> Result<(), GenerationError> {
2240        let values = match &search {
2241            GeneratedDnsSearch::Scalar(value) => std::slice::from_ref(value),
2242            GeneratedDnsSearch::List(values) => values.as_slice(),
2243        };
2244        for value in values {
2245            if value.expose().is_empty()
2246                || value.expose().contains('$')
2247                || value.expose().contains('\r')
2248                || value.expose().contains('\n')
2249                || value.expose().contains('\0')
2250            {
2251                return Err(GenerationError::InvalidDnsSearchValue);
2252            }
2253        }
2254        set_once(&mut self.dns_search, search, "dns_search")
2255    }
2256
2257    /// Returns the configured scalar or ordered-list DNS search-domain form.
2258    #[must_use]
2259    pub const fn dns_search(&self) -> Option<&GeneratedDnsSearch> {
2260        self.dns_search.as_ref()
2261    }
2262
2263    /// Sets the complete ordered service `expose` sequence exactly once.
2264    ///
2265    /// An empty vector remains explicit. Every output item is quoted, so number and string YAML
2266    /// identities are never silently equated. Omitted protocol and explicit `/tcp` remain distinct.
2267    ///
2268    /// # Errors
2269    ///
2270    /// Rejects empty, deferred, multiline, NUL-bearing, malformed, SCTP, unknown-protocol, and
2271    /// exact-duplicate values, or duplicate field configuration.
2272    pub fn set_expose(&mut self, expose: Vec<GeneratedString>) -> Result<(), GenerationError> {
2273        let mut seen = BTreeSet::new();
2274        for item in &expose {
2275            if !valid_generated_expose_item(item.expose()) {
2276                return Err(GenerationError::InvalidExposeValue);
2277            }
2278            if !seen.insert(item.expose()) {
2279                return Err(GenerationError::DuplicateItem("expose"));
2280            }
2281        }
2282        set_once(&mut self.expose, expose, "expose")
2283    }
2284
2285    /// Returns the configured exposed-port sequence, including an explicit empty sequence.
2286    #[must_use]
2287    pub fn expose(&self) -> Option<&[GeneratedString]> {
2288        self.expose.as_deref()
2289    }
2290
2291    /// Sets the complete ordered raw service `security_opt` sequence exactly once.
2292    ///
2293    /// An empty vector remains explicit, exact duplicates retain their order, and no option,
2294    /// profile, provider, or target-runtime normalization is performed.
2295    ///
2296    /// # Errors
2297    ///
2298    /// Rejects empty, deferred, multiline, or NUL-bearing values and duplicate field
2299    /// configuration. NUL bytes are normally rejected while constructing [`GeneratedString`].
2300    pub fn set_security_options(&mut self, options: Vec<GeneratedString>) -> Result<(), GenerationError> {
2301        for option in &options {
2302            if option.expose().is_empty()
2303                || option.expose().contains('$')
2304                || option.expose().contains('\r')
2305                || option.expose().contains('\n')
2306                || option.expose().contains('\0')
2307            {
2308                return Err(GenerationError::InvalidSecurityOptionValue);
2309            }
2310        }
2311        set_once(&mut self.security_options, options, "security_opt")
2312    }
2313
2314    /// Returns configured raw security options, distinguishing omission from an empty sequence.
2315    #[must_use]
2316    pub fn security_options(&self) -> Option<&[GeneratedString]> {
2317        self.security_options.as_deref()
2318    }
2319
2320    /// Returns configured devices, distinguishing omission from an explicit empty sequence.
2321    #[must_use]
2322    pub fn devices(&self) -> Option<&[GeneratedDevice]> {
2323        self.devices.as_deref()
2324    }
2325
2326    /// Sets the container working directory exactly once.
2327    ///
2328    /// # Errors
2329    ///
2330    /// Returns [`GenerationError::EmptyValue`] for an empty directory or
2331    /// [`GenerationError::DuplicateField`] when already configured.
2332    pub fn set_working_dir(&mut self, directory: GeneratedString) -> Result<(), GenerationError> {
2333        require_generated_string("working directory", &directory)?;
2334        set_once(&mut self.working_dir, directory, "working_dir")
2335    }
2336
2337    /// Sets the read-only-root choice exactly once.
2338    ///
2339    /// # Errors
2340    ///
2341    /// Returns [`GenerationError::DuplicateField`] when already configured.
2342    pub fn set_read_only(&mut self, read_only: bool) -> Result<(), GenerationError> {
2343        set_once(&mut self.read_only, read_only, "read_only")
2344    }
2345
2346    /// Sets an unlimited or positive finite service PID limit exactly once.
2347    ///
2348    /// # Errors
2349    ///
2350    /// Returns [`GenerationError::InvalidPidsLimit`] when a finite spelling is empty, zero,
2351    /// signed, fractional, exponent-shaped, or otherwise not ASCII decimal, or
2352    /// [`GenerationError::DuplicateField`] when already configured.
2353    pub fn set_pids_limit(&mut self, limit: GeneratedPidsLimit) -> Result<(), GenerationError> {
2354        if let GeneratedPidsLimit::Finite(decimal) = &limit {
2355            if !valid_positive_pids_decimal(decimal) {
2356                return Err(GenerationError::InvalidPidsLimit);
2357            }
2358        }
2359        set_once(&mut self.pids_limit, limit, "pids_limit")
2360    }
2361
2362    /// Sets one explicit positive service shared-memory size exactly once.
2363    ///
2364    /// # Errors
2365    ///
2366    /// Returns [`GenerationError::InvalidShmSize`] when the amount is empty, zero, has leading
2367    /// zeros, a sign, fraction, exponent, whitespace, or non-ASCII digits, or
2368    /// [`GenerationError::DuplicateField`] when already configured.
2369    pub fn set_shm_size(&mut self, size: GeneratedShmSize) -> Result<(), GenerationError> {
2370        let GeneratedShmSize::Explicit { amount, .. } = &size;
2371        if !valid_generated_shm_amount(amount.expose()) {
2372            return Err(GenerationError::InvalidShmSize);
2373        }
2374        set_once(&mut self.shm_size, size, "shm_size")
2375    }
2376
2377    /// Sets one explicit positive service memory limit exactly once.
2378    ///
2379    /// # Errors
2380    ///
2381    /// Returns [`GenerationError::InvalidMemLimit`] when the amount is empty, zero, has leading
2382    /// zeros, a sign, fraction, exponent, whitespace, or non-ASCII digits, or
2383    /// [`GenerationError::DuplicateField`] when already configured.
2384    pub fn set_mem_limit(&mut self, limit: GeneratedMemLimit) -> Result<(), GenerationError> {
2385        let GeneratedMemLimit::Explicit { amount, .. } = &limit;
2386        if !valid_generated_mem_amount(amount.expose()) {
2387            return Err(GenerationError::InvalidMemLimit);
2388        }
2389        set_once(&mut self.mem_limit, limit, "mem_limit")
2390    }
2391
2392    /// Sets the complete scalar or list service-level `tmpfs` form exactly once.
2393    ///
2394    /// An empty list is retained explicitly. Item spelling, ordering, and case remain unchanged.
2395    ///
2396    /// # Errors
2397    ///
2398    /// Rejects empty, multiline, deferred, or structurally malformed items. Documented `mode`,
2399    /// `uid`, and `gid` assignments and other well-shaped raw target options remain exact, including
2400    /// duplicate list entries. NUL bytes are rejected while constructing [`GeneratedString`]. Returns
2401    /// [`GenerationError::DuplicateField`] when already configured.
2402    pub fn set_tmpfs(&mut self, tmpfs: GeneratedTmpfs) -> Result<(), GenerationError> {
2403        let items = match &tmpfs {
2404            GeneratedTmpfs::Scalar(item) => std::slice::from_ref(item),
2405            GeneratedTmpfs::List(items) => items.as_slice(),
2406        };
2407        for item in items {
2408            require_generated_string("tmpfs item", item)?;
2409            if item.expose().contains('\r') || item.expose().contains('\n') {
2410                return Err(GenerationError::ContainsLineBreak("tmpfs item"));
2411            }
2412            if !valid_generated_tmpfs_item(item.expose()) {
2413                return Err(GenerationError::InvalidTmpfsItem);
2414            }
2415        }
2416        set_once(&mut self.tmpfs, tmpfs, "tmpfs")
2417    }
2418
2419    /// Returns the configured scalar or list form, distinguishing omission from an empty list.
2420    #[must_use]
2421    pub const fn tmpfs(&self) -> Option<&GeneratedTmpfs> {
2422        self.tmpfs.as_ref()
2423    }
2424
2425    /// Sets the complete mapping or list `sysctls` form exactly once.
2426    ///
2427    /// Empty collections remain explicit. Mapping names and list strings must be exact-unique;
2428    /// neither form applies namespace validation or runtime coercion.
2429    ///
2430    /// # Errors
2431    ///
2432    /// Rejects duplicate map names, duplicate exact list items, multiline or dollar-bearing list
2433    /// items, and duplicate field configuration. NUL-bearing list items are rejected while
2434    /// constructing [`GeneratedString`].
2435    pub fn set_sysctls(&mut self, sysctls: GeneratedSysctls) -> Result<(), GenerationError> {
2436        let mut seen = BTreeSet::new();
2437        match &sysctls {
2438            GeneratedSysctls::Map(entries) => {
2439                for entry in entries {
2440                    if !seen.insert(entry.name()) {
2441                        return Err(GenerationError::DuplicateName {
2442                            kind: "sysctl",
2443                            name: entry.name().to_owned(),
2444                        });
2445                    }
2446                }
2447            }
2448            GeneratedSysctls::List(items) => {
2449                for item in items {
2450                    if item.expose().contains(['\r', '\n', '$']) {
2451                        return Err(GenerationError::InvalidSysctlValue);
2452                    }
2453                    if !seen.insert(item.expose()) {
2454                        return Err(GenerationError::DuplicateItem("sysctls"));
2455                    }
2456                }
2457            }
2458        }
2459        set_once(&mut self.sysctls, sysctls, "sysctls")
2460    }
2461
2462    /// Returns the configured form, distinguishing omission from explicit empty collections.
2463    #[must_use]
2464    pub const fn sysctls(&self) -> Option<&GeneratedSysctls> {
2465        self.sysctls.as_ref()
2466    }
2467
2468    /// Sets explicit logging configuration exactly once.
2469    ///
2470    /// # Errors
2471    ///
2472    /// Returns [`GenerationError::DuplicateField`] when already configured. Driver spelling and
2473    /// option semantics are otherwise left uninterpreted.
2474    pub fn set_logging(&mut self, logging: GeneratedLogging) -> Result<(), GenerationError> {
2475        set_once(&mut self.logging, logging, "logging")
2476    }
2477
2478    /// Returns configured logging, distinguishing omission from explicit empty options.
2479    #[must_use]
2480    pub const fn logging(&self) -> Option<&GeneratedLogging> {
2481        self.logging.as_ref()
2482    }
2483
2484    /// Sets the complete ordered service `ulimits` mapping exactly once.
2485    ///
2486    /// An empty mapping remains explicit. Values are already validated while constructing
2487    /// [`GeneratedUlimit`] and names are unique by construction in [`GeneratedUlimits`].
2488    ///
2489    /// # Errors
2490    ///
2491    /// Returns [`GenerationError::DuplicateField`] when already configured.
2492    pub fn set_ulimits(&mut self, ulimits: GeneratedUlimits) -> Result<(), GenerationError> {
2493        set_once(&mut self.ulimits, ulimits, "ulimits")
2494    }
2495
2496    /// Returns configured ordered limits, distinguishing omission from an explicit empty mapping.
2497    #[must_use]
2498    pub const fn ulimits(&self) -> Option<&GeneratedUlimits> {
2499        self.ulimits.as_ref()
2500    }
2501
2502    /// Sets a documented service image pull policy exactly once.
2503    ///
2504    /// # Errors
2505    ///
2506    /// Returns [`GenerationError::InvalidPullPolicyDuration`] for an invalid custom interval or
2507    /// [`GenerationError::DuplicateField`] when already configured.
2508    pub fn set_pull_policy(&mut self, policy: GeneratedPullPolicy) -> Result<(), GenerationError> {
2509        if let GeneratedPullPolicy::Every(duration) = &policy {
2510            if !valid_pull_policy_duration(duration.expose()) {
2511                return Err(GenerationError::InvalidPullPolicyDuration);
2512            }
2513        }
2514        set_once(&mut self.pull_policy, policy, "pull_policy")
2515    }
2516
2517    /// Sets the service-level restart policy exactly once.
2518    ///
2519    /// # Errors
2520    ///
2521    /// Returns [`GenerationError::DuplicateField`] when already configured.
2522    pub fn set_restart(&mut self, restart: GeneratedRestartPolicy) -> Result<(), GenerationError> {
2523        set_once(&mut self.restart, restart, "restart")
2524    }
2525
2526    /// Sets the service stop signal exactly once without imposing a signal-token grammar.
2527    ///
2528    /// # Errors
2529    ///
2530    /// Returns [`GenerationError::DuplicateField`] when already configured. Quoted empty values
2531    /// are preserved; NUL-bearing values are rejected while constructing [`GeneratedString`].
2532    pub fn set_stop_signal(&mut self, signal: GeneratedString) -> Result<(), GenerationError> {
2533        set_once(&mut self.stop_signal, signal, "stop_signal")
2534    }
2535
2536    /// Sets the raw-preserving service stop grace period exactly once.
2537    ///
2538    /// # Errors
2539    ///
2540    /// Returns [`GenerationError::InvalidStopGracePeriod`] when the value does not match the
2541    /// `ComposeLens` raw-preserving duration policy or dollar-marker convention, or
2542    /// [`GenerationError::DuplicateField`] when already configured.
2543    pub fn set_stop_grace_period(&mut self, period: GeneratedString) -> Result<(), GenerationError> {
2544        if !StopGracePeriod::parse(period.expose().to_owned()).is_valid() {
2545            return Err(GenerationError::InvalidStopGracePeriod);
2546        }
2547        set_once(&mut self.stop_grace_period, period, "stop_grace_period")
2548    }
2549
2550    /// Adds one ordered host mapping.
2551    pub fn add_extra_host(&mut self, host: GeneratedExtraHost) {
2552        self.extra_hosts.push(host);
2553    }
2554
2555    /// Adds one ordered published-port declaration.
2556    pub fn add_port(&mut self, port: GeneratedPort) {
2557        self.ports.push(port);
2558    }
2559
2560    /// Adds one ordered mount.
2561    pub fn add_mount(&mut self, mount: GeneratedMount) {
2562        self.mounts.push(mount);
2563    }
2564
2565    /// Adds one uniquely named network attachment.
2566    ///
2567    /// # Errors
2568    ///
2569    /// Returns [`GenerationError::DuplicateName`] when the service already uses the network.
2570    pub fn add_network(&mut self, network: GeneratedNetworkAttachment) -> Result<(), GenerationError> {
2571        if self.networks.iter().any(|candidate| candidate.name == network.name) {
2572            return Err(GenerationError::DuplicateName {
2573                kind: "service network",
2574                name: network.name,
2575            });
2576        }
2577        self.networks.push(network);
2578        Ok(())
2579    }
2580
2581    fn is_sensitive(&self) -> bool {
2582        matches!(
2583            self.hostname.as_ref(),
2584            Some(GeneratedHostname::Resolved(hostname)) if hostname.is_sensitive()
2585        ) || self.image.as_ref().is_some_and(GeneratedString::is_sensitive)
2586            || self.entrypoint.as_ref().is_some_and(entrypoint_is_sensitive)
2587            || self.command.as_ref().is_some_and(command_is_sensitive)
2588            || self
2589                .environment_files
2590                .iter()
2591                .any(GeneratedEnvironmentFile::is_sensitive)
2592            || self
2593                .environment
2594                .iter()
2595                .filter_map(GeneratedEnvironment::value)
2596                .any(GeneratedString::is_sensitive)
2597            || self.labels.iter().any(|label| label.value.is_sensitive())
2598            || self
2599                .annotations
2600                .as_ref()
2601                .is_some_and(|items| items.iter().any(|annotation| annotation.value.is_sensitive()))
2602            || matches!(
2603                self.pull_policy.as_ref(),
2604                Some(GeneratedPullPolicy::Every(duration)) if duration.is_sensitive()
2605            )
2606            || matches!(
2607                self.shm_size.as_ref(),
2608                Some(GeneratedShmSize::Explicit { amount, .. }) if amount.is_sensitive()
2609            )
2610            || matches!(
2611                self.mem_limit.as_ref(),
2612                Some(GeneratedMemLimit::Explicit { amount, .. }) if amount.is_sensitive()
2613            )
2614            || match self.tmpfs.as_ref() {
2615                Some(GeneratedTmpfs::Scalar(item)) => item.is_sensitive(),
2616                Some(GeneratedTmpfs::List(items)) => items.iter().any(GeneratedString::is_sensitive),
2617                None => false,
2618            }
2619            || match self.dns.as_ref() {
2620                Some(GeneratedDns::Scalar(value)) => value.is_sensitive(),
2621                Some(GeneratedDns::List(values)) => values.iter().any(GeneratedString::is_sensitive),
2622                None => false,
2623            }
2624            || self
2625                .dns_options
2626                .as_ref()
2627                .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
2628            || match self.dns_search.as_ref() {
2629                Some(GeneratedDnsSearch::Scalar(value)) => value.is_sensitive(),
2630                Some(GeneratedDnsSearch::List(values)) => values.iter().any(GeneratedString::is_sensitive),
2631                None => false,
2632            }
2633            || self
2634                .expose
2635                .as_ref()
2636                .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
2637            || self
2638                .security_options
2639                .as_ref()
2640                .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
2641            || match self.sysctls.as_ref() {
2642                Some(GeneratedSysctls::Map(entries)) => entries.iter().any(|entry| entry.value.is_sensitive()),
2643                Some(GeneratedSysctls::List(items)) => items.iter().any(GeneratedString::is_sensitive),
2644                None => false,
2645            }
2646            || self.logging.as_ref().is_some_and(GeneratedLogging::is_sensitive)
2647            || self
2648                .ulimits
2649                .as_ref()
2650                .is_some_and(|limits| limits.entries.iter().any(GeneratedUlimit::is_sensitive))
2651            || [
2652                self.user.as_ref(),
2653                self.userns_mode.as_ref(),
2654                self.working_dir.as_ref(),
2655                self.stop_signal.as_ref(),
2656                self.stop_grace_period.as_ref(),
2657            ]
2658            .into_iter()
2659            .flatten()
2660            .any(GeneratedString::is_sensitive)
2661            || self.group_add.iter().any(GeneratedString::is_sensitive)
2662            || self
2663                .cap_add
2664                .as_ref()
2665                .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
2666            || self
2667                .cap_drop
2668                .as_ref()
2669                .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
2670            || self
2671                .devices
2672                .as_ref()
2673                .is_some_and(|items| items.iter().any(GeneratedDevice::is_sensitive))
2674            || self.networks.iter().any(GeneratedNetworkAttachment::is_sensitive)
2675    }
2676}
2677
2678#[derive(Clone, Debug, Eq, PartialEq)]
2679enum GeneratedNetwork {
2680    Basic(GeneratedResource),
2681    Definition(GeneratedNetworkDefinition),
2682}
2683
2684#[derive(Clone, Debug, Eq, PartialEq)]
2685enum GeneratedVolume {
2686    Basic(GeneratedResource),
2687    Definition(GeneratedVolumeDefinition),
2688}
2689
2690impl GeneratedVolume {
2691    fn name(&self) -> &str {
2692        match self {
2693            Self::Basic(volume) => volume.name(),
2694            Self::Definition(volume) => volume.name(),
2695        }
2696    }
2697
2698    fn is_sensitive(&self) -> bool {
2699        match self {
2700            Self::Basic(_) => false,
2701            Self::Definition(volume) => volume.is_sensitive(),
2702        }
2703    }
2704}
2705
2706impl GeneratedNetwork {
2707    fn name(&self) -> &str {
2708        match self {
2709            Self::Basic(network) => network.name(),
2710            Self::Definition(network) => network.name(),
2711        }
2712    }
2713
2714    fn is_sensitive(&self) -> bool {
2715        match self {
2716            Self::Basic(_) => false,
2717            Self::Definition(network) => network.is_sensitive(),
2718        }
2719    }
2720}
2721
2722/// Builder for one new deterministic Compose document.
2723#[derive(Clone, Debug, Default, Eq, PartialEq)]
2724pub struct ComposeDocumentBuilder {
2725    name: Option<String>,
2726    services: Vec<GeneratedService>,
2727    networks: Vec<GeneratedNetwork>,
2728    volumes: Vec<GeneratedVolume>,
2729}
2730
2731impl ComposeDocumentBuilder {
2732    /// Creates an empty generated project.
2733    #[must_use]
2734    pub const fn new() -> Self {
2735        Self {
2736            name: None,
2737            services: Vec::new(),
2738            networks: Vec::new(),
2739            volumes: Vec::new(),
2740        }
2741    }
2742
2743    /// Sets the optional top-level Compose project name exactly once.
2744    ///
2745    /// # Errors
2746    ///
2747    /// Rejects empty/NUL-bearing names and duplicate configuration.
2748    pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
2749        let name = required("project name", name.into())?;
2750        set_once(&mut self.name, name, "name")
2751    }
2752
2753    /// Adds one uniquely named service in output order.
2754    ///
2755    /// # Errors
2756    ///
2757    /// Returns [`GenerationError::DuplicateName`] for a duplicate service name.
2758    pub fn add_service(&mut self, service: GeneratedService) -> Result<(), GenerationError> {
2759        insert_named(&mut self.services, service, "service", GeneratedService::name)
2760    }
2761
2762    /// Adds one uniquely named top-level network in output order.
2763    ///
2764    /// # Errors
2765    ///
2766    /// Returns [`GenerationError::DuplicateName`] for a duplicate network name.
2767    pub fn add_network(&mut self, network: GeneratedResource) -> Result<(), GenerationError> {
2768        insert_named(
2769            &mut self.networks,
2770            GeneratedNetwork::Basic(network),
2771            "network",
2772            GeneratedNetwork::name,
2773        )
2774    }
2775
2776    /// Adds one uniquely named top-level network definition in output order.
2777    ///
2778    /// This is additive to [`Self::add_network`], which retains the existing basic/external
2779    /// [`GeneratedResource`] API for compatibility.
2780    ///
2781    /// # Errors
2782    ///
2783    /// Returns [`GenerationError::DuplicateName`] for a duplicate network name across basic and
2784    /// driver-configured network definitions.
2785    pub fn add_network_definition(&mut self, network: GeneratedNetworkDefinition) -> Result<(), GenerationError> {
2786        insert_named(
2787            &mut self.networks,
2788            GeneratedNetwork::Definition(network),
2789            "network",
2790            GeneratedNetwork::name,
2791        )
2792    }
2793
2794    /// Adds one uniquely named top-level volume in output order.
2795    ///
2796    /// # Errors
2797    ///
2798    /// Returns [`GenerationError::DuplicateName`] for a duplicate volume name.
2799    pub fn add_volume(&mut self, volume: GeneratedResource) -> Result<(), GenerationError> {
2800        insert_named(
2801            &mut self.volumes,
2802            GeneratedVolume::Basic(volume),
2803            "volume",
2804            GeneratedVolume::name,
2805        )
2806    }
2807
2808    /// Adds one uniquely named top-level application-owned volume definition in output order.
2809    ///
2810    /// This is additive to [`Self::add_volume`], which retains the existing basic/external
2811    /// [`GeneratedResource`] API for compatibility. Driver-configured external volumes are not
2812    /// representable: use `GeneratedResource::external` for that lifecycle.
2813    ///
2814    /// # Errors
2815    ///
2816    /// Returns [`GenerationError::DuplicateName`] for a duplicate volume name across basic and
2817    /// driver-configured volume definitions.
2818    pub fn add_volume_definition(&mut self, volume: GeneratedVolumeDefinition) -> Result<(), GenerationError> {
2819        insert_named(
2820            &mut self.volumes,
2821            GeneratedVolume::Definition(volume),
2822            "volume",
2823            GeneratedVolume::name,
2824        )
2825    }
2826
2827    /// Generates YAML and parses it back through `ComposeLens`'s syntax and typed-model boundaries.
2828    ///
2829    /// # Errors
2830    ///
2831    /// Returns [`GenerationError::MissingService`] for an empty project or
2832    /// [`GenerationError::InternalInvariant`] if `ComposeLens` cannot parse its own output.
2833    pub fn build(self, source_id: SourceId) -> Result<GeneratedComposeDocument, GenerationError> {
2834        if self.services.is_empty() {
2835            return Err(GenerationError::MissingService);
2836        }
2837        let sensitive = self.services.iter().any(GeneratedService::is_sensitive)
2838            || self.networks.iter().any(GeneratedNetwork::is_sensitive)
2839            || self.volumes.iter().any(GeneratedVolume::is_sensitive);
2840        let text = render_document(&self);
2841        let syntax = SyntaxDocument::parse(source_id, text.clone())
2842            .map_err(|_| GenerationError::InternalInvariant("syntax-tree"))?;
2843        if !syntax.is_valid() {
2844            return Err(GenerationError::InternalInvariant("syntax"));
2845        }
2846        let model = ComposeDocument::parse(syntax.document());
2847        if !model.is_valid() {
2848            return Err(GenerationError::InternalInvariant("typed-model"));
2849        }
2850        let document = model
2851            .document()
2852            .cloned()
2853            .ok_or(GenerationError::InternalInvariant("document-root"))?;
2854        Ok(GeneratedComposeDocument {
2855            text,
2856            sensitive,
2857            document,
2858        })
2859    }
2860}
2861
2862/// Parse-back-validated deterministic generated Compose document.
2863#[derive(Clone, Eq, PartialEq)]
2864pub struct GeneratedComposeDocument {
2865    text: String,
2866    sensitive: bool,
2867    document: ComposeDocument,
2868}
2869
2870impl GeneratedComposeDocument {
2871    /// Returns the deployable generated YAML through an explicit access boundary.
2872    #[must_use]
2873    pub fn text(&self) -> &str {
2874        &self.text
2875    }
2876
2877    /// Returns the parse-back-validated native Compose model.
2878    #[must_use]
2879    pub const fn document(&self) -> &ComposeDocument {
2880        &self.document
2881    }
2882
2883    /// Reports whether generated output contains a caller-marked sensitive value.
2884    #[must_use]
2885    pub const fn is_sensitive(&self) -> bool {
2886        self.sensitive
2887    }
2888}
2889
2890impl fmt::Debug for GeneratedComposeDocument {
2891    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2892        formatter
2893            .debug_struct("GeneratedComposeDocument")
2894            .field("text", &if self.sensitive { "<redacted>" } else { &self.text })
2895            .field("sensitive", &self.sensitive)
2896            .field("document", &if self.sensitive { "<redacted>" } else { "validated" })
2897            .finish()
2898    }
2899}
2900
2901fn render_document(project: &ComposeDocumentBuilder) -> String {
2902    let mut output = String::new();
2903    if let Some(name) = &project.name {
2904        output.push_str("name: ");
2905        write_quoted(&mut output, name);
2906        output.push('\n');
2907    }
2908    output.push_str("services:\n");
2909    for service in &project.services {
2910        write_indent(&mut output, 1);
2911        write_quoted(&mut output, &service.name);
2912        output.push_str(":\n");
2913        render_service(&mut output, service);
2914    }
2915    render_network_definitions(&mut output, &project.networks);
2916    render_volume_definitions(&mut output, &project.volumes);
2917    output
2918}
2919
2920fn render_service(output: &mut String, service: &GeneratedService) {
2921    if let Some(GeneratedHostname::Resolved(hostname)) = &service.hostname {
2922        render_optional_string(output, "hostname", Some(hostname));
2923    }
2924    render_optional_string(output, "container_name", service.container_name.as_ref());
2925    render_optional_string(output, "image", service.image.as_ref());
2926    if let Some(entrypoint) = &service.entrypoint {
2927        render_entrypoint(output, entrypoint);
2928    }
2929    if let Some(command) = &service.command {
2930        render_command(output, command);
2931    }
2932    if let Some(init) = service.init {
2933        write_field(output, 2, "init");
2934        output.push_str(if init { "true\n" } else { "false\n" });
2935    }
2936    if let Some(stdin_open) = service.stdin_open {
2937        write_field(output, 2, "stdin_open");
2938        output.push_str(if stdin_open { "true\n" } else { "false\n" });
2939    }
2940    if let Some(tty) = service.tty {
2941        write_field(output, 2, "tty");
2942        output.push_str(if tty { "true\n" } else { "false\n" });
2943    }
2944    if let Some(privileged) = service.privileged {
2945        write_field(output, 2, "privileged");
2946        output.push_str(if privileged { "true\n" } else { "false\n" });
2947    }
2948    render_environment_files(output, &service.environment_files);
2949    render_environment(output, &service.environment);
2950    render_labels(output, &service.labels);
2951    if let Some(annotations) = &service.annotations {
2952        render_annotations(output, annotations);
2953    }
2954    render_optional_string(output, "user", service.user.as_ref());
2955    render_optional_string(output, "userns_mode", service.userns_mode.as_ref());
2956    render_string_sequence(output, "group_add", &service.group_add);
2957    if let Some(capabilities) = &service.cap_add {
2958        render_configured_string_sequence(output, "cap_add", capabilities);
2959    }
2960    if let Some(capabilities) = &service.cap_drop {
2961        render_configured_string_sequence(output, "cap_drop", capabilities);
2962    }
2963    render_optional_string(output, "working_dir", service.working_dir.as_ref());
2964    if let Some(read_only) = service.read_only {
2965        write_field(output, 2, "read_only");
2966        output.push_str(if read_only { "true\n" } else { "false\n" });
2967    }
2968    if let Some(pids_limit) = &service.pids_limit {
2969        render_pids_limit(output, pids_limit);
2970    }
2971    if let Some(shm_size) = &service.shm_size {
2972        render_shm_size(output, shm_size);
2973    }
2974    if let Some(mem_limit) = &service.mem_limit {
2975        render_mem_limit(output, mem_limit);
2976    }
2977    if let Some(devices) = &service.devices {
2978        render_devices(output, devices);
2979    }
2980    if let Some(dns) = &service.dns {
2981        render_dns(output, dns);
2982    }
2983    if let Some(options) = &service.dns_options {
2984        render_configured_string_sequence(output, "dns_opt", options);
2985    }
2986    if let Some(search) = &service.dns_search {
2987        render_dns_search(output, search);
2988    }
2989    if let Some(expose) = &service.expose {
2990        render_configured_string_sequence(output, "expose", expose);
2991    }
2992    if let Some(options) = &service.security_options {
2993        render_configured_string_sequence(output, "security_opt", options);
2994    }
2995    if let Some(tmpfs) = &service.tmpfs {
2996        render_tmpfs(output, tmpfs);
2997    }
2998    if let Some(sysctls) = &service.sysctls {
2999        render_sysctls(output, sysctls);
3000    }
3001    if let Some(logging) = &service.logging {
3002        render_logging(output, logging);
3003    }
3004    if let Some(ulimits) = &service.ulimits {
3005        render_ulimits(output, ulimits);
3006    }
3007    if let Some(pull_policy) = &service.pull_policy {
3008        render_pull_policy(output, pull_policy);
3009    }
3010    if let Some(restart) = service.restart {
3011        render_restart(output, restart);
3012    }
3013    render_optional_string(output, "stop_signal", service.stop_signal.as_ref());
3014    render_optional_string(output, "stop_grace_period", service.stop_grace_period.as_ref());
3015    render_extra_hosts(output, &service.extra_hosts);
3016    render_ports(output, &service.ports);
3017    render_mounts(output, &service.mounts);
3018    render_networks(output, &service.networks);
3019}
3020
3021fn render_pids_limit(output: &mut String, limit: &GeneratedPidsLimit) {
3022    write_field(output, 2, "pids_limit");
3023    match limit {
3024        GeneratedPidsLimit::Unlimited => output.push_str("-1\n"),
3025        GeneratedPidsLimit::Finite(decimal) => {
3026            output.push_str(decimal);
3027            output.push('\n');
3028        }
3029    }
3030}
3031
3032fn render_shm_size(output: &mut String, size: &GeneratedShmSize) {
3033    let GeneratedShmSize::Explicit { amount, unit } = size;
3034    write_field(output, 2, "shm_size");
3035    write_quoted(output, &format!("{}{}", amount.expose(), unit.as_str()));
3036    output.push('\n');
3037}
3038
3039fn render_mem_limit(output: &mut String, limit: &GeneratedMemLimit) {
3040    let GeneratedMemLimit::Explicit { amount, unit } = limit;
3041    write_field(output, 2, "mem_limit");
3042    write_quoted(output, &format!("{}{}", amount.expose(), unit.as_str()));
3043    output.push('\n');
3044}
3045
3046fn render_devices(output: &mut String, devices: &[GeneratedDevice]) {
3047    if devices.is_empty() {
3048        output.push_str("    devices: []\n");
3049        return;
3050    }
3051    output.push_str("    devices:\n");
3052    for device in devices {
3053        match device {
3054            GeneratedDevice::Short(value) => {
3055                output.push_str("      - ");
3056                write_quoted(output, value.expose());
3057                output.push('\n');
3058            }
3059            GeneratedDevice::Long(value) => {
3060                output.push_str("      - source: ");
3061                write_quoted(output, value.source().expose());
3062                output.push('\n');
3063                if let Some(target) = value.target() {
3064                    output.push_str("        target: ");
3065                    write_quoted(output, target.expose());
3066                    output.push('\n');
3067                }
3068                if let Some(permissions) = value.permissions() {
3069                    output.push_str("        permissions: ");
3070                    write_quoted(output, permissions.expose());
3071                    output.push('\n');
3072                }
3073            }
3074        }
3075    }
3076}
3077
3078fn render_dns(output: &mut String, dns: &GeneratedDns) {
3079    match dns {
3080        GeneratedDns::Scalar(value) => render_optional_string(output, "dns", Some(value)),
3081        GeneratedDns::List(values) => render_configured_string_sequence(output, "dns", values),
3082    }
3083}
3084
3085fn render_dns_search(output: &mut String, search: &GeneratedDnsSearch) {
3086    match search {
3087        GeneratedDnsSearch::Scalar(value) => render_optional_string(output, "dns_search", Some(value)),
3088        GeneratedDnsSearch::List(values) => render_configured_string_sequence(output, "dns_search", values),
3089    }
3090}
3091
3092fn render_tmpfs(output: &mut String, tmpfs: &GeneratedTmpfs) {
3093    match tmpfs {
3094        GeneratedTmpfs::Scalar(item) => render_optional_string(output, "tmpfs", Some(item)),
3095        GeneratedTmpfs::List(items) => render_configured_string_sequence(output, "tmpfs", items),
3096    }
3097}
3098
3099fn render_sysctls(output: &mut String, sysctls: &GeneratedSysctls) {
3100    match sysctls {
3101        GeneratedSysctls::Map(entries) if entries.is_empty() => output.push_str("    sysctls: {}\n"),
3102        GeneratedSysctls::Map(entries) => {
3103            output.push_str("    sysctls:\n");
3104            for entry in entries {
3105                write_indent(output, 3);
3106                write_quoted(output, entry.name());
3107                output.push_str(": ");
3108                write_quoted(output, entry.value().expose());
3109                output.push('\n');
3110            }
3111        }
3112        GeneratedSysctls::List(items) => render_configured_string_sequence(output, "sysctls", items),
3113    }
3114}
3115
3116fn render_logging(output: &mut String, logging: &GeneratedLogging) {
3117    output.push_str("    logging:\n      driver: ");
3118    write_quoted(output, logging.driver.expose());
3119    output.push('\n');
3120    if logging.options.is_empty() {
3121        output.push_str("      options: {}\n");
3122        return;
3123    }
3124    output.push_str("      options:\n");
3125    for option in &logging.options {
3126        write_indent(output, 4);
3127        write_quoted(output, option.name());
3128        output.push_str(": ");
3129        match option.value() {
3130            GeneratedLoggingOptionValue::String(value) => write_quoted(output, value.expose()),
3131            GeneratedLoggingOptionValue::Number(value) => output.push_str(value.expose()),
3132            GeneratedLoggingOptionValue::Null => output.push_str("null"),
3133        }
3134        output.push('\n');
3135    }
3136}
3137
3138fn render_ulimits(output: &mut String, ulimits: &GeneratedUlimits) {
3139    if ulimits.entries.is_empty() {
3140        output.push_str("    ulimits: {}\n");
3141        return;
3142    }
3143    output.push_str("    ulimits:\n");
3144    for limit in &ulimits.entries {
3145        write_indent(output, 3);
3146        write_quoted(output, limit.name());
3147        match limit.value() {
3148            GeneratedUlimitValue::Single(value) => {
3149                output.push_str(": ");
3150                write_quoted(output, value.expose());
3151                output.push('\n');
3152            }
3153            GeneratedUlimitValue::Range {
3154                soft: Some(soft),
3155                hard: Some(hard),
3156            } => {
3157                output.push_str(":\n");
3158                write_indent(output, 4);
3159                output.push_str("soft: ");
3160                write_quoted(output, soft.expose());
3161                output.push('\n');
3162                write_indent(output, 4);
3163                output.push_str("hard: ");
3164                write_quoted(output, hard.expose());
3165                output.push('\n');
3166            }
3167            GeneratedUlimitValue::Range { .. } => {
3168                unreachable!("generated ulimit ranges are validated during construction")
3169            }
3170        }
3171    }
3172}
3173
3174fn render_pull_policy(output: &mut String, policy: &GeneratedPullPolicy) {
3175    write_field(output, 2, "pull_policy");
3176    let value = match policy {
3177        GeneratedPullPolicy::Always => "always".to_owned(),
3178        GeneratedPullPolicy::Never => "never".to_owned(),
3179        GeneratedPullPolicy::Missing => "missing".to_owned(),
3180        GeneratedPullPolicy::IfNotPresentAlias => "if_not_present".to_owned(),
3181        GeneratedPullPolicy::Build => "build".to_owned(),
3182        GeneratedPullPolicy::Daily => "daily".to_owned(),
3183        GeneratedPullPolicy::Weekly => "weekly".to_owned(),
3184        GeneratedPullPolicy::Every(duration) => format!("every_{}", duration.expose()),
3185    };
3186    write_quoted(output, &value);
3187    output.push('\n');
3188}
3189
3190fn render_entrypoint(output: &mut String, entrypoint: &GeneratedEntrypoint) {
3191    match entrypoint {
3192        GeneratedEntrypoint::List(arguments) if arguments.is_empty() => output.push_str("    entrypoint: []\n"),
3193        GeneratedEntrypoint::List(arguments) => render_string_sequence(output, "entrypoint", arguments),
3194        GeneratedEntrypoint::String(entrypoint) => render_optional_string(output, "entrypoint", Some(entrypoint)),
3195        GeneratedEntrypoint::Empty => output.push_str("    entrypoint: []\n"),
3196    }
3197}
3198
3199fn render_restart(output: &mut String, restart: GeneratedRestartPolicy) {
3200    write_field(output, 2, "restart");
3201    let value = match restart {
3202        GeneratedRestartPolicy::No => "no".to_owned(),
3203        GeneratedRestartPolicy::Always => "always".to_owned(),
3204        GeneratedRestartPolicy::OnFailure { maximum_retries: None } => "on-failure".to_owned(),
3205        GeneratedRestartPolicy::OnFailure {
3206            maximum_retries: Some(maximum_retries),
3207        } => format!("on-failure:{maximum_retries}"),
3208        GeneratedRestartPolicy::UnlessStopped => "unless-stopped".to_owned(),
3209    };
3210    write_quoted(output, &value);
3211    output.push('\n');
3212}
3213
3214fn render_optional_string(output: &mut String, key: &str, value: Option<&GeneratedString>) {
3215    if let Some(value) = value {
3216        write_field(output, 2, key);
3217        write_quoted(output, value.expose());
3218        output.push('\n');
3219    }
3220}
3221
3222fn render_command(output: &mut String, command: &GeneratedCommand) {
3223    match command {
3224        GeneratedCommand::Exec(arguments) if arguments.is_empty() => output.push_str("    command: []\n"),
3225        GeneratedCommand::Exec(arguments) => render_string_sequence(output, "command", arguments),
3226        GeneratedCommand::Shell(command) => render_optional_string(output, "command", Some(command)),
3227        GeneratedCommand::Empty => output.push_str("    command: []\n"),
3228    }
3229}
3230
3231fn render_environment(output: &mut String, environment: &[GeneratedEnvironment]) {
3232    if environment.is_empty() {
3233        return;
3234    }
3235    output.push_str("    environment:\n");
3236    for variable in environment {
3237        output.push_str("      - ");
3238        let value = variable.value.as_ref().map_or_else(
3239            || variable.name.clone(),
3240            |value| format!("{}={}", variable.name, value.expose()),
3241        );
3242        write_quoted(output, &value);
3243        output.push('\n');
3244    }
3245}
3246
3247fn render_environment_files(output: &mut String, environment_files: &[GeneratedEnvironmentFile]) {
3248    if environment_files.is_empty() {
3249        return;
3250    }
3251    output.push_str("    env_file:\n");
3252    for environment_file in environment_files {
3253        match environment_file {
3254            GeneratedEnvironmentFile::Short(path) => {
3255                output.push_str("      - ");
3256                write_quoted(output, path.expose());
3257                output.push('\n');
3258            }
3259            GeneratedEnvironmentFile::Long { path, required, format } => {
3260                output.push_str("      - path: ");
3261                write_quoted(output, path.expose());
3262                output.push('\n');
3263                if let Some(required) = required {
3264                    output.push_str("        required: ");
3265                    output.push_str(if *required { "true\n" } else { "false\n" });
3266                }
3267                if let Some(format) = format {
3268                    output.push_str("        format: ");
3269                    write_quoted(
3270                        output,
3271                        match format {
3272                            GeneratedEnvironmentFileFormat::Raw => "raw",
3273                        },
3274                    );
3275                    output.push('\n');
3276                }
3277            }
3278        }
3279    }
3280}
3281
3282fn render_labels(output: &mut String, labels: &[GeneratedLabel]) {
3283    if labels.is_empty() {
3284        return;
3285    }
3286    output.push_str("    labels:\n");
3287    for label in labels {
3288        output.push_str("      ");
3289        write_quoted(output, &label.name);
3290        output.push_str(": ");
3291        write_quoted(output, label.value.expose());
3292        output.push('\n');
3293    }
3294}
3295
3296fn render_annotations(output: &mut String, annotations: &[GeneratedAnnotation]) {
3297    if annotations.is_empty() {
3298        output.push_str("    annotations: {}\n");
3299        return;
3300    }
3301    output.push_str("    annotations:\n");
3302    for annotation in annotations {
3303        output.push_str("      ");
3304        write_quoted(output, &annotation.name);
3305        output.push_str(": ");
3306        write_quoted(output, annotation.value.expose());
3307        output.push('\n');
3308    }
3309}
3310
3311fn render_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
3312    if values.is_empty() {
3313        return;
3314    }
3315    write_indent(output, 2);
3316    output.push_str(key);
3317    output.push_str(":\n");
3318    for value in values {
3319        output.push_str("      - ");
3320        write_quoted(output, value.expose());
3321        output.push('\n');
3322    }
3323}
3324
3325fn render_configured_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
3326    if values.is_empty() {
3327        write_indent(output, 2);
3328        output.push_str(key);
3329        output.push_str(": []\n");
3330    } else {
3331        render_string_sequence(output, key, values);
3332    }
3333}
3334
3335fn render_extra_hosts(output: &mut String, hosts: &[GeneratedExtraHost]) {
3336    if hosts.is_empty() {
3337        return;
3338    }
3339    output.push_str("    extra_hosts:\n");
3340    for host in hosts {
3341        output.push_str("      - ");
3342        write_quoted(output, &format!("{}={}", host.hostname, host.address));
3343        output.push('\n');
3344    }
3345}
3346
3347fn render_ports(output: &mut String, ports: &[GeneratedPort]) {
3348    if ports.is_empty() {
3349        return;
3350    }
3351    output.push_str("    ports:\n");
3352    for port in ports {
3353        if port.protocol == GeneratedProtocol::Sctp {
3354            render_short_sctp_port(output, port);
3355            continue;
3356        }
3357        output.push_str("      - target: ");
3358        output.push_str(&port.target.to_string());
3359        output.push('\n');
3360        if let Some(published) = port.published {
3361            output.push_str("        published: ");
3362            write_quoted(output, &published.to_string());
3363            output.push('\n');
3364        }
3365        if let Some(host_ip) = &port.host_ip {
3366            output.push_str("        host_ip: ");
3367            write_quoted(output, host_ip);
3368            output.push('\n');
3369        }
3370        output.push_str("        protocol: ");
3371        write_quoted(output, port.protocol.as_str());
3372        output.push('\n');
3373    }
3374}
3375
3376fn render_short_sctp_port(output: &mut String, port: &GeneratedPort) {
3377    let mut value = String::new();
3378    if let Some(host_ip) = &port.host_ip {
3379        if host_ip.contains(':') && !(host_ip.starts_with('[') && host_ip.ends_with(']')) {
3380            value.push('[');
3381            value.push_str(host_ip);
3382            value.push(']');
3383        } else {
3384            value.push_str(host_ip);
3385        }
3386        value.push(':');
3387    }
3388    if let Some(published) = port.published {
3389        value.push_str(&published.to_string());
3390        value.push(':');
3391    }
3392    value.push_str(&port.target.to_string());
3393    value.push_str("/sctp");
3394
3395    output.push_str("      - ");
3396    write_quoted(output, &value);
3397    output.push('\n');
3398}
3399
3400fn render_mounts(output: &mut String, mounts: &[GeneratedMount]) {
3401    if mounts.is_empty() {
3402        return;
3403    }
3404    output.push_str("    volumes:\n");
3405    for mount in mounts {
3406        match &mount.kind {
3407            GeneratedMountKind::Bind {
3408                source,
3409                selinux: Some(selinux),
3410            } => render_selinux_bind(output, source, mount, *selinux),
3411            kind => render_long_mount(output, kind, mount),
3412        }
3413    }
3414}
3415
3416fn render_selinux_bind(output: &mut String, source: &str, mount: &GeneratedMount, selinux: GeneratedSelinux) {
3417    let mut value = format!("{source}:{}:{}", mount.target, selinux.as_str());
3418    if mount.read_only {
3419        value.push_str(",ro");
3420    }
3421    output.push_str("      - ");
3422    write_quoted(output, &value);
3423    output.push('\n');
3424}
3425
3426fn render_long_mount(output: &mut String, kind: &GeneratedMountKind, mount: &GeneratedMount) {
3427    let (mount_type, source) = match kind {
3428        GeneratedMountKind::Volume { source } => ("volume", Some(source.as_str())),
3429        GeneratedMountKind::Bind { source, selinux: None } => ("bind", Some(source.as_str())),
3430        GeneratedMountKind::Anonymous => ("volume", None),
3431        GeneratedMountKind::Bind { selinux: Some(_), .. } => return,
3432    };
3433    output.push_str("      - type: ");
3434    write_quoted(output, mount_type);
3435    output.push('\n');
3436    if let Some(source) = source {
3437        output.push_str("        source: ");
3438        write_quoted(output, source);
3439        output.push('\n');
3440    }
3441    output.push_str("        target: ");
3442    write_quoted(output, &mount.target);
3443    output.push('\n');
3444    if mount.read_only {
3445        output.push_str("        read_only: true\n");
3446    }
3447}
3448
3449fn render_networks(output: &mut String, networks: &[GeneratedNetworkAttachment]) {
3450    if networks.is_empty() {
3451        return;
3452    }
3453    output.push_str("    networks:\n");
3454    for network in networks {
3455        output.push_str("      ");
3456        write_quoted(output, &network.name);
3457        if network.aliases.is_empty() && network.ipv4_address.is_none() && network.ipv6_address.is_none() {
3458            output.push_str(": {}\n");
3459            continue;
3460        }
3461        output.push_str(":\n");
3462        if !network.aliases.is_empty() {
3463            output.push_str("        aliases:\n");
3464            for alias in &network.aliases {
3465                output.push_str("          - ");
3466                write_quoted(output, alias);
3467                output.push('\n');
3468            }
3469        }
3470        for (field, address) in [
3471            ("ipv4_address", network.ipv4_address.as_ref()),
3472            ("ipv6_address", network.ipv6_address.as_ref()),
3473        ] {
3474            if let Some(address) = address {
3475                output.push_str("        ");
3476                output.push_str(field);
3477                output.push_str(": ");
3478                write_quoted(output, address.expose());
3479                output.push('\n');
3480            }
3481        }
3482    }
3483}
3484
3485fn render_network_definitions(output: &mut String, networks: &[GeneratedNetwork]) {
3486    if networks.is_empty() {
3487        return;
3488    }
3489    output.push_str("networks:\n");
3490    for network in networks {
3491        match network {
3492            GeneratedNetwork::Basic(network) => render_basic_resource(output, network),
3493            GeneratedNetwork::Definition(network) => render_network_definition(output, network),
3494        }
3495    }
3496}
3497
3498fn render_network_definition(output: &mut String, network: &GeneratedNetworkDefinition) {
3499    output.push_str("  ");
3500    write_quoted(output, &network.name);
3501    if network.custom_name.is_none()
3502        && network.driver.is_none()
3503        && network.driver_opts.is_none()
3504        && network.enable_ipv6.is_none()
3505        && network.internal.is_none()
3506        && network.labels.is_none()
3507    {
3508        output.push_str(": {}\n");
3509        return;
3510    }
3511    output.push_str(":\n");
3512    if let Some(custom_name) = &network.custom_name {
3513        output.push_str("    name: ");
3514        write_quoted(output, custom_name);
3515        output.push('\n');
3516    }
3517    if let Some(driver) = &network.driver {
3518        output.push_str("    driver: ");
3519        write_quoted(output, driver.expose());
3520        output.push('\n');
3521    }
3522    if let Some(driver_opts) = &network.driver_opts {
3523        if driver_opts.is_empty() {
3524            output.push_str("    driver_opts: {}\n");
3525        } else {
3526            output.push_str("    driver_opts:\n");
3527            for option in driver_opts {
3528                output.push_str("      ");
3529                write_quoted(output, option.name());
3530                output.push_str(": ");
3531                match option.value() {
3532                    GeneratedNetworkDriverOptionValue::String(value) => {
3533                        write_quoted(output, value.expose());
3534                    }
3535                    GeneratedNetworkDriverOptionValue::Number(value) => {
3536                        output.push_str(value.expose());
3537                    }
3538                }
3539                output.push('\n');
3540            }
3541        }
3542    }
3543    if let Some(enable_ipv6) = network.enable_ipv6 {
3544        output.push_str("    enable_ipv6: ");
3545        output.push_str(if enable_ipv6 { "true\n" } else { "false\n" });
3546    }
3547    if let Some(internal) = network.internal {
3548        output.push_str("    internal: ");
3549        output.push_str(if internal { "true\n" } else { "false\n" });
3550    }
3551    if let Some(labels) = &network.labels {
3552        if labels.is_empty() {
3553            output.push_str("    labels: {}\n");
3554        } else {
3555            output.push_str("    labels:\n");
3556            for label in labels {
3557                output.push_str("      ");
3558                write_quoted(output, label.name());
3559                output.push_str(": ");
3560                write_quoted(output, label.value().expose());
3561                output.push('\n');
3562            }
3563        }
3564    }
3565}
3566
3567fn render_volume_definitions(output: &mut String, volumes: &[GeneratedVolume]) {
3568    if volumes.is_empty() {
3569        return;
3570    }
3571    output.push_str("volumes:\n");
3572    for volume in volumes {
3573        match volume {
3574            GeneratedVolume::Basic(volume) => render_basic_resource(output, volume),
3575            GeneratedVolume::Definition(volume) => render_volume_definition(output, volume),
3576        }
3577    }
3578}
3579
3580fn render_volume_definition(output: &mut String, volume: &GeneratedVolumeDefinition) {
3581    output.push_str("  ");
3582    write_quoted(output, &volume.name);
3583    if volume.custom_name.is_none()
3584        && volume.driver.is_none()
3585        && volume.driver_opts.is_none()
3586        && volume.labels.is_none()
3587    {
3588        output.push_str(": {}\n");
3589        return;
3590    }
3591    output.push_str(":\n");
3592    if let Some(custom_name) = &volume.custom_name {
3593        output.push_str("    name: ");
3594        write_quoted(output, custom_name);
3595        output.push('\n');
3596    }
3597    if let Some(driver) = &volume.driver {
3598        output.push_str("    driver: ");
3599        write_quoted(output, driver.expose());
3600        output.push('\n');
3601    }
3602    if let Some(driver_opts) = &volume.driver_opts {
3603        if driver_opts.is_empty() {
3604            output.push_str("    driver_opts: {}\n");
3605        } else {
3606            output.push_str("    driver_opts:\n");
3607            for option in driver_opts {
3608                output.push_str("      ");
3609                write_quoted(output, option.name());
3610                output.push_str(": ");
3611                match option.value() {
3612                    GeneratedVolumeDriverOptionValue::String(value) => write_quoted(output, value.expose()),
3613                    GeneratedVolumeDriverOptionValue::Number(value) => output.push_str(value.expose()),
3614                }
3615                output.push('\n');
3616            }
3617        }
3618    }
3619    if let Some(labels) = &volume.labels {
3620        if labels.is_empty() {
3621            output.push_str("    labels: {}\n");
3622        } else {
3623            output.push_str("    labels:\n");
3624            for label in labels {
3625                output.push_str("      ");
3626                write_quoted(output, label.name());
3627                output.push_str(": ");
3628                write_quoted(output, label.value().expose());
3629                output.push('\n');
3630            }
3631        }
3632    }
3633}
3634
3635fn render_basic_resource(output: &mut String, resource: &GeneratedResource) {
3636    output.push_str("  ");
3637    write_quoted(output, &resource.name);
3638    if !resource.external && resource.custom_name.is_none() {
3639        output.push_str(": {}\n");
3640        return;
3641    }
3642    output.push_str(":\n");
3643    if let Some(custom_name) = &resource.custom_name {
3644        output.push_str("    name: ");
3645        write_quoted(output, custom_name);
3646        output.push('\n');
3647    }
3648    if resource.external {
3649        output.push_str("    external: true\n");
3650    }
3651}
3652
3653fn write_field(output: &mut String, depth: usize, key: &str) {
3654    write_indent(output, depth);
3655    output.push_str(key);
3656    output.push_str(": ");
3657}
3658
3659fn write_indent(output: &mut String, depth: usize) {
3660    for _ in 0..depth {
3661        output.push_str("  ");
3662    }
3663}
3664
3665fn required(kind: &'static str, value: String) -> Result<String, GenerationError> {
3666    if value.is_empty() {
3667        return Err(GenerationError::EmptyValue(kind));
3668    }
3669    if value.contains('\0') {
3670        return Err(GenerationError::ContainsNul(kind));
3671    }
3672    Ok(value)
3673}
3674
3675fn require_generated_string(kind: &'static str, value: &GeneratedString) -> Result<(), GenerationError> {
3676    if value.expose().is_empty() {
3677        return Err(GenerationError::EmptyValue(kind));
3678    }
3679    Ok(())
3680}
3681
3682fn validate_generated_device_member(
3683    member: &'static str,
3684    value: &GeneratedString,
3685    require_non_empty: bool,
3686) -> Result<(), GenerationError> {
3687    if valid_generated_device_string(value.expose(), require_non_empty) {
3688        Ok(())
3689    } else {
3690        Err(GenerationError::InvalidDeviceValue(member))
3691    }
3692}
3693
3694fn validate_generated_ulimit_value(value: &GeneratedString) -> Result<(), GenerationError> {
3695    let value = value.expose();
3696    if value.contains(['\r', '\n', '$'])
3697        || (value != "-1" && (value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit())))
3698    {
3699        return Err(GenerationError::InvalidUlimitValue);
3700    }
3701    Ok(())
3702}
3703
3704fn valid_yaml_number(value: &str) -> bool {
3705    let ordinary = !value.is_empty()
3706        && value.bytes().any(|byte| byte.is_ascii_digit())
3707        && value.bytes().all(|byte| {
3708            byte.is_ascii_digit()
3709                || matches!(
3710                    byte,
3711                    b'+' | b'-'
3712                        | b'.'
3713                        | b'_'
3714                        | b'e'
3715                        | b'E'
3716                        | b'x'
3717                        | b'X'
3718                        | b'o'
3719                        | b'O'
3720                        | b'a'..=b'f'
3721                        | b'A'..=b'F'
3722                )
3723        });
3724    let special = matches!(
3725        value,
3726        ".inf" | ".Inf" | ".INF" | "+.inf" | "+.Inf" | "+.INF" | "-.inf" | "-.Inf" | "-.INF" | ".nan" | ".NaN" | ".NAN"
3727    );
3728    if !ordinary && !special {
3729        return false;
3730    }
3731    let parse = YamlFile::parse(value);
3732    if !parse.ok() {
3733        return false;
3734    }
3735    let file = parse.tree();
3736    let Some(document) = file.document() else {
3737        return false;
3738    };
3739    let Some(scalar) = document.as_scalar() else {
3740        return false;
3741    };
3742    let position = scalar.byte_range();
3743    position.start == 0
3744        && position.end as usize == value.len()
3745        && matches!(
3746            ScalarValue::from_scalar(&scalar).scalar_type(),
3747            ScalarType::Integer | ScalarType::Float
3748        )
3749}
3750
3751fn environment_name(value: String) -> Result<String, GenerationError> {
3752    let value = required("environment name", value)?;
3753    if value.contains('=') {
3754        return Err(GenerationError::InvalidEnvironmentName);
3755    }
3756    Ok(value)
3757}
3758
3759fn valid_container_name(value: &str) -> bool {
3760    let mut bytes = value.bytes();
3761    bytes.next().is_some_and(|byte| byte.is_ascii_alphanumeric())
3762        && bytes
3763            .next()
3764            .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
3765        && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
3766}
3767
3768fn short_component(kind: &'static str, value: String, separator: char) -> Result<String, GenerationError> {
3769    let value = required(kind, value)?;
3770    if value.contains(separator) {
3771        return Err(GenerationError::InvalidShortComponent(kind));
3772    }
3773    Ok(value)
3774}
3775
3776fn set_once<T>(slot: &mut Option<T>, value: T, field: &'static str) -> Result<(), GenerationError> {
3777    if slot.is_some() {
3778        return Err(GenerationError::DuplicateField(field));
3779    }
3780    *slot = Some(value);
3781    Ok(())
3782}
3783
3784fn insert_named<T>(
3785    values: &mut Vec<T>,
3786    value: T,
3787    kind: &'static str,
3788    name: impl Fn(&T) -> &str,
3789) -> Result<(), GenerationError> {
3790    let value_name = name(&value);
3791    if values.iter().any(|candidate| name(candidate) == value_name) {
3792        return Err(GenerationError::DuplicateName {
3793            kind,
3794            name: value_name.to_owned(),
3795        });
3796    }
3797    values.push(value);
3798    Ok(())
3799}
3800
3801fn command_is_sensitive(command: &GeneratedCommand) -> bool {
3802    match command {
3803        GeneratedCommand::Exec(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
3804        GeneratedCommand::Shell(command) => command.is_sensitive(),
3805        GeneratedCommand::Empty => false,
3806    }
3807}
3808
3809fn entrypoint_is_sensitive(entrypoint: &GeneratedEntrypoint) -> bool {
3810    match entrypoint {
3811        GeneratedEntrypoint::List(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
3812        GeneratedEntrypoint::String(entrypoint) => entrypoint.is_sensitive(),
3813        GeneratedEntrypoint::Empty => false,
3814    }
3815}