Skip to main content

zsh/extensions/pkg/
store.rs

1//! Global store + installed index. Ported from strykelang's `pkg/store.rs`,
2//! retargeted to `$ZSHRS_HOME/pkg/` and reduced to the global-only model.
3//!
4//! Layout (`$ZSHRS_HOME` defaults to `~/.zshrs`, matching
5//! `autoload_cache::zshrs_home`):
6//! ```text
7//! $ZSHRS_HOME/pkg/
8//!   store/  name@version/     # one extracted copy per (name, version)
9//!   cache/                    # download scratch
10//!   git/                      # bare/working git clones
11//!   bin/                      # launcher symlinks (future)
12//!   installed.toml            # the global install index (source of truth)
13//! ```
14//! Human-readable `name@version` paths give Nix-grade reproducibility from the
15//! index's content hashes without Nix-grade opaque path UX.
16
17use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19
20use super::{PkgError, PkgResult};
21
22/// Installed-index filename under the store root.
23pub const INSTALLED_FILE: &str = "installed.toml";
24
25/// Resolves and lazily creates the `$ZSHRS_HOME/pkg/...` layout.
26pub struct Store {
27    root: PathBuf,
28}
29
30impl Store {
31    /// Construct a [`Store`] rooted at `$ZSHRS_HOME/pkg/` (default
32    /// `~/.zshrs/pkg/`). Honors `ZSHRS_HOME` like the rest of zshrs.
33    pub fn user_default() -> PkgResult<Store> {
34        let home = if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
35            PathBuf::from(custom)
36        } else {
37            let h = std::env::var_os("HOME")
38                .ok_or_else(|| PkgError::Other("HOME environment variable not set".into()))?;
39            PathBuf::from(h).join(".zshrs")
40        };
41        Ok(Store {
42            root: home.join("pkg"),
43        })
44    }
45
46    /// Root at an explicit path (tests).
47    pub fn at(root: impl Into<PathBuf>) -> Store {
48        Store { root: root.into() }
49    }
50
51    /// `store/` — extracted packages.
52    pub fn store_dir(&self) -> PathBuf {
53        self.root.join("store")
54    }
55    /// `cache/` — download scratch.
56    pub fn cache_dir(&self) -> PathBuf {
57        self.root.join("cache")
58    }
59    /// `git/` — git clones.
60    pub fn git_dir(&self) -> PathBuf {
61        self.root.join("git")
62    }
63    /// `bin/` — launcher links.
64    pub fn bin_dir(&self) -> PathBuf {
65        self.root.join("bin")
66    }
67    /// `$ZSHRS_HOME/pkg/` root.
68    pub fn root(&self) -> &Path {
69        &self.root
70    }
71
72    /// Where a package extraction lives: `store/{name}@{version}/`.
73    pub fn package_dir(&self, name: &str, version: &str) -> PathBuf {
74        self.store_dir().join(format!("{}@{}", name, version))
75    }
76
77    /// Create the full directory layout. Idempotent.
78    pub fn ensure_layout(&self) -> PkgResult<()> {
79        for d in [
80            self.store_dir(),
81            self.cache_dir(),
82            self.git_dir(),
83            self.bin_dir(),
84        ] {
85            std::fs::create_dir_all(&d)
86                .map_err(|e| PkgError::Io(format!("create {}: {}", d.display(), e)))?;
87        }
88        Ok(())
89    }
90
91    /// True if a `name@version` extraction already exists.
92    pub fn has_package(&self, name: &str, version: &str) -> bool {
93        self.package_dir(name, version).is_dir()
94    }
95
96    /// Copy a staged plugin tree wholesale into `store/{name}@{version}/`,
97    /// excluding VCS/build scratch (`.git/`, `target/`) so the store holds only
98    /// the loadable plugin. The destination is cleared first for fresh
99    /// re-installs. Returns the store path.
100    pub fn install_dir(&self, name: &str, version: &str, src: &Path) -> PkgResult<PathBuf> {
101        let dst = self.package_dir(name, version);
102        if dst.exists() {
103            std::fs::remove_dir_all(&dst)
104                .map_err(|e| PkgError::Io(format!("clear {}: {}", dst.display(), e)))?;
105        }
106        std::fs::create_dir_all(&dst)?;
107        copy_dir_filtered(src, &dst)?;
108        Ok(dst)
109    }
110}
111
112/// The global install index at `$ZSHRS_HOME/pkg/installed.toml` — the single
113/// source of truth for what's installed. `znative load` reads it at shell startup
114/// to load every plugin with zero network.
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct InstalledIndex {
117    /// Schema version.
118    pub version: u32,
119    /// One entry per installed plugin, sorted by name for deterministic diffs.
120    #[serde(default, rename = "package")]
121    pub packages: Vec<InstalledPlugin>,
122}
123
124/// One installed plugin: identity + provenance + everything `znative load` needs
125/// to load it without re-resolving.
126#[derive(Debug, Clone, Serialize, Deserialize, Default)]
127pub struct InstalledPlugin {
128    /// Plugin name (store key `name@version`).
129    pub name: String,
130    /// Installed version (`0.0.0` when the plugin declared none).
131    pub version: String,
132    /// Provenance: `github:owner/repo`, `git+URL`, or `path+file://DIR`.
133    pub source: String,
134    /// `"native"` or `"script"`.
135    pub kind: String,
136    /// SHA-256 of the extracted tree, `sha256-<hex>` (audit / change detection).
137    #[serde(default, skip_serializing_if = "String::is_empty")]
138    pub integrity: String,
139    /// Native: the cdylib filename inside the store dir (e.g. `libfoo.dylib`).
140    #[serde(default, skip_serializing_if = "String::is_empty")]
141    pub lib: String,
142    /// Script: files to `source`, relative to the store dir.
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub source_files: Vec<String>,
145    /// Script: dirs to prepend to `$fpath`, relative to the store dir.
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    pub fpath: Vec<String>,
148}
149
150impl InstalledIndex {
151    /// Empty index stamped with the current schema version.
152    pub fn new() -> InstalledIndex {
153        InstalledIndex {
154            version: 1,
155            packages: Vec::new(),
156        }
157    }
158
159    /// Load the index from a [`Store`], or an empty index when it doesn't exist.
160    pub fn load_from(store: &Store) -> PkgResult<InstalledIndex> {
161        let path = store.root().join(INSTALLED_FILE);
162        if !path.is_file() {
163            return Ok(InstalledIndex::new());
164        }
165        let s = std::fs::read_to_string(&path)
166            .map_err(|e| PkgError::Io(format!("read {}: {}", path.display(), e)))?;
167        toml::from_str::<InstalledIndex>(&s)
168            .map_err(|e| PkgError::Other(format!("parse {}: {}", path.display(), e.message())))
169    }
170
171    /// Write the index (packages sorted by name) under `store.root()`.
172    pub fn save_to(&mut self, store: &Store) -> PkgResult<()> {
173        self.packages.sort_by(|a, b| a.name.cmp(&b.name));
174        let path = store.root().join(INSTALLED_FILE);
175        if let Some(parent) = path.parent() {
176            std::fs::create_dir_all(parent)?;
177        }
178        let body = toml::to_string_pretty(&self)
179            .map_err(|e| PkgError::Other(format!("serialize {}: {}", INSTALLED_FILE, e)))?;
180        std::fs::write(&path, format!("# znative — auto-generated. Do not edit.\n{}", body))
181            .map_err(|e| PkgError::Io(format!("write {}: {}", path.display(), e)))?;
182        Ok(())
183    }
184
185    /// Find an installed plugin by name.
186    pub fn find(&self, name: &str) -> Option<&InstalledPlugin> {
187        self.packages.iter().find(|p| p.name == name)
188    }
189
190    /// Insert or replace the entry for `p.name`.
191    pub fn upsert(&mut self, p: InstalledPlugin) {
192        if let Some(slot) = self.packages.iter_mut().find(|e| e.name == p.name) {
193            *slot = p;
194        } else {
195            self.packages.push(p);
196        }
197    }
198
199    /// Remove the entry named `name`; returns it if present.
200    pub fn remove(&mut self, name: &str) -> Option<InstalledPlugin> {
201        let idx = self.packages.iter().position(|p| p.name == name)?;
202        Some(self.packages.remove(idx))
203    }
204}
205
206/// Recursively copy `src` into `dst`, skipping `.git/` and `target/` (VCS +
207/// Rust build scratch) at any depth.
208fn copy_dir_filtered(src: &Path, dst: &Path) -> PkgResult<()> {
209    std::fs::create_dir_all(dst)?;
210    for entry in std::fs::read_dir(src)? {
211        let entry = entry?;
212        let name = entry.file_name();
213        let name_s = name.to_string_lossy();
214        if name_s == ".git" || name_s == "target" {
215            continue;
216        }
217        let from = entry.path();
218        let to = dst.join(&name);
219        let ft = entry.file_type()?;
220        if ft.is_dir() {
221            copy_dir_filtered(&from, &to)?;
222        } else if ft.is_symlink() {
223            // Copy the resolved file content (plugins rarely ship symlinks; a
224            // dangling link in the store would break loads).
225            if let Ok(target) = std::fs::read(&from) {
226                std::fs::write(&to, target)?;
227            }
228        } else {
229            std::fs::copy(&from, &to)
230                .map_err(|e| PkgError::Io(format!("copy {}: {}", from.display(), e)))?;
231        }
232    }
233    Ok(())
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn tmp() -> PathBuf {
241        let p = std::env::temp_dir().join(format!(
242            "znative-test-{}-{}",
243            std::process::id(),
244            std::time::SystemTime::now()
245                .duration_since(std::time::UNIX_EPOCH)
246                .unwrap()
247                .subsec_nanos()
248        ));
249        let _ = std::fs::remove_dir_all(&p);
250        std::fs::create_dir_all(&p).unwrap();
251        p
252    }
253
254    #[test]
255    fn install_dir_skips_git_and_target() {
256        let src = tmp();
257        std::fs::write(src.join("a.plugin.zsh"), b"echo hi").unwrap();
258        std::fs::create_dir_all(src.join(".git")).unwrap();
259        std::fs::write(src.join(".git/HEAD"), b"ref").unwrap();
260        std::fs::create_dir_all(src.join("target")).unwrap();
261        std::fs::write(src.join("target/junk"), b"x").unwrap();
262        let store = Store::at(tmp().join("pkg"));
263        store.ensure_layout().unwrap();
264        let dst = store.install_dir("a", "0.1.0", &src).unwrap();
265        assert!(dst.join("a.plugin.zsh").is_file());
266        assert!(!dst.join(".git").exists());
267        assert!(!dst.join("target").exists());
268        let _ = std::fs::remove_dir_all(&src);
269    }
270
271    #[test]
272    fn index_round_trip() {
273        let store = Store::at(tmp().join("pkg"));
274        let mut idx = InstalledIndex::new();
275        idx.upsert(InstalledPlugin {
276            name: "zed".into(),
277            version: "1.0.0".into(),
278            source: "github:o/zed".into(),
279            kind: "script".into(),
280            source_files: vec!["zed.plugin.zsh".into()],
281            ..Default::default()
282        });
283        idx.upsert(InstalledPlugin {
284            name: "abc".into(),
285            version: "0.1.0".into(),
286            source: "github:o/abc".into(),
287            kind: "native".into(),
288            lib: "libabc.dylib".into(),
289            ..Default::default()
290        });
291        idx.save_to(&store).unwrap();
292        let back = InstalledIndex::load_from(&store).unwrap();
293        assert_eq!(back.packages.len(), 2);
294        // Sorted by name: abc before zed.
295        assert_eq!(back.packages[0].name, "abc");
296        assert_eq!(back.find("zed").unwrap().kind, "script");
297        let _ = std::fs::remove_dir_all(store.root());
298    }
299}