atcoder-kit 0.2.0

A command-line tool for AtCoder like acc and oj.
Documentation
use crate::client::model::Problem;
use crate::client::parser::{
    AtCoderPageError, classify_alert, parse_alert, parse_csrf_token, parse_submission_detail_href,
};
use crate::client::{AtCoderClient, endpoints};
use crate::validation::validate_atcoder_identifier;
use anyhow::{Context, Result, bail};
use reqwest::StatusCode;
use serde::Serialize;

#[derive(Serialize, Debug)]
struct SubmitData {
    #[serde(rename = "data.TaskScreenName")]
    task_name: String,
    #[serde(rename = "data.LanguageId")]
    language_id: String,
    #[serde(rename = "sourceCode")]
    source_code: String,
    #[serde(rename = "csrf_token")]
    csrf_token: String,
}

fn submission_error(error: AtCoderPageError) -> anyhow::Error {
    match error {
        AtCoderPageError::ContestNotFound => {
            anyhow::anyhow!("Failed to submit task: Contest not found.")
        }
        AtCoderPageError::TaskNotFound => {
            anyhow::anyhow!("Failed to submit task: Task not found.")
        }
        AtCoderPageError::PermissionDenied => anyhow::anyhow!(
            r#"Failed to submit task: You are not logged in. Run "ackit login" first."#
        ),
        AtCoderPageError::SourceTooLong => {
            anyhow::anyhow!("Failed to submit task: Source code is too long.")
        }
        AtCoderPageError::SourceEmpty => {
            anyhow::anyhow!("Failed to submit task: Source code is empty.")
        }
        AtCoderPageError::TurnstileRequired => {
            anyhow::anyhow!("Failed to submit task: Error. (Maybe Cloudflare Turnstile Required)")
        }
        AtCoderPageError::UnknownAlert(alert) => {
            anyhow::anyhow!("Failed to submit task: {alert}")
        }
    }
}

fn absolute_url(href: &str) -> String {
    if href.starts_with("http://") || href.starts_with("https://") {
        href.to_string()
    } else if href.starts_with('/') {
        format!("{}{href}", endpoints::BASE)
    } else {
        format!("{}/{href}", endpoints::BASE)
    }
}

impl AtCoderClient {
    pub(crate) async fn submit_solution(
        &self,
        contest_id: &str,
        problem: &Problem,
        language_id: u16,
        source_code: String,
    ) -> Result<String> {
        validate_atcoder_identifier(contest_id, "contest ID")?;
        validate_atcoder_identifier(&problem.id, "problem ID")?;
        let problem_url = endpoints::problem(contest_id, &problem.id);
        let problem_page = self.get_page(&problem_url).await?;
        if problem_page.status == StatusCode::NOT_FOUND {
            let error = parse_alert(&problem_page.body)?
                .map(|alert| classify_alert(&alert))
                .unwrap_or_else(|| AtCoderPageError::UnknownAlert("404".into()));
            return Err(submission_error(error));
        }
        if !problem_page.status.is_success() {
            bail!(
                "Failed to fetch task before submission: status {}.",
                problem_page.status.as_u16()
            );
        }
        let csrf_token = parse_csrf_token(&problem_page.body)?;
        let payload = SubmitData {
            task_name: problem.id.clone(),
            language_id: language_id.to_string(),
            source_code,
            csrf_token,
        };

        let response = self
            .http
            .post(endpoints::submit(contest_id))
            .headers(self.headers())
            .form(&payload)
            .send()
            .await
            .context("Failed to submit task.")?;
        let status = response.status();
        let body = response
            .text()
            .await
            .context("Failed to read submission response.")?;
        if let Some(alert) = parse_alert(&body)? {
            return Err(submission_error(classify_alert(&alert)));
        }
        if status != StatusCode::FOUND {
            bail!("Failed to submit task: status {}", status.as_u16());
        }

        let submissions = self.get_page(&endpoints::submissions(contest_id)).await?;
        if !submissions.status.is_success() {
            bail!(
                "Failed to get submissions: status {}.",
                submissions.status.as_u16()
            );
        }
        Ok(absolute_url(&parse_submission_detail_href(
            &submissions.body,
        )?))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resolves_submission_urls() {
        assert_eq!(
            absolute_url("/contests/abc999/submissions/1"),
            "https://atcoder.jp/contests/abc999/submissions/1"
        );
        assert_eq!(
            absolute_url("https://example.com/1"),
            "https://example.com/1"
        );
    }
}