use reqwest::Client;
use serde::Deserialize;
use crate::config::Credentials;
use crate::parser::{self, Verdict};
const BASE_URL: &str = "https://onlinejudge.org";
const LOGIN_URL: &str = "https://onlinejudge.org/index.php?option=com_comprofiler&task=login";
const SUBMIT_URL: &str =
"https://onlinejudge.org/index.php?option=com_onlinejudge&Itemid=25&page=save_submission";
const STATUS_URL: &str = "https://onlinejudge.org/index.php?option=com_onlinejudge&Itemid=9";
const UHUNT_BASE: &str = "https://uhunt.onlinejudge.org/api";
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const ACCEPT_LANGUAGE: &str = "en-US,en;q=0.9";
const DEFAULT_POLL_TIMEOUT: u64 = 120;
const POLL_INTERVAL_SECS: u64 = 1;
pub struct UvaClient {
client: Client,
verbose: bool,
}
#[derive(Debug, Clone)]
pub struct SubmissionResult {
pub run_id: String,
pub initial_verdict: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UhuntSubmission {
#[serde(rename = "run")]
pub run_id: u32,
#[serde(rename = "probid")]
pub problem_id: u32,
#[serde(rename = "ver")]
pub verdict_code: u32,
#[serde(rename = "pte")]
pub runtime: u32,
#[serde(rename = "lang")]
pub language: String,
#[serde(rename = "out")]
pub date: String,
}
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("Login failed: {0}")]
LoginFailed(String),
#[error("Network error: {0}")]
NetworkError(#[from] reqwest::Error),
#[error("Parse error: {0}")]
ParseError(String),
#[error("Polling timed out after {0} seconds")]
PollingTimeout(u64),
}
impl UvaClient {
pub fn new(verbose: bool) -> Result<Self, reqwest::Error> {
let client = Client::builder()
.cookie_store(true)
.user_agent(USER_AGENT)
.build()?;
Ok(UvaClient { client, verbose })
}
pub async fn login(&mut self, credentials: &Credentials) -> Result<(), ClientError> {
if self.verbose {
eprintln!("[verbose] GET {}", BASE_URL);
}
let index_resp = self
.client
.get(BASE_URL)
.header("Accept-Language", ACCEPT_LANGUAGE)
.send()
.await?;
if !index_resp.status().is_success() {
return Err(ClientError::LoginFailed(format!(
"Failed to load index page: HTTP {}",
index_resp.status()
)));
}
let index_html = index_resp.text().await?;
if self.verbose {
eprintln!(
"[verbose] Index page loaded ({} bytes), parsing login form...",
index_html.len()
);
}
let login_form = parser::parse_login_form(&index_html)
.map_err(|e| ClientError::ParseError(format!("Failed to parse login form: {e}")))?;
if self.verbose {
eprintln!(
"[verbose] Found {} fields in login form",
login_form.hidden_fields.len()
);
for (k, v) in &login_form.hidden_fields {
eprintln!(" {k} = {v}");
}
}
let mut post_data: Vec<(&str, &str)> = login_form
.hidden_fields
.iter()
.filter(|(name, _)| name != "username" && name != "passwd" && name != "Submit")
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
post_data.push(("username", &credentials.username));
post_data.push(("passwd", &credentials.password));
post_data.push(("remember", "yes"));
if self.verbose {
eprintln!("[verbose] POST {}", LOGIN_URL);
}
let login_resp = self
.client
.post(LOGIN_URL)
.header("Accept-Language", ACCEPT_LANGUAGE)
.form(&post_data)
.send()
.await?;
if self.verbose {
eprintln!(
"[verbose] Login response: HTTP {} (redirected to {})",
login_resp.status(),
login_resp.url()
);
}
if self.verbose {
eprintln!("[verbose] GET {} (login verification)", STATUS_URL);
}
let status_resp = self
.client
.get(STATUS_URL)
.header("Accept-Language", ACCEPT_LANGUAGE)
.send()
.await?;
let status_html = status_resp.text().await?;
if !parser::is_logged_in(&status_html) {
return Err(ClientError::LoginFailed(
"Incorrect username or password, or session setup failed".to_string(),
));
}
if self.verbose {
eprintln!("[verbose] Login verified successfully");
}
Ok(())
}
pub async fn submit(
&mut self,
problem_id: &str,
language_code: u8,
source_code: &str,
) -> Result<SubmissionResult, ClientError> {
let form = reqwest::multipart::Form::new()
.text("problemid".to_string(), String::new())
.text("category".to_string(), String::new())
.text("localid".to_string(), problem_id.to_string())
.text("language".to_string(), language_code.to_string())
.text("code".to_string(), source_code.to_string());
if self.verbose {
eprintln!(
"[verbose] POST {} (problem={}, lang={})",
SUBMIT_URL, problem_id, language_code
);
}
let submit_resp = self
.client
.post(SUBMIT_URL)
.header("Accept-Language", ACCEPT_LANGUAGE)
.multipart(form)
.send()
.await?;
let submit_url = submit_resp.url().to_string();
let submit_status = submit_resp.status();
if self.verbose {
eprintln!(
"[verbose] Submit response: HTTP {} (url: {})",
submit_status, submit_url
);
}
if self.verbose {
eprintln!(
"[verbose] GET {} (fetching status after submission)",
STATUS_URL
);
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let status_resp = self
.client
.get(STATUS_URL)
.header("Accept-Language", ACCEPT_LANGUAGE)
.send()
.await?;
let status_html = status_resp.text().await?;
if self.verbose {
eprintln!("[verbose] Status page loaded ({} bytes)", status_html.len());
if status_html.len() > 500 {
eprintln!("[verbose] First 500 chars: {}", &status_html[..500]);
}
}
let verdict = parser::parse_latest_verdict(&status_html).map_err(|e| {
ClientError::ParseError(format!("Failed to parse submission response: {e}"))
})?;
if self.verbose {
eprintln!(
"[verbose] Parsed submission result: run_id={}, verdict={}",
verdict.run_id, verdict.verdict
);
}
Ok(SubmissionResult {
run_id: verdict.run_id,
initial_verdict: verdict.verdict,
})
}
pub async fn poll_verdict(&self, max_duration_secs: u64) -> Result<Verdict, ClientError> {
let timeout = if max_duration_secs == 0 {
DEFAULT_POLL_TIMEOUT
} else {
max_duration_secs
};
if self.verbose {
eprintln!(
"[verbose] Polling status page (timeout={}s, interval={}s)",
timeout, POLL_INTERVAL_SECS
);
}
let start = std::time::Instant::now();
loop {
let elapsed_secs = start.elapsed().as_secs();
if elapsed_secs >= timeout {
return Err(ClientError::PollingTimeout(timeout));
}
tokio::time::sleep(std::time::Duration::from_secs(POLL_INTERVAL_SECS)).await;
if self.verbose {
eprintln!(
"[verbose] Polling attempt (elapsed={}s)",
start.elapsed().as_secs()
);
}
let status_resp = self
.client
.get(STATUS_URL)
.header("Accept-Language", ACCEPT_LANGUAGE)
.send()
.await?;
let status_html = status_resp.text().await?;
let verdict = parser::parse_latest_verdict(&status_html).map_err(|e| {
ClientError::ParseError(format!("Failed to parse status page during polling: {e}"))
})?;
if self.verbose {
eprintln!(
"[verbose] Verdict: \"{}\" (run_id: {})",
verdict.verdict, verdict.run_id
);
}
if !verdict.verdict.eq_ignore_ascii_case("In judge queue") {
return Ok(verdict);
}
}
}
pub async fn get_uhunt_user_id(username: &str) -> Result<u32, ClientError> {
let url = format!("{UHUNT_BASE}/uname2uid/{username}");
let resp = Client::new()
.get(&url)
.header("User-Agent", USER_AGENT)
.send()
.await?;
let text = resp.text().await?.trim().to_string();
text.parse::<u32>().map_err(|e| {
ClientError::ParseError(format!(
"Failed to parse user ID from uHunt response \"{text}\": {e}"
))
})
}
pub async fn get_uhunt_recent_submissions(
user_id: u32,
count: u32,
) -> Result<Vec<UhuntSubmission>, ClientError> {
let url = format!("{UHUNT_BASE}/subs-user-last/{user_id}/{count}");
let resp = Client::new()
.get(&url)
.header("User-Agent", USER_AGENT)
.send()
.await?;
let submissions: Vec<UhuntSubmission> = resp.json().await?;
Ok(submissions)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn submission_result_fields() {
let result = SubmissionResult {
run_id: "29964933".to_string(),
initial_verdict: "In judge queue".to_string(),
};
assert_eq!(result.run_id, "29964933");
assert_eq!(result.initial_verdict, "In judge queue");
}
#[test]
fn client_error_display() {
let err = ClientError::LoginFailed("bad creds".into());
assert!(err.to_string().contains("bad creds"));
let err = ClientError::PollingTimeout(120);
assert!(err.to_string().contains("120"));
let err = ClientError::ParseError("malformed HTML".into());
assert!(err.to_string().contains("malformed HTML"));
}
#[test]
fn uhunt_submission_deserialization() {
let json = r#"{
"run": 12345678,
"probid": 100,
"ver": 90,
"pte": 140,
"lang": "C++11",
"out": "2024-11-15 07:56:25"
}"#;
let sub: UhuntSubmission = serde_json::from_str(json).unwrap();
assert_eq!(sub.run_id, 12345678);
assert_eq!(sub.problem_id, 100);
assert_eq!(sub.verdict_code, 90);
assert_eq!(sub.runtime, 140);
assert_eq!(sub.language, "C++11");
assert_eq!(sub.date, "2024-11-15 07:56:25");
}
#[test]
fn client_error_from_reqwest() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let result = rt.block_on(async {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(100))
.build()
.unwrap();
client.get("http://127.0.0.1:1/does-not-exist").send().await
});
let reqwest_err = result.unwrap_err();
let client_err: ClientError = reqwest_err.into();
assert!(matches!(client_err, ClientError::NetworkError(_)));
}
}