//! Plugin Manager window — lists the add-ons compiled into this build and lets //! the user enable/disable each one. A disabled plugin keeps its manifest //! listed but drops its ribbon tab and command dispatch (persisted across //! launches). Dynamic loading still comes with the phase-2 loader; see //! `docs/plugin-architecture.md`. #![cfg_attr(target_arch = "wasm32", allow(dead_code))] use crate::app::Message; use crate::plugin::external::{ExternalPlugin, RegistryEntry, ReleaseInfo}; use crate::ui::style::common::muted_style; use iced::widget::{ button, column, container, markdown, row, rule, scrollable, text, text_input, Space, }; use iced::{Background, Border, Element, Fill, Length, Theme}; use crate::t; use rustc_hash::{FxHashMap, FxHashSet}; use std::borrow::Cow; /// Empty lane kept at the right edge of scroll content so the vertical /// scrollbar never covers card controls. const SCROLLBAR_GUTTER: f32 = 16.0; /// Marketplace state passed to the Plugin Manager view. pub struct MarketView<'a> { pub registry: &'a [RegistryEntry], pub registry_loading: bool, pub registry_error: Option<&'a str>, pub registry_error_details_open: bool, pub input: &'a str, pub search: &'a str, pub repos: &'a [String], pub release_tags: &'a FxHashMap>, pub selected_tag: &'a FxHashMap, pub selected_repo: Option<&'a str>, pub readmes: &'a FxHashMap>, pub readme_loading: &'a FxHashSet, pub status: &'a str, } /// Latest desktop builds, used by the browser-only plugin notice. #[cfg(target_arch = "wasm32")] pub const DESKTOP_DOWNLOAD_URL: &str = "https://github.com/HakanSeven12/OpenCADStudio/releases/latest"; // Register the command names for autocomplete. On the web they open a desktop // download notice instead of the native marketplace. inventory::submit!(crate::command::CommandRegistration { names: &["PLUGINS", "PLUGINMANAGER"] }); fn primary_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().primary.base.color), } } fn badge<'a>(label: String) -> Element<'a, Message> { container(text(label).size(11)) .padding([2, 8]) .style(|theme: &Theme| { let pair = theme.palette().primary.weak; container::Style { background: Some(Background::Color(pair.color)), text_color: Some(pair.text), border: Border { radius: 4.0.into(), ..Default::default() }, ..Default::default() } }) .into() } fn toggle_button<'a>(id: &str, disabled: bool) -> Element<'a, Message> { // Label shows the action the click performs. let label = if disabled { t!("Enable") } else { t!("Disable") }; let want_enabled = disabled; // clicking flips the state let id_owned = id.to_string(); button(text(label).size(12)) .padding([3, 12]) .on_press(Message::SetPluginEnabled(id_owned, want_enabled)) .style(if disabled { button::success } else { button::danger }) .into() } #[derive(Clone, Copy)] enum StatusKind { Muted, Success, Danger, Warning, } /// Coloured status pill for a discovered external package. fn status_badge<'a>(label: Cow<'static, str>, kind: StatusKind) -> Element<'a, Message> { container(text(label).size(11)) .padding([2, 8]) .style(move |theme: &Theme| { let palette = theme.palette(); let pair = match kind { StatusKind::Muted => palette.background.weak, StatusKind::Success => palette.success.weak, StatusKind::Danger => palette.danger.weak, StatusKind::Warning => palette.warning.weak, }; container::Style { background: Some(Background::Color(pair.color)), text_color: Some(pair.text), border: Border { radius: 4.0.into(), ..Default::default() }, ..Default::default() } }) .into() } fn card_style(theme: &Theme, selected: bool) -> container::Style { let palette = theme.palette(); container::Style { background: selected .then(|| Background::Color(palette.primary.weak.color.scale_alpha(0.18))), border: Border { color: if selected { palette.primary.base.color } else { palette.background.strong.color }, width: if selected { 1.5 } else { 1.0 }, radius: 6.0.into(), }, ..Default::default() } } fn repository_for_external( plugin: &ExternalPlugin, registry: &[RegistryEntry], ) -> Option { plugin.repository.clone().or_else(|| { registry .iter() .find(|entry| entry.name.eq_ignore_ascii_case(&plugin.name)) .map(|entry| entry.repo.clone()) }) } fn matches_search(query: &str, values: &[&str]) -> bool { let query = query.trim().to_lowercase(); query.is_empty() || values .iter() .any(|value| value.to_lowercase().contains(&query)) } fn external_matches_search(plugin: &ExternalPlugin, repository: Option<&str>, query: &str) -> bool { matches_search( query, &[ &plugin.name, &plugin.id, &plugin.description, repository.unwrap_or_default(), ], ) || plugin .command_prefixes .iter() .any(|command| matches_search(query, &[command])) } fn repository_is_installed( repository: &str, registry_name: Option<&str>, externals: &[ExternalPlugin], ) -> bool { externals.iter().any(|plugin| { plugin.repository.as_deref() == Some(repository) || registry_name .is_some_and(|name| plugin.name.eq_ignore_ascii_case(name)) }) } fn trim_version_prefix(value: &str) -> &str { value.trim_start_matches(|c| c == 'v' || c == 'V') } fn newest_update(installed: &str, releases: &[ReleaseInfo]) -> Option { let installed = semver::Version::parse(trim_version_prefix(installed)).ok()?; releases .iter() .filter(|release| { ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version) && release.acadrust_compatible && release.rustc_compatible }) .filter_map(|release| { semver::Version::parse(trim_version_prefix(&release.tag)) .ok() .map(|version| (version, release.tag.clone())) }) .filter(|(version, _)| version > &installed) .max_by(|(left, _), (right, _)| left.cmp(right)) .map(|(_, tag)| tag) } fn external_card<'a>( p: &ExternalPlugin, repository: Option, update_tag: Option, loaded: bool, load_error: Option<&str>, disabled: bool, selected: bool, ) -> Element<'a, Message> { let failed_old_api = load_error.is_some() && !p.api_compatible(); let acadrust_mismatch = p.acadrust_declared && !p.acadrust_compatible(); let rustc_mismatch = ocs_plugin_api::version_info::uses_acadrust_gate(p.api_version) && !p.rustc_compatible(); let (status, kind) = if loaded && disabled { (t!("Disabled"), StatusKind::Muted) } else if loaded { (t!("Loaded"), StatusKind::Success) } else if !p.api_compatible() || failed_old_api { (t!("API incompatible"), StatusKind::Danger) } else if acadrust_mismatch || rustc_mismatch { (t!("Incompatible"), StatusKind::Danger) } else if load_error.is_some() { (t!("Load failed"), StatusKind::Danger) } else if !p.lib_present { (t!("No library"), StatusKind::Warning) } else { (t!("Restart to load"), StatusKind::Warning) }; let header = row![ text(p.name.clone()).size(15), Space::new().width(8), badge(format!("v{}", p.version)), Space::new().width(8), badge(format!("API {}", p.api_version)), Space::new().width(Fill), status_badge(status, kind), ] .align_y(iced::Center); let id_line = text(p.id.clone()).size(11).style(muted_style); let mut info_body = column![header, id_line].spacing(5); if !p.description.is_empty() { info_body = info_body.push(text(p.description.clone()).size(12).style(muted_style)); } if !p.command_prefixes.is_empty() { info_body = info_body.push( text(t!( "Commands: %{cmds}", cmds = p.command_prefixes.join(", ") )) .size(11) .style(muted_style), ); } if let Some(error) = load_error { let detail = if failed_old_api { t!( "Plugin API %{plugin} is incompatible with this build's API %{build} ribbon ABI. Update or rebuild the plugin.", plugin = p.api_version, build = ocs_plugin_api::API_VERSION, ) } else { t!("Load failed: %{error}", error = error) }; info_body = info_body.push( text(detail) .size(11) .width(Fill) .style(|theme: &Theme| iced::widget::text::Style { color: Some(theme.palette().danger.base.color), }), ); } let info: Element<'a, Message> = if let Some(repo) = repository.clone() { button(info_body.width(Fill)) .width(Fill) .padding(0) .style(button::text) .on_press(Message::PluginReadmeSelect(repo)) .into() } else { info_body.width(Fill).into() }; let mut actions = row![Space::new().width(Fill)].align_y(iced::Center); if let (Some(repo), Some(tag)) = (repository, update_tag) { actions = actions.push(pill_button( t!("Update to %{tag}", tag = tag), Message::PluginUpdate(repo, tag), button::primary, )); actions = actions.push(Space::new().width(6)); } // A loaded plugin can be turned off (drops its ribbon tab + dispatch). if loaded { actions = actions.push(toggle_button(&p.id, disabled)); actions = actions.push(Space::new().width(6)); } actions = actions.push(pill_button( t!("Uninstall"), Message::PluginUninstall(p.id.clone()), button::danger, )); container(column![info, actions].spacing(8).padding([10, 12])) .width(Fill) .style(move |theme: &Theme| card_style(theme, selected)) .into() } fn pill_button<'a>( label: impl Into>, msg: Message, style: fn(&Theme, button::Status) -> button::Style, ) -> Element<'a, Message> { button(text(label.into()).size(12)) .padding([4, 12]) .on_press(msg) .style(style) .into() } /// Square icon variant of [`pill_button`] for glyph-free actions (e.g. remove). fn pill_icon_button<'a>( icon: &'static [u8], msg: Message, style: fn(&Theme, button::Status) -> button::Style, ) -> Element<'a, Message> { button(crate::ui::icons::themed(icon, 11.0)) .padding([5, 9]) .on_press(msg) .style(style) .into() } /// Release dropdown + Install (+ optional unlink) for one repo. fn install_controls<'a>( repo: &str, releases: Vec, selected: Option, removable: bool, ) -> Element<'a, Message> { let repo_s = repo.to_string(); let tags = releases .iter() .map(|release| release.tag.clone()) .collect::>(); let selected_release = selected.as_ref().and_then(|selected| { releases.iter().find(|release| release.tag == *selected) }); let picker: Element<'_, Message> = if tags.is_empty() { text(t!("no releases")).size(11).style(muted_style).into() } else { let r = repo_s.clone(); iced::widget::pick_list(selected, tags, |value| value.to_string()) .on_select(move |tag| Message::PluginReleaseSelect(r.clone(), tag)) .text_size(12) .into() }; let action = match selected_release { Some(release) if ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version) && release.acadrust_compatible && release.rustc_compatible => { pill_button( t!("Install"), Message::PluginInstall(repo_s.clone()), button::success, ) } Some(_) => status_badge(t!("Incompatible"), StatusKind::Danger), None => status_badge(t!("Unavailable"), StatusKind::Muted), }; let mut controls = row![picker, Space::new().width(8), action] .align_y(iced::Center) .spacing(4); if removable { controls = controls.push(Space::new().width(6)); controls = controls.push(pill_icon_button( crate::ui::icons::CLOSE, Message::PluginRepoRemove(repo_s), button::danger, )); } controls.into() } fn market_card<'a>( body: iced::widget::Column<'a, Message>, selected: bool, ) -> Element<'a, Message> { container(body.spacing(4).padding([10, 12])) .width(Fill) .style(move |theme: &Theme| card_style(theme, selected)) .into() } fn repository_display_name(repository: &str) -> String { repository .rsplit_once('/') .map(|(_, name)| name) .unwrap_or(repository) .split(['-', '_']) .filter(|part| !part.is_empty()) .map(|part| { if part.eq_ignore_ascii_case("opencad") { "OpenCAD".to_string() } else { let mut chars = part.chars(); chars .next() .map(|first| first.to_uppercase().collect::() + chars.as_str()) .unwrap_or_default() } }) .collect::>() .join(" ") } fn add_repository_card<'a>(m: &MarketView) -> Element<'a, Message> { let form = row![ text_input(t!("GitHub URL or owner/repository").as_ref(), m.input) .on_input(Message::PluginRepoInput) .on_submit(Message::PluginRepoAdd) .size(13) .width(Fill), Space::new().width(8), pill_button( t!("Add repository"), Message::PluginRepoAdd, button::primary, ), ] .align_y(iced::Center); market_card( column![ text(t!("Add from GitHub")).size(14), text(t!( "Paste a public repository URL. Compatible releases and the README are detected automatically." )) .size(11) .style(muted_style), Space::new().height(3), form, ] .spacing(5), false, ) } fn registry_error_message(error: &str) -> (Cow<'static, str>, Cow<'static, str>) { let error = error.to_ascii_lowercase(); if error.contains("certificate") || error.contains("unknownissuer") || error.contains("unknown issuer") { ( t!("Unable to verify the server certificate"), t!( "Open CAD Studio could not trust the certificate presented for the plugin registry. Check your system certificate and proxy settings, then retry." ), ) } else if error.contains("timed out") || error.contains("timeout") { ( t!("Plugin registry request timed out"), t!( "Check your internet or proxy connection, then retry. Manually added repositories remain available." ), ) } else { ( t!("Unable to load the plugin registry"), t!( "Check your internet or proxy connection, then retry. Manually added repositories remain available." ), ) } } fn registry_notice<'a>(m: &MarketView) -> Option> { if let Some(error) = m.registry_error { let (title, message) = registry_error_message(error); let actions = row![ pill_button( t!("Retry"), Message::PluginRegistryRetry, button::primary, ), Space::new().width(6), pill_button( if m.registry_error_details_open { t!("Hide details") } else { t!("Show details") }, Message::PluginRegistryErrorDetailsToggle, button::secondary, ), Space::new().width(6), pill_button( t!("Copy details"), Message::PluginRegistryCopyDiagnostics, button::secondary, ), ] .align_y(iced::Center); let mut body = column![ text(title).size(13), text(message).size(11).style(muted_style), Space::new().height(3), actions, ] .spacing(5); if m.registry_error_details_open { body = body.push( container(text(error.to_string()).size(10).width(Fill)) .padding(8) .width(Fill) .style(container::bordered_box), ); } return Some( container(body.padding([10, 12])) .width(Fill) .style(|theme: &Theme| { let pair = theme.palette().warning.weak; container::Style { background: Some(Background::Color(pair.color.scale_alpha(0.16))), border: Border { color: pair.color, width: 1.0, radius: 6.0.into(), }, ..Default::default() } }) .into(), ); } (m.registry_loading && m.registry.is_empty()).then(|| { container( column![ text(t!("Loading plugin catalog…")).size(13), text(t!("Connecting securely using your system certificate settings.")) .size(11) .style(muted_style), ] .spacing(5) .padding([10, 12]), ) .width(Fill) .style(container::bordered_box) .into() }) } fn marketplace_section<'a>( m: &MarketView, externals: &[ExternalPlugin], ) -> Element<'a, Message> { let mut col = column![text(t!("Available plugins")).size(13).style(primary_style)].spacing(6); let mut visible = 0usize; if let Some(notice) = registry_notice(m) { col = col.push(notice); } // Curated registry entries (from the OpenCADStudio repo). for e in m.registry { if repository_is_installed(&e.repo, Some(&e.name), externals) || !matches_search(m.search, &[&e.name, &e.description, &e.repo]) { continue; } visible += 1; let tags = m.release_tags.get(&e.repo).cloned().unwrap_or_default(); let selected = m.selected_tag.get(&e.repo).cloned(); let mut info = column![text(e.name.clone()).size(14)].spacing(4); if !e.description.is_empty() { info = info.push(text(e.description.clone()).size(12).style(muted_style)); } let info = button(info.width(Fill)) .width(Fill) .padding(0) .style(button::text) .on_press(Message::PluginReadmeSelect(e.repo.clone())); let actions = row![ Space::new().width(Fill), install_controls(&e.repo, tags, selected, false), ] .align_y(iced::Center); col = col.push(market_card( column![info, actions].spacing(8), m.selected_repo == Some(e.repo.as_str()), )); } // User-linked repositories join the same catalog, but curated and installed // repositories are suppressed so every plugin appears only once. for repo in m.repos { if m.registry.iter().any(|entry| entry.repo == *repo) || repository_is_installed(repo, None, externals) { continue; } let display_name = repository_display_name(repo); if !matches_search(m.search, &[&display_name, repo]) { continue; } visible += 1; let tags = m.release_tags.get(repo).cloned().unwrap_or_default(); let selected = m.selected_tag.get(repo).cloned(); let info = button( column![ text(display_name).size(14), text(t!("Added from GitHub")).size(11).style(muted_style), ] .spacing(4) .width(Fill), ) .width(Fill) .padding(0) .style(button::text) .on_press(Message::PluginReadmeSelect(repo.clone())); let actions = row![ Space::new().width(Fill), install_controls(repo, tags, selected, true), ] .align_y(iced::Center); col = col.push(market_card( column![info, actions].spacing(8), m.selected_repo == Some(repo.as_str()), )); } if visible == 0 && m.registry_error.is_none() && !(m.registry_loading && m.registry.is_empty()) { let message = if m.search.trim().is_empty() { t!("No additional plugins are available.") } else { t!("No available plugins match your search.") }; col = col.push(text(message).size(12).style(muted_style)); } col = col.push(Space::new().height(8)); col = col.push(add_repository_card(m)); if !m.status.is_empty() { col = col.push(text(m.status.to_string()).size(11).style(muted_style)); } col.into() } fn resolve_readme_link(repo: &str, uri: &str) -> String { if uri.starts_with("https://") || uri.starts_with("http://") || uri.starts_with("mailto:") { uri.to_string() } else if uri.starts_with('/') { format!("https://github.com{uri}") } else if uri.starts_with('#') { format!("https://github.com/{repo}{uri}") } else { format!( "https://github.com/{repo}/blob/HEAD/{}", uri.trim_start_matches("./") ) } } fn readme_panel<'a>( market: &MarketView<'a>, theme: &Theme, width: Length, height: Length, ) -> Element<'a, Message> { let Some(repo) = market.selected_repo else { return container( column![ text(t!("Plugin details")).size(16), text(t!("Select a plugin to read its GitHub README.")) .size(12) .style(muted_style), ] .spacing(8), ) .center_x(width) .center_y(height) .width(width) .height(height) .style(container::bordered_box) .into(); }; let name = repo .rsplit_once('/') .map(|(_, name)| name) .unwrap_or(repo) .to_string(); let header = row![ column![ text(name).size(16), text(repo.to_string()).size(11).style(primary_style), ] .spacing(3) .width(width), pill_button( t!("View on GitHub"), Message::OpenUrl(format!("https://github.com/{repo}")), button::secondary, ), ] .align_y(iced::Center); let content: Element<'a, Message> = if market.readme_loading.contains(repo) { container( column![ text(t!("Loading README…")).size(13), text(t!("Fetching the default branch from GitHub.")) .size(11) .style(muted_style), ] .spacing(6), ) .center_x(width) .center_y(height) .width(width) .height(height) .into() } else { match market.readmes.get(repo) { Some(Ok(readme)) => { let source_repo = repo.to_string(); markdown::view( readme.items(), markdown::Settings::with_text_size(13, theme), ) .map(move |uri| { Message::OpenUrl(resolve_readme_link(&source_repo, &uri)) }) } Some(Err(error)) => container( column![ text(t!("README could not be loaded")).size(14), text(error.clone()).size(11).style(muted_style), Space::new().height(4), pill_button( t!("Retry"), Message::PluginReadmeSelect(repo.to_string()), button::secondary, ), ] .spacing(6), ) .center_x(width) .center_y(height) .width(width) .height(height) .into(), None => container( text(t!("Select the plugin again to load its README.")) .size(12) .style(muted_style), ) .center_x(width) .center_y(height) .width(width) .height(height) .into(), } }; let gutter = iced::Padding { top: 8.0, right: SCROLLBAR_GUTTER, bottom: 8.0, left: 0.0, }; let readme = scrollable(container(content).padding(gutter)) .height(height) .width(width); container( column![header, rule::horizontal(1), readme] .spacing(10) .padding([12, 14]) .width(width) .height(height), ) .width(width) .height(height) .style(container::bordered_box) .into() } pub fn view_window<'a>( disabled: &FxHashSet, externals: &[ExternalPlugin], loaded: &FxHashSet, load_errors: &FxHashMap, market: MarketView<'a>, theme: &'a Theme, sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { let width = sizing.width; let height = sizing.height; let title = text(t!("Plugins")).size(20); let subtitle = text(t!("Browse, install, and manage add-ons. Select one to view its README.")) .size(12) .style(muted_style); let mut list = column![].spacing(10); // Installed external packages (from the plugins folder). if externals.is_empty() { list = list.push(text(t!("No plugins installed yet.")).size(13).style(muted_style)); } else { let mut visible_installed = 0usize; for p in externals { let repository = repository_for_external(p, market.registry); if !external_matches_search(p, repository.as_deref(), market.search) { continue; } if visible_installed == 0 { list = list.push(text(t!("Installed")).size(13).style(primary_style)); } visible_installed += 1; let selected = repository.as_deref() == market.selected_repo; let update_tag = repository .as_ref() .and_then(|repo| market.release_tags.get(repo)) .and_then(|releases| newest_update(&p.version, releases)); list = list.push(external_card( p, repository, update_tag, loaded.contains(&p.id), load_errors.get(&p.id).map(String::as_str), disabled.contains(&p.id), selected, )); } if visible_installed == 0 && !market.search.trim().is_empty() { list = list.push( text(t!("No installed plugins match your search.")) .size(12) .style(muted_style), ); } } // Marketplace: install from a linked repository's releases. list = list.push(Space::new().height(14)); list = list.push(marketplace_section(&market, externals)); let gutter = iced::Padding { top: 0.0, right: SCROLLBAR_GUTTER, bottom: 0.0, left: 0.0, }; let catalog = scrollable(container(list.width(Fill)).padding(gutter)) .height(height) .width(Fill); let search = text_input(t!("Search plugins…").as_ref(), market.search) .on_input(Message::PluginSearchInput) .size(13) .padding([7, 10]) .width(Fill); let catalog_pane = column![search, catalog] .spacing(10) .width(Fill) .height(height); let details = readme_panel(&market, theme, width, height); let body = row![ container(catalog_pane) .width(Length::Fixed(410.0)) .height(height), details, ] .spacing(14) .height(height) .width(width); container( column![title, subtitle, Space::new().height(12), body] .spacing(4) .padding(18) .width(width) .height(height), ) .style(|theme: &Theme| container::Style { background: Some(Background::Color( theme.palette().background.base.color, )), ..Default::default() }) .width(width) .height(height) .into() } /// Browser replacement for the native Plugin Manager. #[cfg(target_arch = "wasm32")] pub fn view_web_notice<'a>() -> Element<'a, Message> { let download = button(text(t!("Download desktop app")).size(13)) .on_press(Message::OpenUrl(DESKTOP_DOWNLOAD_URL.to_string())) .padding([9, 18]) .style(|theme: &Theme, status| { let mut style = button::primary(theme, status); style.text_color = iced::Color::WHITE; style }); let icon = container(crate::ui::icons::themed_primary( crate::ui::icons::GEAR, 26.0, )) .center(Length::Fixed(44.0)) .style(|theme: &Theme| container::Style { background: Some(Background::Color( theme.palette().primary.weak.color, )), border: Border { radius: 12.0.into(), ..Default::default() }, ..Default::default() }); let notice = container( column![ icon, text(t!("Plugins are available in the desktop app")) .size(20) .width(Length::Fit) .align_x(iced::alignment::Horizontal::Center), text(t!( "Open CAD Studio plugins are native packages and cannot run inside a browser. Download the desktop app to browse, install, and use plugins." )) .size(13) .width(Length::Fit) .align_x(iced::alignment::Horizontal::Center) .style(muted_style), Space::new().height(4), download, ] .spacing(10) .align_x(iced::alignment::Horizontal::Center) .width(Length::Fit), ) .width(Length::Fit.max(380.0)); container(notice) .center_x(Length::Fit) .padding([16, 20]) .width(Length::Fit) .height(Length::Fit) .into() } #[cfg(test)] mod tests { use super::{newest_update, registry_error_message, repository_display_name}; use crate::plugin::external::ReleaseInfo; #[test] fn newest_update_uses_semver_not_release_order() { let releases = vec![ ReleaseInfo { tag: "v1.4.0".to_string(), api_version: 4, acadrust_source: None, acadrust_declared: false, acadrust_compatible: true, rustc_version: None, rustc_declared: false, rustc_compatible: true, }, ReleaseInfo { tag: "v2.0.0".to_string(), api_version: 4, acadrust_source: None, acadrust_declared: false, acadrust_compatible: true, rustc_version: None, rustc_declared: false, rustc_compatible: true, }, ReleaseInfo { tag: "v1.9.9".to_string(), api_version: 4, acadrust_source: None, acadrust_declared: false, acadrust_compatible: true, rustc_version: None, rustc_declared: false, rustc_compatible: true, }, ]; assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v2.0.0")); assert_eq!(newest_update("2.0.0", &releases), None); } #[test] fn newest_update_ignores_incompatible_releases() { let releases = vec![ ReleaseInfo { tag: "v1.4.0".to_string(), api_version: 4, acadrust_source: Some( "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd".to_string(), ), acadrust_declared: true, acadrust_compatible: true, rustc_version: None, rustc_declared: false, rustc_compatible: true, }, ReleaseInfo { tag: "v2.0.0".to_string(), api_version: 4, acadrust_source: Some( "git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb".to_string(), ), acadrust_declared: true, acadrust_compatible: false, rustc_version: None, rustc_declared: false, rustc_compatible: true, }, ]; assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v1.4.0")); } #[test] fn repository_name_is_human_readable() { assert_eq!( repository_display_name("owner/opencad-storm_sewer-plugin"), "OpenCAD Storm Sewer Plugin", ); } #[test] fn certificate_errors_get_user_friendly_copy() { // The registry message is localised; pin English so the assertion // does not depend on the ambient process locale. crate::i18n::set_language(crate::i18n::Language::EnUs) .expect("English locale must be available"); let (title, message) = registry_error_message("io: invalid peer certificate: UnknownIssuer"); assert_eq!(title, "Unable to verify the server certificate"); assert!(message.contains("system certificate")); assert!(!message.contains("UnknownIssuer")); } }