rigspark_core/
catalog_request.rs1use crate::sizing::ValidationError;
2use serde_json::Value;
3use url::Url;
4
5const MAX_EVENT_BYTES: usize = 64 * 1024;
6const MODEL_URL_HEADING: &str = "### Hugging Face model URL";
7
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct Repository(String);
10
11impl Repository {
12 pub fn parse(raw: &str) -> Result<Self, ValidationError> {
13 let raw = raw.trim();
14 let authority = raw
15 .strip_prefix("https://")
16 .and_then(|rest| rest.split('/').next())
17 .ok_or_else(|| invalid("canonical Hugging Face URL required"))?;
18 let url = Url::parse(raw).map_err(|_| invalid("invalid Hugging Face URL"))?;
19 if url.scheme() != "https"
20 || authority != "huggingface.co"
21 || url.host_str() != Some("huggingface.co")
22 || !url.username().is_empty()
23 || url.password().is_some()
24 || url.port().is_some()
25 || url.query().is_some()
26 || url.fragment().is_some()
27 {
28 return Err(invalid("canonical Hugging Face URL required"));
29 }
30 let parts: Vec<_> = url
31 .path_segments()
32 .ok_or_else(|| invalid("repository path required"))?
33 .collect();
34 if parts.len() != 2 || !parts.iter().all(|part| valid_part(part)) {
35 return Err(invalid("Hugging Face owner/repository path required"));
36 }
37 Ok(Self(format!("{}/{}", parts[0], parts[1])))
38 }
39
40 pub fn as_str(&self) -> &str {
41 &self.0
42 }
43
44 pub fn url(&self) -> String {
45 format!("https://huggingface.co/{}", self.0)
46 }
47}
48
49fn valid_part(part: &str) -> bool {
50 !part.is_empty()
51 && part.len() <= 96
52 && part.as_bytes()[0].is_ascii_alphanumeric()
53 && part
54 .bytes()
55 .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
56}
57
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct ModelRequest {
60 pub issue_number: u64,
61 pub issue_url: String,
62 pub repository: Repository,
63}
64
65impl ModelRequest {
66 pub fn from_event(raw: &str) -> Result<Self, ValidationError> {
67 if raw.len() > MAX_EVENT_BYTES {
68 return Err(invalid("GitHub event exceeds 64 KiB"));
69 }
70 let event: Value =
71 serde_json::from_str(raw).map_err(|_| invalid("invalid GitHub event"))?;
72 let issue = event
73 .get("issue")
74 .and_then(Value::as_object)
75 .ok_or_else(|| invalid("GitHub issue missing"))?;
76 let issue_number = issue
77 .get("number")
78 .and_then(Value::as_u64)
79 .filter(|number| *number > 0)
80 .ok_or_else(|| invalid("GitHub issue number missing"))?;
81 let issue_url = issue
82 .get("html_url")
83 .and_then(Value::as_str)
84 .ok_or_else(|| invalid("GitHub issue URL missing"))?;
85 validate_issue_url(issue_url, issue_number)?;
86 let body = issue
87 .get("body")
88 .and_then(Value::as_str)
89 .ok_or_else(|| invalid("GitHub issue body missing"))?;
90 let repository = Repository::parse(extract_field(body)?)?;
91 Ok(Self {
92 issue_number,
93 issue_url: issue_url.to_owned(),
94 repository,
95 })
96 }
97}
98
99fn extract_field(body: &str) -> Result<&str, ValidationError> {
100 let (_, after) = body
101 .split_once(MODEL_URL_HEADING)
102 .ok_or_else(|| invalid("model URL field missing"))?;
103 let value = after.split("\n### ").next().unwrap_or(after).trim();
104 if value.is_empty() || value.lines().count() != 1 {
105 return Err(invalid("model URL field must contain one URL"));
106 }
107 Ok(value)
108}
109
110fn validate_issue_url(raw: &str, issue_number: u64) -> Result<(), ValidationError> {
111 let url = Url::parse(raw).map_err(|_| invalid("invalid GitHub issue URL"))?;
112 let expected = format!("/shashankswe2020-ux/rigspark/issues/{issue_number}");
113 if url.scheme() != "https"
114 || url.host_str() != Some("github.com")
115 || url.path() != expected
116 || url.query().is_some()
117 || url.fragment().is_some()
118 {
119 return Err(invalid("unexpected GitHub issue URL"));
120 }
121 Ok(())
122}
123
124fn invalid(message: &str) -> ValidationError {
125 ValidationError(message.into())
126}