use crate::failure::Failure;
use crate::output::outln;
use std::path::{Path, PathBuf};
const WIDTH: usize = 76;
const CRATES_IO_REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
const CRATES_IO_SPARSE: &str = "sparse+https://index.crates.io/";
struct CargoInstall {
command: String,
described_as: String,
}
fn key_source(key: &str) -> Option<&str> {
let open = key.rfind('(')?;
let close = key.rfind(')')?;
if close <= open {
return None;
}
Some(&key[open + 1..close])
}
fn hex_value(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2])) {
out.push(hi * 16 + lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(windows)]
fn strip_drive_slash(p: &str) -> String {
let bytes = p.as_bytes();
if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' {
p[1..].to_string()
} else {
p.to_string()
}
}
#[cfg(not(windows))]
fn strip_drive_slash(p: &str) -> String {
p.to_string()
}
fn classify_source(source: &str) -> Option<CargoInstall> {
if source == CRATES_IO_REGISTRY || source == CRATES_IO_SPARSE {
return Some(CargoInstall {
command: "cargo install vivac".to_string(),
described_as: "from crates.io".to_string(),
});
}
if let Some(url) = source.strip_prefix("registry+") {
return Some(CargoInstall {
command: format!("cargo install vivac --index {url}"),
described_as: format!("from the registry at {url}"),
});
}
if source.starts_with("sparse+") {
return Some(CargoInstall {
command: format!("cargo install vivac --index {source}"),
described_as: format!("from the registry at {source}"),
});
}
if let Some(rest) = source.strip_prefix("git+") {
let end = rest.find(['?', '#']).unwrap_or(rest.len());
let url = &rest[..end];
return Some(CargoInstall {
command: format!("cargo install --git {url} vivac"),
described_as: format!("from {url}"),
});
}
if let Some(raw) = source.strip_prefix("path+file://") {
let path = strip_drive_slash(&percent_decode(raw));
let path = if path.contains(char::is_whitespace) {
format!("\"{path}\"")
} else {
path
};
return Some(CargoInstall {
command: format!("cargo install --path {path}"),
described_as: "from a local folder".to_string(),
});
}
None
}
fn cargo_install_from_json(json: &str) -> Option<CargoInstall> {
let v: serde_json::Value = serde_json::from_str(json).ok()?;
let installs = v.get("installs")?.as_object()?;
let key = installs
.keys()
.find(|k| k.split_whitespace().next() == Some("vivac"))?;
classify_source(key_source(key)?)
}
fn detect_cargo_install(dir: &Path) -> Option<CargoInstall> {
if dir.file_name() != Some(std::ffi::OsStr::new("bin")) {
return None;
}
let json = std::fs::read_to_string(dir.parent()?.join(".crates2.json")).ok()?;
cargo_install_from_json(&json)
}
fn archive_name(os: &str, arch: &str) -> Option<&'static str> {
match (os, arch) {
("linux", "x86_64") => Some("vivac-x86_64-unknown-linux-musl.tar.gz"),
("linux", "aarch64") => Some("vivac-aarch64-unknown-linux-musl.tar.gz"),
("macos", "x86_64") => Some("vivac-x86_64-apple-darwin.tar.gz"),
("macos", "aarch64") => Some("vivac-aarch64-apple-darwin.tar.gz"),
("windows", "x86_64") => Some("vivac-x86_64-pc-windows-msvc.zip"),
_ => None,
}
}
#[cfg(windows)]
mod windows_set_aside {
use crate::failure::Failure;
use std::path::Path;
pub(super) fn set_aside(exe: &Path, dir: &Path, stem: &str) -> Result<usize, Failure> {
let still_running = sweep(dir, stem);
let id = crate::id::ulid();
let next = dir.join(format!("{stem}.next-{id}.exe"));
let previous = dir.join(format!("{stem}.previous-{id}.exe"));
if let Err(e) = std::fs::copy(exe, &next) {
let _ = std::fs::remove_file(&next);
return Err(Failure::set_aside(e));
}
if let Err(e) = std::fs::rename(exe, &previous) {
let _ = std::fs::remove_file(&next);
return Err(Failure::set_aside(e));
}
if let Err(e) = std::fs::rename(&next, exe) {
let _ = std::fs::rename(&previous, exe);
let _ = std::fs::remove_file(&next);
return Err(Failure::set_aside(e));
}
Ok(still_running)
}
fn sweep(dir: &Path, stem: &str) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let previous_prefix = format!("{stem}.previous-");
let next_prefix = format!("{stem}.next-");
let mut still_running = 0;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let is_previous = name.starts_with(&previous_prefix) && name.ends_with(".exe");
let is_next = name.starts_with(&next_prefix) && name.ends_with(".exe");
if !is_previous && !is_next {
continue;
}
if std::fs::remove_file(entry.path()).is_err() && is_previous {
still_running += 1;
}
}
still_running
}
}
fn paragraph(text: &str) {
for line in crate::render::wrap(text, WIDTH, "") {
outln!(" {line}");
}
}
pub fn run() -> Result<(), Failure> {
let exe = std::env::current_exe().map_err(Failure::Io)?;
let dir = exe
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let cargo = detect_cargo_install(&dir);
#[cfg(windows)]
let still_running = {
let stem = exe.file_stem().and_then(|s| s.to_str()).unwrap_or("vivac");
windows_set_aside::set_aside(&exe, &dir, stem)?
};
let v = env!("CARGO_PKG_VERSION");
let dir_display = dir.display();
match &cargo {
Some(c) => paragraph(&format!(
"vivac {v}, installed by cargo install {}.",
c.described_as
)),
None => paragraph(&format!(
"vivac {v}, in {dir_display}, not installed by cargo install."
)),
}
#[cfg(windows)]
{
outln!();
paragraph(&format!(
"The copy that was running is set aside, so the install no longer \
waits for sessions to close. Sessions and vivac web that are already \
open keep {v} until they restart; anything started after the install \
runs the new one."
));
if still_running > 0 {
outln!();
paragraph(&if still_running == 1 {
"1 copy set aside by an earlier update is still running. A later \
vivac update removes it once nothing runs it."
.to_string()
} else {
format!(
"{still_running} copies set aside by an earlier update are still \
running. A later vivac update removes them once nothing runs \
them."
)
});
}
}
outln!();
match &cargo {
Some(c) => {
paragraph("Now run:");
outln!();
outln!(" {}", c.command);
#[cfg(windows)]
{
outln!();
paragraph(
"If it still fails with os error 5, a session started vivac in \
the meantime: run vivac update again, then the install.",
);
}
}
None => match archive_name(std::env::consts::OS, std::env::consts::ARCH) {
Some(archive) => {
let noun = if cfg!(windows) { "vivac.exe" } else { "vivac" };
paragraph(&format!(
"Now download {archive} from \
https://github.com/JAAvila-Of/vivac/releases/latest, check it \
against the SHA256SUMS published there, and put the {noun} \
inside it in {dir_display}."
));
#[cfg(windows)]
{
outln!();
paragraph(
"If that still fails because the file is in use, a session \
started vivac in the meantime: run vivac update again, then \
put it there.",
);
}
}
None => paragraph(
"There is no release archive for this platform. With a Rust \
toolchain, cargo install vivac builds it.",
),
},
}
#[cfg(not(windows))]
{
outln!();
paragraph(&format!(
"Sessions and vivac web that are already open keep {v} until they \
restart; anything started after the install runs the new one."
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_crates_io_registry_key_is_recognised() {
let json = r#"{"installs":{"vivac 0.15.5 (registry+https://github.com/rust-lang/crates.io-index)":{"bins":["vivac"]}}}"#;
let c = cargo_install_from_json(json).expect("should be a cargo install");
assert_eq!(c.command, "cargo install vivac");
assert_eq!(c.described_as, "from crates.io");
}
#[test]
fn a_sparse_crates_io_key_is_recognised() {
let json =
r#"{"installs":{"vivac 0.15.5 (sparse+https://index.crates.io/)":{"bins":["vivac"]}}}"#;
let c = cargo_install_from_json(json).expect("should be a cargo install");
assert_eq!(c.command, "cargo install vivac");
assert_eq!(c.described_as, "from crates.io");
}
#[test]
fn a_registry_key_that_is_not_crates_io_names_its_own_index() {
let json = r#"{"installs":{"vivac 0.15.5 (registry+https://example.com/my-index)":{"bins":["vivac"]}}}"#;
let c = cargo_install_from_json(json).expect("should be a cargo install");
assert_eq!(
c.command,
"cargo install vivac --index https://example.com/my-index"
);
assert_eq!(
c.described_as,
"from the registry at https://example.com/my-index"
);
}
#[test]
fn a_sparse_key_that_is_not_crates_io_keeps_its_own_prefix() {
let json = r#"{"installs":{"vivac 0.15.5 (sparse+https://example.com/my-index/)":{"bins":["vivac"]}}}"#;
let c = cargo_install_from_json(json).expect("should be a cargo install");
assert_eq!(
c.command,
"cargo install vivac --index sparse+https://example.com/my-index/"
);
assert_eq!(
c.described_as,
"from the registry at sparse+https://example.com/my-index/"
);
}
#[test]
fn a_git_key_strips_its_query_and_fragment() {
let json = r#"{"installs":{"vivac 0.15.5 (git+https://github.com/example/vivac?branch=main#deadbeef)":{"bins":["vivac"]}}}"#;
let c = cargo_install_from_json(json).expect("should be a cargo install");
assert_eq!(
c.command,
"cargo install --git https://github.com/example/vivac vivac"
);
assert_eq!(c.described_as, "from https://github.com/example/vivac");
}
#[test]
fn a_windows_path_key_decodes_percent_escapes() {
let json = r#"{"installs":{"vivac 0.15.5 (path+file:///H:/tmp/vivac%20src)":{"bins":["vivac.exe"]}}}"#;
let c = cargo_install_from_json(json).expect("should be a cargo install");
#[cfg(windows)]
assert_eq!(c.command, "cargo install --path \"H:/tmp/vivac src\"");
#[cfg(not(windows))]
assert_eq!(c.command, "cargo install --path \"/H:/tmp/vivac src\"");
assert_eq!(c.described_as, "from a local folder");
}
#[test]
fn an_unrelated_crates_key_is_not_a_cargo_install_of_vivac() {
let json = r#"{"installs":{"ripgrep 14.0.0 (registry+https://github.com/rust-lang/crates.io-index)":{"bins":["rg"]}}}"#;
assert!(cargo_install_from_json(json).is_none());
}
#[test]
fn malformed_json_is_not_a_cargo_install() {
assert!(cargo_install_from_json("{not json").is_none());
}
#[test]
fn the_archive_map_matches_the_release_workflow() {
assert_eq!(
archive_name("linux", "x86_64"),
Some("vivac-x86_64-unknown-linux-musl.tar.gz")
);
assert_eq!(
archive_name("linux", "aarch64"),
Some("vivac-aarch64-unknown-linux-musl.tar.gz")
);
assert_eq!(
archive_name("macos", "x86_64"),
Some("vivac-x86_64-apple-darwin.tar.gz")
);
assert_eq!(
archive_name("macos", "aarch64"),
Some("vivac-aarch64-apple-darwin.tar.gz")
);
assert_eq!(
archive_name("windows", "x86_64"),
Some("vivac-x86_64-pc-windows-msvc.zip")
);
assert_eq!(archive_name("freebsd", "x86_64"), None);
}
}