use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Kind {
Command,
Font,
Extension,
}
impl Kind {
pub fn label(&self) -> &'static str {
match self {
Kind::Command => "command",
Kind::Font => "font",
Kind::Extension => "extension",
}
}
}
#[derive(Debug, Deserialize)]
pub struct Packages {
#[serde(default)]
pub packages: HashMap<String, Package>,
#[serde(default)]
pub ignore: Ignore,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Manager {
Brew,
BrewCask,
Mise,
Apt,
Yay,
ZedExtension,
None,
}
impl Manager {
fn parse(s: Option<&str>) -> Self {
match s {
None | Some("brew") => Manager::Brew,
Some("brew-cask") => Manager::BrewCask,
Some("mise") => Manager::Mise,
Some("apt") => Manager::Apt,
Some("yay") => Manager::Yay,
Some("zed-extension") => Manager::ZedExtension,
_ => Manager::None,
}
}
}
#[derive(Debug, Deserialize, Default)]
pub struct Package {
#[serde(default)]
pub provides: Vec<String>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub os: Vec<String>,
#[serde(default)]
pub optional: bool,
#[serde(default)]
pub manager: Option<String>,
#[serde(default)]
pub apt: Option<String>,
#[serde(default)]
pub yay: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Installable {
pub name: String,
pub manager: Manager,
}
#[derive(Debug, Deserialize, Default)]
pub struct Ignore {
#[serde(default)]
pub commands: Vec<String>,
}
impl Packages {
pub fn installable(&self) -> Vec<Installable> {
let linux_pm = if cfg!(target_os = "macos") {
None
} else {
detect_linux_pm()
};
let mut out: Vec<Installable> = self
.packages
.iter()
.filter(|(_, p)| !p.optional)
.filter(|(_, p)| p.os.is_empty() || p.os.iter().any(|o| o == current_os()))
.filter_map(|(name, p)| resolve(name, p, linux_pm))
.filter(|i| !matches!(i.manager, Manager::None | Manager::ZedExtension))
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))
}
pub fn provided(&self) -> HashMap<(Kind, String), bool> {
let mut out = HashMap::new();
let current_os = if cfg!(target_os = "macos") {
"darwin"
} else {
"linux"
};
for (name, pkg) in &self.packages {
let os_mismatch = !pkg.os.is_empty() && !pkg.os.iter().any(|o| o == current_os);
let optional = pkg.optional || os_mismatch;
let kind = match pkg.kind.as_deref() {
Some("font") => Kind::Font,
Some("extension") => Kind::Extension,
_ => Kind::Command,
};
out.insert((kind, name.clone()), optional);
for p in &pkg.provides {
out.insert((kind, p.clone()), optional);
}
}
for c in &self.ignore.commands {
out.insert((Kind::Command, c.clone()), false);
}
out
}
}
pub fn current_os() -> &'static str {
if cfg!(target_os = "macos") {
"darwin"
} else {
"linux"
}
}
fn detect_linux_pm() -> Option<Manager> {
for (bin, m) in [("yay", Manager::Yay), ("apt-get", Manager::Apt)] {
if which(bin) {
return Some(m);
}
}
None
}
fn which(bin: &str) -> bool {
std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths).any(|d| {
let p = d.join(bin);
p.is_file()
})
})
.unwrap_or(false)
}
fn resolve(name: &str, pkg: &Package, linux_pm: Option<Manager>) -> Option<Installable> {
if let Some(pm) = linux_pm {
let distro_name = match pm {
Manager::Apt => pkg.apt.as_deref(),
Manager::Yay => pkg.yay.as_deref(),
_ => None,
};
if let Some(n) = distro_name {
return Some(Installable {
name: n.to_string(),
manager: pm,
});
}
if Manager::parse(pkg.manager.as_deref()) == Manager::BrewCask && !is_font(pkg) {
return None;
}
}
Some(Installable {
name: name.to_string(),
manager: Manager::parse(pkg.manager.as_deref()),
})
}
fn is_font(pkg: &Package) -> bool {
pkg.kind.as_deref() == Some("font")
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(src: &str) -> Packages {
toml::from_str(src).unwrap()
}
#[test]
fn package_name_is_always_provided() {
let p = parse("[packages]\nbat = {}\n");
let provided = p.provided();
assert!(provided.contains_key(&(Kind::Command, "bat".into())));
}
#[test]
fn provides_adds_aliases_without_replacing_the_name() {
let p = parse("[packages]\nnushell = { provides = [\"nu\"] }\n");
let provided = p.provided();
assert!(provided.contains_key(&(Kind::Command, "nushell".into())));
assert!(provided.contains_key(&(Kind::Command, "nu".into())));
}
#[test]
fn optional_packages_are_declared_but_not_installable() {
let p = parse("[packages]\nkubectl = { optional = true }\n");
assert!(p.provided()[&(Kind::Command, "kubectl".into())]);
assert!(p.installable().is_empty());
}
#[test]
fn ignore_list_counts_as_provided() {
let p = parse("[packages]\n[ignore]\ncommands = [\"brew\"]\n");
assert!(p.provided().contains_key(&(Kind::Command, "brew".into())));
}
#[test]
fn fonts_and_commands_live_in_separate_namespaces() {
let p = parse("[packages.font-x]\nkind = \"font\"\nprovides = [\"Hack\"]\n");
let provided = p.provided();
assert!(provided.contains_key(&(Kind::Font, "Hack".into())));
assert!(!provided.contains_key(&(Kind::Command, "Hack".into())));
}
#[test]
fn zed_extensions_are_not_installed_by_sync() {
let p =
parse("[packages.tokyo-night]\nmanager = \"zed-extension\"\nkind = \"extension\"\n");
assert!(p.installable().is_empty());
}
fn pkg(src: &str) -> Package {
toml::from_str(src).unwrap()
}
#[test]
fn apt_declaration_overrides_manager_and_name_on_linux() {
let p = pkg("apt = \"libyaml-dev\"\nyay = \"libyaml\"\n");
let r = resolve("libyaml", &p, Some(Manager::Apt)).unwrap();
assert_eq!(r.manager, Manager::Apt);
assert_eq!(r.name, "libyaml-dev");
}
#[test]
fn yay_declaration_is_used_on_arch() {
let p = pkg("apt = \"build-essential\"\nyay = \"base-devel\"\n");
let r = resolve("build-essential", &p, Some(Manager::Yay)).unwrap();
assert_eq!(r.manager, Manager::Yay);
assert_eq!(r.name, "base-devel");
}
#[test]
fn falls_back_to_default_manager_when_no_distro_name() {
let p = pkg("");
let r = resolve("bat", &p, Some(Manager::Apt)).unwrap();
assert_eq!(r.manager, Manager::Brew);
assert_eq!(r.name, "bat");
}
#[test]
fn casks_are_skipped_on_linux() {
let p = pkg("manager = \"brew-cask\"\n");
assert!(resolve("alacritty", &p, Some(Manager::Apt)).is_none());
}
#[test]
fn font_casks_still_apply_on_linux() {
let p = pkg("manager = \"brew-cask\"\nkind = \"font\"\n");
let r = resolve("font-cica", &p, Some(Manager::Apt)).unwrap();
assert_eq!(r.manager, Manager::BrewCask);
}
#[test]
fn distro_names_are_ignored_on_macos() {
let p = pkg("apt = \"libyaml-dev\"\n");
let r = resolve("libyaml", &p, None).unwrap();
assert_eq!(r.manager, Manager::Brew);
assert_eq!(r.name, "libyaml");
}
#[test]
fn manager_defaults_to_brew() {
let p = parse("[packages]\nbat = {}\n");
let i = p.installable();
assert_eq!(i.len(), 1);
assert_eq!(i[0].manager, Manager::Brew);
}
}