1use std::path::PathBuf;
17use std::sync::Arc;
18use std::sync::atomic::Ordering;
19use std::time::Duration;
20
21use forge_agent::userdata::plugins::cli::{self, UpdateRunner};
22use forge_primitives::plugins::{
23 InstalledPluginEntry, MarketplaceEntry, MarketplaceSourceEntry, PluginRunRowStatus,
24 PluginUpdateRun, PluginUpdateRunRow, PluginUpdateTrigger, PluginsCliActionSuccess,
25 PluginsInventorySnapshot, update_availability,
26};
27
28use crate::protocol::{DispatchError, SessionUpdate};
29use crate::workspace::Workspace;
30
31const PLUGIN_CALL_TIMEOUT: Duration = Duration::from_secs(180);
34
35type PluginCliFut<T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>>;
37type RunAndRefresh = Arc<
38 dyn Fn(String, Vec<String>) -> PluginCliFut<Result<(PluginsInventorySnapshot, PathBuf), String>>
39 + Send
40 + Sync,
41>;
42type RefreshFn = Arc<
43 dyn Fn(String) -> PluginCliFut<Result<(PluginsInventorySnapshot, PathBuf), String>>
44 + Send
45 + Sync,
46>;
47type ScanFn = Arc<dyn Fn() -> Option<PluginsInventorySnapshot> + Send + Sync>;
48
49#[derive(Clone)]
52pub(crate) struct PluginCli {
53 pub run_and_refresh: RunAndRefresh,
56 pub refresh: RefreshFn,
58 pub runner: UpdateRunner,
60 pub scan: ScanFn,
63}
64
65impl PluginCli {
66 pub(crate) fn real() -> Self {
67 Self {
68 run_and_refresh: Arc::new(|cwd, args| {
69 Box::pin(cli::run_cli_command_and_refresh(cwd, None, args))
70 }),
71 refresh: Arc::new(|cwd| Box::pin(cli::refresh_inventory(cwd, None))),
72 runner: UpdateRunner::real(),
73 scan: Arc::new(cli::inventory_from_disk_blocking),
74 }
75 }
76}
77
78struct PluginActionGuard(Arc<Workspace>);
82
83impl Drop for PluginActionGuard {
84 fn drop(&mut self) {
85 self.0.plugin_action_running.store(false, Ordering::Release);
86 }
87}
88
89fn plugin_call_cwd(scope: &str, project_path: Option<&str>, home: &str) -> String {
98 match scope {
99 "local" | "project" => {
100 project_path.filter(|path| !path.is_empty()).unwrap_or(home).to_owned()
101 }
102 _ => home.to_owned(),
103 }
104}
105
106async fn scan_inventory(cli: &PluginCli) -> Result<PluginsInventorySnapshot, String> {
114 let scan = Arc::clone(&cli.scan);
115 tokio::task::spawn_blocking(move || scan())
116 .await
117 .map_err(|error| format!("the plugin disk scan failed to join: {error}"))?
118 .ok_or_else(|| {
119 "no plugins root resolves (no CLAUDE_CONFIG_DIR or HOME); the inventory cannot be read"
120 .to_owned()
121 })
122}
123
124fn fail_action(ws: &Arc<Workspace>, cwd: String, message: String, dispatch_id: Option<u64>) {
127 tracing::warn!(
128 event_name = "plugin_cli_action_failed",
129 cwd = %cwd,
130 error = %message,
131 "a plugin action failed",
132 );
133 let _ = ws.update_sender().send(SessionUpdate::PluginsCliActionFailed {
134 cwd_raw: cwd,
135 message,
136 dispatch_id,
137 });
138}
139
140async fn run_action(
142 ws: &Arc<Workspace>,
143 cli: &PluginCli,
144 cwd: String,
145 args: Vec<String>,
146 message: String,
147 dispatch_id: Option<u64>,
148) {
149 match (cli.run_and_refresh)(cwd.clone(), args).await {
150 Ok((snapshot, claude_path)) => {
151 let _ = ws.update_sender().send(SessionUpdate::PluginsCliActionSucceeded {
152 cwd_raw: cwd,
153 result: PluginsCliActionSuccess { snapshot, message, claude_path },
154 dispatch_id,
155 });
156 }
157 Err(message) => fail_action(ws, cwd, message, dispatch_id),
158 }
159}
160
161fn stale_rows(
165 installed: &[InstalledPluginEntry],
166 marketplace: &[MarketplaceEntry],
167 home: &str,
168) -> Vec<PluginUpdateRunRow> {
169 update_availability(installed, marketplace)
170 .into_iter()
171 .map(|stale| {
172 let project_path = installed
173 .iter()
174 .find(|entry| entry.id == stale.plugin_id && entry.scope == stale.scope)
175 .and_then(|entry| entry.project_path.as_deref());
176 let mut row = PluginUpdateRunRow::queued(
177 stale.plugin_id,
178 stale.scope.clone(),
179 plugin_call_cwd(&stale.scope, project_path, home),
180 stale.installed_version,
181 );
182 row.available_version = stale.available_version;
183 row
184 })
185 .collect()
186}
187
188impl Workspace {
189 pub fn plugin_update_run(&self) -> Option<PluginUpdateRun> {
192 self.plugin_run.lock().clone()
193 }
194
195 pub async fn plugins_inventory(&self) -> Option<PluginsInventorySnapshot> {
199 let cli = self.plugin_cli();
200 tokio::task::spawn_blocking(move || (cli.scan)()).await.ok().flatten()
201 }
202
203 #[cfg(test)]
204 pub(crate) fn set_plugin_cli(&self, cli: PluginCli) {
205 *self.plugin_cli.lock() = cli;
206 }
207
208 #[cfg(any(test, feature = "testing"))]
213 pub fn set_plugin_scan_for_test(
214 &self,
215 scan: impl Fn() -> Option<PluginsInventorySnapshot> + Send + Sync + 'static,
216 ) {
217 self.plugin_cli.lock().scan = Arc::new(scan);
218 }
219
220 fn plugin_cli(&self) -> PluginCli {
221 self.plugin_cli.lock().clone()
222 }
223
224 fn begin_plugin_action(&self) -> Result<(), DispatchError> {
226 if self.plugin_action_running.swap(true, Ordering::AcqRel) {
227 return Err(DispatchError::PluginActionRunning);
228 }
229 Ok(())
230 }
231
232 fn plugin_home() -> Result<String, DispatchError> {
236 dirs::home_dir()
237 .map(|home| home.to_string_lossy().into_owned())
238 .ok_or(DispatchError::PluginHomeUnavailable)
239 }
240
241 fn declared_project_path(&self, name: &str) -> Result<String, DispatchError> {
243 self.list_projects()
244 .into_iter()
245 .find(|project| project.name == name)
246 .map(|project| project.path.to_string_lossy().into_owned())
247 .ok_or_else(|| DispatchError::ProjectNotFound { name: name.to_owned() })
248 }
249
250 pub(crate) fn start_plugin_install(
252 self: &Arc<Self>,
253 plugin_id: String,
254 scope: String,
255 project: Option<&str>,
256 ) -> Result<(), DispatchError> {
257 let home = Self::plugin_home()?;
258 let cwd = match scope.as_str() {
259 "local" | "project" => match project {
260 Some(name) => self.declared_project_path(name)?,
261 None => {
262 return Err(DispatchError::PluginProjectRequired);
263 }
264 },
265 _ => home,
266 };
267 let message = match scope.as_str() {
268 "project" | "local" => format!("Installed {plugin_id} for {scope} scope"),
269 _ => format!("Installed {plugin_id} for user scope"),
270 };
271 let args =
272 vec!["plugin".to_owned(), "install".to_owned(), plugin_id, "--scope".to_owned(), scope];
273 self.start_plugin_cli_action(cwd, args, message, None)
276 }
277
278 pub(crate) fn start_plugin_update(
286 self: &Arc<Self>,
287 plugin_id: String,
288 scope: String,
289 ) -> Result<(), DispatchError> {
290 let home = Self::plugin_home()?;
291 self.begin_plugin_action()?;
292 let ws = Arc::clone(self);
293 tokio::spawn(async move {
294 let _guard = PluginActionGuard(Arc::clone(&ws));
295 let cli = ws.plugin_cli();
296 let snapshot = match scan_inventory(&cli).await {
297 Ok(snapshot) => snapshot,
298 Err(message) => return fail_action(&ws, home, message, None),
299 };
300 let Some(entry) = snapshot
301 .installed
302 .iter()
303 .find(|entry| entry.id == plugin_id && entry.scope == scope)
304 .cloned()
305 else {
306 return fail_action(
307 &ws,
308 home,
309 format!(
310 "plugin '{plugin_id}' is not installed in {scope} scope; refresh the page"
311 ),
312 None,
313 );
314 };
315 let cwd = plugin_call_cwd(&scope, entry.project_path.as_deref(), &home);
316 let message = format!("Updated {plugin_id} in {scope} scope");
317 let args = vec![
318 "plugin".to_owned(),
319 "update".to_owned(),
320 plugin_id,
321 "--scope".to_owned(),
322 scope,
323 ];
324 run_action(&ws, &cli, cwd, args, message, None).await;
325 });
326 Ok(())
327 }
328
329 fn start_plugin_cli_action(
332 self: &Arc<Self>,
333 cwd: String,
334 args: Vec<String>,
335 message: String,
336 dispatch_id: Option<u64>,
337 ) -> Result<(), DispatchError> {
338 self.begin_plugin_action()?;
339 let ws = Arc::clone(self);
340 tokio::spawn(async move {
341 let _guard = PluginActionGuard(Arc::clone(&ws));
342 let cli = ws.plugin_cli();
343 run_action(&ws, &cli, cwd, args, message, dispatch_id).await;
344 });
345 Ok(())
346 }
347
348 pub(crate) fn start_plugin_update_all(self: &Arc<Self>) -> Result<(), DispatchError> {
357 let home = Self::plugin_home()?;
358 self.begin_plugin_action()?;
359 let ws = Arc::clone(self);
360 tokio::spawn(async move {
361 let _guard = PluginActionGuard(Arc::clone(&ws));
362 let cli = ws.plugin_cli();
363 let snapshot = match scan_inventory(&cli).await {
364 Ok(snapshot) => snapshot,
365 Err(message) => return fail_action(&ws, home, message, None),
366 };
367 let rows = stale_rows(&snapshot.installed, &snapshot.marketplace, &home);
368 if rows.is_empty() {
369 let run =
370 PluginUpdateRun { trigger: PluginUpdateTrigger::Manual, finished: true, rows };
371 *ws.plugin_run.lock() = Some(run.clone());
372 let _ = ws.update_sender().send(SessionUpdate::PluginsUpdateRunFinished {
373 cwd_raw: home,
374 run,
375 snapshot: Some(snapshot),
376 claude_path: None,
377 });
378 return;
379 }
380 let run =
381 PluginUpdateRun { trigger: PluginUpdateTrigger::Manual, finished: false, rows };
382 *ws.plugin_run.lock() = Some(run.clone());
383 let marketplaces = snapshot.marketplaces;
384 run_update_batch(ws, cli, home, marketplaces, run).await;
385 });
386 Ok(())
387 }
388
389 pub(crate) fn start_plugin_check(self: &Arc<Self>) -> Result<(), DispatchError> {
392 let home = Self::plugin_home()?;
393 self.begin_plugin_action()?;
394 let ws = Arc::clone(self);
395 tokio::spawn(async move {
396 let _guard = PluginActionGuard(Arc::clone(&ws));
397 let cli = ws.plugin_cli();
398 match (cli.refresh)(home.clone()).await {
399 Ok((snapshot, claude_path)) => {
400 let rows = update_availability(&snapshot.installed, &snapshot.marketplace)
401 .into_iter()
402 .map(|stale| PluginUpdateRunRow {
403 plugin_id: stale.plugin_id,
404 scope: stale.scope,
405 cwd_raw: String::new(),
406 marketplace: stale.marketplace,
407 status: PluginRunRowStatus::UpdateAvailable,
408 installed_version: stale.installed_version,
409 available_version: stale.available_version,
410 detail: None,
411 })
412 .collect();
413 let run = PluginUpdateRun {
414 trigger: PluginUpdateTrigger::Manual,
415 finished: true,
416 rows,
417 };
418 *ws.plugin_run.lock() = Some(run.clone());
419 let _ = ws.update_sender().send(SessionUpdate::PluginsUpdateRunFinished {
420 cwd_raw: home,
421 run,
422 snapshot: Some(snapshot),
423 claude_path: Some(claude_path),
424 });
425 }
426 Err(message) => {
427 tracing::warn!(
428 event_name = "plugin_update_check_failed",
429 error = %message,
430 "the plugin update check could not refresh the inventory",
431 );
432 let _ = ws.update_sender().send(SessionUpdate::PluginsInventoryRefreshFailed {
433 cwd_raw: home,
434 message,
435 trigger: PluginUpdateTrigger::Manual,
436 });
437 }
438 }
439 });
440 Ok(())
441 }
442
443 pub(crate) fn start_marketplace_add(
445 self: &Arc<Self>,
446 source: String,
447 dispatch_id: Option<u64>,
448 ) -> Result<(), DispatchError> {
449 let home = Self::plugin_home()?;
450 let message = format!("Added marketplace {source}");
451 let args = vec![
452 "plugin".to_owned(),
453 "marketplace".to_owned(),
454 "add".to_owned(),
455 source,
456 "--scope".to_owned(),
457 "user".to_owned(),
458 ];
459 self.start_plugin_cli_action(home, args, message, dispatch_id)
460 }
461
462 pub(crate) fn start_marketplace_repair(
467 self: &Arc<Self>,
468 name: String,
469 ) -> Result<(), DispatchError> {
470 let home = Self::plugin_home()?;
471 self.begin_plugin_action()?;
472 let ws = Arc::clone(self);
473 tokio::spawn(async move {
474 let _guard = PluginActionGuard(Arc::clone(&ws));
475 let cli = ws.plugin_cli();
476 let snapshot = match scan_inventory(&cli).await {
477 Ok(snapshot) => snapshot,
478 Err(message) => return fail_action(&ws, home, message, None),
479 };
480 let Some(marketplace) =
481 snapshot.marketplaces.iter().find(|marketplace| marketplace.name == name).cloned()
482 else {
483 return fail_action(
484 &ws,
485 home,
486 format!("no marketplace named '{name}' is configured; refresh the page"),
487 None,
488 );
489 };
490 let readd_source = match marketplace.source.as_deref() {
491 Some("directory") => marketplace
492 .install_location
493 .clone()
494 .or_else(|| marketplace.repo.clone())
495 .unwrap_or_else(|| name.clone()),
496 _ => marketplace.repo.clone().unwrap_or_else(|| name.clone()),
497 };
498 let remove = vec![
499 "plugin".to_owned(),
500 "marketplace".to_owned(),
501 "remove".to_owned(),
502 name.clone(),
503 ];
504 let add = vec![
505 "plugin".to_owned(),
506 "marketplace".to_owned(),
507 "add".to_owned(),
508 readd_source,
509 "--scope".to_owned(),
510 "user".to_owned(),
511 ];
512 let outcome = match (cli.run_and_refresh)(home.clone(), remove).await {
513 Ok(_) => (cli.run_and_refresh)(home.clone(), add).await,
514 Err(message) => Err(message),
515 };
516 match outcome {
517 Ok((snapshot, claude_path)) => {
518 let _ = ws.update_sender().send(SessionUpdate::PluginsCliActionSucceeded {
519 cwd_raw: home,
520 result: PluginsCliActionSuccess {
521 snapshot,
522 message: format!("Repaired {name} marketplace"),
523 claude_path,
524 },
525 dispatch_id: None,
526 });
527 }
528 Err(message) => fail_action(&ws, home, message, None),
529 }
530 });
531 Ok(())
532 }
533}
534
535async fn run_update_batch(
538 ws: Arc<Workspace>,
539 cli: PluginCli,
540 home: String,
541 marketplaces: Vec<MarketplaceSourceEntry>,
542 run: PluginUpdateRun,
543) {
544 let mut marketplace_refs = std::collections::HashMap::new();
548 for marketplace in marketplaces {
549 if let Some(location) = marketplace.install_location
550 && let Some(head) = cli::marketplace_head(location).await
551 {
552 marketplace_refs.insert(marketplace.name, head);
553 }
554 }
555 let plan = cli::BatchPlan {
556 cwd_context: home.clone(),
557 claude_path: None,
558 call_timeout: PLUGIN_CALL_TIMEOUT,
559 marketplace_refs,
560 run,
561 };
562 let progress_ws = Arc::clone(&ws);
563 let progress_home = home.clone();
564 let out = cli::execute_update_batch(plan, cli.runner, move |run| {
565 *progress_ws.plugin_run.lock() = Some(run.clone());
566 let _ = progress_ws
567 .update_sender()
568 .send(SessionUpdate::PluginsUpdateRunProgress { cwd_raw: progress_home.clone(), run });
569 })
570 .await;
571 if !out.records.is_empty() {
572 ws.record_plugin_updates(&out.records);
573 }
574 *ws.plugin_run.lock() = Some(out.run.clone());
575 let _ = ws.update_sender().send(SessionUpdate::PluginsUpdateRunFinished {
576 cwd_raw: home,
577 run: out.run,
578 snapshot: out.snapshot,
579 claude_path: out.claude_path,
580 });
581}
582
583#[cfg(test)]
584mod tests {
585 use std::sync::{Arc, Mutex};
586
587 use forge_primitives::plugins::{
588 InstalledPluginEntry, MarketplaceSourceEntry, PluginCapability, PluginsInventorySnapshot,
589 };
590
591 use super::*;
592 use crate::SeatUpdate;
593 use crate::protocol::Command;
594 use crate::workspace::Workspace;
595
596 #[derive(Debug, Clone, PartialEq)]
598 struct CliCall {
599 cwd: String,
600 args: Vec<String>,
601 }
602
603 #[derive(Default)]
605 struct FakeCli {
606 run: Mutex<Vec<CliCall>>,
607 refresh: Mutex<Vec<CliCall>>,
608 updates: Mutex<Vec<CliCall>>,
609 }
610
611 fn marketplace(
612 name: &str,
613 source: &str,
614 repo: Option<&str>,
615 location: Option<&str>,
616 ) -> MarketplaceSourceEntry {
617 MarketplaceSourceEntry {
618 name: name.to_owned(),
619 source: Some(source.to_owned()),
620 repo: repo.map(str::to_owned),
621 install_location: location.map(str::to_owned),
622 }
623 }
624
625 fn installed(
626 id: &str,
627 version: &str,
628 scope: &str,
629 project_path: Option<&str>,
630 ) -> InstalledPluginEntry {
631 InstalledPluginEntry {
632 id: id.to_owned(),
633 version: Some(version.to_owned()),
634 scope: scope.to_owned(),
635 enabled: true,
636 installed_at: None,
637 last_updated: None,
638 project_path: project_path.map(str::to_owned),
639 capability: PluginCapability::Skill,
640 }
641 }
642
643 fn marketplace_entry(id: &str, version: &str) -> MarketplaceEntry {
644 MarketplaceEntry {
645 plugin_id: id.to_owned(),
646 name: id.split('@').next().unwrap_or(id).to_owned(),
647 description: None,
648 marketplace_name: None,
649 version: Some(version.to_owned()),
650 install_count: None,
651 source: None,
652 }
653 }
654
655 fn fake_cli(
658 scan: &PluginsInventorySnapshot,
659 refreshed: &PluginsInventorySnapshot,
660 ) -> (PluginCli, Arc<FakeCli>) {
661 let calls = Arc::new(FakeCli::default());
662 let run = {
663 let calls = Arc::clone(&calls);
664 let refreshed = refreshed.clone();
665 Arc::new(move |cwd: String, args: Vec<String>| {
666 calls.run.lock().expect("lock").push(CliCall { cwd, args });
667 let refreshed = refreshed.clone();
668 Box::pin(async move { Ok((refreshed, PathBuf::from("/fake/claude"))) })
669 as PluginCliFut<Result<(PluginsInventorySnapshot, PathBuf), String>>
670 })
671 };
672 let refresh = {
673 let calls = Arc::clone(&calls);
674 let refreshed = refreshed.clone();
675 Arc::new(move |cwd: String| {
676 calls.refresh.lock().expect("lock").push(CliCall { cwd, args: vec![] });
677 let refreshed = refreshed.clone();
678 Box::pin(async move { Ok((refreshed, PathBuf::from("/fake/claude"))) })
679 as PluginCliFut<Result<(PluginsInventorySnapshot, PathBuf), String>>
680 })
681 };
682 let updates = {
683 let calls = Arc::clone(&calls);
684 Arc::new(move |_cached: Option<PathBuf>, cwd: String, args: Vec<String>| {
685 calls.updates.lock().expect("lock").push(CliCall { cwd, args });
686 Box::pin(async move {
687 Ok((PathBuf::from("/fake/claude"), "the CLI said so".to_owned()))
688 })
689 as forge_agent::userdata::plugins::cli::RunnerFut<
690 Result<(PathBuf, String), String>,
691 >
692 })
693 };
694 let batch_refresh = {
695 let refreshed = refreshed.clone();
696 std::sync::Arc::new(move |_cached: Option<PathBuf>, _cwd: String| {
697 let refreshed = refreshed.clone();
698 Box::pin(async move { Ok((refreshed, PathBuf::from("/fake/claude"))) })
699 as forge_agent::userdata::plugins::cli::RunnerFut<
700 Result<(PluginsInventorySnapshot, PathBuf), String>,
701 >
702 })
703 };
704 let scan_snapshot = scan.clone();
705 let cli = PluginCli {
706 run_and_refresh: run,
707 refresh,
708 runner: UpdateRunner { run_update: updates, refresh: batch_refresh },
709 scan: Arc::new(move || Some(scan_snapshot.clone())),
710 };
711 (cli, calls)
712 }
713
714 async fn next_update(
716 rx: &mut tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
717 ) -> SessionUpdate {
718 std::sync::Arc::unwrap_or_clone(
719 tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
720 .await
721 .expect("an update lands")
722 .expect("the stream is open")
723 .update,
724 )
725 }
726
727 fn home() -> String {
729 dirs::home_dir().expect("home").to_string_lossy().into_owned()
730 }
731
732 #[tokio::test]
737 async fn a_project_install_runs_in_the_declared_project() {
738 let dir = tempfile::tempdir().expect("tempdir");
739 let forge = dir.path().join("forge");
740 std::fs::create_dir_all(&forge).expect("forge/");
741 std::fs::write(
742 forge.join("forge.toml"),
743 "\
744[[orgs]]
745name = \"Busytools\"
746accounts = [\"acct-a\"]
747
748[[orgs.projects]]
749name = \"proj\"
750path = \"/tmp/plugin-cwd-probe\"
751model = \"claude-sonnet-5\"
752
753[[accounts]]
754display_name = \"acct-a\"
755token = \"t\"
756models = [\"claude-sonnet-5\"]
757provider = \"anthropic\"
758",
759 )
760 .expect("write forge.toml");
761 let ws = Arc::new(Workspace::new_for_test(dir.path().to_owned()).expect("workspace"));
762 let (cli, calls) =
763 fake_cli(&PluginsInventorySnapshot::default(), &PluginsInventorySnapshot::default());
764 ws.set_plugin_cli(cli);
765 let declared = ws.list_projects()[0].path.to_string_lossy().into_owned();
766 let mut rx = ws.subscribe_answerer();
767
768 ws.dispatch(Command::PluginInstall {
769 plugin_id: "demo@probe".to_owned(),
770 scope: "project".to_owned(),
771 project: Some("proj".to_owned()),
772 })
773 .expect("the install dispatches");
774 let _ = next_update(&mut rx).await;
775
776 let call = calls.run.lock().expect("lock")[0].clone();
777 assert_eq!(call.cwd, declared, "the install runs in the declared project's path");
778 assert_eq!(
779 call.args,
780 vec!["plugin", "install", "demo@probe", "--scope", "project"],
781 "the CLI call carries the scope"
782 );
783
784 let refusal = ws
785 .dispatch(Command::PluginInstall {
786 plugin_id: "demo@probe".to_owned(),
787 scope: "local".to_owned(),
788 project: Some("nowhere".to_owned()),
789 })
790 .expect_err("an undeclared project is refused");
791 assert!(
792 matches!(&refusal, DispatchError::ProjectNotFound { name } if name == "nowhere"),
793 "the refusal names the project, got: {refusal:?}"
794 );
795 assert_eq!(calls.run.lock().expect("lock").len(), 1, "and nothing ran for it");
796
797 let refusal = ws
798 .dispatch(Command::PluginInstall {
799 plugin_id: "demo@probe".to_owned(),
800 scope: "local".to_owned(),
801 project: None,
802 })
803 .expect_err("a project-scoped install with no project is refused");
804 assert!(
805 matches!(refusal, DispatchError::PluginProjectRequired),
806 "the refusal says the project is required, got: {refusal:?}"
807 );
808 }
809
810 #[tokio::test]
813 async fn a_user_install_runs_in_the_home_directory() {
814 let (ws, mut rx) = Workspace::testing_stub();
815 let (cli, calls) =
816 fake_cli(&PluginsInventorySnapshot::default(), &PluginsInventorySnapshot::default());
817 ws.set_plugin_cli(cli);
818
819 ws.dispatch(Command::PluginInstall {
820 plugin_id: "demo@probe".to_owned(),
821 scope: "user".to_owned(),
822 project: None,
823 })
824 .expect("the install dispatches");
825 let _ = next_update(&mut rx).await;
826
827 assert_eq!(
828 calls.run.lock().expect("lock")[0].cwd,
829 home(),
830 "user scope runs in the home directory, not the launch directory"
831 );
832 }
833
834 #[tokio::test]
839 async fn update_all_queues_exactly_the_stale_set() {
840 let (ws, mut rx) = Workspace::testing_stub();
841 let scan = PluginsInventorySnapshot {
842 installed: vec![
843 installed("stale@probe", "1.0.0", "user", None),
844 installed("current@probe", "2.0.0", "user", None),
845 installed("local@probe", "1.0.0", "local", Some("/tmp/wc-issues")),
846 ],
847 marketplace: vec![
848 marketplace_entry("stale@probe", "1.1.0"),
849 marketplace_entry("current@probe", "2.0.0"),
850 marketplace_entry("local@probe", "1.2.0"),
851 ],
852 ..PluginsInventorySnapshot::default()
853 };
854 let refreshed = PluginsInventorySnapshot {
855 installed: vec![
856 installed("stale@probe", "1.1.0", "user", None),
857 installed("current@probe", "2.0.0", "user", None),
858 installed("local@probe", "1.2.0", "local", Some("/tmp/wc-issues")),
859 ],
860 ..PluginsInventorySnapshot::default()
861 };
862 let (cli, calls) = fake_cli(&scan, &refreshed);
863 ws.set_plugin_cli(cli);
864 let dir = tempfile::tempdir().expect("tempdir");
867 ws.install_db_for_test(
868 crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
869 );
870
871 ws.dispatch(Command::PluginUpdateAll).expect("the run dispatches");
872
873 let finished = loop {
874 match next_update(&mut rx).await {
875 SessionUpdate::PluginsUpdateRunFinished { run, .. } => break run,
876 SessionUpdate::PluginsUpdateRunProgress { .. } => {}
877 other => panic!("unexpected update: {other:?}"),
878 }
879 };
880 assert!(finished.finished, "the run is over");
881 let ids: Vec<&str> = finished.rows.iter().map(|row| row.plugin_id.as_str()).collect();
882 assert_eq!(ids, vec!["stale@probe", "local@probe"], "only the stale entries are queued");
883 assert_eq!(
884 finished.rows[0].cwd_raw,
885 home(),
886 "a user-scoped row runs in the home directory"
887 );
888 assert_eq!(
889 finished.rows[1].cwd_raw, "/tmp/wc-issues",
890 "a local-scoped row runs in its own project"
891 );
892 assert_eq!(
893 finished.rows[0].status,
894 PluginRunRowStatus::Updated,
895 "a version that moved reads updated"
896 );
897
898 let updated: Vec<Vec<String>> =
899 calls.updates.lock().expect("lock").iter().map(|call| call.args.clone()).collect();
900 assert_eq!(
901 updated,
902 vec![
903 vec!["plugin", "update", "stale@probe", "--scope", "user"],
904 vec!["plugin", "update", "local@probe", "--scope", "local"],
905 ],
906 "one update call per stale row, in plan order"
907 );
908 let records = ws.plugin_update_records();
909 assert_eq!(records.len(), 2, "each applied update recorded what a rollback restores");
910 let stale = records
911 .iter()
912 .find(|record| record.plugin_id == "stale@probe")
913 .expect("the applied update recorded its own row");
914 assert_eq!(stale.from_version.as_deref(), Some("1.0.0"));
915 assert_eq!(stale.to_version.as_deref(), Some("1.1.0"));
916 }
917
918 #[tokio::test]
922 async fn nothing_stale_lands_as_a_finished_empty_run() {
923 let (ws, mut rx) = Workspace::testing_stub();
924 let scan = PluginsInventorySnapshot {
925 installed: vec![installed("current@probe", "2.0.0", "user", None)],
926 marketplace: vec![marketplace_entry("current@probe", "2.0.0")],
927 ..PluginsInventorySnapshot::default()
928 };
929 let (cli, calls) = fake_cli(&scan, &PluginsInventorySnapshot::default());
930 ws.set_plugin_cli(cli);
931
932 ws.dispatch(Command::PluginUpdateAll).expect("the run dispatches");
933
934 let SessionUpdate::PluginsUpdateRunFinished { run, .. } = next_update(&mut rx).await else {
935 panic!("the run reports its finish");
936 };
937 assert!(run.finished, "an empty run is a finished one");
938 assert!(run.rows.is_empty(), "nothing stale is nothing to do");
939 assert!(calls.updates.lock().expect("lock").is_empty(), "and no update call went out");
940 assert!(
941 !ws.plugin_action_running.load(Ordering::Acquire),
942 "the gate opened when the run ended"
943 );
944 }
945
946 #[tokio::test]
951 async fn a_scan_that_cannot_run_reports_the_join() {
952 let (ws, mut rx) = Workspace::testing_stub();
953 let (cli, _calls) =
954 fake_cli(&PluginsInventorySnapshot::default(), &PluginsInventorySnapshot::default());
955 let panicking = PluginCli { scan: Arc::new(|| panic!("the scan thread died")), ..cli };
956 ws.set_plugin_cli(panicking);
957
958 ws.dispatch(Command::PluginUpdateAll).expect("the dispatch is accepted");
959
960 let SessionUpdate::PluginsCliActionFailed { message, .. } = next_update(&mut rx).await
961 else {
962 panic!("the failure reports as a frame");
963 };
964 assert!(message.contains("failed to join"), "the join is named, got: {message}");
965 assert!(
966 !message.contains("no plugins root resolves"),
967 "and not as a missing root, got: {message}"
968 );
969 }
970
971 #[tokio::test]
976 async fn a_missing_plugins_root_reports_as_a_failed_action() {
977 let (ws, mut rx) = Workspace::testing_stub();
978 let (cli, _calls) =
979 fake_cli(&PluginsInventorySnapshot::default(), &PluginsInventorySnapshot::default());
980 let no_root = PluginCli { scan: Arc::new(|| None), ..cli };
981 ws.set_plugin_cli(no_root);
982
983 ws.dispatch(Command::PluginUpdateAll).expect("the dispatch is accepted");
984
985 let SessionUpdate::PluginsCliActionFailed { message, .. } = next_update(&mut rx).await
986 else {
987 panic!("the failure reports as a frame");
988 };
989 assert!(
990 message.contains("no plugins root resolves"),
991 "the message names the missing root, got: {message}"
992 );
993 assert!(
994 !ws.plugin_action_running.load(Ordering::Acquire),
995 "the gate opened when the task ended"
996 );
997 }
998
999 #[test]
1002 fn a_second_action_is_refused_while_one_runs() {
1003 let (ws, _rx) = Workspace::testing_stub();
1004 ws.plugin_action_running.store(true, Ordering::Release);
1005 let refusal = ws
1006 .dispatch(Command::PluginCheckUpdates)
1007 .expect_err("a running action refuses a second");
1008 assert!(
1009 matches!(refusal, DispatchError::PluginActionRunning),
1010 "the refusal names what is running, got: {refusal:?}"
1011 );
1012 }
1013
1014 #[tokio::test]
1018 async fn an_update_of_an_uninstalled_plugin_fails_naming_it() {
1019 let (ws, mut rx) = Workspace::testing_stub();
1020 let (cli, calls) =
1021 fake_cli(&PluginsInventorySnapshot::default(), &PluginsInventorySnapshot::default());
1022 ws.set_plugin_cli(cli);
1023
1024 ws.dispatch(Command::PluginUpdate {
1025 plugin_id: "ghost@probe".to_owned(),
1026 scope: "user".to_owned(),
1027 })
1028 .expect("the dispatch is accepted");
1029
1030 let SessionUpdate::PluginsCliActionFailed { message, .. } = next_update(&mut rx).await
1031 else {
1032 panic!("the failure reports as a frame");
1033 };
1034 assert!(
1035 message.contains("ghost@probe") && message.contains("user"),
1036 "the failure names the plugin and the scope, got: {message}"
1037 );
1038 assert!(calls.run.lock().expect("lock").is_empty(), "and no CLI call went out");
1039 }
1040
1041 #[tokio::test]
1045 async fn a_landed_install_reports_the_refreshed_inventory() {
1046 let (ws, mut rx) = Workspace::testing_stub();
1047 let refreshed = PluginsInventorySnapshot {
1048 installed: vec![installed("demo@probe", "1.0.0", "user", None)],
1049 ..PluginsInventorySnapshot::default()
1050 };
1051 let (cli, _calls) = fake_cli(&PluginsInventorySnapshot::default(), &refreshed);
1052 ws.set_plugin_cli(cli);
1053
1054 ws.dispatch(Command::PluginInstall {
1055 plugin_id: "demo@probe".to_owned(),
1056 scope: "user".to_owned(),
1057 project: None,
1058 })
1059 .expect("the install dispatches");
1060
1061 let SessionUpdate::PluginsCliActionSucceeded { result, .. } = next_update(&mut rx).await
1062 else {
1063 panic!("the action reports its outcome");
1064 };
1065 assert_eq!(result.message, "Installed demo@probe for user scope");
1066 assert_eq!(result.snapshot.installed.len(), 1, "the frame carries the refreshed inventory");
1067 assert!(
1068 !ws.plugin_action_running.load(Ordering::Acquire),
1069 "the gate opened when the action ended"
1070 );
1071 }
1072
1073 #[tokio::test]
1077 async fn an_adds_outcome_names_the_dispatch_it_answers() {
1078 let (ws, mut rx) = Workspace::testing_stub();
1079 let (cli, _calls) =
1080 fake_cli(&PluginsInventorySnapshot::default(), &PluginsInventorySnapshot::default());
1081 ws.set_plugin_cli(cli);
1082
1083 ws.dispatch(Command::MarketplaceAdd {
1084 source: "owner/repo".to_owned(),
1085 dispatch_id: Some(41),
1086 })
1087 .expect("the add dispatches");
1088
1089 let SessionUpdate::PluginsCliActionSucceeded { dispatch_id, .. } =
1090 next_update(&mut rx).await
1091 else {
1092 panic!("the add reports its outcome");
1093 };
1094 assert_eq!(dispatch_id, Some(41), "the frame names the dispatch the press made");
1095 }
1096
1097 #[tokio::test]
1101 async fn a_repair_is_the_remove_then_readd_pair() {
1102 let (ws, mut rx) = Workspace::testing_stub();
1103 let scan = PluginsInventorySnapshot {
1104 marketplaces: vec![
1105 marketplace("git-mp", "github", Some("owner/repo"), Some("/tmp/clone")),
1106 marketplace("dir-mp", "directory", None, Some("/tmp/dir-clone")),
1107 ],
1108 ..PluginsInventorySnapshot::default()
1109 };
1110 let (cli, calls) = fake_cli(&scan, &PluginsInventorySnapshot::default());
1111 ws.set_plugin_cli(cli);
1112
1113 ws.dispatch(Command::MarketplaceRepair { name: "git-mp".to_owned() })
1114 .expect("the repair dispatches");
1115 let _ = next_update(&mut rx).await;
1116 let pair: Vec<Vec<String>> =
1117 calls.run.lock().expect("lock").iter().map(|call| call.args.clone()).collect();
1118 assert_eq!(
1119 pair,
1120 vec![
1121 vec!["plugin", "marketplace", "remove", "git-mp"],
1122 vec!["plugin", "marketplace", "add", "owner/repo", "--scope", "user"],
1123 ],
1124 "a git marketplace re-adds from its repo"
1125 );
1126 calls.run.lock().expect("lock").clear();
1127
1128 ws.dispatch(Command::MarketplaceRepair { name: "dir-mp".to_owned() })
1129 .expect("the repair dispatches");
1130 let _ = next_update(&mut rx).await;
1131 let pair: Vec<Vec<String>> =
1132 calls.run.lock().expect("lock").iter().map(|call| call.args.clone()).collect();
1133 assert_eq!(
1134 pair[1],
1135 vec!["plugin", "marketplace", "add", "/tmp/dir-clone", "--scope", "user"],
1136 "a directory marketplace re-adds from its clone location"
1137 );
1138
1139 ws.dispatch(Command::MarketplaceRepair { name: "absent".to_owned() })
1140 .expect("the dispatch is accepted");
1141 let SessionUpdate::PluginsCliActionFailed { message, .. } = next_update(&mut rx).await
1142 else {
1143 panic!("the unknown marketplace reports as a failure frame");
1144 };
1145 assert!(message.contains("absent"), "the failure names it, got: {message}");
1146 }
1147
1148 #[tokio::test]
1151 async fn the_check_applies_nothing() {
1152 let (ws, mut rx) = Workspace::testing_stub();
1153 let refreshed = PluginsInventorySnapshot {
1154 installed: vec![installed("stale@probe", "1.0.0", "user", None)],
1155 marketplace: vec![marketplace_entry("stale@probe", "1.1.0")],
1156 ..PluginsInventorySnapshot::default()
1157 };
1158 let (cli, calls) = fake_cli(&PluginsInventorySnapshot::default(), &refreshed);
1159 ws.set_plugin_cli(cli);
1160
1161 ws.dispatch(Command::PluginCheckUpdates).expect("the check dispatches");
1162
1163 let SessionUpdate::PluginsUpdateRunFinished { run, snapshot, .. } =
1164 next_update(&mut rx).await
1165 else {
1166 panic!("the check reports a finished run");
1167 };
1168 assert!(run.finished);
1169 assert_eq!(run.rows.len(), 1, "one check-only row for the stale entry");
1170 assert_eq!(
1171 run.rows[0].status,
1172 PluginRunRowStatus::UpdateAvailable,
1173 "a check reports availability and applies nothing"
1174 );
1175 assert!(snapshot.is_some(), "the check's refreshed inventory rides the frame");
1176 assert!(calls.updates.lock().expect("lock").is_empty(), "no update call went out");
1177 assert_eq!(
1178 calls.refresh.lock().expect("lock").len(),
1179 1,
1180 "the refresh is what the check runs"
1181 );
1182 }
1183}