use std::borrow::Cow;
use std::fmt;
use std::str::FromStr;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use url::Url;
use super::names::MoltbookSubmoltName;
pub const GITHUB_REPO_REMOTE_ERROR_MESSAGE: &str = "repo_url must be a GitHub HTTPS repository URL or git@github.com:{owner}/{repo}.git SSH remote";
pub const GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE: &str = "pr_url must be a GitHub HTTPS pull request URL like https://github.com/{owner}/{repo}/pull/{number}";
pub const EXTERNAL_DATA_URL_ERROR_MESSAGE: &str = "external data url must be an HTTPS URL";
pub const MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE: &str =
"moltbook submolt url must be https://www.moltbook.com/m/{submolt-name}";
pub const MOLTBOOK_POST_URL_ERROR_MESSAGE: &str =
"moltbook post url must be https://www.moltbook.com/post/{post-id}";
pub const GITHUB_APP_REDIRECT_URL_ERROR_MESSAGE: &str =
"GitHub sign-in redirect URL must be an absolute HTTP(S) URL without query or fragment";
pub const GITHUB_APP_AUTHORIZE_URL_ERROR_MESSAGE: &str =
"GitHub sign-in authorize URL must be https://github.com/login/oauth/authorize";
pub const GITHUB_SIGN_IN_AUTHORIZATION_URL_ERROR_MESSAGE: &str =
"GitHub sign-in authorization URL must be an HTTPS GitHub authorize URL without fragment";
pub const GITHUB_APP_TOKEN_URL_ERROR_MESSAGE: &str =
"GitHub sign-in token URL must be https://github.com/login/oauth/access_token";
pub const GITHUB_API_USER_URL_ERROR_MESSAGE: &str =
"github API user URL must be https://api.github.com/user";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UrlFieldError {
message: &'static str,
}
impl UrlFieldError {
const fn new(message: &'static str) -> Self {
Self { message }
}
}
impl fmt::Display for UrlFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.message)
}
}
impl std::error::Error for UrlFieldError {}
macro_rules! impl_string_url_serde {
($type_name:ident) => {
impl Serialize for $type_name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for $type_name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::from_str(&value).map_err(serde::de::Error::custom)
}
}
};
}
macro_rules! impl_string_url_schema {
($type_name:ident, $schema_name:literal, $pattern:literal) => {
impl JsonSchema for $type_name {
fn inline_schema() -> bool {
true
}
fn schema_name() -> Cow<'static, str> {
$schema_name.into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"type": "string",
"format": "uri",
"pattern": $pattern
})
}
}
};
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum GithubRepoRemote {
Https {
url: Url,
key: GithubRepoKey,
},
Ssh(GithubSshRepoRemote),
}
impl GithubRepoRemote {
pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
value.as_ref().parse()
}
pub fn as_str(&self) -> &str {
match self {
Self::Https { url, .. } => url.as_str(),
Self::Ssh(remote) => remote.as_str(),
}
}
pub fn repository_key(&self) -> &GithubRepoKey {
match self {
Self::Https { key, .. } => key,
Self::Ssh(remote) => remote.repository_key(),
}
}
}
impl fmt::Display for GithubRepoRemote {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for GithubRepoRemote {
type Err = UrlFieldError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let value = value.trim();
if value.starts_with("git@github.com:") {
return Ok(Self::Ssh(GithubSshRepoRemote::try_new(value)?));
}
let url = parse_url(value, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
let key = github_https_repo_key(&url)?;
Ok(Self::Https { url, key })
}
}
impl Serialize for GithubRepoRemote {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for GithubRepoRemote {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::from_str(&value).map_err(serde::de::Error::custom)
}
}
impl JsonSchema for GithubRepoRemote {
fn inline_schema() -> bool {
true
}
fn schema_name() -> Cow<'static, str> {
"GithubRepoRemote".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"type": "string",
"pattern": r"^(https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?|git@github\.com:[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\.git)$"
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GithubSshRepoRemote {
value: String,
key: GithubRepoKey,
}
impl GithubSshRepoRemote {
fn try_new(value: &str) -> Result<Self, UrlFieldError> {
reject_whitespace_or_control(value, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
let Some(rest) = value.strip_prefix("git@github.com:") else {
return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
};
let Some((owner, repo_with_suffix)) = rest.split_once('/') else {
return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
};
if repo_with_suffix.contains('/') {
return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
}
let Some(repo) = repo_with_suffix.strip_suffix(".git") else {
return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
};
let key = GithubRepoKey::try_new(owner, repo)?;
Ok(Self {
value: format!("git@github.com:{owner}/{repo}.git"),
key,
})
}
fn as_str(&self) -> &str {
&self.value
}
fn repository_key(&self) -> &GithubRepoKey {
&self.key
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GithubRepoKey(String);
impl GithubRepoKey {
fn try_new(owner: &str, repo: &str) -> Result<Self, UrlFieldError> {
validate_github_path_segment(owner, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
validate_github_path_segment(repo, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
Ok(Self(format!(
"{}/{}",
owner.to_ascii_lowercase(),
repo.to_ascii_lowercase()
)))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for GithubRepoKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GithubPullRequestUrl(Url);
impl GithubPullRequestUrl {
pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
value.as_ref().parse()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn repository_key(&self) -> Result<GithubRepoKey, UrlFieldError> {
github_pull_request_parts(&self.0)
.map(|(owner, repo, _number)| GithubRepoKey(format!("{owner}/{repo}")))
}
pub fn number(&self) -> Result<String, UrlFieldError> {
github_pull_request_parts(&self.0).map(|(_owner, _repo, number)| number)
}
}
impl fmt::Display for GithubPullRequestUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for GithubPullRequestUrl {
type Err = UrlFieldError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let url = parse_url(value.trim(), GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
validate_github_https_pull_request_url(&url)?;
Ok(Self(url))
}
}
impl_string_url_serde!(GithubPullRequestUrl);
impl_string_url_schema!(
GithubPullRequestUrl,
"GithubPullRequestUrl",
r"^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/pull/[0-9]+$"
);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ExternalDataUrl(Url);
impl ExternalDataUrl {
pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
value.as_ref().parse()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl fmt::Display for ExternalDataUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for ExternalDataUrl {
type Err = UrlFieldError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let url = parse_url(value.trim(), EXTERNAL_DATA_URL_ERROR_MESSAGE)?;
validate_https_url(&url, EXTERNAL_DATA_URL_ERROR_MESSAGE)?;
Ok(Self(url))
}
}
impl_string_url_serde!(ExternalDataUrl);
impl_string_url_schema!(ExternalDataUrl, "ExternalDataUrl", r"^https://[^?#]+$");
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MoltbookSubmoltUrl(Url);
impl MoltbookSubmoltUrl {
pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
value.as_ref().parse()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn submolt_name(&self) -> Result<MoltbookSubmoltName, UrlFieldError> {
moltbook_submolt_name(&self.0)
}
}
impl fmt::Display for MoltbookSubmoltUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for MoltbookSubmoltUrl {
type Err = UrlFieldError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let url = parse_url(value.trim(), MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE)?;
validate_moltbook_submolt_url(&url)?;
Ok(Self(url))
}
}
impl_string_url_serde!(MoltbookSubmoltUrl);
impl_string_url_schema!(
MoltbookSubmoltUrl,
"MoltbookSubmoltUrl",
r"^https://www\.moltbook\.com/m/[a-z0-9](?:[a-z0-9]|-(?!-)){0,28}[a-z0-9]$"
);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MoltbookPostUrl(Url);
impl MoltbookPostUrl {
pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
value.as_ref().parse()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl fmt::Display for MoltbookPostUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for MoltbookPostUrl {
type Err = UrlFieldError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let url = parse_url(value.trim(), MOLTBOOK_POST_URL_ERROR_MESSAGE)?;
validate_moltbook_post_url(&url)?;
Ok(Self(url))
}
}
impl_string_url_serde!(MoltbookPostUrl);
impl_string_url_schema!(
MoltbookPostUrl,
"MoltbookPostUrl",
r"^https://www\.moltbook\.com/post/[A-Za-z0-9_-]+$"
);
macro_rules! define_url_wrapper {
($type_name:ident, $schema_name:literal, $validator:ident, $message:ident) => {
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct $type_name(Url);
impl $type_name {
pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
value.as_ref().parse()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn to_url(&self) -> Url {
self.0.clone()
}
}
impl fmt::Display for $type_name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for $type_name {
type Err = UrlFieldError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let url = parse_url(value.trim(), $message)?;
$validator(&url)?;
Ok(Self(url))
}
}
impl_string_url_serde!($type_name);
impl_string_url_schema!($type_name, $schema_name, r"^https?://[^?#]+$");
};
}
define_url_wrapper!(
GithubAppRedirectUrl,
"GithubAppRedirectUrl",
validate_github_app_redirect_url,
GITHUB_APP_REDIRECT_URL_ERROR_MESSAGE
);
define_url_wrapper!(
GithubAppAuthorizeUrl,
"GithubAppAuthorizeUrl",
validate_github_app_authorize_url,
GITHUB_APP_AUTHORIZE_URL_ERROR_MESSAGE
);
define_url_wrapper!(
GithubAppTokenUrl,
"GithubAppTokenUrl",
validate_github_app_token_url,
GITHUB_APP_TOKEN_URL_ERROR_MESSAGE
);
define_url_wrapper!(
GithubApiUserUrl,
"GithubApiUserUrl",
validate_github_api_user_url,
GITHUB_API_USER_URL_ERROR_MESSAGE
);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GithubSignInAuthorizationUrl(Url);
impl GithubSignInAuthorizationUrl {
pub fn try_from_url(url: Url) -> Result<Self, UrlFieldError> {
validate_github_sign_in_authorization_url(&url)?;
Ok(Self(url))
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl fmt::Display for GithubSignInAuthorizationUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for GithubSignInAuthorizationUrl {
type Err = UrlFieldError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let url = parse_url(value.trim(), GITHUB_SIGN_IN_AUTHORIZATION_URL_ERROR_MESSAGE)?;
Self::try_from_url(url)
}
}
impl_string_url_serde!(GithubSignInAuthorizationUrl);
impl_string_url_schema!(
GithubSignInAuthorizationUrl,
"GithubSignInAuthorizationUrl",
r"^https://github\.com/login/oauth/authorize\?[^#]+$"
);
fn parse_url(value: &str, message: &'static str) -> Result<Url, UrlFieldError> {
reject_whitespace_or_control(value, message)?;
Url::parse(value).map_err(|_| UrlFieldError::new(message))
}
fn github_https_repo_key(url: &Url) -> Result<GithubRepoKey, UrlFieldError> {
validate_github_https_base(url, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
let segments = github_path_segments(url, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
let [owner, repo_with_suffix] = segments.as_slice() else {
return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
};
let repo = repo_with_suffix
.strip_suffix(".git")
.unwrap_or(repo_with_suffix.as_str());
GithubRepoKey::try_new(owner, repo)
}
fn validate_github_https_pull_request_url(url: &Url) -> Result<(), UrlFieldError> {
validate_github_https_base(url, GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
github_pull_request_parts(url).map(|_| ())
}
fn github_pull_request_parts(url: &Url) -> Result<(String, String, String), UrlFieldError> {
let segments = github_path_segments(url, GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
let [owner, repo, pull, number] = segments.as_slice() else {
return Err(UrlFieldError::new(GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE));
};
if pull != "pull" {
return Err(UrlFieldError::new(GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE));
}
validate_github_path_segment(owner, GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
validate_github_path_segment(repo, GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(UrlFieldError::new(GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE));
}
Ok((
owner.to_ascii_lowercase(),
repo.to_ascii_lowercase(),
number.to_string(),
))
}
fn validate_github_app_redirect_url(url: &Url) -> Result<(), UrlFieldError> {
if !matches!(url.scheme(), "http" | "https")
|| url.cannot_be_a_base()
|| url.host_str().is_none()
|| url_has_userinfo(url)
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(UrlFieldError::new(GITHUB_APP_REDIRECT_URL_ERROR_MESSAGE));
}
Ok(())
}
fn validate_github_app_authorize_url(url: &Url) -> Result<(), UrlFieldError> {
validate_exact_https_url(
url,
"github.com",
"/login/oauth/authorize",
GITHUB_APP_AUTHORIZE_URL_ERROR_MESSAGE,
)
}
fn validate_github_sign_in_authorization_url(url: &Url) -> Result<(), UrlFieldError> {
if url.scheme() != "https"
|| url.cannot_be_a_base()
|| url.host_str() != Some("github.com")
|| url.port().is_some()
|| url.path() != "/login/oauth/authorize"
|| url.query().is_none()
|| url.fragment().is_some()
{
return Err(UrlFieldError::new(
GITHUB_SIGN_IN_AUTHORIZATION_URL_ERROR_MESSAGE,
));
}
Ok(())
}
fn validate_github_app_token_url(url: &Url) -> Result<(), UrlFieldError> {
validate_exact_https_url(
url,
"github.com",
"/login/oauth/access_token",
GITHUB_APP_TOKEN_URL_ERROR_MESSAGE,
)
}
fn validate_github_api_user_url(url: &Url) -> Result<(), UrlFieldError> {
validate_exact_https_url(
url,
"api.github.com",
"/user",
GITHUB_API_USER_URL_ERROR_MESSAGE,
)
}
fn validate_moltbook_submolt_url(url: &Url) -> Result<(), UrlFieldError> {
validate_moltbook_base(url, MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE)?;
moltbook_submolt_name(url).map(|_| ())
}
fn validate_moltbook_post_url(url: &Url) -> Result<(), UrlFieldError> {
validate_moltbook_base(url, MOLTBOOK_POST_URL_ERROR_MESSAGE)?;
let segments = moltbook_path_segments(url, MOLTBOOK_POST_URL_ERROR_MESSAGE)?;
let [post, post_id] = segments.as_slice() else {
return Err(UrlFieldError::new(MOLTBOOK_POST_URL_ERROR_MESSAGE));
};
if post != "post" || !has_moltbook_post_id_syntax(post_id) {
return Err(UrlFieldError::new(MOLTBOOK_POST_URL_ERROR_MESSAGE));
}
Ok(())
}
fn moltbook_submolt_name(url: &Url) -> Result<MoltbookSubmoltName, UrlFieldError> {
let segments = moltbook_path_segments(url, MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE)?;
let [m, name] = segments.as_slice() else {
return Err(UrlFieldError::new(MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE));
};
if m != "m" {
return Err(UrlFieldError::new(MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE));
}
let parsed = MoltbookSubmoltName::try_new(name.to_string())
.map_err(|_| UrlFieldError::new(MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE))?;
if parsed.as_str() != name {
return Err(UrlFieldError::new(MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE));
}
Ok(parsed)
}
fn validate_moltbook_base(url: &Url, message: &'static str) -> Result<(), UrlFieldError> {
validate_https_url(url, message)?;
if url.host_str() != Some("www.moltbook.com")
|| url.port().is_some()
|| !url.username().is_empty()
|| url.password().is_some()
{
return Err(UrlFieldError::new(message));
}
Ok(())
}
fn moltbook_path_segments(url: &Url, message: &'static str) -> Result<Vec<String>, UrlFieldError> {
let Some(segments) = url.path_segments() else {
return Err(UrlFieldError::new(message));
};
let segments: Vec<String> = segments.map(ToString::to_string).collect();
if segments.iter().any(|segment| segment.is_empty()) {
return Err(UrlFieldError::new(message));
}
Ok(segments)
}
fn has_moltbook_post_id_syntax(value: &str) -> bool {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
}
fn validate_exact_https_url(
url: &Url,
host: &str,
path: &str,
message: &'static str,
) -> Result<(), UrlFieldError> {
validate_https_url(url, message)?;
if url.host_str() != Some(host)
|| url.port().is_some()
|| url_has_userinfo(url)
|| url.path() != path
{
return Err(UrlFieldError::new(message));
}
Ok(())
}
fn url_has_userinfo(url: &Url) -> bool {
!url.username().is_empty() || url.password().is_some()
}
fn validate_github_https_base(url: &Url, message: &'static str) -> Result<(), UrlFieldError> {
validate_https_url(url, message)?;
if url.host_str() != Some("github.com")
|| url.port().is_some()
|| !url.username().is_empty()
|| url.password().is_some()
{
return Err(UrlFieldError::new(message));
}
Ok(())
}
fn validate_https_url(url: &Url, message: &'static str) -> Result<(), UrlFieldError> {
if url.scheme() != "https" || url.cannot_be_a_base() || url.host_str().is_none() {
return Err(UrlFieldError::new(message));
}
if url.query().is_some() || url.fragment().is_some() {
return Err(UrlFieldError::new(message));
}
Ok(())
}
fn github_path_segments(url: &Url, message: &'static str) -> Result<Vec<String>, UrlFieldError> {
let Some(segments) = url.path_segments() else {
return Err(UrlFieldError::new(message));
};
let segments: Vec<String> = segments.map(ToString::to_string).collect();
if segments.iter().any(|segment| segment.is_empty()) {
return Err(UrlFieldError::new(message));
}
Ok(segments)
}
fn validate_github_path_segment(value: &str, message: &'static str) -> Result<(), UrlFieldError> {
if value.is_empty()
|| value == ".git"
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
{
return Err(UrlFieldError::new(message));
}
Ok(())
}
fn reject_whitespace_or_control(value: &str, message: &'static str) -> Result<(), UrlFieldError> {
if value.is_empty() || value.chars().any(|c| c.is_whitespace() || c.is_control()) {
Err(UrlFieldError::new(message))
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{
ExternalDataUrl, GithubApiUserUrl, GithubAppAuthorizeUrl, GithubAppRedirectUrl,
GithubAppTokenUrl, GithubPullRequestUrl, GithubRepoRemote, MoltbookPostUrl,
MoltbookSubmoltUrl,
};
#[test]
fn parses_github_repo_remotes() {
let https =
GithubRepoRemote::try_new("https://github.com/Agentics-Reifying/Agentics-Challenges")
.expect("HTTPS remote is valid");
let https_with_suffix = GithubRepoRemote::try_new(
"https://github.com/agentics-reifying/agentics-challenges.git",
)
.expect("HTTPS .git remote is valid");
let ssh =
GithubRepoRemote::try_new("git@github.com:agentics-reifying/agentics-challenges.git")
.expect("SSH remote is valid");
assert_eq!(https.repository_key(), https_with_suffix.repository_key());
assert_eq!(https.repository_key(), ssh.repository_key());
assert_eq!(
https.repository_key().as_str(),
"agentics-reifying/agentics-challenges"
);
assert!(GithubRepoRemote::try_new("http://github.com/owner/repo").is_err());
assert!(GithubRepoRemote::try_new("https://example.com/owner/repo").is_err());
assert!(GithubRepoRemote::try_new("git@github.com:owner/repo").is_err());
}
#[test]
fn parses_github_pull_request_urls() {
assert!(
GithubPullRequestUrl::try_new(
"https://github.com/agentics-reifying/agentics-challenges/pull/7",
)
.is_ok()
);
assert!(
GithubPullRequestUrl::try_new(
"git@github.com:agentics-reifying/agentics-challenges.git",
)
.is_err()
);
assert!(GithubPullRequestUrl::try_new("https://github.com/owner/repo/issues/7").is_err());
}
#[test]
fn parses_challenge_external_urls() {
assert!(ExternalDataUrl::try_new("https://example.com/data.bin").is_ok());
assert!(ExternalDataUrl::try_new("http://example.com/data.bin").is_err());
assert!(ExternalDataUrl::try_new("https://example.com/data.bin#section").is_err());
}
#[test]
fn rejects_github_app_url_userinfo() {
assert!(GithubAppAuthorizeUrl::try_new("https://github.com/login/oauth/authorize").is_ok());
assert!(GithubAppTokenUrl::try_new("https://github.com/login/oauth/access_token").is_ok());
assert!(GithubApiUserUrl::try_new("https://api.github.com/user").is_ok());
assert!(
GithubAppRedirectUrl::try_new("http://127.0.0.1:3001/auth/github/callback").is_ok()
);
assert!(
GithubAppAuthorizeUrl::try_new("https://user:pass@github.com/login/oauth/authorize")
.is_err()
);
assert!(
GithubAppTokenUrl::try_new("https://user:pass@github.com/login/oauth/access_token")
.is_err()
);
assert!(GithubApiUserUrl::try_new("https://user:pass@api.github.com/user").is_err());
assert!(
GithubAppRedirectUrl::try_new("http://user:pass@127.0.0.1:3001/auth/github/callback")
.is_err()
);
}
#[test]
fn parses_moltbook_urls() {
let submolt = MoltbookSubmoltUrl::try_new("https://www.moltbook.com/m/agentics-platform")
.expect("canonical Submolt URL should parse");
assert_eq!(
submolt
.submolt_name()
.expect("Submolt name should parse")
.as_str(),
"agentics-platform"
);
assert!(MoltbookPostUrl::try_new("https://www.moltbook.com/post/abc-123_X").is_ok());
for value in [
"https://moltbook.com/m/agentics-platform",
"https://www.moltbook.com/m/Agentics-Platform",
"https://www.moltbook.com/submolts/agentics-platform",
"https://www.moltbook.com/m/agentics-platform?x=1",
"https://example.com/m/agentics-platform",
] {
assert!(MoltbookSubmoltUrl::try_new(value).is_err());
}
for value in [
"https://moltbook.com/post/abc-123",
"https://www.moltbook.com/posts/abc-123",
"https://www.moltbook.com/post/abc-123/comments",
"https://www.moltbook.com/post/abc.123",
"https://example.com/post/abc-123",
] {
assert!(MoltbookPostUrl::try_new(value).is_err());
}
}
}