use-dockerfile 0.0.1

Primitive Dockerfile instruction line helpers for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

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

/// Error returned when a Dockerfile instruction line is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DockerfileInstructionError {
    /// The line was empty after trimming.
    Empty,
    /// The instruction keyword was not recognized.
    UnknownInstruction,
    /// The instruction had no argument text.
    MissingArguments,
}

impl fmt::Display for DockerfileInstructionError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Dockerfile instruction cannot be empty"),
            Self::UnknownInstruction => formatter.write_str("unknown Dockerfile instruction"),
            Self::MissingArguments => formatter.write_str("Dockerfile instruction needs arguments"),
        }
    }
}

impl Error for DockerfileInstructionError {}

/// Common Dockerfile instruction keywords.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DockerfileInstructionKind {
    /// `FROM`.
    From,
    /// `RUN`.
    Run,
    /// `COPY`.
    Copy,
    /// `ADD`.
    Add,
    /// `CMD`.
    Cmd,
    /// `ENTRYPOINT`.
    Entrypoint,
    /// `ENV`.
    Env,
    /// `ARG`.
    Arg,
    /// `WORKDIR`.
    Workdir,
    /// `EXPOSE`.
    Expose,
    /// `LABEL`.
    Label,
    /// `USER`.
    User,
    /// `VOLUME`.
    Volume,
    /// `HEALTHCHECK`.
    Healthcheck,
    /// `STOPSIGNAL`.
    Stopsignal,
    /// `SHELL`.
    Shell,
}

impl DockerfileInstructionKind {
    /// Returns the uppercase Dockerfile keyword.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::From => "FROM",
            Self::Run => "RUN",
            Self::Copy => "COPY",
            Self::Add => "ADD",
            Self::Cmd => "CMD",
            Self::Entrypoint => "ENTRYPOINT",
            Self::Env => "ENV",
            Self::Arg => "ARG",
            Self::Workdir => "WORKDIR",
            Self::Expose => "EXPOSE",
            Self::Label => "LABEL",
            Self::User => "USER",
            Self::Volume => "VOLUME",
            Self::Healthcheck => "HEALTHCHECK",
            Self::Stopsignal => "STOPSIGNAL",
            Self::Shell => "SHELL",
        }
    }
}

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

impl FromStr for DockerfileInstructionKind {
    type Err = DockerfileInstructionError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_uppercase().as_str() {
            "FROM" => Ok(Self::From),
            "RUN" => Ok(Self::Run),
            "COPY" => Ok(Self::Copy),
            "ADD" => Ok(Self::Add),
            "CMD" => Ok(Self::Cmd),
            "ENTRYPOINT" => Ok(Self::Entrypoint),
            "ENV" => Ok(Self::Env),
            "ARG" => Ok(Self::Arg),
            "WORKDIR" => Ok(Self::Workdir),
            "EXPOSE" => Ok(Self::Expose),
            "LABEL" => Ok(Self::Label),
            "USER" => Ok(Self::User),
            "VOLUME" => Ok(Self::Volume),
            "HEALTHCHECK" => Ok(Self::Healthcheck),
            "STOPSIGNAL" => Ok(Self::Stopsignal),
            "SHELL" => Ok(Self::Shell),
            _ => Err(DockerfileInstructionError::UnknownInstruction),
        }
    }
}

/// A single Dockerfile instruction line.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DockerfileInstruction {
    kind: DockerfileInstructionKind,
    arguments: String,
}

impl DockerfileInstruction {
    /// Creates an instruction from a kind and argument text.
    pub fn new(
        kind: DockerfileInstructionKind,
        arguments: impl AsRef<str>,
    ) -> Result<Self, DockerfileInstructionError> {
        let arguments = arguments.as_ref().trim();
        if arguments.is_empty() {
            return Err(DockerfileInstructionError::MissingArguments);
        }
        Ok(Self {
            kind,
            arguments: arguments.to_string(),
        })
    }

    /// Creates a `FROM` instruction.
    pub fn from(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::From, arguments)
    }

    /// Creates a `RUN` instruction.
    #[must_use]
    pub fn run(arguments: impl AsRef<str>) -> Self {
        Self {
            kind: DockerfileInstructionKind::Run,
            arguments: arguments.as_ref().trim().to_string(),
        }
    }

    /// Creates a `COPY` instruction.
    pub fn copy(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Copy, arguments)
    }

    /// Creates an `ADD` instruction.
    pub fn add(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Add, arguments)
    }

    /// Creates a `CMD` instruction.
    pub fn cmd(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Cmd, arguments)
    }

    /// Creates an `ENTRYPOINT` instruction.
    pub fn entrypoint(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Entrypoint, arguments)
    }

    /// Creates an `ENV` instruction.
    pub fn env(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Env, arguments)
    }

    /// Creates an `ARG` instruction.
    pub fn arg(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Arg, arguments)
    }

    /// Creates a `WORKDIR` instruction.
    pub fn workdir(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Workdir, arguments)
    }

    /// Creates an `EXPOSE` instruction.
    pub fn expose(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Expose, arguments)
    }

    /// Creates a `LABEL` instruction.
    pub fn label(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Label, arguments)
    }

    /// Creates a `USER` instruction.
    pub fn user(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::User, arguments)
    }

    /// Creates a `VOLUME` instruction.
    pub fn volume(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Volume, arguments)
    }

    /// Creates a `HEALTHCHECK` instruction.
    pub fn healthcheck(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Healthcheck, arguments)
    }

    /// Creates a `STOPSIGNAL` instruction.
    pub fn stopsignal(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Stopsignal, arguments)
    }

    /// Creates a `SHELL` instruction.
    pub fn shell(arguments: impl AsRef<str>) -> Result<Self, DockerfileInstructionError> {
        Self::new(DockerfileInstructionKind::Shell, arguments)
    }

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

    /// Returns the instruction argument text.
    #[must_use]
    pub fn arguments(&self) -> &str {
        &self.arguments
    }
}

impl fmt::Display for DockerfileInstruction {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{} {}", self.kind, self.arguments)
    }
}

impl FromStr for DockerfileInstruction {
    type Err = DockerfileInstructionError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let trimmed = value.trim();
        if trimmed.is_empty() {
            return Err(DockerfileInstructionError::Empty);
        }
        let Some((keyword, arguments)) = trimmed.split_once(char::is_whitespace) else {
            return Err(DockerfileInstructionError::MissingArguments);
        };
        Self::new(keyword.parse()?, arguments)
    }
}

impl TryFrom<&str> for DockerfileInstruction {
    type Error = DockerfileInstructionError;

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

#[cfg(test)]
mod tests {
    use super::{DockerfileInstruction, DockerfileInstructionKind};

    #[test]
    fn parses_and_renders_instruction_lines() -> Result<(), Box<dyn std::error::Error>> {
        let from: DockerfileInstruction = "FROM rust:1.95".parse()?;
        let copy = DockerfileInstruction::copy("src/ /app/src/")?;

        assert_eq!(from.kind(), DockerfileInstructionKind::From);
        assert_eq!(from.arguments(), "rust:1.95");
        assert_eq!(copy.to_string(), "COPY src/ /app/src/");
        assert_eq!(
            DockerfileInstruction::run("cargo test").to_string(),
            "RUN cargo test"
        );
        Ok(())
    }
}