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::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(|| "'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('/') || arg.starts_with("./") || arg.starts_with("../") {
80        parse_local(arg)
81    } else {
82        Err(format!(
83            "unrecognized install argument '{}': expected a GitHub URL (https://github.com/owner/repo) or a local path",
84            arg
85        ))
86    }
87}
88
89/// Parse the portion of a GitHub URL after `https://github.com/`.
90fn parse_github(rest: &str) -> Result<InstallTarget, String> {
91    // Split off version at `@` — but only after the github.com/ prefix has been stripped.
92    let (url_part, version) = if let Some(at_pos) = rest.find('@') {
93        let v = rest[at_pos + 1..].to_string();
94        if v.is_empty() {
95            return Err(format!(
96                "empty version after '@' in 'https://github.com/{}'",
97                rest
98            ));
99        }
100        (&rest[..at_pos], Some(v))
101    } else {
102        (rest, None)
103    };
104
105    // Strip trailing `/` and `.git` suffix.
106    let url_part = url_part.trim_end_matches('/');
107    let url_part = url_part.strip_suffix(".git").unwrap_or(url_part);
108    // Strip again in case there was a trailing slash before `.git`
109    let url_part = url_part.trim_end_matches('/');
110
111    // Split into owner/repo — exactly two non-empty segments, no extra path components.
112    let parts: Vec<&str> = url_part.splitn(3, '/').collect();
113    if parts.len() < 2 || parts[0].is_empty() || parts[1].is_empty() {
114        return Err(format!(
115            "invalid GitHub URL 'https://github.com/{}': expected 'https://github.com/owner/repo'",
116            url_part
117        ));
118    }
119    if parts.len() > 2 {
120        return Err(format!(
121            "invalid GitHub URL 'https://github.com/{}': unexpected path after repo name",
122            url_part
123        ));
124    }
125    let owner = parts[0].to_string();
126    let repo = parts[1].to_string();
127    let name = repo.clone();
128
129    Ok(InstallTarget {
130        name,
131        source: PluginSource::GitHub { owner, repo },
132        version,
133    })
134}
135
136/// Parse a local filesystem path (absolute or relative).
137fn parse_local(arg: &str) -> Result<InstallTarget, String> {
138    let path = Path::new(arg);
139    let canonical = path
140        .canonicalize()
141        .map_err(|e| format!("cannot resolve local path '{}': {}", arg, e))?;
142
143    let name = canonical
144        .file_stem()
145        .and_then(|s| s.to_str())
146        .ok_or_else(|| {
147            format!(
148                "cannot determine plugin name from path '{}'",
149                canonical.display()
150            )
151        })?
152        .to_string();
153
154    let path_str = canonical
155        .to_str()
156        .ok_or_else(|| {
157            format!(
158                "path '{}' contains non-UTF-8 characters",
159                canonical.display()
160            )
161        })?
162        .to_string();
163
164    Ok(InstallTarget {
165        name,
166        source: PluginSource::Local { path: path_str },
167        version: None,
168    })
169}
170
171/// Main install entry point.
172/// `github_client` is optional — if None and a GitHub latest version is needed, a default client is created.
173pub fn install(
174    arg: &str,
175    force: bool,
176    config_path: &Path,
177    github_client: Option<&GitHubClient>,
178) -> Result<String, String> {
179    let mut target = parse_install_arg(arg)?;
180
181    // Resolve latest version for GitHub sources when not specified
182    if matches!(&target.source, PluginSource::GitHub { .. }) && target.version.is_none() {
183        let default_client;
184        let client = match github_client {
185            Some(c) => c,
186            None => {
187                default_client = GitHubClient::new();
188                &default_client
189            }
190        };
191        if let PluginSource::GitHub { owner, repo } = &target.source {
192            let version = client.latest_version(owner, repo)?;
193            target.version = Some(version);
194        }
195    }
196
197    // Ensure config file exists
198    if !config_path.exists() {
199        if let Some(parent) = config_path.parent() {
200            std::fs::create_dir_all(parent)
201                .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
202        }
203        std::fs::write(config_path, "")
204            .map_err(|e| format!("failed to create {}: {}", config_path.display(), e))?;
205    }
206
207    write_plugin_entry(config_path, &target, force)?;
208
209    // Build result message
210    let source_str = source_string(&target.source);
211    let msg = match &target.version {
212        Some(v) => format!("Installed plugin '{}' ({}@{})", target.name, source_str, v),
213        None => format!("Installed plugin '{}' ({})", target.name, source_str),
214    };
215
216    Ok(msg)
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn write_github_entry_to_empty_file() {
225        let f = tempfile::NamedTempFile::new().unwrap();
226        std::fs::write(f.path(), "").unwrap();
227        let target = InstallTarget {
228            name: "foo".into(),
229            source: PluginSource::GitHub {
230                owner: "example".into(),
231                repo: "foo".into(),
232            },
233            version: Some("1.0.0".into()),
234        };
235        write_plugin_entry(f.path(), &target, false).unwrap();
236        let content = std::fs::read_to_string(f.path()).unwrap();
237        assert!(content.contains("name = \"foo\""));
238        assert!(content.contains("source = \"github:example/foo\""));
239        assert!(content.contains("version = \"1.0.0\""));
240        assert!(content.contains("enabled = true"));
241    }
242
243    #[test]
244    fn write_local_entry_appends() {
245        let f = tempfile::NamedTempFile::new().unwrap();
246        std::fs::write(
247            f.path(),
248            "[[plugin]]\nname = \"existing\"\nsource = \"local:/tmp/lib.dylib\"\nenabled = true\n",
249        )
250        .unwrap();
251        let target = InstallTarget {
252            name: "new-plugin".into(),
253            source: PluginSource::Local {
254                path: "/usr/lib/new.dylib".into(),
255            },
256            version: None,
257        };
258        write_plugin_entry(f.path(), &target, false).unwrap();
259        let content = std::fs::read_to_string(f.path()).unwrap();
260        assert!(content.contains("name = \"existing\""));
261        assert!(content.contains("name = \"new-plugin\""));
262        assert!(content.contains("source = \"local:/usr/lib/new.dylib\""));
263        assert!(!content.contains("version"));
264    }
265
266    #[test]
267    fn write_duplicate_without_force_errors() {
268        let f = tempfile::NamedTempFile::new().unwrap();
269        std::fs::write(
270            f.path(),
271            "[[plugin]]\nname = \"foo\"\nsource = \"local:/tmp/lib.dylib\"\nenabled = true\n",
272        )
273        .unwrap();
274        let target = InstallTarget {
275            name: "foo".into(),
276            source: PluginSource::Local {
277                path: "/tmp/new.dylib".into(),
278            },
279            version: None,
280        };
281        let result = write_plugin_entry(f.path(), &target, false);
282        assert!(result.is_err());
283        assert!(result.unwrap_err().contains("already installed"));
284    }
285
286    #[test]
287    fn write_duplicate_with_force_replaces() {
288        let f = tempfile::NamedTempFile::new().unwrap();
289        std::fs::write(
290            f.path(),
291            "[[plugin]]\nname = \"foo\"\nsource = \"local:/tmp/old.dylib\"\nenabled = true\n",
292        )
293        .unwrap();
294        let target = InstallTarget {
295            name: "foo".into(),
296            source: PluginSource::GitHub {
297                owner: "example".into(),
298                repo: "foo".into(),
299            },
300            version: Some("2.0.0".into()),
301        };
302        write_plugin_entry(f.path(), &target, true).unwrap();
303        let content = std::fs::read_to_string(f.path()).unwrap();
304        assert!(!content.contains("local:/tmp/old.dylib"));
305        assert!(content.contains("github:example/foo"));
306        assert!(content.contains("version = \"2.0.0\""));
307    }
308
309    #[test]
310    fn write_preserves_comments() {
311        let f = tempfile::NamedTempFile::new().unwrap();
312        std::fs::write(
313            f.path(),
314            "# My plugins config\n\n[[plugin]]\nname = \"bar\"\nsource = \"local:/tmp/bar.dylib\"\nenabled = true\n",
315        )
316        .unwrap();
317        let target = InstallTarget {
318            name: "baz".into(),
319            source: PluginSource::Local {
320                path: "/tmp/baz.dylib".into(),
321            },
322            version: None,
323        };
324        write_plugin_entry(f.path(), &target, false).unwrap();
325        let content = std::fs::read_to_string(f.path()).unwrap();
326        assert!(content.contains("# My plugins config"));
327        assert!(content.contains("name = \"bar\""));
328        assert!(content.contains("name = \"baz\""));
329    }
330
331    #[test]
332    fn parse_github_url_no_version() {
333        let t = parse_install_arg("https://github.com/example/kish-plugin-foo").unwrap();
334        assert_eq!(t.name, "kish-plugin-foo");
335        assert_eq!(
336            t.source,
337            PluginSource::GitHub {
338                owner: "example".into(),
339                repo: "kish-plugin-foo".into()
340            }
341        );
342        assert_eq!(t.version, None);
343    }
344
345    #[test]
346    fn parse_github_url_with_version() {
347        let t = parse_install_arg("https://github.com/example/plugin@1.0.0").unwrap();
348        assert_eq!(t.name, "plugin");
349        assert_eq!(
350            t.source,
351            PluginSource::GitHub {
352                owner: "example".into(),
353                repo: "plugin".into()
354            }
355        );
356        assert_eq!(t.version, Some("1.0.0".into()));
357    }
358
359    #[test]
360    fn parse_github_url_trailing_slash_stripped() {
361        let t = parse_install_arg("https://github.com/owner/repo/").unwrap();
362        assert_eq!(t.name, "repo");
363        assert_eq!(
364            t.source,
365            PluginSource::GitHub {
366                owner: "owner".into(),
367                repo: "repo".into()
368            }
369        );
370    }
371
372    #[test]
373    fn parse_github_url_with_dot_git_suffix() {
374        let t = parse_install_arg("https://github.com/owner/repo.git").unwrap();
375        assert_eq!(t.name, "repo");
376        assert_eq!(
377            t.source,
378            PluginSource::GitHub {
379                owner: "owner".into(),
380                repo: "repo".into()
381            }
382        );
383    }
384
385    #[test]
386    fn parse_github_invalid_url_missing_repo() {
387        let result = parse_install_arg("https://github.com/owneronly");
388        assert!(result.is_err());
389    }
390
391    #[test]
392    fn parse_github_invalid_url_empty_repo() {
393        let result = parse_install_arg("https://github.com/owner/");
394        assert!(result.is_err());
395    }
396
397    #[test]
398    fn parse_github_empty_version_error() {
399        let result = parse_install_arg("https://github.com/owner/repo@");
400        assert!(result.is_err());
401        assert!(result.unwrap_err().contains("empty version"));
402    }
403
404    #[test]
405    fn parse_github_extra_path_segments_error() {
406        let result = parse_install_arg("https://github.com/owner/repo/tree/main");
407        assert!(result.is_err());
408        assert!(result.unwrap_err().contains("unexpected path"));
409    }
410
411    #[test]
412    fn parse_local_absolute_path() {
413        let t = parse_install_arg("/tmp").unwrap();
414        assert_eq!(t.name, "tmp");
415        assert!(matches!(t.source, PluginSource::Local { .. }));
416        assert_eq!(t.version, None);
417    }
418
419    #[test]
420    fn parse_local_nonexistent_path_error() {
421        let result = parse_install_arg("/nonexistent/path/to/lib.dylib");
422        assert!(result.is_err());
423    }
424
425    #[test]
426    fn install_github_with_explicit_version() {
427        let dir = tempfile::tempdir().unwrap();
428        let config_path = dir.path().join("plugins.toml");
429        std::fs::write(&config_path, "").unwrap();
430
431        install(
432            "https://github.com/example/my-plugin@1.0.0",
433            false,
434            &config_path,
435            None, // skip GitHub API when version is explicit
436        )
437        .unwrap();
438
439        let content = std::fs::read_to_string(&config_path).unwrap();
440        assert!(content.contains("name = \"my-plugin\""));
441        assert!(content.contains("source = \"github:example/my-plugin\""));
442        assert!(content.contains("version = \"1.0.0\""));
443        assert!(content.contains("enabled = true"));
444    }
445
446    #[test]
447    fn install_local_path() {
448        let dir = tempfile::tempdir().unwrap();
449        let config_path = dir.path().join("plugins.toml");
450        std::fs::write(&config_path, "").unwrap();
451
452        // Create a temp file to act as the local plugin binary
453        let lib_file = dir.path().join("libtest.dylib");
454        std::fs::write(&lib_file, b"fake").unwrap();
455        let lib_path = lib_file.to_string_lossy().to_string();
456        // canonicalize resolves symlinks (e.g. /var -> /private/var on macOS)
457        let canonical_lib_path = lib_file
458            .canonicalize()
459            .unwrap()
460            .to_string_lossy()
461            .to_string();
462
463        install(&lib_path, false, &config_path, None).unwrap();
464
465        let content = std::fs::read_to_string(&config_path).unwrap();
466        assert!(content.contains("name = \"libtest\""));
467        assert!(content.contains(&format!("source = \"local:{}\"", canonical_lib_path)));
468        assert!(!content.contains("version"));
469    }
470
471    #[test]
472    fn install_duplicate_without_force() {
473        let dir = tempfile::tempdir().unwrap();
474        let config_path = dir.path().join("plugins.toml");
475        std::fs::write(
476            &config_path,
477            "[[plugin]]\nname = \"my-plugin\"\nsource = \"local:/tmp/x.dylib\"\nenabled = true\n",
478        )
479        .unwrap();
480
481        let result = install(
482            "https://github.com/example/my-plugin@1.0.0",
483            false,
484            &config_path,
485            None,
486        );
487        assert!(result.is_err());
488        assert!(result.unwrap_err().contains("already installed"));
489    }
490
491    #[test]
492    fn install_duplicate_with_force() {
493        let dir = tempfile::tempdir().unwrap();
494        let config_path = dir.path().join("plugins.toml");
495        std::fs::write(
496            &config_path,
497            "[[plugin]]\nname = \"my-plugin\"\nsource = \"local:/tmp/old.dylib\"\nenabled = true\n",
498        )
499        .unwrap();
500
501        install(
502            "https://github.com/example/my-plugin@2.0.0",
503            true,
504            &config_path,
505            None,
506        )
507        .unwrap();
508
509        let content = std::fs::read_to_string(&config_path).unwrap();
510        assert!(!content.contains("local:/tmp/old.dylib"));
511        assert!(content.contains("github:example/my-plugin"));
512        assert!(content.contains("version = \"2.0.0\""));
513    }
514}