diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 60ad366..4d46d1f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,8 +22,22 @@ jobs: with: fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v5 + name: Set up Zig + uses: mlugg/setup-zig@v1 + - + name: Set up Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + - + name: Install zigbuild + run: cargo install cargo-zigbuild + - + name: Install openssl + run: sudo apt-get update && sudo apt-get install -y libssl-dev pkg-config + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 diff --git a/.goreleaser.yml b/.goreleaser.yml index 4147330..40da2d5 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,12 +1,6 @@ version: 2 builds: - - env: - - CGO_ENABLED=0 - goos: - - linux - - windows - - darwin - goarch: - - amd64 - - arm64 + - id: rust + builder: rust + dir: rust binary: popcorn-cli diff --git a/README.md b/README.md index 5361cda..073a9f0 100644 --- a/README.md +++ b/README.md @@ -12,27 +12,25 @@ A command-line interface tool for submitting solutions to the [Popcorn Discord B ### Option 2: Building from source -If you want to build from source, you'll need: -1. Install [Go](https://golang.org/doc/install) -2. Run: -```bash -GOPROXY=direct go install github.com/s1ro1/popcorn-cli@latest -``` -3. Make sure the `popcorn-cli` binary is in your PATH +This app is written in Rust, so you can just install it via `cargo install` ## Usage -Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API +Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API. You can get this from the [GPU Mode Discord server](https://discord.gg/gpumode). + +Then, you need to be registered to use this app. You can register by running: `popcorn-cli register [discord|github]`. We strongly reccomend using your Discord account to register, as this will match your submissions to your Discord account. +Once you're registered, there is a file created in your `$HOME` called `.popcorn-cli.yaml` that contains your registration token. This token is sent with each request. + +If you want to re-register (you can do this any number of times), you can run `popcorn-cli reregister [discord|github]`. + +After this, you can submit a solution by running: -Then, simply run the binary: ```bash -popcorn-cli +popcorn-cli submit ``` The interactive CLI will guide you through the process of: 1. Selecting a leaderboard -2. Choosing a runner -3. Selecting GPU options -4. Setting submission mode -5. Submitting your work - +2. Selecting GPU options +3. Setting submission mode +4. Submitting your work diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..8a6bea3 --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,18 @@ +# Generated by Cargo +/target/ + +# Backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# IDEs and editors +/.idea/ +/.vscode/ +*.swp +*.swo \ No newline at end of file diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..d7f3774 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "popcorn-cli" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clap = { version = "4.5.3", features = ["derive"] } +reqwest = { version = "0.11", features = ["json", "multipart"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +ratatui = "0.26.1" +crossterm = "0.27.0" +anyhow = "1.0" +ctrlc = "3.4.6" +dirs = "5.0" +serde_yaml = "0.9" +webbrowser = "0.8" +base64-url = "3.0.0" +urlencoding = "2.1.3" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..81e3225 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,3 @@ +# Popcorn CLI (Rust Version) + +Run `./build.sh` and then ` POPCORN_API_URL="http://127.0.0.1:8000" target/release/popcorn-cli ../../discord/discord-cluster-manager/reference-kernels/problems/pmpp/grayscale_py/submission.py` diff --git a/rust/build.sh b/rust/build.sh new file mode 100755 index 0000000..b088e49 --- /dev/null +++ b/rust/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +echo "Building Popcorn CLI (Rust version)..." +cargo build --release + +echo "Build complete! Binary is available at: target/release/popcorn-cli" +echo "Run with: ./target/release/popcorn-cli " \ No newline at end of file diff --git a/rust/src/cmd/auth.rs b/rust/src/cmd/auth.rs new file mode 100644 index 0000000..3498353 --- /dev/null +++ b/rust/src/cmd/auth.rs @@ -0,0 +1,145 @@ +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 urlencoding; +use webbrowser; + +use crate::service; // Assuming service::create_client is needed + +// Configuration structure +#[derive(Serialize, Deserialize, Debug, Default)] +struct Config { + cli_id: Option, +} + +// Helper function to get the config file path +fn get_config_path() -> Result { + dirs::home_dir() + .map(|mut path| { + path.push(".popcorn.yaml"); + path + }) + .ok_or_else(|| anyhow!("Could not find home directory")) +} + +// Helper function to load config +fn load_config() -> Result { + let path = get_config_path()?; + if !path.exists() { + return Ok(Config::default()); + } + let file = File::open(path)?; + serde_yaml::from_reader(file).map_err(|e| anyhow!("Failed to parse config file: {}", e)) +} + +// Helper function to save config +fn save_config(config: &Config) -> Result<()> { + let path = get_config_path()?; + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) // Overwrite existing file + .open(path)?; + serde_yaml::to_writer(file, config).map_err(|e| anyhow!("Failed to write config file: {}", e)) +} + +// Structure for the API response +#[derive(Deserialize)] +struct AuthInitResponse { + state: String, // This is the cli_id +} + +// Function to handle the login logic +pub async fn run_auth(reset: bool, auth_provider: &str) -> Result<()> { + println!("Attempting authentication via {}...", auth_provider); + + let popcorn_api_url = std::env::var("POPCORN_API_URL") + .map_err(|_| anyhow!("POPCORN_API_URL environment variable not set"))?; + + let client = service::create_client(None)?; + + let init_url = format!("{}/auth/init?provider={}", popcorn_api_url, auth_provider); + println!("Requesting CLI ID from {}", init_url); + + let init_resp = client.get(&init_url).send().await?; + + let status = init_resp.status(); + + if !status.is_success() { + let error_text = init_resp.text().await?; + return Err(anyhow!( + "Failed to initialize auth ({}): {}", + status, + error_text + )); + } + + let auth_init_data: AuthInitResponse = init_resp.json().await?; + let cli_id = auth_init_data.state; + println!("Received CLI ID: {}", cli_id); + + let state_json = serde_json::json!({ + "cli_id": cli_id, + "is_reset": reset + }) + .to_string(); + let state_b64 = base64_url::encode(&state_json); + + let auth_url = match auth_provider { + "discord" => { + let base_auth_url = "https://discord.com/oauth2/authorize?client_id=1357446383497511096&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fauth%2Fcli%2Fdiscord&scope=identify"; + format!("{}&state={}", base_auth_url, state_b64) + } + "github" => { + let client_id = "Ov23lieFd2onYk4OnKIR"; + let redirect_uri = "http://localhost:8000/auth/cli/github"; + // URL encode the redirect URI + let encoded_redirect_uri = urlencoding::encode(redirect_uri); + format!( + "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&state={}", + client_id, encoded_redirect_uri, state_b64 + ) + } + _ => { + return Err(anyhow!( + "Unsupported authentication provider: {}", + auth_provider + )) + } + }; + + println!( + "\n>>> Please open the following URL in your browser to log in via {}:", + auth_provider + ); + println!("{}", auth_url); + println!("\nWaiting for you to complete the authentication in your browser..."); + println!( + "After successful authentication with {}, the CLI ID will be saved.", + auth_provider + ); + + if webbrowser::open(&auth_url).is_err() { + println!( + "Could not automatically open the browser. Please copy the URL above and paste it manually." + ); + } + + // Save the cli_id to config file optimistically + let mut config = load_config().unwrap_or_default(); + config.cli_id = Some(cli_id.clone()); + save_config(&config)?; + + println!( + "\nSuccessfully initiated authentication. Your CLI ID ({}) has been saved to {}. To use the CLI on different machines, you can copy the config file.", + cli_id, + get_config_path()?.display() + ); + println!("You can now use other commands that require authentication."); + + Ok(()) +} diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs new file mode 100644 index 0000000..4fde558 --- /dev/null +++ b/rust/src/cmd/mod.rs @@ -0,0 +1,109 @@ +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; + +mod auth; +mod submit; + +#[derive(Serialize, Deserialize, Debug, Default)] +struct Config { + cli_id: Option, +} + +fn get_config_path() -> Result { + dirs::home_dir() + .map(|mut path| { + path.push(".popcorn.yaml"); + path + }) + .ok_or_else(|| anyhow!("Could not find home directory")) +} + +fn load_config() -> Result { + let path = get_config_path()?; + if !path.exists() { + return Err(anyhow!( + "Config file not found at {}. Please run `popcorn register` first.", + path.display() + )); + } + let file = File::open(path)?; + serde_yaml::from_reader(file).map_err(|e| anyhow!("Failed to parse config file: {}", e)) +} + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +pub struct Cli { + #[command(subcommand)] + command: Option, + + /// Optional: Path to the solution file + filepath: Option, +} + +#[derive(Subcommand, Debug)] +enum AuthProvider { + Discord, + Github, +} + +#[derive(Subcommand, Debug)] +enum Commands { + Reregister { + #[command(subcommand)] + provider: AuthProvider, + }, + Register { + #[command(subcommand)] + provider: AuthProvider, + }, + Submit { + filepath: Option, + }, +} + +pub async fn execute(cli: Cli) -> Result<()> { + match cli.command { + Some(Commands::Reregister { provider }) => { + let provider_str = match provider { + AuthProvider::Discord => "discord", + AuthProvider::Github => "github", + }; + auth::run_auth(true, provider_str).await + } + Some(Commands::Register { provider }) => { + let provider_str = match provider { + AuthProvider::Discord => "discord", + AuthProvider::Github => "github", + }; + auth::run_auth(false, provider_str).await + } + Some(Commands::Submit { filepath }) => { + let config = load_config()?; + let cli_id = config.cli_id.ok_or_else(|| { + anyhow!( + "cli_id not found in config file ({}). Please run `popcorn register` first.", + get_config_path() + .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) + ) + })?; + let file_to_submit = filepath.or(cli.filepath); + submit::run_submit_tui(file_to_submit, cli_id).await + } + None => { + let config = load_config()?; + let cli_id = config.cli_id.ok_or_else(|| { + anyhow!( + "cli_id not found in config file ({}). Please run `popcorn register` first.", + get_config_path() + .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) + ) + })?; + submit::run_submit_tui(cli.filepath, cli_id).await + } + } +} diff --git a/rust/src/cmd/submit.rs b/rust/src/cmd/submit.rs new file mode 100644 index 0000000..8a776e2 --- /dev/null +++ b/rust/src/cmd/submit.rs @@ -0,0 +1,702 @@ +use std::fs::File; +use std::io::{self, Read}; +use std::path::Path; + +use anyhow::{anyhow, Result}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen}; +use ratatui::prelude::*; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use tokio::task::JoinHandle; + +use crate::models::{GpuItem, LeaderboardItem, ModelState, SubmissionModeItem}; +use crate::service; +use crate::utils; + +pub struct App { + pub filepath: String, + pub cli_id: String, + pub leaderboards: Vec, + pub leaderboards_state: ListState, + pub selected_leaderboard: Option, + pub gpus: Vec, + pub gpus_state: ListState, + pub selected_gpu: Option, + pub submission_modes: Vec, + pub submission_modes_state: ListState, + pub selected_submission_mode: Option, + pub modal_state: ModelState, + pub final_status: Option, + pub loading_message: Option, + pub should_quit: bool, + pub submission_task: Option>>, + pub leaderboards_task: Option, anyhow::Error>>>, + pub gpus_task: Option, anyhow::Error>>>, +} + +impl App { + pub fn new>(filepath: P, cli_id: String) -> Self { + let submission_modes = vec![ + SubmissionModeItem::new( + "Test".to_string(), + "Test the solution and give detailed results about passed/failed tests.".to_string(), + "test".to_string(), + ), + SubmissionModeItem::new( + "Benchmark".to_string(), + "Benchmark the solution, this also runs the tests and afterwards runs the benchmark, returning detailed timing results".to_string(), + "benchmark".to_string(), + ), + SubmissionModeItem::new( + "Leaderboard".to_string(), + "Submit to the leaderboard, this first runs public tests and then private tests. If both pass, the submission is evaluated and submit to the leaderboard.".to_string(), + "leaderboard".to_string(), + ), + SubmissionModeItem::new( + "Private".to_string(), + "TODO".to_string(), + "private".to_string(), + ), + SubmissionModeItem::new( + "Script".to_string(), + "TODO".to_string(), + "script".to_string(), + ), + SubmissionModeItem::new( + "Profile".to_string(), + "TODO".to_string(), + "profile".to_string(), + ), + ]; + + let mut app = Self { + filepath: filepath.as_ref().to_string_lossy().to_string(), + cli_id, + leaderboards: Vec::new(), + leaderboards_state: ListState::default(), + selected_leaderboard: None, + gpus: Vec::new(), + gpus_state: ListState::default(), + selected_gpu: None, + submission_modes, + submission_modes_state: ListState::default(), + selected_submission_mode: None, + modal_state: ModelState::LeaderboardSelection, + final_status: None, + loading_message: None, + should_quit: false, + submission_task: None, + leaderboards_task: None, + gpus_task: None, + }; + + // Initialize list states + app.leaderboards_state.select(Some(0)); + app.gpus_state.select(Some(0)); + app.submission_modes_state.select(Some(0)); + + app + } + + pub fn initialize_with_directives(&mut self, popcorn_directives: utils::PopcornDirectives) { + if !popcorn_directives.leaderboard_name.is_empty() { + self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); + + if !popcorn_directives.gpus.is_empty() { + self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); + self.modal_state = ModelState::SubmissionModeSelection; + } else { + self.modal_state = ModelState::GpuSelection; + } + } else if !popcorn_directives.gpus.is_empty() { + self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); + if !popcorn_directives.leaderboard_name.is_empty() { + self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); + self.modal_state = ModelState::SubmissionModeSelection; + } else { + self.modal_state = ModelState::LeaderboardSelection; + } + } else { + self.modal_state = ModelState::LeaderboardSelection; + } + } + + pub fn handle_key_event(&mut self, key: KeyEvent) -> Result { + // Allow quitting anytime, even while loading + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.should_quit = true; + return Ok(true); + } + + // Ignore other keys while loading + if self.loading_message.is_some() { + return Ok(false); + } + + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + return Ok(true); + } + KeyCode::Enter => match self.modal_state { + ModelState::LeaderboardSelection => { + if let Some(idx) = self.leaderboards_state.selected() { + if idx < self.leaderboards.len() { + self.selected_leaderboard = + Some(self.leaderboards[idx].title_text.clone()); + + if self.selected_gpu.is_none() { + self.modal_state = ModelState::GpuSelection; + // Spawn GPU loading task + if let Err(e) = self.spawn_load_gpus() { + self.set_error_and_quit(format!( + "Error starting GPU fetch: {}", + e + )); + } + } else { + self.modal_state = ModelState::SubmissionModeSelection; + } + return Ok(true); + } + } + } + ModelState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx < self.gpus.len() { + self.selected_gpu = Some(self.gpus[idx].title_text.clone()); + self.modal_state = ModelState::SubmissionModeSelection; + return Ok(true); + } + } + } + ModelState::SubmissionModeSelection => { + if let Some(idx) = self.submission_modes_state.selected() { + if idx < self.submission_modes.len() { + self.selected_submission_mode = + Some(self.submission_modes[idx].value.clone()); + self.modal_state = ModelState::WaitingForResult; // State for logic, UI uses loading msg + // Spawn the submission task + if let Err(e) = self.spawn_submit_solution() { + self.set_error_and_quit(format!( + "Error starting submission: {}", + e + )); + } + return Ok(true); + } + } + } + _ => {} // WaitingForResult state doesn't handle Enter + }, + KeyCode::Up => { + self.move_selection_up(); + return Ok(true); + } + KeyCode::Down => { + self.move_selection_down(); + return Ok(true); + } + _ => {} // Ignore other keys + } + + Ok(false) + } + + // Helper to reduce repetition + fn set_error_and_quit(&mut self, error_message: String) { + self.final_status = Some(error_message); + self.should_quit = true; + self.loading_message = None; // Clear loading on error + } + + fn move_selection_up(&mut self) { + match self.modal_state { + ModelState::LeaderboardSelection => { + if let Some(idx) = self.leaderboards_state.selected() { + if idx > 0 { + self.leaderboards_state.select(Some(idx - 1)); + } + } + } + ModelState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx > 0 { + self.gpus_state.select(Some(idx - 1)); + } + } + } + ModelState::SubmissionModeSelection => { + if let Some(idx) = self.submission_modes_state.selected() { + if idx > 0 { + self.submission_modes_state.select(Some(idx - 1)); + } + } + } + _ => {} + } + } + + fn move_selection_down(&mut self) { + match self.modal_state { + ModelState::LeaderboardSelection => { + if let Some(idx) = self.leaderboards_state.selected() { + if idx < self.leaderboards.len().saturating_sub(1) { + self.leaderboards_state.select(Some(idx + 1)); + } + } + } + ModelState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx < self.gpus.len().saturating_sub(1) { + self.gpus_state.select(Some(idx + 1)); + } + } + } + ModelState::SubmissionModeSelection => { + if let Some(idx) = self.submission_modes_state.selected() { + if idx < self.submission_modes.len().saturating_sub(1) { + self.submission_modes_state.select(Some(idx + 1)); + } + } + } + _ => {} + } + } + + pub fn spawn_load_leaderboards(&mut self) -> Result<()> { + let client = service::create_client(Some(self.cli_id.clone()))?; + self.leaderboards_task = Some(tokio::spawn(async move { + service::fetch_leaderboards(&client).await + })); + self.loading_message = Some("Loading leaderboards...".to_string()); + Ok(()) + } + + pub fn spawn_load_gpus(&mut self) -> Result<()> { + let client = service::create_client(Some(self.cli_id.clone()))?; + let leaderboard_name = self + .selected_leaderboard + .clone() + .ok_or_else(|| anyhow!("Leaderboard not selected"))?; + self.gpus_task = Some(tokio::spawn(async move { + service::fetch_gpus(&client, &leaderboard_name).await + })); + self.loading_message = Some("Loading GPUs...".to_string()); + Ok(()) + } + + pub fn spawn_submit_solution(&mut self) -> Result<()> { + let client = service::create_client(Some(self.cli_id.clone()))?; + let filepath = self.filepath.clone(); + let leaderboard = self + .selected_leaderboard + .clone() + .ok_or_else(|| anyhow!("Leaderboard not selected"))?; + let gpu = self + .selected_gpu + .clone() + .ok_or_else(|| anyhow!("GPU not selected"))?; + let mode = self + .selected_submission_mode + .clone() + .ok_or_else(|| anyhow!("Submission mode not selected"))?; + + // Read file content + let mut file = File::open(&filepath)?; + let mut file_content = String::new(); + file.read_to_string(&mut file_content)?; + + self.submission_task = Some(tokio::spawn(async move { + service::submit_solution(&client, &filepath, &file_content, &leaderboard, &gpu, &mode) + .await + })); + self.loading_message = Some("Submitting solution...".to_string()); + Ok(()) + } + + pub async fn check_leaderboard_task(&mut self) { + if let Some(handle) = &mut self.leaderboards_task { + if handle.is_finished() { + let task = self.leaderboards_task.take().unwrap(); + match task.await { + Ok(Ok(leaderboards)) => { + self.leaderboards = leaderboards; + // If a leaderboard was pre-selected (e.g., from directives), try to find and select it + if let Some(selected_name) = &self.selected_leaderboard { + if let Some(index) = self + .leaderboards + .iter() + .position(|lb| &lb.title_text == selected_name) + { + self.leaderboards_state.select(Some(index)); + // If GPU was also pre-selected, move to submission mode selection + // Otherwise, spawn GPU loading task + if self.selected_gpu.is_some() { + self.modal_state = ModelState::SubmissionModeSelection; + } else { + self.modal_state = ModelState::GpuSelection; + if let Err(e) = self.spawn_load_gpus() { + self.set_error_and_quit(format!( + "Error starting GPU fetch: {}", + e + )); + return; // Exit early on error + } + } + } else { + // Pre-selected leaderboard not found, reset selection and state + self.selected_leaderboard = None; + self.leaderboards_state.select(Some(0)); // Select first available + self.modal_state = ModelState::LeaderboardSelection; + // Stay here + } + } else { + self.leaderboards_state.select(Some(0)); // Select first if no pre-selection + } + + self.loading_message = None; + } + Ok(Err(e)) => { + self.set_error_and_quit(format!("Error fetching leaderboards: {}", e)) + } + Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), + } + } + } + } + + pub async fn check_gpu_task(&mut self) { + if let Some(handle) = &mut self.gpus_task { + if handle.is_finished() { + let task = self.gpus_task.take().unwrap(); + match task.await { + Ok(Ok(gpus)) => { + self.gpus = gpus; + // If a GPU was pre-selected, try to find and select it + if let Some(selected_name) = &self.selected_gpu { + if let Some(index) = self + .gpus + .iter() + .position(|gpu| &gpu.title_text == selected_name) + { + self.gpus_state.select(Some(index)); + self.modal_state = ModelState::SubmissionModeSelection; + // Move to next step + } else { + // Pre-selected GPU not found, reset selection + self.selected_gpu = None; + self.gpus_state.select(Some(0)); // Select first available + self.modal_state = ModelState::GpuSelection; // Stay here + } + } else { + self.gpus_state.select(Some(0)); // Select first if no pre-selection + } + + self.loading_message = None; + } + Ok(Err(e)) => self.set_error_and_quit(format!("Error fetching GPUs: {}", e)), + Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), + } + } + } + } + + pub async fn check_submission_task(&mut self) { + if let Some(handle) = &mut self.submission_task { + if handle.is_finished() { + let task = self.submission_task.take().unwrap(); + match task.await { + Ok(Ok(status)) => { + self.final_status = Some(status); + self.should_quit = true; // Quit after showing final status + self.loading_message = None; + } + Ok(Err(e)) => self.set_error_and_quit(format!("Submission error: {}", e)), + Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), + } + } + } + } +} + +pub fn ui(app: &App, frame: &mut Frame) { + let main_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0)].as_ref()) + .split(frame.size()); + + // Determine the area available for the list *before* the match statement + let list_area = main_layout[0]; + // Calculate usable width for text wrapping (subtract borders, padding, highlight symbol) + let available_width = list_area.width.saturating_sub(4) as usize; + + if let Some(ref msg) = app.loading_message { + let loading_paragraph = Paragraph::new(msg.clone()) + .block(Block::default().title("Loading").borders(Borders::ALL)) + .alignment(Alignment::Center); + + let area = centered_rect(60, 20, frame.size()); + frame.render_widget(loading_paragraph, area); + return; // Don't render anything else while loading + } + + let list_block = Block::default().borders(Borders::ALL); + let list_style = Style::default().fg(Color::White); + + match app.modal_state { + ModelState::LeaderboardSelection => { + let items: Vec = app + .leaderboards + .iter() + .map(|lb| { + let title_line = Line::from(Span::styled( + lb.title_text.clone(), + Style::default().fg(Color::White).bold(), + )); + // Create lines for the description, splitting by newline + let mut lines = vec![title_line]; + for desc_part in lb.task_description.split('\n') { + lines.push(Line::from(Span::styled( + desc_part.to_string(), + Style::default().fg(Color::Gray).dim(), + ))); + } + ListItem::new(lines) // Use the combined vector of lines + }) + .collect(); + let list = List::new(items) + .block(list_block.title("Select Leaderboard")) + .style(list_style) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, main_layout[0], &mut app.leaderboards_state.clone()); + } + ModelState::GpuSelection => { + let items: Vec = app + .gpus + .iter() + .map(|gpu| { + // GPUs still only have a title line + let line = Line::from(vec![Span::styled( + gpu.title_text.clone(), + Style::default().fg(Color::White).bold(), + )]); + ListItem::new(line) // Keep as single line + }) + .collect(); + let list = List::new(items) + .block(list_block.title(format!( + "Select GPU for '{}'", + app.selected_leaderboard.as_deref().unwrap_or("N/A") + ))) + .style(list_style) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, main_layout[0], &mut app.gpus_state.clone()); + } + ModelState::SubmissionModeSelection => { + let items: Vec = app + .submission_modes + .iter() + .map(|mode| { + let title_line = Line::from(Span::styled( + mode.title_text.clone(), + Style::default().fg(Color::White).bold(), + )); + + let mut lines = vec![title_line]; + let description_text = &mode.description_text; + + // Manual wrapping logic + if available_width > 0 { + let mut current_line = String::with_capacity(available_width); + for word in description_text.split_whitespace() { + // Check if the word itself is too long + if word.len() > available_width { + // If a line is currently being built, push it first + if !current_line.is_empty() { + lines.push(Line::from(Span::styled( + current_line.clone(), + Style::default().fg(Color::Gray).dim(), + ))); + current_line.clear(); + } + // Push the long word on its own line + lines.push(Line::from(Span::styled( + word.to_string(), + Style::default().fg(Color::Gray).dim(), + ))); + } else if current_line.is_empty() { + // Start a new line + current_line.push_str(word); + } else if current_line.len() + word.len() + 1 <= available_width { + // Add word to current line + current_line.push(' '); + current_line.push_str(word); + } else { + // Word doesn't fit, push the completed line + lines.push(Line::from(Span::styled( + current_line.clone(), + Style::default().fg(Color::Gray).dim(), + ))); + // Start a new line with the current word + current_line.clear(); + current_line.push_str(word); + } + } + // Push the last remaining line if it's not empty + if !current_line.is_empty() { + lines.push(Line::from(Span::styled( + current_line, + Style::default().fg(Color::Gray).dim(), + ))); + } + } else { + // Fallback: push the original description as one line if width is zero + lines.push(Line::from(Span::styled( + description_text.clone(), + Style::default().fg(Color::Gray).dim(), + ))); + } + + ListItem::new(lines) + }) + .collect(); + let list = List::new(items) + .block(list_block.title(format!( + "Select Submission Mode for '{}' on '{}'", + app.selected_leaderboard.as_deref().unwrap_or("N/A"), + app.selected_gpu.as_deref().unwrap_or("N/A") + ))) + .style(list_style) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("> "); + frame.render_stateful_widget( + list, + main_layout[0], + &mut app.submission_modes_state.clone(), + ); + } + ModelState::WaitingForResult => { + // This state is handled by the loading message check at the beginning + } + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +pub async fn run_submit_tui(filepath: Option, cli_id: String) -> Result<()> { + let file_to_submit = match filepath { + Some(fp) => fp, + None => { + // Prompt user for filepath if not provided + println!("Please enter the path to your solution file:"); + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + input.trim().to_string() + } + }; + + if !Path::new(&file_to_submit).exists() { + return Err(anyhow!("File not found: {}", file_to_submit)); + } + + let (directives, has_multiple_gpus) = utils::get_popcorn_directives(&file_to_submit)?; + + if has_multiple_gpus { + return Err(anyhow!( + "Multiple GPUs are not supported yet. Please specify only one GPU." + )); + } + + let mut app = App::new(&file_to_submit, cli_id); + app.initialize_with_directives(directives); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + crossterm::execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + if app.modal_state == ModelState::LeaderboardSelection { + if let Err(e) = app.spawn_load_leaderboards() { + // Cleanup terminal before exiting on initial load error + disable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen + )?; + terminal.show_cursor()?; + return Err(anyhow!("Error starting leaderboard fetch: {}", e)); + } + } else if app.modal_state == ModelState::GpuSelection { + if let Err(e) = app.spawn_load_gpus() { + // Cleanup terminal before exiting on initial load error + disable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen + )?; + terminal.show_cursor()?; + return Err(anyhow!("Error starting GPU fetch: {}", e)); + } + } + + // Main application loop + while !app.should_quit { + terminal.draw(|f| ui(&app, f))?; + + // Check for finished async tasks without blocking drawing + app.check_leaderboard_task().await; + app.check_gpu_task().await; + app.check_submission_task().await; + + // Handle input events + if event::poll(std::time::Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + app.handle_key_event(key)?; + } + } + } + } + + // Restore terminal + disable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen + )?; + terminal.show_cursor()?; + + utils::display_ascii_art(); + + if let Some(status) = app.final_status { + println!("{}", status); + } else { + println!("Operation cancelled."); // Or some other default message if quit early + } + + Ok(()) +} diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..ccf767c --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,28 @@ +mod cmd; +mod models; +mod service; +mod utils; + +use crate::cmd::Cli; +use clap::Parser; +use std::env; +use std::process; + +#[tokio::main] +async fn main() { + // Parse command line arguments + let cli = Cli::parse(); + + // Popcorn API URL check (needed for most commands) + // We might want to move this check inside specific commands later if some don't need it. + if env::var("POPCORN_API_URL").is_err() { + eprintln!("POPCORN_API_URL is not set. Please set it to the URL of the Popcorn API."); + process::exit(1); + } + + // Execute the parsed command + if let Err(e) = cmd::execute(cli).await { + eprintln!("Application error: {}", e); + process::exit(1); + } +} diff --git a/rust/src/models/mod.rs b/rust/src/models/mod.rs new file mode 100644 index 0000000..257f751 --- /dev/null +++ b/rust/src/models/mod.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug)] +pub struct LeaderboardItem { + pub title_text: String, + pub task_description: String, +} + +impl LeaderboardItem { + pub fn new(title_text: String, task_description: String) -> Self { + Self { + title_text, + task_description, + } + } +} + +#[derive(Clone, Debug)] +pub struct GpuItem { + pub title_text: String, +} + +impl GpuItem { + pub fn new(title_text: String) -> Self { + Self { title_text } + } +} + +#[derive(Clone, Debug)] +pub struct SubmissionModeItem { + pub title_text: String, + pub description_text: String, + pub value: String, +} + +impl SubmissionModeItem { + pub fn new(title_text: String, description_text: String, value: String) -> Self { + Self { + title_text, + description_text, + value, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ModelState { + LeaderboardSelection, + GpuSelection, + SubmissionModeSelection, + WaitingForResult, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SubmissionResultMsg(pub String); diff --git a/rust/src/service/mod.rs b/rust/src/service/mod.rs new file mode 100644 index 0000000..6fd4d2c --- /dev/null +++ b/rust/src/service/mod.rs @@ -0,0 +1,146 @@ +use anyhow::{anyhow, Result}; +use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::multipart::{Form, Part}; +use reqwest::Client; +use serde_json::Value; +use std::env; +use std::path::Path; +use std::time::Duration; + +use crate::models::{GpuItem, LeaderboardItem}; + +// Helper function to create a reusable reqwest client +pub fn create_client(cli_id: Option) -> Result { + let mut default_headers = HeaderMap::new(); + + if let Some(id) = cli_id { + match HeaderValue::from_str(&id) { + Ok(val) => { + default_headers.insert("X-Popcorn-Cli-Id", val); + } + Err(_) => { + return Err(anyhow!("Invalid cli_id format for HTTP header")); + } + } + } + + Client::builder() + .timeout(Duration::from_secs(180)) + .default_headers(default_headers) + .build() + .map_err(|e| anyhow!("Failed to create HTTP client: {}", e)) +} + +pub async fn fetch_leaderboards(client: &Client) -> Result> { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .get(format!("{}/leaderboards", base_url)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + return Err(anyhow!("Server returned status {}: {}", status, error_text)); + } + + let leaderboards: Vec = resp.json().await?; + + let mut leaderboard_items = Vec::new(); + for lb in leaderboards { + let task = lb["task"] + .as_object() + .ok_or_else(|| anyhow!("Invalid JSON structure"))?; + let name = lb["name"] + .as_str() + .ok_or_else(|| anyhow!("Invalid JSON structure"))?; + let description = task["description"] + .as_str() + .ok_or_else(|| anyhow!("Invalid JSON structure"))?; + + leaderboard_items.push(LeaderboardItem::new( + name.to_string(), + description.to_string(), + )); + } + + Ok(leaderboard_items) +} + +pub async fn fetch_gpus(client: &Client, leaderboard: &str) -> Result> { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .get(format!("{}/gpus/{}", base_url, leaderboard)) + .timeout(Duration::from_secs(120)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + return Err(anyhow!("Server returned status {}: {}", status, error_text)); + } + + let gpus: Vec = resp.json().await?; + + let gpu_items = gpus.into_iter().map(|gpu| GpuItem::new(gpu)).collect(); + + Ok(gpu_items) +} + +pub async fn submit_solution>( + client: &Client, + filepath: P, + file_content: &str, + leaderboard: &str, + gpu: &str, + submission_mode: &str, +) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let filename = filepath + .as_ref() + .file_name() + .ok_or_else(|| anyhow!("Invalid filepath"))? + .to_string_lossy(); + + let part = Part::bytes(file_content.as_bytes().to_vec()).file_name(filename.to_string()); + + let form = Form::new().part("file", part); + + let url = format!( + "{}/{}/{}/{}", + base_url, + leaderboard.to_lowercase(), + gpu.to_lowercase(), + submission_mode.to_lowercase() + ); + + let resp = client + .post(&url) + .multipart(form) + .timeout(Duration::from_secs(180)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + return Err(anyhow!("Server returned status {}: {}", status, error_text)); + } + + let result: Value = resp.json().await?; + + let pretty_result = match result.get("results") { + Some(result_obj) => serde_json::to_string_pretty(result_obj)?, + None => return Err(anyhow!("Invalid response structure")), + }; + + Ok(pretty_result) +} diff --git a/rust/src/utils/mod.rs b/rust/src/utils/mod.rs new file mode 100644 index 0000000..cb29342 --- /dev/null +++ b/rust/src/utils/mod.rs @@ -0,0 +1,80 @@ +use std::fs; +use std::path::Path; +use anyhow::Result; + +pub struct PopcornDirectives { + pub leaderboard_name: String, + pub gpus: Vec, +} + +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; + + for line in content.lines() { + if !line.starts_with("//") && !line.starts_with("#") { + continue; + } + + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + + if parts[0] == "//!POPCORN" || parts[0] == "#!POPCORN" { + let arg = parts[1].to_lowercase(); + if arg == "gpu" || arg == "gpus" { + gpus = parts[2..].iter().map(|s| s.to_string()).collect(); + } else if arg == "leaderboard" && parts.len() > 2 { + leaderboard_name = parts[2].to_string(); + } + } + } + + if gpus.len() > 1 { + has_multiple_gpus = true; + gpus = vec![gpus[0].clone()]; + } + + Ok(( + PopcornDirectives { + leaderboard_name, + gpus, + }, + has_multiple_gpus + )) +} + +pub fn display_ascii_art() { + let art = r#" + _ __ _ ______ _ +| | / / | | | ___ \ | | +| |/ / ___ _ __ _ __ ___ | | | |_/ / ___ _| |_ +| \ / _ \ '__| '_ \ / _ \| | | ___ \ / _ \| | __| +| |\ \ __/ | | | | | __/| | | |_/ /| (_) | | |_ +\_| \_/\___|_| |_| |_|\___|_/ \____/ \___/|_|\__| + + POPCORN CLI - GPU MODE + + ┌───────────────────────────────────────┐ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ooOoo│ │ooOoo│ │ooOoo│ │▒ + │ │oOOOo│ │oOOOo│ │oOOOo│ │▒ + │ │ooOoo│ │ooOoo│ │ooOoo│ ┌────────┐ │▒ + │ └─────┘ └─────┘ └─────┘ │████████│ │▒ + │ │████████│ │▒ + │ ┌────────────────────────┐ │████████│ │▒ + │ │ │ │████████│ │▒ + │ │ POPCORN GPU COMPUTE │ └────────┘ │▒ + │ │ │ │▒ + │ └────────────────────────┘ │▒ + │ │▒ + └───────────────────────────────────────┘▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +"#; + println!("{}", art); +}