1use 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,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub upstream_sha256: Option<String>,
33 pub source: String,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub version: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub cwasm_path: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub wasmtime_version: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub target_triple: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub engine_config_hash: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub required_capabilities: Option<Vec<String>>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub implemented_hooks: Option<Vec<String>>,
65}
66
67fn default_true() -> bool {
68 true
69}
70
71pub fn load_lockfile(path: &Path) -> Result<LockFile, String> {
72 if !path.exists() {
73 return Ok(LockFile { plugin: Vec::new() });
74 }
75 let content =
76 std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path.display(), e))?;
77 toml::from_str(&content).map_err(|e| format!("{}: {}", path.display(), e))
78}
79
80pub fn save_lockfile(path: &Path, lockfile: &LockFile) -> Result<(), String> {
81 let content =
82 toml::to_string_pretty(lockfile).map_err(|e| format!("serialize lock file: {}", e))?;
83 let parent = path
84 .parent()
85 .ok_or_else(|| format!("{}: no parent directory", path.display()))?;
86 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {}", parent.display(), e))?;
87 let mut tmp =
88 NamedTempFile::new_in(parent).map_err(|e| format!("{}: {}", path.display(), e))?;
89 tmp.write_all(content.as_bytes())
90 .map_err(|e| format!("{}: {}", path.display(), e))?;
91 tmp.persist(path)
92 .map_err(|e| format!("{}: {}", path.display(), e))?;
93 Ok(())
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 fn sample_entry() -> LockEntry {
101 LockEntry {
102 name: "git-status".into(),
103 path: "~/.yosh/plugins/git-status/git_status.wasm".into(),
104 enabled: true,
105 capabilities: Some(vec!["variables:read".into(), "io".into()]),
106 sha256: "abc123".into(),
107 upstream_sha256: Some("upstream123".into()),
108 source: "github:user/repo".into(),
109 version: Some("1.2.3".into()),
110 cwasm_path: None,
111 wasmtime_version: None,
112 target_triple: None,
113 engine_config_hash: None,
114 required_capabilities: None,
115 implemented_hooks: None,
116 }
117 }
118
119 #[test]
120 fn round_trip() {
121 let dir = tempfile::tempdir().unwrap();
122 let lock_path = dir.path().join("plugins.lock");
123 let original = LockFile {
124 plugin: vec![sample_entry()],
125 };
126 save_lockfile(&lock_path, &original).unwrap();
127 let loaded = load_lockfile(&lock_path).unwrap();
128 assert_eq!(original, loaded);
129 }
130
131 #[test]
132 fn load_nonexistent_returns_empty() {
133 let lf = load_lockfile(Path::new("/nonexistent/plugins.lock")).unwrap();
134 assert!(lf.plugin.is_empty());
135 }
136
137 #[test]
138 fn save_creates_parent_dirs() {
139 let dir = tempfile::tempdir().unwrap();
140 let lock_path = dir.path().join("sub/dir/plugins.lock");
141 let lf = LockFile {
142 plugin: vec![sample_entry()],
143 };
144 save_lockfile(&lock_path, &lf).unwrap();
145 assert!(lock_path.exists());
146 }
147
148 #[test]
149 fn local_entry_without_version() {
150 let entry = LockEntry {
151 name: "local-tool".into(),
152 path: "~/.yosh/plugins/local.wasm".into(),
153 enabled: true,
154 capabilities: Some(vec!["io".into()]),
155 sha256: "def456".into(),
156 upstream_sha256: None,
157 source: "local:~/.yosh/plugins/local.wasm".into(),
158 version: None,
159 cwasm_path: None,
160 wasmtime_version: None,
161 target_triple: None,
162 engine_config_hash: None,
163 required_capabilities: None,
164 implemented_hooks: None,
165 };
166 let dir = tempfile::tempdir().unwrap();
167 let lock_path = dir.path().join("plugins.lock");
168 let original = LockFile {
169 plugin: vec![entry],
170 };
171 save_lockfile(&lock_path, &original).unwrap();
172 let loaded = load_lockfile(&lock_path).unwrap();
173 assert_eq!(original, loaded);
174 assert!(loaded.plugin[0].version.is_none());
175 }
176
177 #[test]
178 fn partial_write_only_successful_entries() {
179 let dir = tempfile::tempdir().unwrap();
180 let lock_path = dir.path().join("plugins.lock");
181 let lf = LockFile {
182 plugin: vec![sample_entry()],
183 };
184 save_lockfile(&lock_path, &lf).unwrap();
185 let loaded = load_lockfile(&lock_path).unwrap();
186 assert_eq!(loaded.plugin.len(), 1);
187 }
188
189 #[test]
190 fn legacy_lockfile_without_upstream_sha256_loads() {
191 let dir = tempfile::tempdir().unwrap();
192 let lock_path = dir.path().join("plugins.lock");
193 let legacy = "\
197[[plugin]]
198name = \"old\"
199path = \"~/.yosh/plugins/old/old.wasm\"
200enabled = true
201sha256 = \"deadbeef\"
202source = \"github:u/r\"
203version = \"0.1.0\"
204";
205 std::fs::write(&lock_path, legacy).unwrap();
206 let loaded = load_lockfile(&lock_path).unwrap();
207 assert_eq!(loaded.plugin.len(), 1);
208 assert!(loaded.plugin[0].upstream_sha256.is_none());
209 }
210
211 #[test]
212 fn empty_lockfile() {
213 let dir = tempfile::tempdir().unwrap();
214 let lock_path = dir.path().join("plugins.lock");
215 let lf = LockFile { plugin: vec![] };
216 save_lockfile(&lock_path, &lf).unwrap();
217 let loaded = load_lockfile(&lock_path).unwrap();
218 assert!(loaded.plugin.is_empty());
219 }
220}