Skip to main content

stryke/pkg/
store.rs

1//! Global store layout: `~/.stryke/{store,cache,git,bin,index}/`.
2//!
3//! Paths are human-readable (`name@version`) per RFC §"Global Store" — we get
4//! Nix-grade reproducibility from the lockfile's content hashes without
5//! Nix-grade opaque path UX.
6//!
7//! Also hosts the global pin file [`InstalledIndex`] at `~/.stryke/installed.toml`,
8//! which records every `s pkg install -g` so that one-off scripts run outside
9//! a project can still resolve `use Foo` to a store entry. The project lockfile
10//! takes precedence for in-project resolution; the installed index is only
11//! consulted when there's no project lockfile entry for the requested package.
12
13use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15
16use super::manifest::Manifest;
17use super::{PkgError, PkgResult};
18
19/// Manifest filename — duplicated from `commands::MANIFEST_FILE` so `store`
20/// stays free of `commands` imports (which would be a cycle).
21const MANIFEST_FILE: &str = "stryke.toml";
22
23/// Subtrees copied wholesale from a path-dep into the store. Mirrors the
24/// canonical release-tarball layout: `lib/` (stk modules + cdylib in
25/// production layout), `bin/` (script launchers).
26const PACKAGE_SUBTREES: &[&str] = &["lib", "bin"];
27
28/// Filename of the global installed-package index.
29pub const INSTALLED_FILE: &str = "installed.toml";
30
31/// Resolves and (lazily) creates the standard `~/.stryke/...` layout.
32pub struct Store {
33    /// `root` field.
34    root: PathBuf,
35}
36
37impl Store {
38    /// Construct a [`Store`] rooted at `~/.stryke/`. Honors the `STRYKE_HOME`
39    /// environment variable for tests and CI sandboxes.
40    pub fn user_default() -> PkgResult<Store> {
41        if let Ok(custom) = std::env::var("STRYKE_HOME") {
42            return Ok(Store {
43                root: PathBuf::from(custom),
44            });
45        }
46        let home = std::env::var("HOME")
47            .map_err(|_| PkgError::Other("HOME environment variable not set".into()))?;
48        Ok(Store {
49            root: PathBuf::from(home).join(".stryke"),
50        })
51    }
52
53    /// Construct a [`Store`] rooted at an explicit path (used by tests).
54    pub fn at(root: impl Into<PathBuf>) -> Store {
55        Store { root: root.into() }
56    }
57    /// `root` — see implementation.
58    pub fn root(&self) -> &Path {
59        &self.root
60    }
61    /// `store_dir` — see implementation.
62    pub fn store_dir(&self) -> PathBuf {
63        self.root.join("store")
64    }
65    /// `cache_dir` — see implementation.
66    pub fn cache_dir(&self) -> PathBuf {
67        self.root.join("cache")
68    }
69    /// `git_dir` — see implementation.
70    pub fn git_dir(&self) -> PathBuf {
71        self.root.join("git")
72    }
73    /// `bin_dir` — see implementation.
74    pub fn bin_dir(&self) -> PathBuf {
75        self.root.join("bin")
76    }
77    /// `index_dir` — see implementation.
78    pub fn index_dir(&self) -> PathBuf {
79        self.root.join("index")
80    }
81
82    /// Path where a package extraction lives: `~/.stryke/store/{name}@{version}/`.
83    pub fn package_dir(&self, name: &str, version: &str) -> PathBuf {
84        self.store_dir().join(format!("{}@{}", name, version))
85    }
86
87    /// Ensure the full directory layout exists. Idempotent. Called eagerly by
88    /// `s install`; tests exercise it directly.
89    pub fn ensure_layout(&self) -> PkgResult<()> {
90        for d in [
91            self.store_dir(),
92            self.cache_dir(),
93            self.git_dir(),
94            self.bin_dir(),
95            self.index_dir(),
96        ] {
97            std::fs::create_dir_all(&d)
98                .map_err(|e| PkgError::Io(format!("create {}: {}", d.display(), e)))?;
99        }
100        Ok(())
101    }
102
103    /// True if a `name@version` extraction already exists in the store.
104    pub fn has_package(&self, name: &str, version: &str) -> bool {
105        self.package_dir(name, version).is_dir()
106    }
107
108    /// Copy a path-dep into the store as `name@version` using the canonical
109    /// release-tarball layout, so `s install -g .` produces a byte-for-byte
110    /// equivalent on-disk shape to `s install -g github.com/<owner>/<repo>`.
111    /// Exactly three things land in the store and nothing else (no
112    /// `target/`, `.git/`, `vendor/`, `tests/`, root-level `README*` /
113    /// `LICENSE*` / `CHANGELOG*`, editor scratch, or out-of-tree `[bin]`
114    /// paths — the GitHub release tarball doesn't ship those either):
115    ///
116    /// * `stryke.toml` (required)
117    /// * `lib/` subtree (recursive, if present)
118    /// * `bin/` subtree (recursive, if present)
119    ///
120    /// Packages with `[bin]` entries pointing outside `bin/` are rejected by
121    /// the release-tarball staging step (every shipped binary lives under
122    /// `bin/<name>.stk`); the path install enforces the same rule by simply
123    /// not copying anything outside the canonical subtrees, so an
124    /// out-of-tree `[bin]` path becomes a no-op at install time rather than
125    /// a store-layout divergence.
126    ///
127    /// Existing destination is removed first so re-installs see fresh content.
128    pub fn install_path_dep(
129        &self,
130        name: &str,
131        version: &str,
132        src: &Path,
133        _manifest: &Manifest,
134    ) -> PkgResult<PathBuf> {
135        let dst = self.package_dir(name, version);
136        if dst.exists() {
137            std::fs::remove_dir_all(&dst)
138                .map_err(|e| PkgError::Io(format!("clear {}: {}", dst.display(), e)))?;
139        }
140        std::fs::create_dir_all(&dst)?;
141
142        let toml_src = src.join(MANIFEST_FILE);
143        let toml_dst = dst.join(MANIFEST_FILE);
144        std::fs::copy(&toml_src, &toml_dst)
145            .map_err(|e| PkgError::Io(format!("copy {}: {}", toml_src.display(), e)))?;
146
147        for sub in PACKAGE_SUBTREES {
148            let from = src.join(sub);
149            if from.is_dir() {
150                let to = dst.join(sub);
151                std::fs::create_dir_all(&to)?;
152                copy_dir(&from, &to)?;
153            }
154        }
155
156        Ok(dst)
157    }
158}
159
160/// Global pin for every `s pkg install -g`-installed package.
161///
162/// Lives at `~/.stryke/installed.toml`. Unlike per-project lockfiles, this
163/// has no SHA-256 integrity hashes and no transitive-dep records — it's a
164/// flat name→version map that lets one-off scripts run outside any project
165/// still resolve `use Foo` to a global store entry.
166///
167/// Conflict resolution in [`crate::pkg::commands::resolve_module`]:
168/// project lockfile (#2) wins over this global pin (#3). If a project pins
169/// `gui` at `0.1.0` and the global index has `gui@0.2.0`, the project gets
170/// `0.1.0`; standalone scripts get `0.2.0`.
171#[derive(Debug, Clone, Serialize, Deserialize, Default)]
172pub struct InstalledIndex {
173    /// Schema version. Bumped when the layout changes incompatibly.
174    pub version: u32,
175    /// One entry per installed package, sorted by name.
176    #[serde(default, rename = "package")]
177    pub packages: Vec<InstalledPackage>,
178}
179
180/// One entry in the installed-package index.
181#[derive(Debug, Clone, Serialize, Deserialize, Default)]
182pub struct InstalledPackage {
183    /// Package name (matches `[package].name` in the installed manifest).
184    pub name: String,
185    /// Version that was installed (matches `[package].version`).
186    pub version: String,
187    /// Where the install came from — `github:owner/repo`, `path+file://...`,
188    /// etc. Recorded for `s list -g` display + future upgrade paths.
189    pub source: String,
190    /// `[ffi].namespace` from the installed manifest, lowercased. Empty when
191    /// the package has no `[ffi]` section. Bridges `use GUI` (lookup key
192    /// `"gui"`) to a store entry whose package name is unrelated (e.g.
193    /// `stryke-gui`). Resolver tries name match first, then namespace match.
194    #[serde(default, skip_serializing_if = "String::is_empty")]
195    pub namespace: String,
196}
197
198impl InstalledIndex {
199    /// Empty index stamped with the current schema version.
200    pub fn new() -> InstalledIndex {
201        InstalledIndex {
202            version: 1,
203            packages: Vec::new(),
204        }
205    }
206
207    /// Convenience: load via [`Store::user_default`] (honors `STRYKE_HOME`).
208    /// Production code paths use this; tests prefer [`InstalledIndex::load_from`]
209    /// with an explicit store root so parallel test execution doesn't race on
210    /// the process-global env var.
211    pub fn load_or_default() -> PkgResult<InstalledIndex> {
212        let store = Store::user_default()?;
213        Self::load_from(&store)
214    }
215
216    /// Load the index from a specific [`Store`] root. Returns an empty
217    /// index when the file doesn't exist yet — the index materializes on
218    /// the first `s pkg install -g`.
219    pub fn load_from(store: &Store) -> PkgResult<InstalledIndex> {
220        let path = store.root().join(INSTALLED_FILE);
221        if !path.is_file() {
222            return Ok(InstalledIndex::new());
223        }
224        let s = std::fs::read_to_string(&path)
225            .map_err(|e| PkgError::Io(format!("read {}: {}", path.display(), e)))?;
226        toml::from_str::<InstalledIndex>(&s)
227            .map_err(|e| PkgError::Other(format!("parse {}: {}", path.display(), e.message())))
228    }
229
230    /// Convenience: save via [`Store::user_default`] (honors `STRYKE_HOME`).
231    pub fn save(&self) -> PkgResult<()> {
232        let store = Store::user_default()?;
233        self.save_to(&store)
234    }
235
236    /// Save the index under a specific [`Store`] root. Packages are sorted
237    /// by name first so the file is deterministic across runs and friendly
238    /// to `diff`.
239    pub fn save_to(&self, store: &Store) -> PkgResult<()> {
240        let path = store.root().join(INSTALLED_FILE);
241        if let Some(parent) = path.parent() {
242            std::fs::create_dir_all(parent)
243                .map_err(|e| PkgError::Io(format!("create {}: {}", parent.display(), e)))?;
244        }
245        let mut copy = self.clone();
246        copy.packages.sort_by(|a, b| a.name.cmp(&b.name));
247        let mut header =
248            String::from("# Auto-generated. Updated by `s pkg install -g` / `s uninstall -g`.\n");
249        header.push_str("# Hand-edit this only if you understand the impact on `use X` resolution.\n\n");
250        let body = toml::to_string_pretty(&copy)
251            .map_err(|e| PkgError::Other(format!("serialize {}: {}", path.display(), e)))?;
252        std::fs::write(&path, format!("{}{}", header, body))
253            .map_err(|e| PkgError::Io(format!("write {}: {}", path.display(), e)))?;
254        Ok(())
255    }
256
257    /// Find an installed package by name (case-sensitive). Use the package's
258    /// canonical name from `[package].name`, not a logical `use Foo`-style
259    /// segment — the lookup is verbatim.
260    pub fn find(&self, name: &str) -> Option<&InstalledPackage> {
261        self.packages.iter().find(|p| p.name == name)
262    }
263
264    /// Find an installed package by `[ffi].namespace` (case-insensitive). Used
265    /// by the resolver when `use Foo` doesn't match any `[package].name` —
266    /// bridges `use GUI` → store entry `stryke-gui@*` where the package name
267    /// (matches the repo / dir) is unrelated to the `use` namespace. The
268    /// stored `namespace` keeps the manifest's exact casing (e.g. `"GUI"`);
269    /// matching is case-insensitive so `use GUI` and `use gui` both land.
270    pub fn find_by_namespace(&self, namespace: &str) -> Option<&InstalledPackage> {
271        self.packages
272            .iter()
273            .find(|p| !p.namespace.is_empty() && p.namespace.eq_ignore_ascii_case(namespace))
274    }
275
276    /// Insert or overwrite the entry for `name`. Multiple installs of the
277    /// same package (e.g. `s pkg install -g <url>` after a previous install)
278    /// collapse to one entry — the latest install always wins.
279    pub fn upsert(
280        &mut self,
281        name: impl Into<String>,
282        version: impl Into<String>,
283        source: impl Into<String>,
284    ) {
285        self.upsert_with_namespace(name, version, source, "");
286    }
287
288    /// `upsert` plus an `[ffi].namespace` value to record on the entry. Used
289    /// by the install path so the resolver can later route `use GUI` to a
290    /// store entry whose package name (matching the repo/dir) differs from
291    /// the namespace.
292    pub fn upsert_with_namespace(
293        &mut self,
294        name: impl Into<String>,
295        version: impl Into<String>,
296        source: impl Into<String>,
297        namespace: impl Into<String>,
298    ) {
299        let name = name.into();
300        let version = version.into();
301        let source = source.into();
302        let namespace = namespace.into();
303        if let Some(slot) = self.packages.iter_mut().find(|p| p.name == name) {
304            slot.version = version;
305            slot.source = source;
306            slot.namespace = namespace;
307        } else {
308            self.packages.push(InstalledPackage {
309                name,
310                version,
311                source,
312                namespace,
313            });
314        }
315    }
316
317    /// Remove the entry for `name`. Returns `true` if a matching entry was
318    /// removed, `false` if the package wasn't in the index.
319    pub fn remove(&mut self, name: &str) -> bool {
320        let before = self.packages.len();
321        self.packages.retain(|p| p.name != name);
322        self.packages.len() != before
323    }
324}
325
326/// Recursive directory copy. Symlinks are copied as symlinks; files preserve
327/// permissions when the OS supports it.
328fn copy_dir(src: &Path, dst: &Path) -> PkgResult<()> {
329    for entry in std::fs::read_dir(src)? {
330        let entry = entry?;
331        let from = entry.path();
332        let name = entry.file_name();
333        let to = dst.join(&name);
334        let meta = entry.metadata()?;
335        if meta.file_type().is_symlink() {
336            #[cfg(unix)]
337            {
338                let target = std::fs::read_link(&from)?;
339                std::os::unix::fs::symlink(target, &to)
340                    .map_err(|e| PkgError::Io(format!("symlink {}: {}", to.display(), e)))?;
341            }
342            #[cfg(not(unix))]
343            std::fs::copy(&from, &to)
344                .map_err(|e| PkgError::Io(format!("copy {}: {}", from.display(), e)))?;
345        } else if meta.is_dir() {
346            std::fs::create_dir_all(&to)?;
347            copy_dir(&from, &to)?;
348        } else {
349            std::fs::copy(&from, &to)
350                .map_err(|e| PkgError::Io(format!("copy {}: {}", from.display(), e)))?;
351        }
352    }
353    Ok(())
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn tempdir() -> PathBuf {
361        let pid = std::process::id();
362        let nanos = std::time::SystemTime::now()
363            .duration_since(std::time::UNIX_EPOCH)
364            .unwrap()
365            .subsec_nanos();
366        let p = std::env::temp_dir().join(format!("stryke-store-test-{}-{}", pid, nanos));
367        let _ = std::fs::remove_dir_all(&p);
368        std::fs::create_dir_all(&p).unwrap();
369        p
370    }
371
372    #[test]
373    fn ensure_layout_creates_subdirs() {
374        let root = tempdir();
375        let s = Store::at(&root);
376        s.ensure_layout().unwrap();
377        assert!(s.store_dir().is_dir());
378        assert!(s.cache_dir().is_dir());
379        assert!(s.git_dir().is_dir());
380        assert!(s.bin_dir().is_dir());
381        assert!(s.index_dir().is_dir());
382    }
383
384    #[test]
385    fn package_dir_path_shape() {
386        let s = Store::at("/x");
387        assert_eq!(
388            s.package_dir("http", "1.0.0"),
389            PathBuf::from("/x/store/http@1.0.0")
390        );
391    }
392
393    #[test]
394    fn installed_index_round_trip() {
395        // Use an explicit Store::at() rather than STRYKE_HOME so parallel
396        // test execution doesn't race on the process-global env var. The
397        // load_from/save_to API mirrors Store::at vs Store::user_default.
398        let root = tempdir();
399        let store = Store::at(&root);
400        store.ensure_layout().unwrap();
401        let mut idx = InstalledIndex::new();
402        idx.upsert("gui", "0.2.0", "github:MenkeTechnologies/stryke-gui");
403        idx.upsert("aws", "0.1.0", "github:MenkeTechnologies/stryke-aws");
404        idx.save_to(&store).unwrap();
405
406        let reloaded = InstalledIndex::load_from(&store).unwrap();
407        assert_eq!(reloaded.version, 1);
408        assert_eq!(reloaded.packages.len(), 2);
409        // Sorted by name on save.
410        assert_eq!(reloaded.packages[0].name, "aws");
411        assert_eq!(reloaded.packages[1].name, "gui");
412        assert_eq!(
413            reloaded.find("gui").unwrap().source,
414            "github:MenkeTechnologies/stryke-gui"
415        );
416    }
417
418    #[test]
419    fn installed_index_upsert_overwrites() {
420        let mut idx = InstalledIndex::new();
421        idx.upsert("gui", "0.1.0", "github:a/b");
422        idx.upsert("gui", "0.2.0", "github:a/b");
423        assert_eq!(idx.packages.len(), 1);
424        assert_eq!(idx.packages[0].version, "0.2.0");
425    }
426
427    #[test]
428    fn installed_index_remove_returns_true_when_present() {
429        let mut idx = InstalledIndex::new();
430        idx.upsert("gui", "0.1.0", "github:a/b");
431        assert!(idx.remove("gui"));
432        assert!(!idx.remove("gui"));
433        assert!(idx.packages.is_empty());
434    }
435
436    #[test]
437    fn installed_index_load_from_returns_empty_when_missing() {
438        let root = tempdir();
439        let store = Store::at(&root);
440        store.ensure_layout().unwrap();
441        let idx = InstalledIndex::load_from(&store).unwrap();
442        assert!(idx.packages.is_empty());
443    }
444
445    #[test]
446    fn install_path_dep_round_trip() {
447        let store_root = tempdir();
448        let src = tempdir();
449        std::fs::create_dir_all(src.join("lib")).unwrap();
450        std::fs::write(src.join("lib/Foo.stk"), b"sub foo { 1 }").unwrap();
451        std::fs::write(
452            src.join("stryke.toml"),
453            "[package]\nname = \"foo\"\nversion = \"0.1.0\"\n",
454        )
455        .unwrap();
456        let s = Store::at(&store_root);
457        s.ensure_layout().unwrap();
458        let manifest = Manifest::default();
459        let dst = s
460            .install_path_dep("foo", "0.1.0", &src, &manifest)
461            .unwrap();
462        assert!(dst.is_dir());
463        assert!(dst.join("lib/Foo.stk").is_file());
464        assert!(dst.join("stryke.toml").is_file());
465    }
466
467    /// `s install -g .` from a working repo must NOT drag `target/`, `.git/`,
468    /// `vendor/`, or test fixtures into the store — the GH install only ships
469    /// the curated tarball, and the local path now matches that file set.
470    #[test]
471    fn install_path_dep_excludes_build_artifacts() {
472        let store_root = tempdir();
473        let src = tempdir();
474        std::fs::write(
475            src.join("stryke.toml"),
476            "[package]\nname = \"stryke-arrow\"\nversion = \"0.1.0\"\n",
477        )
478        .unwrap();
479        std::fs::create_dir_all(src.join("lib")).unwrap();
480        std::fs::write(src.join("lib/Arrow.stk"), b"sub arrow { 1 }").unwrap();
481        std::fs::write(src.join("README.md"), b"# stryke-arrow\n").unwrap();
482        std::fs::write(src.join("LICENSE"), b"MIT\n").unwrap();
483
484        // Cruft that must stay out of the store.
485        std::fs::create_dir_all(src.join("target/release")).unwrap();
486        std::fs::write(src.join("target/release/garbage.rlib"), b"junk").unwrap();
487        std::fs::create_dir_all(src.join(".git")).unwrap();
488        std::fs::write(src.join(".git/HEAD"), b"ref: refs/heads/main\n").unwrap();
489        std::fs::create_dir_all(src.join("vendor/foo")).unwrap();
490        std::fs::write(src.join("vendor/foo/big.bin"), b"vendor").unwrap();
491        std::fs::create_dir_all(src.join("tests")).unwrap();
492        std::fs::write(src.join("tests/fixture.stk"), b"# fixture").unwrap();
493        std::fs::write(src.join(".DS_Store"), b"junk").unwrap();
494        std::fs::write(src.join("Cargo.toml"), b"[package]\nname=\"x\"\n").unwrap();
495
496        let s = Store::at(&store_root);
497        s.ensure_layout().unwrap();
498        let manifest = Manifest::default();
499        let dst = s
500            .install_path_dep("stryke-arrow", "0.1.0", &src, &manifest)
501            .unwrap();
502
503        // Store dir name is the package's exact authored name.
504        assert!(dst.ends_with("store/stryke-arrow@0.1.0"));
505
506        // Canonical layout present.
507        assert!(dst.join("stryke.toml").is_file());
508        assert!(dst.join("lib/Arrow.stk").is_file());
509
510        // Cruft absent. README / LICENSE / CHANGELOG are part of "cruft"
511        // because the GitHub release tarball doesn't ship them either —
512        // `s install -g .` must produce a byte-for-byte equivalent store
513        // layout to `s install -g github.com/...`.
514        assert!(!dst.join("README.md").exists(), "README.md leaked");
515        assert!(!dst.join("LICENSE").exists(), "LICENSE leaked");
516        assert!(!dst.join("target").exists(), "target/ leaked into store");
517        assert!(!dst.join(".git").exists(), ".git/ leaked into store");
518        assert!(!dst.join("vendor").exists(), "vendor/ leaked into store");
519        assert!(!dst.join("tests").exists(), "tests/ leaked into store");
520        assert!(!dst.join(".DS_Store").exists(), ".DS_Store leaked");
521        assert!(!dst.join("Cargo.toml").exists(), "Cargo.toml leaked");
522    }
523}