diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e7b1a74 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,68 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Run Tests + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + rust: [stable] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + components: clippy, rustfmt + + - name: Set up cargo cache + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.os }}-test + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Build + run: cargo build --verbose + + - name: Run tests + run: cargo test --verbose + + coverage: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin + + - name: Run coverage + run: cargo tarpaulin --out Xml --verbose + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: cobertura.xml + fail_ci_if_error: false diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..42e9985 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +## Contributing + +### Development Setup + +```bash +# Build +cargo build + +# Run tests +cargo test + +# Format code (required before commits) +cargo fmt --all + +# Lint (must pass with no warnings) +cargo clippy --all-targets --all-features -- -D warnings +``` + +### CI Requirements + +All PRs must pass: +- `cargo fmt --all -- --check` - Code formatting +- `cargo clippy -- -D warnings` - No clippy warnings allowed +- `cargo test` - All tests pass +- Builds on Linux, macOS, and Windows + +## Architecture Overview + +Popcorn CLI is a command-line tool for submitting GPU kernel optimization solutions to [gpumode.com](https://gpumode.com) competitions. + +### Directory Structure + +``` +src/ +├── main.rs # Entry point, sets POPCORN_API_URL +├── cmd/ # Command handling +│ ├── mod.rs # CLI argument parsing (clap), config loading +│ ├── auth.rs # OAuth authentication (Discord/GitHub) +│ └── submit.rs # Submission logic, TUI app state machine +├── service/ +│ └── mod.rs # HTTP client, API calls, SSE streaming +├── models/ +│ └── mod.rs # Data structures (LeaderboardItem, GpuItem, AppState) +├── utils/ +│ └── mod.rs # Directive parsing, text wrapping, ASCII art +└── views/ + ├── loading_page.rs # TUI loading screen with progress bar + └── result_page.rs # TUI results display with scrolling +``` + +### Core Flow + +1. **Authentication** (`cmd/auth.rs`): User registers via Discord/GitHub OAuth. CLI ID stored in `~/.popcorn.yaml`. + +2. **Submission** (`cmd/submit.rs`): + - TUI mode: Interactive selection of leaderboard → GPU → mode + - Plain mode (`--no-tui`): Direct submission with CLI flags + - Reads solution file with optional `#!POPCORN` directives for defaults + +3. **API Communication** (`service/mod.rs`): + - Fetches available leaderboards and GPUs + - Submits solutions via multipart form POST + - Handles SSE (Server-Sent Events) streaming for real-time results + - Supports modes: `test`, `benchmark`, `leaderboard`, `profile` + +### File Directives + +Users can embed defaults in their solution files: + +```python +#!POPCORN leaderboard amd-fp8-mm +#!POPCORN gpu MI300 + +def solution(): + ... +``` + +Or C++ style: +```cpp +//!POPCORN leaderboard nvidia-matmul +//!POPCORN gpu H100 +``` + +### Key Dependencies + +- `clap` - CLI argument parsing +- `ratatui` + `crossterm` - Terminal UI +- `reqwest` - HTTP client with SSE streaming +- `tokio` - Async runtime +- `serde` / `serde_yaml` / `serde_json` - Serialization diff --git a/Cargo.lock b/Cargo.lock index 4dcb102..ffff48a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1178,6 +1178,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "tempfile", "tokio", "urlencoding", "webbrowser", diff --git a/Cargo.toml b/Cargo.toml index d212bd7..779842d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,4 +25,5 @@ urlencoding = "2.1.3" bytes = "1.10.1" futures-util = "0.3.31" - +[dev-dependencies] +tempfile = "3.10" diff --git a/src/cmd/admin.rs b/src/cmd/admin.rs index 338f373..f34ef0e 100644 --- a/src/cmd/admin.rs +++ b/src/cmd/admin.rs @@ -153,7 +153,10 @@ pub async fn handle_admin(action: AdminAction) -> Result<()> { if !skipped.is_empty() { println!("\nSkipped {} leaderboard(s):", skipped.len()); for item in skipped { - let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("unknown"); + let name = item + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown"); let reason = item .get("reason") .and_then(|r| r.as_str()) @@ -166,8 +169,14 @@ pub async fn handle_admin(action: AdminAction) -> Result<()> { if !errors.is_empty() { println!("\nErrors ({}):", errors.len()); for item in errors { - let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("unknown"); - let error = item.get("error").and_then(|e| e.as_str()).unwrap_or("unknown"); + let name = item + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown"); + let error = item + .get("error") + .and_then(|e| e.as_str()) + .unwrap_or("unknown"); println!(" ! {}: {}", name, error); } } diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs index 80cb079..483c3b1 100644 --- a/src/cmd/auth.rs +++ b/src/cmd/auth.rs @@ -1,11 +1,7 @@ use anyhow::{anyhow, Result}; -use base64_url; -use dirs; use serde::{Deserialize, Serialize}; -use serde_yaml; use std::fs::{File, OpenOptions}; use std::path::PathBuf; -use webbrowser; use crate::service; // Assuming service::create_client is needed diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 6da81bc..66a8909 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,8 +1,6 @@ use anyhow::{anyhow, Result}; use clap::{Parser, Subcommand}; -use dirs; use serde::{Deserialize, Serialize}; -use serde_yaml; use std::fs::File; use std::path::PathBuf; @@ -173,9 +171,7 @@ pub async fn execute(cli: Cli) -> Result<()> { .await } } - Some(Commands::Admin { action }) => { - admin::handle_admin(action).await - } + Some(Commands::Admin { action }) => admin::handle_admin(action).await, None => { // Check if any of the submission-related flags were used at the top level if cli.gpu.is_some() || cli.leaderboard.is_some() || cli.mode.is_some() { @@ -207,7 +203,9 @@ pub async fn execute(cli: Cli) -> Result<()> { ) .await } else { - Err(anyhow!("No command or submission file specified. Use --help for usage.")) + Err(anyhow!( + "No command or submission file specified. Use --help for usage." + )) } } } diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 325b5f1..a508b12 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -338,7 +338,6 @@ impl App { "Error starting GPU fetch: {}", e )); - return; } } } else { diff --git a/src/main.rs b/src/main.rs index 8154bc8..5b4e5a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,10 @@ use std::process; async fn main() { // Set the API URL FIRST - before anything else if env::var("POPCORN_API_URL").is_err() { - env::set_var("POPCORN_API_URL", "https://discord-cluster-manager-1f6c4782e60a.herokuapp.com"); + env::set_var( + "POPCORN_API_URL", + "https://discord-cluster-manager-1f6c4782e60a.herokuapp.com", + ); } // Parse command line arguments let cli = Cli::parse(); diff --git a/src/models/mod.rs b/src/models/mod.rs index 5a9c8a8..dd199e1 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,5 +1,3 @@ -use serde::{Deserialize, Serialize}; - #[derive(Clone, Debug)] pub struct LeaderboardItem { pub title_text: String, @@ -51,6 +49,3 @@ pub enum AppState { SubmissionModeSelection, WaitingForResult, } - -#[derive(Debug, Serialize, Deserialize)] -pub struct SubmissionResultMsg(pub String); diff --git a/src/service/mod.rs b/src/service/mod.rs index b5b6409..1eb135e 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -57,7 +57,8 @@ pub fn create_admin_client(admin_token: &str) -> Result { /// Start accepting jobs on the server pub async fn admin_start(client: &Client) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let resp = client .post(format!("{}/admin/start", base_url)) @@ -71,7 +72,8 @@ pub async fn admin_start(client: &Client) -> Result { /// Stop accepting jobs on the server pub async fn admin_stop(client: &Client) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let resp = client .post(format!("{}/admin/stop", base_url)) @@ -85,7 +87,8 @@ pub async fn admin_stop(client: &Client) -> Result { /// Get server stats pub async fn admin_stats(client: &Client, last_day_only: bool) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let url = if last_day_only { format!("{}/admin/stats?last_day_only=true", base_url) @@ -104,7 +107,8 @@ pub async fn admin_stats(client: &Client, last_day_only: bool) -> Result /// Get a submission by ID pub async fn admin_get_submission(client: &Client, submission_id: i64) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let resp = client .get(format!("{}/admin/submissions/{}", base_url, submission_id)) @@ -117,7 +121,8 @@ pub async fn admin_get_submission(client: &Client, submission_id: i64) -> Result /// Delete a submission by ID pub async fn admin_delete_submission(client: &Client, submission_id: i64) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let resp = client .delete(format!("{}/admin/submissions/{}", base_url, submission_id)) @@ -129,11 +134,9 @@ pub async fn admin_delete_submission(client: &Client, submission_id: i64) -> Res } /// Create a dev leaderboard from a problem directory -pub async fn admin_create_leaderboard( - client: &Client, - directory: &str, -) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; +pub async fn admin_create_leaderboard(client: &Client, directory: &str) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let payload = serde_json::json!({ "directory": directory @@ -150,11 +153,19 @@ pub async fn admin_create_leaderboard( } /// Delete a leaderboard -pub async fn admin_delete_leaderboard(client: &Client, leaderboard_name: &str, force: bool) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; +pub async fn admin_delete_leaderboard( + client: &Client, + leaderboard_name: &str, + force: bool, +) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let url = if force { - format!("{}/admin/leaderboards/{}?force=true", base_url, leaderboard_name) + format!( + "{}/admin/leaderboards/{}?force=true", + base_url, leaderboard_name + ) } else { format!("{}/admin/leaderboards/{}", base_url, leaderboard_name) }; @@ -176,7 +187,8 @@ pub async fn admin_update_problems( branch: &str, force: bool, ) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let mut payload = serde_json::json!({ "repository": repository, @@ -212,7 +224,9 @@ async fn handle_admin_response(resp: reqwest::Response) -> Result { detail.unwrap_or(error_text) )); } - resp.json().await.map_err(|e| anyhow!("Failed to parse response: {}", e)) + resp.json() + .await + .map_err(|e| anyhow!("Failed to parse response: {}", e)) } pub async fn fetch_leaderboards(client: &Client) -> Result> { @@ -235,7 +249,7 @@ pub async fn fetch_leaderboards(client: &Client) -> Result> let mut leaderboard_items = Vec::new(); for lb in leaderboards { - let task = lb["task"] + let _task = lb["task"] .as_object() .ok_or_else(|| anyhow!("Invalid JSON structure"))?; let name = lb["name"] @@ -272,7 +286,7 @@ pub async fn fetch_gpus(client: &Client, leaderboard: &str) -> Result = resp.json().await?; - let gpu_items = gpus.into_iter().map(|gpu| GpuItem::new(gpu)).collect(); + let gpu_items = gpus.into_iter().map(GpuItem::new).collect(); Ok(gpu_items) } @@ -332,7 +346,7 @@ pub async fn submit_solution>( .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) - .map_or(false, |s| s.starts_with("text/event-stream")) + .is_some_and(|s| s.starts_with("text/event-stream")) { let mut resp = resp; let mut buffer = String::new(); @@ -347,10 +361,10 @@ pub async fn submit_solution>( let mut data_json = None; for line in message_str.lines() { - if line.starts_with("event:") { - event_type = Some(line["event:".len()..].trim()); - } else if line.starts_with("data:") { - data_json = Some(line["data:".len()..].trim()); + if let Some(stripped) = line.strip_prefix("event:") { + event_type = Some(stripped.trim()); + } else if let Some(stripped) = line.strip_prefix("data:") { + data_json = Some(stripped.trim()); } } @@ -375,13 +389,17 @@ pub async fn submit_solution>( if let Some(ref cb) = on_log { // Handle "results" array - if let Some(results_array) = result_val.get("results").and_then(|v| v.as_array()) { + if let Some(results_array) = + result_val.get("results").and_then(|v| v.as_array()) + { let mode_key = submission_mode.to_lowercase(); // Special handling for profile mode if mode_key == "profile" { for (i, result_item) in results_array.iter().enumerate() { - if let Some(runs) = result_item.get("runs").and_then(|r| r.as_object()) { + if let Some(runs) = + result_item.get("runs").and_then(|r| r.as_object()) + { for (key, run_data) in runs.iter() { if key.starts_with("profile") { handle_profile_result(cb, run_data, i); @@ -392,19 +410,32 @@ pub async fn submit_solution>( } else { // Existing handling for non-profile modes for (i, result_item) in results_array.iter().enumerate() { - if let Some(run_obj) = result_item.get("runs") + if let Some(run_obj) = result_item + .get("runs") .and_then(|r| r.get(&mode_key)) .and_then(|t| t.get("run")) { - if let Some(stdout) = run_obj.get("stdout").and_then(|s| s.as_str()) { + if let Some(stdout) = + run_obj.get("stdout").and_then(|s| s.as_str()) + { if !stdout.is_empty() { - cb(format!("STDOUT (Run {}):\n{}", i + 1, stdout)); + cb(format!( + "STDOUT (Run {}):\n{}", + i + 1, + stdout + )); } } // Also check stderr - if let Some(stderr) = run_obj.get("stderr").and_then(|s| s.as_str()) { + if let Some(stderr) = + run_obj.get("stderr").and_then(|s| s.as_str()) + { if !stderr.is_empty() { - cb(format!("STDERR (Run {}):\n{}", i + 1, stderr)); + cb(format!( + "STDERR (Run {}):\n{}", + i + 1, + stderr + )); } } } @@ -412,7 +443,9 @@ pub async fn submit_solution>( } } else { // Fallback for single object or different structure - if let Some(stdout) = result_val.get("stdout").and_then(|s| s.as_str()) { + if let Some(stdout) = + result_val.get("stdout").and_then(|s| s.as_str()) + { if !stdout.is_empty() { cb(format!("STDOUT:\n{}", stdout)); } @@ -473,11 +506,7 @@ pub async fn submit_solution>( /// Handle profile mode results by decoding and displaying profile data, /// and saving trace files to the current directory. -fn handle_profile_result( - cb: &Box, - run_data: &Value, - run_idx: usize, -) { +fn handle_profile_result(cb: &(dyn Fn(String) + Send + Sync), run_data: &Value, run_idx: usize) { // 1. Get profiler type and display it if let Some(profile) = run_data.get("profile") { let profiler = profile @@ -558,3 +587,101 @@ fn handle_profile_result( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_client_without_cli_id() { + let client = create_client(None); + + assert!(client.is_ok()); + } + + #[test] + fn test_create_client_with_valid_cli_id() { + let client = create_client(Some("valid-cli-id-123".to_string())); + + assert!(client.is_ok()); + } + + #[test] + fn test_create_client_with_empty_cli_id() { + let client = create_client(Some("".to_string())); + + assert!(client.is_ok()); + } + + #[test] + fn test_create_client_with_invalid_header_chars() { + // Headers cannot contain newlines or certain control characters + let client = create_client(Some("invalid\nheader".to_string())); + + assert!(client.is_err()); + let err_msg = client.unwrap_err().to_string(); + assert!(err_msg.contains("Invalid cli_id format")); + } + + #[tokio::test] + async fn test_fetch_leaderboards_missing_env_var() { + // Temporarily unset the env var if set + let original = std::env::var("POPCORN_API_URL").ok(); + std::env::remove_var("POPCORN_API_URL"); + + let client = create_client(None).unwrap(); + let result = fetch_leaderboards(&client).await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("POPCORN_API_URL")); + + // Restore original value if it existed + if let Some(val) = original { + std::env::set_var("POPCORN_API_URL", val); + } + } + + #[tokio::test] + async fn test_fetch_gpus_missing_env_var() { + let original = std::env::var("POPCORN_API_URL").ok(); + std::env::remove_var("POPCORN_API_URL"); + + let client = create_client(None).unwrap(); + let result = fetch_gpus(&client, "test-leaderboard").await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("POPCORN_API_URL")); + + if let Some(val) = original { + std::env::set_var("POPCORN_API_URL", val); + } + } + + #[tokio::test] + async fn test_submit_solution_missing_env_var() { + let original = std::env::var("POPCORN_API_URL").ok(); + std::env::remove_var("POPCORN_API_URL"); + + let client = create_client(None).unwrap(); + let result = submit_solution( + &client, + "test.py", + "print('hello')", + "test-leaderboard", + "H100", + "test", + None, + ) + .await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("POPCORN_API_URL")); + + if let Some(val) = original { + std::env::set_var("POPCORN_API_URL", val); + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index d44304a..383a171 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,6 +1,6 @@ +use anyhow::Result; use std::fs; use std::path::Path; -use anyhow::Result; pub struct PopcornDirectives { pub leaderboard_name: String, @@ -9,7 +9,7 @@ pub struct PopcornDirectives { pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDirectives, bool)> { let content = fs::read_to_string(filepath)?; - + let mut gpus: Vec = Vec::new(); let mut leaderboard_name = String::new(); let mut has_multiple_gpus = false; @@ -44,7 +44,7 @@ pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDir leaderboard_name, gpus, }, - has_multiple_gpus + has_multiple_gpus, )) } @@ -74,7 +74,8 @@ pub fn get_ascii_art_frame(frame: u16) -> String { │ └──────────────────────────────────┘ │▒ └────────────────────────────────────────────┘▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"#.to_string(), + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"# + .to_string(), 1 => r#" ▗▖ ▗▖▗▄▄▄▖▗▄▄▖ ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▄▄▖ ▗▄▖ ▗▄▄▄▖ ▐▌▗▞▘▐▌ ▐▌ ▐▌▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌ █ @@ -98,7 +99,8 @@ pub fn get_ascii_art_frame(frame: u16) -> String { │ └──────────────────────────────────┘ │▒ └────────────────────────────────────────────┘▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"#.to_string(), + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"# + .to_string(), _ => r#" ▗▖ ▗▖▗▄▄▄▖▗▄▄▖ ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▄▄▖ ▗▄▖ ▗▄▄▄▖ ▐▌▗▞▘▐▌ ▐▌ ▐▌▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌ █ @@ -122,20 +124,16 @@ pub fn get_ascii_art_frame(frame: u16) -> String { │ └──────────────────────────────────┘ │▒ └────────────────────────────────────────────┘▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"#.to_string() + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"# + .to_string(), } } -pub fn get_ascii_art() -> String { - get_ascii_art_frame(0) -} - -pub fn display_ascii_art() { - let art = get_ascii_art(); - println!("{}", art); -} - -pub fn custom_wrap(initial_text: String, remaining_text: String, available_width: usize) -> Vec { +pub fn custom_wrap( + initial_text: String, + remaining_text: String, + available_width: usize, +) -> Vec { let mut lines = vec![initial_text]; let mut current_line = String::with_capacity(available_width); for word in remaining_text.split_whitespace() { @@ -147,7 +145,7 @@ pub fn custom_wrap(initial_text: String, remaining_text: String, available_width lines.push(word.to_string()); } else if current_line.is_empty() { current_line.push_str(word); - } else if current_line.len() + word.len() + 1 <= available_width { + } else if current_line.len() + word.len() < available_width { current_line.push(' '); current_line.push_str(word); } else { @@ -162,3 +160,158 @@ pub fn custom_wrap(initial_text: String, remaining_text: String, available_width } lines } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + // Tests for get_popcorn_directives + + #[test] + fn test_parse_python_style_directives() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "#!POPCORN leaderboard my-leaderboard").unwrap(); + writeln!(file, "#!POPCORN gpu H100").unwrap(); + writeln!(file).unwrap(); + writeln!(file, "def main():").unwrap(); + writeln!(file, " pass").unwrap(); + + let (directives, has_multiple_gpus) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, "my-leaderboard"); + assert_eq!(directives.gpus, vec!["H100"]); + assert!(!has_multiple_gpus); + } + + #[test] + fn test_parse_cpp_style_directives() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "//!POPCORN leaderboard amd-fp8-mm").unwrap(); + writeln!(file, "//!POPCORN gpu MI300").unwrap(); + writeln!(file).unwrap(); + writeln!(file, "int main() {{ return 0; }}").unwrap(); + + let (directives, has_multiple_gpus) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, "amd-fp8-mm"); + assert_eq!(directives.gpus, vec!["MI300"]); + assert!(!has_multiple_gpus); + } + + #[test] + fn test_parse_multiple_gpus_truncates_to_first() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "#!POPCORN leaderboard test").unwrap(); + writeln!(file, "#!POPCORN gpus H100 MI300 A100").unwrap(); + + let (directives, has_multiple_gpus) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, "test"); + assert_eq!(directives.gpus, vec!["H100"]); + assert!(has_multiple_gpus); + } + + #[test] + fn test_parse_gpu_vs_gpus_keyword() { + // Test "gpu" keyword + let mut file1 = NamedTempFile::new().unwrap(); + writeln!(file1, "#!POPCORN gpu A100").unwrap(); + let (directives1, _) = get_popcorn_directives(file1.path()).unwrap(); + assert_eq!(directives1.gpus, vec!["A100"]); + + // Test "gpus" keyword + let mut file2 = NamedTempFile::new().unwrap(); + writeln!(file2, "#!POPCORN gpus V100").unwrap(); + let (directives2, _) = get_popcorn_directives(file2.path()).unwrap(); + assert_eq!(directives2.gpus, vec!["V100"]); + } + + #[test] + fn test_parse_empty_file_returns_empty_directives() { + let file = NamedTempFile::new().unwrap(); + + let (directives, has_multiple_gpus) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, ""); + assert!(directives.gpus.is_empty()); + assert!(!has_multiple_gpus); + } + + #[test] + fn test_parse_ignores_non_directive_comments() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "# This is a regular comment").unwrap(); + writeln!(file, "// Another regular comment").unwrap(); + writeln!(file, "#!POPCORN leaderboard real-leaderboard").unwrap(); + writeln!(file, "# POPCORN gpu should-be-ignored").unwrap(); + + let (directives, _) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, "real-leaderboard"); + assert!(directives.gpus.is_empty()); + } + + #[test] + fn test_parse_case_insensitive_directive_args() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "#!POPCORN GPU H100").unwrap(); + writeln!(file, "#!POPCORN LEADERBOARD TEST").unwrap(); + + let (directives, _) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.gpus, vec!["H100"]); + assert_eq!(directives.leaderboard_name, "TEST"); + } + + #[test] + fn test_parse_nonexistent_file_returns_error() { + let result = get_popcorn_directives("/nonexistent/path/file.py"); + assert!(result.is_err()); + } + + // Tests for custom_wrap + + #[test] + fn test_wrap_simple_text() { + let result = custom_wrap("Header:".to_string(), "hello world".to_string(), 20); + + assert_eq!(result, vec!["Header:", "hello world"]); + } + + #[test] + fn test_wrap_breaks_at_width() { + let result = custom_wrap("".to_string(), "one two three four".to_string(), 10); + + assert_eq!(result, vec!["", "one two", "three four"]); + } + + #[test] + fn test_wrap_handles_long_words() { + let result = custom_wrap( + "".to_string(), + "short verylongwordthatexceedswidth short".to_string(), + 10, + ); + + assert_eq!( + result, + vec!["", "short", "verylongwordthatexceedswidth", "short"] + ); + } + + #[test] + fn test_wrap_empty_remaining_text() { + let result = custom_wrap("Header".to_string(), "".to_string(), 20); + + assert_eq!(result, vec!["Header"]); + } + + #[test] + fn test_wrap_preserves_initial_text() { + let result = custom_wrap("PREFIX: ".to_string(), "some text".to_string(), 20); + + assert_eq!(result[0], "PREFIX: "); + } +} diff --git a/src/views/loading_page.rs b/src/views/loading_page.rs index 9bf0d9d..02a809c 100644 --- a/src/views/loading_page.rs +++ b/src/views/loading_page.rs @@ -22,12 +22,12 @@ pub struct LoadingPage { fn get_gradient_color(progress: f64) -> Color { // Convert progress from 0-100 to 0-1 let t = progress / 100.0; - + // Start with red (255, 0, 0) and end with green (0, 255, 0) let r = ((1.0 - t) * 255.0) as u8; let g = (t * 255.0) as u8; let b = 0; - + Color::Rgb(r, g, b) } @@ -63,11 +63,11 @@ fn get_footer_text(state: &LoadingPageState) -> String { } if percentage > 75.0 { - return "Almost there!".to_string(); + "Almost there!".to_string() } else if percentage > 35.0 { - return "Crunching numbers...".to_string(); + "Crunching numbers...".to_string() } else { - return "This is taking a while, huh?".to_string(); + "This is taking a while, huh?".to_string() } } diff --git a/src/views/mod.rs b/src/views/mod.rs index a0e6eff..f396fbb 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -1,2 +1,2 @@ -pub mod result_page; pub mod loading_page; +pub mod result_page; diff --git a/src/views/result_page.rs b/src/views/result_page.rs index 14ad4ad..16f6f4d 100644 --- a/src/views/result_page.rs +++ b/src/views/result_page.rs @@ -33,9 +33,7 @@ impl ResultPage { let num_lines = result_text.lines().count(); - state.vertical_scroll_state = state - .vertical_scroll_state - .content_length(num_lines); + state.vertical_scroll_state = state.vertical_scroll_state.content_length(num_lines); state.horizontal_scroll_state = state.horizontal_scroll_state.content_length(max_width); state.animation_frame = 0; @@ -70,7 +68,7 @@ impl ResultPage { .result_text .clone() .block(right_block) - .scroll((state.vertical_scroll as u16, state.horizontal_scroll as u16)); + .scroll((state.vertical_scroll, state.horizontal_scroll)); result_text.render(right, buf); }