#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::fmt;
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ComposeTextError {
Empty,
InvalidName,
InvalidEnvironmentKey,
}
impl fmt::Display for ComposeTextError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Compose text value cannot be empty"),
Self::InvalidName => formatter.write_str("invalid Compose name"),
Self::InvalidEnvironmentKey => formatter.write_str("invalid Compose environment key"),
}
}
}
impl Error for ComposeTextError {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ComposeBuild {
context: String,
dockerfile: Option<String>,
target: Option<String>,
args: Vec<(String, String)>,
}
impl ComposeBuild {
pub fn new(context: impl AsRef<str>) -> Result<Self, ComposeTextError> {
let context = normalize_non_empty(context.as_ref())?;
Ok(Self {
context,
dockerfile: None,
target: None,
args: Vec::new(),
})
}
#[must_use]
pub fn with_dockerfile(mut self, dockerfile: impl Into<String>) -> Self {
self.dockerfile = Some(dockerfile.into());
self
}
#[must_use]
pub fn with_target(mut self, target: impl Into<String>) -> Self {
self.target = Some(target.into());
self
}
pub fn with_arg(
mut self,
key: impl AsRef<str>,
value: impl Into<String>,
) -> Result<Self, ComposeTextError> {
let key = normalize_env_key(key.as_ref())?;
self.args.push((key, value.into()));
Ok(self)
}
#[must_use]
pub fn context(&self) -> &str {
&self.context
}
#[must_use]
pub fn dockerfile(&self) -> Option<&str> {
self.dockerfile.as_deref()
}
#[must_use]
pub fn target(&self) -> Option<&str> {
self.target.as_deref()
}
#[must_use]
pub fn args(&self) -> &[(String, String)] {
&self.args
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ComposeService {
name: String,
image: Option<String>,
build: Option<ComposeBuild>,
ports: Vec<String>,
volumes: Vec<String>,
environment: Vec<(String, String)>,
depends_on: Vec<String>,
networks: Vec<String>,
profiles: Vec<String>,
}
impl ComposeService {
#[must_use]
pub fn new(name: impl AsRef<str>) -> Self {
Self::empty(name.as_ref().trim())
}
pub fn try_new(name: impl AsRef<str>) -> Result<Self, ComposeTextError> {
let name = normalize_name(name.as_ref())?;
Ok(Self::empty(&name))
}
#[must_use]
pub fn with_image(mut self, image: impl Into<String>) -> Self {
self.image = Some(image.into());
self
}
#[must_use]
pub fn with_build(mut self, build: ComposeBuild) -> Self {
self.build = Some(build);
self
}
#[must_use]
pub fn with_port(mut self, port: impl Into<String>) -> Self {
self.ports.push(port.into());
self
}
#[must_use]
pub fn with_volume(mut self, volume: impl Into<String>) -> Self {
self.volumes.push(volume.into());
self
}
pub fn with_environment(
mut self,
key: impl AsRef<str>,
value: impl Into<String>,
) -> Result<Self, ComposeTextError> {
let key = normalize_env_key(key.as_ref())?;
self.environment.push((key, value.into()));
Ok(self)
}
pub fn with_dependency(mut self, name: impl AsRef<str>) -> Result<Self, ComposeTextError> {
self.depends_on.push(normalize_name(name.as_ref())?);
Ok(self)
}
pub fn with_network(mut self, name: impl AsRef<str>) -> Result<Self, ComposeTextError> {
self.networks.push(normalize_name(name.as_ref())?);
Ok(self)
}
pub fn with_profile(mut self, name: impl AsRef<str>) -> Result<Self, ComposeTextError> {
self.profiles.push(normalize_name(name.as_ref())?);
Ok(self)
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn image(&self) -> Option<&str> {
self.image.as_deref()
}
#[must_use]
pub const fn build(&self) -> Option<&ComposeBuild> {
self.build.as_ref()
}
#[must_use]
pub fn ports(&self) -> &[String] {
&self.ports
}
#[must_use]
pub fn volumes(&self) -> &[String] {
&self.volumes
}
#[must_use]
pub fn environment(&self) -> &[(String, String)] {
&self.environment
}
#[must_use]
pub fn depends_on(&self) -> &[String] {
&self.depends_on
}
#[must_use]
pub fn networks(&self) -> &[String] {
&self.networks
}
#[must_use]
pub fn profiles(&self) -> &[String] {
&self.profiles
}
fn empty(name: &str) -> Self {
Self {
name: name.to_string(),
image: None,
build: None,
ports: Vec::new(),
volumes: Vec::new(),
environment: Vec::new(),
depends_on: Vec::new(),
networks: Vec::new(),
profiles: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ComposeProject {
services: Vec<ComposeService>,
}
impl ComposeProject {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_service(mut self, service: ComposeService) -> Self {
self.services.push(service);
self
}
#[must_use]
pub fn services(&self) -> &[ComposeService] {
&self.services
}
}
fn normalize_non_empty(value: &str) -> Result<String, ComposeTextError> {
let trimmed = value.trim();
if trimmed.is_empty() {
Err(ComposeTextError::Empty)
} else {
Ok(trimmed.to_string())
}
}
fn normalize_name(value: &str) -> Result<String, ComposeTextError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ComposeTextError::Empty);
}
if trimmed
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
{
Ok(trimmed.to_string())
} else {
Err(ComposeTextError::InvalidName)
}
}
fn normalize_env_key(value: &str) -> Result<String, ComposeTextError> {
let trimmed = value.trim();
let mut chars = trimmed.chars();
let Some(first) = chars.next() else {
return Err(ComposeTextError::InvalidEnvironmentKey);
};
if !(first == '_' || first.is_ascii_alphabetic()) {
return Err(ComposeTextError::InvalidEnvironmentKey);
}
if chars.any(|character| !(character == '_' || character.is_ascii_alphanumeric())) {
return Err(ComposeTextError::InvalidEnvironmentKey);
}
Ok(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::{ComposeBuild, ComposeProject, ComposeService, ComposeTextError};
#[test]
fn models_compose_service_primitives() -> Result<(), Box<dyn std::error::Error>> {
let build = ComposeBuild::new(".")?.with_arg("PROFILE", "dev")?;
let service = ComposeService::try_new("web")?
.with_image("ghcr.io/rustuse/app:latest")
.with_build(build)
.with_port("8080:80")
.with_volume("cache:/var/cache")
.with_environment("RUST_LOG", "info")?
.with_dependency("db")?
.with_network("frontend")?
.with_profile("dev")?;
let project = ComposeProject::new().with_service(service);
assert_eq!(project.services()[0].name(), "web");
assert_eq!(project.services()[0].environment()[0].0, "RUST_LOG");
assert_eq!(
project.services()[0].build().unwrap().args()[0].0,
"PROFILE"
);
assert_eq!(
ComposeService::try_new("bad name"),
Err(ComposeTextError::InvalidName)
);
Ok(())
}
}