oj-submit 0.1.0

A fast, simple CLI for submitting solutions to the UVA Online Judge (onlinejudge.org)
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};

// ---------------------------------------------------------------------------
// CLI definition
// ---------------------------------------------------------------------------

/// oj-submit — Submit solutions to the UVA Online Judge (onlinejudge.org).
///
/// Usage:
///   oj-submit <FILE>                    Submit a solution (auto-detect language & problem)
///   oj-submit login                     Store credentials
///   oj-submit logout                    Remove credentials
///   oj-submit status                    Check recent submissions
#[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 {
    /// Source file to submit. When provided, triggers submission mode.
    /// Problem ID is auto-extracted from the filename (e.g. 100.cpp).
    file: Option<String>,

    /// Override the problem ID (extracted from filename by default).
    #[arg(short, long = "problem")]
    problem_id: Option<String>,

    /// Enable verbose output showing HTTP requests and parse steps.
    #[arg(short, long)]
    verbose: bool,

    /// Submit but don't wait for the verdict.
    #[arg(short = 'n', long = "no-wait")]
    no_wait: bool,

    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(Subcommand)]
enum Commands {
    /// Store your onlinejudge.org credentials in the OS keyring.
    Login,

    /// Remove stored credentials from the OS keyring.
    Logout,

    /// Show recent submissions for the logged-in user.
    Status {
        /// Enable verbose output.
        #[arg(short, long)]
        verbose: bool,
    },
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();

    // Handle subcommands first
    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 a file was provided, submit it
    if let Some(file) = cli.file {
        return cmd_submit(&file, cli.problem_id.as_deref(), cli.verbose, cli.no_wait).await;
    }

    // No file and no subcommand — show help
    use clap::CommandFactory;
    Cli::command().print_help()?;
    Ok(())
}

// ---------------------------------------------------------------------------
// Subcommands
// ---------------------------------------------------------------------------

/// Prompt the user for their username and password, then save to the keyring.
async fn cmd_login() -> Result<()> {
    // --- Username ---
    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.");
    }

    // --- Password (hidden input) ---
    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(())
}

/// Delete stored credentials from the keyring.
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(())
}

/// Submit a source file to the UVA Online Judge.
async fn cmd_submit(
    file_path: &str,
    problem_id_override: Option<&str>,
    verbose: bool,
    no_wait: bool,
) -> Result<()> {
    // ── 1. Detect language ───────────────────────────────────────────
    let language = detect_language(file_path).map_err(|e| anyhow::anyhow!(e))?;

    if verbose {
        eprintln!(
            "[verbose] Detected language: {} (code={})",
            language.name, language.code
        );
    }

    // ── 2. Resolve problem ID ────────────────────────────────────────
    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}");
    }

    // ── 3. Read source code ──────────────────────────────────────────
    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()
        );
    }

    // ── 4. Load credentials ──────────────────────────────────────────
    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
        );
    }

    // ── 5. Build client and submit ───────────────────────────────────
    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
    );

    // ── 6. Wait for verdict (unless --no-wait) ───────────────────────
    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(())
}

/// Fetch and display recent submissions for the logged-in user.
async fn cmd_status(verbose: bool) -> Result<()> {
    // ── 1. Load credentials ──────────────────────────────────────────
    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
        );
    }

    // ── 2. Look up uHunt user ID ─────────────────────────────────────
    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}");
    }

    // ── 3. Fetch recent submissions ──────────────────────────────────
    let submissions = UvaClient::get_uhunt_recent_submissions(user_id, 10)
        .await
        .context("Failed to fetch recent submissions")?;

    // ── 4. Display table ─────────────────────────────────────────────
    println!(
        "{} Recent submissions for {}:",
        "📋".blue(),
        credentials.username.cyan()
    );
    println!();
    println!("{}", format_status_table(&submissions));

    Ok(())
}