mod client;
mod config;
mod language;
mod parser;
mod poll;
mod submit;
use std::io::{self, Write};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use colored::Colorize;
use client::UvaClient;
use config::Credentials;
use language::{detect_language, extract_problem_id};
use poll::{colorize_final_verdict, format_status_table, wait_for_verdict};
use submit::{SubmissionRequest, submit_solution};
#[derive(Parser)]
#[command(
name = "oj-submit",
version,
about = "Submit solutions to the UVA Online Judge",
long_about = "oj-submit is a fast, simple CLI for submitting solutions\nto onlinejudge.org. It stores your credentials securely,\nauto-detects language from file extensions, and polls\nfor verdicts with a progress indicator."
)]
struct Cli {
file: Option<String>,
#[arg(short, long = "problem")]
problem_id: Option<String>,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long = "no-wait")]
no_wait: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Login,
Logout,
Status {
#[arg(short, long)]
verbose: bool,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
if let Some(cmd) = cli.command {
return match cmd {
Commands::Login => cmd_login().await,
Commands::Logout => cmd_logout().await,
Commands::Status { verbose } => cmd_status(verbose).await,
};
}
if let Some(file) = cli.file {
return cmd_submit(&file, cli.problem_id.as_deref(), cli.verbose, cli.no_wait).await;
}
use clap::CommandFactory;
Cli::command().print_help()?;
Ok(())
}
async fn cmd_login() -> Result<()> {
print!("Username: ");
io::stdout().flush()?;
let mut username = String::new();
io::stdin()
.read_line(&mut username)
.context("Failed to read username")?;
let username = username.trim().to_string();
if username.is_empty() {
anyhow::bail!("Username must not be empty.");
}
print!("Password: ");
io::stdout().flush()?;
let password = rpassword::read_password().context("Failed to read password")?;
if password.is_empty() {
anyhow::bail!("Password must not be empty.");
}
Credentials::save(&username, &password).context("Failed to save credentials to keyring")?;
println!(
"{} Credentials saved for user `{}`.",
"✓".green().bold(),
username.cyan()
);
Ok(())
}
async fn cmd_logout() -> Result<()> {
if !Credentials::exists() {
println!("{} No credentials stored. Nothing to do.", "!".yellow());
return Ok(());
}
Credentials::delete().context("Failed to delete credentials from keyring")?;
println!(
"{} Credentials deleted. You can run `{}` to log in again.",
"✓".green().bold(),
"oj-submit login".cyan()
);
Ok(())
}
async fn cmd_submit(
file_path: &str,
problem_id_override: Option<&str>,
verbose: bool,
no_wait: bool,
) -> Result<()> {
let language = detect_language(file_path).map_err(|e| anyhow::anyhow!(e))?;
if verbose {
eprintln!(
"[verbose] Detected language: {} (code={})",
language.name, language.code
);
}
let problem_id = if let Some(id) = problem_id_override {
id.to_string()
} else if let Some(id) = extract_problem_id(file_path) {
id.to_string()
} else {
anyhow::bail!(
"Could not extract a problem ID from `{file_path}`.\n\
Either:\n • Rename the file to include the problem number (e.g. `100.cpp`)\n \
• Or pass the problem ID explicitly: `oj-submit {file_path} --problem 100`"
);
};
if verbose {
eprintln!("[verbose] Problem ID: {problem_id}");
}
let source_code = std::fs::read_to_string(file_path)
.with_context(|| format!("Failed to read source file `{file_path}`"))?;
if source_code.trim().is_empty() {
anyhow::bail!("Source file `{file_path}` is empty.");
}
if verbose {
eprintln!(
"[verbose] Source code: {} bytes ({} lines)",
source_code.len(),
source_code.lines().count()
);
}
let credentials = Credentials::load().with_context(|| {
format!(
"No credentials found.\n\
Run `{}` to store your onlinejudge.org username and password.",
"oj-submit login".cyan()
)
})?;
if verbose {
eprintln!(
"[verbose] Credentials loaded for user `{}`",
credentials.username
);
}
let mut client = UvaClient::new(verbose).context("Failed to create HTTP client")?;
println!(
"{} Logging in as {}...",
"→".blue(),
credentials.username.cyan()
);
client.login(&credentials).await.context("Login failed")?;
let request = SubmissionRequest {
file_path: file_path.to_string(),
problem_id,
language,
source_code,
};
let outcome = submit_solution(&mut client, &request)
.await
.context("Submission failed")?;
println!(
"{} Submitted! Run ID: {} (initial verdict: {})",
"✓".green().bold(),
outcome.run_id.cyan(),
outcome.initial_verdict
);
if no_wait {
println!(
"{} Skipping verdict poll (use without `--no-wait` to wait).",
"ℹ".blue()
);
return Ok(());
}
println!(
"{} Polling for verdict (Ctrl+C to cancel)...",
"⏳".yellow()
);
let verdict = wait_for_verdict(&client, 0)
.await
.context("Failed to retrieve verdict")?;
println!();
println!("{}", colorize_final_verdict(&verdict));
Ok(())
}
async fn cmd_status(verbose: bool) -> Result<()> {
let credentials = Credentials::load().with_context(|| {
format!(
"No credentials found.\n\
Run `{}` to store your onlinejudge.org username and password.",
"oj-submit login".cyan()
)
})?;
if verbose {
eprintln!(
"[verbose] Fetching submissions for user `{}`",
credentials.username
);
}
let user_id = UvaClient::get_uhunt_user_id(&credentials.username)
.await
.with_context(|| {
format!(
"Failed to look up user ID for `{}`. Check your username.",
credentials.username
)
})?;
if user_id == 0 {
anyhow::bail!(
"User `{}` not found on UVA Online Judge.\n\
Make sure your username is correct.",
credentials.username
);
}
if verbose {
eprintln!("[verbose] uHunt user ID: {user_id}");
}
let submissions = UvaClient::get_uhunt_recent_submissions(user_id, 10)
.await
.context("Failed to fetch recent submissions")?;
println!(
"{} Recent submissions for {}:",
"📋".blue(),
credentials.username.cyan()
);
println!();
println!("{}", format_status_table(&submissions));
Ok(())
}