use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use super::{PkgError, PkgResult};
pub const INSTALLED_FILE: &str = "installed.toml";
pub struct Store {
root: PathBuf,
}
impl Store {
pub fn user_default() -> PkgResult<Store> {
let home = if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
PathBuf::from(custom)
} else {
let h = std::env::var_os("HOME")
.ok_or_else(|| PkgError::Other("HOME environment variable not set".into()))?;
PathBuf::from(h).join(".zshrs")
};
Ok(Store {
root: home.join("pkg"),
})
}
pub fn at(root: impl Into<PathBuf>) -> Store {
Store { root: root.into() }
}
pub fn store_dir(&self) -> PathBuf {
self.root.join("store")
}
pub fn cache_dir(&self) -> PathBuf {
self.root.join("cache")
}
pub fn git_dir(&self) -> PathBuf {
self.root.join("git")
}
pub fn bin_dir(&self) -> PathBuf {
self.root.join("bin")
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn package_dir(&self, name: &str, version: &str) -> PathBuf {
self.store_dir().join(format!("{}@{}", name, version))
}
pub fn ensure_layout(&self) -> PkgResult<()> {
for d in [
self.store_dir(),
self.cache_dir(),
self.git_dir(),
self.bin_dir(),
] {
std::fs::create_dir_all(&d)
.map_err(|e| PkgError::Io(format!("create {}: {}", d.display(), e)))?;
}
Ok(())
}
pub fn has_package(&self, name: &str, version: &str) -> bool {
self.package_dir(name, version).is_dir()
}
pub fn install_dir(&self, name: &str, version: &str, src: &Path) -> PkgResult<PathBuf> {
let dst = self.package_dir(name, version);
if dst.exists() {
std::fs::remove_dir_all(&dst)
.map_err(|e| PkgError::Io(format!("clear {}: {}", dst.display(), e)))?;
}
std::fs::create_dir_all(&dst)?;
copy_dir_filtered(src, &dst)?;
Ok(dst)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InstalledIndex {
pub version: u32,
#[serde(default, rename = "package")]
pub packages: Vec<InstalledPlugin>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InstalledPlugin {
pub name: String,
pub version: String,
pub source: String,
pub kind: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub integrity: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub lib: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_files: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fpath: Vec<String>,
}
impl InstalledIndex {
pub fn new() -> InstalledIndex {
InstalledIndex {
version: 1,
packages: Vec::new(),
}
}
pub fn load_from(store: &Store) -> PkgResult<InstalledIndex> {
let path = store.root().join(INSTALLED_FILE);
if !path.is_file() {
return Ok(InstalledIndex::new());
}
let s = std::fs::read_to_string(&path)
.map_err(|e| PkgError::Io(format!("read {}: {}", path.display(), e)))?;
toml::from_str::<InstalledIndex>(&s)
.map_err(|e| PkgError::Other(format!("parse {}: {}", path.display(), e.message())))
}
pub fn save_to(&mut self, store: &Store) -> PkgResult<()> {
self.packages.sort_by(|a, b| a.name.cmp(&b.name));
let path = store.root().join(INSTALLED_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(&self)
.map_err(|e| PkgError::Other(format!("serialize {}: {}", INSTALLED_FILE, e)))?;
std::fs::write(&path, format!("# znative — auto-generated. Do not edit.\n{}", body))
.map_err(|e| PkgError::Io(format!("write {}: {}", path.display(), e)))?;
Ok(())
}
pub fn find(&self, name: &str) -> Option<&InstalledPlugin> {
self.packages.iter().find(|p| p.name == name)
}
pub fn upsert(&mut self, p: InstalledPlugin) {
if let Some(slot) = self.packages.iter_mut().find(|e| e.name == p.name) {
*slot = p;
} else {
self.packages.push(p);
}
}
pub fn remove(&mut self, name: &str) -> Option<InstalledPlugin> {
let idx = self.packages.iter().position(|p| p.name == name)?;
Some(self.packages.remove(idx))
}
}
fn copy_dir_filtered(src: &Path, dst: &Path) -> PkgResult<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name();
let name_s = name.to_string_lossy();
if name_s == ".git" || name_s == "target" {
continue;
}
let from = entry.path();
let to = dst.join(&name);
let ft = entry.file_type()?;
if ft.is_dir() {
copy_dir_filtered(&from, &to)?;
} else if ft.is_symlink() {
if let Ok(target) = std::fs::read(&from) {
std::fs::write(&to, target)?;
}
} else {
std::fs::copy(&from, &to)
.map_err(|e| PkgError::Io(format!("copy {}: {}", from.display(), e)))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> PathBuf {
let p = std::env::temp_dir().join(format!(
"znative-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn install_dir_skips_git_and_target() {
let src = tmp();
std::fs::write(src.join("a.plugin.zsh"), b"echo hi").unwrap();
std::fs::create_dir_all(src.join(".git")).unwrap();
std::fs::write(src.join(".git/HEAD"), b"ref").unwrap();
std::fs::create_dir_all(src.join("target")).unwrap();
std::fs::write(src.join("target/junk"), b"x").unwrap();
let store = Store::at(tmp().join("pkg"));
store.ensure_layout().unwrap();
let dst = store.install_dir("a", "0.1.0", &src).unwrap();
assert!(dst.join("a.plugin.zsh").is_file());
assert!(!dst.join(".git").exists());
assert!(!dst.join("target").exists());
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn index_round_trip() {
let store = Store::at(tmp().join("pkg"));
let mut idx = InstalledIndex::new();
idx.upsert(InstalledPlugin {
name: "zed".into(),
version: "1.0.0".into(),
source: "github:o/zed".into(),
kind: "script".into(),
source_files: vec!["zed.plugin.zsh".into()],
..Default::default()
});
idx.upsert(InstalledPlugin {
name: "abc".into(),
version: "0.1.0".into(),
source: "github:o/abc".into(),
kind: "native".into(),
lib: "libabc.dylib".into(),
..Default::default()
});
idx.save_to(&store).unwrap();
let back = InstalledIndex::load_from(&store).unwrap();
assert_eq!(back.packages.len(), 2);
assert_eq!(back.packages[0].name, "abc");
assert_eq!(back.find("zed").unwrap().kind, "script");
let _ = std::fs::remove_dir_all(store.root());
}
}