1use std::time::Duration;
2
3use reqwest::{
4 Client as HttpClient, ClientBuilder,
5 header::{COOKIE, HeaderValue},
6 redirect::Policy,
7};
8use serde::{Deserialize, Serialize, de::DeserializeOwned};
9
10use crate::{
11 AccountStats, CodeSnippet, ContestRegistration, ContestSummary, Credentials, Difficulty,
12 DifficultyCounts, Discussion, DiscussionList, DiscussionSummary, Error, Problem,
13 ProblemSummary, RecentSubmission, RunResult, RunState, SearchResults, StarterCode,
14 SubmissionResult, SubmissionState, TestCases,
15};
16
17pub(crate) const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
18const QUERY: &str = "query questionData($titleSlug: String!) {
19 question(titleSlug: $titleSlug) { frontendQuestionId: questionFrontendId titleSlug title content isPaidOnly }
20}";
21const DAILY_QUERY: &str = "query questionOfToday {
22 activeDailyCodingChallengeQuestion { question { frontendQuestionId: questionFrontendId titleSlug title content isPaidOnly } }
23}";
24const USER_STATUS_QUERY: &str = "query userStatus { userStatus { isSignedIn username } }";
25const ACCOUNT_STATS_QUERY: &str = "query accountStats($username: String!) {
26 matchedUser(username: $username) {
27 username
28 submitStats {
29 acSubmissionNum { difficulty count submissions }
30 totalSubmissionNum { difficulty count submissions }
31 }
32 }
33 submissionList(offset: 0, limit: 10) {
34 hasNext
35 submissions { id statusDisplay title titleSlug timestamp lang runtime memory url }
36 }
37}";
38const UPCOMING_CONTESTS_QUERY: &str = "query upcomingContests {
39 upcomingContests { title titleSlug startTime duration isVirtual }
40}";
41const CONTEST_QUERY: &str = "query contest($titleSlug: String!) {
42 contest(titleSlug: $titleSlug) { title titleSlug startTime duration isVirtual }
43}";
44const CONTEST_REGISTRATION_QUERY: &str = "query contestRegistration($titleSlug: String!) {
45 contest(titleSlug: $titleSlug) { titleSlug userRegistered }
46}";
47const TRENDING_DISCUSSIONS_QUERY: &str = "query trendingDiscussions {
48 cachedTrendingCategoryTopics(first: 10) {
49 id title viewCount topLevelCommentCount
50 post { voteCount creationDate author { username } }
51 }
52}";
53const DISCUSSION_QUESTION_QUERY: &str = "query discussionQuestion($titleSlug: String!) {
54 question(titleSlug: $titleSlug) { questionId titleSlug }
55}";
56const PROBLEM_DISCUSSIONS_QUERY: &str = "query problemDiscussions($questionId: Int!, $orderBy: TopicSortingOption!, $pageNo: Int!, $numPerPage: Int!) {
57 questionTopics(orderBy: $orderBy, questionId: $questionId, pageNo: $pageNo, numPerPage: $numPerPage) {
58 totalNum data {
59 id title viewCount topLevelCommentCount
60 post { voteCount creationDate author { username } }
61 }
62 }
63}";
64const DISCUSSION_QUERY: &str = "query discussion($topicId: Int!) {
65 topic(id: $topicId) {
66 id title viewCount topLevelCommentCount tags pinned
67 post { voteCount creationDate updationDate content author { username } }
68 }
69}";
70const DISCUSSION_ARTICLE_QUERY: &str = "query discussionArticle($topicId: ID) {
71 ugcArticleDiscussionArticle(topicId: $topicId) { uuid content }
72}";
73const STARTER_QUERY: &str = "query questionEditorData($titleSlug: String!) {
74 question(titleSlug: $titleSlug) { questionId title titleSlug codeDefinition isPaidOnly }
75}";
76const TEST_CASES_QUERY: &str = "query consolePanelConfig($titleSlug: String!) {
77 question(titleSlug: $titleSlug) { questionId titleSlug isPaidOnly enableRunCode exampleTestcaseList sampleTestCase }
78}";
79const QUESTION_LIST_QUERY: &str = "query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) {
80 problemsetQuestionList: questionList(categorySlug: $categorySlug, limit: $limit, skip: $skip, filters: $filters) {
81 total: totalNum
82 questions: data { frontendQuestionId: questionFrontendId title titleSlug difficulty isPaidOnly }
83 }
84}";
85const QUESTION_LIST_LIMIT: u32 = 20;
86const MAX_SOURCE_BYTES: usize = 1024 * 1024;
87const MAX_TEST_CASES: usize = 128;
88const MAX_TEST_INPUT_BYTES: usize = 1024 * 1024;
89const MAX_CODE_DEFINITION_BYTES: usize = 1024 * 1024;
90const MAX_STARTER_SNIPPETS: usize = 64;
91const MAX_RUN_OUTPUT_BYTES: usize = 64 * 1024;
92const MAX_RECENT_SUBMISSIONS: usize = 10;
93const MAX_ACCOUNT_COUNT: u32 = 100_000_000;
94const MAX_CONTESTS: usize = 64;
95const MAX_CONTEST_DURATION: u64 = 7 * 86_400;
96const MAX_CONTEST_TIMESTAMP: u64 = 253_402_300_799;
97const MAX_DISCUSSIONS: usize = 20;
98const MAX_DISCUSSION_TITLE: usize = 256;
99const MAX_DISCUSSION_CONTENT: usize = 512 * 1024;
100const MAX_DISCUSSION_ARTICLE_UUID: usize = 128;
101const MAX_DISCUSSION_TAGS: usize = 20;
102const MAX_DISCUSSION_TAG: usize = 64;
103const MAX_DISCUSSION_COUNT: u64 = 1_000_000_000;
104
105pub struct Client {
106 pub(crate) http: HttpClient,
107 pub(crate) endpoint: Box<str>,
108}
109
110impl Client {
111 pub fn new() -> Result<Self, Error> {
112 Ok(Self {
113 http: http_builder().build()?,
114 endpoint: "https://leetcode.com/graphql/".into(),
115 })
116 }
117
118 pub async fn problem(
120 &self,
121 slug: &str,
122 progress: impl FnMut(usize, Option<u64>),
123 ) -> Result<Problem, Error> {
124 if !valid_slug(slug) {
125 return Err(Error::InvalidSlug);
126 }
127
128 let response: ProblemResponse = self
129 .graphql(
130 &ProblemRequest {
131 query: QUERY,
132 operation_name: "questionData",
133 variables: Variables { title_slug: slug },
134 },
135 progress,
136 )
137 .await?;
138 if !response.errors.is_empty() {
139 return Err(Error::Graphql);
140 }
141 let question = response
142 .data
143 .ok_or(Error::InvalidResponse)?
144 .question
145 .ok_or(Error::NotFound)?;
146 problem_from_question(question, Some(slug))
147 }
148
149 pub async fn daily(&self, progress: impl FnMut(usize, Option<u64>)) -> Result<Problem, Error> {
151 let response: DailyResponse = self
152 .graphql(
153 &DailyRequest {
154 query: DAILY_QUERY,
155 operation_name: "questionOfToday",
156 },
157 progress,
158 )
159 .await?;
160 if !response.errors.is_empty() {
161 return Err(Error::Graphql);
162 }
163 let question = response
164 .data
165 .ok_or(Error::InvalidResponse)?
166 .active_daily_coding_challenge_question
167 .ok_or(Error::InvalidResponse)?
168 .question
169 .ok_or(Error::InvalidResponse)?;
170 problem_from_question(question, None)
171 }
172
173 pub async fn search(
175 &self,
176 query: &str,
177 progress: impl FnMut(usize, Option<u64>),
178 ) -> Result<SearchResults, Error> {
179 let query = query.trim();
180 if query.is_empty()
181 || query.len() > 100
182 || !query
183 .bytes()
184 .all(|byte| byte.is_ascii_graphic() || byte == b' ')
185 {
186 return Err(Error::InvalidQuery);
187 }
188
189 let response: QuestionListResponse = self
190 .graphql(
191 &QuestionListRequest {
192 query: QUESTION_LIST_QUERY,
193 operation_name: "problemsetQuestionList",
194 variables: QuestionListVariables {
195 category_slug: "",
196 limit: QUESTION_LIST_LIMIT,
197 skip: 0,
198 filters: QuestionListFilters {
199 search_keywords: Some(query),
200 difficulty: None,
201 tags: None,
202 },
203 },
204 },
205 progress,
206 )
207 .await?;
208 question_list_from_response(response)
209 }
210
211 pub async fn list(
213 &self,
214 difficulty: Option<Difficulty>,
215 tag: Option<&str>,
216 progress: impl FnMut(usize, Option<u64>),
217 ) -> Result<SearchResults, Error> {
218 if tag.is_some_and(|tag| !valid_tag(tag)) {
219 return Err(Error::InvalidTag);
220 }
221
222 let response: QuestionListResponse = self
223 .graphql(
224 &QuestionListRequest {
225 query: QUESTION_LIST_QUERY,
226 operation_name: "problemsetQuestionList",
227 variables: QuestionListVariables {
228 category_slug: "",
229 limit: QUESTION_LIST_LIMIT,
230 skip: 0,
231 filters: QuestionListFilters {
232 search_keywords: None,
233 difficulty: difficulty.map(difficulty_name),
234 tags: tag.map(|tag| [tag]),
235 },
236 },
237 },
238 progress,
239 )
240 .await?;
241 question_list_from_response(response)
242 }
243
244 pub async fn auth_status(&self, credentials: &Credentials) -> Result<bool, Error> {
246 let response: UserStatusResponse = self
247 .graphql_authenticated(
248 &UserStatusRequest {
249 query: USER_STATUS_QUERY,
250 operation_name: "userStatus",
251 },
252 credentials,
253 |_, _| {},
254 )
255 .await?;
256 if !response.errors.is_empty() {
257 return Err(Error::Graphql);
258 }
259 Ok(response
260 .data
261 .ok_or(Error::InvalidResponse)?
262 .user_status
263 .ok_or(Error::InvalidResponse)?
264 .is_signed_in)
265 }
266
267 pub async fn account_stats(&self, credentials: &Credentials) -> Result<AccountStats, Error> {
269 let status: UserStatusResponse = self
270 .graphql_authenticated(
271 &UserStatusRequest {
272 query: USER_STATUS_QUERY,
273 operation_name: "userStatus",
274 },
275 credentials,
276 |_, _| {},
277 )
278 .await?;
279 if !status.errors.is_empty() {
280 return Err(Error::Graphql);
281 }
282 let status = status
283 .data
284 .ok_or(Error::InvalidResponse)?
285 .user_status
286 .ok_or(Error::InvalidResponse)?;
287 if !status.is_signed_in {
288 return Err(Error::Authentication);
289 }
290 let username = status.username.ok_or(Error::InvalidResponse)?;
291 if !valid_label(&username, 128) {
292 return Err(Error::InvalidResponse);
293 }
294
295 let response: AccountStatsResponse = self
296 .graphql_authenticated(
297 &AccountStatsRequest {
298 query: ACCOUNT_STATS_QUERY,
299 operation_name: "accountStats",
300 variables: AccountStatsVariables {
301 username: username.as_ref(),
302 },
303 },
304 credentials,
305 |_, _| {},
306 )
307 .await?;
308 account_stats_from_response(response, username)
309 }
310
311 pub async fn contests(
313 &self,
314 progress: impl FnMut(usize, Option<u64>),
315 ) -> Result<Vec<ContestSummary>, Error> {
316 let response: UpcomingContestsResponse = self
317 .graphql(
318 &UpcomingContestsRequest {
319 query: UPCOMING_CONTESTS_QUERY,
320 operation_name: "upcomingContests",
321 },
322 progress,
323 )
324 .await?;
325 contests_from_response(response)
326 }
327
328 pub async fn contest(
330 &self,
331 slug: &str,
332 progress: impl FnMut(usize, Option<u64>),
333 ) -> Result<ContestSummary, Error> {
334 if !valid_slug(slug) {
335 return Err(Error::InvalidSlug);
336 }
337 let response: ContestResponse = self
338 .graphql(
339 &ContestRequest {
340 query: CONTEST_QUERY,
341 operation_name: "contest",
342 variables: Variables { title_slug: slug },
343 },
344 progress,
345 )
346 .await?;
347 contest_from_response(response, slug)
348 }
349
350 pub async fn contest_registration(
352 &self,
353 slug: &str,
354 credentials: &Credentials,
355 progress: impl FnMut(usize, Option<u64>),
356 ) -> Result<ContestRegistration, Error> {
357 if !valid_slug(slug) {
358 return Err(Error::InvalidSlug);
359 }
360 let response: ContestRegistrationResponse = self
361 .graphql_authenticated(
362 &ContestRegistrationRequest {
363 query: CONTEST_REGISTRATION_QUERY,
364 operation_name: "contestRegistration",
365 variables: Variables { title_slug: slug },
366 },
367 credentials,
368 progress,
369 )
370 .await?;
371 contest_registration_from_response(response, slug)
372 }
373
374 pub async fn trending_discussions(
376 &self,
377 progress: impl FnMut(usize, Option<u64>),
378 ) -> Result<DiscussionList, Error> {
379 let response: TrendingDiscussionsResponse = self
380 .graphql(
381 &TrendingDiscussionsRequest {
382 query: TRENDING_DISCUSSIONS_QUERY,
383 operation_name: "trendingDiscussions",
384 },
385 progress,
386 )
387 .await?;
388 discussion_list_from_trending(response)
389 }
390
391 pub async fn problem_discussions(
393 &self,
394 slug: &str,
395 mut progress: impl FnMut(usize, Option<u64>),
396 ) -> Result<DiscussionList, Error> {
397 if !valid_slug(slug) {
398 return Err(Error::InvalidSlug);
399 }
400 let question: DiscussionQuestionResponse = self
401 .graphql(
402 &DiscussionQuestionRequest {
403 query: DISCUSSION_QUESTION_QUERY,
404 operation_name: "discussionQuestion",
405 variables: Variables { title_slug: slug },
406 },
407 &mut progress,
408 )
409 .await?;
410 let question_id = discussion_question_id(question, slug)?;
411 let response: ProblemDiscussionsResponse = self
412 .graphql(
413 &ProblemDiscussionsRequest {
414 query: PROBLEM_DISCUSSIONS_QUERY,
415 operation_name: "problemDiscussions",
416 variables: ProblemDiscussionsVariables {
417 question_id,
418 order_by: "newest_to_oldest",
419 page_no: 1,
420 num_per_page: MAX_DISCUSSIONS as u32,
421 },
422 },
423 progress,
424 )
425 .await?;
426 discussion_list_from_problem(response)
427 }
428
429 pub async fn discussion(
431 &self,
432 topic_id: u32,
433 mut progress: impl FnMut(usize, Option<u64>),
434 ) -> Result<Discussion, Error> {
435 if topic_id == 0 {
436 return Err(Error::InvalidResponse);
437 }
438 let response: DiscussionResponse = self
439 .graphql(
440 &DiscussionRequest {
441 query: DISCUSSION_QUERY,
442 operation_name: "discussion",
443 variables: DiscussionVariables { topic_id },
444 },
445 &mut progress,
446 )
447 .await?;
448 let mut discussion = discussion_from_response(response, topic_id)?;
449 if discussion.content.trim() == "article-topic" {
450 let article: DiscussionArticleResponse = self
451 .graphql(
452 &DiscussionArticleRequest {
453 query: DISCUSSION_ARTICLE_QUERY,
454 operation_name: "discussionArticle",
455 variables: DiscussionArticleVariables { topic_id },
456 },
457 progress,
458 )
459 .await?;
460 discussion.content = discussion_article_content(article)?;
461 }
462 Ok(discussion)
463 }
464
465 pub async fn starter(
467 &self,
468 slug: &str,
469 progress: impl FnMut(usize, Option<u64>),
470 ) -> Result<StarterCode, Error> {
471 if !valid_slug(slug) {
472 return Err(Error::InvalidSlug);
473 }
474 let response: StarterResponse = self
475 .graphql(
476 &StarterRequest {
477 query: STARTER_QUERY,
478 operation_name: "questionEditorData",
479 variables: Variables { title_slug: slug },
480 },
481 progress,
482 )
483 .await?;
484 if !response.errors.is_empty() {
485 return Err(Error::Graphql);
486 }
487 starter_from_response(response, slug)
488 }
489
490 pub async fn test_cases(
492 &self,
493 slug: &str,
494 progress: impl FnMut(usize, Option<u64>),
495 ) -> Result<TestCases, Error> {
496 if !valid_slug(slug) {
497 return Err(Error::InvalidSlug);
498 }
499 let response: TestCasesResponse = self
500 .graphql(
501 &TestCasesRequest {
502 query: TEST_CASES_QUERY,
503 operation_name: "consolePanelConfig",
504 variables: Variables { title_slug: slug },
505 },
506 progress,
507 )
508 .await?;
509 match test_cases_from_response(response, slug) {
510 Err(Error::InvalidResponse) => Err(Error::InvalidTestCases),
511 result => result,
512 }
513 }
514
515 pub async fn run(
517 &self,
518 credentials: &Credentials,
519 slug: &str,
520 question_id: &str,
521 language: &str,
522 source: &str,
523 input: &str,
524 ) -> Result<Box<str>, Error> {
525 if !valid_slug(slug) {
526 return Err(Error::InvalidSlug);
527 }
528 if !valid_question_id(question_id) {
529 return Err(Error::InvalidResponse);
530 }
531 if !valid_language_slug(language) {
532 return Err(Error::InvalidLanguage);
533 }
534 if !valid_source(source) {
535 return Err(Error::InvalidSource);
536 }
537 if !valid_test_input(input) {
538 return Err(Error::InvalidTestInput);
539 }
540 let url = self.endpoint_path(&format!("/problems/{slug}/interpret_solution/"))?;
541 let referer = format!("https://leetcode.com/problems/{slug}/");
542 let response: RunResponse = self
543 .authenticated_json(
544 self.authenticated(
545 self.http.post(url).json(&RunRequest {
546 data_input: input,
547 lang: language,
548 question_id,
549 typed_code: source,
550 }),
551 credentials,
552 )?
553 .header(reqwest::header::REFERER, referer),
554 )
555 .await?;
556 match response.interpret_id {
557 Some(id) if valid_interpret_id(&id) => Ok(id),
558 Some(_) => Err(Error::RunRejected(
559 "LeetCode returned an invalid test-run identifier".into(),
560 )),
561 None => Err(Error::RunRejected(
562 response
563 .detail
564 .or(response.message)
565 .or(response.error)
566 .filter(|message| valid_label(message, 512))
567 .unwrap_or_else(|| "no run identifier was returned".into()),
568 )),
569 }
570 }
571
572 pub async fn run_result(&self, credentials: &Credentials, id: &str) -> Result<RunState, Error> {
574 if !valid_interpret_id(id) {
575 return Err(Error::InvalidResponse);
576 }
577 let url = self.endpoint_path(&format!("/submissions/detail/{id}/check/"))?;
578 let response: RunStatusResponse = self
579 .authenticated_json(self.authenticated(self.http.get(url), credentials)?)
580 .await?;
581 match run_from_response(response, id) {
582 Err(Error::InvalidResponse) => Err(Error::InvalidTestResult),
583 result => result,
584 }
585 }
586
587 pub async fn submit(
589 &self,
590 credentials: &Credentials,
591 slug: &str,
592 question_id: &str,
593 language: &str,
594 source: &str,
595 ) -> Result<u64, Error> {
596 if !valid_slug(slug) {
597 return Err(Error::InvalidSlug);
598 }
599 if !valid_question_id(question_id) {
600 return Err(Error::InvalidResponse);
601 }
602 if !valid_language_slug(language) {
603 return Err(Error::InvalidLanguage);
604 }
605 if !valid_source(source) {
606 return Err(Error::InvalidSource);
607 }
608 let url = self.endpoint_path(&format!("/problems/{slug}/submit/"))?;
609 let response: SubmitResponse = self
610 .authenticated_json(self.authenticated(
611 self.http.post(url).json(&SubmitRequest {
612 question_id,
613 lang: language,
614 typed_code: source,
615 }),
616 credentials,
617 )?)
618 .await?;
619 response
620 .submission_id
621 .filter(|id| *id > 0)
622 .ok_or(Error::InvalidResponse)
623 }
624
625 pub async fn submission(
627 &self,
628 credentials: &Credentials,
629 id: u64,
630 ) -> Result<SubmissionState, Error> {
631 if id == 0 {
632 return Err(Error::InvalidResponse);
633 }
634 let url = self.endpoint_path(&format!("/submissions/detail/{id}/check/"))?;
635 let response: SubmissionResponse = self
636 .authenticated_json(self.authenticated(self.http.get(url), credentials)?)
637 .await?;
638 match submission_from_response(response, id) {
639 Err(Error::InvalidResponse) => Err(Error::InvalidSubmissionResult),
640 result => result,
641 }
642 }
643
644 async fn graphql<T: DeserializeOwned>(
645 &self,
646 request: &impl Serialize,
647 progress: impl FnMut(usize, Option<u64>),
648 ) -> Result<T, Error> {
649 let response = self
650 .http
651 .post(self.endpoint.as_ref())
652 .header(reqwest::header::REFERER, "https://leetcode.com/")
653 .header(reqwest::header::ORIGIN, "https://leetcode.com")
654 .json(request)
655 .send()
656 .await?;
657 if !response.status().is_success() {
658 return Err(Error::Status(response.status()));
659 }
660 read_response(response, progress).await
661 }
662
663 async fn graphql_authenticated<T: DeserializeOwned>(
664 &self,
665 request: &impl Serialize,
666 credentials: &Credentials,
667 progress: impl FnMut(usize, Option<u64>),
668 ) -> Result<T, Error> {
669 let response = self
670 .authenticated(
671 self.http.post(self.endpoint.as_ref()).json(request),
672 credentials,
673 )?
674 .send()
675 .await?;
676 if response.status().is_client_error() && matches!(response.status().as_u16(), 401 | 403) {
677 return Err(Error::Authentication);
678 }
679 if !response.status().is_success() {
680 return Err(Error::Status(response.status()));
681 }
682 read_response(response, progress).await
683 }
684
685 fn authenticated(
686 &self,
687 request: reqwest::RequestBuilder,
688 credentials: &Credentials,
689 ) -> Result<reqwest::RequestBuilder, Error> {
690 let mut cookie = HeaderValue::from_str(&format!(
691 "LEETCODE_SESSION={}; csrftoken={}",
692 credentials.session(),
693 credentials.csrf()
694 ))
695 .map_err(|_| Error::InvalidCredentials)?;
696 cookie.set_sensitive(true);
697 let mut csrf =
698 HeaderValue::from_str(credentials.csrf()).map_err(|_| Error::InvalidCredentials)?;
699 csrf.set_sensitive(true);
700 Ok(request
701 .header(COOKIE, cookie)
702 .header("x-csrftoken", csrf)
703 .header(reqwest::header::REFERER, "https://leetcode.com/")
704 .header(reqwest::header::ORIGIN, "https://leetcode.com"))
705 }
706
707 async fn authenticated_json<T: DeserializeOwned>(
708 &self,
709 request: reqwest::RequestBuilder,
710 ) -> Result<T, Error> {
711 let response = request.send().await?;
712 if matches!(response.status().as_u16(), 401 | 403) {
713 return Err(Error::Authentication);
714 }
715 if !response.status().is_success() {
716 return Err(Error::Status(response.status()));
717 }
718 read_response(response, |_, _| {}).await
719 }
720
721 fn endpoint_path(&self, path: &str) -> Result<reqwest::Url, Error> {
722 let mut url = reqwest::Url::parse(&self.endpoint).map_err(|_| Error::InvalidResponse)?;
723 url.set_path(path);
724 url.set_query(None);
725 Ok(url)
726 }
727}
728
729pub(crate) fn http_builder() -> ClientBuilder {
730 HttpClient::builder()
731 .https_only(true)
732 .connect_timeout(Duration::from_secs(10))
733 .read_timeout(Duration::from_secs(20))
734 .timeout(Duration::from_secs(30))
735 .redirect(Policy::none())
736 .retry(reqwest::retry::never())
737 .no_gzip()
738 .no_brotli()
739 .no_deflate()
740 .no_zstd()
741 .pool_max_idle_per_host(2)
742 .user_agent(concat!(
743 env!("CARGO_PKG_NAME"),
744 "/",
745 env!("CARGO_PKG_VERSION")
746 ))
747}
748
749async fn read_response<T: DeserializeOwned>(
750 mut response: reqwest::Response,
751 mut progress: impl FnMut(usize, Option<u64>),
752) -> Result<T, Error> {
753 if response
754 .content_length()
755 .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64)
756 {
757 return Err(Error::ResponseTooLarge {
758 limit: MAX_RESPONSE_BYTES,
759 });
760 }
761
762 let mut body = Vec::with_capacity(8 * 1024);
763 let total = response.content_length();
764 progress(0, total);
765 while let Some(chunk) = response.chunk().await? {
766 if chunk.len() > MAX_RESPONSE_BYTES - body.len() {
767 return Err(Error::ResponseTooLarge {
768 limit: MAX_RESPONSE_BYTES,
769 });
770 }
771 body.extend_from_slice(&chunk);
772 progress(body.len(), total);
773 }
774 Ok(serde_json::from_slice(&body)?)
775}
776
777#[derive(Serialize)]
778#[serde(rename_all = "camelCase")]
779struct ProblemRequest<'a> {
780 query: &'static str,
781 operation_name: &'static str,
782 variables: Variables<'a>,
783}
784
785#[derive(Serialize)]
786#[serde(rename_all = "camelCase")]
787struct Variables<'a> {
788 title_slug: &'a str,
789}
790
791#[derive(Serialize)]
792#[serde(rename_all = "camelCase")]
793struct DailyRequest {
794 query: &'static str,
795 operation_name: &'static str,
796}
797
798#[derive(Serialize)]
799#[serde(rename_all = "camelCase")]
800struct UpcomingContestsRequest {
801 query: &'static str,
802 operation_name: &'static str,
803}
804
805#[derive(Serialize)]
806#[serde(rename_all = "camelCase")]
807struct ContestRequest<'a> {
808 query: &'static str,
809 operation_name: &'static str,
810 variables: Variables<'a>,
811}
812
813#[derive(Serialize)]
814#[serde(rename_all = "camelCase")]
815struct ContestRegistrationRequest<'a> {
816 query: &'static str,
817 operation_name: &'static str,
818 variables: Variables<'a>,
819}
820
821#[derive(Serialize)]
822#[serde(rename_all = "camelCase")]
823struct TrendingDiscussionsRequest {
824 query: &'static str,
825 operation_name: &'static str,
826}
827
828#[derive(Serialize)]
829#[serde(rename_all = "camelCase")]
830struct DiscussionQuestionRequest<'a> {
831 query: &'static str,
832 operation_name: &'static str,
833 variables: Variables<'a>,
834}
835
836#[derive(Serialize)]
837#[serde(rename_all = "camelCase")]
838struct ProblemDiscussionsRequest {
839 query: &'static str,
840 operation_name: &'static str,
841 variables: ProblemDiscussionsVariables,
842}
843
844#[derive(Serialize)]
845#[serde(rename_all = "camelCase")]
846struct ProblemDiscussionsVariables {
847 question_id: u32,
848 order_by: &'static str,
849 page_no: u32,
850 num_per_page: u32,
851}
852
853#[derive(Serialize)]
854#[serde(rename_all = "camelCase")]
855struct DiscussionRequest {
856 query: &'static str,
857 operation_name: &'static str,
858 variables: DiscussionVariables,
859}
860
861#[derive(Serialize)]
862#[serde(rename_all = "camelCase")]
863struct DiscussionVariables {
864 topic_id: u32,
865}
866
867#[derive(Serialize)]
868#[serde(rename_all = "camelCase")]
869struct DiscussionArticleRequest {
870 query: &'static str,
871 operation_name: &'static str,
872 variables: DiscussionArticleVariables,
873}
874
875#[derive(Serialize)]
876#[serde(rename_all = "camelCase")]
877struct DiscussionArticleVariables {
878 topic_id: u32,
879}
880
881#[derive(Serialize)]
882#[serde(rename_all = "camelCase")]
883struct QuestionListRequest<'a> {
884 query: &'static str,
885 operation_name: &'static str,
886 variables: QuestionListVariables<'a>,
887}
888
889#[derive(Serialize)]
890#[serde(rename_all = "camelCase")]
891struct QuestionListVariables<'a> {
892 category_slug: &'static str,
893 limit: u32,
894 skip: u32,
895 filters: QuestionListFilters<'a>,
896}
897
898#[derive(Serialize)]
899#[serde(rename_all = "camelCase")]
900struct QuestionListFilters<'a> {
901 #[serde(skip_serializing_if = "Option::is_none")]
902 search_keywords: Option<&'a str>,
903 #[serde(skip_serializing_if = "Option::is_none")]
904 difficulty: Option<&'static str>,
905 #[serde(skip_serializing_if = "Option::is_none")]
906 tags: Option<[&'a str; 1]>,
907}
908
909#[derive(Deserialize)]
910struct ProblemResponse {
911 data: Option<ProblemData>,
912 #[serde(default)]
913 errors: Vec<serde::de::IgnoredAny>,
914}
915
916#[derive(Deserialize)]
917struct ProblemData {
918 #[serde(deserialize_with = "Option::deserialize")]
919 question: Option<Question>,
920}
921
922#[derive(Deserialize)]
923#[serde(rename_all = "camelCase")]
924struct Question {
925 frontend_question_id: Option<Box<str>>,
926 title_slug: Box<str>,
927 title: Box<str>,
928 content: Option<Box<str>>,
929 is_paid_only: bool,
930}
931
932#[derive(Deserialize)]
933struct DailyResponse {
934 data: Option<DailyData>,
935 #[serde(default)]
936 errors: Vec<serde::de::IgnoredAny>,
937}
938
939#[derive(Deserialize)]
940#[serde(rename_all = "camelCase")]
941struct DailyData {
942 active_daily_coding_challenge_question: Option<DailyChallenge>,
943}
944
945#[derive(Deserialize)]
946struct DailyChallenge {
947 question: Option<Question>,
948}
949
950#[derive(Serialize)]
951#[serde(rename_all = "camelCase")]
952struct UserStatusRequest {
953 query: &'static str,
954 operation_name: &'static str,
955}
956
957#[derive(Deserialize)]
958struct UserStatusResponse {
959 data: Option<UserStatusData>,
960 #[serde(default)]
961 errors: Vec<serde::de::IgnoredAny>,
962}
963
964#[derive(Deserialize)]
965#[serde(rename_all = "camelCase")]
966struct UserStatusData {
967 user_status: Option<UserStatus>,
968}
969
970#[derive(Deserialize)]
971#[serde(rename_all = "camelCase")]
972struct UserStatus {
973 is_signed_in: bool,
974 username: Option<Box<str>>,
975}
976
977#[derive(Serialize)]
978#[serde(rename_all = "camelCase")]
979struct AccountStatsRequest<'a> {
980 query: &'static str,
981 operation_name: &'static str,
982 variables: AccountStatsVariables<'a>,
983}
984
985#[derive(Serialize)]
986#[serde(rename_all = "camelCase")]
987struct AccountStatsVariables<'a> {
988 username: &'a str,
989}
990
991#[derive(Deserialize)]
992struct AccountStatsResponse {
993 data: Option<AccountStatsData>,
994 #[serde(default)]
995 errors: Vec<serde::de::IgnoredAny>,
996}
997
998#[derive(Deserialize)]
999struct UpcomingContestsResponse {
1000 data: Option<UpcomingContestsData>,
1001 #[serde(default)]
1002 errors: Vec<serde::de::IgnoredAny>,
1003}
1004
1005#[derive(Deserialize)]
1006#[serde(rename_all = "camelCase")]
1007struct UpcomingContestsData {
1008 upcoming_contests: Vec<Contest>,
1009}
1010
1011#[derive(Deserialize)]
1012struct ContestResponse {
1013 data: Option<ContestData>,
1014 #[serde(default)]
1015 errors: Vec<serde::de::IgnoredAny>,
1016}
1017
1018#[derive(Deserialize)]
1019struct ContestData {
1020 #[serde(deserialize_with = "Option::deserialize")]
1021 contest: Option<Contest>,
1022}
1023
1024#[derive(Deserialize)]
1025struct ContestRegistrationResponse {
1026 data: Option<ContestRegistrationData>,
1027 #[serde(default)]
1028 errors: Vec<serde::de::IgnoredAny>,
1029}
1030
1031#[derive(Deserialize)]
1032struct ContestRegistrationData {
1033 #[serde(deserialize_with = "Option::deserialize")]
1034 contest: Option<ContestRegistrationDataContest>,
1035}
1036
1037#[derive(Deserialize)]
1038#[serde(rename_all = "camelCase")]
1039struct ContestRegistrationDataContest {
1040 title_slug: Box<str>,
1041 user_registered: bool,
1042}
1043
1044#[derive(Deserialize)]
1045struct TrendingDiscussionsResponse {
1046 data: Option<TrendingDiscussionsData>,
1047 #[serde(default)]
1048 errors: Vec<serde::de::IgnoredAny>,
1049}
1050
1051#[derive(Deserialize)]
1052#[serde(rename_all = "camelCase")]
1053struct TrendingDiscussionsData {
1054 cached_trending_category_topics: Vec<DiscussionTopic>,
1055}
1056
1057#[derive(Deserialize)]
1058struct DiscussionQuestionResponse {
1059 data: Option<DiscussionQuestionData>,
1060 #[serde(default)]
1061 errors: Vec<serde::de::IgnoredAny>,
1062}
1063
1064#[derive(Deserialize)]
1065struct DiscussionQuestionData {
1066 #[serde(deserialize_with = "Option::deserialize")]
1067 question: Option<DiscussionQuestion>,
1068}
1069
1070#[derive(Deserialize)]
1071#[serde(rename_all = "camelCase")]
1072struct DiscussionQuestion {
1073 question_id: Box<str>,
1074 title_slug: Box<str>,
1075}
1076
1077#[derive(Deserialize)]
1078struct ProblemDiscussionsResponse {
1079 data: Option<ProblemDiscussionsData>,
1080 #[serde(default)]
1081 errors: Vec<serde::de::IgnoredAny>,
1082}
1083
1084#[derive(Deserialize)]
1085#[serde(rename_all = "camelCase")]
1086struct ProblemDiscussionsData {
1087 question_topics: Option<DiscussionConnection>,
1088}
1089
1090#[derive(Deserialize)]
1091#[serde(rename_all = "camelCase")]
1092struct DiscussionConnection {
1093 total_num: NumberResponse,
1094 data: Vec<DiscussionTopic>,
1095}
1096
1097#[derive(Deserialize)]
1098struct DiscussionResponse {
1099 data: Option<DiscussionData>,
1100 #[serde(default)]
1101 errors: Vec<serde::de::IgnoredAny>,
1102}
1103
1104#[derive(Deserialize)]
1105struct DiscussionData {
1106 #[serde(deserialize_with = "Option::deserialize")]
1107 topic: Option<DiscussionTopic>,
1108}
1109
1110#[derive(Deserialize)]
1111struct DiscussionArticleResponse {
1112 data: Option<DiscussionArticleData>,
1113 #[serde(default)]
1114 errors: Vec<serde::de::IgnoredAny>,
1115}
1116
1117#[derive(Deserialize)]
1118#[serde(rename_all = "camelCase")]
1119struct DiscussionArticleData {
1120 ugc_article_discussion_article: Option<DiscussionArticle>,
1121}
1122
1123#[derive(Deserialize)]
1124struct DiscussionArticle {
1125 uuid: Box<str>,
1126 content: Box<str>,
1127}
1128
1129#[derive(Deserialize)]
1130#[serde(rename_all = "camelCase")]
1131struct DiscussionTopic {
1132 id: NumberResponse,
1133 title: Box<str>,
1134 view_count: NumberResponse,
1135 top_level_comment_count: NumberResponse,
1136 post: Option<DiscussionPost>,
1137 #[serde(default)]
1138 tags: Vec<Box<str>>,
1139 #[serde(default)]
1140 pinned: bool,
1141}
1142
1143#[derive(Deserialize)]
1144#[serde(rename_all = "camelCase")]
1145struct DiscussionPost {
1146 vote_count: SignedNumberResponse,
1147 creation_date: NumberResponse,
1148 #[serde(default)]
1149 updation_date: Option<NumberResponse>,
1150 #[serde(default)]
1151 content: Option<Box<str>>,
1152 author: Option<DiscussionAuthor>,
1153}
1154
1155#[derive(Deserialize)]
1156struct DiscussionAuthor {
1157 username: Box<str>,
1158}
1159
1160#[derive(Deserialize)]
1161#[serde(rename_all = "camelCase")]
1162struct Contest {
1163 title: Box<str>,
1164 title_slug: Box<str>,
1165 start_time: NumberResponse,
1166 duration: NumberResponse,
1167 is_virtual: bool,
1168}
1169
1170#[derive(Deserialize)]
1171#[serde(rename_all = "camelCase")]
1172struct AccountStatsData {
1173 matched_user: Option<AccountUser>,
1174 submission_list: Option<SubmissionList>,
1175}
1176
1177#[derive(Deserialize)]
1178#[serde(rename_all = "camelCase")]
1179struct AccountUser {
1180 username: Box<str>,
1181 submit_stats: Option<SubmitStats>,
1182}
1183
1184#[derive(Deserialize)]
1185#[serde(rename_all = "camelCase")]
1186struct SubmitStats {
1187 ac_submission_num: Vec<SubmissionCount>,
1188 total_submission_num: Vec<SubmissionCount>,
1189}
1190
1191#[derive(Deserialize)]
1192struct SubmissionCount {
1193 difficulty: Box<str>,
1194 count: u32,
1195 submissions: u32,
1196}
1197
1198#[derive(Deserialize)]
1199#[serde(rename_all = "camelCase")]
1200struct SubmissionList {
1201 has_next: bool,
1202 submissions: Vec<RecentSubmissionResponse>,
1203}
1204
1205#[derive(Deserialize)]
1206#[serde(rename_all = "camelCase")]
1207struct RecentSubmissionResponse {
1208 id: NumberResponse,
1209 status_display: Box<str>,
1210 title: Box<str>,
1211 title_slug: Box<str>,
1212 timestamp: NumberResponse,
1213 lang: Box<str>,
1214 runtime: Option<Box<str>>,
1215 memory: Option<Box<str>>,
1216 url: Option<Box<str>>,
1217}
1218
1219#[derive(Deserialize)]
1220#[serde(untagged)]
1221enum NumberResponse {
1222 Number(u64),
1223 Text(Box<str>),
1224}
1225
1226#[derive(Deserialize)]
1227#[serde(untagged)]
1228enum SignedNumberResponse {
1229 Number(i64),
1230 Text(Box<str>),
1231}
1232
1233#[derive(Serialize)]
1234#[serde(rename_all = "camelCase")]
1235struct StarterRequest<'a> {
1236 query: &'static str,
1237 operation_name: &'static str,
1238 variables: Variables<'a>,
1239}
1240
1241#[derive(Deserialize)]
1242struct StarterResponse {
1243 data: Option<StarterData>,
1244 #[serde(default)]
1245 errors: Vec<serde::de::IgnoredAny>,
1246}
1247
1248#[derive(Deserialize)]
1249struct StarterData {
1250 #[serde(deserialize_with = "Option::deserialize")]
1251 question: Option<StarterQuestion>,
1252}
1253
1254#[derive(Deserialize)]
1255#[serde(rename_all = "camelCase")]
1256struct StarterQuestion {
1257 question_id: Box<str>,
1258 title: Box<str>,
1259 title_slug: Box<str>,
1260 code_definition: Option<Box<str>>,
1261 is_paid_only: bool,
1262}
1263
1264#[derive(Serialize)]
1265#[serde(rename_all = "camelCase")]
1266struct TestCasesRequest<'a> {
1267 query: &'static str,
1268 operation_name: &'static str,
1269 variables: Variables<'a>,
1270}
1271
1272#[derive(Deserialize)]
1273struct TestCasesResponse {
1274 data: Option<TestCasesData>,
1275 #[serde(default)]
1276 errors: Vec<serde::de::IgnoredAny>,
1277}
1278
1279#[derive(Deserialize)]
1280struct TestCasesData {
1281 #[serde(deserialize_with = "Option::deserialize")]
1282 question: Option<TestCasesQuestion>,
1283}
1284
1285#[derive(Deserialize)]
1286#[serde(rename_all = "camelCase")]
1287struct TestCasesQuestion {
1288 question_id: Box<str>,
1289 title_slug: Box<str>,
1290 is_paid_only: bool,
1291 enable_run_code: bool,
1292 example_testcase_list: Option<Vec<Box<str>>>,
1293 sample_test_case: Option<Box<str>>,
1294}
1295
1296#[derive(Deserialize)]
1297#[serde(rename_all = "camelCase")]
1298struct CodeDefinition {
1299 value: Box<str>,
1300 text: Box<str>,
1301 default_code: Box<str>,
1302}
1303
1304#[derive(Serialize)]
1305struct SubmitRequest<'a> {
1306 question_id: &'a str,
1307 lang: &'a str,
1308 typed_code: &'a str,
1309}
1310
1311#[derive(Serialize)]
1312struct RunRequest<'a> {
1313 data_input: &'a str,
1314 lang: &'a str,
1315 question_id: &'a str,
1316 typed_code: &'a str,
1317}
1318
1319#[derive(Deserialize)]
1320struct RunResponse {
1321 interpret_id: Option<Box<str>>,
1322 detail: Option<Box<str>>,
1323 message: Option<Box<str>>,
1324 error: Option<Box<str>>,
1325}
1326
1327#[derive(Deserialize)]
1328struct SubmitResponse {
1329 submission_id: Option<u64>,
1330}
1331
1332#[derive(Deserialize)]
1333struct SubmissionResponse {
1334 state: Box<str>,
1335 status_msg: Option<Box<str>>,
1336 status_code: Option<i32>,
1337 status_runtime: Option<Box<str>>,
1338 status_memory: Option<Box<str>>,
1339 total_correct: Option<u32>,
1340 total_testcases: Option<u32>,
1341 compile_error: Option<Box<str>>,
1342 full_compile_error: Option<Box<str>>,
1343 runtime_error: Option<Box<str>>,
1344 full_runtime_error: Option<Box<str>>,
1345}
1346
1347#[derive(Deserialize)]
1348struct RunStatusResponse {
1349 state: Box<str>,
1350 status_msg: Option<Box<str>>,
1351 status_code: Option<i32>,
1352 correct_answer: Option<bool>,
1353 status_runtime: Option<Box<str>>,
1354 status_memory: Option<Box<str>>,
1355 total_correct: Option<u32>,
1356 total_testcases: Option<u32>,
1357 code_answer: Option<Vec<Box<str>>>,
1358 expected_answer: Option<Vec<Box<str>>>,
1359 expected_code_answer: Option<Vec<Box<str>>>,
1360 code_output: Option<OutputValue>,
1361 std_output_list: Option<OutputValue>,
1362 input_formatted: Option<Box<str>>,
1363 last_testcase: Option<Box<str>>,
1364 expected_output: Option<Box<str>>,
1365 compile_error: Option<Box<str>>,
1366 full_compile_error: Option<Box<str>>,
1367 runtime_error: Option<Box<str>>,
1368 full_runtime_error: Option<Box<str>>,
1369}
1370
1371#[derive(Deserialize)]
1372#[serde(untagged)]
1373enum OutputValue {
1374 Text(Box<str>),
1375 List(Vec<Box<str>>),
1376}
1377
1378#[derive(Deserialize)]
1379struct QuestionListResponse {
1380 data: Option<QuestionListData>,
1381 #[serde(default)]
1382 errors: Vec<serde::de::IgnoredAny>,
1383}
1384
1385#[derive(Deserialize)]
1386#[serde(rename_all = "camelCase")]
1387struct QuestionListData {
1388 problemset_question_list: QuestionList,
1389}
1390
1391#[derive(Deserialize)]
1392struct QuestionList {
1393 total: u32,
1394 questions: Vec<QuestionListQuestion>,
1395}
1396
1397#[derive(Deserialize)]
1398#[serde(rename_all = "camelCase")]
1399struct QuestionListQuestion {
1400 frontend_question_id: Box<str>,
1401 title: Box<str>,
1402 title_slug: Box<str>,
1403 difficulty: QuestionDifficulty,
1404 is_paid_only: bool,
1405}
1406
1407#[derive(Deserialize)]
1408enum QuestionDifficulty {
1409 Easy,
1410 Medium,
1411 Hard,
1412 #[serde(other)]
1413 Unknown,
1414}
1415
1416fn valid_slug(slug: &str) -> bool {
1417 !slug.is_empty()
1418 && slug.len() <= 128
1419 && slug
1420 .bytes()
1421 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1422}
1423
1424fn valid_tag(tag: &str) -> bool {
1425 !tag.is_empty()
1426 && tag.len() <= 64
1427 && !tag.starts_with('-')
1428 && !tag.ends_with('-')
1429 && tag
1430 .bytes()
1431 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1432}
1433
1434fn valid_question_id(id: &str) -> bool {
1435 !id.is_empty()
1436 && id.len() <= 32
1437 && id.bytes().all(|byte| byte.is_ascii_digit())
1438 && id.parse::<u64>().is_ok_and(|id| id > 0)
1439}
1440
1441fn valid_source(source: &str) -> bool {
1442 !source.is_empty()
1443 && source.len() <= MAX_SOURCE_BYTES
1444 && !source.bytes().any(|byte| byte == b'\0')
1445}
1446
1447fn valid_test_input(input: &str) -> bool {
1448 !input.is_empty()
1449 && input.len() <= MAX_TEST_INPUT_BYTES
1450 && !input.bytes().any(|byte| byte == b'\0')
1451}
1452
1453fn valid_interpret_id(id: &str) -> bool {
1454 !id.is_empty()
1455 && id.len() <= 128
1456 && id.bytes().any(|byte| byte.is_ascii_alphanumeric())
1457 && id
1458 .bytes()
1459 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1460}
1461
1462fn valid_language_slug(language: &str) -> bool {
1463 !language.is_empty()
1464 && language.len() <= 32
1465 && language
1466 .bytes()
1467 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
1468}
1469
1470fn difficulty_name(difficulty: Difficulty) -> &'static str {
1471 match difficulty {
1472 Difficulty::Easy => "EASY",
1473 Difficulty::Medium => "MEDIUM",
1474 Difficulty::Hard => "HARD",
1475 }
1476}
1477
1478fn question_list_from_response(response: QuestionListResponse) -> Result<SearchResults, Error> {
1479 if !response.errors.is_empty() {
1480 return Err(Error::Graphql);
1481 }
1482 let list = response
1483 .data
1484 .ok_or(Error::InvalidResponse)?
1485 .problemset_question_list;
1486 if list.questions.len() > QUESTION_LIST_LIMIT as usize
1487 || list.total < list.questions.len() as u32
1488 {
1489 return Err(Error::InvalidResponse);
1490 }
1491
1492 let mut problems = Vec::with_capacity(list.questions.len());
1493 for question in list.questions {
1494 let number = question
1495 .frontend_question_id
1496 .parse()
1497 .ok()
1498 .filter(|number: &u32| *number > 0)
1499 .ok_or(Error::InvalidResponse)?;
1500 if !valid_slug(&question.title_slug) || !valid_label(&question.title, 256) {
1501 return Err(Error::InvalidResponse);
1502 }
1503 let difficulty = match question.difficulty {
1504 QuestionDifficulty::Easy => Difficulty::Easy,
1505 QuestionDifficulty::Medium => Difficulty::Medium,
1506 QuestionDifficulty::Hard => Difficulty::Hard,
1507 QuestionDifficulty::Unknown => return Err(Error::InvalidResponse),
1508 };
1509 problems.push(ProblemSummary {
1510 number,
1511 id: question.title_slug,
1512 title: question.title,
1513 difficulty,
1514 paid_only: question.is_paid_only,
1515 });
1516 }
1517 Ok(SearchResults {
1518 total: list.total,
1519 problems,
1520 })
1521}
1522
1523fn account_stats_from_response(
1524 response: AccountStatsResponse,
1525 expected_username: Box<str>,
1526) -> Result<AccountStats, Error> {
1527 if !response.errors.is_empty() {
1528 return Err(Error::Graphql);
1529 }
1530 let data = response.data.ok_or(Error::InvalidResponse)?;
1531 let user = data.matched_user.ok_or(Error::Authentication)?;
1532 if user.username != expected_username || !valid_label(&user.username, 128) {
1533 return Err(Error::InvalidResponse);
1534 }
1535 let stats = user.submit_stats.ok_or(Error::InvalidResponse)?;
1536 let solved = difficulty_counts(&stats.ac_submission_num, |row| row.count)?;
1537 let accepted_submissions = difficulty_counts(&stats.ac_submission_num, |row| row.submissions)?;
1538 let submissions = difficulty_counts(&stats.total_submission_num, |row| row.submissions)?;
1539 if !counts_within(&solved, &accepted_submissions)
1540 || !counts_within(&accepted_submissions, &submissions)
1541 {
1542 return Err(Error::InvalidResponse);
1543 }
1544
1545 let list = data.submission_list.ok_or(Error::InvalidResponse)?;
1546 if list.submissions.len() > MAX_RECENT_SUBMISSIONS {
1547 return Err(Error::InvalidResponse);
1548 }
1549 let mut recent_submissions = Vec::with_capacity(list.submissions.len());
1550 for submission in list.submissions {
1551 let id = positive_number(submission.id).ok_or(Error::InvalidResponse)?;
1552 let timestamp = positive_number(submission.timestamp).ok_or(Error::InvalidResponse)?;
1553 if !valid_label(&submission.status_display, 128)
1554 || !valid_label(&submission.title, 256)
1555 || !valid_slug(&submission.title_slug)
1556 || !valid_label(&submission.lang, 64)
1557 {
1558 return Err(Error::InvalidResponse);
1559 }
1560 let pending = matches!(
1561 submission.status_display.as_ref(),
1562 "Pending" | "Judging" | "Started"
1563 );
1564 recent_submissions.push(RecentSubmission {
1565 id,
1566 status: submission.status_display,
1567 title: submission.title,
1568 slug: submission.title_slug,
1569 timestamp,
1570 language: submission.lang,
1571 runtime: optional_label(submission.runtime, 64)?,
1572 memory: optional_label(submission.memory, 64)?,
1573 url: optional_label(submission.url, 1024)?,
1574 pending,
1575 });
1576 }
1577 Ok(AccountStats {
1578 username: user.username,
1579 solved,
1580 accepted_submissions,
1581 submissions,
1582 recent_submissions,
1583 has_more_submissions: list.has_next,
1584 })
1585}
1586
1587fn contests_from_response(
1588 response: UpcomingContestsResponse,
1589) -> Result<Vec<ContestSummary>, Error> {
1590 if !response.errors.is_empty() {
1591 return Err(Error::Graphql);
1592 }
1593 let contests = response
1594 .data
1595 .ok_or(Error::InvalidResponse)?
1596 .upcoming_contests;
1597 if contests.len() > MAX_CONTESTS {
1598 return Err(Error::InvalidResponse);
1599 }
1600 let mut summaries = Vec::with_capacity(contests.len());
1601 for contest in contests {
1602 let summary = contest_summary(contest)?;
1603 if summaries
1604 .iter()
1605 .any(|existing: &ContestSummary| existing.slug == summary.slug)
1606 {
1607 return Err(Error::InvalidResponse);
1608 }
1609 summaries.push(summary);
1610 }
1611 summaries.sort_unstable_by(|left, right| {
1612 (left.start_time, left.slug.as_ref()).cmp(&(right.start_time, right.slug.as_ref()))
1613 });
1614 Ok(summaries)
1615}
1616
1617fn contest_from_response(
1618 response: ContestResponse,
1619 expected_slug: &str,
1620) -> Result<ContestSummary, Error> {
1621 if !response.errors.is_empty() {
1622 return Err(Error::Graphql);
1623 }
1624 let contest = response
1625 .data
1626 .ok_or(Error::InvalidResponse)?
1627 .contest
1628 .ok_or(Error::NotFound)?;
1629 let contest = contest_summary(contest)?;
1630 if contest.slug.as_ref() != expected_slug {
1631 return Err(Error::InvalidResponse);
1632 }
1633 Ok(contest)
1634}
1635
1636fn contest_registration_from_response(
1637 response: ContestRegistrationResponse,
1638 expected_slug: &str,
1639) -> Result<ContestRegistration, Error> {
1640 if !response.errors.is_empty() {
1641 return Err(Error::Graphql);
1642 }
1643 let contest = response
1644 .data
1645 .ok_or(Error::InvalidResponse)?
1646 .contest
1647 .ok_or(Error::NotFound)?;
1648 if !valid_slug(&contest.title_slug) || contest.title_slug.as_ref() != expected_slug {
1649 return Err(Error::InvalidResponse);
1650 }
1651 Ok(ContestRegistration {
1652 slug: contest.title_slug,
1653 registered: contest.user_registered,
1654 })
1655}
1656
1657fn contest_summary(contest: Contest) -> Result<ContestSummary, Error> {
1658 let start_time = positive_number(contest.start_time)
1659 .filter(|start_time| *start_time <= MAX_CONTEST_TIMESTAMP)
1660 .ok_or(Error::InvalidResponse)?;
1661 let duration_seconds = positive_number(contest.duration)
1662 .filter(|duration| *duration <= MAX_CONTEST_DURATION)
1663 .and_then(|duration| u32::try_from(duration).ok())
1664 .ok_or(Error::InvalidResponse)?;
1665 if !valid_slug(&contest.title_slug) || !valid_label(&contest.title, 256) {
1666 return Err(Error::InvalidResponse);
1667 }
1668 Ok(ContestSummary {
1669 slug: contest.title_slug,
1670 title: contest.title,
1671 start_time,
1672 duration_seconds,
1673 virtual_contest: contest.is_virtual,
1674 })
1675}
1676
1677fn discussion_list_from_trending(
1678 response: TrendingDiscussionsResponse,
1679) -> Result<DiscussionList, Error> {
1680 if !response.errors.is_empty() {
1681 return Err(Error::Graphql);
1682 }
1683 let topics = response
1684 .data
1685 .ok_or(Error::InvalidResponse)?
1686 .cached_trending_category_topics;
1687 if topics.len() > 10 {
1688 return Err(Error::InvalidResponse);
1689 }
1690 let discussions = discussion_summaries(topics)?;
1691 Ok(DiscussionList {
1692 total: None,
1693 discussions,
1694 })
1695}
1696
1697fn discussion_question_id(
1698 response: DiscussionQuestionResponse,
1699 expected_slug: &str,
1700) -> Result<u32, Error> {
1701 if !response.errors.is_empty() {
1702 return Err(Error::Graphql);
1703 }
1704 let question = response
1705 .data
1706 .ok_or(Error::InvalidResponse)?
1707 .question
1708 .ok_or(Error::NotFound)?;
1709 if question.title_slug.as_ref() != expected_slug || !valid_question_id(&question.question_id) {
1710 return Err(Error::InvalidResponse);
1711 }
1712 question
1713 .question_id
1714 .parse()
1715 .ok()
1716 .filter(|id: &u32| *id > 0)
1717 .ok_or(Error::InvalidResponse)
1718}
1719
1720fn discussion_list_from_problem(
1721 response: ProblemDiscussionsResponse,
1722) -> Result<DiscussionList, Error> {
1723 if !response.errors.is_empty() {
1724 return Err(Error::Graphql);
1725 }
1726 let topics = response
1727 .data
1728 .ok_or(Error::InvalidResponse)?
1729 .question_topics
1730 .ok_or(Error::InvalidResponse)?;
1731 let total = nonnegative_count(topics.total_num)?;
1732 if topics.data.len() > MAX_DISCUSSIONS || total < topics.data.len() as u32 {
1733 return Err(Error::InvalidResponse);
1734 }
1735 let discussions = discussion_summaries(topics.data)?;
1736 Ok(DiscussionList {
1737 total: Some(total),
1738 discussions,
1739 })
1740}
1741
1742fn discussion_summaries(
1743 topics: impl IntoIterator<Item = DiscussionTopic>,
1744) -> Result<Vec<DiscussionSummary>, Error> {
1745 let mut discussions = Vec::new();
1746 for topic in topics {
1747 let summary = discussion_summary(topic)?;
1748 if discussions
1749 .iter()
1750 .any(|existing: &DiscussionSummary| existing.id == summary.id)
1751 {
1752 return Err(Error::InvalidResponse);
1753 }
1754 discussions.push(summary);
1755 }
1756 Ok(discussions)
1757}
1758
1759fn discussion_summary(topic: DiscussionTopic) -> Result<DiscussionSummary, Error> {
1760 let post = topic.post.ok_or(Error::InvalidResponse)?;
1761 let id = bounded_count(topic.id)?;
1762 let views = nonnegative_count(topic.view_count)?;
1763 let comments = nonnegative_count(topic.top_level_comment_count)?;
1764 let votes = signed_count(post.vote_count)?;
1765 let created_at = positive_number(post.creation_date).ok_or(Error::InvalidResponse)?;
1766 if !valid_label(&topic.title, MAX_DISCUSSION_TITLE) {
1767 return Err(Error::InvalidResponse);
1768 }
1769 Ok(DiscussionSummary {
1770 id,
1771 title: topic.title,
1772 author: discussion_author(post.author)?,
1773 created_at,
1774 views,
1775 comments,
1776 votes,
1777 })
1778}
1779
1780fn discussion_from_response(
1781 response: DiscussionResponse,
1782 expected_id: u32,
1783) -> Result<Discussion, Error> {
1784 if !response.errors.is_empty() {
1785 return Err(Error::Graphql);
1786 }
1787 let topic = response
1788 .data
1789 .ok_or(Error::InvalidResponse)?
1790 .topic
1791 .ok_or(Error::NotFound)?;
1792 let post = topic.post.ok_or(Error::InvalidResponse)?;
1793 let id = bounded_count(topic.id)?;
1794 let views = nonnegative_count(topic.view_count)?;
1795 let comments = nonnegative_count(topic.top_level_comment_count)?;
1796 let votes = signed_count(post.vote_count)?;
1797 let created_at = positive_number(post.creation_date).ok_or(Error::InvalidResponse)?;
1798 let updated_at = match post.updation_date {
1799 Some(value) => Some(positive_number(value).ok_or(Error::InvalidResponse)?),
1800 None => None,
1801 };
1802 let content = post.content.ok_or(Error::InvalidResponse)?;
1803 if id != expected_id
1804 || !valid_label(&topic.title, MAX_DISCUSSION_TITLE)
1805 || !valid_discussion_content(&content)
1806 || topic.tags.len() > MAX_DISCUSSION_TAGS
1807 || topic
1808 .tags
1809 .iter()
1810 .any(|tag| !valid_label(tag, MAX_DISCUSSION_TAG))
1811 {
1812 return Err(Error::InvalidResponse);
1813 }
1814 Ok(Discussion {
1815 id,
1816 title: topic.title,
1817 author: discussion_author(post.author)?,
1818 content,
1819 created_at,
1820 updated_at,
1821 views,
1822 comments,
1823 votes,
1824 tags: topic.tags,
1825 pinned: topic.pinned,
1826 })
1827}
1828
1829fn discussion_article_content(response: DiscussionArticleResponse) -> Result<Box<str>, Error> {
1830 if !response.errors.is_empty() {
1831 return Err(Error::Graphql);
1832 }
1833 let article = response
1834 .data
1835 .ok_or(Error::InvalidResponse)?
1836 .ugc_article_discussion_article
1837 .ok_or(Error::NotFound)?;
1838 if !valid_label(&article.uuid, MAX_DISCUSSION_ARTICLE_UUID)
1839 || !valid_discussion_content(&article.content)
1840 {
1841 return Err(Error::InvalidResponse);
1842 }
1843 Ok(article.content)
1844}
1845
1846fn valid_discussion_content(content: &str) -> bool {
1847 content.len() <= MAX_DISCUSSION_CONTENT && !content.bytes().any(|byte| byte == b'\0')
1848}
1849
1850fn discussion_author(author: Option<DiscussionAuthor>) -> Result<Option<Box<str>>, Error> {
1851 match author {
1852 None => Ok(None),
1853 Some(author) if author.username.as_ref() == "deleted_user" => Ok(None),
1854 Some(author) if valid_label(&author.username, 64) => Ok(Some(author.username)),
1855 Some(_) => Err(Error::InvalidResponse),
1856 }
1857}
1858
1859fn bounded_count(value: NumberResponse) -> Result<u32, Error> {
1860 positive_number(value)
1861 .filter(|value| *value <= MAX_DISCUSSION_COUNT)
1862 .and_then(|value| u32::try_from(value).ok())
1863 .ok_or(Error::InvalidResponse)
1864}
1865
1866fn nonnegative_count(value: NumberResponse) -> Result<u32, Error> {
1867 let value = match value {
1868 NumberResponse::Number(value) => value,
1869 NumberResponse::Text(value) => value.parse().map_err(|_| Error::InvalidResponse)?,
1870 };
1871 u32::try_from(value)
1872 .ok()
1873 .filter(|value| *value as u64 <= MAX_DISCUSSION_COUNT)
1874 .ok_or(Error::InvalidResponse)
1875}
1876
1877fn signed_count(value: SignedNumberResponse) -> Result<i32, Error> {
1878 let value = match value {
1879 SignedNumberResponse::Number(value) => value,
1880 SignedNumberResponse::Text(value) => value.parse().map_err(|_| Error::InvalidResponse)?,
1881 };
1882 i32::try_from(value)
1883 .ok()
1884 .filter(|value| value.unsigned_abs() <= MAX_DISCUSSION_COUNT as u32)
1885 .ok_or(Error::InvalidResponse)
1886}
1887
1888fn positive_number(value: NumberResponse) -> Option<u64> {
1889 match value {
1890 NumberResponse::Number(value) => Some(value),
1891 NumberResponse::Text(value) => value.parse().ok(),
1892 }
1893 .filter(|value| *value > 0)
1894}
1895
1896fn difficulty_counts(
1897 rows: &[SubmissionCount],
1898 value: impl Fn(&SubmissionCount) -> u32,
1899) -> Result<DifficultyCounts, Error> {
1900 if rows.len() != 4 {
1901 return Err(Error::InvalidResponse);
1902 }
1903 let mut counts = [None; 4];
1904 for row in rows {
1905 if row.count > row.submissions
1906 || row.count > MAX_ACCOUNT_COUNT
1907 || row.submissions > MAX_ACCOUNT_COUNT
1908 {
1909 return Err(Error::InvalidResponse);
1910 }
1911 let index = match row.difficulty.as_ref() {
1912 "All" => 0,
1913 "Easy" => 1,
1914 "Medium" => 2,
1915 "Hard" => 3,
1916 _ => return Err(Error::InvalidResponse),
1917 };
1918 if counts[index].replace(value(row)).is_some() {
1919 return Err(Error::InvalidResponse);
1920 }
1921 }
1922 let [Some(all), Some(easy), Some(medium), Some(hard)] = counts else {
1923 return Err(Error::InvalidResponse);
1924 };
1925 Ok(DifficultyCounts {
1926 all,
1927 easy,
1928 medium,
1929 hard,
1930 })
1931}
1932
1933fn counts_within(left: &DifficultyCounts, right: &DifficultyCounts) -> bool {
1934 left.all <= right.all
1935 && left.easy <= right.easy
1936 && left.medium <= right.medium
1937 && left.hard <= right.hard
1938}
1939
1940fn starter_from_response(
1941 response: StarterResponse,
1942 expected_slug: &str,
1943) -> Result<StarterCode, Error> {
1944 if !response.errors.is_empty() {
1945 return Err(Error::Graphql);
1946 }
1947 let question = response
1948 .data
1949 .ok_or(Error::InvalidResponse)?
1950 .question
1951 .ok_or(Error::NotFound)?;
1952 if question.is_paid_only {
1953 return Err(Error::PremiumRequired);
1954 }
1955 if !valid_question_id(&question.question_id)
1956 || question.title_slug.as_ref() != expected_slug
1957 || !valid_label(&question.title_slug, 128)
1958 || !valid_label(&question.title, 256)
1959 {
1960 return Err(Error::InvalidResponse);
1961 }
1962 let code_definition = question.code_definition.ok_or(Error::InvalidResponse)?;
1963 if code_definition.len() > MAX_CODE_DEFINITION_BYTES {
1964 return Err(Error::InvalidResponse);
1965 }
1966 let definitions: Vec<CodeDefinition> =
1967 serde_json::from_str(&code_definition).map_err(|_| Error::InvalidResponse)?;
1968 if definitions.is_empty() || definitions.len() > MAX_STARTER_SNIPPETS {
1969 return Err(Error::InvalidResponse);
1970 }
1971 let mut snippets = Vec::with_capacity(definitions.len());
1972 for definition in definitions {
1973 if !valid_label(&definition.text, 64)
1974 || !valid_language_slug(&definition.value)
1975 || !valid_source(&definition.default_code)
1976 || snippets.iter().any(|snippet: &CodeSnippet| {
1977 snippet.language == definition.text || snippet.language_slug == definition.value
1978 })
1979 {
1980 return Err(Error::InvalidResponse);
1981 }
1982 snippets.push(CodeSnippet {
1983 language: definition.text,
1984 language_slug: definition.value,
1985 source: definition.default_code,
1986 });
1987 }
1988 Ok(StarterCode {
1989 question_id: question.question_id,
1990 id: question.title_slug,
1991 title: question.title,
1992 snippets,
1993 })
1994}
1995
1996fn test_cases_from_response(
1997 response: TestCasesResponse,
1998 expected_slug: &str,
1999) -> Result<TestCases, Error> {
2000 if !response.errors.is_empty() {
2001 return Err(Error::Graphql);
2002 }
2003 let question = response
2004 .data
2005 .ok_or(Error::InvalidResponse)?
2006 .question
2007 .ok_or(Error::NotFound)?;
2008 if question.is_paid_only {
2009 return Err(Error::PremiumRequired);
2010 }
2011 if !question.enable_run_code {
2012 return Err(Error::RunUnavailable);
2013 }
2014 if !valid_question_id(&question.question_id) || question.title_slug.as_ref() != expected_slug {
2015 return Err(Error::InvalidResponse);
2016 }
2017
2018 let examples = question.example_testcase_list.unwrap_or_default();
2019 if examples.len() > MAX_TEST_CASES {
2020 return Err(Error::InvalidResponse);
2021 }
2022 let input = if examples.is_empty() {
2023 question
2024 .sample_test_case
2025 .filter(|sample| !sample.trim().is_empty())
2026 .ok_or(Error::InvalidResponse)?
2027 .into()
2028 } else {
2029 let mut input = String::new();
2030 for example in examples.iter().filter(|example| !example.trim().is_empty()) {
2031 if !input.is_empty() {
2032 input.push('\n');
2033 }
2034 input.push_str(example);
2035 if input.len() > MAX_TEST_INPUT_BYTES {
2036 return Err(Error::InvalidResponse);
2037 }
2038 }
2039 input
2040 };
2041 if !valid_test_input(&input) {
2042 return Err(Error::InvalidResponse);
2043 }
2044 Ok(TestCases {
2045 question_id: question.question_id,
2046 input: input.into(),
2047 })
2048}
2049
2050fn submission_from_response(
2051 response: SubmissionResponse,
2052 id: u64,
2053) -> Result<SubmissionState, Error> {
2054 match response.state.as_ref() {
2055 "PENDING" | "STARTED" => Ok(SubmissionState::Pending),
2056 "SUCCESS" => {
2057 let status =
2058 optional_label(response.status_msg, 256)?.unwrap_or_else(|| "Finished".into());
2059 let accepted = response.status_code == Some(10) || status.as_ref() == "Accepted";
2060 let runtime = optional_label(response.status_runtime, 64)?;
2061 let memory = optional_label(response.status_memory, 64)?;
2062 if matches!(
2063 (response.total_correct, response.total_testcases),
2064 (Some(passed), Some(total)) if passed > total
2065 ) {
2066 return Err(Error::InvalidResponse);
2067 }
2068 let message = optional_message(response.full_compile_error)?
2069 .or(optional_message(response.full_runtime_error)?)
2070 .or(optional_message(response.compile_error)?)
2071 .or(optional_message(response.runtime_error)?);
2072 Ok(SubmissionState::Complete(SubmissionResult {
2073 id,
2074 status,
2075 accepted,
2076 runtime,
2077 memory,
2078 passed: response.total_correct,
2079 total: response.total_testcases,
2080 message,
2081 }))
2082 }
2083 _ => Err(Error::InvalidResponse),
2084 }
2085}
2086
2087fn run_from_response(response: RunStatusResponse, id: &str) -> Result<RunState, Error> {
2088 match response.state.as_ref() {
2089 "PENDING" | "STARTED" => Ok(RunState::Pending),
2090 "SUCCESS" => {
2091 let status = optional_label(response.status_msg, 256)?;
2092 let status_code = response.status_code;
2093 let runtime = optional_label(response.status_runtime, 64)?;
2094 let memory = optional_label(response.status_memory, 64)?;
2095 if matches!(
2096 (response.total_correct, response.total_testcases),
2097 (Some(passed), Some(total)) if passed > total
2098 ) {
2099 return Err(Error::InvalidResponse);
2100 }
2101 let expected_answers = response
2102 .expected_code_answer
2103 .filter(|answers| !answers.is_empty())
2104 .or(response.expected_answer);
2105 let observed = response
2106 .code_answer
2107 .as_ref()
2108 .zip(expected_answers.as_ref())
2109 .filter(|(actual, expected)| !actual.is_empty() && actual.len() == expected.len())
2110 .and_then(|(actual, expected)| {
2111 let total = u32::try_from(actual.len()).ok()?;
2112 let passed = u32::try_from(
2113 actual
2114 .iter()
2115 .zip(expected)
2116 .filter(|(actual, expected)| actual == expected)
2117 .count(),
2118 )
2119 .ok()?;
2120 Some((passed, total))
2121 });
2122 let passed_cases = response
2123 .total_correct
2124 .or_else(|| observed.map(|(passed, _)| passed));
2125 let total_cases = response
2126 .total_testcases
2127 .or_else(|| observed.map(|(_, total)| total));
2128 let passed = response
2129 .correct_answer
2130 .or_else(|| {
2131 passed_cases
2132 .zip(total_cases)
2133 .map(|(passed, total)| passed == total)
2134 })
2135 .unwrap_or(status_code == Some(10) || status.as_deref() == Some("Accepted"));
2136 let has_compile_error = response
2137 .full_compile_error
2138 .as_deref()
2139 .or(response.compile_error.as_deref())
2140 .is_some_and(|error| !error.is_empty());
2141 let has_runtime_error = response
2142 .full_runtime_error
2143 .as_deref()
2144 .or(response.runtime_error.as_deref())
2145 .is_some_and(|error| !error.is_empty());
2146 let mut status = status.unwrap_or_else(|| {
2147 if has_compile_error {
2148 "Compile Error".into()
2149 } else if has_runtime_error {
2150 "Runtime Error".into()
2151 } else if passed {
2152 "Accepted".into()
2153 } else {
2154 "Wrong Answer".into()
2155 }
2156 });
2157 if !passed && status_code == Some(10) && status.as_ref() == "Accepted" {
2158 status = "Wrong Answer".into();
2159 }
2160 let message = optional_output(response.full_compile_error)?
2161 .or(optional_output(response.full_runtime_error)?)
2162 .or(optional_output(response.compile_error)?)
2163 .or(optional_output(response.runtime_error)?);
2164 let output = optional_output_list(response.code_answer)?
2165 .or(optional_output_value(response.code_output)?)
2166 .or(optional_output_value(response.std_output_list)?);
2167 Ok(RunState::Complete(RunResult {
2168 id: id.into(),
2169 status,
2170 passed,
2171 runtime,
2172 memory,
2173 passed_cases,
2174 total_cases,
2175 input: optional_output(response.input_formatted)?
2176 .or(optional_output(response.last_testcase)?),
2177 output,
2178 expected: optional_output_list(expected_answers)?
2179 .or(optional_output(response.expected_output)?),
2180 message,
2181 }))
2182 }
2183 _ => Err(Error::InvalidResponse),
2184 }
2185}
2186
2187fn optional_label(value: Option<Box<str>>, limit: usize) -> Result<Option<Box<str>>, Error> {
2188 match value {
2189 Some(value) if value.is_empty() => Ok(None),
2190 Some(value) if valid_label(&value, limit) => Ok(Some(value)),
2191 Some(_) => Err(Error::InvalidResponse),
2192 None => Ok(None),
2193 }
2194}
2195
2196fn optional_message(value: Option<Box<str>>) -> Result<Option<Box<str>>, Error> {
2197 match value {
2198 Some(value) if value.is_empty() => Ok(None),
2199 Some(value) if value.len() <= 64 * 1024 && !value.bytes().any(|byte| byte == b'\0') => {
2200 Ok(Some(value))
2201 }
2202 Some(_) => Err(Error::InvalidResponse),
2203 None => Ok(None),
2204 }
2205}
2206
2207fn optional_output(value: Option<Box<str>>) -> Result<Option<Box<str>>, Error> {
2208 let Some(value) = value else {
2209 return Ok(None);
2210 };
2211 if value.is_empty() {
2212 return Ok(None);
2213 }
2214 if value.len() > MAX_RUN_OUTPUT_BYTES || value.bytes().any(|byte| byte == b'\0') {
2215 return Err(Error::InvalidResponse);
2216 }
2217 let sanitized: String = value
2218 .chars()
2219 .filter(|character| !character.is_control() || matches!(character, '\n' | '\r' | '\t'))
2220 .collect();
2221 Ok((!sanitized.is_empty()).then(|| sanitized.into()))
2222}
2223
2224fn optional_output_value(value: Option<OutputValue>) -> Result<Option<Box<str>>, Error> {
2225 match value {
2226 Some(OutputValue::Text(value)) => optional_output(Some(value)),
2227 Some(OutputValue::List(values)) => optional_output_list(Some(values)),
2228 None => Ok(None),
2229 }
2230}
2231
2232fn optional_output_list(values: Option<Vec<Box<str>>>) -> Result<Option<Box<str>>, Error> {
2233 let Some(values) = values else {
2234 return Ok(None);
2235 };
2236 if values.len() > MAX_TEST_CASES {
2237 return Err(Error::InvalidResponse);
2238 }
2239 let mut joined = String::new();
2240 for (index, value) in values.into_iter().enumerate() {
2241 let separator = usize::from(index > 0);
2242 if value.len() + separator > MAX_RUN_OUTPUT_BYTES.saturating_sub(joined.len()) {
2243 return Err(Error::InvalidResponse);
2244 }
2245 if index > 0 {
2246 joined.push('\n');
2247 }
2248 joined.push_str(&value);
2249 }
2250 optional_output(Some(joined.into()))
2251}
2252
2253fn problem_from_question(
2254 question: Question,
2255 expected_slug: Option<&str>,
2256) -> Result<Problem, Error> {
2257 let number = question
2258 .frontend_question_id
2259 .as_deref()
2260 .and_then(|number| number.parse::<u32>().ok())
2261 .filter(|number| *number > 0)
2262 .ok_or(Error::InvalidResponse)?;
2263 if !valid_slug(&question.title_slug)
2264 || expected_slug.is_some_and(|slug| question.title_slug.as_ref() != slug)
2265 || !valid_label(&question.title, 256)
2266 {
2267 return Err(Error::InvalidResponse);
2268 }
2269 let statement = question
2270 .content
2271 .filter(|content| !content.trim().is_empty())
2272 .ok_or(if question.is_paid_only {
2273 Error::PremiumRequired
2274 } else {
2275 Error::InvalidResponse
2276 })?;
2277 Ok(Problem {
2278 number,
2279 id: question.title_slug,
2280 title: question.title,
2281 statement,
2282 })
2283}
2284
2285fn valid_label(value: &str, limit: usize) -> bool {
2286 !value.is_empty()
2287 && value.len() <= limit
2288 && value == value.trim()
2289 && !value.chars().any(char::is_control)
2290}