Skip to content
//! Application inventory types and the parsed primitives they are built from.

use std::fmt;

use serde::{Deserialize, Serialize};

/// Reason a string was rejected as a [`Host`].
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum InvalidHost {
    /// The value was empty or longer than 253 characters.
    #[error("host must be 1..=253 characters, got {0}")]
    Length(usize),
    /// The value contained a character outside `[A-Za-z0-9.-]`.
    #[error("host contains illegal character {0:?}; allowed: letters, digits, '.', '-'")]
    Character(char),
    /// The value started or ended with a separator, or had an empty label.
    #[error("host has an empty or misplaced label separator")]
    Label,
}

/// Reason a string was rejected as an [`ImageRef`].
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum InvalidImageRef {
    /// The value was empty or longer than 512 characters.
    #[error("image reference must be 1..=512 characters, got {0}")]
    Length(usize),
    /// The value contained a character outside the OCI reference alphabet.
    #[error("image reference contains illegal character {0:?}")]
    Character(char),
    /// The value began with `-`, which a CLI would read as a flag.
    #[error("image reference must not start with '-'")]
    LeadingDash,
}

/// Reason a string was rejected as an [`ImageSource`].
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum InvalidImageSource {
    /// The value was empty or too long to combine with the longest OCI tag.
    #[error("image source must be 1..=383 characters, got {0}")]
    Length(usize),
    /// The value contained a character outside the OCI reference alphabet.
    #[error("image source contains illegal character {0:?}")]
    Character(char),
    /// The value began with `-`, which a CLI would read as a flag.
    #[error("image source must not start with '-'")]
    LeadingDash,
    /// The source included a tag or digest; version is declared separately.
    #[error("image source must not include a tag or digest; put it in version")]
    Versioned,
    /// The repository path had an empty component.
    #[error("image source has an empty or misplaced path separator")]
    Path,
}

/// Reason a string was rejected as an [`ImageVersion`].
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum InvalidImageVersion {
    /// OCI tags contain at most 128 characters.
    #[error("image version must be 1..=128 characters, got {0}")]
    Length(usize),
    /// The first tag character was not alphanumeric or `_`.
    #[error("image version must start with a letter, digit, or '_'")]
    LeadingCharacter,
    /// The value contained a character outside the OCI tag alphabet.
    #[error("image version contains illegal character {0:?}")]
    Character(char),
}

/// The hostname `once` uses to address a deployed application.
///
/// Parsing is what keeps a client-supplied value from ever becoming a `once`
/// flag: the alphabet excludes whitespace, `-` in leading position, and every
/// shell metacharacter.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct Host(String);

impl Host {
    /// Parses a hostname, rejecting anything a CLI could misread as a flag.
    pub fn parse(raw: &str) -> Result<Self, InvalidHost> {
        if raw.is_empty() || raw.len() > 253 {
            return Err(InvalidHost::Length(raw.len()));
        }
        if let Some(bad) = raw
            .chars()
            .find(|c| !(c.is_ascii_alphanumeric() || *c == '.' || *c == '-'))
        {
            return Err(InvalidHost::Character(bad));
        }
        let misplaced = raw.starts_with(['-', '.'])
            || raw.ends_with(['-', '.'])
            || raw.split('.').any(str::is_empty);
        if misplaced {
            return Err(InvalidHost::Label);
        }
        Ok(Self(raw.to_owned()))
    }

    /// Borrows the validated hostname.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl TryFrom<String> for Host {
    type Error = InvalidHost;

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

impl From<Host> for String {
    fn from(value: Host) -> Self {
        value.0
    }
}

impl fmt::Display for Host {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

/// An OCI image reference such as `ghcr.io/basecamp/writebook:1.4.0`.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct ImageRef(String);

impl ImageRef {
    /// Parses an image reference, rejecting flag-shaped and shell-unsafe input.
    pub fn parse(raw: &str) -> Result<Self, InvalidImageRef> {
        if raw.is_empty() || raw.len() > 512 {
            return Err(InvalidImageRef::Length(raw.len()));
        }
        if raw.starts_with('-') {
            return Err(InvalidImageRef::LeadingDash);
        }
        if let Some(bad) = raw.chars().find(|c| !is_reference_char(*c)) {
            return Err(InvalidImageRef::Character(bad));
        }
        Ok(Self(raw.to_owned()))
    }

    /// Borrows the validated reference.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

const fn is_reference_char(c: char) -> bool {
    c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | ':' | '@' | '+')
}

impl TryFrom<String> for ImageRef {
    type Error = InvalidImageRef;

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

impl From<ImageRef> for String {
    fn from(value: ImageRef) -> Self {
        value.0
    }
}

impl fmt::Display for ImageRef {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

/// An OCI image repository without a tag or digest, such as
/// `ghcr.io/basecamp/writebook`.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct ImageSource(String);

impl ImageSource {
    /// Parses an unversioned OCI image repository.
    pub fn parse(raw: &str) -> Result<Self, InvalidImageSource> {
        if raw.is_empty() || raw.len() > 383 {
            return Err(InvalidImageSource::Length(raw.len()));
        }
        if raw.starts_with('-') {
            return Err(InvalidImageSource::LeadingDash);
        }
        if let Some(bad) = raw.chars().find(|c| !is_reference_char(*c)) {
            return Err(InvalidImageSource::Character(bad));
        }
        if raw.contains('@')
            || raw
                .rsplit('/')
                .next()
                .is_some_and(|part| part.contains(':'))
        {
            return Err(InvalidImageSource::Versioned);
        }
        if raw.starts_with('/') || raw.ends_with('/') || raw.split('/').any(str::is_empty) {
            return Err(InvalidImageSource::Path);
        }
        Ok(Self(raw.to_owned()))
    }

    /// Borrows the validated repository.
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Combines this repository with its separately declared version.
    pub fn into_image(mut self, version: &ImageVersion) -> ImageRef {
        self.0.reserve(version.0.len().saturating_add(1));
        self.0.push(':');
        self.0.push_str(&version.0);
        ImageRef(self.0)
    }
}

impl TryFrom<String> for ImageSource {
    type Error = InvalidImageSource;

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

impl From<ImageSource> for String {
    fn from(value: ImageSource) -> Self {
        value.0
    }
}

impl fmt::Display for ImageSource {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

/// An OCI image tag used as the declared application version.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct ImageVersion(String);

impl ImageVersion {
    /// Parses an OCI tag.
    pub fn parse(raw: &str) -> Result<Self, InvalidImageVersion> {
        if raw.is_empty() || raw.len() > 128 {
            return Err(InvalidImageVersion::Length(raw.len()));
        }
        let mut chars = raw.chars();
        if !chars
            .next()
            .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
        {
            return Err(InvalidImageVersion::LeadingCharacter);
        }
        if let Some(bad) =
            chars.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')))
        {
            return Err(InvalidImageVersion::Character(bad));
        }
        Ok(Self(raw.to_owned()))
    }

    /// Borrows the validated version tag.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl TryFrom<String> for ImageVersion {
    type Error = InvalidImageVersion;

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

impl From<ImageVersion> for String {
    fn from(value: ImageVersion) -> Self {
        value.0
    }
}

impl fmt::Display for ImageVersion {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

/// The Docker container name backing an application. Display-only; never
/// forwarded to a command line.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ContainerName(pub String);

impl fmt::Display for ContainerName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

/// Lifecycle state of the container backing an application.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContainerState {
    /// Created but never started.
    Created,
    /// Serving traffic.
    Running,
    /// Frozen, typically mid-backup.
    Paused,
    /// Restarting after an exit.
    Restarting,
    /// Being torn down by the daemon.
    Removing,
    /// Stopped.
    Exited,
    /// The daemon considers the container unrecoverable.
    Dead,
    /// The daemon reported a state this build does not know.
    Unknown,
}

impl ContainerState {
    /// Maps a Docker `State` string onto a known variant.
    pub fn from_docker(raw: &str) -> Self {
        match raw {
            "created" => Self::Created,
            "running" => Self::Running,
            "paused" => Self::Paused,
            "restarting" => Self::Restarting,
            "removing" => Self::Removing,
            "exited" => Self::Exited,
            "dead" => Self::Dead,
            _ => Self::Unknown,
        }
    }

    /// Human label for tables and logs.
    pub const fn label(self) -> &'static str {
        match self {
            Self::Created => "created",
            Self::Running => "running",
            Self::Paused => "paused",
            Self::Restarting => "restarting",
            Self::Removing => "removing",
            Self::Exited => "exited",
            Self::Dead => "dead",
            Self::Unknown => "unknown",
        }
    }
}

/// One application as `twice` sees it, assembled from the Docker daemon.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppSummary {
    /// Hostname `once` addresses the application by.
    pub host: Host,
    /// Backing container name.
    pub container: ContainerName,
    /// Image the container currently runs.
    pub image: ImageRef,
    /// Lifecycle state.
    pub state: ContainerState,
    /// Human-readable status line straight from the daemon, e.g. `Up 6 hours`.
    pub status: String,
    /// Whether `once` was told to auto-update this app, when the label says so.
    pub auto_update: Option<bool>,
    /// Whether `once` was told to auto-backup this app, when the label says so.
    pub auto_backup: Option<bool>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn host_accepts_a_normal_dns_name() {
        assert_eq!(
            Host::parse("writebook.example.com").unwrap().as_str(),
            "writebook.example.com"
        );
    }

    #[test]
    fn host_rejects_flag_shaped_and_shell_shaped_input() {
        for hostile in [
            "--image",
            "-n",
            "a b",
            "a;rm -rf /",
            "a/../b",
            "$(id)",
            "a|b",
            "",
        ] {
            assert!(
                Host::parse(hostile).is_err(),
                "accepted hostile host {hostile:?}"
            );
        }
    }

    #[test]
    fn host_rejects_empty_and_misplaced_labels() {
        for bad in ["a..b", ".a", "a.", "a-", "-a"] {
            assert_eq!(
                Host::parse(bad),
                Err(InvalidHost::Label),
                "accepted {bad:?}"
            );
        }
    }

    #[test]
    fn image_ref_accepts_registry_port_tag_and_digest() {
        let raw = "ghcr.io:443/basecamp/writebook@sha256:abc123";
        assert_eq!(ImageRef::parse(raw).unwrap().as_str(), raw);
    }

    #[test]
    fn image_ref_rejects_flag_shaped_and_shell_shaped_input() {
        for hostile in [
            "--env", "-x", "img ref", "img;id", "img$(id)", "img\nrm", "",
        ] {
            assert!(
                ImageRef::parse(hostile).is_err(),
                "accepted hostile image {hostile:?}"
            );
        }
    }

    #[test]
    fn source_and_version_form_a_pinned_image() {
        let source = ImageSource::parse("registry.example.com:5000/team/app").unwrap();
        let version = ImageVersion::parse("v1.2.3-rc.1").unwrap();
        assert_eq!(
            source.into_image(&version).as_str(),
            "registry.example.com:5000/team/app:v1.2.3-rc.1"
        );
    }

    #[test]
    fn source_requires_version_to_be_separate() {
        for versioned in ["ghcr.io/team/app:1.2.3", "ghcr.io/team/app@sha256:abc"] {
            assert_eq!(
                ImageSource::parse(versioned),
                Err(InvalidImageSource::Versioned)
            );
        }
    }

    #[test]
    fn version_uses_the_oci_tag_alphabet() {
        assert!(ImageVersion::parse("1.2.3").is_ok());
        for bad in ["", "-latest", "feature/latest", "version with spaces"] {
            assert!(
                ImageVersion::parse(bad).is_err(),
                "accepted version {bad:?}"
            );
        }
    }

    #[test]
    fn container_state_maps_unknown_daemon_strings() {
        assert_eq!(
            ContainerState::from_docker("running"),
            ContainerState::Running
        );
        assert_eq!(
            ContainerState::from_docker("teleporting"),
            ContainerState::Unknown
        );
    }
}