Skip to main content

yosh_plugin_manager/
install.rs

1use std::path::Path;
2
3use toml_edit::{DocumentMut, Item, Table, value};
4
5use crate::config::{self, PluginSource};
6use crate::github::GitHubClient;
7
8#[derive(Debug)]
9pub struct InstallTarget {
10    pub name: String,
11    pub source: PluginSource,
12    pub version: Option<String>,
13}
14
15const GITHUB_PREFIX: &str = "https://github.com/";
16
17fn source_string(source: &PluginSource) -> String {
18    match source {
19        PluginSource::GitHub { owner, repo } => format!("github:{}/{}", owner, repo),
20        PluginSource::Local { path } => format!("local:{}", path),
21    }
22}
23
24pub fn write_plugin_entry(
25    config_path: &Path,
26    target: &InstallTarget,
27    force: bool,
28) -> Result<(), String> {
29    let content = std::fs::read_to_string(config_path).unwrap_or_default();
30
31    let mut doc: DocumentMut = content
32        .parse()
33        .map_err(|e| format!("failed to parse {}: {}", config_path.display(), e))?;
34
35    // Ensure [[plugin]] array of tables exists
36    if !doc.contains_key("plugin") {
37        doc["plugin"] = Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
38    }
39
40    let plugins = doc["plugin"]
41        .as_array_of_tables_mut()
42        .ok_or_else(|| "config 'plugin' key is not an array of tables".to_string())?;
43
44    // Check for duplicates
45    let existing_idx = plugins
46        .iter()
47        .position(|t| t.get("name").and_then(|v| v.as_str()) == Some(&target.name));
48
49    if let Some(idx) = existing_idx {
50        if !force {
51            return Err(format!(
52                "plugin '{}' is already installed. Use --force to overwrite.",
53                target.name
54            ));
55        }
56        plugins.remove(idx);
57    }
58
59    // Build new entry
60    let mut entry = Table::new();
61    entry.insert("name", value(&target.name));
62    entry.insert("source", value(source_string(&target.source)));
63    if let Some(ver) = &target.version {
64        entry.insert("version", value(ver.as_str()));
65    }
66    entry.insert("enabled", value(true));
67
68    plugins.push(entry);
69
70    std::fs::write(config_path, doc.to_string())
71        .map_err(|e| format!("failed to write {}: {}", config_path.display(), e))?;
72
73    Ok(())
74}
75
76pub fn parse_install_arg(arg: &str) -> Result<InstallTarget, String> {
77    if let Some(rest) = arg.strip_prefix(GITHUB_PREFIX) {
78        parse_github(rest)
79    } else if arg.starts_with('/')
80        || arg.starts_with("./")
81        || arg.starts_with("../")
82        || arg.starts_with("~/")
83    {
84        parse_local(arg)
85    } else {
86        Err(format!(
87            "unrecognized install argument '{}': expected a GitHub URL (https://github.com/owner/repo) or a local path",
88            arg
89        ))
90    }
91}
92
93/// Parse the portion of a GitHub URL after `https://github.com/`.
94fn parse_github(rest: &str) -> Result<InstallTarget, String> {
95    // Split off version at `@` — but only after the github.com/ prefix has been stripped.
96    let (url_part, version) = if let Some(at_pos) = rest.find('@') {
97        let v = rest[at_pos + 1..].to_string();
98        if v.is_empty() {
99            return Err(format!(
100                "empty version after '@' in 'https://github.com/{}'",
101                rest
102            ));
103        }
104        (&rest[..at_pos], Some(v))
105    } else {
106        (rest, None)
107    };
108
109    // Strip trailing `/` and `.git` suffix.
110    let url_part = url_part.trim_end_matches('/');
111    let url_part = url_part.strip_suffix(".git").unwrap_or(url_part);
112    // Strip again in case there was a trailing slash before `.git`
113    let url_part = url_part.trim_end_matches('/');
114
115    // Split into owner/repo — exactly two non-empty segments, no extra path components.
116    let parts: Vec<&str> = url_part.splitn(3, '/').collect();
117    if parts.len() < 2 || parts[0].is_empty() || parts[1].is_empty() {
118        return Err(format!(
119            "invalid GitHub URL 'https://github.com/{}': expected 'https://github.com/owner/repo'",
120            url_part
121        ));
122    }
123    if parts.len() > 2 {
124        return Err(format!(
125            "invalid GitHub URL 'https://github.com/{}': unexpected path after repo name",
126            url_part
127        ));
128    }
129    let owner = parts[0].to_string();
130    let repo = parts[1].to_string();
131    let name = repo.clone();
132
133    Ok(InstallTarget {
134        name,
135        source: PluginSource::GitHub { owner, repo },
136        version,
137    })
138}
139
140/// Parse a local filesystem path (absolute or relative).
141///
142/// v0.2.0+ requires `.wasm` Component Model plugins; we reject any other
143/// extension at install time so the user sees the migration error
144/// immediately rather than at sync time.
145fn parse_local(arg: &str) -> Result<InstallTarget, String> {
146    // `Path::canonicalize` doesn't expand `~`, so `yosh-plugin install
147    // ~/my-plugin.wasm` would otherwise fail with ENOENT. Pre-expand
148    // tildes via the same helper sync/lockfile uses.
149    let expanded = config::expand_tilde_path(arg);
150    let path = expanded.as_path();
151    let canonical = path
152        .canonicalize()
153        .map_err(|e| format!("cannot resolve local path '{}': {}", arg, e))?;
154
155    // Require `.wasm` for local plugin installs. Directories are allowed
156    // (the file_stem-based fallback below handled them historically) so
157    // we only enforce the extension when one is present.
158    if canonical.is_file() {
159        let valid_ext = canonical
160            .extension()
161            .and_then(|e| e.to_str())
162            .map(|e| e.eq_ignore_ascii_case("wasm"))
163            .unwrap_or(false);
164        if !valid_ext {
165            return Err(format!(
166                "{}: not a .wasm file (yosh v0.2.0 requires WebAssembly Component plugins)",
167                canonical.display()
168            ));
169        }
170    }
171
172    let name = canonical
173        .file_stem()
174        .and_then(|s| s.to_str())
175        .ok_or_else(|| {
176            format!(
177                "cannot determine plugin name from path '{}'",
178                canonical.display()
179            )
180        })?
181        .to_string();
182
183    let path_str = canonical
184        .to_str()
185        .ok_or_else(|| {
186            format!(
187                "path '{}' contains non-UTF-8 characters",
188                canonical.display()
189            )
190        })?
191        .to_string();
192
193    Ok(InstallTarget {
194        name,
195        source: PluginSource::Local { path: path_str },
196        version: None,
197    })
198}
199
200/// Main install entry point.
201/// `github_client` is optional — if None and a GitHub latest version is needed, a default client is created.
202pub fn install(
203    arg: &str,
204    force: bool,
205    config_path: &Path,
206    github_client: Option<&GitHubClient>,
207) -> Result<String, String> {
208    let mut target = parse_install_arg(arg)?;
209
210    // Resolve latest version for GitHub sources when not specified
211    if matches!(&target.source, PluginSource::GitHub { .. }) && target.version.is_none() {
212        let default_client;
213        let client = match github_client {
214            Some(c) => c,
215            None => {
216                default_client = GitHubClient::new();
217                &default_client
218            }
219        };
220        if let PluginSource::GitHub { owner, repo } = &target.source {
221            let version = client.latest_version(owner, repo)?;
222            target.version = Some(version);
223        }
224    }
225
226    // Ensure config file exists
227    if !config_path.exists() {
228        if let Some(parent) = config_path.parent() {
229            std::fs::create_dir_all(parent)
230                .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
231        }
232        std::fs::write(config_path, "")
233            .map_err(|e| format!("failed to create {}: {}", config_path.display(), e))?;
234    }
235
236    write_plugin_entry(config_path, &target, force)?;
237
238    // Build result message
239    let source_str = source_string(&target.source);
240    let msg = match &target.version {
241        Some(v) => format!("Installed plugin '{}' ({}@{})", target.name, source_str, v),
242        None => format!("Installed plugin '{}' ({})", target.name, source_str),
243    };
244
245    Ok(msg)
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn write_github_entry_to_empty_file() {
254        let f = tempfile::NamedTempFile::new().unwrap();
255        std::fs::write(f.path(), "").unwrap();
256        let target = InstallTarget {
257            name: "foo".into(),
258            source: PluginSource::GitHub {
259                owner: "example".into(),
260                repo: "foo".into(),
261            },
262            version: Some("1.0.0".into()),
263        };
264        write_plugin_entry(f.path(), &target, false).unwrap();
265        let content = std::fs::read_to_string(f.path()).unwrap();
266        assert!(content.contains("name = \"foo\""));
267        assert!(content.contains("source = \"github:example/foo\""));
268        assert!(content.contains("version = \"1.0.0\""));
269        assert!(content.contains("enabled = true"));
270    }
271
272    #[test]
273    fn write_local_entry_appends() {
274        let f = tempfile::NamedTempFile::new().unwrap();
275        std::fs::write(
276            f.path(),
277            "[[plugin]]\nname = \"existing\"\nsource = \"local:/tmp/lib.dylib\"\nenabled = true\n",
278        )
279        .unwrap();
280        let target = InstallTarget {
281            name: "new-plugin".into(),
282            source: PluginSource::Local {
283                path: "/usr/lib/new.dylib".into(),
284            },
285            version: None,
286        };
287        write_plugin_entry(f.path(), &target, false).unwrap();
288        let content = std::fs::read_to_string(f.path()).unwrap();
289        assert!(content.contains("name = \"existing\""));
290        assert!(content.contains("name = \"new-plugin\""));
291        assert!(content.contains("source = \"local:/usr/lib/new.dylib\""));
292        assert!(!content.contains("version"));
293    }
294
295    #[test]
296    fn write_duplicate_without_force_errors() {
297        let f = tempfile::NamedTempFile::new().unwrap();
298        std::fs::write(
299            f.path(),
300            "[[plugin]]\nname = \"foo\"\nsource = \"local:/tmp/lib.dylib\"\nenabled = true\n",
301        )
302        .unwrap();
303        let target = InstallTarget {
304            name: "foo".into(),
305            source: PluginSource::Local {
306                path: "/tmp/new.dylib".into(),
307            },
308            version: None,
309        };
310        let result = write_plugin_entry(f.path(), &target, false);
311        assert!(result.is_err());
312        assert!(result.unwrap_err().contains("already installed"));
313    }
314
315    #[test]
316    fn write_duplicate_with_force_replaces() {
317        let f = tempfile::NamedTempFile::new().unwrap();
318        std::fs::write(
319            f.path(),
320            "[[plugin]]\nname = \"foo\"\nsource = \"local:/tmp/old.dylib\"\nenabled = true\n",
321        )
322        .unwrap();
323        let target = InstallTarget {
324            name: "foo".into(),
325            source: PluginSource::GitHub {
326                owner: "example".into(),
327                repo: "foo".into(),
328            },
329            version: Some("2.0.0".into()),
330        };
331        write_plugin_entry(f.path(), &target, true).unwrap();
332        let content = std::fs::read_to_string(f.path()).unwrap();
333        assert!(!content.contains("local:/tmp/old.dylib"));
334        assert!(content.contains("github:example/foo"));
335        assert!(content.contains("version = \"2.0.0\""));
336    }
337
338    #[test]
339    fn write_preserves_comments() {
340        let f = tempfile::NamedTempFile::new().unwrap();
341        std::fs::write(
342            f.path(),
343            "# My plugins config\n\n[[plugin]]\nname = \"bar\"\nsource = \"local:/tmp/bar.dylib\"\nenabled = true\n",
344        )
345        .unwrap();
346        let target = InstallTarget {
347            name: "baz".into(),
348            source: PluginSource::Local {
349                path: "/tmp/baz.dylib".into(),
350            },
351            version: None,
352        };
353        write_plugin_entry(f.path(), &target, false).unwrap();
354        let content = std::fs::read_to_string(f.path()).unwrap();
355        assert!(content.contains("# My plugins config"));
356        assert!(content.contains("name = \"bar\""));
357        assert!(content.contains("name = \"baz\""));
358    }
359
360    #[test]
361    fn parse_github_url_no_version() {
362        let t = parse_install_arg("https://github.com/example/yosh-plugin-foo").unwrap();
363        assert_eq!(t.name, "yosh-plugin-foo");
364        assert_eq!(
365            t.source,
366            PluginSource::GitHub {
367                owner: "example".into(),
368                repo: "yosh-plugin-foo".into()
369            }
370        );
371        assert_eq!(t.version, None);
372    }
373
374    #[test]
375    fn parse_github_url_with_version() {
376        let t = parse_install_arg("https://github.com/example/plugin@1.0.0").unwrap();
377        assert_eq!(t.name, "plugin");
378        assert_eq!(
379            t.source,
380            PluginSource::GitHub {
381                owner: "example".into(),
382                repo: "plugin".into()
383            }
384        );
385        assert_eq!(t.version, Some("1.0.0".into()));
386    }
387
388    #[test]
389    fn parse_github_url_trailing_slash_stripped() {
390        let t = parse_install_arg("https://github.com/owner/repo/").unwrap();
391        assert_eq!(t.name, "repo");
392        assert_eq!(
393            t.source,
394            PluginSource::GitHub {
395                owner: "owner".into(),
396                repo: "repo".into()
397            }
398        );
399    }
400
401    #[test]
402    fn parse_github_url_with_dot_git_suffix() {
403        let t = parse_install_arg("https://github.com/owner/repo.git").unwrap();
404        assert_eq!(t.name, "repo");
405        assert_eq!(
406            t.source,
407            PluginSource::GitHub {
408                owner: "owner".into(),
409                repo: "repo".into()
410            }
411        );
412    }
413
414    #[test]
415    fn parse_github_invalid_url_missing_repo() {
416        let result = parse_install_arg("https://github.com/owneronly");
417        assert!(result.is_err());
418    }
419
420    #[test]
421    fn parse_github_invalid_url_empty_repo() {
422        let result = parse_install_arg("https://github.com/owner/");
423        assert!(result.is_err());
424    }
425
426    #[test]
427    fn parse_github_empty_version_error() {
428        let result = parse_install_arg("https://github.com/owner/repo@");
429        assert!(result.is_err());
430        assert!(result.unwrap_err().contains("empty version"));
431    }
432
433    #[test]
434    fn parse_github_extra_path_segments_error() {
435        let result = parse_install_arg("https://github.com/owner/repo/tree/main");
436        assert!(result.is_err());
437        assert!(result.unwrap_err().contains("unexpected path"));
438    }
439
440    #[test]
441    fn parse_local_absolute_path() {
442        let t = parse_install_arg("/tmp").unwrap();
443        assert_eq!(t.name, "tmp");
444        assert!(matches!(t.source, PluginSource::Local { .. }));
445        assert_eq!(t.version, None);
446    }
447
448    #[test]
449    fn parse_local_nonexistent_path_error() {
450        let result = parse_install_arg("/nonexistent/path/to/lib.dylib");
451        assert!(result.is_err());
452    }
453
454    /// `~` in a local install path is expanded via $HOME so users can
455    /// run `yosh-plugin install ~/my-plugin.wasm` without first
456    /// resolving the tilde themselves. Before this fix `canonicalize`
457    /// took the literal `~/...` string and failed with ENOENT.
458    #[test]
459    fn parse_local_path_with_tilde_is_expanded() {
460        let dir = tempfile::tempdir().unwrap();
461        // Stage a .wasm under tmp_dir so canonicalize succeeds after expansion.
462        let plugin = dir.path().join("tilde_test_plugin.wasm");
463        std::fs::write(&plugin, b"\0asm\x01\0\0\0").unwrap();
464        // Build a tilde path by overriding HOME for the duration of this
465        // call. The helper's `~/...` strip-prefix logic + HOME concat then
466        // resolves to `dir.path()/tilde_test_plugin.wasm`.
467        let prev_home = std::env::var_os("HOME");
468        // SAFETY: this test holds no other threads' references to HOME.
469        unsafe {
470            std::env::set_var("HOME", dir.path());
471        }
472        let result = parse_install_arg("~/tilde_test_plugin.wasm");
473        if let Some(h) = prev_home {
474            unsafe {
475                std::env::set_var("HOME", h);
476            }
477        } else {
478            unsafe {
479                std::env::remove_var("HOME");
480            }
481        }
482        let t = result.expect("tilde-prefixed local path should parse");
483        assert_eq!(t.name, "tilde_test_plugin");
484        assert!(matches!(t.source, PluginSource::Local { .. }));
485    }
486
487    #[test]
488    fn install_github_with_explicit_version() {
489        let dir = tempfile::tempdir().unwrap();
490        let config_path = dir.path().join("plugins.toml");
491        std::fs::write(&config_path, "").unwrap();
492
493        install(
494            "https://github.com/example/my-plugin@1.0.0",
495            false,
496            &config_path,
497            None, // skip GitHub API when version is explicit
498        )
499        .unwrap();
500
501        let content = std::fs::read_to_string(&config_path).unwrap();
502        assert!(content.contains("name = \"my-plugin\""));
503        assert!(content.contains("source = \"github:example/my-plugin\""));
504        assert!(content.contains("version = \"1.0.0\""));
505        assert!(content.contains("enabled = true"));
506    }
507
508    #[test]
509    fn install_local_path() {
510        let dir = tempfile::tempdir().unwrap();
511        let config_path = dir.path().join("plugins.toml");
512        std::fs::write(&config_path, "").unwrap();
513
514        // Create a temp file to act as the local plugin binary.
515        // v0.2.0+ requires `.wasm` component plugins.
516        let lib_file = dir.path().join("test.wasm");
517        std::fs::write(&lib_file, b"fake").unwrap();
518        let lib_path = lib_file.to_string_lossy().to_string();
519        // canonicalize resolves symlinks (e.g. /var -> /private/var on macOS)
520        let canonical_lib_path = lib_file
521            .canonicalize()
522            .unwrap()
523            .to_string_lossy()
524            .to_string();
525
526        install(&lib_path, false, &config_path, None).unwrap();
527
528        let content = std::fs::read_to_string(&config_path).unwrap();
529        assert!(content.contains("name = \"test\""));
530        assert!(content.contains(&format!("source = \"local:{}\"", canonical_lib_path)));
531        assert!(!content.contains("version"));
532    }
533
534    #[test]
535    fn install_rejects_non_wasm_local_file() {
536        let dir = tempfile::tempdir().unwrap();
537        let config_path = dir.path().join("plugins.toml");
538        std::fs::write(&config_path, "").unwrap();
539
540        let lib_file = dir.path().join("libtest.dylib");
541        std::fs::write(&lib_file, b"fake").unwrap();
542        let lib_path = lib_file.to_string_lossy().to_string();
543
544        let err = install(&lib_path, false, &config_path, None).unwrap_err();
545        assert!(
546            err.contains("not a .wasm file") || err.contains("v0.2.0"),
547            "expected wasm-only error, got: {}",
548            err
549        );
550    }
551
552    #[test]
553    fn install_duplicate_without_force() {
554        let dir = tempfile::tempdir().unwrap();
555        let config_path = dir.path().join("plugins.toml");
556        std::fs::write(
557            &config_path,
558            "[[plugin]]\nname = \"my-plugin\"\nsource = \"local:/tmp/x.dylib\"\nenabled = true\n",
559        )
560        .unwrap();
561
562        let result = install(
563            "https://github.com/example/my-plugin@1.0.0",
564            false,
565            &config_path,
566            None,
567        );
568        assert!(result.is_err());
569        assert!(result.unwrap_err().contains("already installed"));
570    }
571
572    #[test]
573    fn install_duplicate_with_force() {
574        let dir = tempfile::tempdir().unwrap();
575        let config_path = dir.path().join("plugins.toml");
576        std::fs::write(
577            &config_path,
578            "[[plugin]]\nname = \"my-plugin\"\nsource = \"local:/tmp/old.dylib\"\nenabled = true\n",
579        )
580        .unwrap();
581
582        install(
583            "https://github.com/example/my-plugin@2.0.0",
584            true,
585            &config_path,
586            None,
587        )
588        .unwrap();
589
590        let content = std::fs::read_to_string(&config_path).unwrap();
591        assert!(!content.contains("local:/tmp/old.dylib"));
592        assert!(content.contains("github:example/my-plugin"));
593        assert!(content.contains("version = \"2.0.0\""));
594    }
595}