use-docker-env 0.0.1

Primitive Docker environment file 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 environment text is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EnvParseError {
    /// The line had no `=` separator.
    MissingEquals,
    /// The environment variable key was empty or invalid.
    InvalidKey,
}

impl fmt::Display for EnvParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingEquals => formatter.write_str("environment variable line needs `=`"),
            Self::InvalidKey => formatter.write_str("invalid environment variable key"),
        }
    }
}

impl Error for EnvParseError {}

/// Environment file line classification.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum EnvLineKind {
    /// A blank line.
    Blank,
    /// A comment line beginning with `#` after trimming leading whitespace.
    Comment,
    /// A `KEY=value` variable line.
    Variable,
}

/// A validated environment variable key and value.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct EnvVar {
    key: String,
    value: String,
}

impl EnvVar {
    /// Creates an environment variable pair.
    pub fn new(key: impl AsRef<str>, value: impl Into<String>) -> Result<Self, EnvParseError> {
        let key = key.as_ref().trim();
        validate_key(key)?;
        Ok(Self {
            key: key.to_string(),
            value: value.into(),
        })
    }

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

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

impl fmt::Display for EnvVar {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}={}", self.key, self.value)
    }
}

impl FromStr for EnvVar {
    type Err = EnvParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        let Some((key, env_value)) = value.split_once('=') else {
            return Err(EnvParseError::MissingEquals);
        };
        Self::new(key, env_value)
    }
}

/// A classified Docker env-file line.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EnvLine {
    original: String,
    kind: EnvLineKind,
    variable: Option<EnvVar>,
}

impl EnvLine {
    /// Parses an env-file line.
    pub fn parse(value: impl AsRef<str>) -> Result<Self, EnvParseError> {
        let original = value.as_ref().to_string();
        let trimmed = value.as_ref().trim();
        if trimmed.is_empty() {
            return Ok(Self {
                original,
                kind: EnvLineKind::Blank,
                variable: None,
            });
        }
        if trimmed.starts_with('#') {
            return Ok(Self {
                original,
                kind: EnvLineKind::Comment,
                variable: None,
            });
        }
        let variable = trimmed.parse()?;
        Ok(Self {
            original,
            kind: EnvLineKind::Variable,
            variable: Some(variable),
        })
    }

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

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

    /// Returns the parsed variable pair when this is a variable line.
    #[must_use]
    pub const fn variable(&self) -> Option<&EnvVar> {
        self.variable.as_ref()
    }

    /// Returns the parsed key when this is a variable line.
    #[must_use]
    pub fn key(&self) -> Option<&str> {
        self.variable.as_ref().map(EnvVar::key)
    }

    /// Returns the parsed value when this is a variable line.
    #[must_use]
    pub fn value(&self) -> Option<&str> {
        self.variable.as_ref().map(EnvVar::value)
    }
}

fn validate_key(value: &str) -> Result<(), EnvParseError> {
    let mut chars = value.chars();
    let Some(first) = chars.next() else {
        return Err(EnvParseError::InvalidKey);
    };
    if !(first == '_' || first.is_ascii_alphabetic()) {
        return Err(EnvParseError::InvalidKey);
    }
    if chars.any(|character| !(character == '_' || character.is_ascii_alphanumeric())) {
        return Err(EnvParseError::InvalidKey);
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{EnvLine, EnvLineKind, EnvParseError, EnvVar};

    #[test]
    fn parses_env_lines() -> Result<(), Box<dyn std::error::Error>> {
        let line = EnvLine::parse("RUST_LOG=info")?;
        let comment = EnvLine::parse("# local")?;

        assert_eq!(line.kind(), EnvLineKind::Variable);
        assert_eq!(line.key(), Some("RUST_LOG"));
        assert_eq!(line.value(), Some("info"));
        assert_eq!(comment.kind(), EnvLineKind::Comment);
        assert_eq!(EnvVar::new("1BAD", "value"), Err(EnvParseError::InvalidKey));
        Ok(())
    }
}