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