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(&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 pub fn find(&self, name: &str) -> Option<&InstalledPlugin> {
187 self.packages.iter().find(|p| p.name == name)
188 }
189
190 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 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
206fn 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 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 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}