oj-submit 0.1.0

A fast, simple CLI for submitting solutions to the UVA Online Judge (onlinejudge.org)
//! Submission orchestration with rate limiting.
//!
//! Provides [`submit_solution`] which wraps [`UvaClient::submit`] with a
//! global rate limiter enforcing a minimum 5-second gap between submissions.

use std::sync::Mutex;
use std::time::{Duration, Instant};

use crate::client::{ClientError, UvaClient};
use crate::language::LanguageInfo;

// ---------------------------------------------------------------------------
// Rate limiter
// ---------------------------------------------------------------------------

/// Minimum interval between consecutive submissions.
const MIN_SUBMISSION_INTERVAL: Duration = Duration::from_secs(5);

/// Global rate limiter state. The `Mutex` is held only briefly (to read/write
/// the `Instant`), so contention is negligible.
static LAST_SUBMISSION: Mutex<Option<Instant>> = Mutex::new(None);

/// Block until the rate limiter allows the next submission.
///
/// Sleeps for the remaining time if the minimum interval has not elapsed
/// since the last submission. This is a best-effort guard — it does not
/// prevent concurrent callers from racing, but a single-threaded CLI
/// caller will never hit that path.
async fn rate_limit_wait() {
    let wait_duration = {
        let lock = LAST_SUBMISSION.lock().expect("rate limiter poisoned");
        match *lock {
            Some(last) => {
                let elapsed = last.elapsed();
                if elapsed < MIN_SUBMISSION_INTERVAL {
                    MIN_SUBMISSION_INTERVAL - elapsed
                } else {
                    Duration::ZERO
                }
            }
            None => Duration::ZERO,
        }
    };

    if !wait_duration.is_zero() {
        tokio::time::sleep(wait_duration).await;
    }

    // Record the submission time *before* returning so the actual network
    // round-trip doesn't eat into the rate limit window.
    let mut lock = LAST_SUBMISSION.lock().expect("rate limiter poisoned");
    *lock = Some(Instant::now());
}

/// Reset the rate limiter (useful in tests).
#[cfg(test)]
pub(crate) fn reset_rate_limiter() {
    let mut lock = LAST_SUBMISSION.lock().expect("rate limiter poisoned");
    *lock = None;
}

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/// A request to submit a single solution.
#[derive(Debug, Clone, PartialEq)]
pub struct SubmissionRequest {
    /// The UVA problem number as a string (e.g. `"100"`).
    pub problem_id: String,
    /// Detected language information.
    pub language: LanguageInfo,
    /// Full source code to submit.
    pub source_code: String,
    /// Path to the submitted file (for display purposes).
    pub file_path: String,
}

/// The outcome of a successful submission POST.
#[derive(Debug, Clone)]
pub struct SubmissionOutcome {
    /// The run (submission) ID assigned by the judge.
    pub run_id: String,
    /// The initial verdict text (typically `"In judge queue"`).
    pub initial_verdict: String,
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Submit a solution to the UVA Online Judge with rate limiting.
///
/// Enforces a minimum 5-second gap between consecutive submissions using a
/// global static timer. This prevents accidental flooding of the judge when
/// submitting multiple solutions in quick succession.
///
/// # Errors
///
/// Propagates any [`ClientError`] from the underlying HTTP request or
/// response parsing.
pub async fn submit_solution(
    client: &mut UvaClient,
    request: &SubmissionRequest,
) -> Result<SubmissionOutcome, ClientError> {
    rate_limit_wait().await;

    let result = client
        .submit(
            &request.problem_id,
            request.language.code,
            &request.source_code,
        )
        .await?;

    Ok(SubmissionOutcome {
        run_id: result.run_id,
        initial_verdict: result.initial_verdict,
    })
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex as StdMutex;

    /// Serialize tests that touch the global rate limiter to avoid
    /// interference when `cargo test` runs them in parallel.
    static TEST_MUTEX: StdMutex<()> = StdMutex::new(());

    #[test]
    fn submission_request_fields() {
        let req = SubmissionRequest {
            problem_id: "100".into(),
            language: LanguageInfo {
                code: 5,
                name: "C++11",
            },
            source_code: "#include <iostream>".into(),
            file_path: "100.cpp".into(),
        };

        assert_eq!(req.problem_id, "100");
        assert_eq!(req.language.code, 5);
        assert_eq!(req.source_code, "#include <iostream>");
        assert_eq!(req.file_path, "100.cpp");
    }

    #[test]
    fn submission_outcome_fields() {
        let outcome = SubmissionOutcome {
            run_id: "29964933".into(),
            initial_verdict: "In judge queue".into(),
        };

        assert_eq!(outcome.run_id, "29964933");
        assert_eq!(outcome.initial_verdict, "In judge queue");
    }

    #[test]
    fn rate_limit_reset_clears_timer() {
        let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
        // Set the timer to now
        {
            let mut lock = LAST_SUBMISSION.lock().unwrap();
            *lock = Some(Instant::now());
        }

        // Reset should clear it
        reset_rate_limiter();

        let lock = LAST_SUBMISSION.lock().unwrap();
        assert!(lock.is_none(), "rate limiter should be cleared after reset");
    }

    #[test]
    fn rate_limit_records_submission_time() {
        let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
        reset_rate_limiter();

        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();
        rt.block_on(rate_limit_wait());

        let lock = LAST_SUBMISSION.lock().unwrap();
        let last = lock.expect("timer should be set after rate_limit_wait");
        // Should have been set very recently
        assert!(last.elapsed() < Duration::from_secs(1));
    }

    #[test]
    fn rate_limit_second_call_waits() {
        let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
        reset_rate_limiter();

        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();

        // First call — sets the timer
        rt.block_on(async { rate_limit_wait().await });

        // Second call should block for ~5 seconds
        let elapsed = rt.block_on(async {
            let start = Instant::now();
            rate_limit_wait().await;
            start.elapsed()
        });

        // Should have waited close to 5 seconds (within a generous margin)
        assert!(elapsed >= Duration::from_secs(4));
    }

    #[test]
    fn rate_limit_after_interval_allows_immediate() {
        let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
        reset_rate_limiter();

        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();

        // Simulate a submission that happened 10 seconds ago
        {
            let mut lock = LAST_SUBMISSION.lock().unwrap();
            *lock = Some(Instant::now() - Duration::from_secs(10));
        }

        let elapsed = rt.block_on(async {
            let start = Instant::now();
            rate_limit_wait().await;
            start.elapsed()
        });

        // Should have returned quickly
        assert!(
            elapsed < Duration::from_secs(2),
            "expected immediate return but waited {elapsed:?}"
        );
    }

    #[test]
    fn submission_request_clone() {
        let req = SubmissionRequest {
            problem_id: "500".into(),
            language: LanguageInfo {
                code: 6,
                name: "Python 3",
            },
            source_code: "print('hello')".into(),
            file_path: "problems/500.py".into(),
        };

        let cloned = req.clone();
        assert_eq!(req, cloned);
    }

    #[test]
    fn submission_outcome_clone() {
        let outcome = SubmissionOutcome {
            run_id: "12345".into(),
            initial_verdict: "Accepted".into(),
        };

        let cloned = outcome.clone();
        assert_eq!(outcome.run_id, cloned.run_id);
        assert_eq!(outcome.initial_verdict, cloned.initial_verdict);
    }
}