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 { plugin: new_entries };
88 save_lockfile(&lock_path, &new_lock)?;
89
90 Ok(SyncResult { succeeded, failed })
91}
92
93fn sync_one(
94 client: &GitHubClient,
95 decl: &PluginDecl,
96 existing_lock: &LockFile,
97) -> Result<LockEntry, String> {
98 let existing = existing_lock.plugin.iter().find(|e| e.name == decl.name);
99
100 match &decl.source {
101 PluginSource::GitHub { owner, repo } => {
102 let version = decl.version.as_deref().unwrap(); let asset_name = asset_filename(&decl.name, decl.asset.as_deref());
104 let dest_dir = plugin_dir().join(&decl.name);
105 let dest_path = dest_dir.join(&asset_name);
106
107 if let Some(existing) = existing {
109 if existing.version.as_deref() == Some(version) && dest_path.exists() {
110 match verify_checksum(&dest_path, &existing.sha256) {
111 Ok(true) => {
112 return Ok(LockEntry {
113 name: decl.name.clone(),
114 path: format!(
115 "~/.yosh/plugins/{}/{}",
116 decl.name, asset_name
117 ),
118 enabled: decl.enabled,
119 capabilities: decl.capabilities.clone(),
120 sha256: existing.sha256.clone(),
121 source: format!("github:{}/{}", owner, repo),
122 version: Some(version.to_string()),
123 });
124 }
125 Ok(false) => {
126 eprintln!(
127 "yosh-plugin: {}: local binary checksum mismatch, re-downloading",
128 decl.name
129 );
130 }
131 Err(e) => {
132 eprintln!("yosh-plugin: {}: verify failed: {}", decl.name, e);
133 }
134 }
135 }
136 }
137
138 let url = client.find_asset_url(owner, repo, version, &asset_name)?;
140 std::fs::create_dir_all(&dest_dir)
141 .map_err(|e| format!("create dir {}: {}", dest_dir.display(), e))?;
142 client.download(&url, &dest_path)?;
143 let sha256 = sha256_file(&dest_path)?;
144
145 if let Some(existing) = existing {
147 if existing.version.as_deref() == Some(version) && sha256 != existing.sha256 {
148 let _ = std::fs::remove_file(&dest_path);
149 return Err(format!(
150 "re-downloaded binary has different checksum (expected {}, got {}). \
151 The upstream release asset may have been replaced.",
152 existing.sha256, sha256
153 ));
154 }
155 }
156
157 Ok(LockEntry {
158 name: decl.name.clone(),
159 path: format!("~/.yosh/plugins/{}/{}", decl.name, asset_name),
160 enabled: decl.enabled,
161 capabilities: decl.capabilities.clone(),
162 sha256,
163 source: format!("github:{}/{}", owner, repo),
164 version: Some(version.to_string()),
165 })
166 }
167 PluginSource::Local { path } => {
168 let resolved = config::expand_tilde_path(path);
169 if !resolved.exists() {
170 return Err(format!("file not found: {}", resolved.display()));
171 }
172 let sha256 = sha256_file(&resolved)?;
173 Ok(LockEntry {
174 name: decl.name.clone(),
175 path: path.clone(),
176 enabled: decl.enabled,
177 capabilities: decl.capabilities.clone(),
178 sha256,
179 source: format!("local:{}", path),
180 version: None,
181 })
182 }
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use std::io::Write;
190
191 #[test]
192 fn expand_tilde_via_config() {
193 let result = config::expand_tilde_path("~/.yosh/plugins/lib.dylib");
194 assert!(!result.to_string_lossy().starts_with("~"));
195 }
196
197 #[test]
198 fn expand_tilde_absolute_path() {
199 let result = config::expand_tilde_path("/absolute/path");
200 assert_eq!(result, PathBuf::from("/absolute/path"));
201 }
202
203 #[test]
204 fn sync_one_local_plugin() {
205 let mut f = tempfile::NamedTempFile::new().unwrap();
206 f.write_all(b"fake binary content").unwrap();
207 let path = f.path().to_string_lossy().to_string();
208
209 let decl = PluginDecl {
210 name: "local-test".into(),
211 source: PluginSource::Local { path: path.clone() },
212 version: None,
213 enabled: true,
214 capabilities: Some(vec!["io".into()]),
215 asset: None,
216 };
217 let client = GitHubClient::new();
218 let empty_lock = LockFile { plugin: vec![] };
219 let entry = sync_one(&client, &decl, &empty_lock).unwrap();
220 assert_eq!(entry.name, "local-test");
221 assert_eq!(entry.path, path);
222 assert!(!entry.sha256.is_empty());
223 assert!(entry.version.is_none());
224 }
225
226 #[test]
227 fn sync_one_local_plugin_missing_file() {
228 let decl = PluginDecl {
229 name: "missing".into(),
230 source: PluginSource::Local { path: "/nonexistent/lib.dylib".into() },
231 version: None,
232 enabled: true,
233 capabilities: None,
234 asset: None,
235 };
236 let client = GitHubClient::new();
237 let empty_lock = LockFile { plugin: vec![] };
238 let result = sync_one(&client, &decl, &empty_lock);
239 assert!(result.is_err());
240 }
241}