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 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 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 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
93fn parse_github(rest: &str) -> Result<InstallTarget, String> {
95 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 let url_part = url_part.trim_end_matches('/');
111 let url_part = url_part.strip_suffix(".git").unwrap_or(url_part);
112 let url_part = url_part.trim_end_matches('/');
114
115 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
140fn parse_local(arg: &str) -> Result<InstallTarget, String> {
146 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 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
200pub 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 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 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 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 #[test]
459 fn parse_local_path_with_tilde_is_expanded() {
460 let dir = tempfile::tempdir().unwrap();
461 let plugin = dir.path().join("tilde_test_plugin.wasm");
463 std::fs::write(&plugin, b"\0asm\x01\0\0\0").unwrap();
464 let prev_home = std::env::var_os("HOME");
468 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, )
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 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 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}