Skip to main content

use_docker_compose/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::error::Error;
6
7/// Error returned when Compose model text is invalid.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum ComposeTextError {
10    /// The value was empty after trimming.
11    Empty,
12    /// A service, network, profile, or dependency name was invalid.
13    InvalidName,
14    /// An environment variable key was invalid.
15    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/// Docker Compose build metadata.
31#[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    /// Creates build metadata with a context path.
41    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    /// Adds a Dockerfile path.
52    #[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    /// Adds a target stage.
59    #[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    /// Adds a build argument.
66    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    /// Returns the build context path.
77    #[must_use]
78    pub fn context(&self) -> &str {
79        &self.context
80    }
81
82    /// Returns the optional Dockerfile path.
83    #[must_use]
84    pub fn dockerfile(&self) -> Option<&str> {
85        self.dockerfile.as_deref()
86    }
87
88    /// Returns the optional target stage.
89    #[must_use]
90    pub fn target(&self) -> Option<&str> {
91        self.target.as_deref()
92    }
93
94    /// Returns build args.
95    #[must_use]
96    pub fn args(&self) -> &[(String, String)] {
97        &self.args
98    }
99}
100
101/// Docker Compose service metadata.
102#[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    /// Creates a service with trusted static text.
117    #[must_use]
118    pub fn new(name: impl AsRef<str>) -> Self {
119        Self::empty(name.as_ref().trim())
120    }
121
122    /// Creates a service with validated dynamic text.
123    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    /// Adds an image reference string.
129    #[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    /// Adds build metadata.
136    #[must_use]
137    pub fn with_build(mut self, build: ComposeBuild) -> Self {
138        self.build = Some(build);
139        self
140    }
141
142    /// Adds a port mapping string.
143    #[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    /// Adds a volume string.
150    #[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    /// Adds an environment key/value pair.
157    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    /// Adds a service dependency.
168    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    /// Adds a named network.
174    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    /// Adds a profile.
180    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    /// Returns the service name.
186    #[must_use]
187    pub fn name(&self) -> &str {
188        &self.name
189    }
190
191    /// Returns the optional image reference string.
192    #[must_use]
193    pub fn image(&self) -> Option<&str> {
194        self.image.as_deref()
195    }
196
197    /// Returns the optional build metadata.
198    #[must_use]
199    pub const fn build(&self) -> Option<&ComposeBuild> {
200        self.build.as_ref()
201    }
202
203    /// Returns port mapping strings.
204    #[must_use]
205    pub fn ports(&self) -> &[String] {
206        &self.ports
207    }
208
209    /// Returns volume strings.
210    #[must_use]
211    pub fn volumes(&self) -> &[String] {
212        &self.volumes
213    }
214
215    /// Returns environment key/value pairs.
216    #[must_use]
217    pub fn environment(&self) -> &[(String, String)] {
218        &self.environment
219    }
220
221    /// Returns dependencies.
222    #[must_use]
223    pub fn depends_on(&self) -> &[String] {
224        &self.depends_on
225    }
226
227    /// Returns network names.
228    #[must_use]
229    pub fn networks(&self) -> &[String] {
230        &self.networks
231    }
232
233    /// Returns profiles.
234    #[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/// A lightweight Compose project model.
255#[derive(Clone, Debug, Default, Eq, PartialEq)]
256pub struct ComposeProject {
257    services: Vec<ComposeService>,
258}
259
260impl ComposeProject {
261    /// Creates an empty project.
262    #[must_use]
263    pub fn new() -> Self {
264        Self::default()
265    }
266
267    /// Adds a service.
268    #[must_use]
269    pub fn with_service(mut self, service: ComposeService) -> Self {
270        self.services.push(service);
271        self
272    }
273
274    /// Returns services.
275    #[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}