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,
}
#[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 {
pub crates: Vec<String>,
pub root: Option<Utf8PathBuf>,
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>;
#[derive(Clone, Debug, PartialEq)]
enum Crate {
CratesIoCrate(CratesIoCrate),
GitCrate(GitCrate),
}
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);
}
}