From 224a1de844173fdedc1479b4556dd5ed5f6e9f6d Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Wed, 9 Apr 2025 01:39:44 +0300 Subject: [PATCH 01/19] WIP rust port --- rust/.gitignore | 18 ++ rust/Cargo.toml | 16 ++ rust/README.md | 56 ++++++ rust/build.sh | 8 + rust/src/cmd/mod.rs | 408 ++++++++++++++++++++++++++++++++++++++++ rust/src/main.rs | 20 ++ rust/src/models/mod.rs | 75 ++++++++ rust/src/service/mod.rs | 115 +++++++++++ rust/src/utils/mod.rs | 80 ++++++++ 9 files changed, 796 insertions(+) create mode 100644 rust/.gitignore create mode 100644 rust/Cargo.toml create mode 100644 rust/README.md create mode 100755 rust/build.sh create mode 100644 rust/src/cmd/mod.rs create mode 100644 rust/src/main.rs create mode 100644 rust/src/models/mod.rs create mode 100644 rust/src/service/mod.rs create mode 100644 rust/src/utils/mod.rs 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..93e7771 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,16 @@ +[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 = "4.5.3" +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" \ No newline at end of file diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..0074ec4 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,56 @@ +# Popcorn CLI (Rust Version) + +A Rust implementation of the Popcorn CLI tool for interacting with the Popcorn GPU service. + +## Features + +- Submit code to Popcorn GPU service +- Select from available leaderboards +- Choose GPU configurations +- Multiple submission modes (test, benchmark, leaderboard) + +## Requirements + +- Rust 1.56.0 or later +- A valid Popcorn API URL + +## Setup + +```bash +# Clone the repository +git clone https://github.com/your-username/popcorn-cli +cd popcorn-cli/rust + +# Build the project +cargo build --release + +# Set the API URL +export POPCORN_API_URL=https://your-popcorn-api-url +``` + +## Usage + +```bash +# Run the CLI tool +cargo run --release -- /path/to/your/file.py +``` + +### Popcorn Directives + +You can add directives to your code files to pre-select leaderboards and GPUs: + +```python +#!POPCORN leaderboard matrix_multiplication +#!POPCORN gpu A100 +``` + +Or in other languages: + +```cpp +//!POPCORN leaderboard matrix_multiplication +//!POPCORN gpu A100 +``` + +## License + +[Same as original project license] \ No newline at end of file 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/mod.rs b/rust/src/cmd/mod.rs new file mode 100644 index 0000000..892dccb --- /dev/null +++ b/rust/src/cmd/mod.rs @@ -0,0 +1,408 @@ +use std::io::{self, Write}; +use std::fs; +use std::path::Path; + +use anyhow::{Result, anyhow}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use ratatui::style::{Color, Style}; + +use crate::models::{LeaderboardItem, GpuItem, SubmissionModeItem, ModelState}; +use crate::service; +use crate::utils; + +pub struct App { + pub filepath: 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 is_loading: bool, + pub should_quit: bool, +} + +impl App { + pub fn new>(filepath: P) -> 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(), + 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, + is_loading: false, + should_quit: false, + }; + + // 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; + } + } + } + + pub fn handle_key_event(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('q') | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + 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; + } 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; + self.is_loading = true; + return Ok(true); + } + } + } + _ => {} + } + } + KeyCode::Up => { + self.move_selection_up(); + return Ok(true); + } + KeyCode::Down => { + self.move_selection_down(); + return Ok(true); + } + _ => {} + } + + Ok(false) + } + + 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() - 1 { + self.leaderboards_state.select(Some(idx + 1)); + } + } + } + ModelState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx < self.gpus.len() - 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() - 1 { + self.submission_modes_state.select(Some(idx + 1)); + } + } + } + _ => {} + } + } + + pub async fn load_leaderboards(&mut self) -> Result<()> { + self.leaderboards = service::fetch_leaderboards().await?; + Ok(()) + } + + pub async fn load_gpus(&mut self) -> Result<()> { + if let Some(leaderboard) = &self.selected_leaderboard { + self.gpus = service::fetch_available_gpus(leaderboard).await?; + if self.gpus.is_empty() { + return Err(anyhow!("No GPUs available for this leaderboard.")); + } + } + Ok(()) + } + + pub async fn submit_solution(&mut self) -> Result<()> { + let leaderboard = self.selected_leaderboard.as_ref() + .ok_or_else(|| anyhow!("No leaderboard selected"))?; + + let gpu = self.selected_gpu.as_ref() + .ok_or_else(|| anyhow!("No GPU selected"))?; + + let submission_mode = self.selected_submission_mode.as_ref() + .ok_or_else(|| anyhow!("No submission mode selected"))?; + + let file_content = fs::read(&self.filepath)?; + + let result = service::submit_solution( + leaderboard, + gpu, + submission_mode, + &self.filepath, + &file_content + ).await?; + + self.final_status = Some(result); + self.should_quit = true; + + Ok(()) + } +} + +pub fn ui(app: &App, frame: &mut Frame) { + let chunks = Layout::default() + .margin(1) + .constraints([Constraint::Min(0)].as_ref()) + .split(frame.size()); + + match app.modal_state { + ModelState::LeaderboardSelection => { + let items: Vec = app.leaderboards + .iter() + .map(|item| { + ListItem::new(format!("{}\n{}", item.title(), item.description())) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().title("Leaderboards").borders(Borders::ALL)) + .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); + + frame.render_stateful_widget(list, chunks[0], &mut app.leaderboards_state.clone()); + } + ModelState::GpuSelection => { + let items: Vec = app.gpus + .iter() + .map(|item| { + ListItem::new(item.title()) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().title("GPUs").borders(Borders::ALL)) + .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); + + frame.render_stateful_widget(list, chunks[0], &mut app.gpus_state.clone()); + } + ModelState::SubmissionModeSelection => { + let items: Vec = app.submission_modes + .iter() + .map(|item| { + ListItem::new(format!("{}\n{}", item.title(), item.description())) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().title("Submission Mode").borders(Borders::ALL)) + .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); + + frame.render_stateful_widget(list, chunks[0], &mut app.submission_modes_state.clone()); + } + ModelState::WaitingForResult => { + let text = "Submitting solution... press Ctrl+C to quit"; + + let paragraph = Paragraph::new(text) + .block(Block::default().title("Status").borders(Borders::ALL)) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, chunks[0]); + } + } +} + +pub async fn execute() -> Result<()> { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + println!("Usage: popcorn "); + return Ok(()); + } + + let filepath = &args[1]; + let path = Path::new(filepath); + + if !path.exists() { + println!("File does not exist: {}", filepath); + return Ok(()); + } + + let (popcorn_directives, error) = utils::get_popcorn_directives(filepath)?; + + if let Some(error_msg) = error { + println!("Error: {}", error_msg); + print!("Continue? [y/N] "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if input.trim().to_lowercase() != "y" { + return Ok(()); + } + } + + // Initialize app + let mut app = App::new(filepath); + app.initialize_with_directives(popcorn_directives); + + // Initialize terminal + enable_raw_mode()?; + let stdout = io::stdout(); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + // Load initial data + if app.modal_state == ModelState::LeaderboardSelection { + app.load_leaderboards().await?; + } + + if app.modal_state == ModelState::GpuSelection { + app.load_gpus().await?; + } + + // Main event loop + loop { + terminal.draw(|frame| ui(&app, frame))?; + + if app.is_loading && app.modal_state == ModelState::WaitingForResult { + if let Err(e) = app.submit_solution().await { + app.final_status = Some(format!("Error: {}", e)); + app.should_quit = true; + } + app.is_loading = false; + } + + if app.should_quit { + break; + } + + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + app.handle_key_event(key)?; + } + } + } + + // Restore terminal + disable_raw_mode()?; + terminal.clear()?; + terminal.show_cursor()?; + + // Display results + utils::display_ascii_art(); + + if let Some(status) = app.final_status { + println!("\nResult:\n\n{}\n", status); + } + + Ok(()) +} \ No newline at end of file diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..7c3446e --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,20 @@ +mod cmd; +mod models; +mod service; +mod utils; + +use std::env; +use std::process; + +#[tokio::main] +async fn main() { + 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); + } + + if let Err(e) = cmd::execute().await { + eprintln!("Application error: {}", e); + process::exit(1); + } +} \ No newline at end of file diff --git a/rust/src/models/mod.rs b/rust/src/models/mod.rs new file mode 100644 index 0000000..b9ce252 --- /dev/null +++ b/rust/src/models/mod.rs @@ -0,0 +1,75 @@ +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, + } + } + + pub fn title(&self) -> &str { + &self.title_text + } + + pub fn description(&self) -> &str { + &self.task_description + } +} + +#[derive(Clone, Debug)] +pub struct GpuItem { + pub title_text: String, +} + +impl GpuItem { + pub fn new(title_text: String) -> Self { + Self { title_text } + } + + pub fn title(&self) -> &str { + &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, + } + } + + pub fn title(&self) -> &str { + &self.title_text + } + + pub fn description(&self) -> &str { + &self.description_text + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ModelState { + LeaderboardSelection, + GpuSelection, + SubmissionModeSelection, + WaitingForResult, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SubmissionResultMsg(pub String); \ No newline at end of file diff --git a/rust/src/service/mod.rs b/rust/src/service/mod.rs new file mode 100644 index 0000000..6c5c967 --- /dev/null +++ b/rust/src/service/mod.rs @@ -0,0 +1,115 @@ +use anyhow::{Result, anyhow}; +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::{LeaderboardItem, GpuItem}; + +pub async fn fetch_leaderboards() -> Result> { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let client = Client::new(); + let resp = client + .get(format!("{}/leaderboards", base_url)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + return Err(anyhow!("Failed to fetch leaderboards: {}", status)); + } + + 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_available_gpus(leaderboard: &str) -> Result> { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let client = Client::new(); + let resp = client + .get(format!("{}/gpus/{}", base_url, leaderboard)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + return Err(anyhow!("Failed to fetch GPUs: {}", status)); + } + + 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>( + leaderboard: &str, + gpu: &str, + submission_mode: &str, + filename: P, + file_content: &[u8], +) -> Result { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let filename = filename.as_ref() + .file_name() + .ok_or_else(|| anyhow!("Invalid filename"))? + .to_string_lossy(); + + let part = Part::bytes(file_content.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 client = Client::new(); + let resp = client + .post(&url) + .multipart(form) + .timeout(Duration::from_secs(60)) + .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("result") { + Some(result_obj) => serde_json::to_string_pretty(result_obj)?, + None => return Err(anyhow!("Invalid response structure")), + }; + + Ok(pretty_result) +} \ No newline at end of file diff --git a/rust/src/utils/mod.rs b/rust/src/utils/mod.rs new file mode 100644 index 0000000..47fcfca --- /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, Option)> { + let content = fs::read_to_string(filepath)?; + + let mut gpus: Vec = Vec::new(); + let mut leaderboard_name = String::new(); + let mut error = None; + + 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 { + error = Some(format!("multiple GPUs are not yet supported, continue with the first gpu? ({}) [y/N]", gpus[0])); + gpus = vec![gpus[0].clone()]; + } + + Ok(( + PopcornDirectives { + leaderboard_name, + gpus, + }, + error + )) +} + +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); +} \ No newline at end of file From 98fbbe866991fb3eea6c8730b4da978ca3195ad6 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Wed, 9 Apr 2025 01:43:45 +0300 Subject: [PATCH 02/19] Update README.md --- rust/README.md | 55 +------------------------------------------------- 1 file changed, 1 insertion(+), 54 deletions(-) diff --git a/rust/README.md b/rust/README.md index 0074ec4..81e3225 100644 --- a/rust/README.md +++ b/rust/README.md @@ -1,56 +1,3 @@ # Popcorn CLI (Rust Version) -A Rust implementation of the Popcorn CLI tool for interacting with the Popcorn GPU service. - -## Features - -- Submit code to Popcorn GPU service -- Select from available leaderboards -- Choose GPU configurations -- Multiple submission modes (test, benchmark, leaderboard) - -## Requirements - -- Rust 1.56.0 or later -- A valid Popcorn API URL - -## Setup - -```bash -# Clone the repository -git clone https://github.com/your-username/popcorn-cli -cd popcorn-cli/rust - -# Build the project -cargo build --release - -# Set the API URL -export POPCORN_API_URL=https://your-popcorn-api-url -``` - -## Usage - -```bash -# Run the CLI tool -cargo run --release -- /path/to/your/file.py -``` - -### Popcorn Directives - -You can add directives to your code files to pre-select leaderboards and GPUs: - -```python -#!POPCORN leaderboard matrix_multiplication -#!POPCORN gpu A100 -``` - -Or in other languages: - -```cpp -//!POPCORN leaderboard matrix_multiplication -//!POPCORN gpu A100 -``` - -## License - -[Same as original project license] \ No newline at end of file +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` From a04ac6149a5b1df93ae4961fdc9dd0b4a379caa6 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 12 Apr 2025 15:19:47 +0200 Subject: [PATCH 03/19] Feat: screen fix --- rust/src/cmd/mod.rs | 292 ++++++++++++++++++++++++---------------- rust/src/service/mod.rs | 5 +- rust/src/utils/mod.rs | 10 +- 3 files changed, 182 insertions(+), 125 deletions(-) diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs index 892dccb..02cd0cf 100644 --- a/rust/src/cmd/mod.rs +++ b/rust/src/cmd/mod.rs @@ -1,15 +1,15 @@ -use std::io::{self, Write}; use std::fs; +use std::io::{self, Write}; use std::path::Path; -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen}; use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; -use crate::models::{LeaderboardItem, GpuItem, SubmissionModeItem, ModelState}; +use crate::models::{GpuItem, LeaderboardItem, ModelState, SubmissionModeItem}; use crate::service; use crate::utils; @@ -64,7 +64,7 @@ impl App { "profile".to_string(), ), ]; - + let mut app = Self { filepath: filepath.as_ref().to_string_lossy().to_string(), leaderboards: Vec::new(), @@ -81,19 +81,19 @@ impl App { is_loading: false, should_quit: false, }; - + // 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; @@ -102,51 +102,53 @@ impl App { } } } - + pub fn handle_key_event(&mut self, key: KeyEvent) -> Result { match key.code { - KeyCode::Char('q') | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Char('q') | KeyCode::Char('c') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { 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; - } else { - self.modal_state = ModelState::SubmissionModeSelection; - } - 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; + } 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::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; - self.is_loading = true; - 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; + self.is_loading = true; + return Ok(true); } } - _ => {} } - } + _ => {} + }, KeyCode::Up => { self.move_selection_up(); return Ok(true); @@ -157,10 +159,10 @@ impl App { } _ => {} } - + Ok(false) } - + fn move_selection_up(&mut self) { match self.modal_state { ModelState::LeaderboardSelection => { @@ -187,7 +189,7 @@ impl App { _ => {} } } - + fn move_selection_down(&mut self) { match self.modal_state { ModelState::LeaderboardSelection => { @@ -214,45 +216,75 @@ impl App { _ => {} } } - + pub async fn load_leaderboards(&mut self) -> Result<()> { - self.leaderboards = service::fetch_leaderboards().await?; + match service::fetch_leaderboards().await { + Ok(leaderboards) => { + self.leaderboards = leaderboards; + } + Err(e) => { + return Err(e); + } + } Ok(()) } - pub async fn load_gpus(&mut self) -> Result<()> { if let Some(leaderboard) = &self.selected_leaderboard { - self.gpus = service::fetch_available_gpus(leaderboard).await?; - if self.gpus.is_empty() { - return Err(anyhow!("No GPUs available for this leaderboard.")); + match service::fetch_available_gpus(leaderboard).await { + Ok(gpus) => { + self.gpus = gpus; + if self.gpus.is_empty() { + return Err(anyhow!("No GPUs available for this leaderboard.")); + } + } + Err(e) => { + if e.to_string().contains("Invalid leaderboard name") { + return Err(anyhow!("Invalid leaderboard name: '{}'. Please check if the leaderboard exists.", leaderboard)); + } + return Err(e); + } } } Ok(()) } - + pub async fn submit_solution(&mut self) -> Result<()> { - let leaderboard = self.selected_leaderboard.as_ref() + let leaderboard = self + .selected_leaderboard + .as_ref() .ok_or_else(|| anyhow!("No leaderboard selected"))?; - - let gpu = self.selected_gpu.as_ref() + + let gpu = self + .selected_gpu + .as_ref() .ok_or_else(|| anyhow!("No GPU selected"))?; - - let submission_mode = self.selected_submission_mode.as_ref() + + let submission_mode = self + .selected_submission_mode + .as_ref() .ok_or_else(|| anyhow!("No submission mode selected"))?; - + let file_content = fs::read(&self.filepath)?; - + let result = service::submit_solution( leaderboard, gpu, submission_mode, &self.filepath, - &file_content - ).await?; - - self.final_status = Some(result); - self.should_quit = true; - + &file_content, + ) + .await; + + match result { + Ok(result) => { + self.final_status = Some(result); + self.should_quit = true; + } + Err(e) => { + return Err(e); + } + } + Ok(()) } } @@ -262,57 +294,58 @@ pub fn ui(app: &App, frame: &mut Frame) { .margin(1) .constraints([Constraint::Min(0)].as_ref()) .split(frame.size()); - + match app.modal_state { ModelState::LeaderboardSelection => { - let items: Vec = app.leaderboards + let items: Vec = app + .leaderboards .iter() - .map(|item| { - ListItem::new(format!("{}\n{}", item.title(), item.description())) - }) + .map(|item| ListItem::new(format!("{}\n{}", item.title(), item.description()))) .collect(); - + let list = List::new(items) .block(Block::default().title("Leaderboards").borders(Borders::ALL)) .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); - + frame.render_stateful_widget(list, chunks[0], &mut app.leaderboards_state.clone()); } ModelState::GpuSelection => { - let items: Vec = app.gpus + let items: Vec = app + .gpus .iter() - .map(|item| { - ListItem::new(item.title()) - }) + .map(|item| ListItem::new(item.title())) .collect(); - + let list = List::new(items) .block(Block::default().title("GPUs").borders(Borders::ALL)) .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); - + frame.render_stateful_widget(list, chunks[0], &mut app.gpus_state.clone()); } ModelState::SubmissionModeSelection => { - let items: Vec = app.submission_modes + let items: Vec = app + .submission_modes .iter() - .map(|item| { - ListItem::new(format!("{}\n{}", item.title(), item.description())) - }) + .map(|item| ListItem::new(format!("{}\n{}", item.title(), item.description()))) .collect(); - + let list = List::new(items) - .block(Block::default().title("Submission Mode").borders(Borders::ALL)) + .block( + Block::default() + .title("Submission Mode") + .borders(Borders::ALL), + ) .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); - + frame.render_stateful_widget(list, chunks[0], &mut app.submission_modes_state.clone()); } ModelState::WaitingForResult => { let text = "Submitting solution... press Ctrl+C to quit"; - + let paragraph = Paragraph::new(text) .block(Block::default().title("Status").borders(Borders::ALL)) .alignment(Alignment::Center); - + frame.render_widget(paragraph, chunks[0]); } } @@ -320,59 +353,71 @@ pub fn ui(app: &App, frame: &mut Frame) { pub async fn execute() -> Result<()> { let args: Vec = std::env::args().collect(); - + if args.len() < 2 { println!("Usage: popcorn "); return Ok(()); } - + let filepath = &args[1]; let path = Path::new(filepath); - + if !path.exists() { println!("File does not exist: {}", filepath); return Ok(()); } - - let (popcorn_directives, error) = utils::get_popcorn_directives(filepath)?; - - if let Some(error_msg) = error { - println!("Error: {}", error_msg); + + let (popcorn_directives, has_multiple_gpus) = utils::get_popcorn_directives(filepath)?; + + if has_multiple_gpus { + println!("Error: multiple GPUs are not yet supported, continue with the first gpu? ({}) [y/N]", popcorn_directives.gpus[0]); print!("Continue? [y/N] "); io::stdout().flush()?; - + let mut input = String::new(); io::stdin().read_line(&mut input)?; - + if input.trim().to_lowercase() != "y" { return Ok(()); } } - + // Initialize app let mut app = App::new(filepath); app.initialize_with_directives(popcorn_directives); - + // Initialize terminal enable_raw_mode()?; - let stdout = io::stdout(); - let backend = CrosstermBackend::new(stdout); + crossterm::execute!(io::stdout(), EnterAlternateScreen)?; + let backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(backend)?; terminal.clear()?; - + // Load initial data if app.modal_state == ModelState::LeaderboardSelection { - app.load_leaderboards().await?; + match app.load_leaderboards().await { + Ok(_) => {} + Err(e) => { + app.final_status = Some(format!("Error: {}", e)); + app.should_quit = true; + } + } } - + if app.modal_state == ModelState::GpuSelection { - app.load_gpus().await?; + match app.load_gpus().await { + Ok(_) => {} + Err(e) => { + app.final_status = Some(format!("Error: {}", e)); + app.should_quit = true; + } + } } - + // Main event loop loop { terminal.draw(|frame| ui(&app, frame))?; - + if app.is_loading && app.modal_state == ModelState::WaitingForResult { if let Err(e) = app.submit_solution().await { app.final_status = Some(format!("Error: {}", e)); @@ -380,29 +425,40 @@ pub async fn execute() -> Result<()> { } app.is_loading = false; } - + if app.should_quit { break; } - + if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { app.handle_key_event(key)?; } } } - - // Restore terminal - disable_raw_mode()?; + terminal.clear()?; - terminal.show_cursor()?; - - // Display results + disable_raw_mode()?; + + crossterm::execute!( + io::stdout(), + crossterm::terminal::LeaveAlternateScreen, + crossterm::cursor::Show + )?; + + std::thread::sleep(std::time::Duration::from_millis(100)); + + crossterm::execute!( + io::stdout(), + crossterm::terminal::Clear(crossterm::terminal::ClearType::All), + crossterm::cursor::MoveTo(0, 0) + )?; + utils::display_ascii_art(); - + if let Some(status) = app.final_status { println!("\nResult:\n\n{}\n", status); } - + Ok(()) -} \ No newline at end of file +} diff --git a/rust/src/service/mod.rs b/rust/src/service/mod.rs index 6c5c967..aea05bf 100644 --- a/rust/src/service/mod.rs +++ b/rust/src/service/mod.rs @@ -52,7 +52,8 @@ pub async fn fetch_available_gpus(leaderboard: &str) -> Result> { let status = resp.status(); if !status.is_success() { - return Err(anyhow!("Failed to fetch GPUs: {}", status)); + let error_text = resp.text().await?; + return Err(anyhow!("Server returned status {}: {}", status, error_text)); } let gpus: Vec = resp.json().await?; @@ -112,4 +113,4 @@ pub async fn submit_solution>( }; Ok(pretty_result) -} \ No newline at end of file +} diff --git a/rust/src/utils/mod.rs b/rust/src/utils/mod.rs index 47fcfca..cb29342 100644 --- a/rust/src/utils/mod.rs +++ b/rust/src/utils/mod.rs @@ -7,12 +7,12 @@ pub struct PopcornDirectives { pub gpus: Vec, } -pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDirectives, Option)> { +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 error = None; + let mut has_multiple_gpus = false; for line in content.lines() { if !line.starts_with("//") && !line.starts_with("#") { @@ -35,7 +35,7 @@ pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDir } if gpus.len() > 1 { - error = Some(format!("multiple GPUs are not yet supported, continue with the first gpu? ({}) [y/N]", gpus[0])); + has_multiple_gpus = true; gpus = vec![gpus[0].clone()]; } @@ -44,7 +44,7 @@ pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDir leaderboard_name, gpus, }, - error + has_multiple_gpus )) } @@ -77,4 +77,4 @@ pub fn display_ascii_art() { ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ "#; println!("{}", art); -} \ No newline at end of file +} From c8ffe91a3dd49a2a5fb6c52f07b84fa0bcb102e9 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 12 Apr 2025 17:56:59 +0200 Subject: [PATCH 04/19] Refactor: huge refactor --- rust/Cargo.toml | 3 +- rust/src/cmd/mod.rs | 354 ++++++++++++++++++++++++++++------------ rust/src/service/mod.rs | 83 +++++----- 3 files changed, 295 insertions(+), 145 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 93e7771..2718973 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -13,4 +13,5 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" ratatui = "0.26.1" crossterm = "0.27.0" -anyhow = "1.0" \ No newline at end of file +anyhow = "1.0" +ctrlc = "3.4.6" diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs index 02cd0cf..9ca80c9 100644 --- a/rust/src/cmd/mod.rs +++ b/rust/src/cmd/mod.rs @@ -8,6 +8,7 @@ use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScree use ratatui::prelude::*; use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use tokio::task::JoinHandle; use crate::models::{GpuItem, LeaderboardItem, ModelState, SubmissionModeItem}; use crate::service; @@ -26,8 +27,11 @@ pub struct App { pub selected_submission_mode: Option, pub modal_state: ModelState, pub final_status: Option, - pub is_loading: bool, + 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 { @@ -78,8 +82,11 @@ impl App { selected_submission_mode: None, modal_state: ModelState::LeaderboardSelection, final_status: None, - is_loading: false, + loading_message: None, should_quit: false, + submission_task: None, + leaderboards_task: None, + gpus_task: None, }; // Initialize list states @@ -100,14 +107,33 @@ impl App { } 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') | KeyCode::Char('c') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { + KeyCode::Char('q') => { self.should_quit = true; return Ok(true); } @@ -120,6 +146,13 @@ impl App { 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; } @@ -141,13 +174,19 @@ impl App { if idx < self.submission_modes.len() { self.selected_submission_mode = Some(self.submission_modes[idx].value.clone()); - self.modal_state = ModelState::WaitingForResult; - self.is_loading = true; + 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(); @@ -157,12 +196,19 @@ impl App { 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 => { @@ -194,21 +240,21 @@ impl App { match self.modal_state { ModelState::LeaderboardSelection => { if let Some(idx) = self.leaderboards_state.selected() { - if idx < self.leaderboards.len() - 1 { + 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() - 1 { + 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() - 1 { + if idx < self.submission_modes.len().saturating_sub(1) { self.submission_modes_state.select(Some(idx + 1)); } } @@ -217,75 +263,166 @@ impl App { } } - pub async fn load_leaderboards(&mut self) -> Result<()> { - match service::fetch_leaderboards().await { - Ok(leaderboards) => { - self.leaderboards = leaderboards; - } - Err(e) => { - return Err(e); - } + pub fn spawn_load_leaderboards(&mut self) -> Result<()> { + if self.leaderboards_task.is_some() { + return Ok(()); } + self.loading_message = Some("Fetching leaderboards...".to_string()); + let handle = tokio::spawn(async { service::fetch_leaderboards().await }); + self.leaderboards_task = Some(handle); Ok(()) } - pub async fn load_gpus(&mut self) -> Result<()> { - if let Some(leaderboard) = &self.selected_leaderboard { - match service::fetch_available_gpus(leaderboard).await { - Ok(gpus) => { - self.gpus = gpus; - if self.gpus.is_empty() { - return Err(anyhow!("No GPUs available for this leaderboard.")); - } - } - Err(e) => { - if e.to_string().contains("Invalid leaderboard name") { - return Err(anyhow!("Invalid leaderboard name: '{}'. Please check if the leaderboard exists.", leaderboard)); - } - return Err(e); - } - } + + pub fn spawn_load_gpus(&mut self) -> Result<()> { + if self.gpus_task.is_some() { + return Ok(()); } + let leaderboard = self + .selected_leaderboard + .clone() + .ok_or_else(|| anyhow!("Cannot load GPUs without a selected leaderboard."))?; + + self.loading_message = Some("Fetching GPUs...".to_string()); + + let handle = tokio::spawn(async move { service::fetch_available_gpus(&leaderboard).await }); + self.gpus_task = Some(handle); Ok(()) } - pub async fn submit_solution(&mut self) -> Result<()> { + pub fn spawn_submit_solution(&mut self) -> Result<()> { + if self.submission_task.is_some() { + return Ok(()); + } let leaderboard = self .selected_leaderboard - .as_ref() - .ok_or_else(|| anyhow!("No leaderboard selected"))?; + .clone() + .ok_or_else(|| anyhow!("Internal Error: No leaderboard selected"))?; let gpu = self .selected_gpu - .as_ref() - .ok_or_else(|| anyhow!("No GPU selected"))?; + .clone() + .ok_or_else(|| anyhow!("Internal Error: No GPU selected"))?; let submission_mode = self .selected_submission_mode - .as_ref() - .ok_or_else(|| anyhow!("No submission mode selected"))?; - - let file_content = fs::read(&self.filepath)?; - - let result = service::submit_solution( - leaderboard, - gpu, - submission_mode, - &self.filepath, - &file_content, - ) - .await; - - match result { - Ok(result) => { - self.final_status = Some(result); - self.should_quit = true; + .clone() + .ok_or_else(|| anyhow!("Internal Error: No submission mode selected"))?; + + let filepath = self.filepath.clone(); + + self.loading_message = Some("Submitting solution...".to_string()); + + let handle = tokio::spawn(async move { + match fs::read(&filepath) { + Ok(file_content) => { + service::submit_solution( + &leaderboard, + &gpu, + &submission_mode, + &filepath, + &file_content, + ) + .await + } + Err(e) => Err(anyhow!("Failed to read file {}: {}", filepath, e)), } - Err(e) => { - return Err(e); + }); + self.submission_task = Some(handle); + Ok(()) + } + + pub async fn check_leaderboard_task(&mut self) { + let mut result_to_process: Option<_> = None; + if let Some(handle) = self.leaderboards_task.as_mut() { + if handle.is_finished() { + // Task is finished, take it and await the result. + if let Some(h) = self.leaderboards_task.take() { + result_to_process = Some(h.await); + } } } - Ok(()) + if let Some(join_result) = result_to_process { + match join_result { + Ok(Ok(leaderboards)) => { + self.leaderboards = leaderboards; + if !self.leaderboards.is_empty() { + self.leaderboards_state.select(Some(0)); + } else { + self.leaderboards_state.select(None); // Ensure selection is cleared if empty + } + self.loading_message = None; // Clear loading on success + } + Ok(Err(e)) => { + self.set_error_and_quit(format!("Error fetching leaderboards: {}", e)); + } + Err(e) => { + // This usually means the task panicked. + self.set_error_and_quit(format!("Leaderboard fetch task failed: {}", e)); + } + } + } + } + + pub async fn check_gpu_task(&mut self) { + let mut result_to_process: Option<_> = None; + if let Some(handle) = self.gpus_task.as_mut() { + if handle.is_finished() { + if let Some(h) = self.gpus_task.take() { + result_to_process = Some(h.await); + } + } + } + + if let Some(join_result) = result_to_process { + match join_result { + Ok(Ok(gpus)) => { + self.gpus = gpus; + if self.gpus.is_empty() { + self.set_error_and_quit( + "No GPUs available for the selected leaderboard.".to_string(), + ); + self.gpus_state.select(None); // Clear selection if empty + } else { + self.gpus_state.select(Some(0)); + } + self.loading_message = None; // Clear loading on success + } + Ok(Err(e)) => { + self.set_error_and_quit(format!("Error fetching GPUs: {}", e)); + } + Err(e) => { + self.set_error_and_quit(format!("GPU fetch task failed: {}", e)); + } + } + } + } + + pub async fn check_submission_task(&mut self) { + let mut result_to_process: Option<_> = None; + if let Some(handle) = self.submission_task.as_mut() { + if handle.is_finished() { + if let Some(h) = self.submission_task.take() { + result_to_process = Some(h.await); + } + } + } + + if let Some(join_result) = result_to_process { + match join_result { + Ok(Ok(result)) => { + self.final_status = Some(result); + self.should_quit = true; + self.loading_message = None; + } + Ok(Err(e)) => { + self.set_error_and_quit(format!("Submission failed: {}", e)); + } + Err(e) => { + self.set_error_and_quit(format!("Submission task failed: {}", e)); + } + } + } } } @@ -295,6 +432,15 @@ pub fn ui(app: &App, frame: &mut Frame) { .constraints([Constraint::Min(0)].as_ref()) .split(frame.size()); + if let Some(message) = &app.loading_message { + let text = format!("{} (Press Ctrl+C to quit)", message); + let paragraph = Paragraph::new(text) + .block(Block::default().title("Status").borders(Borders::ALL)) + .alignment(Alignment::Center); + frame.render_widget(paragraph, chunks[0]); + return; + } + match app.modal_state { ModelState::LeaderboardSelection => { let items: Vec = app @@ -340,12 +486,8 @@ pub fn ui(app: &App, frame: &mut Frame) { frame.render_stateful_widget(list, chunks[0], &mut app.submission_modes_state.clone()); } ModelState::WaitingForResult => { - let text = "Submitting solution... press Ctrl+C to quit"; - - let paragraph = Paragraph::new(text) - .block(Block::default().title("Status").borders(Borders::ALL)) - .alignment(Alignment::Center); - + let paragraph = + Paragraph::new("").block(Block::default().title("Status").borders(Borders::ALL)); frame.render_widget(paragraph, chunks[0]); } } @@ -370,16 +512,10 @@ pub async fn execute() -> Result<()> { let (popcorn_directives, has_multiple_gpus) = utils::get_popcorn_directives(filepath)?; if has_multiple_gpus { - println!("Error: multiple GPUs are not yet supported, continue with the first gpu? ({}) [y/N]", popcorn_directives.gpus[0]); - print!("Continue? [y/N] "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - if input.trim().to_lowercase() != "y" { - return Ok(()); - } + println!( + "Warning: multiple GPUs specified, only the first one ({}) will be used.", + popcorn_directives.gpus[0] + ); } // Initialize app @@ -393,61 +529,65 @@ pub async fn execute() -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.clear()?; - // Load initial data - if app.modal_state == ModelState::LeaderboardSelection { - match app.load_leaderboards().await { - Ok(_) => {} - Err(e) => { - app.final_status = Some(format!("Error: {}", e)); + // Perform initial data loading by spawning tasks + match app.modal_state { + ModelState::LeaderboardSelection => { + // Spawn the task, handle immediate spawn error + if let Err(e) = app.spawn_load_leaderboards() { + // Error during spawning itself (rare) + app.final_status = Some(format!("Error starting leaderboard fetch: {}", e)); app.should_quit = true; } } - } - - if app.modal_state == ModelState::GpuSelection { - match app.load_gpus().await { - Ok(_) => {} - Err(e) => { - app.final_status = Some(format!("Error: {}", e)); + ModelState::GpuSelection => { + // Spawn the task, handle immediate spawn error + if let Err(e) = app.spawn_load_gpus() { + // Error during spawning itself (e.g., no leaderboard selected) + app.final_status = Some(format!("Error starting GPU fetch: {}", e)); app.should_quit = true; } } + _ => { /* No initial loading needed for other states */ } } // Main event loop - loop { + while !app.should_quit { + // Draw UI (shows loading screen if loading_message is Some) terminal.draw(|frame| ui(&app, frame))?; - if app.is_loading && app.modal_state == ModelState::WaitingForResult { - if let Err(e) = app.submit_solution().await { - app.final_status = Some(format!("Error: {}", e)); - app.should_quit = true; + // Handle events first (to ensure Ctrl+C works during checks below) + if crossterm::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)?; + // If event handling caused quit, break early + if app.should_quit { + break; + } + } } - app.is_loading = false; } - if app.should_quit { - break; - } + app.check_leaderboard_task().await; - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - app.handle_key_event(key)?; - } - } + app.check_gpu_task().await; + + app.check_submission_task().await; } + // Cleanup terminal terminal.clear()?; disable_raw_mode()?; - crossterm::execute!( io::stdout(), crossterm::terminal::LeaveAlternateScreen, crossterm::cursor::Show )?; - std::thread::sleep(std::time::Duration::from_millis(100)); + // Brief pause allows the terminal to restore properly before printing final output + std::thread::sleep(std::time::Duration::from_millis(50)); + // Clear screen again and move cursor to top-left for final output crossterm::execute!( io::stdout(), crossterm::terminal::Clear(crossterm::terminal::ClearType::All), diff --git a/rust/src/service/mod.rs b/rust/src/service/mod.rs index aea05bf..8b42fb9 100644 --- a/rust/src/service/mod.rs +++ b/rust/src/service/mod.rs @@ -1,4 +1,4 @@ -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; use reqwest::multipart::{Form, Part}; use reqwest::Client; use serde_json::Value; @@ -6,62 +6,69 @@ use std::env; use std::path::Path; use std::time::Duration; -use crate::models::{LeaderboardItem, GpuItem}; +use crate::models::{GpuItem, LeaderboardItem}; pub async fn fetch_leaderboards() -> 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 client = Client::new(); let resp = client .get(format!("{}/leaderboards", base_url)) .timeout(Duration::from_secs(30)) .send() .await?; - + let status = resp.status(); if !status.is_success() { - return Err(anyhow!("Failed to fetch leaderboards: {}", status)); + 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"))?; - + 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_available_gpus(leaderboard: &str) -> 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 client = Client::new(); let resp = client .get(format!("{}/gpus/{}", base_url, leaderboard)) - .timeout(Duration::from_secs(30)) + .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(); - + + let gpu_items = gpus.into_iter().map(|gpu| GpuItem::new(gpu)).collect(); + Ok(gpu_items) } @@ -72,25 +79,27 @@ pub async fn submit_solution>( filename: P, file_content: &[u8], ) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; - - let filename = filename.as_ref() + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let filename = filename + .as_ref() .file_name() .ok_or_else(|| anyhow!("Invalid filename"))? .to_string_lossy(); - - let part = Part::bytes(file_content.to_vec()) - .file_name(filename.to_string()); - + + let part = Part::bytes(file_content.to_vec()).file_name(filename.to_string()); + let form = Form::new().part("file", part); - - let url = format!("{}/{}/{}/{}", + + let url = format!( + "{}/{}/{}/{}", base_url, leaderboard.to_lowercase(), gpu.to_lowercase(), submission_mode.to_lowercase() ); - + let client = Client::new(); let resp = client .post(&url) @@ -98,19 +107,19 @@ pub async fn submit_solution>( .timeout(Duration::from_secs(60)) .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("result") { Some(result_obj) => serde_json::to_string_pretty(result_obj)?, None => return Err(anyhow!("Invalid response structure")), }; - + Ok(pretty_result) } From 9ad1ff06e8fbbd544afb9fb006a739ee4a22a296 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 12 Apr 2025 19:12:26 +0200 Subject: [PATCH 05/19] Feat: login + extra refactor --- rust/Cargo.toml | 6 +- rust/src/cmd/mod.rs | 570 +++++++++++++++++++++++++--------------- rust/src/main.rs | 14 +- rust/src/models/mod.rs | 22 +- rust/src/service/mod.rs | 26 +- 5 files changed, 385 insertions(+), 253 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 2718973..920ff3b 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clap = "4.5.3" +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"] } @@ -15,3 +15,7 @@ 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" diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs index 9ca80c9..6e607f6 100644 --- a/rust/src/cmd/mod.rs +++ b/rust/src/cmd/mod.rs @@ -1,12 +1,14 @@ -use std::fs; -use std::io::{self, Write}; +use std::fs::File; +use std::io::{self, Read}; use std::path::Path; use anyhow::{anyhow, Result}; +use clap::{Parser, Subcommand}; 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}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use tokio::task::JoinHandle; @@ -14,6 +16,29 @@ use crate::models::{GpuItem, LeaderboardItem, ModelState, SubmissionModeItem}; use crate::service; use crate::utils; +mod login; + +#[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 Commands { + /// Login to Popcorn via Discord + Login, + /// Submit a solution (default command) + Submit { + /// Path to the solution file + filepath: Option, + }, +} + pub struct App { pub filepath: String, pub leaderboards: Vec, @@ -264,162 +289,155 @@ impl App { } pub fn spawn_load_leaderboards(&mut self) -> Result<()> { - if self.leaderboards_task.is_some() { - return Ok(()); - } - self.loading_message = Some("Fetching leaderboards...".to_string()); - let handle = tokio::spawn(async { service::fetch_leaderboards().await }); - self.leaderboards_task = Some(handle); + let client = service::create_client()?; + 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<()> { - if self.gpus_task.is_some() { - return Ok(()); - } - let leaderboard = self + let client = service::create_client()?; + let leaderboard_name = self .selected_leaderboard .clone() - .ok_or_else(|| anyhow!("Cannot load GPUs without a selected leaderboard."))?; - - self.loading_message = Some("Fetching GPUs...".to_string()); - - let handle = tokio::spawn(async move { service::fetch_available_gpus(&leaderboard).await }); - self.gpus_task = Some(handle); + .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<()> { - if self.submission_task.is_some() { - return Ok(()); - } + let client = service::create_client()?; + let filepath = self.filepath.clone(); let leaderboard = self .selected_leaderboard .clone() - .ok_or_else(|| anyhow!("Internal Error: No leaderboard selected"))?; - + .ok_or_else(|| anyhow!("Leaderboard not selected"))?; let gpu = self .selected_gpu .clone() - .ok_or_else(|| anyhow!("Internal Error: No GPU selected"))?; - - let submission_mode = self + .ok_or_else(|| anyhow!("GPU not selected"))?; + let mode = self .selected_submission_mode .clone() - .ok_or_else(|| anyhow!("Internal Error: No submission mode selected"))?; + .ok_or_else(|| anyhow!("Submission mode not selected"))?; - let filepath = self.filepath.clone(); + // 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()); - - let handle = tokio::spawn(async move { - match fs::read(&filepath) { - Ok(file_content) => { - service::submit_solution( - &leaderboard, - &gpu, - &submission_mode, - &filepath, - &file_content, - ) - .await - } - Err(e) => Err(anyhow!("Failed to read file {}: {}", filepath, e)), - } - }); - self.submission_task = Some(handle); Ok(()) } pub async fn check_leaderboard_task(&mut self) { - let mut result_to_process: Option<_> = None; - if let Some(handle) = self.leaderboards_task.as_mut() { + if let Some(handle) = &mut self.leaderboards_task { if handle.is_finished() { - // Task is finished, take it and await the result. - if let Some(h) = self.leaderboards_task.take() { - result_to_process = Some(h.await); - } - } - } + 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 + } - if let Some(join_result) = result_to_process { - match join_result { - Ok(Ok(leaderboards)) => { - self.leaderboards = leaderboards; - if !self.leaderboards.is_empty() { - self.leaderboards_state.select(Some(0)); - } else { - self.leaderboards_state.select(None); // Ensure selection is cleared if empty + self.loading_message = None; } - self.loading_message = None; // Clear loading on success - } - Ok(Err(e)) => { - self.set_error_and_quit(format!("Error fetching leaderboards: {}", e)); - } - Err(e) => { - // This usually means the task panicked. - self.set_error_and_quit(format!("Leaderboard fetch task failed: {}", e)); + 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) { - let mut result_to_process: Option<_> = None; - if let Some(handle) = self.gpus_task.as_mut() { + if let Some(handle) = &mut self.gpus_task { if handle.is_finished() { - if let Some(h) = self.gpus_task.take() { - result_to_process = Some(h.await); - } - } - } + 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 + } - if let Some(join_result) = result_to_process { - match join_result { - Ok(Ok(gpus)) => { - self.gpus = gpus; - if self.gpus.is_empty() { - self.set_error_and_quit( - "No GPUs available for the selected leaderboard.".to_string(), - ); - self.gpus_state.select(None); // Clear selection if empty - } else { - self.gpus_state.select(Some(0)); + self.loading_message = None; } - self.loading_message = None; // Clear loading on success - } - Ok(Err(e)) => { - self.set_error_and_quit(format!("Error fetching GPUs: {}", e)); - } - Err(e) => { - self.set_error_and_quit(format!("GPU fetch task failed: {}", e)); + 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) { - let mut result_to_process: Option<_> = None; - if let Some(handle) = self.submission_task.as_mut() { + if let Some(handle) = &mut self.submission_task { if handle.is_finished() { - if let Some(h) = self.submission_task.take() { - result_to_process = Some(h.await); - } - } - } - - if let Some(join_result) = result_to_process { - match join_result { - Ok(Ok(result)) => { - self.final_status = Some(result); - self.should_quit = true; - self.loading_message = None; - } - Ok(Err(e)) => { - self.set_error_and_quit(format!("Submission failed: {}", e)); - } - Err(e) => { - self.set_error_and_quit(format!("Submission task failed: {}", e)); + 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)), } } } @@ -427,177 +445,293 @@ impl App { } pub fn ui(app: &App, frame: &mut Frame) { - let chunks = Layout::default() - .margin(1) + let main_layout = Layout::default() + .direction(Direction::Vertical) .constraints([Constraint::Min(0)].as_ref()) .split(frame.size()); - if let Some(message) = &app.loading_message { - let text = format!("{} (Press Ctrl+C to quit)", message); - let paragraph = Paragraph::new(text) - .block(Block::default().title("Status").borders(Borders::ALL)) + // 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); - frame.render_widget(paragraph, chunks[0]); - return; + + 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(|item| ListItem::new(format!("{}\n{}", item.title(), item.description()))) + .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(Block::default().title("Leaderboards").borders(Borders::ALL)) - .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); - - frame.render_stateful_widget(list, chunks[0], &mut app.leaderboards_state.clone()); + .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(|item| ListItem::new(item.title())) + .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(Block::default().title("GPUs").borders(Borders::ALL)) - .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); - - frame.render_stateful_widget(list, chunks[0], &mut app.gpus_state.clone()); + .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(|item| ListItem::new(format!("{}\n{}", item.title(), item.description()))) - .collect(); + .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( - Block::default() - .title("Submission Mode") - .borders(Borders::ALL), - ) - .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); - - frame.render_stateful_widget(list, chunks[0], &mut app.submission_modes_state.clone()); + .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 => { - let paragraph = - Paragraph::new("").block(Block::default().title("Status").borders(Borders::ALL)); - frame.render_widget(paragraph, chunks[0]); + // This state is handled by the loading message check at the beginning } } } -pub async fn execute() -> Result<()> { - let args: Vec = std::env::args().collect(); +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] +} - if args.len() < 2 { - println!("Usage: popcorn "); - return Ok(()); +pub async fn execute(cli: Cli) -> Result<()> { + match cli.command { + Some(Commands::Login) => login::run_login().await, + Some(Commands::Submit { filepath }) => { + let file_to_submit = filepath.or(cli.filepath); // Use filepath from subcommand first, then top-level + run_submit_tui(file_to_submit).await + } + None => { + // Default behavior: run submit TUI, potentially with top-level filepath + run_submit_tui(cli.filepath).await + } } +} - let filepath = &args[1]; - let path = Path::new(filepath); +async fn run_submit_tui(filepath: Option) -> 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.exists() { - println!("File does not exist: {}", filepath); - return Ok(()); + if !Path::new(&file_to_submit).exists() { + return Err(anyhow!("File not found: {}", file_to_submit)); } - let (popcorn_directives, has_multiple_gpus) = utils::get_popcorn_directives(filepath)?; + let (directives, has_multiple_gpus) = utils::get_popcorn_directives(&file_to_submit)?; if has_multiple_gpus { - println!( - "Warning: multiple GPUs specified, only the first one ({}) will be used.", - popcorn_directives.gpus[0] - ); + return Err(anyhow!( + "Multiple GPUs are not supported yet. Please specify only one GPU." + )); } - // Initialize app - let mut app = App::new(filepath); - app.initialize_with_directives(popcorn_directives); + let mut app = App::new(&file_to_submit); + app.initialize_with_directives(directives); - // Initialize terminal enable_raw_mode()?; - crossterm::execute!(io::stdout(), EnterAlternateScreen)?; - let backend = CrosstermBackend::new(io::stdout()); + let mut stdout = io::stdout(); + crossterm::execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - terminal.clear()?; - // Perform initial data loading by spawning tasks - match app.modal_state { - ModelState::LeaderboardSelection => { - // Spawn the task, handle immediate spawn error - if let Err(e) = app.spawn_load_leaderboards() { - // Error during spawning itself (rare) - app.final_status = Some(format!("Error starting leaderboard fetch: {}", e)); - app.should_quit = true; - } + 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)); } - ModelState::GpuSelection => { - // Spawn the task, handle immediate spawn error - if let Err(e) = app.spawn_load_gpus() { - // Error during spawning itself (e.g., no leaderboard selected) - app.final_status = Some(format!("Error starting GPU fetch: {}", e)); - app.should_quit = true; - } + } 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)); } - _ => { /* No initial loading needed for other states */ } } - // Main event loop + // Main application loop while !app.should_quit { - // Draw UI (shows loading screen if loading_message is Some) - terminal.draw(|frame| ui(&app, frame))?; + 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 events first (to ensure Ctrl+C works during checks below) - if crossterm::event::poll(std::time::Duration::from_millis(50))? { + // 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)?; - // If event handling caused quit, break early - if app.should_quit { - break; - } } } } - - app.check_leaderboard_task().await; - - app.check_gpu_task().await; - - app.check_submission_task().await; } - // Cleanup terminal - terminal.clear()?; + // Restore terminal disable_raw_mode()?; crossterm::execute!( - io::stdout(), - crossterm::terminal::LeaveAlternateScreen, - crossterm::cursor::Show - )?; - - // Brief pause allows the terminal to restore properly before printing final output - std::thread::sleep(std::time::Duration::from_millis(50)); - - // Clear screen again and move cursor to top-left for final output - crossterm::execute!( - io::stdout(), - crossterm::terminal::Clear(crossterm::terminal::ClearType::All), - crossterm::cursor::MoveTo(0, 0) + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen )?; + terminal.show_cursor()?; utils::display_ascii_art(); if let Some(status) = app.final_status { - println!("\nResult:\n\n{}\n", 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 index 7c3446e..ccf767c 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -3,18 +3,26 @@ 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); } - - if let Err(e) = cmd::execute().await { + + // Execute the parsed command + if let Err(e) = cmd::execute(cli).await { eprintln!("Application error: {}", e); process::exit(1); } -} \ No newline at end of file +} diff --git a/rust/src/models/mod.rs b/rust/src/models/mod.rs index b9ce252..257f751 100644 --- a/rust/src/models/mod.rs +++ b/rust/src/models/mod.rs @@ -13,14 +13,6 @@ impl LeaderboardItem { task_description, } } - - pub fn title(&self) -> &str { - &self.title_text - } - - pub fn description(&self) -> &str { - &self.task_description - } } #[derive(Clone, Debug)] @@ -32,10 +24,6 @@ impl GpuItem { pub fn new(title_text: String) -> Self { Self { title_text } } - - pub fn title(&self) -> &str { - &self.title_text - } } #[derive(Clone, Debug)] @@ -53,14 +41,6 @@ impl SubmissionModeItem { value, } } - - pub fn title(&self) -> &str { - &self.title_text - } - - pub fn description(&self) -> &str { - &self.description_text - } } #[derive(Clone, Copy, Debug, PartialEq)] @@ -72,4 +52,4 @@ pub enum ModelState { } #[derive(Debug, Serialize, Deserialize)] -pub struct SubmissionResultMsg(pub String); \ No newline at end of file +pub struct SubmissionResultMsg(pub String); diff --git a/rust/src/service/mod.rs b/rust/src/service/mod.rs index 8b42fb9..489d45e 100644 --- a/rust/src/service/mod.rs +++ b/rust/src/service/mod.rs @@ -8,11 +8,18 @@ use std::time::Duration; use crate::models::{GpuItem, LeaderboardItem}; -pub async fn fetch_leaderboards() -> Result> { +// Helper function to create a reusable reqwest client +pub fn create_client() -> Result { + Client::builder() + .timeout(Duration::from_secs(60)) // Set a default timeout + .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 client = Client::new(); let resp = client .get(format!("{}/leaderboards", base_url)) .timeout(Duration::from_secs(30)) @@ -48,11 +55,10 @@ pub async fn fetch_leaderboards() -> Result> { Ok(leaderboard_items) } -pub async fn fetch_available_gpus(leaderboard: &str) -> Result> { +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 client = Client::new(); let resp = client .get(format!("{}/gpus/{}", base_url, leaderboard)) .timeout(Duration::from_secs(120)) @@ -73,22 +79,23 @@ pub async fn fetch_available_gpus(leaderboard: &str) -> Result> { } pub async fn submit_solution>( + client: &Client, + filepath: P, + file_content: &str, leaderboard: &str, gpu: &str, submission_mode: &str, - filename: P, - file_content: &[u8], ) -> Result { let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; - let filename = filename + let filename = filepath .as_ref() .file_name() - .ok_or_else(|| anyhow!("Invalid filename"))? + .ok_or_else(|| anyhow!("Invalid filepath"))? .to_string_lossy(); - let part = Part::bytes(file_content.to_vec()).file_name(filename.to_string()); + let part = Part::bytes(file_content.as_bytes().to_vec()).file_name(filename.to_string()); let form = Form::new().part("file", part); @@ -100,7 +107,6 @@ pub async fn submit_solution>( submission_mode.to_lowercase() ); - let client = Client::new(); let resp = client .post(&url) .multipart(form) From ea3f4ab47c59f70f0fd243beafdcb41358a47d2a Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 12 Apr 2025 23:16:32 +0200 Subject: [PATCH 06/19] Feat: reregister --- rust/src/cmd/mod.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs index 6e607f6..fe0471c 100644 --- a/rust/src/cmd/mod.rs +++ b/rust/src/cmd/mod.rs @@ -31,7 +31,9 @@ pub struct Cli { #[derive(Subcommand, Debug)] enum Commands { /// Login to Popcorn via Discord - Login, + Reregister, + /// Register to Popcorn via Discord + Register, /// Submit a solution (default command) Submit { /// Path to the solution file @@ -630,7 +632,8 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { pub async fn execute(cli: Cli) -> Result<()> { match cli.command { - Some(Commands::Login) => login::run_login().await, + Some(Commands::Reregister) => login::run_auth(true).await, + Some(Commands::Register) => login::run_auth(false).await, Some(Commands::Submit { filepath }) => { let file_to_submit = filepath.or(cli.filepath); // Use filepath from subcommand first, then top-level run_submit_tui(file_to_submit).await From eb0cdcdc7b070a547c90695965dab7666bd14c1f Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 12 Apr 2025 23:55:38 +0200 Subject: [PATCH 07/19] Feat: github --- rust/Cargo.toml | 1 + rust/src/cmd/mod.rs | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 920ff3b..d7f3774 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -19,3 +19,4 @@ dirs = "5.0" serde_yaml = "0.9" webbrowser = "0.8" base64-url = "3.0.0" +urlencoding = "2.1.3" diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs index fe0471c..0db2d2e 100644 --- a/rust/src/cmd/mod.rs +++ b/rust/src/cmd/mod.rs @@ -28,12 +28,26 @@ pub struct Cli { filepath: Option, } +#[derive(Subcommand, Debug)] +enum AuthProvider { + /// Use Discord for authentication + Discord, + /// Use GitHub for authentication + Github, +} + #[derive(Subcommand, Debug)] enum Commands { - /// Login to Popcorn via Discord - Reregister, - /// Register to Popcorn via Discord - Register, + /// Re-register with Popcorn (links existing account) + Reregister { + #[command(subcommand)] + provider: AuthProvider, + }, + /// Register a new account with Popcorn + Register { + #[command(subcommand)] + provider: AuthProvider, + }, /// Submit a solution (default command) Submit { /// Path to the solution file @@ -632,8 +646,20 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { pub async fn execute(cli: Cli) -> Result<()> { match cli.command { - Some(Commands::Reregister) => login::run_auth(true).await, - Some(Commands::Register) => login::run_auth(false).await, + Some(Commands::Reregister { provider }) => { + let provider_str = match provider { + AuthProvider::Discord => "discord", + AuthProvider::Github => "github", + }; + login::run_auth(true, provider_str).await + } + Some(Commands::Register { provider }) => { + let provider_str = match provider { + AuthProvider::Discord => "discord", + AuthProvider::Github => "github", + }; + login::run_auth(false, provider_str).await + } Some(Commands::Submit { filepath }) => { let file_to_submit = filepath.or(cli.filepath); // Use filepath from subcommand first, then top-level run_submit_tui(file_to_submit).await From d6f654baad64cdf228baf48460c7aee96fcb4f0b Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 01:17:15 +0200 Subject: [PATCH 08/19] feat: refactor + auth --- rust/src/cmd/auth.rs | 145 ++++++++ rust/src/cmd/mod.rs | 764 +++------------------------------------- rust/src/cmd/submit.rs | 702 ++++++++++++++++++++++++++++++++++++ rust/src/service/mod.rs | 19 +- 4 files changed, 917 insertions(+), 713 deletions(-) create mode 100644 rust/src/cmd/auth.rs create mode 100644 rust/src/cmd/submit.rs 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 index 0db2d2e..4fde558 100644 --- a/rust/src/cmd/mod.rs +++ b/rust/src/cmd/mod.rs @@ -1,22 +1,39 @@ -use std::fs::File; -use std::io::{self, Read}; -use std::path::Path; - use anyhow::{anyhow, Result}; use clap::{Parser, Subcommand}; -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 dirs; +use serde::{Deserialize, Serialize}; +use serde_yaml; +use std::fs::File; +use std::path::PathBuf; + +mod auth; +mod submit; -use crate::models::{GpuItem, LeaderboardItem, ModelState, SubmissionModeItem}; -use crate::service; -use crate::utils; +#[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")) +} -mod login; +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)] @@ -30,620 +47,25 @@ pub struct Cli { #[derive(Subcommand, Debug)] enum AuthProvider { - /// Use Discord for authentication Discord, - /// Use GitHub for authentication Github, } #[derive(Subcommand, Debug)] enum Commands { - /// Re-register with Popcorn (links existing account) Reregister { #[command(subcommand)] provider: AuthProvider, }, - /// Register a new account with Popcorn Register { #[command(subcommand)] provider: AuthProvider, }, - /// Submit a solution (default command) Submit { - /// Path to the solution file filepath: Option, }, } -pub struct App { - pub filepath: 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) -> 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(), - 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()?; - 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()?; - 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()?; - 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 execute(cli: Cli) -> Result<()> { match cli.command { Some(Commands::Reregister { provider }) => { @@ -651,117 +73,37 @@ pub async fn execute(cli: Cli) -> Result<()> { AuthProvider::Discord => "discord", AuthProvider::Github => "github", }; - login::run_auth(true, provider_str).await + auth::run_auth(true, provider_str).await } Some(Commands::Register { provider }) => { let provider_str = match provider { AuthProvider::Discord => "discord", AuthProvider::Github => "github", }; - login::run_auth(false, provider_str).await + auth::run_auth(false, provider_str).await } Some(Commands::Submit { filepath }) => { - let file_to_submit = filepath.or(cli.filepath); // Use filepath from subcommand first, then top-level - run_submit_tui(file_to_submit).await - } - None => { - // Default behavior: run submit TUI, potentially with top-level filepath - run_submit_tui(cli.filepath).await + 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 } - } -} - -async fn run_submit_tui(filepath: Option) -> 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); - 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)); + 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 } } - - // 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/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/service/mod.rs b/rust/src/service/mod.rs index 489d45e..db5fc02 100644 --- a/rust/src/service/mod.rs +++ b/rust/src/service/mod.rs @@ -1,4 +1,5 @@ use anyhow::{anyhow, Result}; +use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::multipart::{Form, Part}; use reqwest::Client; use serde_json::Value; @@ -9,9 +10,23 @@ use std::time::Duration; use crate::models::{GpuItem, LeaderboardItem}; // Helper function to create a reusable reqwest client -pub fn create_client() -> Result { +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(60)) // Set a default timeout + .timeout(Duration::from_secs(60)) + .default_headers(default_headers) .build() .map_err(|e| anyhow!("Failed to create HTTP client: {}", e)) } From 3cc5a99af8258032deed0d7d9e157ebf7e391949 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 01:41:49 +0200 Subject: [PATCH 09/19] Feat: build --- .github/workflows/build.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 60ad366..1a076f7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,8 +22,12 @@ jobs: with: fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v5 + name: Set up Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 From 85b639a2af0e4a1d30bf100ce45c763128a51a58 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 01:49:13 +0200 Subject: [PATCH 10/19] Fix: build --- .goreleaser.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 4147330..1e9eefa 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,12 +1,7 @@ version: 2 builds: - - env: - - CGO_ENABLED=0 - goos: - - linux - - windows - - darwin - goarch: - - amd64 - - arm64 + - id: rust + builder: rust + dir: rust binary: popcorn-cli + command: build From 3fb6315ef1f977cde6e814015e96fcb9c416c08f Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 01:55:20 +0200 Subject: [PATCH 11/19] Fix: build --- .goreleaser.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index 1e9eefa..2027660 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -5,3 +5,8 @@ builds: dir: rust binary: popcorn-cli command: build + targets: + - x86_64-unknown-linux-gnu + - x86_64-apple-darwin + - x86_64-pc-windows-gnu + - aarch64-unknown-linux-gnu From e00e952fafc41a18c9b44c026cec5f1f97b039f6 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 01:59:17 +0200 Subject: [PATCH 12/19] Fix: build2 --- .github/workflows/build.yml | 3 +++ .goreleaser.yml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1a076f7..12cc441 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,6 +28,9 @@ jobs: toolchain: stable profile: minimal override: true + - + name: Set up Zig + uses: mlugg/setup-zig@v1 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 diff --git a/.goreleaser.yml b/.goreleaser.yml index 2027660..3f6f2fb 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -4,7 +4,7 @@ builds: builder: rust dir: rust binary: popcorn-cli - command: build + command: zigbuild targets: - x86_64-unknown-linux-gnu - x86_64-apple-darwin From f1e983fc1796c5c891dd4228ba2c671bc6331ace Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 02:02:33 +0200 Subject: [PATCH 13/19] Fix: build. --- .github/workflows/build.yml | 7 ------- .goreleaser.yml | 5 ----- 2 files changed, 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 12cc441..699c707 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,13 +21,6 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Set up Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - profile: minimal - override: true - name: Set up Zig uses: mlugg/setup-zig@v1 diff --git a/.goreleaser.yml b/.goreleaser.yml index 3f6f2fb..99f7258 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -5,8 +5,3 @@ builds: dir: rust binary: popcorn-cli command: zigbuild - targets: - - x86_64-unknown-linux-gnu - - x86_64-apple-darwin - - x86_64-pc-windows-gnu - - aarch64-unknown-linux-gnu From 8698a7d4be4524622ca9a017baf9c8af28e36542 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 02:05:55 +0200 Subject: [PATCH 14/19] Fix: build. --- .goreleaser.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 99f7258..40da2d5 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -4,4 +4,3 @@ builds: builder: rust dir: rust binary: popcorn-cli - command: zigbuild From 97c50520a2a8a3df802aec9d52474485a84cb727 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 02:07:10 +0200 Subject: [PATCH 15/19] Fix: build. --- .github/workflows/build.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 699c707..993bfa9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,6 +24,16 @@ jobs: - 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: Run GoReleaser uses: goreleaser/goreleaser-action@v6 From 5333f75996f1f31fa7d05fcd023c5baf7949cebc Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 02:15:39 +0200 Subject: [PATCH 16/19] Fix: build. --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 993bfa9..4d46d1f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,10 @@ jobs: - 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 From 1aa5582abc672a179ca3b1458d089ba60c4a384d Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 13:20:32 +0200 Subject: [PATCH 17/19] Fix: timeouts --- rust/src/service/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/src/service/mod.rs b/rust/src/service/mod.rs index db5fc02..6fd4d2c 100644 --- a/rust/src/service/mod.rs +++ b/rust/src/service/mod.rs @@ -25,7 +25,7 @@ pub fn create_client(cli_id: Option) -> Result { } Client::builder() - .timeout(Duration::from_secs(60)) + .timeout(Duration::from_secs(180)) .default_headers(default_headers) .build() .map_err(|e| anyhow!("Failed to create HTTP client: {}", e)) @@ -125,7 +125,7 @@ pub async fn submit_solution>( let resp = client .post(&url) .multipart(form) - .timeout(Duration::from_secs(60)) + .timeout(Duration::from_secs(180)) .send() .await?; @@ -137,7 +137,7 @@ pub async fn submit_solution>( let result: Value = resp.json().await?; - let pretty_result = match result.get("result") { + let pretty_result = match result.get("results") { Some(result_obj) => serde_json::to_string_pretty(result_obj)?, None => return Err(anyhow!("Invalid response structure")), }; From 01b389647e750b2eb218c0a13437f30fab2f56cd Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 16:20:56 +0200 Subject: [PATCH 18/19] Feat: readme --- README.md | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5361cda..61291c6 100644 --- a/README.md +++ b/README.md @@ -12,27 +12,26 @@ 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 From 3205bead07d20cc1be50fd3c7d73bac526401d5e Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 16:21:47 +0200 Subject: [PATCH 19/19] Fix: readme --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 61291c6..073a9f0 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ popcorn-cli submit The interactive CLI will guide you through the process of: 1. Selecting a leaderboard -2 Selecting GPU options +2. Selecting GPU options 3. Setting submission mode 4. Submitting your work -