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 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 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 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
90fn parse_github(rest: &str) -> Result<InstallTarget, String> {
92 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 let url_part = url_part.trim_end_matches('/');
108 let url_part = url_part.strip_suffix(".git").unwrap_or(url_part);
109 let url_part = url_part.trim_end_matches('/');
111
112 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
137fn 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
162pub 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 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 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 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, )
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 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 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}