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, Eq, PartialEq)]
1638pub struct GeneratedNetworkAttachment {
1639    name: String,
1640    aliases: Vec<String>,
1641    alias_sensitivities: Vec<bool>,
1642    ipv4_address: Option<GeneratedString>,
1643    ipv6_address: Option<GeneratedString>,
1644}
1645
1646impl GeneratedNetworkAttachment {
1647    /// Creates an attachment without aliases or per-network addresses.
1648    ///
1649    /// # Errors
1650    ///
1651    /// Rejects an empty or NUL-bearing network name.
1652    pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
1653        Ok(Self {
1654            name: required("network name", name.into())?,
1655            aliases: Vec::new(),
1656            alias_sensitivities: Vec::new(),
1657            ipv4_address: None,
1658            ipv6_address: None,
1659        })
1660    }
1661
1662    /// Adds one ordered alias.
1663    ///
1664    /// # Errors
1665    ///
1666    /// Rejects an empty or NUL-bearing alias.
1667    pub fn add_alias(&mut self, alias: impl Into<String>) -> Result<(), GenerationError> {
1668        self.add_alias_with_sensitivity(alias.into(), false)
1669    }
1670
1671    /// Adds one ordered alias through the generated-string sensitivity boundary.
1672    ///
1673    /// # Errors
1674    ///
1675    /// Rejects an empty alias. NUL-bearing values are rejected while constructing
1676    /// [`GeneratedString`].
1677    pub fn add_alias_value(&mut self, alias: &GeneratedString) -> Result<(), GenerationError> {
1678        let sensitive = alias.is_sensitive();
1679        self.add_alias_with_sensitivity(alias.expose().to_owned(), sensitive)
1680    }
1681
1682    fn add_alias_with_sensitivity(&mut self, alias: String, sensitive: bool) -> Result<(), GenerationError> {
1683        self.aliases.push(required("network alias", alias)?);
1684        self.alias_sensitivities.push(sensitive);
1685        Ok(())
1686    }
1687
1688    /// Sets one raw per-attachment IPv4 address exactly once.
1689    ///
1690    /// No IP grammar, top-level IPAM pool, provider, or runtime validation is applied.
1691    ///
1692    /// # Errors
1693    ///
1694    /// Returns [`GenerationError::DuplicateField`] when already configured.
1695    pub fn set_ipv4_address(&mut self, address: GeneratedString) -> Result<(), GenerationError> {
1696        set_once(&mut self.ipv4_address, address, "ipv4_address")
1697    }
1698
1699    /// Sets one raw per-attachment IPv6 address exactly once.
1700    ///
1701    /// No IP grammar, top-level IPAM pool, provider, or runtime validation is applied.
1702    ///
1703    /// # Errors
1704    ///
1705    /// Returns [`GenerationError::DuplicateField`] when already configured.
1706    pub fn set_ipv6_address(&mut self, address: GeneratedString) -> Result<(), GenerationError> {
1707        set_once(&mut self.ipv6_address, address, "ipv6_address")
1708    }
1709
1710    /// Returns the network name.
1711    #[must_use]
1712    pub fn name(&self) -> &str {
1713        &self.name
1714    }
1715
1716    /// Returns aliases in insertion order.
1717    #[must_use]
1718    pub fn aliases(&self) -> &[String] {
1719        &self.aliases
1720    }
1721
1722    /// Returns per-alias sensitivity flags in the same order as [`Self::aliases`].
1723    #[must_use]
1724    pub fn alias_sensitivities(&self) -> &[bool] {
1725        &self.alias_sensitivities
1726    }
1727
1728    /// Returns the optional raw per-attachment IPv4 address.
1729    #[must_use]
1730    pub const fn ipv4_address(&self) -> Option<&GeneratedString> {
1731        self.ipv4_address.as_ref()
1732    }
1733
1734    /// Returns the optional raw per-attachment IPv6 address.
1735    #[must_use]
1736    pub const fn ipv6_address(&self) -> Option<&GeneratedString> {
1737        self.ipv6_address.as_ref()
1738    }
1739
1740    fn is_sensitive(&self) -> bool {
1741        self.alias_sensitivities.iter().copied().any(std::convert::identity)
1742            || self.ipv4_address.as_ref().is_some_and(GeneratedString::is_sensitive)
1743            || self.ipv6_address.as_ref().is_some_and(GeneratedString::is_sensitive)
1744    }
1745}
1746
1747impl fmt::Debug for GeneratedNetworkAttachment {
1748    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1749        let aliases = self
1750            .aliases
1751            .iter()
1752            .enumerate()
1753            .map(|(index, alias)| {
1754                if self.alias_sensitivities.get(index).copied().unwrap_or(false) {
1755                    "<redacted>"
1756                } else {
1757                    alias.as_str()
1758                }
1759            })
1760            .collect::<Vec<_>>();
1761        formatter
1762            .debug_struct("GeneratedNetworkAttachment")
1763            .field("name", &self.name)
1764            .field("aliases", &aliases)
1765            .field("ipv4_address", &self.ipv4_address)
1766            .field("ipv6_address", &self.ipv6_address)
1767            .finish()
1768    }
1769}
1770
1771/// One top-level network or volume lifecycle definition.
1772#[derive(Clone, Debug, Eq, PartialEq)]
1773pub struct GeneratedResource {
1774    name: String,
1775    external: bool,
1776    custom_name: Option<String>,
1777}
1778
1779impl GeneratedResource {
1780    /// Creates an application-owned resource definition.
1781    ///
1782    /// # Errors
1783    ///
1784    /// Rejects an empty or NUL-bearing name.
1785    pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
1786        Ok(Self {
1787            name: required("resource name", name.into())?,
1788            external: false,
1789            custom_name: None,
1790        })
1791    }
1792
1793    /// Creates an externally managed resource definition.
1794    ///
1795    /// # Errors
1796    ///
1797    /// Rejects an empty or NUL-bearing name.
1798    pub fn external(name: impl Into<String>) -> Result<Self, GenerationError> {
1799        Ok(Self {
1800            name: required("resource name", name.into())?,
1801            external: true,
1802            custom_name: None,
1803        })
1804    }
1805
1806    /// Sets the exact platform-level resource name once.
1807    ///
1808    /// This prevents Compose project scoping from changing a reviewed runtime resource name.
1809    ///
1810    /// # Errors
1811    ///
1812    /// Rejects an empty/NUL-bearing name and duplicate configuration.
1813    pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
1814        let name = required("custom resource name", name.into())?;
1815        set_once(&mut self.custom_name, name, "resource name")
1816    }
1817
1818    /// Returns the resource name.
1819    #[must_use]
1820    pub fn name(&self) -> &str {
1821        &self.name
1822    }
1823
1824    /// Reports whether Compose should reuse an external resource.
1825    #[must_use]
1826    pub const fn is_external(&self) -> bool {
1827        self.external
1828    }
1829
1830    /// Returns the optional exact platform-level resource name.
1831    #[must_use]
1832    pub fn custom_name(&self) -> Option<&str> {
1833        self.custom_name.as_deref()
1834    }
1835}
1836
1837/// A generated `cpu_rt_runtime` spelling with an explicit YAML scalar category.
1838#[derive(Clone, Debug, Eq, PartialEq)]
1839#[non_exhaustive]
1840pub enum GeneratedCpuRtRuntime {
1841    /// An unquoted integer microsecond scalar.
1842    Microseconds(GeneratedString),
1843    /// A Compose duration string.
1844    Duration(GeneratedString),
1845}
1846
1847impl GeneratedCpuRtRuntime {
1848    fn is_sensitive(&self) -> bool {
1849        match self {
1850            Self::Microseconds(value) | Self::Duration(value) => value.is_sensitive(),
1851        }
1852    }
1853}
1854
1855/// Raw service resource and namespace fields selected for deterministic generated output.
1856/// String-bearing variants use minimal safe quoting so caller-selected spelling remains a YAML string;
1857/// `cpu_rt_runtime` explicitly selects either an integer microsecond scalar or a duration string.
1858#[derive(Clone, Debug, Eq, PartialEq)]
1859#[non_exhaustive]
1860pub enum GeneratedServiceRuntimeField {
1861    /// Raw resolved service domain name.
1862    Domainname(GeneratedString),
1863    /// Raw resolved service isolation spelling.
1864    Isolation(GeneratedString),
1865    /// Raw resolved service MAC-address spelling.
1866    MacAddress(GeneratedString),
1867    /// Raw resolved service UTS spelling.
1868    Uts(GeneratedString),
1869    /// Literal API-socket mount choice.
1870    UseApiSocket(bool),
1871    /// Safe scalar GPU selector.
1872    GpusAll(GeneratedString),
1873    /// `cpu_rt_runtime` with an explicit integer or duration scalar category.
1874    CpuRtRuntime(GeneratedCpuRtRuntime),
1875    /// `cpu_shares` raw integer spelling.
1876    CpuShares(GeneratedString),
1877    /// `cpus` raw decimal spelling.
1878    Cpus(GeneratedString),
1879    /// `cpuset` raw string spelling.
1880    Cpuset(GeneratedString),
1881    /// Ordered raw `device_cgroup_rules` strings.
1882    DeviceCgroupRules(Vec<GeneratedString>),
1883    /// `ipc` raw mode spelling.
1884    Ipc(GeneratedString),
1885    /// `mem_reservation` raw byte-value spelling.
1886    MemReservation(GeneratedString),
1887    /// `mem_swappiness` raw integer spelling.
1888    MemSwappiness(GeneratedString),
1889    /// `memswap_limit` raw unlimited, zero, or positive byte-quantity spelling.
1890    MemswapLimit(GeneratedString),
1891    /// `network_mode` raw mode spelling.
1892    NetworkMode(GeneratedString),
1893    /// Literal `oom_kill_disable` choice.
1894    OomKillDisable(bool),
1895    /// `oom_score_adj` raw integer spelling.
1896    OomScoreAdj(GeneratedString),
1897    /// `pid` raw mode spelling.
1898    Pid(GeneratedString),
1899    /// `scale` raw integer spelling.
1900    Scale(GeneratedString),
1901    /// Ordered raw `volumes_from` strings.
1902    VolumesFrom(Vec<GeneratedString>),
1903}
1904
1905impl GeneratedServiceRuntimeField {
1906    fn field_name(&self) -> &'static str {
1907        match self {
1908            Self::Domainname(_) => "domainname",
1909            Self::Isolation(_) => "isolation",
1910            Self::MacAddress(_) => "mac_address",
1911            Self::Uts(_) => "uts",
1912            Self::UseApiSocket(_) => "use_api_socket",
1913            Self::GpusAll(_) => "gpus",
1914            Self::CpuRtRuntime(_) => "cpu_rt_runtime",
1915            Self::CpuShares(_) => "cpu_shares",
1916            Self::Cpus(_) => "cpus",
1917            Self::Cpuset(_) => "cpuset",
1918            Self::DeviceCgroupRules(_) => "device_cgroup_rules",
1919            Self::Ipc(_) => "ipc",
1920            Self::MemReservation(_) => "mem_reservation",
1921            Self::MemSwappiness(_) => "mem_swappiness",
1922            Self::MemswapLimit(_) => "memswap_limit",
1923            Self::NetworkMode(_) => "network_mode",
1924            Self::OomKillDisable(_) => "oom_kill_disable",
1925            Self::OomScoreAdj(_) => "oom_score_adj",
1926            Self::Pid(_) => "pid",
1927            Self::Scale(_) => "scale",
1928            Self::VolumesFrom(_) => "volumes_from",
1929        }
1930    }
1931
1932    fn is_sensitive(&self) -> bool {
1933        match self {
1934            Self::Domainname(value)
1935            | Self::Isolation(value)
1936            | Self::MacAddress(value)
1937            | Self::Uts(value)
1938            | Self::GpusAll(value)
1939            | Self::CpuShares(value)
1940            | Self::Cpus(value)
1941            | Self::Cpuset(value)
1942            | Self::Ipc(value)
1943            | Self::MemReservation(value)
1944            | Self::MemSwappiness(value)
1945            | Self::MemswapLimit(value)
1946            | Self::NetworkMode(value)
1947            | Self::OomScoreAdj(value)
1948            | Self::Pid(value)
1949            | Self::Scale(value) => value.is_sensitive(),
1950            Self::DeviceCgroupRules(values) | Self::VolumesFrom(values) => {
1951                values.iter().any(GeneratedString::is_sensitive)
1952            }
1953            Self::UseApiSocket(_) | Self::OomKillDisable(_) => false,
1954            Self::CpuRtRuntime(value) => value.is_sensitive(),
1955        }
1956    }
1957}
1958
1959/// A typed generated Compose service definition.
1960#[derive(Clone, Debug, Eq, PartialEq)]
1961pub struct GeneratedService {
1962    name: String,
1963    hostname: Option<GeneratedHostname>,
1964    container_name: Option<GeneratedString>,
1965    image: Option<GeneratedString>,
1966    entrypoint: Option<GeneratedEntrypoint>,
1967    command: Option<GeneratedCommand>,
1968    init: Option<bool>,
1969    stdin_open: Option<bool>,
1970    tty: Option<bool>,
1971    privileged: Option<bool>,
1972    environment_files: Vec<GeneratedEnvironmentFile>,
1973    environment: Vec<GeneratedEnvironment>,
1974    labels: Vec<GeneratedLabel>,
1975    annotations: Option<Vec<GeneratedAnnotation>>,
1976    user: Option<GeneratedString>,
1977    userns_mode: Option<GeneratedString>,
1978    group_add: Vec<GeneratedString>,
1979    cap_add: Option<Vec<GeneratedString>>,
1980    cap_drop: Option<Vec<GeneratedString>>,
1981    devices: Option<Vec<GeneratedDevice>>,
1982    dns: Option<GeneratedDns>,
1983    dns_options: Option<Vec<GeneratedString>>,
1984    dns_search: Option<GeneratedDnsSearch>,
1985    expose: Option<Vec<GeneratedString>>,
1986    security_options: Option<Vec<GeneratedString>>,
1987    working_dir: Option<GeneratedString>,
1988    read_only: Option<bool>,
1989    pids_limit: Option<GeneratedPidsLimit>,
1990    shm_size: Option<GeneratedShmSize>,
1991    mem_limit: Option<GeneratedMemLimit>,
1992    tmpfs: Option<GeneratedTmpfs>,
1993    sysctls: Option<GeneratedSysctls>,
1994    logging: Option<GeneratedLogging>,
1995    ulimits: Option<GeneratedUlimits>,
1996    pull_policy: Option<GeneratedPullPolicy>,
1997    restart: Option<GeneratedRestartPolicy>,
1998    stop_signal: Option<GeneratedString>,
1999    stop_grace_period: Option<GeneratedString>,
2000    extra_hosts: Vec<GeneratedExtraHost>,
2001    ports: Vec<GeneratedPort>,
2002    mounts: Vec<GeneratedMount>,
2003    networks: Vec<GeneratedNetworkAttachment>,
2004    runtime_fields: Vec<GeneratedServiceRuntimeField>,
2005}
2006
2007impl GeneratedService {
2008    /// Creates an empty service with a validated name.
2009    ///
2010    /// # Errors
2011    ///
2012    /// Rejects an empty or NUL-bearing name.
2013    pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
2014        Ok(Self {
2015            name: required("service name", name.into())?,
2016            hostname: None,
2017            container_name: None,
2018            image: None,
2019            entrypoint: None,
2020            command: None,
2021            init: None,
2022            stdin_open: None,
2023            tty: None,
2024            privileged: None,
2025            environment_files: Vec::new(),
2026            environment: Vec::new(),
2027            labels: Vec::new(),
2028            annotations: None,
2029            user: None,
2030            userns_mode: None,
2031            group_add: Vec::new(),
2032            cap_add: None,
2033            cap_drop: None,
2034            devices: None,
2035            dns: None,
2036            dns_options: None,
2037            dns_search: None,
2038            expose: None,
2039            security_options: None,
2040            working_dir: None,
2041            read_only: None,
2042            pids_limit: None,
2043            shm_size: None,
2044            mem_limit: None,
2045            tmpfs: None,
2046            sysctls: None,
2047            logging: None,
2048            ulimits: None,
2049            pull_policy: None,
2050            restart: None,
2051            stop_signal: None,
2052            stop_grace_period: None,
2053            extra_hosts: Vec::new(),
2054            ports: Vec::new(),
2055            mounts: Vec::new(),
2056            networks: Vec::new(),
2057            runtime_fields: Vec::new(),
2058        })
2059    }
2060
2061    /// Returns the service name.
2062    #[must_use]
2063    pub fn name(&self) -> &str {
2064        &self.name
2065    }
2066
2067    /// Adds one generated raw-preserving resource or namespace field exactly once.
2068    ///
2069    /// All string values must be resolved single-line strings. The generated YAML is parse-back
2070    /// validated with the rest of the document; this method deliberately makes no provider or
2071    /// runtime support claim.
2072    ///
2073    /// # Errors
2074    ///
2075    /// Returns [`GenerationError::DuplicateField`] when the same runtime field was already
2076    /// selected, or [`GenerationError::InvalidServiceRuntimeField`] when its value is not safe
2077    /// for generated Compose YAML.
2078    pub fn add_runtime_field(&mut self, field: GeneratedServiceRuntimeField) -> Result<(), GenerationError> {
2079        if self
2080            .runtime_fields
2081            .iter()
2082            .any(|existing| existing.field_name() == field.field_name())
2083        {
2084            return Err(GenerationError::DuplicateField(field.field_name()));
2085        }
2086        if !generated_runtime_field_safe(&field) {
2087            return Err(GenerationError::InvalidServiceRuntimeField(field.field_name()));
2088        }
2089        self.runtime_fields.push(field);
2090        Ok(())
2091    }
2092
2093    /// Returns the selected raw-preserving generated runtime fields in insertion order.
2094    #[must_use]
2095    pub fn runtime_fields(&self) -> &[GeneratedServiceRuntimeField] {
2096        &self.runtime_fields
2097    }
2098
2099    /// Sets one resolved RFC-1123 service hostname exactly once.
2100    ///
2101    /// # Errors
2102    ///
2103    /// Returns [`GenerationError::InvalidHostname`] for an empty, expression-shaped, non-ASCII,
2104    /// overlong, or otherwise invalid hostname, or [`GenerationError::DuplicateField`] when
2105    /// already configured.
2106    pub fn set_hostname(&mut self, hostname: GeneratedHostname) -> Result<(), GenerationError> {
2107        let GeneratedHostname::Resolved(value) = &hostname;
2108        if !valid_hostname(value.expose()) {
2109            return Err(GenerationError::InvalidHostname);
2110        }
2111        set_once(&mut self.hostname, hostname, "hostname")
2112    }
2113
2114    /// Sets the custom runtime container name exactly once.
2115    ///
2116    /// # Errors
2117    ///
2118    /// Returns [`GenerationError::InvalidContainerName`] when the value does not match Compose's
2119    /// portable container-name grammar or [`GenerationError::DuplicateField`] when already
2120    /// configured.
2121    pub fn set_container_name(&mut self, name: GeneratedString) -> Result<(), GenerationError> {
2122        if !valid_container_name(name.expose()) {
2123            return Err(GenerationError::InvalidContainerName);
2124        }
2125        set_once(&mut self.container_name, name, "container_name")
2126    }
2127
2128    /// Sets the service image exactly once.
2129    ///
2130    /// # Errors
2131    ///
2132    /// Returns [`GenerationError::EmptyValue`] for an empty image or
2133    /// [`GenerationError::DuplicateField`] when already configured.
2134    pub fn set_image(&mut self, image: GeneratedString) -> Result<(), GenerationError> {
2135        require_generated_string("service image", &image)?;
2136        set_once(&mut self.image, image, "image")
2137    }
2138
2139    /// Sets the Compose entrypoint form exactly once.
2140    ///
2141    /// # Errors
2142    ///
2143    /// Returns [`GenerationError::DuplicateField`] when already configured.
2144    pub fn set_entrypoint(&mut self, entrypoint: GeneratedEntrypoint) -> Result<(), GenerationError> {
2145        set_once(&mut self.entrypoint, entrypoint, "entrypoint")
2146    }
2147
2148    /// Sets the Compose command form exactly once.
2149    ///
2150    /// # Errors
2151    ///
2152    /// Returns [`GenerationError::DuplicateField`] when already configured.
2153    pub fn set_command(&mut self, command: GeneratedCommand) -> Result<(), GenerationError> {
2154        set_once(&mut self.command, command, "command")
2155    }
2156
2157    /// Sets the Compose init-process choice exactly once.
2158    ///
2159    /// # Errors
2160    ///
2161    /// Returns [`GenerationError::DuplicateField`] when already configured.
2162    pub fn set_init(&mut self, init: bool) -> Result<(), GenerationError> {
2163        set_once(&mut self.init, init, "init")
2164    }
2165
2166    /// Sets the Compose standard-input-open choice exactly once.
2167    ///
2168    /// # Errors
2169    ///
2170    /// Returns [`GenerationError::DuplicateField`] when already configured.
2171    pub fn set_stdin_open(&mut self, stdin_open: bool) -> Result<(), GenerationError> {
2172        set_once(&mut self.stdin_open, stdin_open, "stdin_open")
2173    }
2174
2175    /// Sets the Compose terminal-allocation choice exactly once.
2176    ///
2177    /// # Errors
2178    ///
2179    /// Returns [`GenerationError::DuplicateField`] when already configured.
2180    pub fn set_tty(&mut self, tty: bool) -> Result<(), GenerationError> {
2181        set_once(&mut self.tty, tty, "tty")
2182    }
2183
2184    /// Sets the Compose privileged choice exactly once.
2185    ///
2186    /// # Errors
2187    ///
2188    /// Returns [`GenerationError::DuplicateField`] when already configured.
2189    pub fn set_privileged(&mut self, privileged: bool) -> Result<(), GenerationError> {
2190        set_once(&mut self.privileged, privileged, "privileged")
2191    }
2192
2193    /// Adds one ordered environment-file declaration.
2194    pub fn add_environment_file(&mut self, environment_file: GeneratedEnvironmentFile) {
2195        self.environment_files.push(environment_file);
2196    }
2197
2198    /// Adds one ordered environment entry.
2199    pub fn add_environment(&mut self, environment: GeneratedEnvironment) {
2200        self.environment.push(environment);
2201    }
2202
2203    /// Adds one uniquely named service metadata label.
2204    ///
2205    /// # Errors
2206    ///
2207    /// Returns [`GenerationError::DuplicateName`] when the service already defines the label.
2208    pub fn add_label(&mut self, label: GeneratedLabel) -> Result<(), GenerationError> {
2209        if self.labels.iter().any(|candidate| candidate.name == label.name) {
2210            return Err(GenerationError::DuplicateName {
2211                kind: "service label",
2212                name: label.name,
2213            });
2214        }
2215        self.labels.push(label);
2216        Ok(())
2217    }
2218
2219    /// Sets the complete ordered mapping-form annotation collection exactly once.
2220    ///
2221    /// Omission remains distinct from an explicit empty mapping. Names must be unique and all
2222    /// entries carry explicit resolved string values; key-only and null forms cannot enter this API.
2223    ///
2224    /// # Errors
2225    ///
2226    /// Returns [`GenerationError::DuplicateName`] for duplicate names,
2227    /// [`GenerationError::InvalidAnnotationName`] or [`GenerationError::InvalidAnnotationValue`]
2228    /// for unsafe values, or [`GenerationError::DuplicateField`] when already configured.
2229    pub fn set_annotations(&mut self, annotations: Vec<GeneratedAnnotation>) -> Result<(), GenerationError> {
2230        let mut seen = BTreeSet::new();
2231        for annotation in &annotations {
2232            if annotation.name.is_empty() || annotation.name.contains(['$', '\r', '\n', '\0']) {
2233                return Err(GenerationError::InvalidAnnotationName);
2234            }
2235            if annotation.value.expose().contains(['$', '\r', '\n', '\0']) {
2236                return Err(GenerationError::InvalidAnnotationValue);
2237            }
2238            if !seen.insert(annotation.name.as_str()) {
2239                return Err(GenerationError::DuplicateName {
2240                    kind: "service annotation",
2241                    name: annotation.name.clone(),
2242                });
2243            }
2244        }
2245        set_once(&mut self.annotations, annotations, "annotations")
2246    }
2247
2248    /// Returns configured annotations, distinguishing omission from an explicit empty mapping.
2249    #[must_use]
2250    pub fn annotations(&self) -> Option<&[GeneratedAnnotation]> {
2251        self.annotations.as_deref()
2252    }
2253
2254    /// Sets the combined Compose `user[:group]` value exactly once.
2255    ///
2256    /// # Errors
2257    ///
2258    /// Returns [`GenerationError::DuplicateField`] when already configured.
2259    pub fn set_user(&mut self, user: GeneratedString) -> Result<(), GenerationError> {
2260        set_once(&mut self.user, user, "user")
2261    }
2262
2263    /// Sets the user-namespace mode exactly once.
2264    ///
2265    /// # Errors
2266    ///
2267    /// Returns [`GenerationError::EmptyValue`] for an empty mode or
2268    /// [`GenerationError::DuplicateField`] when already configured.
2269    pub fn set_userns_mode(&mut self, mode: GeneratedString) -> Result<(), GenerationError> {
2270        require_generated_string("user namespace mode", &mode)?;
2271        set_once(&mut self.userns_mode, mode, "userns_mode")
2272    }
2273
2274    /// Adds one ordered supplementary group.
2275    ///
2276    /// # Errors
2277    ///
2278    /// Returns [`GenerationError::EmptyValue`] for an empty group.
2279    pub fn add_supplementary_group(&mut self, group: GeneratedString) -> Result<(), GenerationError> {
2280        require_generated_string("supplementary group", &group)?;
2281        self.group_add.push(group);
2282        Ok(())
2283    }
2284
2285    /// Sets the complete ordered `cap_add` sequence exactly once.
2286    ///
2287    /// An empty vector is retained as explicit `cap_add: []`; never calling this method omits the
2288    /// field. Values preserve exact case and ordering. No capability whitelist is applied.
2289    ///
2290    /// # Errors
2291    ///
2292    /// Returns [`GenerationError::EmptyValue`] for an empty item,
2293    /// [`GenerationError::ContainsLineBreak`] for a carriage return or line feed,
2294    /// [`GenerationError::DuplicateItem`] for an exact case-sensitive duplicate, or
2295    /// [`GenerationError::DuplicateField`] when already configured. NUL bytes are rejected while
2296    /// constructing [`GeneratedString`].
2297    pub fn set_cap_add(&mut self, capabilities: Vec<GeneratedString>) -> Result<(), GenerationError> {
2298        let mut seen = BTreeSet::new();
2299        for capability in &capabilities {
2300            require_generated_string("cap_add item", capability)?;
2301            if capability.expose().contains('\r') || capability.expose().contains('\n') {
2302                return Err(GenerationError::ContainsLineBreak("cap_add item"));
2303            }
2304            if !seen.insert(capability.expose()) {
2305                return Err(GenerationError::DuplicateItem("cap_add"));
2306            }
2307        }
2308        set_once(&mut self.cap_add, capabilities, "cap_add")
2309    }
2310
2311    /// Returns the configured `cap_add` sequence, distinguishing omission from an empty vector.
2312    #[must_use]
2313    pub fn cap_add(&self) -> Option<&[GeneratedString]> {
2314        self.cap_add.as_deref()
2315    }
2316
2317    /// Sets the complete ordered `cap_drop` sequence exactly once.
2318    ///
2319    /// An empty vector is retained as explicit `cap_drop: []`; never calling this method omits the
2320    /// field. Values preserve exact case and ordering. No capability whitelist is applied.
2321    ///
2322    /// # Errors
2323    ///
2324    /// Returns [`GenerationError::EmptyValue`] for an empty item,
2325    /// [`GenerationError::ContainsLineBreak`] for a carriage return or line feed,
2326    /// [`GenerationError::DuplicateItem`] for an exact case-sensitive duplicate, or
2327    /// [`GenerationError::DuplicateField`] when already configured. NUL bytes are rejected while
2328    /// constructing [`GeneratedString`].
2329    pub fn set_cap_drop(&mut self, capabilities: Vec<GeneratedString>) -> Result<(), GenerationError> {
2330        let mut seen = BTreeSet::new();
2331        for capability in &capabilities {
2332            require_generated_string("cap_drop item", capability)?;
2333            if capability.expose().contains('\r') || capability.expose().contains('\n') {
2334                return Err(GenerationError::ContainsLineBreak("cap_drop item"));
2335            }
2336            if !seen.insert(capability.expose()) {
2337                return Err(GenerationError::DuplicateItem("cap_drop"));
2338            }
2339        }
2340        set_once(&mut self.cap_drop, capabilities, "cap_drop")
2341    }
2342
2343    /// Returns the configured `cap_drop` sequence, distinguishing omission from an empty vector.
2344    #[must_use]
2345    pub fn cap_drop(&self) -> Option<&[GeneratedString]> {
2346        self.cap_drop.as_deref()
2347    }
2348
2349    /// Sets the complete ordered mixed short/long `devices` sequence exactly once.
2350    ///
2351    /// An empty vector is emitted as `devices: []`; omission remains distinct. Exact duplicate
2352    /// items and caller order are preserved. This validates only safe resolved YAML output and
2353    /// does not inspect host devices, split colon triples, validate CDI, normalize permissions,
2354    /// or claim runtime access.
2355    ///
2356    /// # Errors
2357    ///
2358    /// Rejects empty short items and empty long sources, plus NUL-bearing, multiline, or
2359    /// dollar-bearing values. NUL bytes are normally rejected while constructing
2360    /// [`GeneratedString`]. Returns [`GenerationError::DuplicateField`] when already configured.
2361    pub fn set_devices(&mut self, devices: Vec<GeneratedDevice>) -> Result<(), GenerationError> {
2362        for device in &devices {
2363            match device {
2364                GeneratedDevice::Short(value) => {
2365                    validate_generated_device_member("short item", value, true)?;
2366                }
2367                GeneratedDevice::Long(value) => {
2368                    validate_generated_device_member("source", value.source(), true)?;
2369                    if let Some(target) = value.target() {
2370                        validate_generated_device_member("target", target, false)?;
2371                    }
2372                    if let Some(permissions) = value.permissions() {
2373                        validate_generated_device_member("permissions", permissions, false)?;
2374                    }
2375                }
2376            }
2377        }
2378        set_once(&mut self.devices, devices, "devices")
2379    }
2380
2381    /// Sets the complete scalar or ordered-list service `dns` form exactly once.
2382    ///
2383    /// An empty list remains explicit. Values are retained as raw server strings: this API does
2384    /// not require an IP address, parse a resolver grammar, or perform network access.
2385    ///
2386    /// # Errors
2387    ///
2388    /// Rejects empty, multiline, NUL-bearing, or dollar-bearing values and duplicate field
2389    /// configuration. NUL bytes are normally rejected while constructing [`GeneratedString`].
2390    pub fn set_dns(&mut self, dns: GeneratedDns) -> Result<(), GenerationError> {
2391        let values = match &dns {
2392            GeneratedDns::Scalar(value) => std::slice::from_ref(value),
2393            GeneratedDns::List(values) => values.as_slice(),
2394        };
2395        for value in values {
2396            if value.expose().is_empty()
2397                || value.expose().contains('$')
2398                || value.expose().contains('\r')
2399                || value.expose().contains('\n')
2400            {
2401                return Err(GenerationError::InvalidDnsValue);
2402            }
2403        }
2404        set_once(&mut self.dns, dns, "dns")
2405    }
2406
2407    /// Returns the configured scalar or ordered-list DNS form.
2408    #[must_use]
2409    pub const fn dns(&self) -> Option<&GeneratedDns> {
2410        self.dns.as_ref()
2411    }
2412
2413    /// Sets the complete ordered service `dns_opt` sequence exactly once.
2414    ///
2415    /// An empty vector remains explicit while leaving this setter unused omits the field. Values
2416    /// are treated as raw resolver-option strings; no option grammar or runtime behavior is
2417    /// inferred.
2418    ///
2419    /// # Errors
2420    ///
2421    /// Rejects empty, multiline, NUL-bearing, dollar-bearing, or exact-duplicate values and
2422    /// duplicate field configuration. NUL bytes are normally rejected while constructing
2423    /// [`GeneratedString`].
2424    pub fn set_dns_options(&mut self, options: Vec<GeneratedString>) -> Result<(), GenerationError> {
2425        let mut seen = BTreeSet::new();
2426        for option in &options {
2427            if option.expose().is_empty()
2428                || option.expose().contains('$')
2429                || option.expose().contains('\r')
2430                || option.expose().contains('\n')
2431                || option.expose().contains('\0')
2432            {
2433                return Err(GenerationError::InvalidDnsOptionValue);
2434            }
2435            if !seen.insert(option.expose()) {
2436                return Err(GenerationError::DuplicateItem("dns_opt"));
2437            }
2438        }
2439        set_once(&mut self.dns_options, options, "dns_opt")
2440    }
2441
2442    /// Returns configured DNS resolver options, distinguishing omission from an empty sequence.
2443    #[must_use]
2444    pub fn dns_options(&self) -> Option<&[GeneratedString]> {
2445        self.dns_options.as_deref()
2446    }
2447
2448    /// Sets the complete scalar or ordered-list service `dns_search` form exactly once.
2449    ///
2450    /// An empty list remains explicit, exact duplicates and `.` are retained, and no domain,
2451    /// resolver, provider, or runtime validation is performed.
2452    ///
2453    /// # Errors
2454    ///
2455    /// Rejects empty, multiline, NUL-bearing, or dollar-bearing values and duplicate field
2456    /// configuration. NUL bytes are normally rejected while constructing [`GeneratedString`].
2457    pub fn set_dns_search(&mut self, search: GeneratedDnsSearch) -> Result<(), GenerationError> {
2458        let values = match &search {
2459            GeneratedDnsSearch::Scalar(value) => std::slice::from_ref(value),
2460            GeneratedDnsSearch::List(values) => values.as_slice(),
2461        };
2462        for value in values {
2463            if value.expose().is_empty()
2464                || value.expose().contains('$')
2465                || value.expose().contains('\r')
2466                || value.expose().contains('\n')
2467                || value.expose().contains('\0')
2468            {
2469                return Err(GenerationError::InvalidDnsSearchValue);
2470            }
2471        }
2472        set_once(&mut self.dns_search, search, "dns_search")
2473    }
2474
2475    /// Returns the configured scalar or ordered-list DNS search-domain form.
2476    #[must_use]
2477    pub const fn dns_search(&self) -> Option<&GeneratedDnsSearch> {
2478        self.dns_search.as_ref()
2479    }
2480
2481    /// Sets the complete ordered service `expose` sequence exactly once.
2482    ///
2483    /// An empty vector remains explicit. Every output item remains a YAML string, so number and
2484    /// string identities are never silently equated. Omitted protocol and explicit `/tcp` remain
2485    /// distinct.
2486    ///
2487    /// # Errors
2488    ///
2489    /// Rejects empty, deferred, multiline, NUL-bearing, malformed, SCTP, unknown-protocol, and
2490    /// exact-duplicate values, or duplicate field configuration.
2491    pub fn set_expose(&mut self, expose: Vec<GeneratedString>) -> Result<(), GenerationError> {
2492        let mut seen = BTreeSet::new();
2493        for item in &expose {
2494            if !valid_generated_expose_item(item.expose()) {
2495                return Err(GenerationError::InvalidExposeValue);
2496            }
2497            if !seen.insert(item.expose()) {
2498                return Err(GenerationError::DuplicateItem("expose"));
2499            }
2500        }
2501        set_once(&mut self.expose, expose, "expose")
2502    }
2503
2504    /// Returns the configured exposed-port sequence, including an explicit empty sequence.
2505    #[must_use]
2506    pub fn expose(&self) -> Option<&[GeneratedString]> {
2507        self.expose.as_deref()
2508    }
2509
2510    /// Sets the complete ordered raw service `security_opt` sequence exactly once.
2511    ///
2512    /// An empty vector remains explicit, exact duplicates retain their order, and no option,
2513    /// profile, provider, or target-runtime normalization is performed.
2514    ///
2515    /// # Errors
2516    ///
2517    /// Rejects empty, deferred, multiline, or NUL-bearing values and duplicate field
2518    /// configuration. NUL bytes are normally rejected while constructing [`GeneratedString`].
2519    pub fn set_security_options(&mut self, options: Vec<GeneratedString>) -> Result<(), GenerationError> {
2520        for option in &options {
2521            if option.expose().is_empty()
2522                || option.expose().contains('$')
2523                || option.expose().contains('\r')
2524                || option.expose().contains('\n')
2525                || option.expose().contains('\0')
2526            {
2527                return Err(GenerationError::InvalidSecurityOptionValue);
2528            }
2529        }
2530        set_once(&mut self.security_options, options, "security_opt")
2531    }
2532
2533    /// Returns configured raw security options, distinguishing omission from an empty sequence.
2534    #[must_use]
2535    pub fn security_options(&self) -> Option<&[GeneratedString]> {
2536        self.security_options.as_deref()
2537    }
2538
2539    /// Returns configured devices, distinguishing omission from an explicit empty sequence.
2540    #[must_use]
2541    pub fn devices(&self) -> Option<&[GeneratedDevice]> {
2542        self.devices.as_deref()
2543    }
2544
2545    /// Sets the container working directory exactly once.
2546    ///
2547    /// # Errors
2548    ///
2549    /// Returns [`GenerationError::EmptyValue`] for an empty directory or
2550    /// [`GenerationError::DuplicateField`] when already configured.
2551    pub fn set_working_dir(&mut self, directory: GeneratedString) -> Result<(), GenerationError> {
2552        require_generated_string("working directory", &directory)?;
2553        set_once(&mut self.working_dir, directory, "working_dir")
2554    }
2555
2556    /// Sets the read-only-root choice exactly once.
2557    ///
2558    /// # Errors
2559    ///
2560    /// Returns [`GenerationError::DuplicateField`] when already configured.
2561    pub fn set_read_only(&mut self, read_only: bool) -> Result<(), GenerationError> {
2562        set_once(&mut self.read_only, read_only, "read_only")
2563    }
2564
2565    /// Sets an unlimited or positive finite service PID limit exactly once.
2566    ///
2567    /// # Errors
2568    ///
2569    /// Returns [`GenerationError::InvalidPidsLimit`] when a finite spelling is empty, zero,
2570    /// signed, fractional, exponent-shaped, or otherwise not ASCII decimal, or
2571    /// [`GenerationError::DuplicateField`] when already configured.
2572    pub fn set_pids_limit(&mut self, limit: GeneratedPidsLimit) -> Result<(), GenerationError> {
2573        if let GeneratedPidsLimit::Finite(decimal) = &limit {
2574            if !valid_positive_pids_decimal(decimal) {
2575                return Err(GenerationError::InvalidPidsLimit);
2576            }
2577        }
2578        set_once(&mut self.pids_limit, limit, "pids_limit")
2579    }
2580
2581    /// Sets one explicit positive service shared-memory size exactly once.
2582    ///
2583    /// # Errors
2584    ///
2585    /// Returns [`GenerationError::InvalidShmSize`] when the amount is empty, zero, has leading
2586    /// zeros, a sign, fraction, exponent, whitespace, or non-ASCII digits, or
2587    /// [`GenerationError::DuplicateField`] when already configured.
2588    pub fn set_shm_size(&mut self, size: GeneratedShmSize) -> Result<(), GenerationError> {
2589        let GeneratedShmSize::Explicit { amount, .. } = &size;
2590        if !valid_generated_shm_amount(amount.expose()) {
2591            return Err(GenerationError::InvalidShmSize);
2592        }
2593        set_once(&mut self.shm_size, size, "shm_size")
2594    }
2595
2596    /// Sets one explicit positive service memory limit exactly once.
2597    ///
2598    /// # Errors
2599    ///
2600    /// Returns [`GenerationError::InvalidMemLimit`] when the amount is empty, zero, has leading
2601    /// zeros, a sign, fraction, exponent, whitespace, or non-ASCII digits, or
2602    /// [`GenerationError::DuplicateField`] when already configured.
2603    pub fn set_mem_limit(&mut self, limit: GeneratedMemLimit) -> Result<(), GenerationError> {
2604        let GeneratedMemLimit::Explicit { amount, .. } = &limit;
2605        if !valid_generated_mem_amount(amount.expose()) {
2606            return Err(GenerationError::InvalidMemLimit);
2607        }
2608        set_once(&mut self.mem_limit, limit, "mem_limit")
2609    }
2610
2611    /// Sets the complete scalar or list service-level `tmpfs` form exactly once.
2612    ///
2613    /// An empty list is retained explicitly. Item spelling, ordering, and case remain unchanged.
2614    ///
2615    /// # Errors
2616    ///
2617    /// Rejects empty, multiline, deferred, or structurally malformed items. Documented `mode`,
2618    /// `uid`, and `gid` assignments and other well-shaped raw target options remain exact, including
2619    /// duplicate list entries. NUL bytes are rejected while constructing [`GeneratedString`]. Returns
2620    /// [`GenerationError::DuplicateField`] when already configured.
2621    pub fn set_tmpfs(&mut self, tmpfs: GeneratedTmpfs) -> Result<(), GenerationError> {
2622        let items = match &tmpfs {
2623            GeneratedTmpfs::Scalar(item) => std::slice::from_ref(item),
2624            GeneratedTmpfs::List(items) => items.as_slice(),
2625        };
2626        for item in items {
2627            require_generated_string("tmpfs item", item)?;
2628            if item.expose().contains('\r') || item.expose().contains('\n') {
2629                return Err(GenerationError::ContainsLineBreak("tmpfs item"));
2630            }
2631            if !valid_generated_tmpfs_item(item.expose()) {
2632                return Err(GenerationError::InvalidTmpfsItem);
2633            }
2634        }
2635        set_once(&mut self.tmpfs, tmpfs, "tmpfs")
2636    }
2637
2638    /// Returns the configured scalar or list form, distinguishing omission from an empty list.
2639    #[must_use]
2640    pub const fn tmpfs(&self) -> Option<&GeneratedTmpfs> {
2641        self.tmpfs.as_ref()
2642    }
2643
2644    /// Sets the complete mapping or list `sysctls` form exactly once.
2645    ///
2646    /// Empty collections remain explicit. Mapping names and list strings must be exact-unique;
2647    /// neither form applies namespace validation or runtime coercion.
2648    ///
2649    /// # Errors
2650    ///
2651    /// Rejects duplicate map names, duplicate exact list items, multiline or dollar-bearing list
2652    /// items, and duplicate field configuration. NUL-bearing list items are rejected while
2653    /// constructing [`GeneratedString`].
2654    pub fn set_sysctls(&mut self, sysctls: GeneratedSysctls) -> Result<(), GenerationError> {
2655        let mut seen = BTreeSet::new();
2656        match &sysctls {
2657            GeneratedSysctls::Map(entries) => {
2658                for entry in entries {
2659                    if !seen.insert(entry.name()) {
2660                        return Err(GenerationError::DuplicateName {
2661                            kind: "sysctl",
2662                            name: entry.name().to_owned(),
2663                        });
2664                    }
2665                }
2666            }
2667            GeneratedSysctls::List(items) => {
2668                for item in items {
2669                    if item.expose().contains(['\r', '\n', '$']) {
2670                        return Err(GenerationError::InvalidSysctlValue);
2671                    }
2672                    if !seen.insert(item.expose()) {
2673                        return Err(GenerationError::DuplicateItem("sysctls"));
2674                    }
2675                }
2676            }
2677        }
2678        set_once(&mut self.sysctls, sysctls, "sysctls")
2679    }
2680
2681    /// Returns the configured form, distinguishing omission from explicit empty collections.
2682    #[must_use]
2683    pub const fn sysctls(&self) -> Option<&GeneratedSysctls> {
2684        self.sysctls.as_ref()
2685    }
2686
2687    /// Sets explicit logging configuration exactly once.
2688    ///
2689    /// # Errors
2690    ///
2691    /// Returns [`GenerationError::DuplicateField`] when already configured. Driver spelling and
2692    /// option semantics are otherwise left uninterpreted.
2693    pub fn set_logging(&mut self, logging: GeneratedLogging) -> Result<(), GenerationError> {
2694        set_once(&mut self.logging, logging, "logging")
2695    }
2696
2697    /// Returns configured logging, distinguishing omission from explicit empty options.
2698    #[must_use]
2699    pub const fn logging(&self) -> Option<&GeneratedLogging> {
2700        self.logging.as_ref()
2701    }
2702
2703    /// Sets the complete ordered service `ulimits` mapping exactly once.
2704    ///
2705    /// An empty mapping remains explicit. Values are already validated while constructing
2706    /// [`GeneratedUlimit`] and names are unique by construction in [`GeneratedUlimits`].
2707    ///
2708    /// # Errors
2709    ///
2710    /// Returns [`GenerationError::DuplicateField`] when already configured.
2711    pub fn set_ulimits(&mut self, ulimits: GeneratedUlimits) -> Result<(), GenerationError> {
2712        set_once(&mut self.ulimits, ulimits, "ulimits")
2713    }
2714
2715    /// Returns configured ordered limits, distinguishing omission from an explicit empty mapping.
2716    #[must_use]
2717    pub const fn ulimits(&self) -> Option<&GeneratedUlimits> {
2718        self.ulimits.as_ref()
2719    }
2720
2721    /// Sets a documented service image pull policy exactly once.
2722    ///
2723    /// # Errors
2724    ///
2725    /// Returns [`GenerationError::InvalidPullPolicyDuration`] for an invalid custom interval or
2726    /// [`GenerationError::DuplicateField`] when already configured.
2727    pub fn set_pull_policy(&mut self, policy: GeneratedPullPolicy) -> Result<(), GenerationError> {
2728        if let GeneratedPullPolicy::Every(duration) = &policy {
2729            if !valid_pull_policy_duration(duration.expose()) {
2730                return Err(GenerationError::InvalidPullPolicyDuration);
2731            }
2732        }
2733        set_once(&mut self.pull_policy, policy, "pull_policy")
2734    }
2735
2736    /// Sets the service-level restart policy exactly once.
2737    ///
2738    /// # Errors
2739    ///
2740    /// Returns [`GenerationError::DuplicateField`] when already configured.
2741    pub fn set_restart(&mut self, restart: GeneratedRestartPolicy) -> Result<(), GenerationError> {
2742        set_once(&mut self.restart, restart, "restart")
2743    }
2744
2745    /// Sets the service stop signal exactly once without imposing a signal-token grammar.
2746    ///
2747    /// # Errors
2748    ///
2749    /// Returns [`GenerationError::DuplicateField`] when already configured. Quoted empty values
2750    /// are preserved; NUL-bearing values are rejected while constructing [`GeneratedString`].
2751    pub fn set_stop_signal(&mut self, signal: GeneratedString) -> Result<(), GenerationError> {
2752        set_once(&mut self.stop_signal, signal, "stop_signal")
2753    }
2754
2755    /// Sets the raw-preserving service stop grace period exactly once.
2756    ///
2757    /// # Errors
2758    ///
2759    /// Returns [`GenerationError::InvalidStopGracePeriod`] when the value does not match the
2760    /// `ComposeLens` raw-preserving duration policy or dollar-marker convention, or
2761    /// [`GenerationError::DuplicateField`] when already configured.
2762    pub fn set_stop_grace_period(&mut self, period: GeneratedString) -> Result<(), GenerationError> {
2763        if !StopGracePeriod::parse(period.expose().to_owned()).is_valid() {
2764            return Err(GenerationError::InvalidStopGracePeriod);
2765        }
2766        set_once(&mut self.stop_grace_period, period, "stop_grace_period")
2767    }
2768
2769    /// Adds one ordered host mapping.
2770    pub fn add_extra_host(&mut self, host: GeneratedExtraHost) {
2771        self.extra_hosts.push(host);
2772    }
2773
2774    /// Adds one ordered published-port declaration.
2775    pub fn add_port(&mut self, port: GeneratedPort) {
2776        self.ports.push(port);
2777    }
2778
2779    /// Adds one ordered mount.
2780    pub fn add_mount(&mut self, mount: GeneratedMount) {
2781        self.mounts.push(mount);
2782    }
2783
2784    /// Adds one uniquely named network attachment.
2785    ///
2786    /// # Errors
2787    ///
2788    /// Returns [`GenerationError::DuplicateName`] when the service already uses the network.
2789    pub fn add_network(&mut self, network: GeneratedNetworkAttachment) -> Result<(), GenerationError> {
2790        if self.networks.iter().any(|candidate| candidate.name == network.name) {
2791            return Err(GenerationError::DuplicateName {
2792                kind: "service network",
2793                name: network.name,
2794            });
2795        }
2796        self.networks.push(network);
2797        Ok(())
2798    }
2799
2800    fn is_sensitive(&self) -> bool {
2801        matches!(
2802            self.hostname.as_ref(),
2803            Some(GeneratedHostname::Resolved(hostname)) if hostname.is_sensitive()
2804        ) || self.image.as_ref().is_some_and(GeneratedString::is_sensitive)
2805            || self.entrypoint.as_ref().is_some_and(entrypoint_is_sensitive)
2806            || self.command.as_ref().is_some_and(command_is_sensitive)
2807            || self
2808                .environment_files
2809                .iter()
2810                .any(GeneratedEnvironmentFile::is_sensitive)
2811            || self
2812                .environment
2813                .iter()
2814                .filter_map(GeneratedEnvironment::value)
2815                .any(GeneratedString::is_sensitive)
2816            || self.labels.iter().any(|label| label.value.is_sensitive())
2817            || self
2818                .annotations
2819                .as_ref()
2820                .is_some_and(|items| items.iter().any(|annotation| annotation.value.is_sensitive()))
2821            || matches!(
2822                self.pull_policy.as_ref(),
2823                Some(GeneratedPullPolicy::Every(duration)) if duration.is_sensitive()
2824            )
2825            || matches!(
2826                self.shm_size.as_ref(),
2827                Some(GeneratedShmSize::Explicit { amount, .. }) if amount.is_sensitive()
2828            )
2829            || matches!(
2830                self.mem_limit.as_ref(),
2831                Some(GeneratedMemLimit::Explicit { amount, .. }) if amount.is_sensitive()
2832            )
2833            || match self.tmpfs.as_ref() {
2834                Some(GeneratedTmpfs::Scalar(item)) => item.is_sensitive(),
2835                Some(GeneratedTmpfs::List(items)) => items.iter().any(GeneratedString::is_sensitive),
2836                None => false,
2837            }
2838            || match self.dns.as_ref() {
2839                Some(GeneratedDns::Scalar(value)) => value.is_sensitive(),
2840                Some(GeneratedDns::List(values)) => values.iter().any(GeneratedString::is_sensitive),
2841                None => false,
2842            }
2843            || self
2844                .dns_options
2845                .as_ref()
2846                .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
2847            || self
2848                .runtime_fields
2849                .iter()
2850                .any(GeneratedServiceRuntimeField::is_sensitive)
2851            || match self.dns_search.as_ref() {
2852                Some(GeneratedDnsSearch::Scalar(value)) => value.is_sensitive(),
2853                Some(GeneratedDnsSearch::List(values)) => values.iter().any(GeneratedString::is_sensitive),
2854                None => false,
2855            }
2856            || self
2857                .expose
2858                .as_ref()
2859                .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
2860            || self
2861                .security_options
2862                .as_ref()
2863                .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
2864            || match self.sysctls.as_ref() {
2865                Some(GeneratedSysctls::Map(entries)) => entries.iter().any(|entry| entry.value.is_sensitive()),
2866                Some(GeneratedSysctls::List(items)) => items.iter().any(GeneratedString::is_sensitive),
2867                None => false,
2868            }
2869            || self.logging.as_ref().is_some_and(GeneratedLogging::is_sensitive)
2870            || self
2871                .ulimits
2872                .as_ref()
2873                .is_some_and(|limits| limits.entries.iter().any(GeneratedUlimit::is_sensitive))
2874            || [
2875                self.user.as_ref(),
2876                self.userns_mode.as_ref(),
2877                self.working_dir.as_ref(),
2878                self.stop_signal.as_ref(),
2879                self.stop_grace_period.as_ref(),
2880            ]
2881            .into_iter()
2882            .flatten()
2883            .any(GeneratedString::is_sensitive)
2884            || self.group_add.iter().any(GeneratedString::is_sensitive)
2885            || self
2886                .cap_add
2887                .as_ref()
2888                .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
2889            || self
2890                .cap_drop
2891                .as_ref()
2892                .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
2893            || self
2894                .devices
2895                .as_ref()
2896                .is_some_and(|items| items.iter().any(GeneratedDevice::is_sensitive))
2897            || self.networks.iter().any(GeneratedNetworkAttachment::is_sensitive)
2898    }
2899}
2900
2901#[derive(Clone, Debug, Eq, PartialEq)]
2902enum GeneratedNetwork {
2903    Basic(GeneratedResource),
2904    Definition(GeneratedNetworkDefinition),
2905}
2906
2907#[derive(Clone, Debug, Eq, PartialEq)]
2908enum GeneratedVolume {
2909    Basic(GeneratedResource),
2910    Definition(GeneratedVolumeDefinition),
2911}
2912
2913impl GeneratedVolume {
2914    fn name(&self) -> &str {
2915        match self {
2916            Self::Basic(volume) => volume.name(),
2917            Self::Definition(volume) => volume.name(),
2918        }
2919    }
2920
2921    fn is_sensitive(&self) -> bool {
2922        match self {
2923            Self::Basic(_) => false,
2924            Self::Definition(volume) => volume.is_sensitive(),
2925        }
2926    }
2927}
2928
2929impl GeneratedNetwork {
2930    fn name(&self) -> &str {
2931        match self {
2932            Self::Basic(network) => network.name(),
2933            Self::Definition(network) => network.name(),
2934        }
2935    }
2936
2937    fn is_sensitive(&self) -> bool {
2938        match self {
2939            Self::Basic(_) => false,
2940            Self::Definition(network) => network.is_sensitive(),
2941        }
2942    }
2943}
2944
2945/// One generated top-level config definition backed by a caller-supplied file spelling.
2946///
2947/// The builder deliberately supports no inline content, environment, external lifecycle,
2948/// labels, template driver, or file access through this type.
2949#[derive(Clone, Eq, PartialEq)]
2950pub struct GeneratedConfigFileDefinition {
2951    name: String,
2952    file: GeneratedString,
2953}
2954
2955impl GeneratedConfigFileDefinition {
2956    /// Creates a config definition with one required resolved single-line `file` value.
2957    ///
2958    /// # Errors
2959    ///
2960    /// Rejects empty, deferred, multiline, or NUL-bearing names and file values.
2961    pub fn new(name: impl Into<String>, file: GeneratedString) -> Result<Self, GenerationError> {
2962        Ok(Self {
2963            name: generated_file_resource_name(name.into())?,
2964            file: generated_file_resource_path(file)?,
2965        })
2966    }
2967
2968    /// Returns the exact generated config name.
2969    #[must_use]
2970    pub fn name(&self) -> &str {
2971        &self.name
2972    }
2973
2974    /// Returns the explicit generated file value through its sensitivity boundary.
2975    #[must_use]
2976    pub const fn file(&self) -> &GeneratedString {
2977        &self.file
2978    }
2979
2980    fn is_sensitive(&self) -> bool {
2981        self.file.is_sensitive()
2982    }
2983}
2984
2985impl fmt::Debug for GeneratedConfigFileDefinition {
2986    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2987        formatter
2988            .debug_struct("GeneratedConfigFileDefinition")
2989            .field("name", &self.name)
2990            .field("file", &self.file)
2991            .finish()
2992    }
2993}
2994
2995/// One generated top-level secret definition backed by a caller-supplied file spelling.
2996///
2997/// The builder deliberately supports no environment, driver, labels, template driver, external
2998/// lifecycle, or file access through this type.
2999#[derive(Clone, Eq, PartialEq)]
3000pub struct GeneratedSecretFileDefinition {
3001    name: String,
3002    file: GeneratedString,
3003}
3004
3005impl GeneratedSecretFileDefinition {
3006    /// Creates a secret definition with one required resolved single-line `file` value.
3007    ///
3008    /// # Errors
3009    ///
3010    /// Rejects empty, deferred, multiline, or NUL-bearing names and file values.
3011    pub fn new(name: impl Into<String>, file: GeneratedString) -> Result<Self, GenerationError> {
3012        Ok(Self {
3013            name: generated_file_resource_name(name.into())?,
3014            file: generated_file_resource_path(file)?,
3015        })
3016    }
3017
3018    /// Returns the exact generated secret name.
3019    #[must_use]
3020    pub fn name(&self) -> &str {
3021        &self.name
3022    }
3023
3024    /// Returns the explicit generated file value through its sensitivity boundary.
3025    #[must_use]
3026    pub const fn file(&self) -> &GeneratedString {
3027        &self.file
3028    }
3029
3030    fn is_sensitive(&self) -> bool {
3031        self.file.is_sensitive()
3032    }
3033}
3034
3035impl fmt::Debug for GeneratedSecretFileDefinition {
3036    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3037        formatter
3038            .debug_struct("GeneratedSecretFileDefinition")
3039            .field("name", &self.name)
3040            .field("file", &self.file)
3041            .finish()
3042    }
3043}
3044
3045/// Builder for one new deterministic Compose document.
3046#[derive(Clone, Debug, Default, Eq, PartialEq)]
3047pub struct ComposeDocumentBuilder {
3048    name: Option<String>,
3049    services: Vec<GeneratedService>,
3050    networks: Vec<GeneratedNetwork>,
3051    volumes: Vec<GeneratedVolume>,
3052    configs: Vec<GeneratedConfigFileDefinition>,
3053    secrets: Vec<GeneratedSecretFileDefinition>,
3054}
3055
3056impl ComposeDocumentBuilder {
3057    /// Creates an empty generated project.
3058    #[must_use]
3059    pub const fn new() -> Self {
3060        Self {
3061            name: None,
3062            services: Vec::new(),
3063            networks: Vec::new(),
3064            volumes: Vec::new(),
3065            configs: Vec::new(),
3066            secrets: Vec::new(),
3067        }
3068    }
3069
3070    /// Sets the optional top-level Compose project name exactly once.
3071    ///
3072    /// # Errors
3073    ///
3074    /// Rejects empty/NUL-bearing names and duplicate configuration.
3075    pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
3076        let name = required("project name", name.into())?;
3077        set_once(&mut self.name, name, "name")
3078    }
3079
3080    /// Adds one uniquely named service in output order.
3081    ///
3082    /// # Errors
3083    ///
3084    /// Returns [`GenerationError::DuplicateName`] for a duplicate service name.
3085    pub fn add_service(&mut self, service: GeneratedService) -> Result<(), GenerationError> {
3086        insert_named(&mut self.services, service, "service", GeneratedService::name)
3087    }
3088
3089    /// Adds one uniquely named top-level network in output order.
3090    ///
3091    /// # Errors
3092    ///
3093    /// Returns [`GenerationError::DuplicateName`] for a duplicate network name.
3094    pub fn add_network(&mut self, network: GeneratedResource) -> Result<(), GenerationError> {
3095        insert_named(
3096            &mut self.networks,
3097            GeneratedNetwork::Basic(network),
3098            "network",
3099            GeneratedNetwork::name,
3100        )
3101    }
3102
3103    /// Adds one uniquely named top-level network definition in output order.
3104    ///
3105    /// This is additive to [`Self::add_network`], which retains the existing basic/external
3106    /// [`GeneratedResource`] API for compatibility.
3107    ///
3108    /// # Errors
3109    ///
3110    /// Returns [`GenerationError::DuplicateName`] for a duplicate network name across basic and
3111    /// driver-configured network definitions.
3112    pub fn add_network_definition(&mut self, network: GeneratedNetworkDefinition) -> Result<(), GenerationError> {
3113        insert_named(
3114            &mut self.networks,
3115            GeneratedNetwork::Definition(network),
3116            "network",
3117            GeneratedNetwork::name,
3118        )
3119    }
3120
3121    /// Adds one uniquely named top-level volume in output order.
3122    ///
3123    /// # Errors
3124    ///
3125    /// Returns [`GenerationError::DuplicateName`] for a duplicate volume name.
3126    pub fn add_volume(&mut self, volume: GeneratedResource) -> Result<(), GenerationError> {
3127        insert_named(
3128            &mut self.volumes,
3129            GeneratedVolume::Basic(volume),
3130            "volume",
3131            GeneratedVolume::name,
3132        )
3133    }
3134
3135    /// Adds one uniquely named top-level application-owned volume definition in output order.
3136    ///
3137    /// This is additive to [`Self::add_volume`], which retains the existing basic/external
3138    /// [`GeneratedResource`] API for compatibility. Driver-configured external volumes are not
3139    /// representable: use `GeneratedResource::external` for that lifecycle.
3140    ///
3141    /// # Errors
3142    ///
3143    /// Returns [`GenerationError::DuplicateName`] for a duplicate volume name across basic and
3144    /// driver-configured volume definitions.
3145    pub fn add_volume_definition(&mut self, volume: GeneratedVolumeDefinition) -> Result<(), GenerationError> {
3146        insert_named(
3147            &mut self.volumes,
3148            GeneratedVolume::Definition(volume),
3149            "volume",
3150            GeneratedVolume::name,
3151        )
3152    }
3153
3154    /// Adds one uniquely named top-level config file definition in output order.
3155    ///
3156    /// # Errors
3157    ///
3158    /// Returns [`GenerationError::DuplicateName`] for a duplicate config name.
3159    pub fn add_config_file(&mut self, config: GeneratedConfigFileDefinition) -> Result<(), GenerationError> {
3160        insert_named(&mut self.configs, config, "config", GeneratedConfigFileDefinition::name)
3161    }
3162
3163    /// Adds one uniquely named top-level secret file definition in output order.
3164    ///
3165    /// # Errors
3166    ///
3167    /// Returns [`GenerationError::DuplicateName`] for a duplicate secret name.
3168    pub fn add_secret_file(&mut self, secret: GeneratedSecretFileDefinition) -> Result<(), GenerationError> {
3169        insert_named(&mut self.secrets, secret, "secret", GeneratedSecretFileDefinition::name)
3170    }
3171
3172    /// Generates YAML and parses it back through `ComposeLens`'s syntax and typed-model boundaries.
3173    ///
3174    /// # Errors
3175    ///
3176    /// Returns [`GenerationError::MissingService`] for an empty project or
3177    /// [`GenerationError::InternalInvariant`] if `ComposeLens` cannot parse its own output.
3178    pub fn build(self, source_id: SourceId) -> Result<GeneratedComposeDocument, GenerationError> {
3179        if self.services.is_empty() {
3180            return Err(GenerationError::MissingService);
3181        }
3182        let sensitive = self.services.iter().any(GeneratedService::is_sensitive)
3183            || self.networks.iter().any(GeneratedNetwork::is_sensitive)
3184            || self.volumes.iter().any(GeneratedVolume::is_sensitive)
3185            || self.configs.iter().any(GeneratedConfigFileDefinition::is_sensitive)
3186            || self.secrets.iter().any(GeneratedSecretFileDefinition::is_sensitive);
3187        let text = render_document(&self);
3188        let syntax = SyntaxDocument::parse(source_id, text.clone())
3189            .map_err(|_| GenerationError::InternalInvariant("syntax-tree"))?;
3190        if !syntax.is_valid() {
3191            return Err(GenerationError::InternalInvariant("syntax"));
3192        }
3193        let model = ComposeDocument::parse(syntax.document());
3194        if !model.is_valid() {
3195            return Err(GenerationError::InternalInvariant("typed-model"));
3196        }
3197        let document = model
3198            .document()
3199            .cloned()
3200            .ok_or(GenerationError::InternalInvariant("document-root"))?;
3201        Ok(GeneratedComposeDocument {
3202            text,
3203            sensitive,
3204            document,
3205        })
3206    }
3207}
3208
3209/// Parse-back-validated deterministic generated Compose document.
3210#[derive(Clone, Eq, PartialEq)]
3211pub struct GeneratedComposeDocument {
3212    text: String,
3213    sensitive: bool,
3214    document: ComposeDocument,
3215}
3216
3217impl GeneratedComposeDocument {
3218    /// Returns the deployable generated YAML through an explicit access boundary.
3219    #[must_use]
3220    pub fn text(&self) -> &str {
3221        &self.text
3222    }
3223
3224    /// Returns the parse-back-validated native Compose model.
3225    #[must_use]
3226    pub const fn document(&self) -> &ComposeDocument {
3227        &self.document
3228    }
3229
3230    /// Reports whether generated output contains a caller-marked sensitive value.
3231    #[must_use]
3232    pub const fn is_sensitive(&self) -> bool {
3233        self.sensitive
3234    }
3235}
3236
3237impl fmt::Debug for GeneratedComposeDocument {
3238    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3239        formatter
3240            .debug_struct("GeneratedComposeDocument")
3241            .field("text", &if self.sensitive { "<redacted>" } else { &self.text })
3242            .field("sensitive", &self.sensitive)
3243            .field("document", &if self.sensitive { "<redacted>" } else { "validated" })
3244            .finish()
3245    }
3246}
3247
3248fn render_document(project: &ComposeDocumentBuilder) -> String {
3249    let mut output = String::from("---\n");
3250    if let Some(name) = &project.name {
3251        output.push_str("name: ");
3252        write_quoted(&mut output, name);
3253        output.push('\n');
3254    }
3255    output.push_str("services:\n");
3256    for service in &project.services {
3257        write_indent(&mut output, 1);
3258        write_quoted(&mut output, &service.name);
3259        output.push_str(":\n");
3260        render_service(&mut output, service);
3261    }
3262    render_network_definitions(&mut output, &project.networks);
3263    render_volume_definitions(&mut output, &project.volumes);
3264    render_file_definitions(
3265        &mut output,
3266        "configs",
3267        &project.configs,
3268        GeneratedConfigFileDefinition::name,
3269        GeneratedConfigFileDefinition::file,
3270    );
3271    render_file_definitions(
3272        &mut output,
3273        "secrets",
3274        &project.secrets,
3275        GeneratedSecretFileDefinition::name,
3276        GeneratedSecretFileDefinition::file,
3277    );
3278    output
3279}
3280
3281fn render_service(output: &mut String, service: &GeneratedService) {
3282    if let Some(GeneratedHostname::Resolved(hostname)) = &service.hostname {
3283        render_optional_string(output, "hostname", Some(hostname));
3284    }
3285    render_optional_string(output, "container_name", service.container_name.as_ref());
3286    render_optional_string(output, "image", service.image.as_ref());
3287    if let Some(entrypoint) = &service.entrypoint {
3288        render_entrypoint(output, entrypoint);
3289    }
3290    if let Some(command) = &service.command {
3291        render_command(output, command);
3292    }
3293    if let Some(init) = service.init {
3294        write_field(output, 2, "init");
3295        output.push_str(if init { "true\n" } else { "false\n" });
3296    }
3297    if let Some(stdin_open) = service.stdin_open {
3298        write_field(output, 2, "stdin_open");
3299        output.push_str(if stdin_open { "true\n" } else { "false\n" });
3300    }
3301    if let Some(tty) = service.tty {
3302        write_field(output, 2, "tty");
3303        output.push_str(if tty { "true\n" } else { "false\n" });
3304    }
3305    if let Some(privileged) = service.privileged {
3306        write_field(output, 2, "privileged");
3307        output.push_str(if privileged { "true\n" } else { "false\n" });
3308    }
3309    render_environment_files(output, &service.environment_files);
3310    render_environment(output, &service.environment);
3311    render_labels(output, &service.labels);
3312    if let Some(annotations) = &service.annotations {
3313        render_annotations(output, annotations);
3314    }
3315    render_optional_string(output, "user", service.user.as_ref());
3316    render_optional_string(output, "userns_mode", service.userns_mode.as_ref());
3317    render_string_sequence(output, "group_add", &service.group_add);
3318    if let Some(capabilities) = &service.cap_add {
3319        render_configured_string_sequence(output, "cap_add", capabilities);
3320    }
3321    if let Some(capabilities) = &service.cap_drop {
3322        render_configured_string_sequence(output, "cap_drop", capabilities);
3323    }
3324    render_optional_string(output, "working_dir", service.working_dir.as_ref());
3325    if let Some(read_only) = service.read_only {
3326        write_field(output, 2, "read_only");
3327        output.push_str(if read_only { "true\n" } else { "false\n" });
3328    }
3329    if let Some(pids_limit) = &service.pids_limit {
3330        render_pids_limit(output, pids_limit);
3331    }
3332    if let Some(shm_size) = &service.shm_size {
3333        render_shm_size(output, shm_size);
3334    }
3335    if let Some(mem_limit) = &service.mem_limit {
3336        render_mem_limit(output, mem_limit);
3337    }
3338    render_runtime_fields(output, &service.runtime_fields);
3339    if let Some(devices) = &service.devices {
3340        render_devices(output, devices);
3341    }
3342    if let Some(dns) = &service.dns {
3343        render_dns(output, dns);
3344    }
3345    if let Some(options) = &service.dns_options {
3346        render_configured_string_sequence(output, "dns_opt", options);
3347    }
3348    if let Some(search) = &service.dns_search {
3349        render_dns_search(output, search);
3350    }
3351    if let Some(expose) = &service.expose {
3352        render_configured_string_sequence(output, "expose", expose);
3353    }
3354    if let Some(options) = &service.security_options {
3355        render_configured_string_sequence(output, "security_opt", options);
3356    }
3357    if let Some(tmpfs) = &service.tmpfs {
3358        render_tmpfs(output, tmpfs);
3359    }
3360    if let Some(sysctls) = &service.sysctls {
3361        render_sysctls(output, sysctls);
3362    }
3363    if let Some(logging) = &service.logging {
3364        render_logging(output, logging);
3365    }
3366    if let Some(ulimits) = &service.ulimits {
3367        render_ulimits(output, ulimits);
3368    }
3369    if let Some(pull_policy) = &service.pull_policy {
3370        render_pull_policy(output, pull_policy);
3371    }
3372    if let Some(restart) = service.restart {
3373        render_restart(output, restart);
3374    }
3375    render_optional_string(output, "stop_signal", service.stop_signal.as_ref());
3376    render_optional_string(output, "stop_grace_period", service.stop_grace_period.as_ref());
3377    render_extra_hosts(output, &service.extra_hosts);
3378    render_ports(output, &service.ports);
3379    render_mounts(output, &service.mounts);
3380    render_networks(output, &service.networks);
3381}
3382
3383fn render_runtime_fields(output: &mut String, fields: &[GeneratedServiceRuntimeField]) {
3384    for field in fields {
3385        match field {
3386            GeneratedServiceRuntimeField::Domainname(value) => {
3387                render_optional_string(output, "domainname", Some(value));
3388            }
3389            GeneratedServiceRuntimeField::Isolation(value) => {
3390                render_optional_string(output, "isolation", Some(value));
3391            }
3392            GeneratedServiceRuntimeField::MacAddress(value) => {
3393                render_optional_string(output, "mac_address", Some(value));
3394            }
3395            GeneratedServiceRuntimeField::Uts(value) => {
3396                render_optional_string(output, "uts", Some(value));
3397            }
3398            GeneratedServiceRuntimeField::UseApiSocket(value) => {
3399                write_field(output, 2, "use_api_socket");
3400                output.push_str(if *value { "true\n" } else { "false\n" });
3401            }
3402            GeneratedServiceRuntimeField::GpusAll(value) => {
3403                render_optional_string(output, "gpus", Some(value));
3404            }
3405            GeneratedServiceRuntimeField::CpuRtRuntime(GeneratedCpuRtRuntime::Microseconds(value)) => {
3406                write_field(output, 2, "cpu_rt_runtime");
3407                output.push_str(value.expose());
3408                output.push('\n');
3409            }
3410            GeneratedServiceRuntimeField::CpuRtRuntime(GeneratedCpuRtRuntime::Duration(value)) => {
3411                render_optional_string(output, "cpu_rt_runtime", Some(value));
3412            }
3413            GeneratedServiceRuntimeField::CpuShares(value) => {
3414                render_optional_string(output, "cpu_shares", Some(value));
3415            }
3416            GeneratedServiceRuntimeField::Cpus(value) => {
3417                render_optional_string(output, "cpus", Some(value));
3418            }
3419            GeneratedServiceRuntimeField::Cpuset(value) => {
3420                render_optional_string(output, "cpuset", Some(value));
3421            }
3422            GeneratedServiceRuntimeField::DeviceCgroupRules(values) => {
3423                render_configured_string_sequence(output, "device_cgroup_rules", values);
3424            }
3425            GeneratedServiceRuntimeField::Ipc(value) => {
3426                render_optional_string(output, "ipc", Some(value));
3427            }
3428            GeneratedServiceRuntimeField::MemReservation(value) => {
3429                render_optional_string(output, "mem_reservation", Some(value));
3430            }
3431            GeneratedServiceRuntimeField::MemSwappiness(value) => {
3432                render_optional_string(output, "mem_swappiness", Some(value));
3433            }
3434            GeneratedServiceRuntimeField::MemswapLimit(value) => {
3435                render_optional_string(output, "memswap_limit", Some(value));
3436            }
3437            GeneratedServiceRuntimeField::NetworkMode(value) => {
3438                render_optional_string(output, "network_mode", Some(value));
3439            }
3440            GeneratedServiceRuntimeField::OomKillDisable(value) => {
3441                write_field(output, 2, "oom_kill_disable");
3442                output.push_str(if *value { "true\n" } else { "false\n" });
3443            }
3444            GeneratedServiceRuntimeField::OomScoreAdj(value) => {
3445                render_optional_string(output, "oom_score_adj", Some(value));
3446            }
3447            GeneratedServiceRuntimeField::Pid(value) => {
3448                render_optional_string(output, "pid", Some(value));
3449            }
3450            GeneratedServiceRuntimeField::Scale(value) => {
3451                render_optional_string(output, "scale", Some(value));
3452            }
3453            GeneratedServiceRuntimeField::VolumesFrom(values) => {
3454                render_configured_string_sequence(output, "volumes_from", values);
3455            }
3456        }
3457    }
3458}
3459
3460fn generated_runtime_field_safe(field: &GeneratedServiceRuntimeField) -> bool {
3461    let safe = |value: &GeneratedString| !value.expose().is_empty() && !value.expose().contains(['\n', '\r', '$']);
3462    let unsigned = |value: &GeneratedString| safe(value) && value.expose().bytes().all(|byte| byte.is_ascii_digit());
3463    let bounded_unsigned = |value: &GeneratedString| unsigned(value) && value.expose().parse::<i128>().is_ok();
3464    let signed_range = |value: &GeneratedString, min: i32, max: i32| {
3465        safe(value)
3466            && value
3467                .expose()
3468                .parse::<i32>()
3469                .is_ok_and(|number| (min..=max).contains(&number))
3470    };
3471    let decimal = |value: &GeneratedString| safe(value) && normalize_generated_decimal(value.expose()).is_some();
3472    let reference = |value: &GeneratedString| {
3473        safe(value)
3474            && (!value.expose().contains(':')
3475                || value
3476                    .expose()
3477                    .split_once(':')
3478                    .is_some_and(|(_, target)| !target.is_empty()))
3479    };
3480    match field {
3481        GeneratedServiceRuntimeField::Domainname(value)
3482        | GeneratedServiceRuntimeField::Isolation(value)
3483        | GeneratedServiceRuntimeField::MacAddress(value)
3484        | GeneratedServiceRuntimeField::Uts(value)
3485        | GeneratedServiceRuntimeField::Cpuset(value) => safe(value),
3486        GeneratedServiceRuntimeField::UseApiSocket(_) | GeneratedServiceRuntimeField::OomKillDisable(_) => true,
3487        GeneratedServiceRuntimeField::GpusAll(value) => safe(value) && value.expose() == "all",
3488        GeneratedServiceRuntimeField::CpuRtRuntime(GeneratedCpuRtRuntime::Microseconds(value)) => unsigned(value),
3489        GeneratedServiceRuntimeField::CpuShares(value) | GeneratedServiceRuntimeField::Scale(value) => {
3490            bounded_unsigned(value)
3491        }
3492        GeneratedServiceRuntimeField::CpuRtRuntime(GeneratedCpuRtRuntime::Duration(value)) => {
3493            safe(value)
3494                && matches!(
3495                    CpuRtRuntime::parse_string(value.expose().to_owned()),
3496                    CpuRtRuntime::Duration(_)
3497                )
3498        }
3499        GeneratedServiceRuntimeField::Cpus(value) => decimal(value),
3500        GeneratedServiceRuntimeField::DeviceCgroupRules(values) => values.iter().all(safe),
3501        GeneratedServiceRuntimeField::Ipc(value)
3502        | GeneratedServiceRuntimeField::NetworkMode(value)
3503        | GeneratedServiceRuntimeField::Pid(value) => reference(value),
3504        GeneratedServiceRuntimeField::MemReservation(value) => {
3505            safe(value) && valid_generated_runtime_memory(value.expose(), false)
3506        }
3507        GeneratedServiceRuntimeField::MemswapLimit(value) => {
3508            safe(value) && valid_generated_runtime_memory(value.expose(), true)
3509        }
3510        GeneratedServiceRuntimeField::MemSwappiness(value) => signed_range(value, 0, 100),
3511        GeneratedServiceRuntimeField::OomScoreAdj(value) => signed_range(value, -1000, 1000),
3512        GeneratedServiceRuntimeField::VolumesFrom(values) => values.iter().all(reference),
3513    }
3514}
3515
3516fn normalize_generated_decimal(value: &str) -> Option<()> {
3517    let (whole, fraction) = value.split_once('.').unwrap_or((value, ""));
3518    let valid_shape = if value.contains('.') {
3519        !whole.is_empty() && !fraction.is_empty()
3520    } else {
3521        !whole.is_empty()
3522    };
3523    (valid_shape
3524        && whole.bytes().all(|byte| byte.is_ascii_digit())
3525        && fraction.bytes().all(|byte| byte.is_ascii_digit())
3526        && value.bytes().filter(|byte| *byte == b'.').count() <= 1)
3527        .then_some(())
3528}
3529
3530/// Validates resolved byte-value spellings without applying a host-size conversion.
3531///
3532/// `memswap_limit` additionally permits Compose's explicit `-1` unlimited branch. The
3533/// relationship between a positive swap value and `mem_limit` remains a project diagnostic,
3534/// because it cannot be decided while fields are added independently.
3535fn valid_generated_runtime_memory(value: &str, allow_unlimited: bool) -> bool {
3536    if allow_unlimited && value == "-1" {
3537        return true;
3538    }
3539    if !value.is_empty() && value.bytes().all(|byte| byte == b'0') {
3540        return true;
3541    }
3542    let Some(amount) = ["kb", "mb", "gb", "b", "k", "m", "g"]
3543        .into_iter()
3544        .find_map(|unit| value.strip_suffix(unit))
3545    else {
3546        return false;
3547    };
3548    !amount.is_empty() && amount.bytes().all(|byte| byte.is_ascii_digit())
3549}
3550
3551fn render_pids_limit(output: &mut String, limit: &GeneratedPidsLimit) {
3552    write_field(output, 2, "pids_limit");
3553    match limit {
3554        GeneratedPidsLimit::Unlimited => output.push_str("-1\n"),
3555        GeneratedPidsLimit::Finite(decimal) => {
3556            output.push_str(decimal);
3557            output.push('\n');
3558        }
3559    }
3560}
3561
3562fn render_shm_size(output: &mut String, size: &GeneratedShmSize) {
3563    let GeneratedShmSize::Explicit { amount, unit } = size;
3564    write_field(output, 2, "shm_size");
3565    write_quoted(output, &format!("{}{}", amount.expose(), unit.as_str()));
3566    output.push('\n');
3567}
3568
3569fn render_mem_limit(output: &mut String, limit: &GeneratedMemLimit) {
3570    let GeneratedMemLimit::Explicit { amount, unit } = limit;
3571    write_field(output, 2, "mem_limit");
3572    write_quoted(output, &format!("{}{}", amount.expose(), unit.as_str()));
3573    output.push('\n');
3574}
3575
3576fn render_devices(output: &mut String, devices: &[GeneratedDevice]) {
3577    if devices.is_empty() {
3578        output.push_str("    devices: []\n");
3579        return;
3580    }
3581    output.push_str("    devices:\n");
3582    for device in devices {
3583        match device {
3584            GeneratedDevice::Short(value) => {
3585                output.push_str("      - ");
3586                write_quoted(output, value.expose());
3587                output.push('\n');
3588            }
3589            GeneratedDevice::Long(value) => {
3590                output.push_str("      - source: ");
3591                write_quoted(output, value.source().expose());
3592                output.push('\n');
3593                if let Some(target) = value.target() {
3594                    output.push_str("        target: ");
3595                    write_quoted(output, target.expose());
3596                    output.push('\n');
3597                }
3598                if let Some(permissions) = value.permissions() {
3599                    output.push_str("        permissions: ");
3600                    write_quoted(output, permissions.expose());
3601                    output.push('\n');
3602                }
3603            }
3604        }
3605    }
3606}
3607
3608fn render_dns(output: &mut String, dns: &GeneratedDns) {
3609    match dns {
3610        GeneratedDns::Scalar(value) => render_optional_string(output, "dns", Some(value)),
3611        GeneratedDns::List(values) => render_configured_string_sequence(output, "dns", values),
3612    }
3613}
3614
3615fn render_dns_search(output: &mut String, search: &GeneratedDnsSearch) {
3616    match search {
3617        GeneratedDnsSearch::Scalar(value) => render_optional_string(output, "dns_search", Some(value)),
3618        GeneratedDnsSearch::List(values) => render_configured_string_sequence(output, "dns_search", values),
3619    }
3620}
3621
3622fn render_tmpfs(output: &mut String, tmpfs: &GeneratedTmpfs) {
3623    match tmpfs {
3624        GeneratedTmpfs::Scalar(item) => render_optional_string(output, "tmpfs", Some(item)),
3625        GeneratedTmpfs::List(items) => render_configured_string_sequence(output, "tmpfs", items),
3626    }
3627}
3628
3629fn render_sysctls(output: &mut String, sysctls: &GeneratedSysctls) {
3630    match sysctls {
3631        GeneratedSysctls::Map(entries) if entries.is_empty() => output.push_str("    sysctls: {}\n"),
3632        GeneratedSysctls::Map(entries) => {
3633            output.push_str("    sysctls:\n");
3634            for entry in entries {
3635                write_indent(output, 3);
3636                write_quoted(output, entry.name());
3637                output.push_str(": ");
3638                write_quoted(output, entry.value().expose());
3639                output.push('\n');
3640            }
3641        }
3642        GeneratedSysctls::List(items) => render_configured_string_sequence(output, "sysctls", items),
3643    }
3644}
3645
3646fn render_logging(output: &mut String, logging: &GeneratedLogging) {
3647    output.push_str("    logging:\n      driver: ");
3648    write_quoted(output, logging.driver.expose());
3649    output.push('\n');
3650    if logging.options.is_empty() {
3651        output.push_str("      options: {}\n");
3652        return;
3653    }
3654    output.push_str("      options:\n");
3655    for option in &logging.options {
3656        write_indent(output, 4);
3657        write_quoted(output, option.name());
3658        output.push_str(": ");
3659        match option.value() {
3660            GeneratedLoggingOptionValue::String(value) => write_quoted(output, value.expose()),
3661            GeneratedLoggingOptionValue::Number(value) => output.push_str(value.expose()),
3662            GeneratedLoggingOptionValue::Null => output.push_str("null"),
3663        }
3664        output.push('\n');
3665    }
3666}
3667
3668fn render_ulimits(output: &mut String, ulimits: &GeneratedUlimits) {
3669    if ulimits.entries.is_empty() {
3670        output.push_str("    ulimits: {}\n");
3671        return;
3672    }
3673    output.push_str("    ulimits:\n");
3674    for limit in &ulimits.entries {
3675        write_indent(output, 3);
3676        write_quoted(output, limit.name());
3677        match limit.value() {
3678            GeneratedUlimitValue::Single(value) => {
3679                output.push_str(": ");
3680                write_quoted(output, value.expose());
3681                output.push('\n');
3682            }
3683            GeneratedUlimitValue::Range {
3684                soft: Some(soft),
3685                hard: Some(hard),
3686            } => {
3687                output.push_str(":\n");
3688                write_indent(output, 4);
3689                output.push_str("soft: ");
3690                write_quoted(output, soft.expose());
3691                output.push('\n');
3692                write_indent(output, 4);
3693                output.push_str("hard: ");
3694                write_quoted(output, hard.expose());
3695                output.push('\n');
3696            }
3697            GeneratedUlimitValue::Range { .. } => {
3698                unreachable!("generated ulimit ranges are validated during construction")
3699            }
3700        }
3701    }
3702}
3703
3704fn render_pull_policy(output: &mut String, policy: &GeneratedPullPolicy) {
3705    write_field(output, 2, "pull_policy");
3706    let value = match policy {
3707        GeneratedPullPolicy::Always => "always".to_owned(),
3708        GeneratedPullPolicy::Never => "never".to_owned(),
3709        GeneratedPullPolicy::Missing => "missing".to_owned(),
3710        GeneratedPullPolicy::IfNotPresentAlias => "if_not_present".to_owned(),
3711        GeneratedPullPolicy::Build => "build".to_owned(),
3712        GeneratedPullPolicy::Daily => "daily".to_owned(),
3713        GeneratedPullPolicy::Weekly => "weekly".to_owned(),
3714        GeneratedPullPolicy::Every(duration) => format!("every_{}", duration.expose()),
3715    };
3716    write_quoted(output, &value);
3717    output.push('\n');
3718}
3719
3720fn render_entrypoint(output: &mut String, entrypoint: &GeneratedEntrypoint) {
3721    match entrypoint {
3722        GeneratedEntrypoint::List(arguments) if arguments.is_empty() => output.push_str("    entrypoint: []\n"),
3723        GeneratedEntrypoint::List(arguments) => render_string_sequence(output, "entrypoint", arguments),
3724        GeneratedEntrypoint::String(entrypoint) => render_optional_string(output, "entrypoint", Some(entrypoint)),
3725        GeneratedEntrypoint::Empty => output.push_str("    entrypoint: []\n"),
3726    }
3727}
3728
3729fn render_restart(output: &mut String, restart: GeneratedRestartPolicy) {
3730    write_field(output, 2, "restart");
3731    let value = match restart {
3732        GeneratedRestartPolicy::No => "no".to_owned(),
3733        GeneratedRestartPolicy::Always => "always".to_owned(),
3734        GeneratedRestartPolicy::OnFailure { maximum_retries: None } => "on-failure".to_owned(),
3735        GeneratedRestartPolicy::OnFailure {
3736            maximum_retries: Some(maximum_retries),
3737        } => format!("on-failure:{maximum_retries}"),
3738        GeneratedRestartPolicy::UnlessStopped => "unless-stopped".to_owned(),
3739    };
3740    write_quoted(output, &value);
3741    output.push('\n');
3742}
3743
3744fn render_optional_string(output: &mut String, key: &str, value: Option<&GeneratedString>) {
3745    if let Some(value) = value {
3746        write_field(output, 2, key);
3747        write_quoted(output, value.expose());
3748        output.push('\n');
3749    }
3750}
3751
3752fn render_command(output: &mut String, command: &GeneratedCommand) {
3753    match command {
3754        GeneratedCommand::Exec(arguments) if arguments.is_empty() => output.push_str("    command: []\n"),
3755        GeneratedCommand::Exec(arguments) => render_string_sequence(output, "command", arguments),
3756        GeneratedCommand::Shell(command) => render_optional_string(output, "command", Some(command)),
3757        GeneratedCommand::Empty => output.push_str("    command: []\n"),
3758    }
3759}
3760
3761fn render_environment(output: &mut String, environment: &[GeneratedEnvironment]) {
3762    if environment.is_empty() {
3763        return;
3764    }
3765    output.push_str("    environment:\n");
3766    let mut variables: Vec<_> = environment.iter().collect();
3767    variables.sort_by(|left, right| left.name.cmp(&right.name));
3768    for variable in variables {
3769        output.push_str("      - ");
3770        let value = variable.value.as_ref().map_or_else(
3771            || variable.name.clone(),
3772            |value| format!("{}={}", variable.name, value.expose()),
3773        );
3774        write_quoted(output, &value);
3775        output.push('\n');
3776    }
3777}
3778
3779fn render_environment_files(output: &mut String, environment_files: &[GeneratedEnvironmentFile]) {
3780    if environment_files.is_empty() {
3781        return;
3782    }
3783    output.push_str("    env_file:\n");
3784    for environment_file in environment_files {
3785        match environment_file {
3786            GeneratedEnvironmentFile::Short(path) => {
3787                output.push_str("      - ");
3788                write_quoted(output, path.expose());
3789                output.push('\n');
3790            }
3791            GeneratedEnvironmentFile::Long { path, required, format } => {
3792                output.push_str("      - path: ");
3793                write_quoted(output, path.expose());
3794                output.push('\n');
3795                if let Some(required) = required {
3796                    output.push_str("        required: ");
3797                    output.push_str(if *required { "true\n" } else { "false\n" });
3798                }
3799                if let Some(format) = format {
3800                    output.push_str("        format: ");
3801                    write_quoted(
3802                        output,
3803                        match format {
3804                            GeneratedEnvironmentFileFormat::Raw => "raw",
3805                        },
3806                    );
3807                    output.push('\n');
3808                }
3809            }
3810        }
3811    }
3812}
3813
3814fn render_labels(output: &mut String, labels: &[GeneratedLabel]) {
3815    if labels.is_empty() {
3816        return;
3817    }
3818    output.push_str("    labels:\n");
3819    for label in labels {
3820        output.push_str("      ");
3821        write_quoted(output, &label.name);
3822        output.push_str(": ");
3823        write_quoted(output, label.value.expose());
3824        output.push('\n');
3825    }
3826}
3827
3828fn render_annotations(output: &mut String, annotations: &[GeneratedAnnotation]) {
3829    if annotations.is_empty() {
3830        output.push_str("    annotations: {}\n");
3831        return;
3832    }
3833    output.push_str("    annotations:\n");
3834    for annotation in annotations {
3835        output.push_str("      ");
3836        write_quoted(output, &annotation.name);
3837        output.push_str(": ");
3838        write_quoted(output, annotation.value.expose());
3839        output.push('\n');
3840    }
3841}
3842
3843fn render_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
3844    if values.is_empty() {
3845        return;
3846    }
3847    write_indent(output, 2);
3848    output.push_str(key);
3849    output.push_str(":\n");
3850    for value in values {
3851        output.push_str("      - ");
3852        write_quoted(output, value.expose());
3853        output.push('\n');
3854    }
3855}
3856
3857fn render_configured_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
3858    if values.is_empty() {
3859        write_indent(output, 2);
3860        output.push_str(key);
3861        output.push_str(": []\n");
3862    } else {
3863        render_string_sequence(output, key, values);
3864    }
3865}
3866
3867fn render_extra_hosts(output: &mut String, hosts: &[GeneratedExtraHost]) {
3868    if hosts.is_empty() {
3869        return;
3870    }
3871    output.push_str("    extra_hosts:\n");
3872    for host in hosts {
3873        output.push_str("      - ");
3874        write_quoted(output, &format!("{}={}", host.hostname, host.address));
3875        output.push('\n');
3876    }
3877}
3878
3879fn render_ports(output: &mut String, ports: &[GeneratedPort]) {
3880    if ports.is_empty() {
3881        return;
3882    }
3883    output.push_str("    ports:\n");
3884    for port in ports {
3885        if port.protocol == GeneratedProtocol::Sctp {
3886            render_short_sctp_port(output, port);
3887            continue;
3888        }
3889        output.push_str("      - target: ");
3890        output.push_str(&port.target.to_string());
3891        output.push('\n');
3892        if let Some(published) = port.published {
3893            output.push_str("        published: ");
3894            write_quoted(output, &published.to_string());
3895            output.push('\n');
3896        }
3897        if let Some(host_ip) = &port.host_ip {
3898            output.push_str("        host_ip: ");
3899            write_quoted(output, host_ip);
3900            output.push('\n');
3901        }
3902        output.push_str("        protocol: ");
3903        write_quoted(output, port.protocol.as_str());
3904        output.push('\n');
3905    }
3906}
3907
3908fn render_short_sctp_port(output: &mut String, port: &GeneratedPort) {
3909    let mut value = String::new();
3910    if let Some(host_ip) = &port.host_ip {
3911        if host_ip.contains(':') && !(host_ip.starts_with('[') && host_ip.ends_with(']')) {
3912            value.push('[');
3913            value.push_str(host_ip);
3914            value.push(']');
3915        } else {
3916            value.push_str(host_ip);
3917        }
3918        value.push(':');
3919    }
3920    if let Some(published) = port.published {
3921        value.push_str(&published.to_string());
3922        value.push(':');
3923    }
3924    value.push_str(&port.target.to_string());
3925    value.push_str("/sctp");
3926
3927    output.push_str("      - ");
3928    write_quoted(output, &value);
3929    output.push('\n');
3930}
3931
3932fn render_mounts(output: &mut String, mounts: &[GeneratedMount]) {
3933    if mounts.is_empty() {
3934        return;
3935    }
3936    output.push_str("    volumes:\n");
3937    for mount in mounts {
3938        match &mount.kind {
3939            GeneratedMountKind::Bind {
3940                source,
3941                selinux: Some(selinux),
3942            } => render_selinux_bind(output, source, mount, *selinux),
3943            kind => render_long_mount(output, kind, mount),
3944        }
3945    }
3946}
3947
3948fn render_selinux_bind(output: &mut String, source: &str, mount: &GeneratedMount, selinux: GeneratedSelinux) {
3949    let mut value = format!("{source}:{}:{}", mount.target, selinux.as_str());
3950    if mount.read_only {
3951        value.push_str(",ro");
3952    }
3953    output.push_str("      - ");
3954    write_quoted(output, &value);
3955    output.push('\n');
3956}
3957
3958fn render_long_mount(output: &mut String, kind: &GeneratedMountKind, mount: &GeneratedMount) {
3959    let (mount_type, source) = match kind {
3960        GeneratedMountKind::Volume { source } => ("volume", Some(source.as_str())),
3961        GeneratedMountKind::Bind { source, selinux: None } => ("bind", Some(source.as_str())),
3962        GeneratedMountKind::Anonymous => ("volume", None),
3963        GeneratedMountKind::Bind { selinux: Some(_), .. } => return,
3964    };
3965    output.push_str("      - type: ");
3966    write_quoted(output, mount_type);
3967    output.push('\n');
3968    if let Some(source) = source {
3969        output.push_str("        source: ");
3970        write_quoted(output, source);
3971        output.push('\n');
3972    }
3973    output.push_str("        target: ");
3974    write_quoted(output, &mount.target);
3975    output.push('\n');
3976    if mount.read_only {
3977        output.push_str("        read_only: true\n");
3978    }
3979}
3980
3981fn render_networks(output: &mut String, networks: &[GeneratedNetworkAttachment]) {
3982    if networks.is_empty() {
3983        return;
3984    }
3985    output.push_str("    networks:\n");
3986    for network in networks {
3987        output.push_str("      ");
3988        write_quoted(output, &network.name);
3989        if network.aliases.is_empty() && network.ipv4_address.is_none() && network.ipv6_address.is_none() {
3990            output.push_str(": {}\n");
3991            continue;
3992        }
3993        output.push_str(":\n");
3994        if !network.aliases.is_empty() {
3995            output.push_str("        aliases:\n");
3996            for alias in &network.aliases {
3997                output.push_str("          - ");
3998                write_quoted(output, alias);
3999                output.push('\n');
4000            }
4001        }
4002        for (field, address) in [
4003            ("ipv4_address", network.ipv4_address.as_ref()),
4004            ("ipv6_address", network.ipv6_address.as_ref()),
4005        ] {
4006            if let Some(address) = address {
4007                output.push_str("        ");
4008                output.push_str(field);
4009                output.push_str(": ");
4010                write_quoted(output, address.expose());
4011                output.push('\n');
4012            }
4013        }
4014    }
4015}
4016
4017fn render_network_definitions(output: &mut String, networks: &[GeneratedNetwork]) {
4018    if networks.is_empty() {
4019        return;
4020    }
4021    output.push_str("networks:\n");
4022    for network in networks {
4023        match network {
4024            GeneratedNetwork::Basic(network) => render_basic_resource(output, network),
4025            GeneratedNetwork::Definition(network) => render_network_definition(output, network),
4026        }
4027    }
4028}
4029
4030fn render_network_definition(output: &mut String, network: &GeneratedNetworkDefinition) {
4031    output.push_str("  ");
4032    write_quoted(output, &network.name);
4033    if network.custom_name.is_none()
4034        && network.driver.is_none()
4035        && network.driver_opts.is_none()
4036        && network.enable_ipv6.is_none()
4037        && network.internal.is_none()
4038        && network.labels.is_none()
4039    {
4040        output.push_str(": {}\n");
4041        return;
4042    }
4043    output.push_str(":\n");
4044    if let Some(custom_name) = &network.custom_name {
4045        output.push_str("    name: ");
4046        write_quoted(output, custom_name);
4047        output.push('\n');
4048    }
4049    if let Some(driver) = &network.driver {
4050        output.push_str("    driver: ");
4051        write_quoted(output, driver.expose());
4052        output.push('\n');
4053    }
4054    if let Some(driver_opts) = &network.driver_opts {
4055        if driver_opts.is_empty() {
4056            output.push_str("    driver_opts: {}\n");
4057        } else {
4058            output.push_str("    driver_opts:\n");
4059            for option in driver_opts {
4060                output.push_str("      ");
4061                write_quoted(output, option.name());
4062                output.push_str(": ");
4063                match option.value() {
4064                    GeneratedNetworkDriverOptionValue::String(value) => {
4065                        write_quoted(output, value.expose());
4066                    }
4067                    GeneratedNetworkDriverOptionValue::Number(value) => {
4068                        output.push_str(value.expose());
4069                    }
4070                }
4071                output.push('\n');
4072            }
4073        }
4074    }
4075    if let Some(enable_ipv6) = network.enable_ipv6 {
4076        output.push_str("    enable_ipv6: ");
4077        output.push_str(if enable_ipv6 { "true\n" } else { "false\n" });
4078    }
4079    if let Some(internal) = network.internal {
4080        output.push_str("    internal: ");
4081        output.push_str(if internal { "true\n" } else { "false\n" });
4082    }
4083    if let Some(labels) = &network.labels {
4084        if labels.is_empty() {
4085            output.push_str("    labels: {}\n");
4086        } else {
4087            output.push_str("    labels:\n");
4088            for label in labels {
4089                output.push_str("      ");
4090                write_quoted(output, label.name());
4091                output.push_str(": ");
4092                write_quoted(output, label.value().expose());
4093                output.push('\n');
4094            }
4095        }
4096    }
4097}
4098
4099fn render_volume_definitions(output: &mut String, volumes: &[GeneratedVolume]) {
4100    if volumes.is_empty() {
4101        return;
4102    }
4103    output.push_str("volumes:\n");
4104    for volume in volumes {
4105        match volume {
4106            GeneratedVolume::Basic(volume) => render_basic_resource(output, volume),
4107            GeneratedVolume::Definition(volume) => render_volume_definition(output, volume),
4108        }
4109    }
4110}
4111
4112fn render_file_definitions<T>(
4113    output: &mut String,
4114    field: &str,
4115    definitions: &[T],
4116    name: impl Fn(&T) -> &str,
4117    file: impl Fn(&T) -> &GeneratedString,
4118) {
4119    if definitions.is_empty() {
4120        return;
4121    }
4122    output.push_str(field);
4123    output.push_str(":\n");
4124    for definition in definitions {
4125        output.push_str("  ");
4126        write_quoted(output, name(definition));
4127        output.push_str(":\n    file: ");
4128        write_quoted(output, file(definition).expose());
4129        output.push('\n');
4130    }
4131}
4132
4133fn render_volume_definition(output: &mut String, volume: &GeneratedVolumeDefinition) {
4134    output.push_str("  ");
4135    write_quoted(output, &volume.name);
4136    if volume.custom_name.is_none()
4137        && volume.driver.is_none()
4138        && volume.driver_opts.is_none()
4139        && volume.labels.is_none()
4140    {
4141        output.push_str(": {}\n");
4142        return;
4143    }
4144    output.push_str(":\n");
4145    if let Some(custom_name) = &volume.custom_name {
4146        output.push_str("    name: ");
4147        write_quoted(output, custom_name);
4148        output.push('\n');
4149    }
4150    if let Some(driver) = &volume.driver {
4151        output.push_str("    driver: ");
4152        write_quoted(output, driver.expose());
4153        output.push('\n');
4154    }
4155    if let Some(driver_opts) = &volume.driver_opts {
4156        if driver_opts.is_empty() {
4157            output.push_str("    driver_opts: {}\n");
4158        } else {
4159            output.push_str("    driver_opts:\n");
4160            for option in driver_opts {
4161                output.push_str("      ");
4162                write_quoted(output, option.name());
4163                output.push_str(": ");
4164                match option.value() {
4165                    GeneratedVolumeDriverOptionValue::String(value) => write_quoted(output, value.expose()),
4166                    GeneratedVolumeDriverOptionValue::Number(value) => output.push_str(value.expose()),
4167                }
4168                output.push('\n');
4169            }
4170        }
4171    }
4172    if let Some(labels) = &volume.labels {
4173        if labels.is_empty() {
4174            output.push_str("    labels: {}\n");
4175        } else {
4176            output.push_str("    labels:\n");
4177            for label in labels {
4178                output.push_str("      ");
4179                write_quoted(output, label.name());
4180                output.push_str(": ");
4181                write_quoted(output, label.value().expose());
4182                output.push('\n');
4183            }
4184        }
4185    }
4186}
4187
4188fn render_basic_resource(output: &mut String, resource: &GeneratedResource) {
4189    output.push_str("  ");
4190    write_quoted(output, &resource.name);
4191    if !resource.external && resource.custom_name.is_none() {
4192        output.push_str(": {}\n");
4193        return;
4194    }
4195    output.push_str(":\n");
4196    if let Some(custom_name) = &resource.custom_name {
4197        output.push_str("    name: ");
4198        write_quoted(output, custom_name);
4199        output.push('\n');
4200    }
4201    if resource.external {
4202        output.push_str("    external: true\n");
4203    }
4204}
4205
4206fn write_field(output: &mut String, depth: usize, key: &str) {
4207    write_indent(output, depth);
4208    output.push_str(key);
4209    output.push_str(": ");
4210}
4211
4212fn write_indent(output: &mut String, depth: usize) {
4213    for _ in 0..depth {
4214        output.push_str("  ");
4215    }
4216}
4217
4218fn required(kind: &'static str, value: String) -> Result<String, GenerationError> {
4219    if value.is_empty() {
4220        return Err(GenerationError::EmptyValue(kind));
4221    }
4222    if value.contains('\0') {
4223        return Err(GenerationError::ContainsNul(kind));
4224    }
4225    Ok(value)
4226}
4227
4228fn require_generated_string(kind: &'static str, value: &GeneratedString) -> Result<(), GenerationError> {
4229    if value.expose().is_empty() {
4230        return Err(GenerationError::EmptyValue(kind));
4231    }
4232    Ok(())
4233}
4234
4235fn generated_file_resource_name(value: String) -> Result<String, GenerationError> {
4236    if value.is_empty() || value.contains(['\0', '\r', '\n', '$']) {
4237        Err(GenerationError::InvalidFileResourceName)
4238    } else {
4239        Ok(value)
4240    }
4241}
4242
4243fn generated_file_resource_path(value: GeneratedString) -> Result<GeneratedString, GenerationError> {
4244    if value.expose().is_empty() || value.expose().contains(['\0', '\r', '\n', '$']) {
4245        Err(GenerationError::InvalidFileResourcePath)
4246    } else {
4247        Ok(value)
4248    }
4249}
4250
4251fn validate_generated_device_member(
4252    member: &'static str,
4253    value: &GeneratedString,
4254    require_non_empty: bool,
4255) -> Result<(), GenerationError> {
4256    if valid_generated_device_string(value.expose(), require_non_empty) {
4257        Ok(())
4258    } else {
4259        Err(GenerationError::InvalidDeviceValue(member))
4260    }
4261}
4262
4263fn validate_generated_ulimit_value(value: &GeneratedString) -> Result<(), GenerationError> {
4264    let value = value.expose();
4265    if value.contains(['\r', '\n', '$'])
4266        || (value != "-1" && (value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit())))
4267    {
4268        return Err(GenerationError::InvalidUlimitValue);
4269    }
4270    Ok(())
4271}
4272
4273fn valid_yaml_number(value: &str) -> bool {
4274    let ordinary = !value.is_empty()
4275        && value.bytes().any(|byte| byte.is_ascii_digit())
4276        && value.bytes().all(|byte| {
4277            byte.is_ascii_digit()
4278                || matches!(
4279                    byte,
4280                    b'+' | b'-'
4281                        | b'.'
4282                        | b'_'
4283                        | b'e'
4284                        | b'E'
4285                        | b'x'
4286                        | b'X'
4287                        | b'o'
4288                        | b'O'
4289                        | b'a'..=b'f'
4290                        | b'A'..=b'F'
4291                )
4292        });
4293    let special = matches!(
4294        value,
4295        ".inf" | ".Inf" | ".INF" | "+.inf" | "+.Inf" | "+.INF" | "-.inf" | "-.Inf" | "-.INF" | ".nan" | ".NaN" | ".NAN"
4296    );
4297    if !ordinary && !special {
4298        return false;
4299    }
4300    let parse = YamlFile::parse(value);
4301    if !parse.ok() {
4302        return false;
4303    }
4304    let file = parse.tree();
4305    let Some(document) = file.document() else {
4306        return false;
4307    };
4308    let Some(scalar) = document.as_scalar() else {
4309        return false;
4310    };
4311    let position = scalar.byte_range();
4312    position.start == 0
4313        && position.end as usize == value.len()
4314        && matches!(
4315            ScalarValue::from_scalar(&scalar).scalar_type(),
4316            ScalarType::Integer | ScalarType::Float
4317        )
4318}
4319
4320fn environment_name(value: String) -> Result<String, GenerationError> {
4321    let value = required("environment name", value)?;
4322    if value.contains('=') {
4323        return Err(GenerationError::InvalidEnvironmentName);
4324    }
4325    Ok(value)
4326}
4327
4328fn valid_container_name(value: &str) -> bool {
4329    let mut bytes = value.bytes();
4330    bytes.next().is_some_and(|byte| byte.is_ascii_alphanumeric())
4331        && bytes
4332            .next()
4333            .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
4334        && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
4335}
4336
4337fn short_component(kind: &'static str, value: String, separator: char) -> Result<String, GenerationError> {
4338    let value = required(kind, value)?;
4339    if value.contains(separator) {
4340        return Err(GenerationError::InvalidShortComponent(kind));
4341    }
4342    Ok(value)
4343}
4344
4345fn set_once<T>(slot: &mut Option<T>, value: T, field: &'static str) -> Result<(), GenerationError> {
4346    if slot.is_some() {
4347        return Err(GenerationError::DuplicateField(field));
4348    }
4349    *slot = Some(value);
4350    Ok(())
4351}
4352
4353fn insert_named<T>(
4354    values: &mut Vec<T>,
4355    value: T,
4356    kind: &'static str,
4357    name: impl Fn(&T) -> &str,
4358) -> Result<(), GenerationError> {
4359    let value_name = name(&value);
4360    if values.iter().any(|candidate| name(candidate) == value_name) {
4361        return Err(GenerationError::DuplicateName {
4362            kind,
4363            name: value_name.to_owned(),
4364        });
4365    }
4366    values.push(value);
4367    Ok(())
4368}
4369
4370fn command_is_sensitive(command: &GeneratedCommand) -> bool {
4371    match command {
4372        GeneratedCommand::Exec(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
4373        GeneratedCommand::Shell(command) => command.is_sensitive(),
4374        GeneratedCommand::Empty => false,
4375    }
4376}
4377
4378fn entrypoint_is_sensitive(entrypoint: &GeneratedEntrypoint) -> bool {
4379    match entrypoint {
4380        GeneratedEntrypoint::List(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
4381        GeneratedEntrypoint::String(entrypoint) => entrypoint.is_sensitive(),
4382        GeneratedEntrypoint::Empty => false,
4383    }
4384}