Skip to main content

yosh_plugin_manager/
sync.rs

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)>, // (name, error)
36}
37
38/// Run the sync flow: read config, diff against lock, download/verify, write lock.
39pub 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    // Prune: delete binaries for plugins removed from config
72    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(); // validated in config
105            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            // Check if unchanged (same version, already in lock, file exists)
110            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            // Download
138            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 re-downloading same version and hash changed, that's suspicious
145            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}