use-docker-compose 0.0.1

Lightweight Docker Compose model primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::fmt;
use std::error::Error;

/// Error returned when Compose model text is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ComposeTextError {
    /// The value was empty after trimming.
    Empty,
    /// A service, network, profile, or dependency name was invalid.
    InvalidName,
    /// An environment variable key was invalid.
    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 {}

/// Docker Compose build metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ComposeBuild {
    context: String,
    dockerfile: Option<String>,
    target: Option<String>,
    args: Vec<(String, String)>,
}

impl ComposeBuild {
    /// Creates build metadata with a context path.
    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(),
        })
    }

    /// Adds a Dockerfile path.
    #[must_use]
    pub fn with_dockerfile(mut self, dockerfile: impl Into<String>) -> Self {
        self.dockerfile = Some(dockerfile.into());
        self
    }

    /// Adds a target stage.
    #[must_use]
    pub fn with_target(mut self, target: impl Into<String>) -> Self {
        self.target = Some(target.into());
        self
    }

    /// Adds a build argument.
    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)
    }

    /// Returns the build context path.
    #[must_use]
    pub fn context(&self) -> &str {
        &self.context
    }

    /// Returns the optional Dockerfile path.
    #[must_use]
    pub fn dockerfile(&self) -> Option<&str> {
        self.dockerfile.as_deref()
    }

    /// Returns the optional target stage.
    #[must_use]
    pub fn target(&self) -> Option<&str> {
        self.target.as_deref()
    }

    /// Returns build args.
    #[must_use]
    pub fn args(&self) -> &[(String, String)] {
        &self.args
    }
}

/// Docker Compose service metadata.
#[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 {
    /// Creates a service with trusted static text.
    #[must_use]
    pub fn new(name: impl AsRef<str>) -> Self {
        Self::empty(name.as_ref().trim())
    }

    /// Creates a service with validated dynamic text.
    pub fn try_new(name: impl AsRef<str>) -> Result<Self, ComposeTextError> {
        let name = normalize_name(name.as_ref())?;
        Ok(Self::empty(&name))
    }

    /// Adds an image reference string.
    #[must_use]
    pub fn with_image(mut self, image: impl Into<String>) -> Self {
        self.image = Some(image.into());
        self
    }

    /// Adds build metadata.
    #[must_use]
    pub fn with_build(mut self, build: ComposeBuild) -> Self {
        self.build = Some(build);
        self
    }

    /// Adds a port mapping string.
    #[must_use]
    pub fn with_port(mut self, port: impl Into<String>) -> Self {
        self.ports.push(port.into());
        self
    }

    /// Adds a volume string.
    #[must_use]
    pub fn with_volume(mut self, volume: impl Into<String>) -> Self {
        self.volumes.push(volume.into());
        self
    }

    /// Adds an environment key/value pair.
    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)
    }

    /// Adds a service dependency.
    pub fn with_dependency(mut self, name: impl AsRef<str>) -> Result<Self, ComposeTextError> {
        self.depends_on.push(normalize_name(name.as_ref())?);
        Ok(self)
    }

    /// Adds a named network.
    pub fn with_network(mut self, name: impl AsRef<str>) -> Result<Self, ComposeTextError> {
        self.networks.push(normalize_name(name.as_ref())?);
        Ok(self)
    }

    /// Adds a profile.
    pub fn with_profile(mut self, name: impl AsRef<str>) -> Result<Self, ComposeTextError> {
        self.profiles.push(normalize_name(name.as_ref())?);
        Ok(self)
    }

    /// Returns the service name.
    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Returns the optional image reference string.
    #[must_use]
    pub fn image(&self) -> Option<&str> {
        self.image.as_deref()
    }

    /// Returns the optional build metadata.
    #[must_use]
    pub const fn build(&self) -> Option<&ComposeBuild> {
        self.build.as_ref()
    }

    /// Returns port mapping strings.
    #[must_use]
    pub fn ports(&self) -> &[String] {
        &self.ports
    }

    /// Returns volume strings.
    #[must_use]
    pub fn volumes(&self) -> &[String] {
        &self.volumes
    }

    /// Returns environment key/value pairs.
    #[must_use]
    pub fn environment(&self) -> &[(String, String)] {
        &self.environment
    }

    /// Returns dependencies.
    #[must_use]
    pub fn depends_on(&self) -> &[String] {
        &self.depends_on
    }

    /// Returns network names.
    #[must_use]
    pub fn networks(&self) -> &[String] {
        &self.networks
    }

    /// Returns profiles.
    #[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(),
        }
    }
}

/// A lightweight Compose project model.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ComposeProject {
    services: Vec<ComposeService>,
}

impl ComposeProject {
    /// Creates an empty project.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Adds a service.
    #[must_use]
    pub fn with_service(mut self, service: ComposeService) -> Self {
        self.services.push(service);
        self
    }

    /// Returns services.
    #[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(())
    }
}