use crate::packages::{Kind, Manager, Package, Packages};
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Report {
pub os: String,
pub profiles: Vec<String>,
pub present: Vec<String>,
pub missing: Vec<String>,
pub unverifiable: Vec<String>,
}
pub fn compare(a: &Path, b: &Path) -> Result<()> {
let ra: Report = serde_json::from_str(&std::fs::read_to_string(a)?)
.with_context(|| format!("failed to parse {}", a.display()))?;
let rb: Report = serde_json::from_str(&std::fs::read_to_string(b)?)
.with_context(|| format!("failed to parse {}", b.display()))?;
let sa: std::collections::BTreeSet<_> = ra.present.iter().collect();
let sb: std::collections::BTreeSet<_> = rb.present.iter().collect();
let only_a: Vec<_> = sa.difference(&sb).collect();
let only_b: Vec<_> = sb.difference(&sa).collect();
println!("{} ({}) vs {} ({})", a.display(), ra.os, b.display(), rb.os);
println!(" {} present on both", sa.intersection(&sb).count());
if only_a.is_empty() && only_b.is_empty() {
println!("\x1b[32mok\x1b[0m the two machines agree");
return Ok(());
}
if !only_a.is_empty() {
println!("\nonly on {} ({}):", a.display(), ra.os);
for n in only_a {
println!(" \x1b[33m<\x1b[0m {n}");
}
}
if !only_b.is_empty() {
println!("\nonly on {} ({}):", b.display(), rb.os);
for n in only_b {
println!(" \x1b[33m>\x1b[0m {n}");
}
}
Ok(())
}
pub fn verify(root: &Path, export: Option<PathBuf>) -> Result<()> {
let packages = Packages::load(&root.join("packages.toml"))?;
let mut missing = Vec::new();
let mut present = Vec::new();
let mut unverifiable = Vec::new();
let mut checked = 0usize;
let mut skipped = 0usize;
for (name, pkg) in packages.applicable() {
match verifiable(&name, pkg) {
None => {
skipped += 1;
unverifiable.push(name.clone());
}
Some(candidates) => {
checked += 1;
if candidates.iter().any(|(kind, n)| resolves(*kind, n)) {
present.push(name.clone());
} else {
let shown = candidates
.iter()
.map(|(_, n)| n.as_str())
.collect::<Vec<_>>()
.join(" / ");
missing.push((name.clone(), shown));
}
}
}
}
missing.sort();
if let Some(out) = export {
let report = Report {
os: crate::packages::current_os().to_string(),
profiles: crate::packages::current_profiles(),
present: present.clone(),
missing: missing.iter().map(|(p, _)| p.clone()).collect(),
unverifiable: unverifiable.clone(),
};
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&out, serde_json::to_string_pretty(&report)?)
.with_context(|| format!("failed to write {}", out.display()))?;
println!("wrote {}", out.display());
}
println!("verified {checked} package(s) against this machine ({skipped} not verifiable)");
for (pkg, names) in &missing {
println!(" \x1b[31mmissing\x1b[0m {pkg} (looked for: {names})");
}
if missing.is_empty() {
println!("\x1b[32mok\x1b[0m everything verifiable is present");
return Ok(());
}
bail!(
"{} declared package(s) not found on this machine",
missing.len()
)
}
fn verifiable(name: &str, pkg: &Package) -> Option<Vec<(Kind, String)>> {
let kind = pkg.kind_of();
match kind {
Kind::Extension => return None,
Kind::Font => {
if pkg.provides.is_empty() {
return None;
}
return Some(
pkg.provides
.iter()
.map(|p| (Kind::Font, p.clone()))
.collect(),
);
}
Kind::Command => {}
Kind::Library => return None,
}
if pkg.manager_of() == Manager::BrewCask {
return None;
}
if pkg.manager_of() == Manager::Mise {
return None;
}
let mut out: Vec<(Kind, String)> = pkg
.provides
.iter()
.map(|p| (Kind::Command, p.clone()))
.collect();
if out.is_empty() {
out.push((Kind::Command, name.to_string()));
}
Some(out)
}
fn resolves(kind: Kind, name: &str) -> bool {
match kind {
Kind::Font => font_installed(name),
_ => on_path(name),
}
}
fn on_path(name: &str) -> bool {
std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths).any(|dir| {
let p = dir.join(name);
p.is_file() && is_executable(&p)
})
})
.unwrap_or(false)
}
#[cfg(unix)]
fn is_executable(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(_p: &Path) -> bool {
true
}
fn font_installed(family: &str) -> bool {
let needle = family.replace(' ', "").to_lowercase();
font_dirs().iter().any(|dir| {
std::fs::read_dir(dir)
.map(|entries| {
entries.filter_map(|e| e.ok()).any(|e| {
e.file_name()
.to_string_lossy()
.replace(' ', "")
.to_lowercase()
.starts_with(&needle)
})
})
.unwrap_or(false)
})
}
fn font_dirs() -> Vec<PathBuf> {
let home = std::env::var("HOME").unwrap_or_default();
if cfg!(target_os = "macos") {
vec![
PathBuf::from(&home).join("Library/Fonts"),
PathBuf::from("/Library/Fonts"),
PathBuf::from("/System/Library/Fonts"),
]
} else {
vec![
PathBuf::from(&home).join(".local/share/fonts"),
PathBuf::from(&home).join(".fonts"),
PathBuf::from("/usr/share/fonts"),
PathBuf::from("/usr/local/share/fonts"),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pkg(src: &str) -> Package {
toml::from_str(src).unwrap()
}
#[test]
fn looks_for_provided_names_not_the_package_name() {
let p = pkg("provides = [\"nvim\"]\n");
let got = verifiable("neovim", &p).unwrap();
assert_eq!(got, vec![(Kind::Command, "nvim".to_string())]);
}
#[test]
fn falls_back_to_the_package_name() {
let p = pkg("");
let got = verifiable("bat", &p).unwrap();
assert_eq!(got, vec![(Kind::Command, "bat".to_string())]);
}
#[test]
fn gui_casks_are_not_verifiable() {
let p = pkg("manager = \"brew-cask\"\n");
assert!(verifiable("slack", &p).is_none());
}
#[test]
fn font_casks_are_verified_by_family_name() {
let p =
pkg("manager = \"brew-cask\"\nkind = \"font\"\nprovides = [\"Hack Nerd Font Mono\"]\n");
let got = verifiable("font-hack-nerd-font", &p).unwrap();
assert_eq!(got, vec![(Kind::Font, "Hack Nerd Font Mono".to_string())]);
}
#[test]
fn mise_managed_tools_are_not_verifiable() {
let p = pkg("manager = \"mise\"\n");
assert!(verifiable("go", &p).is_none());
}
#[test]
fn libraries_are_not_verifiable() {
let p = pkg("kind = \"library\"\n");
assert!(verifiable("libyaml", &p).is_none());
}
}