tuning 0.4.0

ansible-like tool with a smaller scope, focused primarily on complementing dotfiles for cross-machine bliss
use std::fmt;

use colored::Colorize;
use serde::{
    de::{Deserializer, Error as SerdeDeError},
    Deserialize, Serialize,
};

/// Satisfies the [`needs`](Metadata::needs) of other jobs
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum Satisfying {
    /// Job is [`Done`](Status::Done) but did not result in any changes.
    Changed(String, String),
    /// Job is complete.
    Done,
    /// Job is [`Done`](Status::Done) after making necessary changes.
    NoChange(String),
}
impl fmt::Display for Satisfying {
    // TODO: should Display include terminal output concerns?
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::Changed(from, to) => write!(
                f,
                "{}: {} => {}",
                "changed".yellow(),
                from.yellow().dimmed(),
                to.yellow()
            ),
            Self::Done => write!(f, "{}", "done".blue()),
            Self::NoChange(s) => write!(f, "{}: {}", "nochange".green(), s.green()),
        }
    }
}
impl From<&Satisfying> for String {
    fn from(source: &Satisfying) -> Self {
        Self::from(match source {
            Satisfying::Changed(_, _) => "changed",
            Satisfying::Done => "done",
            Satisfying::NoChange(_) => "nochange",
        })
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum Status {
    /// Runner is executing this job.
    /// Possible next states:
    /// [`Satisfying`](Status::Satisfying), [`Unsatisfying`](Status::Unsatisfying).
    InProgress,
    /// Job has no [`needs`](Metadata::needs) or all of them are [`Satisfying`](Status::Satisfying).
    /// Possible next states: [`InProgress`](Status::InProgress).
    Pending,
    /// Settled and satisfies the [`needs`](Metadata::needs) of other jobs
    Satisfying(Satisfying),
    /// Settled and does not satisfy the [`needs`](Metadata::needs) of other jobs
    Unsatisfying(Unsatisfying),
    /// Job has [`needs`](Metadata::needs) that are not yet [`Satisfying`](Status::Satisfying).
    /// Possible next states: [`Unsatisfying`](Status::Unsatisfying), [`Pending`](Status::Pending).
    Waiting,
}
impl fmt::Display for Status {
    // TODO: should Display include terminal output concerns?
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::InProgress => write!(f, "{}", "inprogress".cyan()),
            Self::Pending => write!(f, "{}", "pending".white()),
            Self::Satisfying(s) => write!(f, "{}", s),
            Self::Unsatisfying(u) => write!(f, "{}", u),
            Self::Waiting => write!(f, "{}", "waiting".white()),
        }
    }
}
impl From<&Status> for String {
    fn from(source: &Status) -> Self {
        match source {
            Status::InProgress => Self::from("inprogress"),
            Status::Pending => Self::from("pending"),
            Status::Satisfying(s) => Self::from(s),
            Status::Unsatisfying(u) => Self::from(u),
            Status::Waiting => Self::from("waiting"),
        }
    }
}
impl<'de> Deserialize<'de> for Status {
    // TODO: find a way to tell serde how to deal with enum variants that contain enum variants
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let value = match toml::value::Value::deserialize(deserializer)? {
            toml::Value::String(s) => match s.as_str() {
                "blocked" => Status::Unsatisfying(Unsatisfying::Blocked),
                "changed" => Status::Satisfying(Satisfying::Changed(String::new(), String::new())),
                "done" => Status::Satisfying(Satisfying::Done),
                "error" => Status::Unsatisfying(Unsatisfying::Error),
                "inprogress" => Status::InProgress,
                "nochange" => Status::Satisfying(Satisfying::NoChange(String::new())),
                "pending" => Status::Pending,
                "skipped" => Status::Unsatisfying(Unsatisfying::Skipped),
                "waiting" => Status::Waiting,
                _ => {
                    return Err(SerdeDeError::custom("invalid status"));
                }
            },
            _ => {
                return Err(SerdeDeError::custom(
                    "must provide a string to convert to status",
                ));
            }
        };

        Ok(value)
    }
}
impl Serialize for Status {
    // TODO: find a way to tell serde how to deal with enum variants that contain enum variants
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        serializer.serialize_str(String::from(self).as_str())
    }
}
impl Status {
    pub fn changed(before: String, after: String) -> Self {
        if before == after {
            Self::no_change(before)
        } else {
            Self::Satisfying(Satisfying::Changed(before, after))
        }
    }

    /// Returns `true` if the job is successful and should no longer block dependant jobs.
    pub fn is_satisfying(&self) -> bool {
        matches!(&self, Self::Satisfying(_))
    }

    /// Returns `true` if the job has reached a terminal state and will never change state again.
    #[cfg(test)]
    pub fn is_settled(&self) -> bool {
        matches!(&self, Self::Satisfying(_) | Self::Unsatisfying(_))
    }

    pub fn no_change(before: String) -> Self {
        Self::Satisfying(Satisfying::NoChange(before))
    }
}

/// Does not satisfy the [`needs`](Metadata::needs) of other jobs
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum Unsatisfying {
    /// Job has [`needs`](Metadata::needs) that can never reach [`Done`](Status::Done).
    Blocked,
    /// Job encountered an error.
    Error,
    /// Job has a [`when`](Metadata::when) that evaluated to `false`.
    Skipped,
}
impl fmt::Display for Unsatisfying {
    // TODO: should Display include terminal output concerns?
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::Blocked => write!(f, "{}", "blocked".red().dimmed()),
            Self::Error => write!(f, "{}", "error".red()),
            Self::Skipped => write!(f, "{}", "skipped".blue()),
        }
    }
}
impl From<&Unsatisfying> for String {
    fn from(source: &Unsatisfying) -> Self {
        Self::from(match source {
            Unsatisfying::Blocked => "blocked",
            Unsatisfying::Error => "error",
            Unsatisfying::Skipped => "skipped",
        })
    }
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use super::*;

    #[test]
    fn from_status_for_string() {
        let input = Status::Satisfying(Satisfying::Done);

        assert_eq!(String::from(&input), "done");
    }

    #[test]
    fn from_toml() {
        let input = r#"blocked = "blocked"
changed = "changed"
done = "done"
error = "error"
inprogress = "inprogress"
nochange = "nochange"
pending = "pending"
skipped = "skipped"
waiting = "waiting"
"#;
        let want: BTreeMap<&str, Status> = BTreeMap::from([
            ("blocked", Status::Unsatisfying(Unsatisfying::Blocked)),
            (
                "changed",
                Status::Satisfying(Satisfying::Changed(String::new(), String::new())),
            ),
            ("done", Status::Satisfying(Satisfying::Done)),
            ("error", Status::Unsatisfying(Unsatisfying::Error)),
            ("inprogress", Status::InProgress),
            (
                "nochange",
                Status::Satisfying(Satisfying::NoChange(String::new())),
            ),
            ("pending", Status::Pending),
            ("skipped", Status::Unsatisfying(Unsatisfying::Skipped)),
            ("waiting", Status::Waiting),
        ]);

        let got = toml::from_str::<BTreeMap<&str, Status>>(input).expect("unable to deserialize");

        assert_eq!(got, want);
    }

    #[test]
    fn into_toml() {
        let input: BTreeMap<&str, Status> = BTreeMap::from([
            ("blocked", Status::Unsatisfying(Unsatisfying::Blocked)),
            (
                "changed",
                Status::Satisfying(Satisfying::Changed(String::new(), String::new())),
            ),
            ("done", Status::Satisfying(Satisfying::Done)),
            ("error", Status::Unsatisfying(Unsatisfying::Error)),
            ("inprogress", Status::InProgress),
            (
                "nochange",
                Status::Satisfying(Satisfying::NoChange(String::new())),
            ),
            ("pending", Status::Pending),
            ("skipped", Status::Unsatisfying(Unsatisfying::Skipped)),
            ("waiting", Status::Waiting),
        ]);
        let want = r#"blocked = "blocked"
changed = "changed"
done = "done"
error = "error"
inprogress = "inprogress"
nochange = "nochange"
pending = "pending"
skipped = "skipped"
waiting = "waiting"
"#;

        let got = toml::to_string(&input).expect("unable to serialize");

        assert_eq!(got, want);
    }
}