1use std::{error::Error, fmt};
4
5use crate::{model::ComposeDocument, source::SourceId, syntax::SyntaxDocument};
6
7use super::write_quoted;
8
9#[derive(Clone, Debug, Eq, PartialEq)]
11#[non_exhaustive]
12pub enum GenerationError {
13 EmptyValue(&'static str),
15 ContainsNul(&'static str),
17 InvalidEnvironmentName,
19 InvalidShortComponent(&'static str),
21 InvalidSelinuxBind,
23 DuplicateField(&'static str),
25 DuplicateName {
27 kind: &'static str,
29 name: String,
31 },
32 InvalidPort,
34 UnrepresentableSctpHostIp,
36 MissingService,
38 InternalInvariant(&'static str),
40}
41
42impl fmt::Display for GenerationError {
43 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44 match self {
45 Self::EmptyValue(kind) => write!(formatter, "generated {kind} must not be empty"),
46 Self::ContainsNul(kind) => write!(formatter, "generated {kind} must not contain a NUL byte"),
47 Self::InvalidEnvironmentName => formatter.write_str("generated environment name must not contain `=`"),
48 Self::InvalidShortComponent(kind) => {
49 write!(formatter, "generated {kind} contains its reserved short-form separator")
50 }
51 Self::InvalidSelinuxBind => formatter
52 .write_str("generated SELinux bind source and target must not contain the short-syntax `:` separator"),
53 Self::DuplicateField(field) => write!(formatter, "generated field `{field}` was configured more than once"),
54 Self::DuplicateName { kind, name } => {
55 write!(formatter, "generated {kind} `{name}` was added more than once")
56 }
57 Self::InvalidPort => formatter.write_str("generated container target port must be greater than zero"),
58 Self::UnrepresentableSctpHostIp => formatter.write_str(
59 "generated SCTP port with a host address also requires a published port for Compose short syntax",
60 ),
61 Self::MissingService => formatter.write_str("generated Compose project requires at least one service"),
62 Self::InternalInvariant(stage) => write!(formatter, "generated Compose document failed {stage} validation"),
63 }
64 }
65}
66
67impl Error for GenerationError {}
68
69#[derive(Clone, Eq, PartialEq)]
71pub struct GeneratedString {
72 value: String,
73 sensitive: bool,
74}
75
76impl GeneratedString {
77 pub fn plain(value: impl Into<String>) -> Result<Self, GenerationError> {
83 Self::new(value.into(), false)
84 }
85
86 pub fn sensitive(value: impl Into<String>) -> Result<Self, GenerationError> {
92 Self::new(value.into(), true)
93 }
94
95 fn new(value: String, sensitive: bool) -> Result<Self, GenerationError> {
96 if value.contains('\0') {
97 return Err(GenerationError::ContainsNul("string"));
98 }
99 Ok(Self { value, sensitive })
100 }
101
102 #[must_use]
104 pub fn expose(&self) -> &str {
105 &self.value
106 }
107
108 #[must_use]
110 pub const fn is_sensitive(&self) -> bool {
111 self.sensitive
112 }
113}
114
115impl fmt::Debug for GeneratedString {
116 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117 formatter
118 .debug_struct("GeneratedString")
119 .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
120 .field("sensitive", &self.sensitive)
121 .finish()
122 }
123}
124
125#[derive(Clone, Debug, Eq, PartialEq)]
127#[non_exhaustive]
128pub enum GeneratedCommand {
129 Exec(Vec<GeneratedString>),
131 Shell(GeneratedString),
133 Empty,
135}
136
137#[derive(Clone, Debug, Eq, PartialEq)]
139pub struct GeneratedEnvironment {
140 name: String,
141 value: Option<GeneratedString>,
142}
143
144impl GeneratedEnvironment {
145 pub fn literal(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
151 Ok(Self {
152 name: environment_name(name.into())?,
153 value: Some(value),
154 })
155 }
156
157 pub fn host(name: impl Into<String>) -> Result<Self, GenerationError> {
163 Ok(Self {
164 name: environment_name(name.into())?,
165 value: None,
166 })
167 }
168
169 #[must_use]
171 pub fn name(&self) -> &str {
172 &self.name
173 }
174
175 #[must_use]
177 pub const fn value(&self) -> Option<&GeneratedString> {
178 self.value.as_ref()
179 }
180}
181
182#[derive(Clone, Debug, Eq, PartialEq)]
184pub struct GeneratedExtraHost {
185 hostname: String,
186 address: String,
187}
188
189impl GeneratedExtraHost {
190 pub fn new(hostname: impl Into<String>, address: impl Into<String>) -> Result<Self, GenerationError> {
196 let hostname = short_component("extra-host hostname", hostname.into(), '=')?;
197 let address = short_component("extra-host address", address.into(), '=')?;
198 Ok(Self { hostname, address })
199 }
200
201 #[must_use]
203 pub fn hostname(&self) -> &str {
204 &self.hostname
205 }
206
207 #[must_use]
209 pub fn address(&self) -> &str {
210 &self.address
211 }
212}
213
214#[derive(Clone, Copy, Debug, Eq, PartialEq)]
216#[non_exhaustive]
217pub enum GeneratedProtocol {
218 Tcp,
220 Udp,
222 Sctp,
224}
225
226impl GeneratedProtocol {
227 const fn as_str(self) -> &'static str {
228 match self {
229 Self::Tcp => "tcp",
230 Self::Udp => "udp",
231 Self::Sctp => "sctp",
232 }
233 }
234}
235
236#[derive(Clone, Debug, Eq, PartialEq)]
238pub struct GeneratedPort {
239 target: u16,
240 published: Option<u16>,
241 host_ip: Option<String>,
242 protocol: GeneratedProtocol,
243}
244
245impl GeneratedPort {
246 pub fn new(
254 target: u16,
255 published: Option<u16>,
256 host_ip: Option<String>,
257 protocol: GeneratedProtocol,
258 ) -> Result<Self, GenerationError> {
259 if target == 0 {
260 return Err(GenerationError::InvalidPort);
261 }
262 if let Some(host_ip) = host_ip.as_deref() {
263 required("port host address", host_ip.to_owned())?;
264 if protocol == GeneratedProtocol::Sctp && published.is_none() {
265 return Err(GenerationError::UnrepresentableSctpHostIp);
266 }
267 }
268 Ok(Self {
269 target,
270 published,
271 host_ip,
272 protocol,
273 })
274 }
275
276 #[must_use]
278 pub const fn target(&self) -> u16 {
279 self.target
280 }
281
282 #[must_use]
284 pub const fn published(&self) -> Option<u16> {
285 self.published
286 }
287
288 #[must_use]
290 pub fn host_ip(&self) -> Option<&str> {
291 self.host_ip.as_deref()
292 }
293
294 #[must_use]
296 pub const fn protocol(&self) -> GeneratedProtocol {
297 self.protocol
298 }
299}
300
301#[derive(Clone, Copy, Debug, Eq, PartialEq)]
303#[non_exhaustive]
304pub enum GeneratedSelinux {
305 Private,
307 Shared,
309}
310
311impl GeneratedSelinux {
312 const fn as_str(self) -> &'static str {
313 match self {
314 Self::Private => "Z",
315 Self::Shared => "z",
316 }
317 }
318}
319
320#[derive(Clone, Debug, Eq, PartialEq)]
321enum GeneratedMountKind {
322 Volume {
323 source: String,
324 },
325 Bind {
326 source: String,
327 selinux: Option<GeneratedSelinux>,
328 },
329 Anonymous,
330}
331
332#[derive(Clone, Debug, Eq, PartialEq)]
334pub struct GeneratedMount {
335 kind: GeneratedMountKind,
336 target: String,
337 read_only: bool,
338}
339
340impl GeneratedMount {
341 pub fn volume(
347 source: impl Into<String>,
348 target: impl Into<String>,
349 read_only: bool,
350 ) -> Result<Self, GenerationError> {
351 Ok(Self {
352 kind: GeneratedMountKind::Volume {
353 source: required("volume source", source.into())?,
354 },
355 target: required("mount target", target.into())?,
356 read_only,
357 })
358 }
359
360 pub fn bind(
367 source: impl Into<String>,
368 target: impl Into<String>,
369 read_only: bool,
370 selinux: Option<GeneratedSelinux>,
371 ) -> Result<Self, GenerationError> {
372 let source = required("bind source", source.into())?;
373 let target = required("mount target", target.into())?;
374 if selinux.is_some() && (source.contains(':') || target.contains(':')) {
375 return Err(GenerationError::InvalidSelinuxBind);
376 }
377 Ok(Self {
378 kind: GeneratedMountKind::Bind { source, selinux },
379 target,
380 read_only,
381 })
382 }
383
384 pub fn anonymous(target: impl Into<String>, read_only: bool) -> Result<Self, GenerationError> {
390 Ok(Self {
391 kind: GeneratedMountKind::Anonymous,
392 target: required("mount target", target.into())?,
393 read_only,
394 })
395 }
396
397 #[must_use]
399 pub fn target(&self) -> &str {
400 &self.target
401 }
402
403 #[must_use]
405 pub const fn read_only(&self) -> bool {
406 self.read_only
407 }
408}
409
410#[derive(Clone, Debug, Eq, PartialEq)]
412pub struct GeneratedNetworkAttachment {
413 name: String,
414 aliases: Vec<String>,
415}
416
417impl GeneratedNetworkAttachment {
418 pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
424 Ok(Self {
425 name: required("network name", name.into())?,
426 aliases: Vec::new(),
427 })
428 }
429
430 pub fn add_alias(&mut self, alias: impl Into<String>) -> Result<(), GenerationError> {
436 self.aliases.push(required("network alias", alias.into())?);
437 Ok(())
438 }
439
440 #[must_use]
442 pub fn name(&self) -> &str {
443 &self.name
444 }
445
446 #[must_use]
448 pub fn aliases(&self) -> &[String] {
449 &self.aliases
450 }
451}
452
453#[derive(Clone, Debug, Eq, PartialEq)]
455pub struct GeneratedResource {
456 name: String,
457 external: bool,
458 custom_name: Option<String>,
459}
460
461impl GeneratedResource {
462 pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
468 Ok(Self {
469 name: required("resource name", name.into())?,
470 external: false,
471 custom_name: None,
472 })
473 }
474
475 pub fn external(name: impl Into<String>) -> Result<Self, GenerationError> {
481 Ok(Self {
482 name: required("resource name", name.into())?,
483 external: true,
484 custom_name: None,
485 })
486 }
487
488 pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
496 let name = required("custom resource name", name.into())?;
497 set_once(&mut self.custom_name, name, "resource name")
498 }
499
500 #[must_use]
502 pub fn name(&self) -> &str {
503 &self.name
504 }
505
506 #[must_use]
508 pub const fn is_external(&self) -> bool {
509 self.external
510 }
511
512 #[must_use]
514 pub fn custom_name(&self) -> Option<&str> {
515 self.custom_name.as_deref()
516 }
517}
518
519#[derive(Clone, Debug, Eq, PartialEq)]
521pub struct GeneratedService {
522 name: String,
523 image: Option<GeneratedString>,
524 command: Option<GeneratedCommand>,
525 environment: Vec<GeneratedEnvironment>,
526 user: Option<GeneratedString>,
527 userns_mode: Option<GeneratedString>,
528 group_add: Vec<GeneratedString>,
529 working_dir: Option<GeneratedString>,
530 read_only: Option<bool>,
531 extra_hosts: Vec<GeneratedExtraHost>,
532 ports: Vec<GeneratedPort>,
533 mounts: Vec<GeneratedMount>,
534 networks: Vec<GeneratedNetworkAttachment>,
535}
536
537impl GeneratedService {
538 pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
544 Ok(Self {
545 name: required("service name", name.into())?,
546 image: None,
547 command: None,
548 environment: Vec::new(),
549 user: None,
550 userns_mode: None,
551 group_add: Vec::new(),
552 working_dir: None,
553 read_only: None,
554 extra_hosts: Vec::new(),
555 ports: Vec::new(),
556 mounts: Vec::new(),
557 networks: Vec::new(),
558 })
559 }
560
561 #[must_use]
563 pub fn name(&self) -> &str {
564 &self.name
565 }
566
567 pub fn set_image(&mut self, image: GeneratedString) -> Result<(), GenerationError> {
574 require_generated_string("service image", &image)?;
575 set_once(&mut self.image, image, "image")
576 }
577
578 pub fn set_command(&mut self, command: GeneratedCommand) -> Result<(), GenerationError> {
584 set_once(&mut self.command, command, "command")
585 }
586
587 pub fn add_environment(&mut self, environment: GeneratedEnvironment) {
589 self.environment.push(environment);
590 }
591
592 pub fn set_user(&mut self, user: GeneratedString) -> Result<(), GenerationError> {
598 set_once(&mut self.user, user, "user")
599 }
600
601 pub fn set_userns_mode(&mut self, mode: GeneratedString) -> Result<(), GenerationError> {
608 require_generated_string("user namespace mode", &mode)?;
609 set_once(&mut self.userns_mode, mode, "userns_mode")
610 }
611
612 pub fn add_supplementary_group(&mut self, group: GeneratedString) -> Result<(), GenerationError> {
618 require_generated_string("supplementary group", &group)?;
619 self.group_add.push(group);
620 Ok(())
621 }
622
623 pub fn set_working_dir(&mut self, directory: GeneratedString) -> Result<(), GenerationError> {
630 require_generated_string("working directory", &directory)?;
631 set_once(&mut self.working_dir, directory, "working_dir")
632 }
633
634 pub fn set_read_only(&mut self, read_only: bool) -> Result<(), GenerationError> {
640 set_once(&mut self.read_only, read_only, "read_only")
641 }
642
643 pub fn add_extra_host(&mut self, host: GeneratedExtraHost) {
645 self.extra_hosts.push(host);
646 }
647
648 pub fn add_port(&mut self, port: GeneratedPort) {
650 self.ports.push(port);
651 }
652
653 pub fn add_mount(&mut self, mount: GeneratedMount) {
655 self.mounts.push(mount);
656 }
657
658 pub fn add_network(&mut self, network: GeneratedNetworkAttachment) -> Result<(), GenerationError> {
664 if self.networks.iter().any(|candidate| candidate.name == network.name) {
665 return Err(GenerationError::DuplicateName {
666 kind: "service network",
667 name: network.name,
668 });
669 }
670 self.networks.push(network);
671 Ok(())
672 }
673
674 fn is_sensitive(&self) -> bool {
675 self.image.as_ref().is_some_and(GeneratedString::is_sensitive)
676 || self.command.as_ref().is_some_and(command_is_sensitive)
677 || self
678 .environment
679 .iter()
680 .filter_map(GeneratedEnvironment::value)
681 .any(GeneratedString::is_sensitive)
682 || [self.user.as_ref(), self.userns_mode.as_ref(), self.working_dir.as_ref()]
683 .into_iter()
684 .flatten()
685 .any(GeneratedString::is_sensitive)
686 || self.group_add.iter().any(GeneratedString::is_sensitive)
687 }
688}
689
690#[derive(Clone, Debug, Default, Eq, PartialEq)]
692pub struct ComposeDocumentBuilder {
693 name: Option<String>,
694 services: Vec<GeneratedService>,
695 networks: Vec<GeneratedResource>,
696 volumes: Vec<GeneratedResource>,
697}
698
699impl ComposeDocumentBuilder {
700 #[must_use]
702 pub const fn new() -> Self {
703 Self {
704 name: None,
705 services: Vec::new(),
706 networks: Vec::new(),
707 volumes: Vec::new(),
708 }
709 }
710
711 pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
717 let name = required("project name", name.into())?;
718 set_once(&mut self.name, name, "name")
719 }
720
721 pub fn add_service(&mut self, service: GeneratedService) -> Result<(), GenerationError> {
727 insert_named(&mut self.services, service, "service", GeneratedService::name)
728 }
729
730 pub fn add_network(&mut self, network: GeneratedResource) -> Result<(), GenerationError> {
736 insert_named(&mut self.networks, network, "network", GeneratedResource::name)
737 }
738
739 pub fn add_volume(&mut self, volume: GeneratedResource) -> Result<(), GenerationError> {
745 insert_named(&mut self.volumes, volume, "volume", GeneratedResource::name)
746 }
747
748 pub fn build(self, source_id: SourceId) -> Result<GeneratedComposeDocument, GenerationError> {
755 if self.services.is_empty() {
756 return Err(GenerationError::MissingService);
757 }
758 let sensitive = self.services.iter().any(GeneratedService::is_sensitive);
759 let text = render_document(&self);
760 let syntax = SyntaxDocument::parse(source_id, text.clone())
761 .map_err(|_| GenerationError::InternalInvariant("syntax-tree"))?;
762 if !syntax.is_valid() {
763 return Err(GenerationError::InternalInvariant("syntax"));
764 }
765 let model = ComposeDocument::parse(syntax.document());
766 if !model.is_valid() {
767 return Err(GenerationError::InternalInvariant("typed-model"));
768 }
769 let document = model
770 .document()
771 .cloned()
772 .ok_or(GenerationError::InternalInvariant("document-root"))?;
773 Ok(GeneratedComposeDocument {
774 text,
775 sensitive,
776 document,
777 })
778 }
779}
780
781#[derive(Clone, Eq, PartialEq)]
783pub struct GeneratedComposeDocument {
784 text: String,
785 sensitive: bool,
786 document: ComposeDocument,
787}
788
789impl GeneratedComposeDocument {
790 #[must_use]
792 pub fn text(&self) -> &str {
793 &self.text
794 }
795
796 #[must_use]
798 pub const fn document(&self) -> &ComposeDocument {
799 &self.document
800 }
801
802 #[must_use]
804 pub const fn is_sensitive(&self) -> bool {
805 self.sensitive
806 }
807}
808
809impl fmt::Debug for GeneratedComposeDocument {
810 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
811 formatter
812 .debug_struct("GeneratedComposeDocument")
813 .field("text", &if self.sensitive { "<redacted>" } else { &self.text })
814 .field("sensitive", &self.sensitive)
815 .field("document", &if self.sensitive { "<redacted>" } else { "validated" })
816 .finish()
817 }
818}
819
820fn render_document(project: &ComposeDocumentBuilder) -> String {
821 let mut output = String::new();
822 if let Some(name) = &project.name {
823 output.push_str("name: ");
824 write_quoted(&mut output, name);
825 output.push('\n');
826 }
827 output.push_str("services:\n");
828 for service in &project.services {
829 write_indent(&mut output, 1);
830 write_quoted(&mut output, &service.name);
831 output.push_str(":\n");
832 render_service(&mut output, service);
833 }
834 render_resources(&mut output, "networks", &project.networks);
835 render_resources(&mut output, "volumes", &project.volumes);
836 output
837}
838
839fn render_service(output: &mut String, service: &GeneratedService) {
840 render_optional_string(output, "image", service.image.as_ref());
841 if let Some(command) = &service.command {
842 render_command(output, command);
843 }
844 render_environment(output, &service.environment);
845 render_optional_string(output, "user", service.user.as_ref());
846 render_optional_string(output, "userns_mode", service.userns_mode.as_ref());
847 render_string_sequence(output, "group_add", &service.group_add);
848 render_optional_string(output, "working_dir", service.working_dir.as_ref());
849 if let Some(read_only) = service.read_only {
850 write_field(output, 2, "read_only");
851 output.push_str(if read_only { "true\n" } else { "false\n" });
852 }
853 render_extra_hosts(output, &service.extra_hosts);
854 render_ports(output, &service.ports);
855 render_mounts(output, &service.mounts);
856 render_networks(output, &service.networks);
857}
858
859fn render_optional_string(output: &mut String, key: &str, value: Option<&GeneratedString>) {
860 if let Some(value) = value {
861 write_field(output, 2, key);
862 write_quoted(output, value.expose());
863 output.push('\n');
864 }
865}
866
867fn render_command(output: &mut String, command: &GeneratedCommand) {
868 match command {
869 GeneratedCommand::Exec(arguments) if arguments.is_empty() => output.push_str(" command: []\n"),
870 GeneratedCommand::Exec(arguments) => render_string_sequence(output, "command", arguments),
871 GeneratedCommand::Shell(command) => render_optional_string(output, "command", Some(command)),
872 GeneratedCommand::Empty => output.push_str(" command: []\n"),
873 }
874}
875
876fn render_environment(output: &mut String, environment: &[GeneratedEnvironment]) {
877 if environment.is_empty() {
878 return;
879 }
880 output.push_str(" environment:\n");
881 for variable in environment {
882 output.push_str(" - ");
883 let value = variable.value.as_ref().map_or_else(
884 || variable.name.clone(),
885 |value| format!("{}={}", variable.name, value.expose()),
886 );
887 write_quoted(output, &value);
888 output.push('\n');
889 }
890}
891
892fn render_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
893 if values.is_empty() {
894 return;
895 }
896 write_indent(output, 2);
897 output.push_str(key);
898 output.push_str(":\n");
899 for value in values {
900 output.push_str(" - ");
901 write_quoted(output, value.expose());
902 output.push('\n');
903 }
904}
905
906fn render_extra_hosts(output: &mut String, hosts: &[GeneratedExtraHost]) {
907 if hosts.is_empty() {
908 return;
909 }
910 output.push_str(" extra_hosts:\n");
911 for host in hosts {
912 output.push_str(" - ");
913 write_quoted(output, &format!("{}={}", host.hostname, host.address));
914 output.push('\n');
915 }
916}
917
918fn render_ports(output: &mut String, ports: &[GeneratedPort]) {
919 if ports.is_empty() {
920 return;
921 }
922 output.push_str(" ports:\n");
923 for port in ports {
924 if port.protocol == GeneratedProtocol::Sctp {
925 render_short_sctp_port(output, port);
926 continue;
927 }
928 output.push_str(" - target: ");
929 output.push_str(&port.target.to_string());
930 output.push('\n');
931 if let Some(published) = port.published {
932 output.push_str(" published: ");
933 write_quoted(output, &published.to_string());
934 output.push('\n');
935 }
936 if let Some(host_ip) = &port.host_ip {
937 output.push_str(" host_ip: ");
938 write_quoted(output, host_ip);
939 output.push('\n');
940 }
941 output.push_str(" protocol: ");
942 write_quoted(output, port.protocol.as_str());
943 output.push('\n');
944 }
945}
946
947fn render_short_sctp_port(output: &mut String, port: &GeneratedPort) {
948 let mut value = String::new();
949 if let Some(host_ip) = &port.host_ip {
950 if host_ip.contains(':') && !(host_ip.starts_with('[') && host_ip.ends_with(']')) {
951 value.push('[');
952 value.push_str(host_ip);
953 value.push(']');
954 } else {
955 value.push_str(host_ip);
956 }
957 value.push(':');
958 }
959 if let Some(published) = port.published {
960 value.push_str(&published.to_string());
961 value.push(':');
962 }
963 value.push_str(&port.target.to_string());
964 value.push_str("/sctp");
965
966 output.push_str(" - ");
967 write_quoted(output, &value);
968 output.push('\n');
969}
970
971fn render_mounts(output: &mut String, mounts: &[GeneratedMount]) {
972 if mounts.is_empty() {
973 return;
974 }
975 output.push_str(" volumes:\n");
976 for mount in mounts {
977 match &mount.kind {
978 GeneratedMountKind::Bind {
979 source,
980 selinux: Some(selinux),
981 } => render_selinux_bind(output, source, mount, *selinux),
982 kind => render_long_mount(output, kind, mount),
983 }
984 }
985}
986
987fn render_selinux_bind(output: &mut String, source: &str, mount: &GeneratedMount, selinux: GeneratedSelinux) {
988 let mut value = format!("{source}:{}:{}", mount.target, selinux.as_str());
989 if mount.read_only {
990 value.push_str(",ro");
991 }
992 output.push_str(" - ");
993 write_quoted(output, &value);
994 output.push('\n');
995}
996
997fn render_long_mount(output: &mut String, kind: &GeneratedMountKind, mount: &GeneratedMount) {
998 let (mount_type, source) = match kind {
999 GeneratedMountKind::Volume { source } => ("volume", Some(source.as_str())),
1000 GeneratedMountKind::Bind { source, selinux: None } => ("bind", Some(source.as_str())),
1001 GeneratedMountKind::Anonymous => ("volume", None),
1002 GeneratedMountKind::Bind { selinux: Some(_), .. } => return,
1003 };
1004 output.push_str(" - type: ");
1005 write_quoted(output, mount_type);
1006 output.push('\n');
1007 if let Some(source) = source {
1008 output.push_str(" source: ");
1009 write_quoted(output, source);
1010 output.push('\n');
1011 }
1012 output.push_str(" target: ");
1013 write_quoted(output, &mount.target);
1014 output.push('\n');
1015 if mount.read_only {
1016 output.push_str(" read_only: true\n");
1017 }
1018}
1019
1020fn render_networks(output: &mut String, networks: &[GeneratedNetworkAttachment]) {
1021 if networks.is_empty() {
1022 return;
1023 }
1024 output.push_str(" networks:\n");
1025 for network in networks {
1026 output.push_str(" ");
1027 write_quoted(output, &network.name);
1028 if network.aliases.is_empty() {
1029 output.push_str(": {}\n");
1030 } else {
1031 output.push_str(":\n aliases:\n");
1032 for alias in &network.aliases {
1033 output.push_str(" - ");
1034 write_quoted(output, alias);
1035 output.push('\n');
1036 }
1037 }
1038 }
1039}
1040
1041fn render_resources(output: &mut String, section: &str, resources: &[GeneratedResource]) {
1042 if resources.is_empty() {
1043 return;
1044 }
1045 output.push_str(section);
1046 output.push_str(":\n");
1047 for resource in resources {
1048 output.push_str(" ");
1049 write_quoted(output, &resource.name);
1050 if !resource.external && resource.custom_name.is_none() {
1051 output.push_str(": {}\n");
1052 continue;
1053 }
1054 output.push_str(":\n");
1055 if let Some(custom_name) = &resource.custom_name {
1056 output.push_str(" name: ");
1057 write_quoted(output, custom_name);
1058 output.push('\n');
1059 }
1060 if resource.external {
1061 output.push_str(" external: true\n");
1062 }
1063 }
1064}
1065
1066fn write_field(output: &mut String, depth: usize, key: &str) {
1067 write_indent(output, depth);
1068 output.push_str(key);
1069 output.push_str(": ");
1070}
1071
1072fn write_indent(output: &mut String, depth: usize) {
1073 for _ in 0..depth {
1074 output.push_str(" ");
1075 }
1076}
1077
1078fn required(kind: &'static str, value: String) -> Result<String, GenerationError> {
1079 if value.is_empty() {
1080 return Err(GenerationError::EmptyValue(kind));
1081 }
1082 if value.contains('\0') {
1083 return Err(GenerationError::ContainsNul(kind));
1084 }
1085 Ok(value)
1086}
1087
1088fn require_generated_string(kind: &'static str, value: &GeneratedString) -> Result<(), GenerationError> {
1089 if value.expose().is_empty() {
1090 return Err(GenerationError::EmptyValue(kind));
1091 }
1092 Ok(())
1093}
1094
1095fn environment_name(value: String) -> Result<String, GenerationError> {
1096 let value = required("environment name", value)?;
1097 if value.contains('=') {
1098 return Err(GenerationError::InvalidEnvironmentName);
1099 }
1100 Ok(value)
1101}
1102
1103fn short_component(kind: &'static str, value: String, separator: char) -> Result<String, GenerationError> {
1104 let value = required(kind, value)?;
1105 if value.contains(separator) {
1106 return Err(GenerationError::InvalidShortComponent(kind));
1107 }
1108 Ok(value)
1109}
1110
1111fn set_once<T>(slot: &mut Option<T>, value: T, field: &'static str) -> Result<(), GenerationError> {
1112 if slot.is_some() {
1113 return Err(GenerationError::DuplicateField(field));
1114 }
1115 *slot = Some(value);
1116 Ok(())
1117}
1118
1119fn insert_named<T>(
1120 values: &mut Vec<T>,
1121 value: T,
1122 kind: &'static str,
1123 name: impl Fn(&T) -> &str,
1124) -> Result<(), GenerationError> {
1125 let value_name = name(&value);
1126 if values.iter().any(|candidate| name(candidate) == value_name) {
1127 return Err(GenerationError::DuplicateName {
1128 kind,
1129 name: value_name.to_owned(),
1130 });
1131 }
1132 values.push(value);
1133 Ok(())
1134}
1135
1136fn command_is_sensitive(command: &GeneratedCommand) -> bool {
1137 match command {
1138 GeneratedCommand::Exec(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
1139 GeneratedCommand::Shell(command) => command.is_sensitive(),
1140 GeneratedCommand::Empty => false,
1141 }
1142}