1use std::borrow::Cow;
4use std::fmt;
5use std::str::FromStr;
6
7use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use url::Url;
10
11use super::names::MoltbookSubmoltName;
12
13pub 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";
15
16pub 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}";
18
19pub const EXTERNAL_DATA_URL_ERROR_MESSAGE: &str = "external data url must be an HTTPS URL";
21
22pub const MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE: &str =
24 "moltbook submolt url must be https://www.moltbook.com/m/{submolt-name}";
25
26pub const MOLTBOOK_POST_URL_ERROR_MESSAGE: &str =
28 "moltbook post url must be https://www.moltbook.com/post/{post-id}";
29
30pub const GITHUB_APP_REDIRECT_URL_ERROR_MESSAGE: &str =
32 "GitHub sign-in redirect URL must be an absolute HTTP(S) URL without query or fragment";
33
34pub const GITHUB_APP_AUTHORIZE_URL_ERROR_MESSAGE: &str =
36 "GitHub sign-in authorize URL must be https://github.com/login/oauth/authorize";
37pub const GITHUB_SIGN_IN_AUTHORIZATION_URL_ERROR_MESSAGE: &str =
38 "GitHub sign-in authorization URL must be an HTTPS GitHub authorize URL without fragment";
39
40pub const GITHUB_APP_TOKEN_URL_ERROR_MESSAGE: &str =
42 "GitHub sign-in token URL must be https://github.com/login/oauth/access_token";
43
44pub const GITHUB_API_USER_URL_ERROR_MESSAGE: &str =
46 "github API user URL must be https://api.github.com/user";
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct UrlFieldError {
51 message: &'static str,
52}
53
54impl UrlFieldError {
55 const fn new(message: &'static str) -> Self {
56 Self { message }
57 }
58}
59
60impl fmt::Display for UrlFieldError {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 f.write_str(self.message)
64 }
65}
66
67impl std::error::Error for UrlFieldError {}
68
69macro_rules! impl_string_url_serde {
70 ($type_name:ident) => {
71 impl Serialize for $type_name {
72 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
74 where
75 S: Serializer,
76 {
77 serializer.serialize_str(self.as_str())
78 }
79 }
80
81 impl<'de> Deserialize<'de> for $type_name {
82 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
84 where
85 D: Deserializer<'de>,
86 {
87 let value = String::deserialize(deserializer)?;
88 Self::from_str(&value).map_err(serde::de::Error::custom)
89 }
90 }
91 };
92}
93
94macro_rules! impl_string_url_schema {
95 ($type_name:ident, $schema_name:literal, $pattern:literal) => {
96 impl JsonSchema for $type_name {
97 fn inline_schema() -> bool {
99 true
100 }
101
102 fn schema_name() -> Cow<'static, str> {
104 $schema_name.into()
105 }
106
107 fn json_schema(_: &mut SchemaGenerator) -> Schema {
109 json_schema!({
110 "type": "string",
111 "format": "uri",
112 "pattern": $pattern
113 })
114 }
115 }
116 };
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Hash)]
121pub enum GithubRepoRemote {
122 Https {
124 url: Url,
126 key: GithubRepoKey,
128 },
129 Ssh(GithubSshRepoRemote),
131}
132
133impl GithubRepoRemote {
134 pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
136 value.as_ref().parse()
137 }
138
139 pub fn as_str(&self) -> &str {
141 match self {
142 Self::Https { url, .. } => url.as_str(),
143 Self::Ssh(remote) => remote.as_str(),
144 }
145 }
146
147 pub fn repository_key(&self) -> &GithubRepoKey {
154 match self {
155 Self::Https { key, .. } => key,
156 Self::Ssh(remote) => remote.repository_key(),
157 }
158 }
159}
160
161impl fmt::Display for GithubRepoRemote {
162 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 f.write_str(self.as_str())
165 }
166}
167
168impl FromStr for GithubRepoRemote {
169 type Err = UrlFieldError;
170
171 fn from_str(value: &str) -> Result<Self, Self::Err> {
173 let value = value.trim();
174 if value.starts_with("git@github.com:") {
175 return Ok(Self::Ssh(GithubSshRepoRemote::try_new(value)?));
176 }
177
178 let url = parse_url(value, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
179 let key = github_https_repo_key(&url)?;
180 Ok(Self::Https { url, key })
181 }
182}
183
184impl Serialize for GithubRepoRemote {
185 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
187 where
188 S: Serializer,
189 {
190 serializer.serialize_str(self.as_str())
191 }
192}
193
194impl<'de> Deserialize<'de> for GithubRepoRemote {
195 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
197 where
198 D: Deserializer<'de>,
199 {
200 let value = String::deserialize(deserializer)?;
201 Self::from_str(&value).map_err(serde::de::Error::custom)
202 }
203}
204
205impl JsonSchema for GithubRepoRemote {
206 fn inline_schema() -> bool {
208 true
209 }
210
211 fn schema_name() -> Cow<'static, str> {
213 "GithubRepoRemote".into()
214 }
215
216 fn json_schema(_: &mut SchemaGenerator) -> Schema {
218 json_schema!({
219 "type": "string",
220 "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)$"
221 })
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Hash)]
227pub struct GithubSshRepoRemote {
228 value: String,
229 key: GithubRepoKey,
230}
231
232impl GithubSshRepoRemote {
233 fn try_new(value: &str) -> Result<Self, UrlFieldError> {
235 reject_whitespace_or_control(value, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
236 let Some(rest) = value.strip_prefix("git@github.com:") else {
237 return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
238 };
239 let Some((owner, repo_with_suffix)) = rest.split_once('/') else {
240 return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
241 };
242 if repo_with_suffix.contains('/') {
243 return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
244 }
245 let Some(repo) = repo_with_suffix.strip_suffix(".git") else {
246 return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
247 };
248 let key = GithubRepoKey::try_new(owner, repo)?;
249 Ok(Self {
250 value: format!("git@github.com:{owner}/{repo}.git"),
251 key,
252 })
253 }
254
255 fn as_str(&self) -> &str {
257 &self.value
258 }
259
260 fn repository_key(&self) -> &GithubRepoKey {
262 &self.key
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
272pub struct GithubRepoKey(String);
273
274impl GithubRepoKey {
275 fn try_new(owner: &str, repo: &str) -> Result<Self, UrlFieldError> {
277 validate_github_path_segment(owner, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
278 validate_github_path_segment(repo, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
279 Ok(Self(format!(
280 "{}/{}",
281 owner.to_ascii_lowercase(),
282 repo.to_ascii_lowercase()
283 )))
284 }
285
286 pub fn as_str(&self) -> &str {
288 &self.0
289 }
290}
291
292impl fmt::Display for GithubRepoKey {
293 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295 f.write_str(self.as_str())
296 }
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Hash)]
301pub struct GithubPullRequestUrl(Url);
302
303impl GithubPullRequestUrl {
304 pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
306 value.as_ref().parse()
307 }
308
309 pub fn as_str(&self) -> &str {
311 self.0.as_str()
312 }
313
314 pub fn repository_key(&self) -> Result<GithubRepoKey, UrlFieldError> {
316 github_pull_request_parts(&self.0)
317 .map(|(owner, repo, _number)| GithubRepoKey(format!("{owner}/{repo}")))
318 }
319
320 pub fn number(&self) -> Result<String, UrlFieldError> {
322 github_pull_request_parts(&self.0).map(|(_owner, _repo, number)| number)
323 }
324}
325
326impl fmt::Display for GithubPullRequestUrl {
327 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329 f.write_str(self.as_str())
330 }
331}
332
333impl FromStr for GithubPullRequestUrl {
334 type Err = UrlFieldError;
335
336 fn from_str(value: &str) -> Result<Self, Self::Err> {
338 let url = parse_url(value.trim(), GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
339 validate_github_https_pull_request_url(&url)?;
340 Ok(Self(url))
341 }
342}
343
344impl_string_url_serde!(GithubPullRequestUrl);
345impl_string_url_schema!(
346 GithubPullRequestUrl,
347 "GithubPullRequestUrl",
348 r"^https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/pull/[0-9]+$"
349);
350
351#[derive(Debug, Clone, PartialEq, Eq, Hash)]
353pub struct ExternalDataUrl(Url);
354
355impl ExternalDataUrl {
356 pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
358 value.as_ref().parse()
359 }
360
361 pub fn as_str(&self) -> &str {
363 self.0.as_str()
364 }
365}
366
367impl fmt::Display for ExternalDataUrl {
368 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370 f.write_str(self.as_str())
371 }
372}
373
374impl FromStr for ExternalDataUrl {
375 type Err = UrlFieldError;
376
377 fn from_str(value: &str) -> Result<Self, Self::Err> {
379 let url = parse_url(value.trim(), EXTERNAL_DATA_URL_ERROR_MESSAGE)?;
380 validate_https_url(&url, EXTERNAL_DATA_URL_ERROR_MESSAGE)?;
381 Ok(Self(url))
382 }
383}
384
385impl_string_url_serde!(ExternalDataUrl);
386impl_string_url_schema!(ExternalDataUrl, "ExternalDataUrl", r"^https://[^?#]+$");
387
388#[derive(Debug, Clone, PartialEq, Eq, Hash)]
390pub struct MoltbookSubmoltUrl(Url);
391
392impl MoltbookSubmoltUrl {
393 pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
395 value.as_ref().parse()
396 }
397
398 pub fn as_str(&self) -> &str {
400 self.0.as_str()
401 }
402
403 pub fn submolt_name(&self) -> Result<MoltbookSubmoltName, UrlFieldError> {
405 moltbook_submolt_name(&self.0)
406 }
407}
408
409impl fmt::Display for MoltbookSubmoltUrl {
410 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
412 f.write_str(self.as_str())
413 }
414}
415
416impl FromStr for MoltbookSubmoltUrl {
417 type Err = UrlFieldError;
418
419 fn from_str(value: &str) -> Result<Self, Self::Err> {
421 let url = parse_url(value.trim(), MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE)?;
422 validate_moltbook_submolt_url(&url)?;
423 Ok(Self(url))
424 }
425}
426
427impl_string_url_serde!(MoltbookSubmoltUrl);
428impl_string_url_schema!(
429 MoltbookSubmoltUrl,
430 "MoltbookSubmoltUrl",
431 r"^https://www\.moltbook\.com/m/[a-z0-9](?:[a-z0-9]|-(?!-)){0,28}[a-z0-9]$"
432);
433
434#[derive(Debug, Clone, PartialEq, Eq, Hash)]
436pub struct MoltbookPostUrl(Url);
437
438impl MoltbookPostUrl {
439 pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
441 value.as_ref().parse()
442 }
443
444 pub fn as_str(&self) -> &str {
446 self.0.as_str()
447 }
448}
449
450impl fmt::Display for MoltbookPostUrl {
451 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
453 f.write_str(self.as_str())
454 }
455}
456
457impl FromStr for MoltbookPostUrl {
458 type Err = UrlFieldError;
459
460 fn from_str(value: &str) -> Result<Self, Self::Err> {
462 let url = parse_url(value.trim(), MOLTBOOK_POST_URL_ERROR_MESSAGE)?;
463 validate_moltbook_post_url(&url)?;
464 Ok(Self(url))
465 }
466}
467
468impl_string_url_serde!(MoltbookPostUrl);
469impl_string_url_schema!(
470 MoltbookPostUrl,
471 "MoltbookPostUrl",
472 r"^https://www\.moltbook\.com/post/[A-Za-z0-9_-]+$"
473);
474
475macro_rules! define_url_wrapper {
476 ($type_name:ident, $schema_name:literal, $validator:ident, $message:ident) => {
477 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
478 pub struct $type_name(Url);
479
480 impl $type_name {
481 pub fn try_new(value: impl AsRef<str>) -> Result<Self, UrlFieldError> {
483 value.as_ref().parse()
484 }
485
486 pub fn as_str(&self) -> &str {
488 self.0.as_str()
489 }
490
491 pub fn to_url(&self) -> Url {
493 self.0.clone()
494 }
495 }
496
497 impl fmt::Display for $type_name {
498 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
500 f.write_str(self.as_str())
501 }
502 }
503
504 impl FromStr for $type_name {
505 type Err = UrlFieldError;
506
507 fn from_str(value: &str) -> Result<Self, Self::Err> {
509 let url = parse_url(value.trim(), $message)?;
510 $validator(&url)?;
511 Ok(Self(url))
512 }
513 }
514
515 impl_string_url_serde!($type_name);
516 impl_string_url_schema!($type_name, $schema_name, r"^https?://[^?#]+$");
517 };
518}
519
520define_url_wrapper!(
521 GithubAppRedirectUrl,
522 "GithubAppRedirectUrl",
523 validate_github_app_redirect_url,
524 GITHUB_APP_REDIRECT_URL_ERROR_MESSAGE
525);
526define_url_wrapper!(
527 GithubAppAuthorizeUrl,
528 "GithubAppAuthorizeUrl",
529 validate_github_app_authorize_url,
530 GITHUB_APP_AUTHORIZE_URL_ERROR_MESSAGE
531);
532define_url_wrapper!(
533 GithubAppTokenUrl,
534 "GithubAppTokenUrl",
535 validate_github_app_token_url,
536 GITHUB_APP_TOKEN_URL_ERROR_MESSAGE
537);
538define_url_wrapper!(
539 GithubApiUserUrl,
540 "GithubApiUserUrl",
541 validate_github_api_user_url,
542 GITHUB_API_USER_URL_ERROR_MESSAGE
543);
544
545#[derive(Debug, Clone, PartialEq, Eq, Hash)]
547pub struct GithubSignInAuthorizationUrl(Url);
548
549impl GithubSignInAuthorizationUrl {
550 pub fn try_from_url(url: Url) -> Result<Self, UrlFieldError> {
552 validate_github_sign_in_authorization_url(&url)?;
553 Ok(Self(url))
554 }
555
556 pub fn as_str(&self) -> &str {
558 self.0.as_str()
559 }
560}
561
562impl fmt::Display for GithubSignInAuthorizationUrl {
563 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
565 f.write_str(self.as_str())
566 }
567}
568
569impl FromStr for GithubSignInAuthorizationUrl {
570 type Err = UrlFieldError;
571
572 fn from_str(value: &str) -> Result<Self, Self::Err> {
574 let url = parse_url(value.trim(), GITHUB_SIGN_IN_AUTHORIZATION_URL_ERROR_MESSAGE)?;
575 Self::try_from_url(url)
576 }
577}
578
579impl_string_url_serde!(GithubSignInAuthorizationUrl);
580impl_string_url_schema!(
581 GithubSignInAuthorizationUrl,
582 "GithubSignInAuthorizationUrl",
583 r"^https://github\.com/login/oauth/authorize\?[^#]+$"
584);
585
586fn parse_url(value: &str, message: &'static str) -> Result<Url, UrlFieldError> {
588 reject_whitespace_or_control(value, message)?;
589 Url::parse(value).map_err(|_| UrlFieldError::new(message))
590}
591
592fn github_https_repo_key(url: &Url) -> Result<GithubRepoKey, UrlFieldError> {
594 validate_github_https_base(url, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
595 let segments = github_path_segments(url, GITHUB_REPO_REMOTE_ERROR_MESSAGE)?;
596 let [owner, repo_with_suffix] = segments.as_slice() else {
597 return Err(UrlFieldError::new(GITHUB_REPO_REMOTE_ERROR_MESSAGE));
598 };
599 let repo = repo_with_suffix
600 .strip_suffix(".git")
601 .unwrap_or(repo_with_suffix.as_str());
602 GithubRepoKey::try_new(owner, repo)
603}
604
605fn validate_github_https_pull_request_url(url: &Url) -> Result<(), UrlFieldError> {
607 validate_github_https_base(url, GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
608 github_pull_request_parts(url).map(|_| ())
609}
610
611fn github_pull_request_parts(url: &Url) -> Result<(String, String, String), UrlFieldError> {
613 let segments = github_path_segments(url, GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
614 let [owner, repo, pull, number] = segments.as_slice() else {
615 return Err(UrlFieldError::new(GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE));
616 };
617 if pull != "pull" {
618 return Err(UrlFieldError::new(GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE));
619 }
620 validate_github_path_segment(owner, GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
621 validate_github_path_segment(repo, GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE)?;
622 if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) {
623 return Err(UrlFieldError::new(GITHUB_PULL_REQUEST_URL_ERROR_MESSAGE));
624 }
625 Ok((
626 owner.to_ascii_lowercase(),
627 repo.to_ascii_lowercase(),
628 number.to_string(),
629 ))
630}
631
632fn validate_github_app_redirect_url(url: &Url) -> Result<(), UrlFieldError> {
634 if !matches!(url.scheme(), "http" | "https")
635 || url.cannot_be_a_base()
636 || url.host_str().is_none()
637 || url_has_userinfo(url)
638 || url.query().is_some()
639 || url.fragment().is_some()
640 {
641 return Err(UrlFieldError::new(GITHUB_APP_REDIRECT_URL_ERROR_MESSAGE));
642 }
643 Ok(())
644}
645
646fn validate_github_app_authorize_url(url: &Url) -> Result<(), UrlFieldError> {
648 validate_exact_https_url(
649 url,
650 "github.com",
651 "/login/oauth/authorize",
652 GITHUB_APP_AUTHORIZE_URL_ERROR_MESSAGE,
653 )
654}
655
656fn validate_github_sign_in_authorization_url(url: &Url) -> Result<(), UrlFieldError> {
658 if url.scheme() != "https"
659 || url.cannot_be_a_base()
660 || url.host_str() != Some("github.com")
661 || url.port().is_some()
662 || url.path() != "/login/oauth/authorize"
663 || url.query().is_none()
664 || url.fragment().is_some()
665 {
666 return Err(UrlFieldError::new(
667 GITHUB_SIGN_IN_AUTHORIZATION_URL_ERROR_MESSAGE,
668 ));
669 }
670 Ok(())
671}
672
673fn validate_github_app_token_url(url: &Url) -> Result<(), UrlFieldError> {
675 validate_exact_https_url(
676 url,
677 "github.com",
678 "/login/oauth/access_token",
679 GITHUB_APP_TOKEN_URL_ERROR_MESSAGE,
680 )
681}
682
683fn validate_github_api_user_url(url: &Url) -> Result<(), UrlFieldError> {
685 validate_exact_https_url(
686 url,
687 "api.github.com",
688 "/user",
689 GITHUB_API_USER_URL_ERROR_MESSAGE,
690 )
691}
692
693fn validate_moltbook_submolt_url(url: &Url) -> Result<(), UrlFieldError> {
695 validate_moltbook_base(url, MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE)?;
696 moltbook_submolt_name(url).map(|_| ())
697}
698
699fn validate_moltbook_post_url(url: &Url) -> Result<(), UrlFieldError> {
701 validate_moltbook_base(url, MOLTBOOK_POST_URL_ERROR_MESSAGE)?;
702 let segments = moltbook_path_segments(url, MOLTBOOK_POST_URL_ERROR_MESSAGE)?;
703 let [post, post_id] = segments.as_slice() else {
704 return Err(UrlFieldError::new(MOLTBOOK_POST_URL_ERROR_MESSAGE));
705 };
706 if post != "post" || !has_moltbook_post_id_syntax(post_id) {
707 return Err(UrlFieldError::new(MOLTBOOK_POST_URL_ERROR_MESSAGE));
708 }
709 Ok(())
710}
711
712fn moltbook_submolt_name(url: &Url) -> Result<MoltbookSubmoltName, UrlFieldError> {
714 let segments = moltbook_path_segments(url, MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE)?;
715 let [m, name] = segments.as_slice() else {
716 return Err(UrlFieldError::new(MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE));
717 };
718 if m != "m" {
719 return Err(UrlFieldError::new(MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE));
720 }
721 let parsed = MoltbookSubmoltName::try_new(name.to_string())
722 .map_err(|_| UrlFieldError::new(MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE))?;
723 if parsed.as_str() != name {
724 return Err(UrlFieldError::new(MOLTBOOK_SUBMOLT_URL_ERROR_MESSAGE));
725 }
726 Ok(parsed)
727}
728
729fn validate_moltbook_base(url: &Url, message: &'static str) -> Result<(), UrlFieldError> {
731 validate_https_url(url, message)?;
732 if url.host_str() != Some("www.moltbook.com")
733 || url.port().is_some()
734 || !url.username().is_empty()
735 || url.password().is_some()
736 {
737 return Err(UrlFieldError::new(message));
738 }
739 Ok(())
740}
741
742fn moltbook_path_segments(url: &Url, message: &'static str) -> Result<Vec<String>, UrlFieldError> {
744 let Some(segments) = url.path_segments() else {
745 return Err(UrlFieldError::new(message));
746 };
747 let segments: Vec<String> = segments.map(ToString::to_string).collect();
748 if segments.iter().any(|segment| segment.is_empty()) {
749 return Err(UrlFieldError::new(message));
750 }
751 Ok(segments)
752}
753
754fn has_moltbook_post_id_syntax(value: &str) -> bool {
756 !value.is_empty()
757 && value
758 .bytes()
759 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
760}
761
762fn validate_exact_https_url(
764 url: &Url,
765 host: &str,
766 path: &str,
767 message: &'static str,
768) -> Result<(), UrlFieldError> {
769 validate_https_url(url, message)?;
770 if url.host_str() != Some(host)
771 || url.port().is_some()
772 || url_has_userinfo(url)
773 || url.path() != path
774 {
775 return Err(UrlFieldError::new(message));
776 }
777 Ok(())
778}
779
780fn url_has_userinfo(url: &Url) -> bool {
782 !url.username().is_empty() || url.password().is_some()
783}
784
785fn validate_github_https_base(url: &Url, message: &'static str) -> Result<(), UrlFieldError> {
787 validate_https_url(url, message)?;
788 if url.host_str() != Some("github.com")
789 || url.port().is_some()
790 || !url.username().is_empty()
791 || url.password().is_some()
792 {
793 return Err(UrlFieldError::new(message));
794 }
795 Ok(())
796}
797
798fn validate_https_url(url: &Url, message: &'static str) -> Result<(), UrlFieldError> {
800 if url.scheme() != "https" || url.cannot_be_a_base() || url.host_str().is_none() {
801 return Err(UrlFieldError::new(message));
802 }
803 if url.query().is_some() || url.fragment().is_some() {
804 return Err(UrlFieldError::new(message));
805 }
806 Ok(())
807}
808
809fn github_path_segments(url: &Url, message: &'static str) -> Result<Vec<String>, UrlFieldError> {
811 let Some(segments) = url.path_segments() else {
812 return Err(UrlFieldError::new(message));
813 };
814 let segments: Vec<String> = segments.map(ToString::to_string).collect();
815 if segments.iter().any(|segment| segment.is_empty()) {
816 return Err(UrlFieldError::new(message));
817 }
818 Ok(segments)
819}
820
821fn validate_github_path_segment(value: &str, message: &'static str) -> Result<(), UrlFieldError> {
823 if value.is_empty()
824 || value == ".git"
825 || !value
826 .bytes()
827 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
828 {
829 return Err(UrlFieldError::new(message));
830 }
831 Ok(())
832}
833
834fn reject_whitespace_or_control(value: &str, message: &'static str) -> Result<(), UrlFieldError> {
836 if value.is_empty() || value.chars().any(|c| c.is_whitespace() || c.is_control()) {
837 Err(UrlFieldError::new(message))
838 } else {
839 Ok(())
840 }
841}
842
843#[cfg(test)]
844mod tests {
845 use super::{
846 ExternalDataUrl, GithubApiUserUrl, GithubAppAuthorizeUrl, GithubAppRedirectUrl,
847 GithubAppTokenUrl, GithubPullRequestUrl, GithubRepoRemote, MoltbookPostUrl,
848 MoltbookSubmoltUrl,
849 };
850
851 #[test]
853 fn parses_github_repo_remotes() {
854 let https =
855 GithubRepoRemote::try_new("https://github.com/Agentics-Reifying/Agentics-Challenges")
856 .expect("HTTPS remote is valid");
857 let https_with_suffix = GithubRepoRemote::try_new(
858 "https://github.com/agentics-reifying/agentics-challenges.git",
859 )
860 .expect("HTTPS .git remote is valid");
861 let ssh =
862 GithubRepoRemote::try_new("git@github.com:agentics-reifying/agentics-challenges.git")
863 .expect("SSH remote is valid");
864
865 assert_eq!(https.repository_key(), https_with_suffix.repository_key());
866 assert_eq!(https.repository_key(), ssh.repository_key());
867 assert_eq!(
868 https.repository_key().as_str(),
869 "agentics-reifying/agentics-challenges"
870 );
871 assert!(GithubRepoRemote::try_new("http://github.com/owner/repo").is_err());
872 assert!(GithubRepoRemote::try_new("https://example.com/owner/repo").is_err());
873 assert!(GithubRepoRemote::try_new("git@github.com:owner/repo").is_err());
874 }
875
876 #[test]
878 fn parses_github_pull_request_urls() {
879 assert!(
880 GithubPullRequestUrl::try_new(
881 "https://github.com/agentics-reifying/agentics-challenges/pull/7",
882 )
883 .is_ok()
884 );
885 assert!(
886 GithubPullRequestUrl::try_new(
887 "git@github.com:agentics-reifying/agentics-challenges.git",
888 )
889 .is_err()
890 );
891 assert!(GithubPullRequestUrl::try_new("https://github.com/owner/repo/issues/7").is_err());
892 }
893
894 #[test]
896 fn parses_challenge_external_urls() {
897 assert!(ExternalDataUrl::try_new("https://example.com/data.bin").is_ok());
898 assert!(ExternalDataUrl::try_new("http://example.com/data.bin").is_err());
899 assert!(ExternalDataUrl::try_new("https://example.com/data.bin#section").is_err());
900 }
901
902 #[test]
904 fn rejects_github_app_url_userinfo() {
905 assert!(GithubAppAuthorizeUrl::try_new("https://github.com/login/oauth/authorize").is_ok());
906 assert!(GithubAppTokenUrl::try_new("https://github.com/login/oauth/access_token").is_ok());
907 assert!(GithubApiUserUrl::try_new("https://api.github.com/user").is_ok());
908 assert!(
909 GithubAppRedirectUrl::try_new("http://127.0.0.1:3001/auth/github/callback").is_ok()
910 );
911
912 assert!(
913 GithubAppAuthorizeUrl::try_new("https://user:pass@github.com/login/oauth/authorize")
914 .is_err()
915 );
916 assert!(
917 GithubAppTokenUrl::try_new("https://user:pass@github.com/login/oauth/access_token")
918 .is_err()
919 );
920 assert!(GithubApiUserUrl::try_new("https://user:pass@api.github.com/user").is_err());
921 assert!(
922 GithubAppRedirectUrl::try_new("http://user:pass@127.0.0.1:3001/auth/github/callback")
923 .is_err()
924 );
925 }
926
927 #[test]
929 fn parses_moltbook_urls() {
930 let submolt = MoltbookSubmoltUrl::try_new("https://www.moltbook.com/m/agentics-platform")
931 .expect("canonical Submolt URL should parse");
932 assert_eq!(
933 submolt
934 .submolt_name()
935 .expect("Submolt name should parse")
936 .as_str(),
937 "agentics-platform"
938 );
939 assert!(MoltbookPostUrl::try_new("https://www.moltbook.com/post/abc-123_X").is_ok());
940
941 for value in [
942 "https://moltbook.com/m/agentics-platform",
943 "https://www.moltbook.com/m/Agentics-Platform",
944 "https://www.moltbook.com/submolts/agentics-platform",
945 "https://www.moltbook.com/m/agentics-platform?x=1",
946 "https://example.com/m/agentics-platform",
947 ] {
948 assert!(MoltbookSubmoltUrl::try_new(value).is_err());
949 }
950
951 for value in [
952 "https://moltbook.com/post/abc-123",
953 "https://www.moltbook.com/posts/abc-123",
954 "https://www.moltbook.com/post/abc-123/comments",
955 "https://www.moltbook.com/post/abc.123",
956 "https://example.com/post/abc-123",
957 ] {
958 assert!(MoltbookPostUrl::try_new(value).is_err());
959 }
960 }
961}