tuning 0.4.0

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

use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error as ThisError;
use tokio::process;

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

#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum DesiredState {
    Absent,
    Latest,
    Present,
}

// TODO: wait for https://github.com/rust-lang/rust/issues/35121
// TODO: drop this and use the std::never::Never type instead
#[derive(Debug, ThisError)]
pub(crate) enum Error {
    #[error(transparent)]
    CommandJob {
        #[from]
        source: command::Error,
    },
    #[error(transparent)]
    FileJob {
        #[from]
        source: file::Error,
    },
    #[allow(dead_code)]
    #[error("never")]
    Never,
}
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 InstallerCargo {
    /// Crates to target?
    pub crates: Vec<String>,
    /// Custom path for `CARGO_HOME` / `CARGO_INSTALL_ROOT`?
    pub root: Option<Utf8PathBuf>,
    /// Action to perform?
    /// - [`Absent`](DesiredDate::Absent) to uninstall target [`crates`](InstallerCargo::crates)
    /// - [`Latest`](DesiredDate::Latest) to update all currently-installed crates
    /// - [`Present`](DesiredDate::Present) to install target [`crates`](InstallerCargo::crates)
    pub state: DesiredState,
}
impl Default for InstallerCargo {
    fn default() -> Self {
        Self {
            crates: vec![],
            root: None,
            state: DesiredState::Latest,
        }
    }
}
impl fmt::Display for InstallerCargo {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "cargo install {} {:?} crates",
            &self.crates.len(),
            &self.state
        )
    }
}
impl InstallerCargo {
    pub async fn execute(&self) -> Result {
        match self.state {
            DesiredState::Absent => {
                if self.crates.is_empty() {
                    return Ok(Status::no_change(String::from("0 crates to uninstall")));
                }

                let before = self.found_versions().await;
                let found: Vec<String> = before.keys().map(String::from).collect();
                let surplus: Vec<String> = self
                    .crates
                    .iter()
                    .filter(|c| !c.trim().is_empty() && found.contains(c))
                    .map(String::from)
                    .collect();
                if !surplus.is_empty() {
                    let mut cmd = self.cargo_command();
                    cmd.argv.insert(0, String::from("uninstall"));
                    cmd.argv.extend(surplus);
                    cmd.execute().await?;
                }

                let after = self.found_versions().await;

                Ok(installer::calculate_status(&before, &after))
            }
            DesiredState::Latest => {
                let found: Vec<Crate> = self.found().await.values().map(Crate::clone).collect();
                self.install_crates(found).await
            }
            DesiredState::Present => {
                if self.crates.is_empty() {
                    return Ok(Status::no_change(String::from("0 crates to install")));
                }

                let found = self.found().await;

                let missing: Vec<Crate> = self
                    .crates
                    .iter()
                    .filter(|c| !c.trim().is_empty())
                    .filter_map(|c| {
                        if found.contains_key(c)
                            || found.values().any(|v| {
                                if let Crate::GitCrate(gc) = v {
                                    c == &format!("{}", gc.repository.to_bstring())
                                } else {
                                    false
                                }
                            })
                        {
                            None
                        } else {
                            Some(Crate::from(c.as_str()))
                        }
                    })
                    .collect();

                self.install_crates(missing).await
            }
        }
    }

    fn cargo_command(&self) -> command::Command {
        let mut cmd = command::Command {
            argv: vec![],
            command: String::from("cargo"),
            ..Default::default()
        };
        if let Some(root) = &self.root {
            cmd.argv
                .extend(vec![String::from("--root"), root.to_string()]);
        }
        cmd
    }

    async fn found(&self) -> HashMap<String, Crate> {
        let mut p = process::Command::new("cargo");
        p.args(["install", "--list"]);
        if let Some(root) = &self.root {
            p.args(["--root", root.as_str()]);
        };
        let out = match p.output().await {
            Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
            Err(_) => String::new(),
        };
        if out.trim().is_empty() {
            return HashMap::new();
        }
        parse_installed(out)
    }

    async fn found_versions(&self) -> HashMap<String, String> {
        self.found()
            .await
            .iter()
            .map(|(k, v)| (k.clone(), v.version()))
            .collect()
    }

    async fn install_crates(&self, crates: Vec<Crate>) -> Result {
        let before = self.found_versions().await;

        let crates_io_crates: Vec<String> = crates
            .iter()
            .filter_map(|c| {
                if let Crate::CratesIoCrate(krate) = c {
                    Some(krate.name.clone())
                } else {
                    None
                }
            })
            .collect();

        if !crates_io_crates.is_empty() {
            let mut cmd = self.cargo_command();
            cmd.argv.insert(0, String::from("install"));
            cmd.argv.extend(crates_io_crates);
            cmd.execute().await?;
        }

        let git_crates: Vec<String> = crates
            .iter()
            .filter_map(|c| {
                if let Crate::GitCrate(krate) = c {
                    Some(format!("{}", krate.repository.to_bstring()))
                } else {
                    None
                }
            })
            .collect();

        for repository in git_crates {
            let cmd = self.install_git_crate_command(repository);
            cmd.execute().await?;
        }

        let after = self.found_versions().await;

        Ok(installer::calculate_status(&before, &after))
    }

    fn install_git_crate_command(&self, repository: String) -> command::Command {
        let mut cmd = self.cargo_command();
        cmd.argv.insert(0, String::from("install"));
        cmd.argv.extend([String::from("--git"), repository]);
        cmd
    }
}

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

/// see: [Specifying Dependencies](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html)
#[derive(Clone, Debug, PartialEq)]
enum Crate {
    CratesIoCrate(CratesIoCrate),
    GitCrate(GitCrate),
    // TODO: implement crate from filesystem path
}
impl From<&str> for Crate {
    fn from(input: &str) -> Self {
        let re = regex::Regex::new(r"^https://\S+\.git$").unwrap();
        match (re.is_match(input), git_url::Url::try_from(input)) {
            (true, Ok(repository)) => Self::GitCrate(GitCrate {
                name: String::from(input),
                repository,
                revision: None,
            }),
            _ => Self::CratesIoCrate(CratesIoCrate {
                name: String::from(input),
                version: String::new(),
            }),
        }
    }
}
impl Crate {
    fn version(&self) -> String {
        match self {
            Self::CratesIoCrate(c) => c.version.clone(),
            Self::GitCrate(c) => c.version(),
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
struct CratesIoCrate {
    name: String,
    version: String,
}

#[derive(Clone, Debug, PartialEq)]
struct GitCrate {
    name: String,
    repository: git_url::Url,
    revision: Option<String>,
}
impl GitCrate {
    fn version(&self) -> String {
        match &self.revision {
            Some(revision) => format!("{}#{}", self.repository.to_bstring(), revision),
            None => format!("{}", self.repository.to_bstring()),
        }
    }
}

fn parse_installed<S>(stdout: S) -> HashMap<String, Crate>
where
    S: AsRef<str>,
{
    let re = regex::Regex::new(r"^(?P<name>\S+)\sv(?P<version>\S+)(\s+\((?P<repository>https://\S+\.git)#(?P<revision>\w+)\))?:$").unwrap();
    let s = stdout.as_ref();
    let mut krates: HashMap<String, Crate> = HashMap::new();

    for line in s.lines() {
        if let Some(caps) = re.captures(line) {
            match (
                caps.name("name"),
                caps.name("version"),
                caps.name("repository"),
                caps.name("revision"),
            ) {
                (Some(name), _, Some(repository), Some(revision)) => {
                    if let Ok(r) = git_url::Url::try_from(repository.as_str()) {
                        krates.insert(
                            String::from(name.as_str()),
                            Crate::GitCrate(GitCrate {
                                name: String::from(name.as_str()),
                                repository: r,
                                revision: Some(String::from(revision.as_str())),
                            }),
                        );
                    }
                }
                (Some(name), Some(version), _, _) => {
                    krates.insert(
                        String::from(name.as_str()),
                        Crate::CratesIoCrate(CratesIoCrate {
                            name: String::from(name.as_str()),
                            version: String::from(version.as_str()),
                        }),
                    );
                }
                _ => {}
            }
        }
    }
    krates
}

#[cfg(test)]
mod tests {
    use std::fs::metadata;

    use crate::status::Satisfying;

    use super::super::file::tests::temp_dir;

    use super::*;

    const SAMPLE_CRATE: &str = "ssh-sensible";
    const SAMPLE_CRATE_URL: &str = "https://gitlab.com/jokeyrhyme/ssh-sensible-rs.git";

    fn make_latest(root: Utf8PathBuf) -> InstallerCargo {
        InstallerCargo {
            root: Some(root),
            crates: vec![],
            state: DesiredState::Latest,
        }
    }

    #[tokio::test]
    async fn present_then_latest_then_absent() -> std::result::Result<(), Error> {
        let tmp = temp_dir()?;
        let tmp_root = tmp.join(".cargo");
        let tmp_bin = tmp_root.join("bin");
        assert!(metadata(&tmp_bin.join(SAMPLE_CRATE)).is_err());

        let present = InstallerCargo {
            root: Some(tmp_root.clone()),
            crates: vec![String::from(SAMPLE_CRATE)],
            state: DesiredState::Present,
        };
        let initial_versions = present.found_versions().await;

        assert_eq!(initial_versions.len(), 0);

        let status = present.execute().await?;

        assert_eq!(
            status,
            Status::Satisfying(Satisfying::Changed(
                String::from("0 present"),
                String::from("0 present, 1 installed")
            ))
        );
        assert!(metadata(&tmp_bin.join(SAMPLE_CRATE)).is_ok());

        let status = make_latest(tmp_root.clone()).execute().await?;

        assert_eq!(
            status,
            Status::Satisfying(Satisfying::NoChange(String::from("1 present, 0 installed")))
        );

        let installed_versions = present.found_versions().await;
        assert_ne!(installed_versions.len(), 0);
        assert!(installed_versions.contains_key(SAMPLE_CRATE));

        let absent = InstallerCargo {
            root: Some(tmp_root),
            crates: vec![String::from(SAMPLE_CRATE)],
            state: DesiredState::Absent,
        };
        let status = absent.execute().await?;

        assert_eq!(
            status,
            Status::Satisfying(Satisfying::Changed(
                String::from("1 present"),
                String::from("0 present, 1 uninstalled")
            ))
        );
        assert!(metadata(&tmp_bin.join(SAMPLE_CRATE)).is_err());

        let absent_versions = absent.found_versions().await;
        assert_eq!(absent_versions.len(), 0);

        Ok(())
    }

    #[tokio::test]
    async fn present_then_latest_then_absent_with_git_crate() -> std::result::Result<(), Error> {
        let tmp = temp_dir()?;
        let tmp_root = tmp.join(".cargo");
        let tmp_bin = tmp_root.join("bin");
        assert!(metadata(&tmp_bin.join(SAMPLE_CRATE)).is_err());

        let present = InstallerCargo {
            root: Some(tmp_root.clone()),
            crates: vec![String::from(SAMPLE_CRATE_URL)],
            state: DesiredState::Present,
        };
        let initial_versions = present.found_versions().await;

        assert_eq!(initial_versions.len(), 0);

        let status = present.execute().await?;

        assert_eq!(
            status,
            Status::Satisfying(Satisfying::Changed(
                String::from("0 present"),
                String::from("0 present, 1 installed")
            ))
        );
        assert!(metadata(&tmp_bin.join(SAMPLE_CRATE)).is_ok());

        let status = make_latest(tmp_root.clone()).execute().await?;

        assert_eq!(
            status,
            Status::Satisfying(Satisfying::NoChange(String::from("1 present, 0 installed")))
        );

        let installed_versions = present.found_versions().await;
        assert_ne!(installed_versions.len(), 0);
        assert!(installed_versions.contains_key(SAMPLE_CRATE));

        let absent = InstallerCargo {
            root: Some(tmp_root),
            crates: vec![String::from(SAMPLE_CRATE)],
            state: DesiredState::Absent,
        };
        let status = absent.execute().await?;

        assert_eq!(
            status,
            Status::Satisfying(Satisfying::Changed(
                String::from("1 present"),
                String::from("0 present, 1 uninstalled")
            ))
        );
        assert!(metadata(&tmp_bin.join(SAMPLE_CRATE)).is_err());

        let absent_versions = absent.found_versions().await;
        assert_eq!(absent_versions.len(), 0);

        Ok(())
    }

    #[test]
    fn test_parse_installed() {
        let input = "
racer v2.0.12:
    racer
rustfmt v0.10.0:
    cargo-fmt
    rustfmt
rustsym v0.3.2:
    rustsym
ssh-sensible v1.0.0-alpha.0 (https://gitlab.com/jokeyrhyme/ssh-sensible-rs.git#68ba6e78):
    ssh-sensible
";
        let want = HashMap::<String, Crate>::from([
            (
                String::from("racer"),
                Crate::CratesIoCrate(CratesIoCrate {
                    name: String::from("racer"),
                    version: String::from("2.0.12"),
                }),
            ),
            (
                String::from("rustfmt"),
                Crate::CratesIoCrate(CratesIoCrate {
                    name: String::from("rustfmt"),
                    version: String::from("0.10.0"),
                }),
            ),
            (
                String::from("rustsym"),
                Crate::CratesIoCrate(CratesIoCrate {
                    name: String::from("rustsym"),
                    version: String::from("0.3.2"),
                }),
            ),
            (
                String::from("ssh-sensible"),
                Crate::GitCrate(GitCrate {
                    name: String::from("ssh-sensible"),
                    repository: git_url::Url::try_from(
                        "https://gitlab.com/jokeyrhyme/ssh-sensible-rs.git",
                    )
                    .expect("cannot parse git URL"),
                    revision: Some(String::from("68ba6e78")),
                }),
            ),
        ]);

        let got = parse_installed(input);

        assert_eq!(want, got);
    }
}