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