Skip to content
//! Job types: one `once` invocation, its live log, and its terminal outcome.

use std::fmt;

use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::app::{Host, ImageRef};

/// Identifier for a single `once` invocation tracked by the server.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct JobId(Uuid);

impl JobId {
    /// Mints an identifier for a newly accepted job.
    pub fn new() -> Self {
        Self(Uuid::new_v4())
    }
}

impl Default for JobId {
    fn default() -> Self {
        Self::new()
    }
}

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

impl std::str::FromStr for JobId {
    type Err = uuid::Error;

    fn from_str(raw: &str) -> Result<Self, Self::Err> {
        Ok(Self(Uuid::parse_str(raw)?))
    }
}

/// The operations `twice` is willing to perform. This enum is the whole write
/// surface of the API: it has no variant that removes, resets, restores, or
/// executes anything.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum JobKind {
    /// `once start <host>`
    Start {
        /// Application to start.
        host: Host,
    },
    /// `once stop <host>`
    Stop {
        /// Application to stop.
        host: Host,
    },
    /// `once deploy <image> --host <host>`
    Deploy {
        /// New application hostname.
        host: Host,
        /// Pinned image assembled from the declared source and version.
        image: ImageRef,
    },
    /// `once update <host> --image <image>`
    UpdateImage {
        /// Application to update.
        host: Host,
        /// Image to roll the application onto.
        image: ImageRef,
        /// Explicit `--auto-update` value; omitted means "leave the flag off
        /// the command line and inherit whatever `once` defaults to".
        auto_update: Option<bool>,
    },
}

impl JobKind {
    /// The application this job acts on.
    pub const fn host(&self) -> &Host {
        match self {
            Self::Start { host }
            | Self::Stop { host }
            | Self::Deploy { host, .. }
            | Self::UpdateImage { host, .. } => host,
        }
    }

    /// Short label for UI lists.
    pub const fn label(&self) -> &'static str {
        match self {
            Self::Start { .. } => "start",
            Self::Stop { .. } => "stop",
            Self::Deploy { .. } => "deploy infrastructure",
            Self::UpdateImage { .. } => "update image",
        }
    }
}

/// Terminal or in-flight result of a job.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum JobOutcome {
    /// The child process is still running.
    Running,
    /// The child exited zero.
    Succeeded,
    /// The child exited non-zero, was signalled, or could not be spawned.
    Failed {
        /// Exit status, absent when the process was signalled or never started.
        exit_code: Option<i32>,
        /// Operator-facing explanation.
        message: String,
    },
}

impl JobOutcome {
    /// Whether the job has stopped producing output.
    pub const fn is_terminal(&self) -> bool {
        match self {
            Self::Running => false,
            Self::Succeeded | Self::Failed { .. } => true,
        }
    }
}

/// Which pipe a log line came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LogStream {
    /// Child stdout.
    Stdout,
    /// Child stderr.
    Stderr,
}

/// A single line of child output.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogLine {
    /// Monotonic per-job sequence number, so a reconnecting client can resume.
    pub seq: u64,
    /// Originating pipe.
    pub stream: LogStream,
    /// Line contents with ANSI escapes already stripped.
    pub text: String,
}

/// Full server-side view of a job.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobView {
    /// Job identifier.
    pub id: JobId,
    /// What the job does.
    pub kind: JobKind,
    /// Current outcome.
    pub outcome: JobOutcome,
    /// When the server accepted the job.
    pub started_at: Timestamp,
    /// When the child exited, if it has.
    pub finished_at: Option<Timestamp>,
    /// Buffered output produced so far.
    pub log: Vec<LogLine>,
}

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

    fn host() -> Host {
        Host::parse("writebook.example.com").unwrap()
    }

    #[test]
    fn job_kind_round_trips_through_json() {
        let kind = JobKind::UpdateImage {
            host: host(),
            image: ImageRef::parse("ghcr.io/basecamp/writebook:1.4.0").unwrap(),
            auto_update: Some(false),
        };
        let json = serde_json::to_string(&kind).unwrap();
        assert_eq!(serde_json::from_str::<JobKind>(&json).unwrap(), kind);
    }

    #[test]
    fn job_kind_deserialization_rejects_a_hostile_host() {
        let json = r#"{"kind":"start","host":"--image"}"#;
        assert!(serde_json::from_str::<JobKind>(json).is_err());
    }

    #[test]
    fn running_jobs_are_not_terminal() {
        assert!(!JobOutcome::Running.is_terminal());
        assert!(JobOutcome::Succeeded.is_terminal());
    }
}