tuning 0.4.0

ansible-like tool with a smaller scope, focused primarily on complementing dotfiles for cross-machine bliss
use std::{fmt, fs, io, num::NonZeroU32};

use camino::{Utf8Path, Utf8PathBuf};
use git_url::Url;
use serde::{
    de::{Deserializer, Error as SerdeDeError},
    ser::Serializer,
    Deserialize, Serialize,
};
use thiserror::Error as ThisError;
use tokio::process;

use super::{command, file, Status};

#[derive(Debug, ThisError)]
pub(crate) enum Error {
    #[error(transparent)]
    CommandJob {
        #[from]
        source: command::Error,
    },
    #[error("{} already exists", dest)]
    DestExists { dest: Utf8PathBuf },
    #[error("{} not found", dest)]
    DestNotFound { dest: Utf8PathBuf },
    #[error(transparent)]
    FileJob {
        #[from]
        source: file::Error,
    },
    #[error(transparent)]
    FromUtf8 {
        #[from]
        source: std::string::FromUtf8Error,
    },
    #[error("working `git` not found")]
    GitNotFound,
    #[error(transparent)]
    Io {
        #[from]
        source: io::Error,
    },
    #[allow(dead_code)]
    #[error("never")]
    Never,
    #[error(transparent)]
    UrlParse {
        #[from]
        source: git_url::parse::Error,
    },
}
impl PartialEq for Error {
    fn eq(&self, other: &Error) -> bool {
        format!("{:?}", self) == format!("{:?}", other)
    }
}

#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, rename_all = "lowercase", tag = "type")]
pub(crate) struct Git {
    /// Should we clone [`repo`](Git::repo) if it does not yet exist at [`depth`](Git::depth)?
    /// Default = yes.
    #[serde(deserialize_with = "crate::convert::into_option_bool")]
    pub clone: Option<bool>,
    /// Limit fetching to a specified number of commits in history per [`git fetch --depth=N`](https://git-scm.com/docs/fetch-options#Documentation/fetch-options.txt---depthltdepthgt).
    /// Default = no limit, complete history.
    #[serde(deserialize_with = "crate::convert::into_option_nonzero_u32")]
    pub depth: Option<NonZeroU32>,
    /// Checkout [`repo`](Git::repo) into this target path.
    pub dest: Utf8PathBuf,
    // refspec: String
    /// Should we delete any unexpected files at [`dest`](Git::dest) if necessary?
    /// Default = no, exit early with an error instead of deleting files.
    #[serde(deserialize_with = "crate::convert::into_option_bool")]
    pub force: Option<bool>,
    #[serde(
        deserialize_with = "from_toml_git_url",
        serialize_with = "to_toml_git_url"
    )]
    pub repo: Url,
    /// Should we pull newer commits from the origin?
    /// Default = yes, keep [`dest`](Git::dest) up to date.
    #[serde(deserialize_with = "crate::convert::into_option_bool")]
    pub update: Option<bool>,
}
impl Default for Git {
    fn default() -> Self {
        Self {
            clone: Some(true),
            depth: None,
            dest: Utf8PathBuf::new(),
            force: None,
            repo: Url::try_from(String::from("https://gitlab.com/"))
                .expect("unable to parse default URL"),
            update: Some(true),
        }
    }
}
impl fmt::Display for Git {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{:?} -> {:?}", &self.repo, &self.dest)
    }
}
impl Git {
    pub async fn execute(&self) -> Result {
        if !has_git().await {
            return Err(Error::GitNotFound);
        }

        if !self.dest.exists() && !self.clone.unwrap_or(true) {
            return Err(Error::DestNotFound {
                dest: self.dest.clone(),
            });
        }

        let mut before = String::from("absent");
        if self.dest.exists() {
            if is_git_repository(&self.dest).await
                && is_git_url_eq(&git_origin_remote_url(&self.dest).await?, &self.repo)
            {
                if !self.update.unwrap_or(true) {
                    return Ok(Status::no_change(current_commit(&self.dest).await?));
                }
                return self.git_pull().await;
            }
            if self.force.unwrap_or(false) {
                before = String::from("not repository");
                if self.dest.is_dir() {
                    fs::remove_dir_all(&self.dest)?;
                } else {
                    fs::remove_file(&self.dest)?;
                }
            } else {
                return Err(Error::DestExists {
                    dest: self.dest.clone(),
                });
            }
        }

        self.git_clone().await?;

        Ok(Status::changed(before, current_commit(&self.dest).await?))
    }

    async fn git_clone(&self) -> std::result::Result<(), Error> {
        let mut argv: Vec<String> = vec![String::from("clone")];
        if let Some(depth) = self.depth {
            argv.push(String::from("--depth"));
            argv.push(format!("{}", depth));
        }
        argv.push(format!("{}", self.repo.to_bstring()));
        argv.push(String::from(self.dest.as_str()));

        let clone = command::Command {
            argv,
            command: String::from("git"),
            ..Default::default()
        };
        clone.execute().await?;
        Ok(())
    }

    async fn git_fetch(&self) -> Result {
        let before = current_commit(&self.dest).await?;

        let mut argv: Vec<String> = vec![String::from("fetch")];
        if let Some(depth) = self.depth {
            argv.push(String::from("--depth"));
            argv.push(format!("{}", depth));
        }

        let fetch = command::Command {
            argv,
            chdir: Some(self.dest.clone()),
            command: String::from("git"),
            ..Default::default()
        };
        fetch.execute().await?;

        let after = current_commit(&self.dest).await?;

        Ok(Status::changed(before, after))
    }

    async fn git_hard_reset(&self) -> Result {
        let before = current_commit(&self.dest).await?;

        let reset = command::Command {
            argv: vec![
                String::from("reset"),
                String::from("--hard"),
                String::from("FETCH_HEAD"),
            ],
            chdir: Some(self.dest.clone()),
            command: String::from("git"),
            ..Default::default()
        };
        reset.execute().await?;

        let after = current_commit(&self.dest).await?;

        Ok(Status::changed(before, after))
    }

    async fn git_pull(&self) -> Result {
        let before = current_commit(&self.dest).await?;

        let mut argv: Vec<String> = vec![String::from("pull")];
        if let Some(depth) = self.depth {
            argv.push(String::from("--depth"));
            argv.push(format!("{}", depth));
        }

        let pull = command::Command {
            argv,
            chdir: Some(self.dest.clone()),
            command: String::from("git"),
            ..Default::default()
        };
        if pull.execute().await.is_err() && self.force.unwrap_or(false) {
            self.git_fetch().await?;
            self.git_hard_reset().await?;
        }

        let after = current_commit(&self.dest).await?;

        Ok(Status::changed(before, after))
    }
}

pub(crate) type Result = std::result::Result<Status, Error>;

async fn current_commit<P>(path: P) -> std::result::Result<String, Error>
where
    P: AsRef<Utf8Path>,
{
    let p = path.as_ref();
    let o = process::Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .current_dir(p)
        .output()
        .await?;

    Ok(String::from_utf8(o.stdout)?)
}

fn from_toml_git_url<'de, D>(deserializer: D) -> std::result::Result<Url, D::Error>
where
    D: Deserializer<'de>,
{
    let s = toml::value::Value::deserialize(deserializer)?
        .try_into::<String>()
        .map_err(SerdeDeError::custom)?;
    let u = Url::try_from(s).map_err(SerdeDeError::custom)?;
    Ok(u)
}

async fn git_origin_remote_url<P>(path: P) -> std::result::Result<Url, Error>
where
    P: AsRef<Utf8Path>,
{
    let p = path.as_ref();
    let o = process::Command::new("git")
        .args(["remote", "get-url", "origin"])
        .current_dir(p)
        .output()
        .await?;
    if o.status.success() {
        let u = Url::try_from(String::from_utf8(o.stdout)?)?;
        Ok(u)
    } else {
        Err(Error::CommandJob {
            source: command::Error::NonZeroExitStatus {
                cmd: String::from("git"),
            },
        })
    }
}

async fn has_git() -> bool {
    match process::Command::new("git")
        .args(["--version"])
        .output()
        .await
    {
        Ok(o) => o.status.success(),
        Err(_) => false,
    }
}

async fn is_git_repository<P>(path: P) -> bool
where
    P: AsRef<Utf8Path>,
{
    let p = path.as_ref();
    if !p.is_dir() {
        return false;
    }
    match process::Command::new("git")
        .args(["status"])
        .current_dir(p)
        .output()
        .await
    {
        Ok(o) => o.status.success(),
        Err(_) => false,
    }
}

fn is_git_url_eq(a: &Url, b: &Url) -> bool {
    if a == b {
        return true;
    }
    normalize_git_url(a) == normalize_git_url(b)
}

fn normalize_git_url(u: &Url) -> Url {
    let mut n = u.clone();
    if n.scheme == git_url::Scheme::Ssh {
        // "git@...:..." is common enough that the comparison is meaningful without it
        if n.user() == Some("git") {
            n.set_user(None);
        }

        // some SSH URLs have a path with a leading-slash,
        // which results in a double-slash after parsing
        let p = n.path.to_string();
        if p.starts_with("//") {
            n.path = p.replace("//", "/").into();
        }
    }
    if n.scheme != git_url::Scheme::Https {
        n.scheme = git_url::Scheme::Https;
    }
    n
}

fn to_toml_git_url<S>(input: &Url, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
    S: Serializer,
{
    serializer.serialize_str(&format!("{}", input.to_bstring()))
}

#[allow(clippy::similar_names)]
#[cfg(test)]
mod tests {
    use crate::status::Satisfying;

    use super::super::file::tests::{temp_dir, temp_file};

    use super::*;

    const DOTFILES_REPOSITORY: &str = "https://gitlab.com/jokeyrhyme/dotfiles.git";

    #[tokio::test]
    async fn error_when_absent_and_no_clone() -> std::result::Result<(), Error> {
        let git = Git {
            clone: Some(false),
            dest: temp_dir()?.join("absent"),
            repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
            ..Default::default()
        };

        match git.execute().await {
            Ok(_) => unreachable!(),
            Err(e) => assert_eq!(e, Error::DestNotFound { dest: git.dest }),
        }
        Ok(())
    }

    #[tokio::test]
    async fn git_pull_when_exists_and_matching_remote() -> std::result::Result<(), Error> {
        // TODO: get checkout main HEAD^1 and get commit hash
        // TODO: retrieve commit hash for main HEAD
        let git = Git {
            dest: temp_dir()?.join("exists"),
            repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
            ..Default::default()
        };

        let got = git.execute().await?;

        // TODO: assert that resulting status includes expected old HEAD and new HEAD commit hashes
        match got {
            Status::Satisfying(Satisfying::Changed(_, _)) => {}
            _ => unreachable!(),
        }
        Ok(())
    }

    #[tokio::test]
    async fn rm_and_git_clone_when_not_repo_and_force() -> std::result::Result<(), Error> {
        let f = file::File {
            path: temp_file()?.to_path_buf(),
            state: file::FileState::Directory,
            ..Default::default()
        };
        f.execute().await?;

        let git = Git {
            dest: f.path,
            force: Some(true),
            repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
            ..Default::default()
        };

        let got = git.execute().await?;

        // TODO: retrieve commit hash for remote main HEAD
        // TODO: assert that resulting status includes expected HEAD commit hash
        match got {
            Status::Satisfying(Satisfying::Changed(before, _)) => {
                assert_eq!(before, String::from("not repository"));
            }
            _ => unreachable!(),
        }
        Ok(())
    }

    #[tokio::test]
    async fn rm_and_git_clone_when_no_matching_remote_and_force() -> std::result::Result<(), Error>
    {
        let mut git = Git {
            dest: temp_dir()?.join("mismatch"),
            repo: Url::try_from(DOTFILES_REPOSITORY).expect("unable to parse dotfiles URL"),
            ..Default::default()
        };
        git.execute().await?;

        git = Git {
            dest: git.dest,
            force: Some(true),
            repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
            ..Default::default()
        };

        let got = git.execute().await?;

        // TODO: retrieve commit hash for remote main HEAD
        // TODO: assert that resulting status includes expected HEAD commit hash
        match got {
            Status::Satisfying(Satisfying::Changed(before, _)) => {
                assert_eq!(before, String::from("not repository"));
            }
            _ => unreachable!(),
        }
        Ok(())
    }

    #[tokio::test]
    async fn error_when_not_repo_and_no_force() -> std::result::Result<(), Error> {
        let f = file::File {
            path: temp_file()?.to_path_buf(),
            state: file::FileState::Directory,
            ..Default::default()
        };
        f.execute().await?;

        let git = Git {
            dest: f.path,
            repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
            ..Default::default()
        };

        match git.execute().await {
            Ok(_) => unreachable!(),
            Err(e) => assert_eq!(e, Error::DestExists { dest: git.dest }),
        }
        Ok(())
    }

    #[tokio::test]
    async fn error_when_no_matching_remote_and_no_force() -> std::result::Result<(), Error> {
        let mut git = Git {
            dest: temp_dir()?.join("mismatch"),
            repo: Url::try_from(DOTFILES_REPOSITORY).expect("unable to parse dotfiles URL"),
            ..Default::default()
        };
        git.execute().await?;

        git = Git {
            dest: git.dest,
            repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
            ..Default::default()
        };

        match git.execute().await {
            Ok(_) => unreachable!(),
            Err(e) => assert_eq!(e, Error::DestExists { dest: git.dest }),
        }
        Ok(())
    }

    #[tokio::test]
    async fn git_clone_when_absent() -> std::result::Result<(), Error> {
        let git = Git {
            dest: temp_dir()?.join("absent"),
            repo: Url::try_from(env!("CARGO_PKG_REPOSITORY")).expect("unable to parse project URL"),
            ..Default::default()
        };

        let got = git.execute().await?;

        // TODO: retrieve commit hash for remote main HEAD
        // TODO: assert that resulting status includes expected HEAD commit hash
        match got {
            Status::Satisfying(Satisfying::Changed(before, _)) => {
                assert_eq!(before, String::from("absent"));
            }
            _ => unreachable!(),
        }
        Ok(())
    }

    #[test]
    fn is_git_url_eq_with_same_urls() {
        assert!(is_git_url_eq(
            &Url::try_from(String::from("https://gitlab.com/jokeyrhyme/tuning.git"))
                .expect("unable to parse test URL"),
            &Url::try_from(String::from("https://gitlab.com/jokeyrhyme/tuning.git"))
                .expect("unable to parse test URL")
        ));
    }

    #[test]
    fn is_git_url_eq_with_ssh_versus_https_urls() {
        assert!(is_git_url_eq(
            &Url::try_from(String::from("git@gitlab.com:/jokeyrhyme/tuning.git"))
                .expect("unable to parse test URL"),
            &Url::try_from(String::from("https://gitlab.com/jokeyrhyme/tuning.git"))
                .expect("unable to parse test URL")
        ));
    }
}