Skip to main content

cp_cli_platform_leetcode/
client.rs

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    /// Fetch a public problem by its title slug, without authentication
119    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    /// Fetch today's public daily challenge, without authentication
150    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    /// Search public problems by title keywords, without authentication
174    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    /// List public problems with optional difficulty and tag filters, without authentication
212    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    /// Confirm whether credentials describe a signed-in LeetCode session
245    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    /// Fetch bounded account progress and recent submissions for a signed-in session
268    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    /// List the upcoming public contests
312    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    /// Fetch one public contest by its title slug
329    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    /// Fetch the signed-in participant's registration state for one contest
351    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    /// List ten public discussions currently trending on LeetCode
375    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    /// List the twenty newest public discussions for a problem
392    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    /// Fetch one public discussion and its Markdown post
430    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    /// Fetch every public starter source for a problem
466    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    /// Fetch public example input for LeetCode's remote run-code endpoint
491    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    /// Run source once against public example input; this never creates a submission
516    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    /// Read one remote run status without polling
573    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    /// Submit source once; callers own subsequent status polling
588    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    /// Read one submission status without polling
626    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}