use std::ffi::OsString;
use std::path::{Path, PathBuf};
const WINGET_IDENTIFIER: &str = "shep-pm.shep";
const DEB_BINARY: &str = "/usr/bin/shep";
const DPKG_INFO: &str = "/var/lib/dpkg/info";
const RELEASES_URL: &str = "https://github.com/shep-pm/shep/releases/latest";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Channel {
Homebrew,
Cargo,
Scoop,
Winget,
Chocolatey,
Deb,
Archive,
}
#[derive(Debug, Default)]
pub(crate) struct Probe {
pub(crate) cargo_home: Option<PathBuf>,
pub(crate) scoop_roots: Vec<PathBuf>,
pub(crate) chocolatey_root: Option<PathBuf>,
pub(crate) dpkg_info: PathBuf,
}
impl Probe {
fn resolved(self) -> Self {
let resolve = |root: PathBuf| root.canonicalize().unwrap_or(root);
Self {
cargo_home: self.cargo_home.map(resolve),
scoop_roots: self.scoop_roots.into_iter().map(resolve).collect(),
chocolatey_root: self.chocolatey_root.map(resolve),
dpkg_info: self.dpkg_info,
}
}
fn from_env(var: &dyn Fn(&str) -> Option<OsString>) -> Self {
let set = |key: &str| {
var(key)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
};
Self {
cargo_home: set("CARGO_HOME")
.or_else(|| shep_core::paths::user_home(var).map(|home| home.join(".cargo"))),
scoop_roots: ["SCOOP", "SCOOP_GLOBAL"]
.into_iter()
.filter_map(set)
.collect(),
chocolatey_root: set("ChocolateyInstall"),
dpkg_info: PathBuf::from(DPKG_INFO),
}
}
}
impl Channel {
pub(crate) fn of(exe: &Path, probe: &Probe) -> Self {
let path = Components::of(exe);
if path.has_run(&["cellar", "shep"]) {
Self::Homebrew
} else if probe
.cargo_home
.as_deref()
.is_some_and(|home| path.is_directly_in(&Components::of(home).child("bin")))
{
Self::Cargo
} else if path.has_run(&["scoop", "apps", "shep"])
|| probe
.scoop_roots
.iter()
.any(|root| path.is_under(root, &["apps", "shep"]))
{
Self::Scoop
} else if path.has_winget_packages() {
Self::Winget
} else if path.has_run(&["chocolatey", "lib", "shep"])
|| probe
.chocolatey_root
.as_deref()
.is_some_and(|root| path.is_under(root, &["lib", "shep"]))
{
Self::Chocolatey
} else if path.is_exactly(DEB_BINARY) && dpkg_names_shep(&probe.dpkg_info) {
Self::Deb
} else {
Self::Archive
}
}
pub(crate) fn running() -> Self {
let Ok(exe) = std::env::current_exe().and_then(|exe| exe.canonicalize()) else {
return Self::Archive;
};
Self::of(
&exe,
&Probe::from_env(&|key| std::env::var_os(key)).resolved(),
)
}
pub(crate) fn command(self) -> Option<String> {
match self {
Self::Homebrew => Some("brew upgrade shep".to_owned()),
Self::Cargo => Some("cargo install --locked shep".to_owned()),
Self::Scoop => Some("scoop update shep".to_owned()),
Self::Winget => Some(format!("winget upgrade {WINGET_IDENTIFIER}")),
Self::Chocolatey => Some("choco upgrade shep".to_owned()),
Self::Deb | Self::Archive => None,
}
}
pub(crate) fn manual_steps(self, target: &str) -> Vec<String> {
let sums = format!("{RELEASES_URL}/download/SHA256SUMS");
let check = format!("Check it against {sums} with `sha256sum --check --ignore-missing`");
match self {
Self::Deb => vec![
format!("Download the .deb for your architecture from {RELEASES_URL}"),
check,
"Install it with `sudo dpkg -i shep_*.deb`".to_owned(),
],
Self::Archive => {
let extension = if target.contains("windows") {
"zip"
} else {
"tar.gz"
};
vec![
format!("Download {RELEASES_URL}/download/shep-{target}.{extension}"),
check,
"Put the shep inside it where the old one is".to_owned(),
]
}
Self::Homebrew | Self::Cargo | Self::Scoop | Self::Winget | Self::Chocolatey => {
Vec::new()
}
}
}
}
struct Components(Vec<String>);
impl Components {
fn of(path: &Path) -> Self {
let text = path.to_string_lossy().to_lowercase().replace('\\', "/");
let text = text.strip_prefix("//?/").unwrap_or(&text);
Self(
text.split('/')
.filter(|part| !part.is_empty())
.map(str::to_owned)
.collect(),
)
}
fn child(mut self, name: &str) -> Self {
self.0.push(name.to_owned());
self
}
fn has_run(&self, run: &[&str]) -> bool {
self.0.windows(run.len()).any(|window| window == run)
}
fn is_directly_in(&self, dir: &Self) -> bool {
self.0.len() == dir.0.len() + 1 && self.0.starts_with(&dir.0)
}
fn is_under(&self, root: &Path, run: &[&str]) -> bool {
let mut prefix = Components::of(root).0;
prefix.extend(run.iter().map(|part| (*part).to_owned()));
self.0.len() > prefix.len() && self.0.starts_with(&prefix)
}
fn is_exactly(&self, path: &str) -> bool {
self.0 == Self::of(Path::new(path)).0
}
fn has_winget_packages(&self) -> bool {
self.0
.iter()
.position(|part| part == "winget")
.is_some_and(|at| self.0[at + 1..].iter().any(|part| part == "packages"))
}
}
fn is_shep_list(name: &str) -> bool {
name == "shep.list"
|| name
.strip_prefix("shep:")
.and_then(|rest| rest.strip_suffix(".list"))
.is_some_and(|arch| !arch.is_empty())
}
fn dpkg_names_shep(info: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(info) else {
return false;
};
entries.flatten().any(|entry| {
entry.file_name().to_str().is_some_and(is_shep_list)
&& std::fs::read_to_string(entry.path())
.is_ok_and(|list| list.lines().any(|line| line.trim() == DEB_BINARY))
})
}
#[cfg(test)]
mod tests;