1use super::*;
12use crate::git::{GitPluginConfig, GitPluginLoader, GitPluginSource};
13use crate::loader::PluginLoader;
14use crate::metadata::{MetadataStore, PluginMetadata};
15use crate::remote::{RemotePluginConfig, RemotePluginLoader};
16use crate::signature::SignatureVerifier;
17use serde::Deserialize;
18use std::path::{Path, PathBuf};
19
20#[derive(Debug, Clone)]
22pub enum PluginSource {
23 Local(PathBuf),
25 Url {
27 url: String,
29 checksum: Option<String>,
31 },
32 Git(GitPluginSource),
34 Registry {
36 name: String,
38 version: Option<String>,
40 },
41}
42
43impl PluginSource {
44 pub fn parse(input: &str) -> LoaderResult<Self> {
52 let input = input.trim();
53
54 if input.starts_with("http://") || input.starts_with("https://") {
56 if input.contains(".git")
58 || input.contains("github.com")
59 || input.contains("gitlab.com")
60 {
61 let source = GitPluginSource::parse(input)?;
62 return Ok(PluginSource::Git(source));
63 }
64 return Ok(PluginSource::Url {
65 url: input.to_string(),
66 checksum: None,
67 });
68 }
69
70 if input.starts_with("git@") {
72 let source = GitPluginSource::parse(input)?;
73 return Ok(PluginSource::Git(source));
74 }
75
76 if input.contains('/') || input.contains('\\') || Path::new(input).exists() {
78 return Ok(PluginSource::Local(PathBuf::from(input)));
79 }
80
81 let (name, version) = if let Some((n, v)) = input.split_once('@') {
83 (n.to_string(), Some(v.to_string()))
84 } else {
85 (input.to_string(), None)
86 };
87
88 Ok(PluginSource::Registry { name, version })
89 }
90}
91
92impl std::fmt::Display for PluginSource {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 match self {
95 PluginSource::Local(path) => write!(f, "local:{}", path.display()),
96 PluginSource::Url { url, .. } => write!(f, "url:{}", url),
97 PluginSource::Git(source) => write!(f, "git:{}", source),
98 PluginSource::Registry { name, version } => {
99 if let Some(v) = version {
100 write!(f, "registry:{}@{}", name, v)
101 } else {
102 write!(f, "registry:{}", name)
103 }
104 }
105 }
106 }
107}
108
109#[derive(Debug, Clone)]
111pub struct InstallOptions {
112 pub force: bool,
114 pub skip_validation: bool,
116 pub verify_signature: bool,
118 pub expected_checksum: Option<String>,
120}
121
122impl Default for InstallOptions {
123 fn default() -> Self {
124 Self {
125 force: false,
126 skip_validation: false,
127 verify_signature: true,
128 expected_checksum: None,
129 }
130 }
131}
132
133pub struct PluginInstaller {
135 loader: PluginLoader,
136 remote_loader: RemotePluginLoader,
137 git_loader: GitPluginLoader,
138 config: PluginLoaderConfig,
139 metadata_store: std::sync::Arc<tokio::sync::RwLock<MetadataStore>>,
140}
141
142impl PluginInstaller {
143 pub fn new(loader_config: PluginLoaderConfig) -> LoaderResult<Self> {
145 let loader = PluginLoader::new(loader_config.clone());
146 let remote_loader = RemotePluginLoader::new(RemotePluginConfig::default())?;
147 let git_loader = GitPluginLoader::new(GitPluginConfig::default())?;
148
149 let metadata_dir = shellexpand::tilde("~/.mockforge/plugin-metadata");
151 let metadata_store = MetadataStore::new(PathBuf::from(metadata_dir.as_ref()));
152
153 Ok(Self {
154 loader,
155 remote_loader,
156 git_loader,
157 config: loader_config,
158 metadata_store: std::sync::Arc::new(tokio::sync::RwLock::new(metadata_store)),
159 })
160 }
161
162 pub async fn init(&self) -> LoaderResult<()> {
164 let mut store = self.metadata_store.write().await;
165 store.load().await
166 }
167
168 pub async fn install(
172 &self,
173 source_str: &str,
174 options: InstallOptions,
175 ) -> LoaderResult<PluginId> {
176 let source = PluginSource::parse(source_str)?;
177 self.install_from_source(&source, options).await
178 }
179
180 pub async fn install_from_source(
182 &self,
183 source: &PluginSource,
184 options: InstallOptions,
185 ) -> LoaderResult<PluginId> {
186 tracing::info!("Installing plugin from source: {}", source);
187
188 let plugin_dir = match source {
190 PluginSource::Local(path) => path.clone(),
191 PluginSource::Url { url, checksum } => {
192 let checksum_ref = checksum.as_deref().or(options.expected_checksum.as_deref());
193 self.remote_loader.download_with_checksum(url, checksum_ref).await?
194 }
195 PluginSource::Git(git_source) => self.git_loader.clone_from_git(git_source).await?,
196 PluginSource::Registry { name, version } => {
197 self.install_from_registry(name, version.as_deref(), &options).await?
198 }
199 };
200
201 if options.verify_signature && !options.skip_validation {
203 if let Err(e) = self.verify_plugin_signature(&plugin_dir) {
204 tracing::warn!("Plugin signature verification failed: {}", e);
205 }
207 }
208
209 if !options.skip_validation {
211 self.loader.validate_plugin(&plugin_dir).await?;
212 }
213
214 let manifest = self.loader.validate_plugin(&plugin_dir).await?;
216 let plugin_id = manifest.info.id.clone();
217
218 if self.loader.get_plugin(&plugin_id).await.is_some() && !options.force {
220 return Err(PluginLoaderError::already_loaded(plugin_id));
221 }
222
223 self.loader.load_plugin(&plugin_id).await?;
225
226 let version = manifest.info.version.to_string();
228 let metadata = PluginMetadata::new(plugin_id.clone(), source.clone(), version);
229 let mut store = self.metadata_store.write().await;
230 store.save(metadata).await?;
231
232 tracing::info!("Plugin installed successfully: {}", plugin_id);
233 Ok(plugin_id)
234 }
235
236 async fn install_from_registry(
238 &self,
239 name: &str,
240 version: Option<&str>,
241 options: &InstallOptions,
242 ) -> LoaderResult<PathBuf> {
243 let base_url = std::env::var("MOCKFORGE_PLUGIN_REGISTRY_URL")
244 .unwrap_or_else(|_| "https://registry.mockforge.dev".to_string());
245 let client = reqwest::Client::new();
246
247 let (download_url, checksum) = if let Some(v) = version {
248 let version_url = format!("{}/api/v1/plugins/{}/versions/{}", base_url, name, v);
249 let response = client.get(&version_url).send().await.map_err(|e| {
250 PluginLoaderError::load(format!(
251 "Failed to fetch registry version metadata for {}@{}: {}",
252 name, v, e
253 ))
254 })?;
255
256 if !response.status().is_success() {
257 return Err(PluginLoaderError::load(format!(
258 "Registry lookup failed for {}@{}: {}",
259 name,
260 v,
261 response.status()
262 )));
263 }
264
265 let entry: RegistryVersionResponse = response.json().await.map_err(|e| {
266 PluginLoaderError::load(format!(
267 "Invalid registry response for {}@{}: {}",
268 name, v, e
269 ))
270 })?;
271 (entry.download_url, entry.checksum)
272 } else {
273 let plugin_url = format!("{}/api/v1/plugins/{}", base_url, name);
274 let response = client.get(&plugin_url).send().await.map_err(|e| {
275 PluginLoaderError::load(format!(
276 "Failed to fetch registry plugin metadata for {}: {}",
277 name, e
278 ))
279 })?;
280
281 if !response.status().is_success() {
282 return Err(PluginLoaderError::load(format!(
283 "Registry lookup failed for {}: {}",
284 name,
285 response.status()
286 )));
287 }
288
289 let entry: RegistryPluginResponse = response.json().await.map_err(|e| {
290 PluginLoaderError::load(format!("Invalid registry response for {}: {}", name, e))
291 })?;
292
293 let selected = select_registry_version(&entry).ok_or_else(|| {
294 PluginLoaderError::load(format!(
295 "No installable versions found for plugin '{}'",
296 name
297 ))
298 })?;
299 (selected.download_url.clone(), selected.checksum.clone())
300 };
301
302 let checksum_ref = options.expected_checksum.as_deref().or(checksum.as_deref());
303
304 self.remote_loader.download_with_checksum(&download_url, checksum_ref).await
305 }
306
307 fn verify_plugin_signature(&self, plugin_dir: &Path) -> LoaderResult<()> {
309 let verifier = SignatureVerifier::new(&self.config);
310 verifier.verify_plugin_signature(plugin_dir)
311 }
312
313 pub async fn uninstall(&self, plugin_id: &PluginId) -> LoaderResult<()> {
315 self.loader.unload_plugin(plugin_id).await?;
316
317 let mut store = self.metadata_store.write().await;
319 store.remove(plugin_id).await?;
320
321 Ok(())
322 }
323
324 pub async fn list_installed(&self) -> Vec<PluginId> {
326 self.loader.list_plugins().await
327 }
328
329 pub async fn update(&self, plugin_id: &PluginId) -> LoaderResult<()> {
331 tracing::info!("Updating plugin: {}", plugin_id);
332
333 let metadata = {
335 let store = self.metadata_store.read().await;
336 store.get(plugin_id).cloned().ok_or_else(|| {
337 PluginLoaderError::load(format!(
338 "No installation metadata found for plugin {}. Cannot update.",
339 plugin_id
340 ))
341 })?
342 };
343
344 tracing::info!("Updating plugin {} from source: {}", plugin_id, metadata.source);
345
346 if self.loader.get_plugin(plugin_id).await.is_some() {
348 self.loader.unload_plugin(plugin_id).await?;
349 }
350
351 let options = InstallOptions {
353 force: true,
354 skip_validation: false,
355 verify_signature: true,
356 expected_checksum: None,
357 };
358
359 let new_plugin_id = self.install_from_source(&metadata.source, options).await?;
360
361 if new_plugin_id != *plugin_id {
363 return Err(PluginLoaderError::load(format!(
364 "Plugin ID mismatch after update: expected {}, got {}",
365 plugin_id, new_plugin_id
366 )));
367 }
368
369 let new_manifest = self
371 .loader
372 .get_plugin(&new_plugin_id)
373 .await
374 .ok_or_else(|| PluginLoaderError::load("Failed to get updated plugin"))?
375 .manifest;
376
377 let mut store = self.metadata_store.write().await;
378 if let Some(meta) = store.get(plugin_id).cloned() {
379 let mut updated_meta = meta;
380 updated_meta.mark_updated(new_manifest.info.version.to_string());
381 store.save(updated_meta).await?;
382 }
383
384 tracing::info!("Plugin {} updated successfully", plugin_id);
385 Ok(())
386 }
387
388 pub async fn update_all(&self) -> LoaderResult<Vec<PluginId>> {
390 tracing::info!("Updating all plugins");
391
392 let plugin_ids = {
394 let store = self.metadata_store.read().await;
395 store.list()
396 };
397
398 if plugin_ids.is_empty() {
399 tracing::info!("No plugins found with metadata to update");
400 return Ok(Vec::new());
401 }
402
403 tracing::info!("Found {} plugins to update", plugin_ids.len());
404
405 let mut updated = Vec::new();
406 let mut failed = Vec::new();
407
408 for plugin_id in plugin_ids {
410 match self.update(&plugin_id).await {
411 Ok(_) => {
412 tracing::info!("Successfully updated plugin: {}", plugin_id);
413 updated.push(plugin_id);
414 }
415 Err(e) => {
416 tracing::warn!("Failed to update plugin {}: {}", plugin_id, e);
417 failed.push((plugin_id, e.to_string()));
418 }
419 }
420 }
421
422 tracing::info!(
423 "Plugin update complete: {} succeeded, {} failed",
424 updated.len(),
425 failed.len()
426 );
427
428 if !failed.is_empty() {
429 let failed_list = failed
430 .iter()
431 .map(|(id, err)| format!("{}: {}", id, err))
432 .collect::<Vec<_>>()
433 .join(", ");
434 tracing::warn!("Failed updates: {}", failed_list);
435 }
436
437 Ok(updated)
438 }
439
440 pub async fn clear_caches(&self) -> LoaderResult<()> {
442 self.remote_loader.clear_cache().await?;
443 self.git_loader.clear_cache().await?;
444 Ok(())
445 }
446
447 pub async fn get_cache_stats(&self) -> LoaderResult<CacheStats> {
449 let download_cache_size = self.remote_loader.get_cache_size()?;
450 let git_cache_size = self.git_loader.get_cache_size()?;
451
452 Ok(CacheStats {
453 download_cache_size,
454 git_cache_size,
455 total_size: download_cache_size + git_cache_size,
456 })
457 }
458
459 pub async fn get_plugin_metadata(&self, plugin_id: &PluginId) -> Option<PluginMetadata> {
461 let store = self.metadata_store.read().await;
462 store.get(plugin_id).cloned()
463 }
464
465 pub async fn list_plugins_with_metadata(&self) -> Vec<(PluginId, PluginMetadata)> {
467 let store = self.metadata_store.read().await;
468 store
469 .list()
470 .into_iter()
471 .filter_map(|id| store.get(&id).map(|meta| (id, meta.clone())))
472 .collect()
473 }
474}
475
476#[derive(Debug, Deserialize)]
477struct RegistryVersionResponse {
478 download_url: String,
479 #[serde(default)]
480 checksum: Option<String>,
481}
482
483#[derive(Debug, Deserialize)]
484struct RegistryPluginResponse {
485 version: String,
486 versions: Vec<RegistryVersionResponseWithVersion>,
487}
488
489#[derive(Debug, Deserialize)]
490struct RegistryVersionResponseWithVersion {
491 version: String,
492 download_url: String,
493 #[serde(default)]
494 checksum: Option<String>,
495 #[serde(default)]
496 yanked: bool,
497}
498
499fn select_registry_version(
500 entry: &RegistryPluginResponse,
501) -> Option<&RegistryVersionResponseWithVersion> {
502 if let Some(preferred) = entry.versions.iter().find(|v| v.version == entry.version && !v.yanked)
503 {
504 return Some(preferred);
505 }
506
507 entry.versions.iter().find(|v| !v.yanked)
508}
509
510#[derive(Debug, Clone)]
512pub struct CacheStats {
513 pub download_cache_size: u64,
515 pub git_cache_size: u64,
517 pub total_size: u64,
519}
520
521impl CacheStats {
522 pub fn format_size(bytes: u64) -> String {
524 const KB: u64 = 1024;
525 const MB: u64 = KB * 1024;
526 const GB: u64 = MB * 1024;
527
528 if bytes >= GB {
529 format!("{:.2} GB", bytes as f64 / GB as f64)
530 } else if bytes >= MB {
531 format!("{:.2} MB", bytes as f64 / MB as f64)
532 } else if bytes >= KB {
533 format!("{:.2} KB", bytes as f64 / KB as f64)
534 } else {
535 format!("{} bytes", bytes)
536 }
537 }
538
539 pub fn download_cache_formatted(&self) -> String {
541 Self::format_size(self.download_cache_size)
542 }
543
544 pub fn git_cache_formatted(&self) -> String {
546 Self::format_size(self.git_cache_size)
547 }
548
549 pub fn total_formatted(&self) -> String {
551 Self::format_size(self.total_size)
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[test]
562 fn test_plugin_source_parse_url() {
563 let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
564 assert!(matches!(source, PluginSource::Url { .. }));
565 }
566
567 #[test]
568 fn test_plugin_source_parse_git_https() {
569 let source = PluginSource::parse("https://github.com/user/repo").unwrap();
570 assert!(matches!(source, PluginSource::Git(_)));
571 }
572
573 #[test]
574 fn test_plugin_source_parse_git_ssh() {
575 let source = PluginSource::parse("git@github.com:user/repo.git").unwrap();
576 assert!(matches!(source, PluginSource::Git(_)));
577 }
578
579 #[test]
580 fn test_plugin_source_parse_gitlab() {
581 let source = PluginSource::parse("https://gitlab.com/user/repo").unwrap();
582 assert!(matches!(source, PluginSource::Git(_)));
583 }
584
585 #[test]
586 fn test_plugin_source_parse_local() {
587 let source = PluginSource::parse("/path/to/plugin").unwrap();
588 assert!(matches!(source, PluginSource::Local(_)));
589
590 let source = PluginSource::parse("./relative/path").unwrap();
591 assert!(matches!(source, PluginSource::Local(_)));
592 }
593
594 #[test]
595 fn test_plugin_source_parse_registry() {
596 let source = PluginSource::parse("auth-jwt").unwrap();
597 assert!(matches!(source, PluginSource::Registry { .. }));
598
599 let source = PluginSource::parse("auth-jwt@1.0.0").unwrap();
600 if let PluginSource::Registry { name, version } = source {
601 assert_eq!(name, "auth-jwt");
602 assert_eq!(version, Some("1.0.0".to_string()));
603 } else {
604 panic!("Expected Registry source");
605 }
606 }
607
608 #[test]
609 fn test_plugin_source_parse_registry_without_version() {
610 let source = PluginSource::parse("my-plugin").unwrap();
611 if let PluginSource::Registry { name, version } = source {
612 assert_eq!(name, "my-plugin");
613 assert!(version.is_none());
614 } else {
615 panic!("Expected Registry source");
616 }
617 }
618
619 #[test]
620 fn test_plugin_source_parse_url_with_checksum() {
621 let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
622 if let PluginSource::Url { url, checksum } = source {
623 assert_eq!(url, "https://example.com/plugin.zip");
624 assert!(checksum.is_none());
625 } else {
626 panic!("Expected URL source");
627 }
628 }
629
630 #[test]
631 fn test_plugin_source_parse_empty_string() {
632 let source = PluginSource::parse("").unwrap();
633 assert!(matches!(source, PluginSource::Registry { .. }));
635 }
636
637 #[test]
638 fn test_plugin_source_parse_whitespace() {
639 let source = PluginSource::parse(" https://example.com/plugin.zip ").unwrap();
640 assert!(matches!(source, PluginSource::Url { .. }));
641 }
642
643 #[test]
644 fn test_plugin_source_display() {
645 let source = PluginSource::Local(PathBuf::from("/tmp/plugin"));
646 assert_eq!(source.to_string(), "local:/tmp/plugin");
647
648 let source = PluginSource::Url {
649 url: "https://example.com/plugin.zip".to_string(),
650 checksum: None,
651 };
652 assert_eq!(source.to_string(), "url:https://example.com/plugin.zip");
653
654 let source = PluginSource::Registry {
655 name: "my-plugin".to_string(),
656 version: Some("1.0.0".to_string()),
657 };
658 assert_eq!(source.to_string(), "registry:my-plugin@1.0.0");
659
660 let source = PluginSource::Registry {
661 name: "my-plugin".to_string(),
662 version: None,
663 };
664 assert_eq!(source.to_string(), "registry:my-plugin");
665 }
666
667 #[test]
668 fn test_plugin_source_clone() {
669 let source = PluginSource::Local(PathBuf::from("/tmp"));
670 let cloned = source.clone();
671 assert_eq!(source.to_string(), cloned.to_string());
672 }
673
674 #[test]
677 fn test_install_options_default() {
678 let options = InstallOptions::default();
679 assert!(!options.force);
680 assert!(!options.skip_validation);
681 assert!(options.verify_signature);
682 assert!(options.expected_checksum.is_none());
683 }
684
685 #[test]
686 fn test_install_options_with_force() {
687 let options = InstallOptions {
688 force: true,
689 ..Default::default()
690 };
691 assert!(options.force);
692 }
693
694 #[test]
695 fn test_install_options_with_checksum() {
696 let options = InstallOptions {
697 expected_checksum: Some("abc123".to_string()),
698 ..Default::default()
699 };
700 assert_eq!(options.expected_checksum, Some("abc123".to_string()));
701 }
702
703 #[test]
704 fn test_install_options_skip_validation() {
705 let options = InstallOptions {
706 skip_validation: true,
707 verify_signature: false,
708 ..Default::default()
709 };
710 assert!(options.skip_validation);
711 assert!(!options.verify_signature);
712 }
713
714 #[test]
715 fn test_install_options_clone() {
716 let options = InstallOptions {
717 force: true,
718 skip_validation: false,
719 verify_signature: true,
720 expected_checksum: Some("test".to_string()),
721 };
722 let cloned = options.clone();
723 assert_eq!(options.force, cloned.force);
724 assert_eq!(options.expected_checksum, cloned.expected_checksum);
725 }
726
727 #[test]
730 fn test_cache_stats_formatting() {
731 assert_eq!(CacheStats::format_size(512), "512 bytes");
732 assert_eq!(CacheStats::format_size(1024), "1.00 KB");
733 assert_eq!(CacheStats::format_size(1024 * 1024), "1.00 MB");
734 assert_eq!(CacheStats::format_size(1024 * 1024 * 1024), "1.00 GB");
735 }
736
737 #[test]
738 fn test_cache_stats_edge_cases() {
739 assert_eq!(CacheStats::format_size(0), "0 bytes");
740 assert_eq!(CacheStats::format_size(1), "1 bytes");
741 assert_eq!(CacheStats::format_size(1023), "1023 bytes");
742 assert_eq!(CacheStats::format_size(1025), "1.00 KB");
743 }
744
745 #[test]
746 fn test_cache_stats_large_values() {
747 let tb = 1024u64 * 1024 * 1024 * 1024;
748 assert!(CacheStats::format_size(tb).contains("GB"));
749 }
750
751 #[test]
752 fn test_cache_stats_formatted_methods() {
753 let stats = CacheStats {
754 download_cache_size: 1024 * 1024,
755 git_cache_size: 2 * 1024 * 1024,
756 total_size: 3 * 1024 * 1024,
757 };
758
759 assert_eq!(stats.download_cache_formatted(), "1.00 MB");
760 assert_eq!(stats.git_cache_formatted(), "2.00 MB");
761 assert_eq!(stats.total_formatted(), "3.00 MB");
762 }
763
764 #[test]
765 fn test_cache_stats_total_calculation() {
766 let stats = CacheStats {
767 download_cache_size: 100,
768 git_cache_size: 200,
769 total_size: 300,
770 };
771
772 assert_eq!(stats.total_size, stats.download_cache_size + stats.git_cache_size);
773 }
774
775 #[test]
776 fn test_cache_stats_clone() {
777 let stats = CacheStats {
778 download_cache_size: 1024,
779 git_cache_size: 2048,
780 total_size: 3072,
781 };
782
783 let cloned = stats.clone();
784 assert_eq!(stats.download_cache_size, cloned.download_cache_size);
785 assert_eq!(stats.git_cache_size, cloned.git_cache_size);
786 assert_eq!(stats.total_size, cloned.total_size);
787 }
788
789 #[test]
792 fn test_plugin_source_parse_http_url() {
793 let source = PluginSource::parse("http://example.com/plugin.zip").unwrap();
794 assert!(matches!(source, PluginSource::Url { .. }));
795 }
796
797 #[test]
798 fn test_plugin_source_parse_windows_path() {
799 let source = PluginSource::parse("C:\\Users\\plugin").unwrap();
800 assert!(matches!(source, PluginSource::Local(_)));
801 }
802
803 #[test]
804 fn test_plugin_source_parse_registry_with_special_chars() {
805 let source = PluginSource::parse("my-plugin-name_v2@2.0.0-beta").unwrap();
806 if let PluginSource::Registry { name, version } = source {
807 assert_eq!(name, "my-plugin-name_v2");
808 assert_eq!(version, Some("2.0.0-beta".to_string()));
809 } else {
810 panic!("Expected Registry source");
811 }
812 }
813
814 #[test]
815 fn test_plugin_source_parse_github_dotgit_in_url() {
816 let source = PluginSource::parse("https://github.com/user/repo.git").unwrap();
817 assert!(matches!(source, PluginSource::Git(_)));
818 }
819
820 #[test]
821 fn test_select_registry_version_prefers_current_non_yanked() {
822 let entry = RegistryPluginResponse {
823 version: "2.0.0".to_string(),
824 versions: vec![
825 RegistryVersionResponseWithVersion {
826 version: "1.0.0".to_string(),
827 download_url: "https://example.com/1.0.0.wasm".to_string(),
828 checksum: None,
829 yanked: false,
830 },
831 RegistryVersionResponseWithVersion {
832 version: "2.0.0".to_string(),
833 download_url: "https://example.com/2.0.0.wasm".to_string(),
834 checksum: Some("abc".to_string()),
835 yanked: false,
836 },
837 ],
838 };
839
840 let selected = select_registry_version(&entry).expect("expected selected version");
841 assert_eq!(selected.version, "2.0.0");
842 assert_eq!(selected.download_url, "https://example.com/2.0.0.wasm");
843 }
844
845 #[test]
846 fn test_select_registry_version_falls_back_to_first_non_yanked() {
847 let entry = RegistryPluginResponse {
848 version: "2.0.0".to_string(),
849 versions: vec![
850 RegistryVersionResponseWithVersion {
851 version: "2.0.0".to_string(),
852 download_url: "https://example.com/2.0.0.wasm".to_string(),
853 checksum: None,
854 yanked: true,
855 },
856 RegistryVersionResponseWithVersion {
857 version: "1.9.0".to_string(),
858 download_url: "https://example.com/1.9.0.wasm".to_string(),
859 checksum: None,
860 yanked: false,
861 },
862 ],
863 };
864
865 let selected = select_registry_version(&entry).expect("expected selected version");
866 assert_eq!(selected.version, "1.9.0");
867 }
868}