oj-submit 0.1.0

A fast, simple CLI for submitting solutions to the UVA Online Judge (onlinejudge.org)
use std::path::Path;

use regex::Regex;

/// Supported programming language with its OnlineJudge language code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LanguageInfo {
    pub code: u8,
    pub name: &'static str,
}

/// Maps a file extension to the corresponding language info.
///
/// Supported extensions:
/// - `.c`          → ANSI C
/// - `.java`       → Java
/// - `.cpp`, `.cc`, `.cxx`, `.C` → C++11
/// - `.pas`        → Pascal
/// - `.py`, `.py3` → Python 3
pub fn detect_language(file_path: &str) -> Result<LanguageInfo, String> {
    let path = Path::new(file_path);

    let ext = path
        .extension()
        .and_then(|e| e.to_str())
        .ok_or_else(|| format!("Cannot determine file extension for `{file_path}`"))?;

    // Normalise: accept capitalised extensions by lowering for comparison,
    // but keep the original case for the `.C` → C++11 lookup (handled separately).
    match ext {
        "c" => Ok(LanguageInfo {
            code: 1,
            name: "ANSI C",
        }),
        "java" => Ok(LanguageInfo {
            code: 2,
            name: "Java",
        }),
        "cpp" | "cc" | "cxx" => Ok(LanguageInfo {
            code: 5,
            name: "C++11",
        }),
        "pas" => Ok(LanguageInfo {
            code: 4,
            name: "Pascal",
        }),
        "py" | "py3" => Ok(LanguageInfo {
            code: 6,
            name: "Python 3",
        }),
        _ => {
            // Also check for `.C` (uppercase) which is common on case-sensitive systems.
            let raw_ext = path
                .file_name()
                .and_then(|n| n.to_str())
                .and_then(|n| n.rsplit('.').next());

            match raw_ext {
                Some("C") => Ok(LanguageInfo {
                    code: 5,
                    name: "C++11",
                }),
                _ => Err(format!(
                    "Unsupported file extension `.{ext}` for `{file_path}`"
                )),
            }
        }
    }
}

/// Extracts the numeric problem ID from a filename.
///
/// Looks for one or more contiguous digits in the filename (before the extension).
/// Strips common prefixes like `uva_`, `UVa`, `problem`, etc.
///
/// # Examples
///
/// ```ignore
/// assert_eq!(extract_problem_id("100.cpp"), Some(100));
/// assert_eq!(extract_problem_id("uva_1234.py"), Some(1234));
/// assert_eq!(extract_problem_id("UVa100.java"), Some(100));
/// assert_eq!(extract_problem_id("solution.cpp"), None);
/// ```
pub fn extract_problem_id(file_path: &str) -> Option<u32> {
    let path = Path::new(file_path);

    // Use the file stem (filename without extension) for matching.
    let stem = path.file_stem()?.to_str()?;

    let re = Regex::new(r"(\d+)").ok()?;
    let m = re.find(stem)?;
    m.as_str().parse::<u32>().ok()
}

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

    // ── detect_language ──────────────────────────────────────────────

    #[test]
    fn detect_c() {
        let lang = detect_language("main.c").unwrap();
        assert_eq!(lang.code, 1);
        assert_eq!(lang.name, "ANSI C");
    }

    #[test]
    fn detect_java() {
        let lang = detect_language("Solution.java").unwrap();
        assert_eq!(lang.code, 2);
        assert_eq!(lang.name, "Java");
    }

    #[test]
    fn detect_cpp() {
        let lang = detect_language("solution.cpp").unwrap();
        assert_eq!(lang.code, 5);
        assert_eq!(lang.name, "C++11");
    }

    #[test]
    fn detect_cc() {
        let lang = detect_language("main.cc").unwrap();
        assert_eq!(lang.code, 5);
        assert_eq!(lang.name, "C++11");
    }

    #[test]
    fn detect_cxx() {
        let lang = detect_language("algo.cxx").unwrap();
        assert_eq!(lang.code, 5);
        assert_eq!(lang.name, "C++11");
    }

    #[test]
    fn detect_uppercase_c() {
        let lang = detect_language("solution.C").unwrap();
        assert_eq!(lang.code, 5);
        assert_eq!(lang.name, "C++11");
    }

    #[test]
    fn detect_pascal() {
        let lang = detect_language("program.pas").unwrap();
        assert_eq!(lang.code, 4);
        assert_eq!(lang.name, "Pascal");
    }

    #[test]
    fn detect_python() {
        let lang = detect_language("solve.py").unwrap();
        assert_eq!(lang.code, 6);
        assert_eq!(lang.name, "Python 3");
    }

    #[test]
    fn detect_python3() {
        let lang = detect_language("solve.py3").unwrap();
        assert_eq!(lang.code, 6);
        assert_eq!(lang.name, "Python 3");
    }

    #[test]
    fn detect_unsupported_extension() {
        let err = detect_language("solution.rs").unwrap_err();
        assert!(err.contains(".rs"), "error should mention the extension");
    }

    #[test]
    fn detect_no_extension() {
        let err = detect_language("solution").unwrap_err();
        assert!(
            err.contains("Cannot determine"),
            "should report missing extension"
        );
    }

    #[test]
    fn detect_path_with_directories() {
        let lang = detect_language("src/problems/100.cpp").unwrap();
        assert_eq!(lang.code, 5);
    }

    // ── extract_problem_id ───────────────────────────────────────────

    #[test]
    fn extract_bare_number() {
        assert_eq!(extract_problem_id("100.cpp"), Some(100));
    }

    #[test]
    fn extract_uva_underscore_prefix() {
        assert_eq!(extract_problem_id("uva_1234.py"), Some(1234));
    }

    #[test]
    fn extract_uva_case_insensitive_prefix() {
        assert_eq!(extract_problem_id("UVa100.java"), Some(100));
    }

    #[test]
    fn extract_problem_word_prefix() {
        assert_eq!(extract_problem_id("problem7.cpp"), Some(7));
    }

    #[test]
    fn extract_no_number() {
        assert_eq!(extract_problem_id("solution.cpp"), None);
    }

    #[test]
    fn extract_only_digits_is_number() {
        assert_eq!(extract_problem_id("1000"), Some(1000));
    }

    #[test]
    fn extract_picks_first_number() {
        // "123_456.cpp" → first number is 123
        assert_eq!(extract_problem_id("123_456.cpp"), Some(123));
    }

    #[test]
    fn extract_with_path() {
        assert_eq!(extract_problem_id("problems/500.py"), Some(500));
    }

    #[test]
    fn extract_large_id() {
        assert_eq!(extract_problem_id("uva_10000.cpp"), Some(10000));
    }

    #[test]
    fn extract_empty_stem() {
        assert_eq!(extract_problem_id(".cpp"), None);
    }
}