1use std::io::Read;
28use std::path::Path;
29use std::process::{Command, ExitStatus, Stdio};
30use std::time::{Duration, Instant};
31
32use crate::manifest::{DiscoveredPlugin, PluginManifest, validate_plugin_dir};
33
34const CLONE_TIMEOUT: Duration = Duration::from_secs(180);
38const FETCH_TIMEOUT: Duration = Duration::from_secs(60);
40const PULL_TIMEOUT: Duration = Duration::from_secs(60);
41const DIFF_TIMEOUT: Duration = Duration::from_secs(30);
43const READ_TIMEOUT: Duration = Duration::from_secs(10);
45const POLL_INTERVAL: Duration = Duration::from_millis(10);
47
48#[derive(Debug, Clone, PartialEq)]
51pub struct UpdatePreview {
52 pub up_to_date: bool,
54 pub current: String,
56 pub incoming: String,
58 pub diff: String,
61}
62
63pub fn add(root: &Path, url: &str) -> Result<DiscoveredPlugin, String> {
69 if !is_git_url(url) {
70 return Err(format!(
71 "`{url}` is not a git URL; plugin add installs from git URLs only. \
72 Local plugin directories are installed by copying them into {} by hand.",
73 root.display()
74 ));
75 }
76 std::fs::create_dir_all(root)
77 .map_err(|e| format!("cannot create plugins root {}: {e}", root.display()))?;
78
79 let staging = root.join(format!(".incoming-{}-{}", std::process::id(), now_millis()));
84 let result = (|| {
85 let mut clone = git(Some(root), &[]);
86 clone.arg("clone").arg("--quiet").arg(url).arg(&staging);
87 let out = output_with_timeout(&mut clone, CLONE_TIMEOUT)?;
88 if !out.status.success() {
89 let stderr = out.stderr.trim();
90 return Err(format!(
91 "git clone failed: {}{}",
92 out.status,
93 if stderr.is_empty() {
94 String::new()
95 } else {
96 format!(": {stderr}")
97 }
98 ));
99 }
100 stage_into_place(root, &staging)
101 })();
102 if result.is_err() {
103 let _ = std::fs::remove_dir_all(&staging);
104 }
105 result
106}
107
108fn stage_into_place(root: &Path, staging: &Path) -> Result<DiscoveredPlugin, String> {
113 let manifest_raw = std::fs::read_to_string(staging.join("manifest.json"))
116 .map_err(|e| format!("cloned repository has no readable manifest.json: {e}"))?;
117 let manifest: PluginManifest = serde_json::from_str(&manifest_raw)
118 .map_err(|e| format!("cloned manifest.json is invalid: {e}"))?;
119 if !safe_segment(&manifest.id) {
120 return Err(format!(
121 "manifest id `{}` is not a usable plugin directory name",
122 manifest.id
123 ));
124 }
125 let final_dir = root.join(&manifest.id);
126 if final_dir.exists() {
127 return Err(format!(
128 "plugin directory already exists: {}. Remove it first \
129 (`plugin remove {}` if it was git-installed) and add again.",
130 final_dir.display(),
131 manifest.id
132 ));
133 }
134 std::fs::rename(staging, &final_dir)
135 .map_err(|e| format!("failed to move the cloned plugin into place: {e}"))?;
136 match validate_plugin_dir(&final_dir) {
140 Ok(plugin) => Ok(plugin),
141 Err(reason) => {
142 let _ = std::fs::remove_dir_all(&final_dir);
145 Err(format!("cloned plugin failed validation: {reason}"))
146 }
147 }
148}
149
150pub fn update_fetch(dir: &Path) -> Result<UpdatePreview, String> {
154 require_installed(dir)?;
155 let branch = run_git_ok(
156 Some(dir),
157 &["rev-parse", "--abbrev-ref", "HEAD"],
158 READ_TIMEOUT,
159 "git rev-parse HEAD",
160 )?;
161 let fetch_args = ["fetch", "--quiet", "origin", branch.as_str()];
162 run_git_ok(Some(dir), &fetch_args, FETCH_TIMEOUT, "git fetch")?;
163 let current = run_git_ok(
164 Some(dir),
165 &["rev-parse", "--short", "HEAD"],
166 READ_TIMEOUT,
167 "git rev-parse HEAD",
168 )?;
169 let incoming = run_git_ok(
170 Some(dir),
171 &["rev-parse", "--short", "FETCH_HEAD"],
172 READ_TIMEOUT,
173 "git rev-parse FETCH_HEAD",
174 )?;
175 if current == incoming {
176 return Ok(UpdatePreview {
177 up_to_date: true,
178 current,
179 incoming,
180 diff: String::new(),
181 });
182 }
183 let diff = run_git_ok(
184 Some(dir),
185 &["diff", "--stat", "HEAD..FETCH_HEAD"],
186 DIFF_TIMEOUT,
187 "git diff",
188 )?;
189 Ok(UpdatePreview {
190 up_to_date: false,
191 current,
192 incoming,
193 diff,
194 })
195}
196
197pub fn update_apply(dir: &Path) -> Result<String, String> {
202 require_installed(dir)?;
203 let out = run_git(Some(dir), &["pull", "--ff-only", "--quiet"], PULL_TIMEOUT)?;
204 if !out.status.success() {
205 let stderr = out.stderr.trim();
206 return Err(format!(
207 "update refused — `git pull --ff-only` failed and nothing was changed. \
208 If upstream rewrote history, remove and re-add the plugin to take its version. \
209 git said: {}{}",
210 out.status,
211 if stderr.is_empty() {
212 String::new()
213 } else {
214 format!(": {stderr}")
215 }
216 ));
217 }
218 run_git_ok(
219 Some(dir),
220 &["rev-parse", "--short", "HEAD"],
221 READ_TIMEOUT,
222 "git rev-parse HEAD",
223 )
224}
225
226pub fn remove(root: &Path, id: &str) -> Result<(), String> {
230 if !safe_segment(id) {
231 return Err(format!("`{id}` is not a plugin directory name"));
232 }
233 let dir = root.join(id);
234 if !dir.is_dir() {
235 return Err(format!(
236 "no plugin directory named `{id}` under {}",
237 root.display()
238 ));
239 }
240 require_installed(&dir)?;
241 std::fs::remove_dir_all(&dir).map_err(|e| format!("failed to remove {}: {e}", dir.display()))
242}
243
244pub fn is_git_installed(dir: &Path) -> bool {
249 dir.join(".git").is_dir() && origin_url(dir).is_some()
250}
251
252fn origin_url(dir: &Path) -> Option<String> {
253 run_git_ok(
254 Some(dir),
255 &["remote", "get-url", "origin"],
256 READ_TIMEOUT,
257 "git remote get-url",
258 )
259 .ok()
260}
261
262fn require_installed(dir: &Path) -> Result<(), String> {
263 if !dir.join(".git").is_dir() {
264 return Err(format!(
265 "{} was not installed by plugin add (no .git directory). \
266 Hand-copied plugins are removed by deleting their directory.",
267 dir.display()
268 ));
269 }
270 if origin_url(dir).is_none() {
271 return Err(format!(
272 "{} has .git but no `origin` remote, so it was not installed by \
273 plugin add. Remove its directory by hand.",
274 dir.display()
275 ));
276 }
277 Ok(())
278}
279
280fn safe_segment(id: &str) -> bool {
285 !id.is_empty() && !id.starts_with('.') && !id.contains(['/', '\\']) && !id.contains('\0')
286}
287
288fn is_git_url(url: &str) -> bool {
292 let url = url.trim();
293 if url.is_empty() {
294 return false;
295 }
296 if let Some((scheme, rest)) = url.split_once("://") {
297 return !scheme.is_empty()
298 && scheme
299 .chars()
300 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
301 && !rest.is_empty();
302 }
303 match url.split_once(':') {
304 Some((host, rest)) => !host.is_empty() && !host.contains('/') && !rest.is_empty(),
307 None => false,
308 }
309}
310
311fn now_millis() -> u128 {
312 std::time::SystemTime::now()
313 .duration_since(std::time::UNIX_EPOCH)
314 .unwrap_or_default()
315 .as_millis()
316}
317
318#[derive(Debug)]
320struct BoundedOutput {
321 status: ExitStatus,
322 stdout: String,
323 stderr: String,
324}
325
326fn output_with_timeout(cmd: &mut Command, timeout: Duration) -> Result<BoundedOutput, String> {
334 let mut child = cmd
335 .stdin(Stdio::null())
336 .stdout(Stdio::piped())
337 .stderr(Stdio::piped())
338 .spawn()
339 .map_err(|e| format!("failed to spawn: {e}"))?;
340
341 let mut child_stdout = child
342 .stdout
343 .take()
344 .ok_or_else(|| "stdout pipe unavailable".to_string())?;
345 let mut child_stderr = child
346 .stderr
347 .take()
348 .ok_or_else(|| "stderr pipe unavailable".to_string())?;
349 let stdout_reader = std::thread::spawn(move || {
350 let mut buf = String::new();
351 let _ = child_stdout.read_to_string(&mut buf);
352 buf
353 });
354 let stderr_reader = std::thread::spawn(move || {
355 let mut buf = String::new();
356 let _ = child_stderr.read_to_string(&mut buf);
357 buf
358 });
359
360 let deadline = Instant::now() + timeout;
361 let status = loop {
362 match child.try_wait() {
363 Ok(Some(status)) => break status,
364 Ok(None) => {
365 if Instant::now() >= deadline {
366 let _ = child.kill();
367 let _ = child.wait();
368 return Err(format!("timed out after {:.1}s", timeout.as_secs_f64()));
369 }
370 std::thread::sleep(POLL_INTERVAL);
371 }
372 Err(e) => return Err(format!("failed while waiting: {e}")),
373 }
374 };
375
376 Ok(BoundedOutput {
377 status,
378 stdout: stdout_reader
379 .join()
380 .map_err(|_| "stdout reader thread panicked".to_string())?,
381 stderr: stderr_reader
382 .join()
383 .map_err(|_| "stderr reader thread panicked".to_string())?,
384 })
385}
386
387fn git(cwd: Option<&Path>, args: &[&str]) -> Command {
389 let mut cmd = Command::new("git");
390 cmd.env("GIT_TERMINAL_PROMPT", "0");
391 if let Some(dir) = cwd {
392 cmd.current_dir(dir);
393 }
394 cmd.args(args);
395 cmd
396}
397
398fn run_git(cwd: Option<&Path>, args: &[&str], timeout: Duration) -> Result<BoundedOutput, String> {
399 output_with_timeout(&mut git(cwd, args), timeout)
400}
401
402fn run_git_ok(
406 cwd: Option<&Path>,
407 args: &[&str],
408 timeout: Duration,
409 what: &str,
410) -> Result<String, String> {
411 let out = run_git(cwd, args, timeout)?;
412 if !out.status.success() {
413 let stderr = out.stderr.trim();
414 return Err(format!(
415 "{what} failed: {}{}",
416 out.status,
417 if stderr.is_empty() {
418 String::new()
419 } else {
420 format!(": {stderr}")
421 }
422 ));
423 }
424 Ok(out.stdout.trim().to_string())
425}
426
427#[cfg(test)]
431mod tests {
432 use super::*;
433 use crate::manifest::discover_plugins;
434 use std::fs;
435 use std::path::PathBuf;
436 use tempfile::TempDir;
437
438 const PANEL_MANIFEST: &str = r#"{
441 "schemaVersion": 1,
442 "id": "{id}",
443 "name": "Test Panel",
444 "version": "0.1.0",
445 "kinds": ["panel"],
446 "entryPoints": { "panel": { "command": "panel.sh", "args": [] } }
447 }"#;
448
449 const BOGUS_KIND_MANIFEST: &str = r#"{
451 "schemaVersion": 1,
452 "id": "{id}",
453 "name": "Bogus",
454 "version": "0.1.0",
455 "kinds": ["not-a-kind"],
456 "entryPoints": { "panel": { "command": "panel.sh", "args": [] } }
457 }"#;
458
459 fn write_exec_file(path: &Path) {
460 fs::write(path, "#!/bin/sh\nsleep 30\n").expect("write entry");
461 #[cfg(unix)]
462 {
463 use std::os::unix::fs::PermissionsExt;
464 let mut perms = fs::metadata(path).expect("entry metadata").permissions();
465 perms.set_mode(0o755);
466 fs::set_permissions(path, perms).expect("chmod entry");
467 }
468 }
469
470 fn git_ok(cwd: &Path, args: &[&str]) -> String {
471 run_git_ok(Some(cwd), args, READ_TIMEOUT, "git").expect("git fixture command")
472 }
473
474 fn git_commit(cwd: &Path, msg: &str) {
475 git_ok(
476 cwd,
477 &[
478 "-c",
479 "user.name=par-term-test",
480 "-c",
481 "user.email=test@par-term.test",
482 "commit",
483 "--quiet",
484 "-m",
485 msg,
486 ],
487 );
488 }
489
490 fn source_repo(tmp: &Path, id: &str, manifest: &str) -> PathBuf {
493 let src = tmp.join("src");
494 fs::create_dir_all(&src).expect("create src");
495 fs::write(src.join("manifest.json"), manifest.replace("{id}", id)).expect("write manifest");
496 write_exec_file(&src.join("panel.sh"));
497 git_ok(&src, &["init", "--quiet"]);
498 git_ok(&src, &["add", "-A"]);
499 git_commit(&src, "initial plugin");
500 src
501 }
502
503 fn file_url(path: &Path) -> String {
504 format!("file://{}", path.display())
505 }
506
507 struct Fixture {
510 _tmp: TempDir,
511 src: PathBuf,
512 root: PathBuf,
513 }
514
515 fn fixture(id: &str, manifest: &str) -> Fixture {
516 let tmp = TempDir::new().expect("temp dir");
517 let src = source_repo(tmp.path(), id, manifest);
518 let root = tmp.path().join("plugins");
519 Fixture {
520 _tmp: tmp,
521 src,
522 root,
523 }
524 }
525
526 fn installed_plugin(fx: &Fixture) -> PathBuf {
527 fx.root.join("com.test.panel")
528 }
529
530 #[test]
531 fn add_clones_into_place_and_lands_discoverable() {
532 let fx = fixture("com.test.panel", PANEL_MANIFEST);
533 let added = add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
534 assert_eq!(added.manifest.id, "com.test.panel");
535 assert!(installed_plugin(&fx).is_dir());
537 let (plugins, warnings) = discover_plugins(&fx.root);
538 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
539 assert_eq!(plugins.len(), 1);
540 assert_eq!(plugins[0].manifest.id, "com.test.panel");
541 assert!(is_git_installed(&installed_plugin(&fx)));
542 }
543
544 #[test]
545 fn add_refuses_an_existing_target_directory() {
546 let fx = fixture("com.test.panel", PANEL_MANIFEST);
547 let existing = installed_plugin(&fx);
548 fs::create_dir_all(&existing).expect("pre-create target");
549 fs::write(existing.join("keep.txt"), "hand-copied").expect("seed target");
550 let err = add(&fx.root, &file_url(&fx.src)).expect_err("must refuse");
551 assert!(
552 err.contains("already exists"),
553 "error should name the refusal: {err}"
554 );
555 assert!(
556 err.contains(existing.display().to_string().as_str()),
557 "error should name the target directory: {err}"
558 );
559 assert_eq!(
561 fs::read_to_string(existing.join("keep.txt")).unwrap(),
562 "hand-copied"
563 );
564 }
565
566 #[test]
567 fn add_refuses_non_url_sources() {
568 let fx = fixture("com.test.panel", PANEL_MANIFEST);
569 for bad in ["/absolute/path", "relative/dir", "~/plugin", "."] {
570 let err = add(&fx.root, bad).expect_err("local paths must be refused");
571 assert!(
572 err.contains("not a git URL"),
573 "error for `{bad}` should state the refusal: {err}"
574 );
575 }
576 }
577
578 #[test]
579 fn add_surfaces_a_clone_failure_cleanly() {
580 let fx = fixture("com.test.panel", PANEL_MANIFEST);
581 let nowhere = fx._tmp.path().join("does-not-exist");
582 let err = add(&fx.root, &file_url(&nowhere)).expect_err("clone must fail");
583 assert!(err.contains("git clone failed"), "unexpected error: {err}");
584 assert!(!installed_plugin(&fx).exists());
585 }
586
587 #[test]
588 fn add_removes_everything_it_staged_when_validation_fails() {
589 let fx = fixture("com.test.bad", BOGUS_KIND_MANIFEST);
590 let err = add(&fx.root, &file_url(&fx.src)).expect_err("must refuse");
591 assert!(
592 err.contains("failed validation"),
593 "error should carry the validation reason: {err}"
594 );
595 assert!(
596 err.contains("unknown kinds"),
597 "error should carry the validation reason: {err}"
598 );
599 assert!(!fx.root.join("com.test.bad").exists());
601 let leftovers: Vec<_> = fs::read_dir(&fx.root)
602 .expect("plugins root exists")
603 .filter_map(|e| e.ok())
604 .map(|e| e.file_name().to_string_lossy().into_owned())
605 .collect();
606 assert!(leftovers.is_empty(), "leftovers: {leftovers:?}");
607 }
608
609 #[test]
610 fn update_fetch_reports_a_diff_then_applies_a_fast_forward() {
611 let fx = fixture("com.test.panel", PANEL_MANIFEST);
612 add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
613
614 fs::write(fx.src.join("feature.txt"), "new upstream feature").expect("write feature");
616 git_ok(&fx.src, &["add", "-A"]);
617 git_commit(&fx.src, "add feature");
618
619 let dir = installed_plugin(&fx);
620 let preview = update_fetch(&dir).expect("fetch should succeed");
621 assert!(!preview.up_to_date);
622 assert_ne!(preview.current, preview.incoming);
623 assert!(
624 preview.diff.contains("feature.txt"),
625 "diff should name the changed file: {}",
626 preview.diff
627 );
628 assert!(!dir.join("feature.txt").exists(), "not applied yet");
629
630 let new_head = update_apply(&dir).expect("apply should fast-forward");
631 assert_eq!(new_head, preview.incoming);
632 assert!(dir.join("feature.txt").exists());
633 }
634
635 #[test]
636 fn update_reports_up_to_date_when_nothing_new() {
637 let fx = fixture("com.test.panel", PANEL_MANIFEST);
638 add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
639 let preview = update_fetch(&installed_plugin(&fx)).expect("fetch should succeed");
640 assert!(preview.up_to_date);
641 assert_eq!(preview.current, preview.incoming);
642 }
643
644 #[test]
645 fn update_refuses_a_history_rewrite_without_resetting() {
646 let fx = fixture("com.test.panel", PANEL_MANIFEST);
647 add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
648
649 git_ok(
651 &fx.src,
652 &[
653 "-c",
654 "user.name=par-term-test",
655 "-c",
656 "user.email=test@par-term.test",
657 "commit",
658 "--quiet",
659 "--amend",
660 "-m",
661 "rewritten history",
662 ],
663 );
664
665 let dir = installed_plugin(&fx);
666 let preview = update_fetch(&dir).expect("fetch still succeeds");
667 assert!(!preview.up_to_date);
668 let err = update_apply(&dir).expect_err("must refuse a non-fast-forward");
669 assert!(
670 err.contains("nothing was changed"),
671 "unexpected error: {err}"
672 );
673 assert!(
674 err.contains("remove and re-add"),
675 "error should name the remedy: {err}"
676 );
677 assert!(dir.join("manifest.json").exists());
679 let head = git_ok(&dir, &["rev-parse", "--short", "HEAD"]);
680 assert_eq!(head, preview.current);
681 }
682
683 #[test]
684 fn remove_deletes_a_git_installed_plugin() {
685 let fx = fixture("com.test.panel", PANEL_MANIFEST);
686 add(&fx.root, &file_url(&fx.src)).expect("add should succeed");
687 let dir = installed_plugin(&fx);
688 assert!(dir.is_dir());
689 remove(&fx.root, "com.test.panel").expect("remove should succeed");
690 assert!(!dir.exists());
691 let (plugins, _) = discover_plugins(&fx.root);
692 assert!(plugins.is_empty());
693 }
694
695 #[test]
696 fn remove_refuses_a_hand_copied_directory() {
697 let fx = fixture("com.test.panel", PANEL_MANIFEST);
698 let dir = installed_plugin(&fx);
699 fs::create_dir_all(&dir).expect("create dir");
700 fs::write(
701 dir.join("manifest.json"),
702 PANEL_MANIFEST.replace("{id}", "com.test.panel"),
703 )
704 .expect("write manifest");
705 write_exec_file(&dir.join("panel.sh"));
706
707 let err = remove(&fx.root, "com.test.panel").expect_err("must refuse");
708 assert!(
709 err.contains("not installed by plugin add"),
710 "unexpected error: {err}"
711 );
712 assert!(dir.is_dir(), "the hand-copied plugin must survive");
713 }
714
715 #[test]
716 fn remove_refuses_a_bare_git_init_without_a_remote() {
717 let fx = fixture("com.test.panel", PANEL_MANIFEST);
718 let dir = installed_plugin(&fx);
719 fs::create_dir_all(&dir).expect("create dir");
720 git_ok(&dir, &["init", "--quiet"]);
721
722 let err = remove(&fx.root, "com.test.panel").expect_err("must refuse");
723 assert!(
724 err.contains("no `origin` remote"),
725 "unexpected error: {err}"
726 );
727 assert!(dir.is_dir(), "the local repo must survive");
728 }
729
730 #[test]
731 fn remove_names_a_missing_plugin() {
732 let fx = fixture("com.test.panel", PANEL_MANIFEST);
733 let err = remove(&fx.root, "com.test.nope").expect_err("must refuse");
734 assert!(
735 err.contains("no plugin directory"),
736 "unexpected error: {err}"
737 );
738 }
739
740 #[test]
741 fn is_git_installed_is_false_for_plain_directories() {
742 let fx = fixture("com.test.panel", PANEL_MANIFEST);
743 assert!(!is_git_installed(&fx.root));
744 assert!(!is_git_installed(&fx.src)); }
746
747 #[cfg(unix)]
748 #[test]
749 fn a_command_that_outlives_its_deadline_is_killed() {
750 let mut cmd = Command::new("sleep");
751 cmd.arg("30");
752 let err = output_with_timeout(&mut cmd, Duration::from_millis(500))
753 .expect_err("sleep 30 must exceed a 500ms deadline");
754 assert!(err.contains("timed out"), "unexpected error: {err}");
755 }
756
757 #[cfg(unix)]
758 #[test]
759 fn git_invocations_carry_no_terminal_prompt() {
760 let tmp = TempDir::new().expect("temp dir");
765 let out = run_git_ok(
766 Some(tmp.path()),
767 &[
768 "-c",
769 r#"alias.prompt-check=!sh -c 'printf %s "$GIT_TERMINAL_PROMPT"'"#,
770 "prompt-check",
771 ],
772 READ_TIMEOUT,
773 "git alias",
774 )
775 .expect("alias should run");
776 assert_eq!(out, "0");
777 }
778}