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 { 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(); // validated in config
103            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            // Check if unchanged (same version, already in lock, file exists)
108            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            // Download
139            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 re-downloading same version and hash changed, that's suspicious
146            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}