use-docker-volume 0.0.1

Primitive Docker volume and mount syntax helpers for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

/// Error returned when Docker volume syntax is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockerVolumeError {
    /// The mount text was empty after trimming.
    Empty,
    /// The target path was empty.
    EmptyTarget,
    /// The access mode was not `ro` or `rw`.
    InvalidAccess,
}

impl fmt::Display for DockerVolumeError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Docker volume mount cannot be empty"),
            Self::EmptyTarget => formatter.write_str("Docker volume target cannot be empty"),
            Self::InvalidAccess => formatter.write_str("Docker volume access must be ro or rw"),
        }
    }
}

impl Error for DockerVolumeError {}

/// Volume mount access mode.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum MountAccess {
    /// Read-write access.
    ReadWrite,
    /// Read-only access.
    ReadOnly,
}

impl MountAccess {
    /// Returns the Docker access suffix.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::ReadWrite => "rw",
            Self::ReadOnly => "ro",
        }
    }
}

impl fmt::Display for MountAccess {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for MountAccess {
    type Err = DockerVolumeError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "rw" => Ok(Self::ReadWrite),
            "ro" => Ok(Self::ReadOnly),
            _ => Err(DockerVolumeError::InvalidAccess),
        }
    }
}

/// Broad mount source classification.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum VolumeKind {
    /// A target-only anonymous volume.
    Anonymous,
    /// A named Docker volume.
    Named,
    /// A host bind mount.
    Bind,
}

/// A Docker volume or bind mount specification.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DockerVolumeMount {
    source: Option<String>,
    target: String,
    kind: VolumeKind,
    access: MountAccess,
}

impl DockerVolumeMount {
    /// Creates a mount from validated parts.
    pub fn new(
        source: Option<String>,
        target: impl AsRef<str>,
        access: MountAccess,
    ) -> Result<Self, DockerVolumeError> {
        let target = target.as_ref().trim();
        if target.is_empty() {
            return Err(DockerVolumeError::EmptyTarget);
        }
        let kind = source
            .as_deref()
            .map_or(VolumeKind::Anonymous, classify_source);
        Ok(Self {
            source,
            target: target.to_string(),
            kind,
            access,
        })
    }

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

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

    /// Returns the broad mount kind.
    #[must_use]
    pub const fn kind(&self) -> VolumeKind {
        self.kind
    }

    /// Returns the mount access mode.
    #[must_use]
    pub const fn access(&self) -> MountAccess {
        self.access
    }
}

impl fmt::Display for DockerVolumeMount {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(source) = &self.source {
            write!(formatter, "{source}:")?;
        }
        write!(formatter, "{}", self.target)?;
        if self.access == MountAccess::ReadOnly {
            write!(formatter, ":{}", self.access)?;
        }
        Ok(())
    }
}

impl FromStr for DockerVolumeMount {
    type Err = DockerVolumeError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        parse_mount(value)
    }
}

impl TryFrom<&str> for DockerVolumeMount {
    type Error = DockerVolumeError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        parse_mount(value)
    }
}

fn parse_mount(value: &str) -> Result<DockerVolumeMount, DockerVolumeError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return Err(DockerVolumeError::Empty);
    }

    let (body, access) = match trimmed.rsplit_once(':') {
        Some((body, mode)) if matches!(mode, "ro" | "rw") => (body, mode.parse()?),
        _ => (trimmed, MountAccess::ReadWrite),
    };

    let (source, target) = match body.rsplit_once(':') {
        Some((source, target)) => (Some(source.to_string()), target),
        None => (None, body),
    };
    DockerVolumeMount::new(source, target, access)
}

fn classify_source(source: &str) -> VolumeKind {
    let bytes = source.as_bytes();
    if source.starts_with(['/', '.', '~'])
        || source.contains('\\')
        || bytes.get(1).is_some_and(|byte| *byte == b':')
    {
        VolumeKind::Bind
    } else {
        VolumeKind::Named
    }
}

#[cfg(test)]
mod tests {
    use super::{DockerVolumeMount, MountAccess, VolumeKind};

    #[test]
    fn parses_volume_mounts() -> Result<(), Box<dyn std::error::Error>> {
        let named: DockerVolumeMount = "cache:/var/cache:ro".parse()?;
        let bind: DockerVolumeMount = "C:\\data:/data".parse()?;
        let anonymous: DockerVolumeMount = "/var/lib/app".parse()?;

        assert_eq!(named.kind(), VolumeKind::Named);
        assert_eq!(named.access(), MountAccess::ReadOnly);
        assert_eq!(bind.kind(), VolumeKind::Bind);
        assert_eq!(anonymous.kind(), VolumeKind::Anonymous);
        assert_eq!(named.to_string(), "cache:/var/cache:ro");
        Ok(())
    }
}