1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum ComposeTextError {
10 Empty,
12 InvalidName,
14 InvalidEnvironmentKey,
16}
17
18impl fmt::Display for ComposeTextError {
19 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::Empty => formatter.write_str("Compose text value cannot be empty"),
22 Self::InvalidName => formatter.write_str("invalid Compose name"),
23 Self::InvalidEnvironmentKey => formatter.write_str("invalid Compose environment key"),
24 }
25 }
26}
27
28impl Error for ComposeTextError {}
29
30#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct ComposeBuild {
33 context: String,
34 dockerfile: Option<String>,
35 target: Option<String>,
36 args: Vec<(String, String)>,
37}
38
39impl ComposeBuild {
40 pub fn new(context: impl AsRef<str>) -> Result<Self, ComposeTextError> {
42 let context = normalize_non_empty(context.as_ref())?;
43 Ok(Self {
44 context,
45 dockerfile: None,
46 target: None,
47 args: Vec::new(),
48 })
49 }
50
51 #[must_use]
53 pub fn with_dockerfile(mut self, dockerfile: impl Into<String>) -> Self {
54 self.dockerfile = Some(dockerfile.into());
55 self
56 }
57
58 #[must_use]
60 pub fn with_target(mut self, target: impl Into<String>) -> Self {
61 self.target = Some(target.into());
62 self
63 }
64
65 pub fn with_arg(
67 mut self,
68 key: impl AsRef<str>,
69 value: impl Into<String>,
70 ) -> Result<Self, ComposeTextError> {
71 let key = normalize_env_key(key.as_ref())?;
72 self.args.push((key, value.into()));
73 Ok(self)
74 }
75
76 #[must_use]
78 pub fn context(&self) -> &str {
79 &self.context
80 }
81
82 #[must_use]
84 pub fn dockerfile(&self) -> Option<&str> {
85 self.dockerfile.as_deref()
86 }
87
88 #[must_use]
90 pub fn target(&self) -> Option<&str> {
91 self.target.as_deref()
92 }
93
94 #[must_use]
96 pub fn args(&self) -> &[(String, String)] {
97 &self.args
98 }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct ComposeService {
104 name: String,
105 image: Option<String>,
106 build: Option<ComposeBuild>,
107 ports: Vec<String>,
108 volumes: Vec<String>,
109 environment: Vec<(String, String)>,
110 depends_on: Vec<String>,
111 networks: Vec<String>,
112 profiles: Vec<String>,
113}
114
115impl ComposeService {
116 #[must_use]
118 pub fn new(name: impl AsRef<str>) -> Self {
119 Self::empty(name.as_ref().trim())
120 }
121
122 pub fn try_new(name: impl AsRef<str>) -> Result<Self, ComposeTextError> {
124 let name = normalize_name(name.as_ref())?;
125 Ok(Self::empty(&name))
126 }
127
128 #[must_use]
130 pub fn with_image(mut self, image: impl Into<String>) -> Self {
131 self.image = Some(image.into());
132 self
133 }
134
135 #[must_use]
137 pub fn with_build(mut self, build: ComposeBuild) -> Self {
138 self.build = Some(build);
139 self
140 }
141
142 #[must_use]
144 pub fn with_port(mut self, port: impl Into<String>) -> Self {
145 self.ports.push(port.into());
146 self
147 }
148
149 #[must_use]
151 pub fn with_volume(mut self, volume: impl Into<String>) -> Self {
152 self.volumes.push(volume.into());
153 self
154 }
155
156 pub fn with_environment(
158 mut self,
159 key: impl AsRef<str>,
160 value: impl Into<String>,
161 ) -> Result<Self, ComposeTextError> {
162 let key = normalize_env_key(key.as_ref())?;
163 self.environment.push((key, value.into()));
164 Ok(self)
165 }
166
167 pub fn with_dependency(mut self, name: impl AsRef<str>) -> Result<Self, ComposeTextError> {
169 self.depends_on.push(normalize_name(name.as_ref())?);
170 Ok(self)
171 }
172
173 pub fn with_network(mut self, name: impl AsRef<str>) -> Result<Self, ComposeTextError> {
175 self.networks.push(normalize_name(name.as_ref())?);
176 Ok(self)
177 }
178
179 pub fn with_profile(mut self, name: impl AsRef<str>) -> Result<Self, ComposeTextError> {
181 self.profiles.push(normalize_name(name.as_ref())?);
182 Ok(self)
183 }
184
185 #[must_use]
187 pub fn name(&self) -> &str {
188 &self.name
189 }
190
191 #[must_use]
193 pub fn image(&self) -> Option<&str> {
194 self.image.as_deref()
195 }
196
197 #[must_use]
199 pub const fn build(&self) -> Option<&ComposeBuild> {
200 self.build.as_ref()
201 }
202
203 #[must_use]
205 pub fn ports(&self) -> &[String] {
206 &self.ports
207 }
208
209 #[must_use]
211 pub fn volumes(&self) -> &[String] {
212 &self.volumes
213 }
214
215 #[must_use]
217 pub fn environment(&self) -> &[(String, String)] {
218 &self.environment
219 }
220
221 #[must_use]
223 pub fn depends_on(&self) -> &[String] {
224 &self.depends_on
225 }
226
227 #[must_use]
229 pub fn networks(&self) -> &[String] {
230 &self.networks
231 }
232
233 #[must_use]
235 pub fn profiles(&self) -> &[String] {
236 &self.profiles
237 }
238
239 fn empty(name: &str) -> Self {
240 Self {
241 name: name.to_string(),
242 image: None,
243 build: None,
244 ports: Vec::new(),
245 volumes: Vec::new(),
246 environment: Vec::new(),
247 depends_on: Vec::new(),
248 networks: Vec::new(),
249 profiles: Vec::new(),
250 }
251 }
252}
253
254#[derive(Clone, Debug, Default, Eq, PartialEq)]
256pub struct ComposeProject {
257 services: Vec<ComposeService>,
258}
259
260impl ComposeProject {
261 #[must_use]
263 pub fn new() -> Self {
264 Self::default()
265 }
266
267 #[must_use]
269 pub fn with_service(mut self, service: ComposeService) -> Self {
270 self.services.push(service);
271 self
272 }
273
274 #[must_use]
276 pub fn services(&self) -> &[ComposeService] {
277 &self.services
278 }
279}
280
281fn normalize_non_empty(value: &str) -> Result<String, ComposeTextError> {
282 let trimmed = value.trim();
283 if trimmed.is_empty() {
284 Err(ComposeTextError::Empty)
285 } else {
286 Ok(trimmed.to_string())
287 }
288}
289
290fn normalize_name(value: &str) -> Result<String, ComposeTextError> {
291 let trimmed = value.trim();
292 if trimmed.is_empty() {
293 return Err(ComposeTextError::Empty);
294 }
295 if trimmed
296 .bytes()
297 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
298 {
299 Ok(trimmed.to_string())
300 } else {
301 Err(ComposeTextError::InvalidName)
302 }
303}
304
305fn normalize_env_key(value: &str) -> Result<String, ComposeTextError> {
306 let trimmed = value.trim();
307 let mut chars = trimmed.chars();
308 let Some(first) = chars.next() else {
309 return Err(ComposeTextError::InvalidEnvironmentKey);
310 };
311 if !(first == '_' || first.is_ascii_alphabetic()) {
312 return Err(ComposeTextError::InvalidEnvironmentKey);
313 }
314 if chars.any(|character| !(character == '_' || character.is_ascii_alphanumeric())) {
315 return Err(ComposeTextError::InvalidEnvironmentKey);
316 }
317 Ok(trimmed.to_string())
318}
319
320#[cfg(test)]
321mod tests {
322 use super::{ComposeBuild, ComposeProject, ComposeService, ComposeTextError};
323
324 #[test]
325 fn models_compose_service_primitives() -> Result<(), Box<dyn std::error::Error>> {
326 let build = ComposeBuild::new(".")?.with_arg("PROFILE", "dev")?;
327 let service = ComposeService::try_new("web")?
328 .with_image("ghcr.io/rustuse/app:latest")
329 .with_build(build)
330 .with_port("8080:80")
331 .with_volume("cache:/var/cache")
332 .with_environment("RUST_LOG", "info")?
333 .with_dependency("db")?
334 .with_network("frontend")?
335 .with_profile("dev")?;
336 let project = ComposeProject::new().with_service(service);
337
338 assert_eq!(project.services()[0].name(), "web");
339 assert_eq!(project.services()[0].environment()[0].0, "RUST_LOG");
340 assert_eq!(
341 project.services()[0].build().unwrap().args()[0].0,
342 "PROFILE"
343 );
344 assert_eq!(
345 ComposeService::try_new("bad name"),
346 Err(ComposeTextError::InvalidName)
347 );
348 Ok(())
349 }
350}