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 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 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('/') || 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
89fn parse_github(rest: &str) -> Result<InstallTarget, String> {
91 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 let url_part = url_part.trim_end_matches('/');
107 let url_part = url_part.strip_suffix(".git").unwrap_or(url_part);
108 let url_part = url_part.trim_end_matches('/');
110
111 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
136fn 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
171pub 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 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 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 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, )
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 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 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}