1use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19
20use super::{PkgError, PkgResult};
21
22pub const INSTALLED_FILE: &str = "installed.toml";
24
25pub struct Store {
27 root: PathBuf,
28}
29
30impl Store {
31 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 pub fn at(root: impl Into<PathBuf>) -> Store {
48 Store { root: root.into() }
49 }
50
51 pub fn store_dir(&self) -> PathBuf {
53 self.root.join("store")
54 }
55 pub fn cache_dir(&self) -> PathBuf {
57 self.root.join("cache")
58 }
59 pub fn git_dir(&self) -> PathBuf {
61 self.root.join("git")
62 }
63 pub fn bin_dir(&self) -> PathBuf {
65 self.root.join("bin")
66 }
67 pub fn root(&self) -> &Path {
69 &self.root
70 }
71
72 pub fn package_dir(&self, name: &str, version: &str) -> PathBuf {
74 self.store_dir().join(format!("{}@{}", name, version))
75 }
76
77 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 pub fn has_package(&self, name: &str, version: &str) -> bool {
93 self.package_dir(name, version).is_dir()
94 }
95
96 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct InstalledIndex {
117 pub version: u32,
119 #[serde(default, rename = "package")]
121 pub packages: Vec<InstalledPlugin>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, Default)]
127pub struct InstalledPlugin {
128 pub name: String,
130 pub version: String,
132 pub source: String,
134 pub kind: String,
136 #[serde(default, skip_serializing_if = "String::is_empty")]
138 pub integrity: String,
139 #[serde(default, skip_serializing_if = "String::is_empty")]
141 pub lib: String,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub source_files: Vec<String>,
145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
147 pub fpath: Vec<String>,
148}
149
150impl InstalledIndex {
151 pub fn new() -> InstalledIndex {
153 InstalledIndex {
154 version: 1,
155 packages: Vec::new(),
156 }
157 }
158
159 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 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(
181 &path,
182 format!("# znative — auto-generated. Do not edit.\n{}", body),
183 )
184 .map_err(|e| PkgError::Io(format!("write {}: {}", path.display(), e)))?;
185 Ok(())
186 }
187
188 pub fn find(&self, name: &str) -> Option<&InstalledPlugin> {
190 self.packages.iter().find(|p| p.name == name)
191 }
192
193 pub fn upsert(&mut self, p: InstalledPlugin) {
195 if let Some(slot) = self.packages.iter_mut().find(|e| e.name == p.name) {
196 *slot = p;
197 } else {
198 self.packages.push(p);
199 }
200 }
201
202 pub fn remove(&mut self, name: &str) -> Option<InstalledPlugin> {
204 let idx = self.packages.iter().position(|p| p.name == name)?;
205 Some(self.packages.remove(idx))
206 }
207}
208
209fn copy_dir_filtered(src: &Path, dst: &Path) -> PkgResult<()> {
212 std::fs::create_dir_all(dst)?;
213 for entry in std::fs::read_dir(src)? {
214 let entry = entry?;
215 let name = entry.file_name();
216 let name_s = name.to_string_lossy();
217 if name_s == ".git" || name_s == "target" {
218 continue;
219 }
220 let from = entry.path();
221 let to = dst.join(&name);
222 let ft = entry.file_type()?;
223 if ft.is_dir() {
224 copy_dir_filtered(&from, &to)?;
225 } else if ft.is_symlink() {
226 if let Ok(target) = std::fs::read(&from) {
229 std::fs::write(&to, target)?;
230 }
231 } else {
232 std::fs::copy(&from, &to)
233 .map_err(|e| PkgError::Io(format!("copy {}: {}", from.display(), e)))?;
234 }
235 }
236 Ok(())
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 fn tmp() -> PathBuf {
244 let p = std::env::temp_dir().join(format!(
245 "znative-test-{}-{}",
246 std::process::id(),
247 std::time::SystemTime::now()
248 .duration_since(std::time::UNIX_EPOCH)
249 .unwrap()
250 .subsec_nanos()
251 ));
252 let _ = std::fs::remove_dir_all(&p);
253 std::fs::create_dir_all(&p).unwrap();
254 p
255 }
256
257 #[test]
258 fn install_dir_skips_git_and_target() {
259 let src = tmp();
260 std::fs::write(src.join("a.plugin.zsh"), b"echo hi").unwrap();
261 std::fs::create_dir_all(src.join(".git")).unwrap();
262 std::fs::write(src.join(".git/HEAD"), b"ref").unwrap();
263 std::fs::create_dir_all(src.join("target")).unwrap();
264 std::fs::write(src.join("target/junk"), b"x").unwrap();
265 let store = Store::at(tmp().join("pkg"));
266 store.ensure_layout().unwrap();
267 let dst = store.install_dir("a", "0.1.0", &src).unwrap();
268 assert!(dst.join("a.plugin.zsh").is_file());
269 assert!(!dst.join(".git").exists());
270 assert!(!dst.join("target").exists());
271 let _ = std::fs::remove_dir_all(&src);
272 }
273
274 #[test]
275 fn index_round_trip() {
276 let store = Store::at(tmp().join("pkg"));
277 let mut idx = InstalledIndex::new();
278 idx.upsert(InstalledPlugin {
279 name: "zed".into(),
280 version: "1.0.0".into(),
281 source: "github:o/zed".into(),
282 kind: "script".into(),
283 source_files: vec!["zed.plugin.zsh".into()],
284 ..Default::default()
285 });
286 idx.upsert(InstalledPlugin {
287 name: "abc".into(),
288 version: "0.1.0".into(),
289 source: "github:o/abc".into(),
290 kind: "native".into(),
291 lib: "libabc.dylib".into(),
292 ..Default::default()
293 });
294 idx.save_to(&store).unwrap();
295 let back = InstalledIndex::load_from(&store).unwrap();
296 assert_eq!(back.packages.len(), 2);
297 assert_eq!(back.packages[0].name, "abc");
299 assert_eq!(back.find("zed").unwrap().kind, "script");
300 let _ = std::fs::remove_dir_all(store.root());
301 }
302}