#![cfg_attr(not(feature = "std"), no_std)]
extern crate alloc;
#[cfg(not(feature = "std"))]
extern crate std;
use core::convert::Infallible;
use core::str::FromStr;
use derive_more::Display;
use fluent_uri::{Uri, UriRef};
use serde::{Deserialize, Serialize};
#[cfg(feature = "std")]
use tracing::debug;
use tracing::{error, trace, warn};
use urlencoding::encode;
#[cfg(feature = "analysis")]
pub mod analyzer;
#[cfg(feature = "doctor")]
pub mod doctor;
#[cfg(feature = "std")]
pub mod io;
pub mod prelude;
pub mod schema;
pub mod util;
#[cfg(all(feature = "std", feature = "analysis"))]
use crate::analyzer::{link_check, Check};
#[cfg(feature = "std")]
use crate::io::http::get;
#[cfg(feature = "std")]
use crate::prelude::PathBuf;
use crate::prelude::{format, String, ToString, Vec};
use crate::util::Label;
#[derive(Clone, Debug, Display, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Location {
Simple(String),
#[display("{uri}")]
Detailed {
scheme: Scheme,
uri: String,
},
}
#[derive(Clone, Debug, Display, Serialize, Deserialize)]
#[serde(tag = "provider", rename_all = "lowercase")]
pub enum Repository {
#[display("git")]
Git {
location: Location,
},
#[display("github")]
GitHub {
#[serde(alias = "uri")]
location: Location,
},
#[display("gitlab")]
GitLab {
id: Option<u64>,
#[serde(alias = "uri")]
location: Location,
},
}
#[derive(Clone, Debug, Display, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Scheme {
#[display("https")]
HTTPS,
#[display("http")]
HTTP,
#[display("file")]
File,
Unsupported,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Release {
pub name: String,
pub tag_name: String,
#[serde(alias = "body")]
pub description: String,
pub created_at: String,
#[serde(alias = "published_at")]
pub released_at: String,
pub message: Option<String>,
}
impl FromStr for Location {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match UriRef::parse(s).ok().and_then(|uri| uri.scheme()) {
| Some(scheme) => {
let detected = match scheme.as_str().to_ascii_lowercase().as_str() {
| "https" => Scheme::HTTPS,
| "http" => Scheme::HTTP,
| "file" => Scheme::File,
| _ => Scheme::Unsupported,
};
Ok(Location::Detailed {
scheme: detected,
uri: s.to_string(),
})
}
| None => Ok(Location::Simple(s.to_string())),
}
}
}
impl Location {
pub fn hash(&self) -> String {
let host = self.host().unwrap_or_default().replace('.', "_");
let segments = self
.path()
.map(|p| {
p.split('/')
.filter(|s| !(s.is_empty() || *s == "."))
.map(|s| s.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
[host, segments.join("_").to_lowercase()]
.into_iter()
.filter(|x| !x.is_empty())
.collect::<Vec<String>>()
.join("_")
}
pub fn scheme(&self) -> Scheme {
match self {
| Location::Simple(value) => match Uri::parse(value.as_str()) {
| Ok(uri) => match uri.scheme().as_str().to_ascii_lowercase().as_str() {
| "https" => Scheme::HTTPS,
| "http" => Scheme::HTTP,
| "file" => Scheme::File,
| _ => Scheme::Unsupported,
},
| Err(_) => Scheme::Unsupported,
},
| Location::Detailed { scheme, .. } => scheme.clone(),
}
}
#[cfg(all(feature = "std", feature = "analysis"))]
pub async fn exists(self) -> bool {
let uri = self.uri();
let scheme = self.scheme();
if scheme == Scheme::HTTP {
warn!("=> {} HTTP is supported but only advised in local development scenarios", Label::skip());
}
match scheme {
| Scheme::HTTPS | Scheme::HTTP => match uri {
| Some(uri) => match link_check(Some(uri), None).await {
| Check { success, .. } if success => true,
| _ => false,
},
| None => false,
},
| Scheme::File => match uri {
| Some(_) => PathBuf::from(self.path().unwrap_or_default()).exists(),
| None => false,
},
| Scheme::Unsupported => false,
}
}
pub fn uri(&self) -> Option<String> {
match self {
| Location::Simple(value) => Some(value.clone()),
| Location::Detailed { scheme, uri } => match Uri::parse(uri.as_str()) {
| Ok(parsed) => {
let authority = parsed.authority().map(|auth| auth.as_str().to_string());
let path = parsed.path().to_string();
let query = parsed.query().map(|q| format!("?{q}")).unwrap_or_default();
let fragment = parsed.fragment().map(|f| format!("#{f}")).unwrap_or_default();
Some(match authority {
| Some(auth) if !auth.is_empty() => format!("{scheme}://{auth}{path}{query}{fragment}"),
| _ => format!("{scheme}:{path}{query}{fragment}"),
})
}
| Err(_) => {
warn!("=> {} Parse URI - {uri}", Label::fail());
Some(format!("{scheme}://{uri}"))
}
},
}
}
pub fn host(&self) -> Option<String> {
match self.uri() {
| Some(value) => Uri::parse(value.as_str())
.ok()
.and_then(|uri| uri.authority().map(|auth| auth.host().to_string())),
| None => None,
}
}
pub fn path(&self) -> Option<String> {
match self.uri() {
| Some(value) => Uri::parse(value.as_str()).ok().map(|uri| uri.path().to_string()),
| None => None,
}
}
pub fn port(&self) -> Option<u16> {
match self.uri() {
| Some(value) => Uri::parse(value.as_str())
.ok()
.and_then(|uri| uri.authority().and_then(|auth| auth.port_to_u16().ok()).flatten()),
| None => None,
}
}
}
impl Default for Repository {
fn default() -> Self {
Self::Git {
location: Location::Simple("file:///".to_string()),
}
}
}
impl Repository {
pub fn domain(&self) -> Option<String> {
self.location().host()
}
pub fn is_local(&self) -> bool {
let local_schemes = [Scheme::File];
local_schemes.contains(&self.clone().location().scheme())
}
#[cfg(feature = "std")]
pub async fn latest_release(self) -> Option<Release> {
match self.releases().await {
| releases if releases.is_empty() => None,
| releases => match releases.into_iter().next() {
| Some(release) => {
trace!("=> {} Latest {:#?}", Label::using(), release);
Some(release)
}
| None => None,
},
}
}
pub fn location(&self) -> Location {
match self.clone() {
| Repository::Git { location, .. } => location,
| Repository::GitHub { location, .. } => location,
| Repository::GitLab { location, .. } => location,
}
}
pub fn id(&self) -> Option<String> {
match self {
| Repository::Git { .. } => None,
| Repository::GitHub { .. } => None,
| Repository::GitLab { id, location } => match id {
| Some(value) => Some(value.to_string()),
| None => match location.path() {
| Some(path) => match path.strip_prefix('/') {
| Some(stripped) if !stripped.is_empty() => {
let encoded = encode(stripped).to_string();
trace!(encoded, "=> {} ID", Label::using());
Some(encoded)
}
| _ => None,
},
| None => {
warn!("=> {} Parse GitLab URI", Label::fail());
None
}
},
},
}
}
#[cfg(feature = "std")]
async fn releases(self) -> Vec<Release> {
let maybe_url = match &self {
| Repository::Git { .. } => None,
| Repository::GitHub { location } => {
let host = location.host();
let path = location.path();
match (host, path) {
| (Some(host), Some(path)) => Some(format!("https://api.{host}/repos{path}/releases")),
| (None, _) => {
error!("=> {} Parse GitHub URI host", Label::fail());
None
}
| (_, None) => {
error!("=> {} Parse GitHub URI", Label::fail());
None
}
}
}
| Repository::GitLab { location, .. } => match self.id() {
| Some(id) => match location.host() {
| Some(host) => Some(format!("https://{host}/api/v4/projects/{id}/releases")),
| None => {
error!("=> {} Parse GitLab URI host", Label::fail());
None
}
},
| None => None,
},
};
if let Some(url) = maybe_url {
debug!(url, "=> {}", Label::using());
match get(url).send().await {
| Ok(response) => {
let text = response.text().await;
match text {
| Ok(text) => {
if text.contains("API rate limit exceeded") {
error!("=> {} GitHub API rate limit exceeded", Label::fail());
vec![]
} else {
let releases: Vec<Release> = match serde_json::from_str(&text) {
| Ok(values) => values,
| Err(why) => {
error!("=> {} Parse {} API JSON response - {why}", self, Label::fail());
vec![]
}
};
releases
}
}
| Err(why) => {
error!("=> {} Parse {} API text response - {why}", self, Label::fail());
vec![]
}
}
}
| Err(why) => {
error!("=> {} Download {} releases - {why}", self, Label::fail());
vec![]
}
}
} else {
vec![]
}
}
pub fn raw_url(&self, path: String) -> Option<String> {
match self {
| Repository::GitHub { location, .. } => match location.path() {
| Some(ref value) => Some(format!("https://raw.githubusercontent.com{value}/refs/heads/main/{path}")),
| None => {
error!("=> {} Parse GitHub URI", Label::fail());
None
}
},
| Repository::GitLab { location, .. } => Some(format!("{location}/-/raw/main/{path}")),
| Repository::Git { .. } => None,
}
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::arithmetic_side_effects,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
clippy::unwrap_used
)]
use super::{Location, Repository};
#[test]
fn test_repository_default_is_local_git() {
let repository = Repository::default();
assert!(repository.is_local());
match repository {
| Repository::Git { location } => {
assert_eq!(location.to_string(), "file:///");
}
| _ => panic!("Repository default should be Git with local file URI"),
}
}
#[test]
fn test_repository_id_prefers_explicit_gitlab_id() {
let repository = Repository::GitLab {
id: Some(16689),
location: Location::Simple("https://code.ornl.gov/research-enablement/acorn".to_string()),
};
assert_eq!(repository.id(), Some("16689".to_string()));
}
#[test]
fn test_repository_id_falls_back_to_encoded_gitlab_path() {
let repository = Repository::GitLab {
id: None,
location: Location::Simple("https://code.ornl.gov/research-enablement/acorn".to_string()),
};
assert_eq!(repository.id(), Some("research-enablement%2Facorn".to_string()));
}
#[test]
fn test_repository_id_returns_none_without_gitlab_id_or_valid_uri() {
let repository = Repository::GitLab {
id: None,
location: Location::Simple("not a uri".to_string()),
};
assert_eq!(repository.id(), None);
}
}
#[cfg(all(test, feature = "std"))]
mod test;