diff --git a/docs/dev-tools/backends/spm.md b/docs/dev-tools/backends/spm.md index 15f56a3888..b286ac883d 100644 --- a/docs/dev-tools/backends/spm.md +++ b/docs/dev-tools/backends/spm.md @@ -1,6 +1,6 @@ # SPM Backend -You may install executables managed by [Swift Package Manager](https://www.swift.org/documentation/package-manager) directly from GitHub releases. +You may install executables managed by [Swift Package Manager](https://www.swift.org/documentation/package-manager) directly from GitHub or GitLab releases. The code for this is inside of the mise repository at [`./src/backend/spm.rs`](https://github.com/jdx/mise/blob/main/src/backend/spm.rs). @@ -42,3 +42,28 @@ The version will be set in `~/.config/mise/config.toml` with the following forma | GitHub url for specific release version | `spm:https://github.com/tuist/tuist.git@4.15.0` | Other syntax may work but is unsupported and untested. + +## Tool Options + +The following [tool-options](/dev-tools/#tool-options) are available for the backend — these +go in `[tools]` in `mise.toml`. + +### `provider` + +Set the provider type to use for fetching assets and release information. Either `github` or `gitlab` (default is `github`). +Ensure the `provider` is set to the correct type if you use shorthand notation and `api_url` for self-hosted repositories +as the type probably cannot be derived correctly from the URL. + +```toml +[tools] +"spm:patricklorran/ios-settings" = { version = "latest", provider = "gitlab" } +``` + +### `api_url` + +Set the URL for the provider's API. This is useful when using a self-hosted instance. + +```toml +[tools] +"spm:acme/my-tool" = { version = "latest", provider = "gitlab", api_url = "https://gitlab.acme.com/api/v4" } +``` diff --git a/src/backend/spm.rs b/src/backend/spm.rs index b9ec9c2a86..4e3b5b8a66 100644 --- a/src/backend/spm.rs +++ b/src/backend/spm.rs @@ -6,7 +6,7 @@ use crate::config::{Config, Settings}; use crate::git::{CloneOptions, Git}; use crate::install_context::InstallContext; use crate::toolset::ToolVersion; -use crate::{dirs, file, github}; +use crate::{dirs, file, github, gitlab}; use async_trait::async_trait; use eyre::WrapErr; use serde::Deserializer; @@ -17,6 +17,7 @@ use std::{ fmt::{self, Debug}, sync::Arc, }; +use strum::{AsRefStr, EnumString, VariantNames}; use url::Url; use xx::regex; @@ -40,13 +41,26 @@ impl Backend for SPMBackend { } async fn _list_remote_versions(&self, _config: &Arc) -> eyre::Result> { - let repo = SwiftPackageRepo::new(&self.tool_name())?; - Ok(github::list_releases(repo.shorthand.as_str()) - .await? - .into_iter() - .map(|r| r.tag_name) - .rev() - .collect()) + let provider = GitProvider::from_ba(&self.ba); + let repo = SwiftPackageRepo::new(&self.tool_name(), &provider)?; + let releases = match provider.kind { + GitProviderKind::GitLab => { + gitlab::list_releases_from_url(&provider.api_url, repo.shorthand.as_str()) + .await? + .into_iter() + .map(|r| r.tag_name) + .rev() + .collect() + } + _ => github::list_releases_from_url(&provider.api_url, repo.shorthand.as_str()) + .await? + .into_iter() + .map(|r| r.tag_name) + .rev() + .collect(), + }; + + Ok(releases) } async fn install_version_( @@ -66,8 +80,8 @@ impl Backend for SPMBackend { Or install Swift via https://swift.org/download/", ) .await; - - let repo = SwiftPackageRepo::new(&self.tool_name())?; + let provider = GitProvider::from_ba(&self.ba); + let repo = SwiftPackageRepo::new(&self.tool_name(), &provider)?; let revision = if tv.version == "latest" { self.latest_stable_version(&ctx.config) .await? @@ -208,6 +222,56 @@ impl SPMBackend { } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitProvider { + pub api_url: String, + pub kind: GitProviderKind, +} + +impl Default for GitProvider { + fn default() -> Self { + Self { + api_url: github::API_URL.to_string(), + kind: GitProviderKind::GitHub, + } + } +} + +#[derive(AsRefStr, Clone, Debug, Eq, PartialEq, EnumString, VariantNames)] +pub enum GitProviderKind { + #[strum(serialize = "github")] + GitHub, + #[strum(serialize = "gitlab")] + GitLab, +} + +impl GitProvider { + fn from_ba(ba: &BackendArg) -> Self { + let opts = ba.opts(); + + let default_provider = GitProviderKind::GitHub.as_ref().to_string(); + let provider = opts.get("provider").unwrap_or(&default_provider); + let kind = if ba.tool_name.contains("gitlab.com") { + GitProviderKind::GitLab + } else { + match provider.to_lowercase().as_str() { + "gitlab" => GitProviderKind::GitLab, + _ => GitProviderKind::GitHub, + } + }; + + let api_url = match opts.get("api_url") { + Some(api_url) => api_url.trim_end_matches('/').to_string(), + None => match kind { + GitProviderKind::GitHub => github::API_URL.to_string(), + GitProviderKind::GitLab => gitlab::API_URL.to_string(), + }, + }; + + Self { api_url, kind } + } +} + #[derive(Debug)] struct SwiftPackageRepo { /// https://github.com/owner/repo.git @@ -218,26 +282,31 @@ struct SwiftPackageRepo { impl SwiftPackageRepo { /// Parse the slug or the full URL of a GitHub package repository. - fn new(name: &str) -> Result { + fn new(name: &str, provider: &GitProvider) -> Result { let name = name.strip_prefix("spm:").unwrap_or(name); - let shorthand_regex = regex!(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9._-]+$"); - let shorthand_in_url_regex = - regex!(r"https://github.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9._-]+)\.git"); - - let shorthand = if let Some(Some(m)) = - shorthand_in_url_regex.captures(name).map(|c| c.get(1)) - { - m.as_str() + let shorthand_regex = regex!(r"^(?:[a-zA-Z0-9_-]+/)+[a-zA-Z0-9._-]+$"); + let shorthand_in_url_regex = regex!( + r"^https://(?P[^/]+)/(?P(?:[a-zA-Z0-9_-]+/)+[a-zA-Z0-9._-]+)\.git" + ); + + let (shorthand, url) = if let Some(caps) = shorthand_in_url_regex.captures(name) { + let shorthand = caps.name("shorthand").unwrap().as_str(); + let url = Url::parse(name)?; + (shorthand, url) } else if shorthand_regex.is_match(name) { - name + let host = match provider.kind { + GitProviderKind::GitHub => "github.com", + GitProviderKind::GitLab => "gitlab.com", + }; + let url_str = format!("https://{}/{}.git", host, name); + let url = Url::parse(&url_str)?; + (name, url) } else { Err(eyre::eyre!( - "Invalid Swift package repository: {}. The repository should either be a GitHub repository slug, owner/name, or the complete URL, https://github.com/owner/name.", + "Invalid Swift package repository: {}. The repository should either be a repository slug (owner/name), or the complete URL (e.g. https://github.com/owner/name.git).", name ))? }; - let url_str = format!("https://github.com/{shorthand}.git"); - let url = Url::parse(&url_str)?; Ok(Self { url, @@ -248,54 +317,134 @@ impl SwiftPackageRepo { #[cfg(test)] mod tests { - use crate::config::Config; + use crate::{config::Config, toolset::ToolVersionOptions}; use super::*; + use indexmap::indexmap; use pretty_assertions::assert_str_eq; + #[tokio::test] + async fn test_git_provider_from_ba() { + // Example of defining a capture (closure) in Rust: + let get_ba = |tool: String, opts: Option| { + BackendArg::new_raw("spm".to_string(), Some(tool.clone()), tool, opts) + }; + + assert_eq!( + GitProvider::from_ba(&get_ba("tool".to_string(), None)), + GitProvider { + api_url: github::API_URL.to_string(), + kind: GitProviderKind::GitHub + } + ); + + assert_eq!( + GitProvider::from_ba(&get_ba( + "tool".to_string(), + Some(ToolVersionOptions { + opts: indexmap![ + "provider".to_string() => "gitlab".to_string() + ], + ..Default::default() + }) + )), + GitProvider { + api_url: gitlab::API_URL.to_string(), + kind: GitProviderKind::GitLab + } + ); + + assert_eq!( + GitProvider::from_ba(&get_ba( + "tool".to_string(), + Some(ToolVersionOptions { + opts: indexmap![ + "api_url".to_string() => "https://gitlab.acme.com/api/v4".to_string(), + "provider".to_string() => "gitlab".to_string(), + ], + ..Default::default() + }) + )), + GitProvider { + api_url: "https://gitlab.acme.com/api/v4".to_string(), + kind: GitProviderKind::GitLab + } + ); + } + #[tokio::test] async fn test_spm_repo_init_by_shorthand() { let _config = Config::get().await.unwrap(); - let package_name = "nicklockwood/SwiftFormat"; - let package_repo = SwiftPackageRepo::new(package_name).unwrap(); + let package_repo = + SwiftPackageRepo::new("nicklockwood/SwiftFormat", &GitProvider::default()).unwrap(); assert_str_eq!( package_repo.url.as_str(), "https://github.com/nicklockwood/SwiftFormat.git" ); assert_str_eq!(package_repo.shorthand, "nicklockwood/SwiftFormat"); + + let package_repo = SwiftPackageRepo::new( + "acme/nicklockwood/SwiftFormat", + &GitProvider { + api_url: gitlab::API_URL.to_string(), + kind: GitProviderKind::GitLab, + }, + ) + .unwrap(); + assert_str_eq!( + package_repo.url.as_str(), + "https://gitlab.com/acme/nicklockwood/SwiftFormat.git" + ); + assert_str_eq!(package_repo.shorthand, "acme/nicklockwood/SwiftFormat"); } #[tokio::test] async fn test_spm_repo_init_name() { let _config = Config::get().await.unwrap(); assert!( - SwiftPackageRepo::new("owner/name.swift").is_ok(), + SwiftPackageRepo::new("owner/name.swift", &GitProvider::default()).is_ok(), "name part can contain ." ); assert!( - SwiftPackageRepo::new("owner/name_swift").is_ok(), + SwiftPackageRepo::new("owner/name_swift", &GitProvider::default()).is_ok(), "name part can contain _" ); assert!( - SwiftPackageRepo::new("owner/name-swift").is_ok(), + SwiftPackageRepo::new("owner/name-swift", &GitProvider::default()).is_ok(), "name part can contain -" ); assert!( - SwiftPackageRepo::new("owner/name$swift").is_err(), + SwiftPackageRepo::new("owner/name$swift", &GitProvider::default()).is_err(), "name part cannot contain characters other than a-zA-Z0-9._-" ); } #[tokio::test] async fn test_spm_repo_init_by_url() { - let _config = Config::get().await.unwrap(); - let package_name = "https://github.com/nicklockwood/SwiftFormat.git"; - let package_repo = SwiftPackageRepo::new(package_name).unwrap(); + let package_repo = SwiftPackageRepo::new( + "https://github.com/nicklockwood/SwiftFormat.git", + &GitProvider::default(), + ) + .unwrap(); assert_str_eq!( package_repo.url.as_str(), "https://github.com/nicklockwood/SwiftFormat.git" ); assert_str_eq!(package_repo.shorthand, "nicklockwood/SwiftFormat"); + + let package_repo = SwiftPackageRepo::new( + "https://gitlab.acme.com/acme/someuser/SwiftTool.git", + &GitProvider { + api_url: "https://api.gitlab.acme.com/api/v4".to_string(), + kind: GitProviderKind::GitLab, + }, + ) + .unwrap(); + assert_str_eq!( + package_repo.url.as_str(), + "https://gitlab.acme.com/acme/someuser/SwiftTool.git" + ); + assert_str_eq!(package_repo.shorthand, "acme/someuser/SwiftTool"); } }