yosh_plugin_manager/
lockfile.rs1use std::io::Write;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5use tempfile::NamedTempFile;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct LockFile {
9 #[serde(default)]
10 pub plugin: Vec<LockEntry>,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct LockEntry {
15 pub name: String,
16 pub path: String,
17 #[serde(default = "default_true")]
18 pub enabled: bool,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub capabilities: Option<Vec<String>>,
21 pub sha256: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub upstream_sha256: Option<String>,
31 pub source: String,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub version: Option<String>,
34}
35
36fn default_true() -> bool {
37 true
38}
39
40pub fn load_lockfile(path: &Path) -> Result<LockFile, String> {
41 if !path.exists() {
42 return Ok(LockFile { plugin: Vec::new() });
43 }
44 let content =
45 std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path.display(), e))?;
46 toml::from_str(&content).map_err(|e| format!("{}: {}", path.display(), e))
47}
48
49pub fn save_lockfile(path: &Path, lockfile: &LockFile) -> Result<(), String> {
50 let content =
51 toml::to_string_pretty(lockfile).map_err(|e| format!("serialize lock file: {}", e))?;
52 let parent = path
53 .parent()
54 .ok_or_else(|| format!("{}: no parent directory", path.display()))?;
55 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {}", parent.display(), e))?;
56 let mut tmp =
57 NamedTempFile::new_in(parent).map_err(|e| format!("{}: {}", path.display(), e))?;
58 tmp.write_all(content.as_bytes())
59 .map_err(|e| format!("{}: {}", path.display(), e))?;
60 tmp.persist(path)
61 .map_err(|e| format!("{}: {}", path.display(), e))?;
62 Ok(())
63}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68
69 fn sample_entry() -> LockEntry {
70 LockEntry {
71 name: "git-status".into(),
72 path: "~/.yosh/plugins/git-status/libgit_status.dylib".into(),
73 enabled: true,
74 capabilities: Some(vec!["variables:read".into(), "io".into()]),
75 sha256: "abc123".into(),
76 upstream_sha256: Some("upstream123".into()),
77 source: "github:user/repo".into(),
78 version: Some("1.2.3".into()),
79 }
80 }
81
82 #[test]
83 fn round_trip() {
84 let dir = tempfile::tempdir().unwrap();
85 let lock_path = dir.path().join("plugins.lock");
86 let original = LockFile {
87 plugin: vec![sample_entry()],
88 };
89 save_lockfile(&lock_path, &original).unwrap();
90 let loaded = load_lockfile(&lock_path).unwrap();
91 assert_eq!(original, loaded);
92 }
93
94 #[test]
95 fn load_nonexistent_returns_empty() {
96 let lf = load_lockfile(Path::new("/nonexistent/plugins.lock")).unwrap();
97 assert!(lf.plugin.is_empty());
98 }
99
100 #[test]
101 fn save_creates_parent_dirs() {
102 let dir = tempfile::tempdir().unwrap();
103 let lock_path = dir.path().join("sub/dir/plugins.lock");
104 let lf = LockFile {
105 plugin: vec![sample_entry()],
106 };
107 save_lockfile(&lock_path, &lf).unwrap();
108 assert!(lock_path.exists());
109 }
110
111 #[test]
112 fn local_entry_without_version() {
113 let entry = LockEntry {
114 name: "local-tool".into(),
115 path: "~/.yosh/plugins/liblocal.dylib".into(),
116 enabled: true,
117 capabilities: Some(vec!["io".into()]),
118 sha256: "def456".into(),
119 upstream_sha256: None,
120 source: "local:~/.yosh/plugins/liblocal.dylib".into(),
121 version: None,
122 };
123 let dir = tempfile::tempdir().unwrap();
124 let lock_path = dir.path().join("plugins.lock");
125 let original = LockFile {
126 plugin: vec![entry],
127 };
128 save_lockfile(&lock_path, &original).unwrap();
129 let loaded = load_lockfile(&lock_path).unwrap();
130 assert_eq!(original, loaded);
131 assert!(loaded.plugin[0].version.is_none());
132 }
133
134 #[test]
135 fn partial_write_only_successful_entries() {
136 let dir = tempfile::tempdir().unwrap();
137 let lock_path = dir.path().join("plugins.lock");
138 let lf = LockFile {
139 plugin: vec![sample_entry()],
140 };
141 save_lockfile(&lock_path, &lf).unwrap();
142 let loaded = load_lockfile(&lock_path).unwrap();
143 assert_eq!(loaded.plugin.len(), 1);
144 }
145
146 #[test]
147 fn legacy_lockfile_without_upstream_sha256_loads() {
148 let dir = tempfile::tempdir().unwrap();
149 let lock_path = dir.path().join("plugins.lock");
150 let legacy = "\
154[[plugin]]
155name = \"old\"
156path = \"~/.yosh/plugins/old/libold.dylib\"
157enabled = true
158sha256 = \"deadbeef\"
159source = \"github:u/r\"
160version = \"0.1.0\"
161";
162 std::fs::write(&lock_path, legacy).unwrap();
163 let loaded = load_lockfile(&lock_path).unwrap();
164 assert_eq!(loaded.plugin.len(), 1);
165 assert!(loaded.plugin[0].upstream_sha256.is_none());
166 }
167
168 #[test]
169 fn empty_lockfile() {
170 let dir = tempfile::tempdir().unwrap();
171 let lock_path = dir.path().join("plugins.lock");
172 let lf = LockFile { plugin: vec![] };
173 save_lockfile(&lock_path, &lf).unwrap();
174 let loaded = load_lockfile(&lock_path).unwrap();
175 assert!(loaded.plugin.is_empty());
176 }
177}