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