1use std::path::PathBuf;
2
3use crate::config::{self, PluginDecl, PluginSource};
4use crate::github::GitHubClient;
5use crate::lockfile::{LockEntry, LockFile, load_lockfile, save_lockfile};
6use crate::resolve::asset_filename;
7use crate::verify::{sha256_file, verify_checksum};
8
9fn plugin_dir() -> PathBuf {
10 if let Ok(home) = std::env::var("HOME") {
11 PathBuf::from(home).join(".yosh/plugins")
12 } else {
13 PathBuf::from("/tmp/yosh/plugins")
14 }
15}
16
17fn config_dir() -> PathBuf {
18 if let Ok(home) = std::env::var("HOME") {
19 PathBuf::from(home).join(".config/yosh")
20 } else {
21 PathBuf::from("/tmp/yosh")
22 }
23}
24
25pub fn config_path() -> PathBuf {
26 config_dir().join("plugins.toml")
27}
28
29pub fn lock_path() -> PathBuf {
30 config_dir().join("plugins.lock")
31}
32
33pub struct SyncResult {
34 pub succeeded: Vec<String>,
35 pub failed: Vec<(String, String)>, }
37
38pub fn sync(prune: bool) -> Result<SyncResult, String> {
40 let config_path = config_path();
41 let lock_path = lock_path();
42
43 let decls = config::load_config(&config_path)?;
44
45 let existing_lock = match load_lockfile(&lock_path) {
46 Ok(l) => l,
47 Err(e) => {
48 eprintln!("yosh-plugin: warning: {}", e);
49 LockFile { plugin: Vec::new() }
50 }
51 };
52
53 let client = GitHubClient::new();
54 let mut new_entries: Vec<LockEntry> = Vec::new();
55 let mut succeeded: Vec<String> = Vec::new();
56 let mut failed: Vec<(String, String)> = Vec::new();
57
58 for decl in &decls {
59 match sync_one(&client, decl, &existing_lock) {
60 Ok(entry) => {
61 succeeded.push(decl.name.clone());
62 new_entries.push(entry);
63 }
64 Err(e) => {
65 eprintln!("yosh-plugin: {}: {}", decl.name, e);
66 failed.push((decl.name.clone(), e));
67 }
68 }
69 }
70
71 if prune {
73 for old in &existing_lock.plugin {
74 if !decls.iter().any(|d| d.name == old.name) {
75 let path = config::expand_tilde_path(&old.path);
76 if path.exists() {
77 if let Err(e) = std::fs::remove_file(&path) {
78 eprintln!("yosh-plugin: prune {}: {}", old.name, e);
79 } else {
80 eprintln!("yosh-plugin: pruned {}", old.name);
81 }
82 }
83 }
84 }
85 }
86
87 let new_lock = LockFile {
88 plugin: new_entries,
89 };
90 save_lockfile(&lock_path, &new_lock)?;
91
92 Ok(SyncResult { succeeded, failed })
93}
94
95fn sync_one(
96 client: &GitHubClient,
97 decl: &PluginDecl,
98 existing_lock: &LockFile,
99) -> Result<LockEntry, String> {
100 let existing = existing_lock.plugin.iter().find(|e| e.name == decl.name);
101
102 match &decl.source {
103 PluginSource::GitHub { owner, repo } => {
104 let version = decl.version.as_deref().unwrap(); let asset_name = asset_filename(&decl.name, decl.asset.as_deref());
106 let dest_dir = plugin_dir().join(&decl.name);
107 let dest_path = dest_dir.join(&asset_name);
108
109 if let Some(existing) = existing {
111 if existing.version.as_deref() == Some(version) && dest_path.exists() {
112 match verify_checksum(&dest_path, &existing.sha256) {
113 Ok(true) => {
114 return Ok(LockEntry {
115 name: decl.name.clone(),
116 path: format!("~/.yosh/plugins/{}/{}", decl.name, asset_name),
117 enabled: decl.enabled,
118 capabilities: decl.capabilities.clone(),
119 sha256: existing.sha256.clone(),
120 source: format!("github:{}/{}", owner, repo),
121 version: Some(version.to_string()),
122 });
123 }
124 Ok(false) => {
125 eprintln!(
126 "yosh-plugin: {}: local binary checksum mismatch, re-downloading",
127 decl.name
128 );
129 }
130 Err(e) => {
131 eprintln!("yosh-plugin: {}: verify failed: {}", decl.name, e);
132 }
133 }
134 }
135 }
136
137 let url = client.find_asset_url(owner, repo, version, &asset_name)?;
139 std::fs::create_dir_all(&dest_dir)
140 .map_err(|e| format!("create dir {}: {}", dest_dir.display(), e))?;
141 client.download(&url, &dest_path)?;
142 let sha256 = sha256_file(&dest_path)?;
143
144 if let Some(existing) = existing {
146 if existing.version.as_deref() == Some(version) && sha256 != existing.sha256 {
147 let _ = std::fs::remove_file(&dest_path);
148 return Err(format!(
149 "re-downloaded binary has different checksum (expected {}, got {}). \
150 The upstream release asset may have been replaced.",
151 existing.sha256, sha256
152 ));
153 }
154 }
155
156 Ok(LockEntry {
157 name: decl.name.clone(),
158 path: format!("~/.yosh/plugins/{}/{}", decl.name, asset_name),
159 enabled: decl.enabled,
160 capabilities: decl.capabilities.clone(),
161 sha256,
162 source: format!("github:{}/{}", owner, repo),
163 version: Some(version.to_string()),
164 })
165 }
166 PluginSource::Local { path } => {
167 let resolved = config::expand_tilde_path(path);
168 if !resolved.exists() {
169 return Err(format!("file not found: {}", resolved.display()));
170 }
171 let sha256 = sha256_file(&resolved)?;
172 Ok(LockEntry {
173 name: decl.name.clone(),
174 path: path.clone(),
175 enabled: decl.enabled,
176 capabilities: decl.capabilities.clone(),
177 sha256,
178 source: format!("local:{}", path),
179 version: None,
180 })
181 }
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use std::io::Write;
189
190 #[test]
191 fn expand_tilde_via_config() {
192 let result = config::expand_tilde_path("~/.yosh/plugins/lib.dylib");
193 assert!(!result.to_string_lossy().starts_with("~"));
194 }
195
196 #[test]
197 fn expand_tilde_absolute_path() {
198 let result = config::expand_tilde_path("/absolute/path");
199 assert_eq!(result, PathBuf::from("/absolute/path"));
200 }
201
202 #[test]
203 fn sync_one_local_plugin() {
204 let mut f = tempfile::NamedTempFile::new().unwrap();
205 f.write_all(b"fake binary content").unwrap();
206 let path = f.path().to_string_lossy().to_string();
207
208 let decl = PluginDecl {
209 name: "local-test".into(),
210 source: PluginSource::Local { path: path.clone() },
211 version: None,
212 enabled: true,
213 capabilities: Some(vec!["io".into()]),
214 asset: None,
215 };
216 let client = GitHubClient::new();
217 let empty_lock = LockFile { plugin: vec![] };
218 let entry = sync_one(&client, &decl, &empty_lock).unwrap();
219 assert_eq!(entry.name, "local-test");
220 assert_eq!(entry.path, path);
221 assert!(!entry.sha256.is_empty());
222 assert!(entry.version.is_none());
223 }
224
225 #[test]
226 fn sync_one_local_plugin_missing_file() {
227 let decl = PluginDecl {
228 name: "missing".into(),
229 source: PluginSource::Local {
230 path: "/nonexistent/lib.dylib".into(),
231 },
232 version: None,
233 enabled: true,
234 capabilities: None,
235 asset: None,
236 };
237 let client = GitHubClient::new();
238 let empty_lock = LockFile { plugin: vec![] };
239 let result = sync_one(&client, &decl, &empty_lock);
240 assert!(result.is_err());
241 }
242}