1use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15
16use super::manifest::Manifest;
17use super::{PkgError, PkgResult};
18
19const MANIFEST_FILE: &str = "stryke.toml";
22
23const PACKAGE_SUBTREES: &[&str] = &["lib", "bin"];
27
28pub const INSTALLED_FILE: &str = "installed.toml";
30
31pub struct Store {
33 root: PathBuf,
35}
36
37impl Store {
38 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 pub fn at(root: impl Into<PathBuf>) -> Store {
55 Store { root: root.into() }
56 }
57 pub fn root(&self) -> &Path {
59 &self.root
60 }
61 pub fn store_dir(&self) -> PathBuf {
63 self.root.join("store")
64 }
65 pub fn cache_dir(&self) -> PathBuf {
67 self.root.join("cache")
68 }
69 pub fn git_dir(&self) -> PathBuf {
71 self.root.join("git")
72 }
73 pub fn bin_dir(&self) -> PathBuf {
75 self.root.join("bin")
76 }
77 pub fn index_dir(&self) -> PathBuf {
79 self.root.join("index")
80 }
81
82 pub fn package_dir(&self, name: &str, version: &str) -> PathBuf {
84 self.store_dir().join(format!("{}@{}", name, version))
85 }
86
87 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 pub fn has_package(&self, name: &str, version: &str) -> bool {
105 self.package_dir(name, version).is_dir()
106 }
107
108 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
172pub struct InstalledIndex {
173 pub version: u32,
175 #[serde(default, rename = "package")]
177 pub packages: Vec<InstalledPackage>,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
182pub struct InstalledPackage {
183 pub name: String,
185 pub version: String,
187 pub source: String,
190 #[serde(default, skip_serializing_if = "String::is_empty")]
195 pub namespace: String,
196}
197
198impl InstalledIndex {
199 pub fn new() -> InstalledIndex {
201 InstalledIndex {
202 version: 1,
203 packages: Vec::new(),
204 }
205 }
206
207 pub fn load_or_default() -> PkgResult<InstalledIndex> {
212 let store = Store::user_default()?;
213 Self::load_from(&store)
214 }
215
216 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 pub fn save(&self) -> PkgResult<()> {
232 let store = Store::user_default()?;
233 self.save_to(&store)
234 }
235
236 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(©)
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 pub fn find(&self, name: &str) -> Option<&InstalledPackage> {
261 self.packages.iter().find(|p| p.name == name)
262 }
263
264 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 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 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 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
326fn 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 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 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 #[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 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 assert!(dst.ends_with("store/stryke-arrow@0.1.0"));
505
506 assert!(dst.join("stryke.toml").is_file());
508 assert!(dst.join("lib/Arrow.stk").is_file());
509
510 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}