diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 60ad366..a1f6dd8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,46 +2,133 @@ name: release on: push: - # Sequence of patterns matched against refs/tags + branches: + - main tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 - - # Keep pull request builds for testing - pull_request: + workflow_dispatch: permissions: contents: write jobs: - goreleaser: + version: + name: Generate Version runs-on: ubuntu-latest + outputs: + new_tag: ${{ steps.tag_version.outputs.new_tag }} steps: - - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 - if: startsWith(github.ref, 'refs/tags/') + + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.1 with: - distribution: goreleaser - version: '~> v2' - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: patch + release_branches: main + + build: + name: Build + needs: version + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + artifact_name: popcorn-cli + asset_name: popcorn-cli-linux.tar.gz + compress_cmd: tar -czf + compress_ext: .tar.gz + + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact_name: popcorn-cli + asset_name: popcorn-cli-windows.zip + compress_cmd: 7z a + compress_ext: .zip + + - os: macos-latest + target: aarch64-apple-darwin + artifact_name: popcorn-cli + asset_name: popcorn-cli-macos.tar.gz + compress_cmd: tar -czf + compress_ext: .tar.gz + + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + artifact_name: popcorn-cli + asset_name: popcorn-cli-linux-aarch64.tar.gz + compress_cmd: tar -czf + compress_ext: .tar.gz + + steps: + - uses: actions/checkout@v4 - - - name: Run GoReleaser Snapshot - uses: goreleaser/goreleaser-action@v6 - if: "!startsWith(github.ref, 'refs/tags/')" + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + targets: ${{ matrix.target }} + + - name: Set up cargo cache + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Install musl tools (Linux musl) + if: matrix.target == 'x86_64-unknown-linux-musl' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} + env: + CLI_VERSION: ${{ needs.version.outputs.new_tag }} + + - name: Prepare artifact + shell: bash + run: | + mkdir -p dist + if [[ "${{ matrix.os }}" == "windows-latest" ]]; then + cp target/${{ matrix.target }}/release/popcorn-cli.exe dist/popcorn-cli.exe + else + cp target/${{ matrix.target }}/release/popcorn-cli dist/popcorn-cli + chmod +x dist/popcorn-cli + fi + cd dist + ${{ matrix.compress_cmd }} ../${{ matrix.asset_name }} * + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset_name }} + path: ${{ matrix.asset_name }} + retention-days: 7 + + release: + name: Create Release + needs: [build, version] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + + - name: Create Release + uses: softprops/action-gh-release@v1 with: - distribution: goreleaser - version: '~> v2' - args: release --clean --snapshot + tag_name: ${{ needs.version.outputs.new_tag }} + name: Release ${{ needs.version.outputs.new_tag }} + files: | + popcorn-cli-linux.tar.gz/popcorn-cli-linux.tar.gz + popcorn-cli-linux-aarch64.tar.gz/popcorn-cli-linux-aarch64.tar.gz + popcorn-cli-windows.zip/popcorn-cli-windows.zip + popcorn-cli-macos.tar.gz/popcorn-cli-macos.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..be183c3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,68 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Run Tests + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] + rust: [stable] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + components: clippy, rustfmt + + - name: Set up cargo cache + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.os }}-test + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Build + run: cargo build --verbose + + - name: Run tests + run: cargo test --verbose + + coverage: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin + + - name: Run coverage + run: cargo tarpaulin --out Xml --verbose + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: cobertura.xml + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index e8c1567..47e7497 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ submission.* +target/ +scratch.md +*claude +*.zip +.codex/ +.popcorn/ +.DS_Store diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index 4147330..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 2 -builds: - - env: - - CGO_ENABLED=0 - goos: - - linux - - windows - - darwin - goarch: - - amd64 - - arm64 - binary: popcorn-cli diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1ab8e15 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +## Skills +A skill is a local instruction bundle stored in `SKILL.md`. + +### Available skills +- popcorn-submission-workflow: Helps with Popcorn CLI registration, submission setup, submission modes, and file directives. (file: /Users/ben/code/popcorn-cli/.popcorn/skills/popcorn-submission-workflow/SKILL.md) + +### How to use skills +- Load the skill by reading its `SKILL.md` file when user requests match the description. +- Follow progressive disclosure: read only relevant referenced files/scripts as needed. +- Keep the workspace setup aligned with `popcorn setup`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ca533de --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +# CLAUDE.md + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, testing, architecture, and admin commands. + +## Quick Reference + +```bash +cargo build # Build +cargo test # Run tests +cargo fmt --all # Format (required before commits) +cargo clippy --all-targets --all-features -- -D warnings # Lint (must pass with no warnings) +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d83b746 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,232 @@ +# Contributing + +## Development Setup + +```bash +# Build +cargo build + +# Run tests +cargo test + +# Format code (required before commits) +cargo fmt --all + +# Lint (must pass with no warnings) +cargo clippy --all-targets --all-features -- -D warnings +``` + +### CI Requirements + +All PRs must pass: +- `cargo fmt --all -- --check` - Code formatting +- `cargo clippy -- -D warnings` - No clippy warnings allowed +- `cargo test` - All tests pass +- Builds on Linux, macOS, and Windows + +## Testing + +### Unit Tests + +Tests are in the same file as the code (Rust convention): +- `src/service/mod.rs` - API client tests +- `src/utils/mod.rs` - Utility function tests + +Run all tests: +```bash +cargo test +``` + +Run specific tests: +```bash +cargo test test_name +``` + +### Test Requirements + +When adding new functionality: + +1. **Service functions** (`src/service/mod.rs`): + - Add tests in the `#[cfg(test)] mod tests` block + - Test error handling, response parsing + +2. **Command handlers** (`src/cmd/`): + - Integration testing via E2E regression tests + +### E2E Regression Testing + +Full end-to-end testing requires a running kernelbot API server. You can test against production or a local instance. + +#### Option A: Test Against Production + +```bash +export POPCORN_API_URL=https://site--bot--dxfjds728w5v.code.run +cargo run -- submissions list --leaderboard grayscale +``` + +#### Option B: Test Against Local Server (Recommended for Development) + +This tests the complete flow: CLI → API → Database → Modal runner. + +**Step 1: Set up kernelbot server** (in the kernelbot repo): + +```bash +# Start PostgreSQL +brew services start postgresql@14 + +# Create database and run migrations +createdb kernelbot +export DATABASE_URL="postgresql://$(whoami)@localhost:5432/kernelbot" +uv run yoyo apply --database "$DATABASE_URL" src/migrations/ + +# Create test user +psql "$DATABASE_URL" -c "INSERT INTO leaderboard.user_info (id, user_name, cli_id, cli_valid) +VALUES ('999999', 'testuser', 'test-cli-id-123', true) +ON CONFLICT (id) DO UPDATE SET cli_id = 'test-cli-id-123', cli_valid = true;" + +# Start API server +cd src/kernelbot +export ADMIN_TOKEN="your-admin-token" # Check .env for LOCAL_ADMIN_TOKEN +uv run python main.py --api-only +``` + +**Step 2: Sync leaderboards**: + +```bash +curl -X POST "http://localhost:8000/admin/update-problems" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"problem_set": "pmpp_v2"}' +``` + +**Step 3: Configure CLI for local testing**: + +```bash +# Backup and set test config +cp ~/.popcorn.yaml ~/.popcorn.yaml.bak +echo "cli_id: test-cli-id-123" > ~/.popcorn.yaml +``` + +**Step 4: Run CLI commands**: + +```bash +export POPCORN_API_URL=http://localhost:8000 + +# Test submissions commands +cargo run --release -- submissions list --leaderboard vectoradd_v2 +cargo run --release -- submissions show +cargo run --release -- submissions delete + +# Test actual submission (requires Modal account for GPU execution) +cargo run --release -- submit solution.py --gpu H100 --leaderboard vectoradd_v2 --mode test +``` + +**Step 5: Restore original config**: + +```bash +cp ~/.popcorn.yaml.bak ~/.popcorn.yaml && rm ~/.popcorn.yaml.bak +``` + +#### Troubleshooting + +- **401 Unauthorized**: CLI ID not registered in database - create test user first +- **404 Not Found**: Leaderboards not synced - run update-problems endpoint +- **Connection refused**: API server not running on localhost:8000 +- **"Device not configured"**: TTY issue - ensure POPCORN_API_URL is set + +## Admin Commands + +Admin commands require the `POPCORN_ADMIN_TOKEN` environment variable. + +```bash +# Server control +popcorn admin start # Start accepting jobs +popcorn admin stop # Stop accepting jobs +popcorn admin stats # Get server statistics +popcorn admin stats --last-day # Stats for last 24 hours only + +# Submission management +popcorn admin get-submission # Get any submission by ID +popcorn admin delete-submission # Delete any submission + +# Leaderboard management +popcorn admin create-leaderboard # Create leaderboard from problem directory +popcorn admin delete-leaderboard # Delete a leaderboard +popcorn admin delete-leaderboard --force # Force delete with submissions + +# Invite management +popcorn admin generate-invites --leaderboards lb1 lb2 --count 5 # Generate invite codes +popcorn admin list-invites # List invites for a leaderboard +popcorn admin revoke-invite # Revoke an invite code + +# Update problems from GitHub +popcorn admin update-problems +popcorn admin update-problems --problem-set nvidia --force +``` + +## Architecture Overview + +Popcorn CLI is a command-line tool for submitting GPU kernel optimization solutions to [gpumode.com](https://gpumode.com) competitions. + +### Directory Structure + +``` +src/ +├── main.rs # Entry point, sets POPCORN_API_URL +├── cmd/ # Command handling +│ ├── mod.rs # CLI argument parsing (clap), config loading +│ ├── admin.rs # Admin commands (requires POPCORN_ADMIN_TOKEN) +│ ├── auth.rs # OAuth authentication (Discord/GitHub) +│ ├── submissions.rs # User submission management (list, show, delete) +│ └── submit.rs # Submission logic, TUI app state machine +├── service/ +│ └── mod.rs # HTTP client, API calls, SSE streaming +├── models/ +│ └── mod.rs # Data structures (LeaderboardItem, GpuItem, AppState) +├── utils/ +│ └── mod.rs # Directive parsing, text wrapping, ASCII art +└── views/ + ├── loading_page.rs # TUI loading screen with progress bar + └── result_page.rs # TUI results display with scrolling +``` + +### Before Adding New Features + +**Important:** Before implementing new functionality, check for existing code in both repos: + +1. **Check discord-cluster-manager** for existing Discord commands and database methods: + - `src/kernelbot/cogs/` - Discord bot commands + - `src/libkernelbot/leaderboard_db.py` - Database methods + - `src/kernelbot/api/main.py` - Existing API endpoints + +2. **Check popcorn-cli** for existing service functions and commands: + - `src/service/mod.rs` - API client functions + - `src/cmd/` - CLI command handlers + +3. **Reuse existing functionality** where possible: + - Database methods (e.g., `get_submission_by_id`, `delete_submission`) + - API response handling patterns + - Authentication validation (`validate_user_header`, `validate_cli_header`) + +### Core Flow + +1. **Authentication** (`cmd/auth.rs`): User registers via Discord/GitHub OAuth. CLI ID stored in `~/.popcorn.yaml`. + +2. **Submission** (`cmd/submit.rs`): + - TUI mode: Interactive selection of leaderboard → GPU → mode + - Plain mode (`--no-tui`): Direct submission with CLI flags + - Reads solution file with optional `#!POPCORN` directives for defaults + +3. **API Communication** (`service/mod.rs`): + - Fetches available leaderboards and GPUs + - Submits solutions via multipart form POST + - Handles SSE (Server-Sent Events) streaming for real-time results + - Supports modes: `test`, `benchmark`, `leaderboard`, `profile` + +### Key Dependencies + +- `clap` - CLI argument parsing +- `ratatui` + `crossterm` - Terminal UI +- `reqwest` - HTTP client with SSE streaming +- `tokio` - Async runtime +- `serde` / `serde_yaml` / `serde_json` - Serialization diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..bf83f27 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2282 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-url" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2b6c78c06f7288d5e3c3d683bde35a79531127c83b087e5d0d77c974b4b28" +dependencies = [ + "base64 0.22.1", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" +dependencies = [ + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.9.0", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "ctrlc" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "697b5419f348fd5ae2478e8018cb016c00a5881c7f46c717de98ffd135a5651c" +dependencies = [ + "nix", + "windows-sys 0.59.0", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.9.0", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "popcorn-cli" +version = "0.0.0-dev" +dependencies = [ + "anyhow", + "base64 0.22.1", + "base64-url", + "bytes", + "chrono", + "clap", + "crossterm", + "ctrlc", + "dirs", + "futures-util", + "ratatui", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "tokio", + "urlencoding", + "webbrowser", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags 2.9.0", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "redox_syscall" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +dependencies = [ + "bitflags 2.9.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror", +] + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +dependencies = [ + "bitflags 2.9.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "socket2" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +dependencies = [ + "fastrand", + "getrandom 0.3.2", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio 1.0.3", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b" +dependencies = [ + "core-foundation", + "home", + "jni", + "log", + "ndk-context", + "objc", + "raw-window-handle", + "url", + "web-sys", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.0", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d8d6c05 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "popcorn-cli" +version = "0.0.0-dev" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clap = { version = "4.5.3", features = ["derive"] } +reqwest = { version = "0.11", default-features = false, features = ["json", "multipart", "rustls-tls"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +ratatui = "0.26.1" +crossterm = "0.27.0" +anyhow = "1.0" +ctrlc = "3.4.6" +dirs = "5.0" +serde_yaml = "0.9" +webbrowser = "0.8" +base64-url = "3.0.0" +base64 = "0.22" +chrono = "0.4" +urlencoding = "2.1.3" +bytes = "1.11.1" +futures-util = "0.3.31" + +[dev-dependencies] +tempfile = "3.10" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e46d508 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 GPU MODE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5361cda..7a6069d 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,217 @@ # Popcorn CLI -A command-line interface tool for submitting solutions to the [Popcorn Discord Bot](https://github.com/gpu-mode/discord-cluster-manager) +A command-line interface tool for submitting solutions to the [gpumode.com](https://gpumode.com) +Screenshot 2025-06-10 at 11 17 45 AM + +Tested on linux and mac but should just work on Windows as well. + +## New: Nsight Compute Profiling + +Profile your kernels with `--mode profile` and get detailed metrics. Currently only available for the NVFP4 Blackwell competition (Modal, which we use for other competitions, does not support NCU). See [docs/profiling.md](docs/profiling.md) for details. ## Installation -### Option 1: Using pre-built binaries (Recommended) +### Option 1: One-Line Install (Recommended) + +**Linux/macOS/Unix:** +```bash +curl -fsSL https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.sh | bash +``` + +**Windows (PowerShell):** +```powershell +powershell -ExecutionPolicy Bypass -Command "iwr -UseBasicParsing https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.ps1 | iex" +``` + +After installation, restart your terminal (or run `source ~/.bashrc` / `source ~/.zshrc`). + +### Option 2: Manual Installation -1. Download the latest release for your platform from the releases page +1. Download the binary for your OS from [releases](https://github.com/gpu-mode/popcorn-cli/releases/latest) 2. Extract the archive -3. Move the binary to a location in your PATH +3. Move the binary to a directory in your PATH +4. Make it executable (Linux/macOS): `chmod +x popcorn-cli` + +### Option 3: Building from source + +1. Download rust `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` +2. `cd popcorn-cli && ./build.sh` + +### Troubleshooting + +**Command not found after installation:** +- Restart your terminal +- Check if the install directory is in your PATH: + - Linux/macOS: `echo $PATH` + - Windows: `echo $env:PATH` +- Check if POPCORN_API_URL is set to https://site--bot--dxfjds728w5v.code.run + - Linux/macOS: `echo $POPCORN_API_URL` + - Windows: `echo $env:POPCORN_API_URL` + +## Authentication + +Since we're effectively giving out GPUs for free we rely on either github or discord authentication to prove that you're a real human before you access our service. + +1. Register the CLI (Discord recommended): `popcorn register discord` (or `popcorn register github`) +2. To ensure the above worked you can run `cat $HOME/.popcorn.yaml` which should print your client ID which is what will be sent to us on every request +3. To override the submitter ID for local proxy/testing flows, set `POPCORN_SUBMITTER_ID`. Commands that require `cli_id` use this value first, then fall back to `~/.popcorn.yaml`. + +Sometimes you'll get an error that you're already authenticated despite being unable to submit in which case you can run `popcorn reregister [discord|github]`. + +## Make your first submission + +```bash +wget https://raw.githubusercontent.com/gpu-mode/reference-kernels/refs/heads/main/problems/pmpp_v2/grayscale_py/submission.py +popcorn-cli submit --gpu A100 --leaderboard grayscale_v2 --mode leaderboard submission.py +``` + +We regularly run competitions with clear due dates but for beginners we will always keep open the PMPP_v2 problem set https://github.com/gpu-mode/reference-kernels/tree/main/problems/pmpp_v2 + +## Commands + +### Setup + +Bootstrap a project with Popcorn skill scaffolding and a submission template. + +```bash +# Create a project folder with skill scaffolding + submission.py +popcorn setup +``` + +This will create a new folder named after the selected problem (e.g. `softmax/`), containing a `submission.py` template and agent skills in `.claude/skills` and `.codex/skills`. If a folder with that name already exists, a `-N` suffix is appended (e.g. `softmax-1/`). + +### Submit + +Submit a solution to a leaderboard. Supports both TUI (interactive) and plain modes. + +```bash +# Interactive TUI mode - select leaderboard, GPU, and mode interactively +popcorn submit solution.py + +# Direct submission with all options +popcorn submit --leaderboard grayscale_v2 --gpu A100 --mode leaderboard solution.py + +# Plain output mode (no TUI, good for CI/scripts) +popcorn submit --no-tui --leaderboard grayscale_v2 --gpu A100 --mode test solution.py + +# Save results to a file +popcorn submit --output results.json --leaderboard grayscale_v2 --gpu A100 --mode benchmark solution.py +``` -### Option 2: Building from source +**Submission modes:** +- `test` - Quick test run to check correctness +- `benchmark` - Benchmark your solution (no leaderboard impact) +- `leaderboard` - Official ranked submission +- `profile` - Profile with Nsight Compute (limited availability) + +### Submissions + +Manage your past submissions. + +```bash +# List your submissions for a leaderboard +popcorn submissions list --leaderboard grayscale_v2 + +# Limit number of results +popcorn submissions list --leaderboard grayscale_v2 --limit 10 + +# View a specific submission with full code +popcorn submissions show + +# Delete a submission (with confirmation prompt) +popcorn submissions delete + +# Delete without confirmation +popcorn submissions delete --force +``` + +#### Avoid Reward Hacks + +If you want to avoid reward hacks, inspect your past submissions and delete any bad ones. + +```bash +# 1. List your submissions and note the ID +popcorn submissions list --leaderboard grayscale_v2 + +# 2. Inspect the exact code stored for that submission +popcorn submissions show 1234 + +# 3. Delete the submission if you do not want it kept +popcorn submissions delete 1234 +``` + +`list` shows the submission `ID`, `show` prints the full submitted code for that `ID`, and `delete` previews the submission before removing it. + +### Authentication + +Register or re-register your CLI with Discord or GitHub. -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 +# Initial registration (Discord recommended) +popcorn register discord +popcorn register github + +# Re-register if you need to link a new account +popcorn reregister discord +popcorn reregister github ``` -3. Make sure the `popcorn-cli` binary is in your PATH -## Usage +### File Directives + +You can embed default settings directly in your solution files: + +```python +#!POPCORN leaderboard grayscale_v2 +#!POPCORN gpu A100 + +def solution(): + ... +``` -Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API +Or C++ style: +```cpp +//!POPCORN leaderboard nvidia-matmul +//!POPCORN gpu H100 +``` -Then, simply run the binary: +When these directives are present, you can submit with just: ```bash -popcorn-cli +popcorn submit solution.py ``` -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 +## Submission Format + +Submissions are always a single Python file. If you want to submit native CUDA code, you can use PyTorch's `load_inline` feature (which uses nvcc) or the more experimental [`compile_kernel` API](https://x.com/gaunernst/status/2015242181049745607) for fast compilation. See [this example](https://github.com/gpu-mode/reference-kernels/blob/main/problems/pmpp_v2/vectoradd_py/solutions/correct/submission_cuda_inline.py) for reference. + +### Installing Extra Dependencies + +If your submission requires a Python package that isn't pre-installed in the runtime environment, you can install it directly in your submission file: + +```python +import subprocess +import sys +subprocess.check_call([sys.executable, "-m", "pip", "install", "some_package"]) +``` + +This runs before the rest of your code executes, so the package will be available for import afterwards. + +If you find yourself installing the same package frequently, we're happy to add it to the runtime by default. Open a PR on [gpu-mode/kernelbot](https://github.com/gpu-mode/kernelbot): +- For Modal-based runners: edit [`src/runners/modal_runner.py`](https://github.com/gpu-mode/kernelbot/blob/main/src/runners/modal_runner.py) +- For on-prem hardware: look for the Dockerfiles in the same repo + +For syntax highlighting of both C++ and Python in your IDE, you can use the [PyTorch Load Inline Highlighter](https://marketplace.visualstudio.com/items?itemName=msaroufim.pytorch-load-inline-highlighter) VS Code extension. + +## Reference Kernels + +All reference kernels are available at [gpu-mode/reference-kernels](https://github.com/gpu-mode/reference-kernels). Each problem directory contains: +- `reference.py` - The reference implementation to beat +- `submission.py` - A sample submission you can use as a starting point +- `task.yml` - Input shapes and problem configuration + +Our entire evaluation infrastructure is open source and you can learn more [here](https://github.com/gpu-mode/kernelbot). Development happens on the [KernelBot discord](https://discord.gg/FjYsdHDv7J) + +## Stay Updated + +Interested in new kernel competitions? Join [discord.gg/gpumode](https://discord.gg/gpumode) and check out the **#announcements** channel to be notified when new challenges drop. +glhf! diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..3497d5f --- /dev/null +++ b/build.rs @@ -0,0 +1,7 @@ +fn main() { + // CI sets CLI_VERSION env var from git tag, otherwise show "dev" + let version = std::env::var("CLI_VERSION") + .map(|v| v.trim_start_matches('v').to_string()) + .unwrap_or_else(|_| "dev".to_string()); + println!("cargo:rustc-env=CLI_VERSION={}", version); +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..b088e49 --- /dev/null +++ b/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/docs/AMD_workshop/README.md b/docs/AMD_workshop/README.md new file mode 100644 index 0000000..82aaab2 --- /dev/null +++ b/docs/AMD_workshop/README.md @@ -0,0 +1,99 @@ +# 🍿 Popcorn CLI - Hackathon Quick Install + +Get started with Popcorn CLI in seconds! Choose your installation method based on your operating system. + +## 🚀 One-Line Install Commands + +### For Linux/macOS/Unix: +```bash +curl -fsSL https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.sh | bash +``` + +### For Windows (PowerShell): +```powershell +powershell -ExecutionPolicy Bypass -Command "iwr -UseBasicParsing https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.ps1 | iex" +``` + +## 📋 Quick Start After Installation + +1. **Restart your terminal** (or run `source ~/.bashrc` / `source ~/.zshrc`) + +2. **Register with GitHub** (one-time setup): + ```bash + popcorn-cli register github + ``` + +## 🏃 Run Examples + +Try out the example implementations to get familiar with the system: + +### For Linux/macOS: +```bash +# Download and test v1.py (reference implementation) +wget https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/docs/AMD_workshop/v1.py +popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test v1.py + +# Download and test v2.py (basic optimization) +wget https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/docs/AMD_workshop/v2.py +popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test v2.py + +# Download and test v3.py (advanced optimization) +wget https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/docs/AMD_workshop/v3.py +popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test v3.py +``` + +### For Windows (PowerShell): +```powershell +# Download and test v1.py (reference implementation) +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/docs/AMD_workshop/v1.py" -OutFile "v1.py" +popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test v1.py + +# Download and test v2.py (basic optimization) +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/docs/AMD_workshop/v2.py" -OutFile "v2.py" +popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test v2.py + +# Download and test v3.py (advanced optimization) +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/docs/AMD_workshop/v3.py" -OutFile "v3.py" +popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test v3.py +``` + +### 💡 Pro Tips: +- Start with **v1.py** (reference implementation) to understand the baseline +- Try **v2.py** for basic optimizations +- Challenge yourself with **v3.py** for advanced Triton optimizations +- Use `--mode benchmark` instead of `--mode test` to see performance metrics + + +## 🛠️ Manual Installation + +If the scripts don't work, you can manually install: + +1. Download the binary for your OS from [releases](https://github.com/gpu-mode/popcorn-cli/releases/latest) +2. Extract the archive +3. Move the binary to a directory in your PATH +4. Make it executable (Linux/macOS): `chmod +x popcorn-cli` + +## 🆘 Troubleshooting + +### Command not found after installation +- Restart your terminal +- Check if the install directory is in your PATH: + - Linux/macOS: `echo $PATH` + - Windows: `echo $env:PATH` +- Check if POPCORN_API_URL is set to https://site--bot--dxfjds728w5v.code.run + - Linux/macOS: `echo $POPCORN_API_URL` + - Windows: `echo $env:POPCORN_API_URL` + +## 💡 Need Help? + +- Run `popcorn-cli --help` for usage information +- Check the [main repository](https://github.com/gpu-mode/popcorn-cli) and open an issue +- Join the [GPU Mode Discord](https://discord.gg/gpumode) and ask a question in #amd-competition + +## 🧑‍🎓 Learn more from our favorite writeups + +* https://github.com/luongthecong123/fp8-quant-matmul +* https://seb-v.github.io/optimization/update/2025/01/20/Fast-GPU-Matrix-multiplication.html +* https://akashkarnatak.github.io/amd-challenge/ +* https://www.bilibili.com/read/cv41954307/?opus_fallback=1 +* https://github.com/Snektron/gpumode-amd-fp8-mm diff --git a/docs/AMD_workshop/load_inline_sample.py b/docs/AMD_workshop/load_inline_sample.py new file mode 100644 index 0000000..aba6d46 --- /dev/null +++ b/docs/AMD_workshop/load_inline_sample.py @@ -0,0 +1,27 @@ +# Credit https://github.com/Snektron/gpumode-amd-fp8-mm/blob/main/fp8_gemm.py +#!POPCORN leaderboard amd-fp8-mm +#!POPCORN gpu MI300 +from task import input_t, output_t +import torch +from torch.utils.cpp_extension import load_inline +import time +import os +import sys + +if "PYTORCH_ROCM_ARCH" not in os.environ: + os.environ["PYTORCH_ROCM_ARCH"] = "gfx942:xnack-" + +TESTING = os.environ.get("GPUMODE_TESTING", None) + +with open('solution.hip', 'r') as f: + kernel_cpp = f.read() + +hip_module = load_inline( + name="fp8", + cpp_sources="", + cuda_sources=kernel_cpp, + with_cuda=True, + verbose=False, + extra_cuda_cflags=(["-save-temps"] if TESTING is not None else []) + ["-std=c++20", "-Werror"], + build_directory="/workspace/build/" if TESTING == "vscode" else "/gpumode/amd/fp8/build/" if TESTING is not None else None, + **({'no_implicit_headers': True} if TESTING != "vscode" else {}), diff --git a/docs/AMD_workshop/v1.py b/docs/AMD_workshop/v1.py new file mode 100644 index 0000000..1b0d847 --- /dev/null +++ b/docs/AMD_workshop/v1.py @@ -0,0 +1,59 @@ +#!POPCORN leaderboard amd-fp8-mm +#!POPCORN gpu MI300 + +import torch +from task import input_t, output_t + +def custom_kernel(data: input_t) -> output_t: + """ + Reference implementation of block-scale fp8 gemm + Args: + data: Tuple that expands to: + a: torch.Tensor[float8_e4m3fnuz] of shape [m, k], + b: torch.Tensor[float8_e4m3fnuz] of shape [n, k], + a_scale: torch.Tensor[float32] of shape [m, k // 128], + b_scale: torch.Tensor[float32] of shape [n // 128, k // 128], + c: torch.Tensor[bfloat16] of shape [m, n] + Returns: + Tensor containing output in bf16 + """ + # c: [m, n] is pre-allocated memory to avoid timing allocation overhead. + a, b, a_scale, b_scale, c = data + + # a is M x K in column-major order, we convert here for simplicity. + a = a.contiguous() + a_scale = a_scale.contiguous() + b_scale = b_scale.contiguous() + + # constants + m = a.shape[0] + n = b.shape[0] + k = a.shape[1] + block_shape_n = 128 + block_shape_k = 128 + scale_n = b_scale.shape[0] + scale_k = b_scale.shape[1] + + # Apply scaling to input 'a' + a_scale = a_scale.unsqueeze(-1).repeat(1, 1, block_shape_k) # Shape: [m, scale_k, block_shape_k] + a_scale = a_scale.reshape(m, scale_k * block_shape_k) + a_scale = a_scale[:, :k] + + # Dequantize 'a', in your implementation you should do this at the end. + a = a.to(a_scale.dtype) * a_scale + + # Apply scaling to input 'b' + b_scale = ( + b_scale.view(-1, 1) + .repeat(1, block_shape_n * block_shape_k) + .view(scale_n, scale_k, block_shape_n, block_shape_k) + .permute(0, 2, 1, 3) # Reorder dimensions: [scale_n, blk_n, scale_k, blk_k] + .reshape(scale_n * block_shape_n, scale_k * block_shape_k) + ) + b_scale = b_scale[:n, :k] + + # Dequantize 'b', in your implementation you should do this at the end. + b = b.to(b_scale.dtype) * b_scale + + c[...] = (a @ b.T).to(torch.bfloat16) + return c \ No newline at end of file diff --git a/docs/AMD_workshop/v2.py b/docs/AMD_workshop/v2.py new file mode 100644 index 0000000..fb10609 --- /dev/null +++ b/docs/AMD_workshop/v2.py @@ -0,0 +1,125 @@ +#!POPCORN leaderboard amd-fp8-mm +#!POPCORN gpu MI300 + +from task import input_t, output_t +import torch +import triton +import triton.language as tl + + +@triton.jit +def kernel( + A_ptr, + B_ptr, + A_scale_ptr, + B_scale_ptr, + C_ptr, + M: tl.constexpr, + N: tl.constexpr, + K: tl.constexpr, + BLOCK_M: tl.constexpr, + BLOCK_N: tl.constexpr, + BLOCK_K: tl.constexpr, + BLOCK_Q: tl.constexpr = 128, +): + program_id = tl.program_id(0) + num_pid_across_n = tl.cdiv(N, BLOCK_N) + + program_id_m = program_id // num_pid_across_n + program_id_n = program_id % num_pid_across_n + + # Simple stride assumptions (no transpose) + A_stride_m, A_stride_k = 1, M + B_stride_n, B_stride_k = 1, N + C_stride_m, C_stride_n = N, 1 + + # Scale matrices: A is 1x128, B is 128x128 chunks + A_scale_stride_m, A_scale_stride_k = 1, M + B_scale_stride_n, B_scale_stride_k = 1, tl.cdiv(N, BLOCK_Q) + + # Calculate output block position + offset_m = program_id_m * BLOCK_M + offset_n = program_id_n * BLOCK_N + + # Create block offset arrays + block_offsets_m = offset_m + tl.arange(0, BLOCK_M) + block_offsets_n = offset_n + tl.arange(0, BLOCK_N) + block_offsets_k = tl.arange(0, BLOCK_K) + + # Create pointers for A and B blocks + A_block_ptrs = A_ptr + ( + block_offsets_m[:, None] * A_stride_m + block_offsets_k[None, :] * A_stride_k + ) + B_block_ptrs = B_ptr + ( + block_offsets_k[:, None] * B_stride_k + block_offsets_n[None, :] * B_stride_n + ) + + # Scale pointers + A_scale_block_ptrs = A_scale_ptr + (block_offsets_m[:, None] * A_scale_stride_m) + B_scale_block_ptrs = B_scale_ptr + (offset_n // BLOCK_Q) * B_scale_stride_n + + # Main accumulator + master_accumulator = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32) + + # Process K dimension in BLOCK_Q chunks (128 elements at a time) + num_k_iters = K // BLOCK_Q + for _ in range(0, num_k_iters): + # Inner accumulator for current 128-element K chunk + inner_accumulator = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32) + + # Process the 128-element chunk in smaller BLOCK_K pieces + for _ in tl.range(0, BLOCK_Q // BLOCK_K): + A_block = tl.load(A_block_ptrs) # (BLOCK_M, BLOCK_K) + B_block = tl.load(B_block_ptrs) # (BLOCK_K, BLOCK_N) + inner_accumulator = tl.dot(A_block, B_block, inner_accumulator) + + # Move to next K chunk + A_block_ptrs += BLOCK_K * A_stride_k + B_block_ptrs += BLOCK_K * B_stride_k + + # Load scales and apply to inner result + A_scales = tl.load(A_scale_block_ptrs) # (BLOCK_M, 1) + B_scales = tl.load(B_scale_block_ptrs) # scalar + master_accumulator += inner_accumulator * (A_scales * B_scales) + + # Move to next scale block + A_scale_block_ptrs += A_scale_stride_k + B_scale_block_ptrs += B_scale_stride_k + + # Store final result + block_offsets_m = (program_id_m * BLOCK_M + tl.arange(0, BLOCK_M)[:, None]) + block_offsets_n = (program_id_n * BLOCK_N + tl.arange(0, BLOCK_N)[None, :]) + mask = (block_offsets_m < M) & (block_offsets_n < N) + C_block_ptrs = C_ptr + (block_offsets_m * C_stride_m + block_offsets_n * C_stride_n) + tl.store(C_block_ptrs, master_accumulator, mask=mask) + + +def custom_kernel(data: input_t) -> output_t: + A_tensor, B_tensor, A_scale_tensor, B_scale_tensor, C_tensor = data + + M, K = A_tensor.shape + N, _ = B_tensor.shape + + # Fixed, simple configuration - no dynamic tuning + BLOCK_M = 64 + BLOCK_N = 64 + BLOCK_K = 32 + + # Launch grid + num_blocks = triton.cdiv(M, BLOCK_M) * triton.cdiv(N, BLOCK_N) + + kernel[(num_blocks,)]( + A_tensor, + B_tensor, + A_scale_tensor, + B_scale_tensor, + C_tensor, + M, N, K, + BLOCK_M=BLOCK_M, + BLOCK_N=BLOCK_N, + BLOCK_K=BLOCK_K, + num_warps=4, + num_stages=2, + ) + + return C_tensor \ No newline at end of file diff --git a/docs/AMD_workshop/v3.py b/docs/AMD_workshop/v3.py new file mode 100644 index 0000000..e0a57d8 --- /dev/null +++ b/docs/AMD_workshop/v3.py @@ -0,0 +1,152 @@ +#!POPCORN leaderboard amd-fp8-mm +#!POPCORN gpu MI300 + +from task import input_t, output_t +import torch +import triton +import triton.language as tl + +NUM_SMS = torch.cuda.get_device_properties("cuda").multi_processor_count + + +@triton.jit +def kernel( + A_ptr, + B_ptr, + A_scale_ptr, + B_scale_ptr, + C_ptr, + M: tl.constexpr, + N: tl.constexpr, + K: tl.constexpr, + BLOCK_M: tl.constexpr, + BLOCK_N: tl.constexpr, + BLOCK_K: tl.constexpr, + BLOCK_Q: tl.constexpr = 128, + TRANSPOSE: tl.constexpr = False, +): + program_id = tl.program_id(0) + num_pid_across_n = tl.cdiv(N, BLOCK_N) + + program_id_m = program_id // num_pid_across_n + program_id_n = program_id % num_pid_across_n + + if not TRANSPOSE: + A_stride_m, A_stride_k = 1, M + B_stride_n, B_stride_k = 1, N + else: + A_stride_m, A_stride_k = K, 1 + B_stride_n, B_stride_k = K, 1 + C_stride_m, C_stride_n = N, 1 + # Scale matrices are stored in column-major order, with A being 1x128 and B being 128x128 chunks + # BLOCK_Q is 128 + A_scale_stride_m, A_scale_stride_k = 1, M + B_scale_stride_n, B_scale_stride_k = 1, tl.cdiv(N, BLOCK_Q) + + # Calculate the row and column indices in the output matrix for the current pid + offset_m = program_id_m * BLOCK_M + offset_n = program_id_n * BLOCK_N + + # Arange to make a row and column ptrs + block_offsets_m = offset_m + tl.arange(0, BLOCK_M) + block_offsets_n = offset_n + tl.arange(0, BLOCK_N) + block_offsets_k = tl.arange(0, BLOCK_K) + + # ptrs for BLOCK_M rows of A and BLOCK_N columns of B + A_block_ptrs = A_ptr + ( + block_offsets_m[:, None] * A_stride_m + block_offsets_k[None, :] * A_stride_k + ) + B_block_ptrs = B_ptr + ( + block_offsets_k[:, None] * B_stride_k + block_offsets_n[None, :] * B_stride_n + ) + # since a_scales are 1x128, a_scale_ptrs need to be of shape (BLOCK_M, 1) + # since N, K <= BLOCK_Q, b_scale_ptrs is always a scalar ptr + A_scale_block_ptrs = A_scale_ptr + (block_offsets_m[:, None] * A_scale_stride_m) + B_scale_block_ptrs = B_scale_ptr + (offset_n // BLOCK_Q) * B_scale_stride_n + + # Initialize accumulator for the currrent pid (responsible for BLOCK_M * BLOCK_N elements) + master_accumulator = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32) + + # In each iteration we we load BLOCK_Q elements from K dimension for BLOCK_M rows, resp. BLOCK_N columns + # We choose this to use only 1 scale per iteration + num_k_iters = K // BLOCK_Q + for _ in range(0, num_k_iters): + # Initialize accumulator for the current k iteration + inner_accumulator = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32) + # In each iteration we load BLOCK_K elements from K dimension for BLOCK_M rows, resp. BLOCK_N columns + # We choose this to use small `tl.dot` for the inner accumulator + for _ in tl.range(0, BLOCK_Q // BLOCK_K): + A_block = tl.load(A_block_ptrs) # (BLOCK_M, BLOCK_K) + B_block = tl.load(B_block_ptrs) # (BLOCK_K, BLOCK_N) + inner_accumulator = tl.dot( + A_block, B_block, inner_accumulator + ) # (BLOCK_M, BLOCK_N) + + # Move along the K dimension of A, B + A_block_ptrs += BLOCK_K * A_stride_k + B_block_ptrs += BLOCK_K * B_stride_k + + A_scales = tl.load(A_scale_block_ptrs) # (BLOCK_M, 1) + B_scales = tl.load(B_scale_block_ptrs) # () + master_accumulator += inner_accumulator * (A_scales * B_scales) + + # Move along the K dimension of A, B scales + A_scale_block_ptrs += A_scale_stride_k + B_scale_block_ptrs += B_scale_stride_k + + # Store the result for the current pid + block_offsets_m = ( + program_id_m * BLOCK_M + tl.arange(0, BLOCK_M)[:, None] + ) # (BLOCK_M, 1) + block_offsets_n = ( + program_id_n * BLOCK_N + tl.arange(0, BLOCK_N)[None, :] + ) # (1, BLOCK_N) + mask = (block_offsets_m < M) & (block_offsets_n < N) # (BLOCK_M, BLOCK_N) + C_block_ptrs = C_ptr + (block_offsets_m * C_stride_m + block_offsets_n * C_stride_n) + tl.store(C_block_ptrs, master_accumulator, mask=mask) + + +@torch.compile(dynamic=False, mode="max-autotune-no-cudagraphs") +def contiguous(x): + return x.contiguous() + + +def get_config(M, N, K): + num_blocks_ref = (M // 128) * (N // 128) + TRANSPOSE = False + matrix_instr_nonkdim = 16 + BLOCK_M, BLOCK_N, BLOCK_K = (128, 128, 64) + if num_blocks_ref * 8 < NUM_SMS: # 2 and 7 + BLOCK_M, BLOCK_N, BLOCK_K = (32, 64, 128) + matrix_instr_nonkdim = 16 + elif num_blocks_ref < NUM_SMS: + BLOCK_M, BLOCK_N, BLOCK_K = (64, 64, 64) + + config = dict( + BLOCK_M=BLOCK_M, + BLOCK_N=BLOCK_N, + BLOCK_K=BLOCK_K, + waves_per_eu=2, + matrix_instr_nonkdim=matrix_instr_nonkdim, + num_warps=4, + num_stages=2, + TRANSPOSE=TRANSPOSE, + ) + return config + + +def custom_kernel(data: input_t) -> output_t: + A_tensor, B_tensor, A_scale_tensor, B_scale_tensor, C_tensor = data + + M, K = A_tensor.shape + N, _ = B_tensor.shape + + # heuristic + config = get_config(M, N, K) + + num_blocks = triton.cdiv(M, config["BLOCK_M"]) * triton.cdiv(N, config["BLOCK_N"]) + kernel[(num_blocks,)]( + A_tensor, B_tensor, A_scale_tensor, B_scale_tensor, C_tensor, M, N, K, **config + ) + + return C_tensor diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md new file mode 100644 index 0000000..1aaaedc --- /dev/null +++ b/docs/helion-hackathon.md @@ -0,0 +1,363 @@ +# Helion Kernel Challenge + +Run all the below instructions on your local laptop, you don't need to login to a GPU to make submissions to KernelBot. + +Submit [Helion](https://github.com/pytorch/helion) kernels to the GPU MODE leaderboard on B200 GPUs. The challenge has 5 problems based on production LLM kernel patterns. + +**Deadline:** March 14, 2026 + +**GPU:** B200_Nebius + +## Problems + +| # | Leaderboard Name | Description | +|---|-----------------|-------------| +| 1 | `fp8_quant` | Per-token-group FP8 E4M3 quantization (DeepSeek-V3, Llama 3, Qwen3) | +| 2 | `causal_conv1d` | Causal depthwise 1D convolution (Mamba/Mamba-2) | +| 3 | `gated_deltanet_chunk_fwd_h` | Inter-chunk state recurrence for Gated DeltaNet | +| 4 | `gated_deltanet_chunk_fwd_o` | Output computation for Gated DeltaNet | +| 5 | `gated_deltanet_recompute_w_u` | WY-transform forward kernel for Gated DeltaNet | + +## Scoring + +Each scored problem awards points to the **top 3** fastest correct submissions: + +| Place | Points | +|---|---| +| 1st | 5 | +| 2nd | 3 | +| 3rd | 1 | + +> **Note:** Problem 1 (`fp8_quant`) is **not scored** — it is a warm-up problem only. Points are awarded for problems 2–5. + +- **Performance Metric**: For each benchmark shape, the kernel is captured in a CUDA graph and replayed with L2 cache clearing before each invocation. The graph unrolls enough calls to fill ~100ms of GPU time, and this is repeated 10 times. The runtime is the arithmetic mean of those 10 measurements. +- **Correctness**: Submissions must pass all test input shapes to be eligible for points. +- **Tiebreaker**: If two participants have the same metric value, judges will decide based on the quality of the kernel. +- **Test case shapes**: Provided in `task.yml`; input data sampled from a random distribution. + +**Total score** = Sum of points across problems 2–5 (max 20). + +## Rules & Requirements + +- Kernel must pass all test input shapes (numerical accuracy within tolerance) with participant-provided config +- All benchmark shapes must have their best configs submitted for that kernel to be scored +- Implementations must use Helion DSL. `hl.inline_triton()`, `hl.triton_kernel()`, and `hl.inline_asm_elementwise()` are allowed as escape hatches, but the majority of your kernel should be written in Helion. Submissions that are predominantly inline Triton/ASM with a thin Helion wrapper may be disqualified at judges' discretion +- Unlimited submissions per participant per kernel. Only your best submission counts. Each submission should include: your Helion kernel implementation, one config per test input shape, and one best autotuned config per benchmark input shape + +## Quick Start + +```bash +# 1. Install popcorn-cli +curl -fsSL https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.sh | bash + +# 2. Register +popcorn register discord +# Please click on the link to authenticate on Discord + +# 3. Join the challenge with your invite code +popcorn join + +# 4. Setup a project (downloads the submission template for you) +popcorn setup +# Select "Helion Kernel Challenge", then pick a problem and GPU +``` + +`popcorn setup` fetches the latest problems from reference-kernels, creates a project folder named after the selected problem (e.g. `causal_conv1d_py/`), downloads the submission template with `#!POPCORN` directives pre-filled, and scaffolds agent skills for Codex/Claude Code. If a folder with that name already exists, a `-N` suffix is appended (e.g. `causal_conv1d_py-1/`). + +Alternatively, you can clone the full reference-kernels repo to browse all problems locally: + +```bash +git clone https://github.com/gpu-mode/reference-kernels.git +cd reference-kernels/problems/helion +``` + +Each problem directory (e.g. `causal_conv1d_py/`) contains: +- `reference.py` -- the reference implementation to beat +- `submission.py` -- your starting point +- `task.py` -- type definitions (`input_t`, `output_t`) +- `task.yml` -- input shapes, test cases, and benchmark configs + +## Testing Locally + +You can test and benchmark your submissions locally on your own GPU without submitting to KernelBot. This is useful for fast iteration during development. + +From the `reference-kernels/problems/helion` directory, run: + +```bash +# Correctness test (validates your kernel via CUDA graph capture) +python eval.py test causal_conv1d_py/ + +# Benchmark (measures kernel performance with L2 cache flushing) +python eval.py benchmark causal_conv1d_py/ + +# Both test and benchmark in one go +python eval.py both causal_conv1d_py/ + +# Profile (generates PyTorch profiler trace) +python eval.py profile causal_conv1d_py/ +``` + +Replace `causal_conv1d_py/` with any problem directory. + +## Writing a Helion Submission + +Your submission must be a single Python file that defines `custom_kernel(data: input_t) -> output_t`. To use Helion, write a `@helion.kernel` decorated function and call it from `custom_kernel`. + +Use **per-shape configs** to optimize for each benchmark shape independently. The per-shape config pattern uses a factory function to create kernel variants with different configs, and dispatches based on input tensor shapes: + +```python +from task import input_t, output_t +import torch +import helion +import helion.language as hl + +# Map input shapes to optimized configs (autotune each shape locally). +# Include all test and benchmark shapes from task.yml. +SHAPE_CONFIGS: dict[tuple, helion.Config] = { + # Test shapes + (1, 64, 64, 4): helion.Config(...), # TODO: replace with default config or any config that passes correctness check + (2, 128, 128, 4): helion.Config(...), # TODO: replace with default config or any config that passes correctness check + # ... one entry per test shape + # Benchmark shapes + (1, 768, 512, 4): helion.Config(...), # TODO: replace with your autotuned config + (1, 768, 2048, 4): helion.Config(...), # TODO: replace with your autotuned config + # ... one entry per benchmark shape +} + + +def _make_kernel(config: helion.Config): + @helion.kernel(static_shapes=True, config=config) + def causal_conv1d_kernel(x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor) -> torch.Tensor: + # Your Helion kernel implementation + ... + + return causal_conv1d_kernel + + +_KERNELS = {shape: _make_kernel(cfg) for shape, cfg in SHAPE_CONFIGS.items()} + + +def custom_kernel(data: input_t) -> output_t: + x, weight, bias = data + B, D, S = x.shape + W = weight.shape[1] + kernel = _KERNELS[(B, D, S, W)] + return kernel(x, weight, bias) +``` + +## Do NOT Autotune on KernelBot + +When submitting to KernelBot, you must hardcode configs in your `@helion.kernel` decorator. Do **not** rely on Helion's autotuner at submission time. + +KernelBot runs your submission on shared infrastructure with timeouts. If your kernel triggers autotuning (which can take 10+ minutes and hundreds of trial runs), your submission will time out and fail. + +### Getting a default config (no autotuning) + +During early development, you can use `autotune_effort="none"` to skip autotuning and use Helion's default config. When you run the kernel, Helion prints the default config to stderr: + +``` +Using default config: @helion.kernel(config=helion.Config(block_sizes=[64, 64], num_warps=4, num_stages=1), static_shapes=True) +``` + +Copy the `helion.Config(...)` portion into your `SHAPE_CONFIGS` dict. The default config is usually good enough for test input shapes to pass correctness checks, but won't be competitive for benchmark shapes on the leaderboard. + +### Autotuning for benchmark shapes + +1. **Autotune locally on your Nebius-provided B200 compute.** Run your Helion kernel without a fixed config (or with `autotune_effort="quick"`) to find the best configuration for each benchmark shape. + +2. **Copy the best config** from the autotuner output. When autotuning completes, Helion prints: + ``` + One can hardcode the best config and skip autotuning with: + @helion.kernel(config=helion.Config(block_sizes=[64, 64, 64], num_warps=8, num_stages=3)) + ``` + +3. **Hardcode the config in your submission.** Copy the `helion.Config(...)` from step 2 into the corresponding benchmark shape entry in `SHAPE_CONFIGS`. Repeat steps 1-3 for each benchmark shape in `task.yml`. + +4. **Submit the file** with the hardcoded configs to KernelBot. + +## Submitting All 5 Problems + +### Test first, then submit to leaderboard + +```bash +# Test each problem (quick correctness check) +popcorn submit causal_conv1d_py/submission.py --gpu B200_Nebius --leaderboard causal_conv1d --mode test --no-tui +popcorn submit fp8_quant_py/submission.py --gpu B200_Nebius --leaderboard fp8_quant --mode test --no-tui +popcorn submit gated_deltanet_chunk_fwd_h_py/submission.py --gpu B200_Nebius --leaderboard gated_deltanet_chunk_fwd_h --mode test --no-tui +popcorn submit gated_deltanet_chunk_fwd_o_py/submission.py --gpu B200_Nebius --leaderboard gated_deltanet_chunk_fwd_o --mode test --no-tui +popcorn submit gated_deltanet_recompute_w_u_py/submission.py --gpu B200_Nebius --leaderboard gated_deltanet_recompute_w_u --mode test --no-tui + +# Benchmark (see your perf without affecting the leaderboard) +popcorn submit causal_conv1d_py/submission.py --gpu B200_Nebius --leaderboard causal_conv1d --mode benchmark --no-tui + +# Official leaderboard submission +popcorn submit causal_conv1d_py/submission.py --gpu B200_Nebius --leaderboard causal_conv1d --mode leaderboard --no-tui +popcorn submit fp8_quant_py/submission.py --gpu B200_Nebius --leaderboard fp8_quant --mode leaderboard --no-tui +popcorn submit gated_deltanet_chunk_fwd_h_py/submission.py --gpu B200_Nebius --leaderboard gated_deltanet_chunk_fwd_h --mode leaderboard --no-tui +popcorn submit gated_deltanet_chunk_fwd_o_py/submission.py --gpu B200_Nebius --leaderboard gated_deltanet_chunk_fwd_o --mode leaderboard --no-tui +popcorn submit gated_deltanet_recompute_w_u_py/submission.py --gpu B200_Nebius --leaderboard gated_deltanet_recompute_w_u --mode leaderboard --no-tui +``` + +### Using file directives + +You can also embed the leaderboard and GPU in your submission file so you don't need CLI flags: + +```python +#!POPCORN leaderboard causal_conv1d +#!POPCORN gpu B200_Nebius + +from task import input_t, output_t +import torch +import helion +import helion.language as hl + +@helion.kernel(config=helion.Config(...)) +def causal_conv1d_kernel(...): + ... + +def custom_kernel(data: input_t) -> output_t: + ... +``` + +Then submit with just: +```bash +popcorn submit causal_conv1d_py/submission.py +``` + +## Profiling + +Nsight Compute profiling is available for Helion problems. Use `--mode profile` to get detailed GPU metrics: + +```bash +popcorn submit causal_conv1d_py/submission.py --gpu B200_Nebius --leaderboard causal_conv1d --mode profile --no-tui +``` + +This returns GPU throughput, pipe utilization, and warp stall metrics, plus a downloadable `.ncu-rep` trace file you can open in the Nsight Compute GUI. See [profiling.md](profiling.md) for details on interpreting the output. + +## Optional: Extra Performance Knobs + +The sections below describe two **optional** techniques that can squeeze extra performance out of your kernels. Neither is required — you can place on the leaderboard without them. Try them after you have a working kernel with a tuned config. + +### ACF Files + +Each B200 instance has pre-tuned **PTXAS Advanced Controls Files (ACFs)** at `/opt/booster_pack/`. ACFs are low-level NVIDIA PTX assembler configurations that can improve performance beyond what Helion's standard autotuner finds. Available files: + +``` +/opt/booster_pack/ +├── causal_conv_*.acf (3 files) +├── chunk_fwd_h_*.acf (2 files) +├── chunk_fwd_o_*.acf (7 files) +├── fp8_group_quant_*.acf (7 files) +└── recompute_w_u_fwd_*.acf (5 files) +``` + +**Step 1: Autotune with ACFs.** Pass `autotune_search_acf` to include ACFs in the search space. Helion tries each ACF file (plus the default `-O3` baseline) as another tunable parameter: + +```python +from pathlib import Path + +acf_files = sorted(str(p) for p in Path("/opt/booster_pack").glob("causal_conv_*.acf")) + +@helion.kernel( + static_shapes=True, + autotune_search_acf=acf_files, +) +def my_kernel(...): + ... +``` + +> **Note:** `autotune_search_acf` only takes effect when the autotuner actually runs. It is ignored with `autotune_effort="none"` or a fixed `config=`. + +**Step 2: Hardcode the best ACF in your submission.** After autotuning, look for the `advanced_controls_file` field in the best config and copy it: + +```python +@helion.kernel(config=helion.Config( + advanced_controls_file="/opt/booster_pack/causal_conv_0.acf", + block_sizes=[1, 512], + num_warps=4, + num_stages=3, + # ... rest of your tuned config +)) +def my_kernel(...): + ... +``` + +### TileIR Backend + +The B200 instances also ship with **nvtriton**, NVIDIA's extended Triton compiler that includes a **TileIR** backend — an alternative compilation pipeline that bypasses LLVM and compiles directly to CUBIN via NVIDIA's `tileiras` compiler. + +| | `ENABLE_TILE=0` (default) | `ENABLE_TILE=1` + `HELION_BACKEND=tileir` | +|---|---|---| +| **Helion backend** | `triton` | `tileir` | + +**Step 1: Enable TileIR and autotune.** Set both `ENABLE_TILE=1` and `HELION_BACKEND=tileir` env vars before importing Helion, then autotune as usual. Helion automatically adjusts the search space for the TileIR backend. + +**Step 2: Hardcode the TileIR config in your submission.** Copy the best config from the autotuner output (it will include TileIR-specific fields like `num_ctas` and `occupancy`). The env vars must be set before imports: + +```python +import os +os.environ["ENABLE_TILE"] = "1" +os.environ["HELION_BACKEND"] = "tileir" + +import helion # must be imported after setting env vars +import helion.language as hl + +@helion.kernel(config=helion.Config( + block_sizes=[64, 64], + num_ctas=1, + num_stages=5, + occupancy=4, + # ... rest of your tuned config +)) +def my_kernel(...): + ... +``` + +### Which should I use? + +Try both the default backend (`ENABLE_TILE=0`) and the TileIR backend (`ENABLE_TILE=1` + `HELION_BACKEND=tileir`), with and without ACFs, then submit whichever gives the best benchmark numbers. + +## Tips + +- **Iterate locally first.** Use your Nebius B200 to develop and autotune. Only submit to KernelBot once you have a hardcoded config that works. +- **Check the reference.** Each `reference.py` shows the baseline implementation you're trying to beat. Understanding it helps you write a better kernel. +- **Use `--mode test` first.** Verify correctness before submitting to the leaderboard. This saves time and leaderboard quota. +- **Profile your kernels.** Use `--mode profile` to get Nsight Compute metrics and identify bottlenecks. +- **One config per shape.** Use the per-shape config pattern to provide an optimized config for each benchmark shape in `task.yml`. + +## Working on Your GPU Machine + +- **Use a GitHub repo for your kernels.** Push your work to a private GitHub repo so you don't lose progress if the GPU machine goes offline or loses data. +- **Use tmux for autotuning.** Autotuning can take a long time. Run it inside a `tmux` session so it survives SSH disconnections. +- **Use spawn mode for autotuning if you hit issues.** By default, Helion's autotuner uses `fork` mode for precompilation, which is faster but can hang or crash if a bad config corrupts process state. If that happens, switch to `spawn` mode, which runs each trial in an isolated subprocess with timeout protection — slower due to subprocess overhead, but one bad config can't take down your entire autotuning run. Enable it via environment variable or decorator: + ```bash + export HELION_AUTOTUNE_PRECOMPILE=spawn + ``` + ```python + @helion.kernel(autotune_precompile="spawn") + def my_kernel(...): + ... + ``` + You can also control parallelism with `HELION_AUTOTUNE_PRECOMPILE_JOBS` (defaults to CPU count). +- **Machine frozen or crashed?** If your GPU machine becomes unresponsive and needs a reboot, let us know and we can reboot it for you. + +## Open-Ended Contribution Track + +In addition to the kernel competition, there is a separate open-ended contribution track. Participants can earn recognition and prizes for contributions to Helion beyond kernel implementations. This track is scored independently and does not affect kernel competition standings. Examples: + +| Contribution Type | Description | +|---|---| +| Autotuner Improvements | Enhancements to Helion's autotuning system | +| Bug Fixes | Bug fixes in Helion | +| Tooling/Infrastructure | Improvements to debugging, profiling, or developer experience | +| Documentation | Significant documentation contributions | +| Other Novel Contributions | Other impactful contributions at judges' discretion | + +Contributions are uncapped and evaluated by a panel of judges based on impact and quality. Prizes for this track are awarded separately from the kernel competition. + +## Resources + +- [Helion Documentation](https://helionlang.com) +- [Helion GitHub](https://github.com/pytorch/helion) +- [Reference Kernels](https://github.com/gpu-mode/reference-kernels/tree/main/problems/helion) +- [GPU MODE Discord](https://discord.gg/gpumode) diff --git a/docs/princeton2026.md b/docs/princeton2026.md new file mode 100644 index 0000000..d14260f --- /dev/null +++ b/docs/princeton2026.md @@ -0,0 +1,29 @@ +# Princeton 2026 Quick Start + +Use `A100` and this leaderboard: `princeton_cross_entropy`. + +```bash +# 1. Install the CLI +curl -fsSL https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.sh | bash + +# 2. Register once with GitHub +popcorn register github + +# 3. Join the closed leaderboard with your invite code +popcorn join + +# 4. Get the starter file +wget https://raw.githubusercontent.com/gpu-mode/reference-kernels/main/problems/princeton/cross_entropy_py/submission.py + +# 5. Run a correctness check +popcorn submit --leaderboard princeton_cross_entropy --gpu A100 --mode test submission.py + +# 6. Submit an official ranked run +popcorn submit --leaderboard princeton_cross_entropy --gpu A100 --mode leaderboard submission.py +``` + +Notes: + +- `test` checks correctness only. +- `leaderboard` is the official ranked submission. +- If registration gets stuck, run `popcorn reregister github`. diff --git a/docs/profiling.md b/docs/profiling.md new file mode 100644 index 0000000..45601c6 --- /dev/null +++ b/docs/profiling.md @@ -0,0 +1,65 @@ +# Nsight Compute Profiling + +Profile your kernels directly from the CLI and get detailed Nsight Compute metrics. This is particularly useful for the NVIDIA NVFP4 Blackwell competition where you need to optimize tensor core utilization. + +**Note:** Profiling is currently only available for the NVFP4 Blackwell competition. Modal, which we use for other competitions, does not support NCU. + +## Quick Start + +```bash +popcorn-cli submit submission.py --leaderboard nvfp4_dual_gemm --gpu NVIDIA --mode profile --no-tui +``` + +## Expected Output + +The profiler returns three key metric tables for each benchmark: + +**GPU Throughput** - Overall utilization: +``` +Metric Name Metric Unit Metric Value +---------------- ----------- ------------ +Memory [%] % 32.48 +Compute (SM) [%] % 13.23 +``` + +**Pipe Utilization** - Which pipelines are active: +``` +Metric Name Metric Unit Metric Value +-------------------- ----------- ------------ +TC % 16.67 +TMEM (Tensor Memory) % 15.27 +Tensor (FP) % 12.58 +ALU % 2.38 +TMA % 0.29 +``` + +**Warp State** - Where your warps are stalling: +``` +Metric Name Metric Unit Metric Value +------------------------ ----------- ------------ +Stall Long Scoreboard inst 18.31 +Stall Wait inst 1.88 +Stall Short Scoreboard inst 1.23 +Selected inst 1.00 +Stall Barrier inst 0.75 +``` + +## Trace Files + +After profiling, a zip file is saved to your current directory: +``` +profile_20260113_031052_result0_profile0.zip +``` + +This contains a `.ncu-rep` file (the full Nsight Compute report): +``` +$ unzip -l profile_20260113_031052_result0_profile0.zip + Length Date Time Name +--------- ---------- ----- ---- + 2178383 01-13-2026 03:10 profile.ncu-rep +``` + +You can open this file in the Nsight Compute GUI for detailed analysis: +```bash +ncu-ui profile.ncu-rep +``` diff --git a/go.mod b/go.mod deleted file mode 100644 index 5303f73..0000000 --- a/go.mod +++ /dev/null @@ -1,29 +0,0 @@ -module github.com/S1ro1/popcorn-cli - -go 1.21 - -require ( - github.com/charmbracelet/bubbles v0.18.0 - github.com/charmbracelet/bubbletea v0.25.0 - github.com/charmbracelet/lipgloss v0.9.1 -) - -require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.18 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.2 // indirect - github.com/rivo/uniseg v0.4.6 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.12.0 // indirect - golang.org/x/term v0.6.0 // indirect - golang.org/x/text v0.3.8 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index e44c6a2..0000000 --- a/go.sum +++ /dev/null @@ -1,47 +0,0 @@ -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= -github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= -github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= -github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= -github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= -github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= -github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..0a1e41a --- /dev/null +++ b/install.ps1 @@ -0,0 +1,119 @@ +# Popcorn CLI Hackathon Installer for Windows +# Run with: powershell -ExecutionPolicy Bypass -File install.ps1 + +param( + [switch]$Force = $false +) + +Write-Host "Installing Popcorn CLI for Hackathon (Windows)..." -ForegroundColor Yellow + +# Check if running as administrator (optional but recommended) +$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { + Write-Host "Not running as administrator. Installation will be user-scoped." -ForegroundColor Yellow +} + +# Set variables +$downloadUrl = "https://github.com/gpu-mode/popcorn-cli/releases/latest/download/popcorn-cli-windows.zip" +$tempDir = "$env:TEMP\popcorn-cli-install" +$installDir = "$env:LOCALAPPDATA\popcorn-cli" +$binaryPath = "$installDir\popcorn-cli.exe" + +# Create directories +try { + if (Test-Path $tempDir) { + Remove-Item $tempDir -Recurse -Force + } + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + Write-Host "Created installation directories" -ForegroundColor Green +} catch { + Write-Host "Failed to create directories: $_" -ForegroundColor Red + exit 1 +} + +# Download the binary +Write-Host "Downloading from: $downloadUrl" -ForegroundColor Cyan +try { + $zipPath = "$tempDir\popcorn-cli-windows.zip" + Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath -UseBasicParsing + Write-Host "Download completed" -ForegroundColor Green +} catch { + Write-Host "Download failed: $_" -ForegroundColor Red + exit 1 +} + +# Extract the binary +Write-Host "Extracting binary..." -ForegroundColor Cyan +try { + Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force + + # Find the binary (it might be in a subdirectory) + $binarySource = Get-ChildItem -Path $tempDir -Name "popcorn-cli.exe" -Recurse | Select-Object -First 1 + if ($binarySource) { + $fullBinaryPath = Join-Path $tempDir $binarySource + Copy-Item $fullBinaryPath $binaryPath -Force + Write-Host "Binary extracted and copied" -ForegroundColor Green + } else { + Write-Host "popcorn-cli.exe not found in archive" -ForegroundColor Red + exit 1 + } +} catch { + Write-Host "Extraction failed: $_" -ForegroundColor Red + exit 1 +} + +# Add to PATH +Write-Host "Adding to PATH..." -ForegroundColor Cyan +try { + $userPath = [Environment]::GetEnvironmentVariable("PATH", "User") + if ($userPath -notlike "*$installDir*") { + $newPath = "$installDir;$userPath" + [Environment]::SetEnvironmentVariable("PATH", $newPath, "User") + Write-Host "Added $installDir to user PATH" -ForegroundColor Green + Write-Host "Please restart your terminal or PowerShell session" -ForegroundColor Yellow + } else { + Write-Host "$installDir already in PATH" -ForegroundColor Green + } + + # Also add to current session + $env:PATH = "$installDir;$env:PATH" +} catch { + Write-Host "Could not modify PATH automatically: $_" -ForegroundColor Yellow + Write-Host "Please manually add $installDir to your PATH" -ForegroundColor Yellow +} + +# Cleanup +Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue + +# Test installation +Write-Host "Testing installation..." -ForegroundColor Cyan +try { + $version = & $binaryPath --version 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host "Installation successful!" -ForegroundColor Green + } else { + Write-Host "Binary installed but may not be working correctly" -ForegroundColor Yellow + } +} catch { + Write-Host "Could not test binary: $_" -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "Popcorn CLI installed and ready for hackathon!" -ForegroundColor Green +Write-Host "" +Write-Host "Quick Start:" -ForegroundColor Cyan +Write-Host " 1. Restart your terminal/PowerShell" -ForegroundColor White +Write-Host " 2. Register with GitHub: popcorn-cli register github" -ForegroundColor White +Write-Host " 3. Submit your solution: popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test " -ForegroundColor White +Write-Host "" +Write-Host "Hackathon mode features:" -ForegroundColor Cyan +Write-Host " - API URL pre-configured" -ForegroundColor White +Write-Host " - GitHub authentication (no Discord setup needed)" -ForegroundColor White +Write-Host " - All modes available: test, benchmark, leaderboard, profile" -ForegroundColor White +Write-Host " - Clean user identification" -ForegroundColor White +Write-Host "" +Write-Host "Need help? Run: popcorn-cli --help" -ForegroundColor White +Write-Host "Example: popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test example.py" -ForegroundColor White +Write-Host "" +Write-Host "Installation location: $installDir" -ForegroundColor Gray \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..0d61d61 --- /dev/null +++ b/install.sh @@ -0,0 +1,157 @@ +#!/bin/bash + +set -e + +# Popcorn CLI Hackathon Installer (Unix/Linux/macOS) +# For Windows users: Use install.ps1 instead +echo "🍿 Installing Popcorn CLI for Hackathon (Unix/Linux/macOS)..." + +# Check if we're on Windows +if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]] || [[ "$OSTYPE" == "cygwin" ]]; then + echo "⚠️ Detected Windows environment" + echo "For native Windows, please use install.ps1 instead:" + echo " powershell -ExecutionPolicy Bypass -File install.ps1" + echo "" + echo "This script will continue assuming you're in a Unix-like environment (WSL/Git Bash/MSYS2)" + read -p "Continue? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 0 + fi +fi + +# Detect OS +OS="" +ARCH="" +BINARY_NAME="" +SYMLINK_NAME="" +EXTENSION="" + +if [[ "$OSTYPE" == "linux-gnu"* ]]; then + ARCH=$(uname -m) + if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then + OS="linux-aarch64" + else + OS="linux" + fi + EXTENSION=".tar.gz" + BINARY_NAME="popcorn-cli" + SYMLINK_NAME="popcorn" +elif [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" + EXTENSION=".tar.gz" + BINARY_NAME="popcorn-cli" + SYMLINK_NAME="popcorn" +elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]] || [[ "$OSTYPE" == "cygwin" ]]; then + OS="windows" + EXTENSION=".zip" + BINARY_NAME="popcorn-cli.exe" + SYMLINK_NAME="popcorn.exe" +else + echo "❌ Unsupported operating system: $OSTYPE" + exit 1 +fi + +echo "✅ Detected OS: $OS ($(uname -m))" + +# Download URL +DOWNLOAD_URL="https://github.com/gpu-mode/popcorn-cli/releases/latest/download/popcorn-cli-${OS}${EXTENSION}" +TEMP_DIR="/tmp/popcorn-cli-install" +INSTALL_DIR="$HOME/.local/bin" + +# Create directories +mkdir -p "$TEMP_DIR" +mkdir -p "$INSTALL_DIR" + +echo "📥 Downloading from: $DOWNLOAD_URL" + +# Download the binary +if command -v curl >/dev/null 2>&1; then + curl -L -o "$TEMP_DIR/popcorn-cli${EXTENSION}" "$DOWNLOAD_URL" +elif command -v wget >/dev/null 2>&1; then + wget -O "$TEMP_DIR/popcorn-cli${EXTENSION}" "$DOWNLOAD_URL" +else + echo "❌ Neither curl nor wget found. Please install one of them." + exit 1 +fi + +echo "📦 Extracting binary..." + +# Extract the binary +cd "$TEMP_DIR" +if [[ "$EXTENSION" == ".tar.gz" ]]; then + tar -xzf "popcorn-cli${EXTENSION}" +elif [[ "$EXTENSION" == ".zip" ]]; then + unzip "popcorn-cli${EXTENSION}" +fi + +# Find and move the binary +if [[ -f "$BINARY_NAME" ]]; then + chmod +x "$BINARY_NAME" + mv "$BINARY_NAME" "$INSTALL_DIR/" + echo "✅ Binary installed to $INSTALL_DIR/$BINARY_NAME" + # Create 'popcorn' alias so users can run 'popcorn' instead of 'popcorn-cli' + if [[ -n "$SYMLINK_NAME" ]]; then + if [[ "$OS" == "windows" ]]; then + cp "$INSTALL_DIR/$BINARY_NAME" "$INSTALL_DIR/$SYMLINK_NAME" + else + ln -sf "$INSTALL_DIR/$BINARY_NAME" "$INSTALL_DIR/$SYMLINK_NAME" + fi + echo "✅ Created alias: $SYMLINK_NAME -> $BINARY_NAME" + fi +else + echo "❌ Binary not found after extraction" + exit 1 +fi + +# Add to PATH +SHELL_RC="" +if [[ -n "$ZSH_VERSION" ]]; then + SHELL_RC="$HOME/.zshrc" +elif [[ -n "$BASH_VERSION" ]]; then + SHELL_RC="$HOME/.bashrc" +else + # Try to detect shell + case "$SHELL" in + */zsh) + SHELL_RC="$HOME/.zshrc" + ;; + */bash) + SHELL_RC="$HOME/.bashrc" + ;; + *) + SHELL_RC="$HOME/.profile" + ;; + esac +fi + +# Check if PATH already contains the directory +if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + echo "🔧 Adding $INSTALL_DIR to PATH in $SHELL_RC" + echo "" >> "$SHELL_RC" + echo "# Added by Popcorn CLI installer" >> "$SHELL_RC" + echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$SHELL_RC" + export PATH="$INSTALL_DIR:$PATH" +else + echo "✅ $INSTALL_DIR already in PATH" +fi + +# Cleanup +rm -rf "$TEMP_DIR" + +echo "" +echo "🎉 Popcorn CLI installed and ready for hackathon!" +echo "" +echo "📋 Quick Start:" +echo " 1. Restart your terminal or run: source $SHELL_RC" +echo " 2. Register with GitHub: popcorn-cli register github" +echo " 3. Submit your solution: popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test " +echo "" +echo "🚀 Hackathon mode features:" +echo " - ✅ API URL pre-configured" +echo " - ✅ GitHub authentication (no Discord setup needed)" +echo " - ✅ All modes available: test, benchmark, leaderboard, profile" +echo " - ✅ Clean user identification" +echo "" +echo "💡 Need help? Run: popcorn-cli --help" +echo "🔗 Example: popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test example.py" diff --git a/main.go b/main.go deleted file mode 100644 index a0e9593..0000000 --- a/main.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/S1ro1/popcorn-cli/src/cmd" -) - -func main() { - _, ok := os.LookupEnv("POPCORN_API_URL") - if !ok { - fmt.Println("POPCORN_API_URL is not set. Please set it to the URL of the Popcorn API.") - os.Exit(1) - } - cmd.Execute() -} diff --git a/src/cmd/admin.rs b/src/cmd/admin.rs new file mode 100644 index 0000000..f2c2457 --- /dev/null +++ b/src/cmd/admin.rs @@ -0,0 +1,294 @@ +use anyhow::{anyhow, Result}; +use clap::Subcommand; +use std::env; + +use crate::service; + +#[derive(Subcommand, Debug)] +pub enum AdminAction { + /// Start accepting jobs on the server + Start, + /// Stop accepting jobs on the server + Stop, + /// Get server statistics + Stats { + /// Only show stats for the last 24 hours + #[arg(long)] + last_day: bool, + }, + /// Get a submission by ID + GetSubmission { + /// The submission ID to retrieve + id: i64, + }, + /// Delete a submission by ID + DeleteSubmission { + /// The submission ID to delete + id: i64, + }, + /// Create a dev leaderboard from a problem directory (requires gpus in task.yml) + CreateLeaderboard { + /// Problem directory name (e.g., "identity_py") + directory: String, + }, + /// Delete a leaderboard + DeleteLeaderboard { + /// Name of the leaderboard to delete + name: String, + /// Force deletion even if there are submissions + #[arg(long)] + force: bool, + }, + /// Generate invite codes for leaderboard(s) + GenerateInvites { + /// Leaderboard names to grant access to + #[arg(long, required = true, num_args = 1..)] + leaderboards: Vec, + + /// Number of invite codes to generate (1-10000) + #[arg(long, default_value = "1")] + count: u32, + }, + /// List invite codes for a leaderboard + ListInvites { + /// Leaderboard name + leaderboard: String, + }, + /// Revoke an invite code + RevokeInvite { + /// The invite code to revoke + code: String, + }, + /// Update problems from a GitHub repository (mirrors Discord /admin update-problems) + UpdateProblems { + /// Problem set name (e.g., "nvidia", "pmpp_v2"). If not specified, updates all. + #[arg(long)] + problem_set: Option, + + /// Repository in format "owner/repo" (default: gpu-mode/reference-kernels) + #[arg(long, default_value = "gpu-mode/reference-kernels")] + repository: String, + + /// Branch to pull from (default: main) + #[arg(long, default_value = "main")] + branch: String, + + /// Force update even if task definition changed significantly + #[arg(long)] + force: bool, + + /// Set leaderboard visibility to closed (requires invite to access) + #[arg(long)] + closed: bool, + }, +} + +fn get_admin_token() -> Result { + env::var("POPCORN_ADMIN_TOKEN").map_err(|_| { + anyhow!( + "POPCORN_ADMIN_TOKEN environment variable is not set.\n\ + Set it to your admin token to use admin commands:\n\ + export POPCORN_ADMIN_TOKEN=your_token_here" + ) + }) +} + +pub async fn handle_admin(action: AdminAction) -> Result<()> { + let admin_token = get_admin_token()?; + let client = service::create_admin_client(&admin_token)?; + + match action { + AdminAction::Start => { + let result = service::admin_start(&client).await?; + println!("Server started accepting jobs"); + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::Stop => { + let result = service::admin_stop(&client).await?; + println!("Server stopped accepting jobs"); + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::Stats { last_day } => { + let result = service::admin_stats(&client, last_day).await?; + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::GetSubmission { id } => { + let result = service::admin_get_submission(&client, id).await?; + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::DeleteSubmission { id } => { + let result = service::admin_delete_submission(&client, id).await?; + println!("Deleted submission {}", id); + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::CreateLeaderboard { directory } => { + let result = service::admin_create_leaderboard(&client, &directory).await?; + let name = result["leaderboard"].as_str().unwrap_or(&directory); + println!("Created leaderboard '{}'", name); + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::DeleteLeaderboard { name, force } => { + let result = service::admin_delete_leaderboard(&client, &name, force).await?; + println!("Deleted leaderboard '{}'", name); + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::GenerateInvites { + leaderboards, + count, + } => { + let result = service::admin_generate_invites(&client, &leaderboards, count).await?; + let codes = result["codes"].as_array().map(|arr| arr.len()).unwrap_or(0); + let lbs = result["leaderboards"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + println!("Generated {} invite code(s) for: {}", codes, lbs); + if let Some(arr) = result["codes"].as_array() { + for code in arr { + println!(" {}", code.as_str().unwrap_or("???")); + } + } + } + AdminAction::ListInvites { leaderboard } => { + let result = service::admin_list_invites(&client, &leaderboard).await?; + let invites = result["invites"].as_array(); + match invites { + Some(arr) if arr.is_empty() => { + println!("No invites for '{}'", leaderboard); + } + Some(arr) => { + let claimed = arr + .iter() + .filter(|i| i["user_id"].as_str().is_some()) + .count(); + println!( + "Invites for '{}': {} total, {} claimed, {} unclaimed\n", + leaderboard, + arr.len(), + claimed, + arr.len() - claimed, + ); + let header = format!( + "{:<26} {:<16} {:<20} {}", + "CODE", "STATUS", "CLAIMED BY", "CREATED" + ); + println!("{header}"); + println!("{}", "-".repeat(82)); + for invite in arr { + let code = invite["code"].as_str().unwrap_or("???"); + let user = invite["user_name"] + .as_str() + .or_else(|| invite["user_id"].as_str()); + let status = if user.is_some() { + "claimed" + } else { + "unclaimed" + }; + let user_display = user.unwrap_or("-"); + let created = invite["created_at"].as_str().unwrap_or("-"); + println!( + "{:<26} {:<16} {:<20} {}", + code, status, user_display, created, + ); + } + } + None => { + println!("{}", serde_json::to_string_pretty(&result)?); + } + } + } + AdminAction::RevokeInvite { code } => { + let result = service::admin_revoke_invite(&client, &code).await?; + let was_claimed = result["was_claimed"].as_bool().unwrap_or(false); + println!( + "Revoked invite code '{}' (was {})", + code, + if was_claimed { "claimed" } else { "unclaimed" } + ); + } + AdminAction::UpdateProblems { + problem_set, + repository, + branch, + force, + closed, + } => { + println!( + "Updating problems from {}/tree/{}{}{}...", + repository, + branch, + problem_set + .as_ref() + .map(|ps| format!(" (problem set: {})", ps)) + .unwrap_or_default(), + if closed { " (closed)" } else { "" }, + ); + let result = service::admin_update_problems( + &client, + problem_set.as_deref(), + &repository, + &branch, + force, + closed, + ) + .await?; + + // Pretty print the results + if let Some(created) = result.get("created").and_then(|v| v.as_array()) { + if !created.is_empty() { + println!("\nCreated {} leaderboard(s):", created.len()); + for name in created { + println!(" + {}", name.as_str().unwrap_or("unknown")); + } + } + } + if let Some(updated) = result.get("updated").and_then(|v| v.as_array()) { + if !updated.is_empty() { + println!("\nUpdated {} leaderboard(s):", updated.len()); + for name in updated { + println!(" ~ {}", name.as_str().unwrap_or("unknown")); + } + } + } + if let Some(skipped) = result.get("skipped").and_then(|v| v.as_array()) { + if !skipped.is_empty() { + println!("\nSkipped {} leaderboard(s):", skipped.len()); + for item in skipped { + let name = item + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown"); + let reason = item + .get("reason") + .and_then(|r| r.as_str()) + .unwrap_or("no changes"); + println!(" - {} ({})", name, reason); + } + } + } + if let Some(errors) = result.get("errors").and_then(|v| v.as_array()) { + if !errors.is_empty() { + println!("\nErrors ({}):", errors.len()); + for item in errors { + let name = item + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown"); + let error = item + .get("error") + .and_then(|e| e.as_str()) + .unwrap_or("unknown"); + println!(" ! {}: {}", name, error); + } + } + } + } + } + + Ok(()) +} diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs new file mode 100644 index 0000000..6ad65ec --- /dev/null +++ b/src/cmd/auth.rs @@ -0,0 +1,182 @@ +use anyhow::{anyhow, Result}; +use crossterm::style::Stylize; +use serde::{Deserialize, Serialize}; +use std::fs::{File, OpenOptions}; +use std::path::PathBuf; + +use crate::service; + +// 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!( + "{} Authenticating via {}...", + "●".cyan(), + auth_provider.bold() + ); + + let popcorn_api_url = std::env::var("POPCORN_API_URL").map_err(|_| { + anyhow!( + "{} POPCORN_API_URL environment variable not set", + "error:".red().bold() + ) + })?; + + let client = service::create_client(None)?; + + let init_url = format!("{}/auth/init?provider={}", popcorn_api_url, auth_provider); + + let init_resp = client.get(&init_url).send().await.map_err(|e| { + anyhow!( + "{} Could not reach auth server: {}", + "error:".red().bold(), + e + ) + })?; + + let status = init_resp.status(); + + if !status.is_success() { + let error_text = init_resp.text().await?; + eprintln!( + "{} Failed to initialize auth ({}): {}", + "error:".red().bold(), + status.to_string().red(), + error_text + ); + return Err(anyhow!("Authentication initialization failed")); + } + + let auth_init_data: AuthInitResponse = init_resp.json().await?; + let cli_id = auth_init_data.state; + + 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=1361364685491802243&response_type=code&redirect_uri=https%3A%2F%2Fsite--bot--dxfjds728w5v.code.run%2Fauth%2Fcli%2Fdiscord&scope=identify"; + format!("{}&state={}", base_auth_url, state_b64) + } + "github" => { + let client_id = "Ov23lieFd2onYk4OnKIR"; + let redirect_uri = "https://site--bot--dxfjds728w5v.code.run/auth/cli/github"; + let encoded_redirect_uri = urlencoding::encode(redirect_uri); + format!( + "https://github.com/login/oauth/authorize?client_id={}&state={}&redirect_uri={}", + client_id, state_b64, encoded_redirect_uri + ) + } + _ => { + eprintln!( + "{} Unsupported authentication provider: {}", + "error:".red().bold(), + auth_provider.yellow() + ); + return Err(anyhow!( + "Unsupported authentication provider: {}", + auth_provider + )); + } + }; + + println!( + "\n {} Open this URL to log in via {}:\n", + "▸".bold(), + auth_provider.bold() + ); + println!(" {}\n", auth_url.as_str().underlined().cyan()); + + let mut browser_failed = false; + + if webbrowser::open(&auth_url).is_err() { + browser_failed = true; + println!( + " {} Could not open browser automatically — please copy the link above.", + "!".yellow().bold() + ); + } else { + println!( + " {} Browser opened. Complete the login there.", + "✓".green().bold() + ); + } + + println!( + " {} Waiting for authentication to complete...\n", + "…".dark_grey() + ); + + // 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)?; + + let config_path = get_config_path()?.display().to_string(); + + if !browser_failed { + println!( + " {} Authentication initiated! CLI ID saved to {}", + "✓".green().bold(), + config_path.underlined() + ); + + println!( + " {} You can now use commands that require authentication.\n", + "●".cyan() + ); + } else { + println!( + "{} You need to open the browser URL above to complete the authentication. After that, you can use the CLI as normal.", + "?".yellow().bold(), + ); + } + + Ok(()) +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs new file mode 100644 index 0000000..9f8b9d3 --- /dev/null +++ b/src/cmd/mod.rs @@ -0,0 +1,394 @@ +use anyhow::{anyhow, Result}; +use clap::{Parser, Subcommand}; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::path::PathBuf; + +mod admin; +mod auth; +mod setup; +mod submissions; +mod submit; + +use crate::service; + +pub use admin::AdminAction; + +#[derive(Serialize, Deserialize, Debug, Default)] +struct Config { + cli_id: Option, +} + +fn get_config_path() -> Result { + dirs::home_dir() + .map(|mut path| { + path.push(".popcorn.yaml"); + path + }) + .ok_or_else(|| anyhow!("Could not find home directory")) +} + +fn load_config() -> Result { + let path = get_config_path()?; + if !path.exists() { + return Err(anyhow!( + "Config file not found at {}. Please run `popcorn register` first.", + path.display() + )); + } + let file = File::open(path)?; + serde_yaml::from_reader(file).map_err(|e| anyhow!("Failed to parse config file: {}", e)) +} + +fn submit_cli_id_from_env() -> Option { + std::env::var("POPCORN_SUBMITTER_ID") + .ok() + .filter(|v| !v.trim().is_empty()) +} + +fn resolve_cli_id() -> Result { + if let Some(cli_id) = submit_cli_id_from_env() { + return Ok(cli_id); + } + + let config = load_config()?; + config.cli_id.ok_or_else(|| { + anyhow!( + "cli_id not found in config file ({}). Please run 'popcorn-cli register' first.", + get_config_path() + .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) + ) + }) +} + +#[derive(Parser, Debug)] +#[command(author, version = env!("CLI_VERSION"), about, long_about = None)] +/// Popcorn CLI for GPU Mode competitions. Run `popcorn setup` first in each project so agents use the correct workflow and templates. +pub struct Cli { + #[command(subcommand)] + command: Option, + + /// Optional: Path to the solution file + filepath: Option, + + /// Optional: Directly specify the GPU to use (e.g., "mi300") + #[arg(long)] + pub gpu: Option, + + /// Optional: Directly specify the leaderboard (e.g., "fp8") + #[arg(long)] + pub leaderboard: Option, + + /// Optional: Specify submission mode (test, benchmark, leaderboard, profile) + #[arg(long)] + pub mode: Option, + + // Optional: Specify output file + #[arg(short, long)] + pub output: Option, + + /// Skip the TUI and print results directly to stdout + #[arg(long)] + pub no_tui: bool, +} + +#[derive(Subcommand, Debug)] +enum AuthProvider { + Discord, + Github, +} + +#[derive(Subcommand, Debug)] +enum SubmissionsAction { + /// List your submissions for a leaderboard + List { + /// Leaderboard name (required) + #[arg(long)] + leaderboard: String, + + /// Maximum number of submissions to show + #[arg(long, default_value = "50")] + limit: i32, + }, + /// Show a specific submission with full details and code + Show { + /// Submission ID + id: i64, + }, + /// Delete a submission + Delete { + /// Submission ID + id: i64, + + /// Skip confirmation prompt + #[arg(long)] + force: bool, + }, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Run this first: bootstrap the project with Popcorn agent skills and a submission template + Setup, + Reregister { + #[command(subcommand)] + provider: AuthProvider, + }, + Register { + #[command(subcommand)] + provider: AuthProvider, + }, + Submit { + /// Optional: Path to the solution file (can also be provided as a top-level argument) + filepath: Option, + + /// Optional: Directly specify the GPU to use (e.g., "MI300") + #[arg(long)] + gpu: Option, + + /// Optional: Directly specify the leaderboard (e.g., "amd-fp8-mm") + #[arg(long)] + leaderboard: Option, + + /// Optional: Specify submission mode (test, benchmark, leaderboard, profile) + #[arg(long)] + mode: Option, + + // Optional: Specify output file + #[arg(short, long)] + output: Option, + + /// Skip the TUI and print results directly to stdout + #[arg(long)] + no_tui: bool, + }, + /// Join a closed leaderboard using an invite code + Join { + /// The invite code + code: String, + }, + /// Admin commands (requires POPCORN_ADMIN_TOKEN env var) + Admin { + #[command(subcommand)] + action: AdminAction, + }, + /// Manage your submissions + Submissions { + #[command(subcommand)] + action: SubmissionsAction, + }, +} + +pub async fn execute(cli: Cli) -> Result<()> { + match cli.command { + Some(Commands::Setup) => setup::run_setup().await, + Some(Commands::Reregister { provider }) => { + let provider_str = match provider { + AuthProvider::Discord => "discord", + AuthProvider::Github => "github", + }; + auth::run_auth(true, provider_str).await + } + Some(Commands::Register { provider }) => { + let provider_str = match provider { + AuthProvider::Discord => "discord", + AuthProvider::Github => "github", + }; + auth::run_auth(false, provider_str).await + } + Some(Commands::Submit { + filepath, + gpu, + leaderboard, + mode, + output, + no_tui, + }) => { + let cli_id = resolve_cli_id()?; + + // Use filepath from Submit command first, fallback to top-level filepath + let final_filepath = filepath.or(cli.filepath); + + if no_tui { + submit::run_submit_plain( + final_filepath, // Resolved filepath + gpu, // From Submit command + leaderboard, // From Submit command + mode, // From Submit command + cli_id, + output, // From Submit command + ) + .await + } else { + submit::run_submit_tui( + final_filepath, // Resolved filepath + gpu, // From Submit command + leaderboard, // From Submit command + mode, // From Submit command + cli_id, + output, // From Submit command + ) + .await + } + } + Some(Commands::Join { code }) => { + let cli_id = resolve_cli_id()?; + let client = service::create_client(Some(cli_id))?; + let result = service::join_with_invite(&client, &code).await?; + let leaderboards = result["leaderboards"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + println!("Joined leaderboard(s): {}", leaderboards); + Ok(()) + } + Some(Commands::Admin { action }) => admin::handle_admin(action).await, + Some(Commands::Submissions { action }) => { + let cli_id = resolve_cli_id()?; + + match action { + SubmissionsAction::List { leaderboard, limit } => { + submissions::list_submissions(cli_id, leaderboard, Some(limit)).await + } + SubmissionsAction::Show { id } => submissions::show_submission(cli_id, id).await, + SubmissionsAction::Delete { id, force } => { + submissions::delete_submission(cli_id, id, force).await + } + } + } + None => { + // Check if any of the submission-related flags were used at the top level + if cli.gpu.is_some() || cli.leaderboard.is_some() || cli.mode.is_some() { + return Err(anyhow!( + "Please use the 'submit' subcommand when specifying submission options:\n\ + popcorn-cli submit [--gpu GPU] [--leaderboard LEADERBOARD] [--mode MODE] FILEPATH" + )); + } + + // Handle the case where only a filepath is provided (for backward compatibility) + if let Some(top_level_filepath) = cli.filepath { + let cli_id = resolve_cli_id()?; + + // Run TUI with only filepath, no other options + submit::run_submit_tui( + Some(top_level_filepath), + None, // No GPU option + None, // No leaderboard option + None, // No mode option + cli_id, + None, // No output option + ) + .await + } else { + Err(anyhow!( + "No command or submission file specified. Use --help for usage." + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::resolve_cli_id; + use std::env; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + old_home: Option, + old_submitter: Option, + } + + impl EnvGuard { + fn new() -> Self { + Self { + old_home: env::var("HOME").ok(), + old_submitter: env::var("POPCORN_SUBMITTER_ID").ok(), + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.old_home { + Some(v) => env::set_var("HOME", v), + None => env::remove_var("HOME"), + } + match &self.old_submitter { + Some(v) => env::set_var("POPCORN_SUBMITTER_ID", v), + None => env::remove_var("POPCORN_SUBMITTER_ID"), + } + } + } + + #[test] + fn test_resolve_cli_id_prefers_env_over_config() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = EnvGuard::new(); + + env::set_var("POPCORN_SUBMITTER_ID", "env-cli-id"); + + let cli_id = resolve_cli_id().expect("Expected cli_id resolution to succeed"); + assert_eq!(cli_id, "env-cli-id"); + } + + // dirs::home_dir() on Windows uses a shell API, not HOME, so config + // redirection via HOME only works on Unix. + #[cfg(not(windows))] + #[test] + fn test_resolve_cli_id_falls_back_to_config() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = EnvGuard::new(); + + let tmp = tempfile::tempdir().expect("Failed to create temp dir"); + let config_path = tmp.path().join(".popcorn.yaml"); + std::fs::write(&config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); + + env::set_var("HOME", tmp.path()); + env::remove_var("POPCORN_SUBMITTER_ID"); + + let cli_id = resolve_cli_id().expect("Expected cli_id resolution to succeed"); + assert_eq!(cli_id, "config-cli-id"); + } + + #[cfg(not(windows))] + #[test] + fn test_resolve_cli_id_ignores_empty_env() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = EnvGuard::new(); + + let tmp = tempfile::tempdir().expect("Failed to create temp dir"); + let config_path = tmp.path().join(".popcorn.yaml"); + std::fs::write(&config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); + + env::set_var("HOME", tmp.path()); + env::set_var("POPCORN_SUBMITTER_ID", " "); + + let cli_id = resolve_cli_id().expect("Expected cli_id resolution to succeed"); + assert_eq!(cli_id, "config-cli-id"); + } + + #[cfg(not(windows))] + #[test] + fn test_resolve_cli_id_errors_when_no_cli_id() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _guard = EnvGuard::new(); + + let tmp = tempfile::tempdir().expect("Failed to create temp dir"); + let config_path = tmp.path().join(".popcorn.yaml"); + std::fs::write(&config_path, "{}\n").expect("Failed to write config"); + + env::set_var("HOME", tmp.path()); + env::remove_var("POPCORN_SUBMITTER_ID"); + + let err = resolve_cli_id().unwrap_err(); + assert!(err.to_string().contains("cli_id not found")); + } +} diff --git a/src/cmd/popcorn-cli.go b/src/cmd/popcorn-cli.go deleted file mode 100644 index 96cfe17..0000000 --- a/src/cmd/popcorn-cli.go +++ /dev/null @@ -1,254 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/lipgloss" - - "github.com/S1ro1/popcorn-cli/src/models" - "github.com/S1ro1/popcorn-cli/src/service" - - tea "github.com/charmbracelet/bubbletea" -) - -var runnerItems = []list.Item{ - models.RunnerItem{TitleText: "Modal", DescriptionText: "Submit a solution to be evaluated on Modal runners.", Value: "modal"}, - models.RunnerItem{TitleText: "Github", DescriptionText: "Submit a solution to be evaluated on Github runners. This can take a little longer to spin up.", Value: "github"}, -} - -var submissionModeItems = []list.Item{ - models.SubmissionModeItem{TitleText: "Test", DescriptionText: "Test the solution and give detailed results about passed/failed tests.", Value: "test"}, - models.SubmissionModeItem{TitleText: "Benchmark", DescriptionText: "Benchmark the solution, this also runs the tests and afterwards runs the benchmark, returning detailed timing results", Value: "benchmark"}, - models.SubmissionModeItem{TitleText: "Leaderboard", DescriptionText: "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.", Value: "leaderboard"}, - models.SubmissionModeItem{TitleText: "Private", DescriptionText: "TODO", Value: "private"}, - models.SubmissionModeItem{TitleText: "Script", DescriptionText: "TODO", Value: "script"}, - models.SubmissionModeItem{TitleText: "Profile", DescriptionText: "TODO", Value: "profile"}, -} - -var docStyle = lipgloss.NewStyle().Margin(1, 2) -var p *tea.Program - -type model struct { - filepath string - leaderboardsList list.Model - selectedLeaderboard string - runnersList list.Model - selectedRunner string - gpusList list.Model - selectedGpu string - submissionModeList list.Model - selectedSubmissionMode string - modalState models.ModelState - width int - height int - - finalStatus string - finishedOkay bool - - spinner spinner.Model -} - -func (m model) Init() tea.Cmd { - return tea.EnterAltScreen -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - if !m.finishedOkay { - return m, tea.Quit - } - - switch msg := msg.(type) { - case tea.KeyMsg: - if msg.String() == "ctrl+c" { - return m, tea.Quit - } - if msg.String() == "enter" { - switch m.modalState { - case models.ModelStateLeaderboardSelection: - if i := m.leaderboardsList.SelectedItem(); i != nil { - m.selectedLeaderboard = i.(models.LeaderboardItem).TitleText - m.modalState = models.ModelStateRunnerSelection - m.runnersList.SetSize(m.width-2, m.height-2) - } - case models.ModelStateRunnerSelection: - if i := m.runnersList.SelectedItem(); i != nil { - m.selectedRunner = i.(models.RunnerItem).Value - m.modalState = models.ModelStateGpuSelection - gpus, err := service.GetListItems(func() ([]models.GpuItem, error) { - return service.FetchAvailableGpus(m.selectedLeaderboard, m.selectedRunner) - }) - if err != nil { - m.SetError(fmt.Sprintf("Error fetching GPUs: %s", err)) - return m, tea.Quit - } - if len(gpus) == 0 { - m.SetError("No GPUs available for this runner and leaderboard.") - return m, tea.Quit - } - m.gpusList = list.New(gpus, list.NewDefaultDelegate(), m.width-2, m.height-2) - } - case models.ModelStateGpuSelection: - if i := m.gpusList.SelectedItem(); i != nil { - m.selectedGpu = i.(models.GpuItem).TitleText - m.modalState = models.ModelStateSubmissionModeSelection - m.submissionModeList.SetSize(m.width-2, m.height-2) - } - case models.ModelStateSubmissionModeSelection: - if i := m.submissionModeList.SelectedItem(); i != nil { - m.selectedSubmissionMode = i.(models.SubmissionModeItem).Value - m.modalState = models.ModelStateWaitingForResult - return m, m.Submit() - } - case models.ModelStateWaitingForResult: - return m, nil - } - } - - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - h, v := docStyle.GetFrameSize() - listWidth := msg.Width - h - listHeight := msg.Height - v - - switch m.modalState { - case models.ModelStateLeaderboardSelection: - m.leaderboardsList.SetSize(listWidth, listHeight) - case models.ModelStateRunnerSelection: - m.runnersList.SetSize(listWidth, listHeight) - case models.ModelStateGpuSelection: - m.gpusList.SetSize(listWidth, listHeight) - case models.ModelStateSubmissionModeSelection: - m.submissionModeList.SetSize(listWidth, listHeight) - } - } - - switch m.modalState { - case models.ModelStateLeaderboardSelection: - m.leaderboardsList, cmd = m.leaderboardsList.Update(msg) - case models.ModelStateRunnerSelection: - m.runnersList, cmd = m.runnersList.Update(msg) - case models.ModelStateGpuSelection: - m.gpusList, cmd = m.gpusList.Update(msg) - case models.ModelStateSubmissionModeSelection: - m.submissionModeList, cmd = m.submissionModeList.Update(msg) - case models.ModelStateWaitingForResult: - m.spinner, cmd = m.spinner.Update(msg) - } - - switch msg := msg.(type) { - case models.ErrorMsg: - m.SetError(msg.Err.Error()) - return m, nil - case models.SubmissionResultMsg: - m.finalStatus = string(msg) - m.finishedOkay = true - return m, tea.Quit - } - - return m, cmd -} - -func (m model) View() string { - var content string - switch m.modalState { - case models.ModelStateLeaderboardSelection: - content = m.leaderboardsList.View() - case models.ModelStateRunnerSelection: - content = m.runnersList.View() - case models.ModelStateGpuSelection: - content = m.gpusList.View() - case models.ModelStateSubmissionModeSelection: - content = m.submissionModeList.View() - case models.ModelStateWaitingForResult: - str := fmt.Sprintf("\n\n %s Submitting solution...press ctrl+c to quit\n\n", m.spinner.View()) - content = str - } - return docStyle.Render(content) -} - -func (m *model) SetError(err string) { - m.finalStatus = err - m.finishedOkay = false -} - -func (m model) Submit() tea.Cmd { - return func() tea.Msg { - go func() { - fileContent, err := os.ReadFile(m.filepath) - if err != nil { - p.Send(models.ErrorMsg{Err: fmt.Errorf("error reading file: %s", err)}) - m.SetError(fmt.Sprintf("Error reading file: %s", err)) - return - } - - prettyResult, err := service.SubmitSolution(m.selectedLeaderboard, m.selectedRunner, m.selectedGpu, m.selectedSubmissionMode, m.filepath, fileContent) - if err != nil { - p.Send(models.ErrorMsg{Err: fmt.Errorf("error submitting solution: %s", err)}) - m.SetError(fmt.Sprintf("Error submitting solution: %s", err)) - return - } - - p.Send(models.SubmissionResultMsg(prettyResult)) - }() - - return m.spinner.Tick() - } -} - -func Execute() { - args := os.Args[1:] - - if len(args) == 0 { - fmt.Println("Usage: popgorn ") - return - } - - filepath := args[0] - if _, err := os.Stat(filepath); os.IsNotExist(err) { - fmt.Println("File does not exist: ", filepath) - return - } - - leaderboardItems, err := service.GetListItems(service.FetchLeaderboards) - if err != nil { - fmt.Println("Error fetching leaderboards:", err) - return - } - - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - - m := model{ - filepath: filepath, - leaderboardsList: list.New(leaderboardItems, list.NewDefaultDelegate(), 0, 0), - runnersList: list.New(runnerItems, list.NewDefaultDelegate(), 0, 0), - submissionModeList: list.New(submissionModeItems, list.NewDefaultDelegate(), 0, 0), - spinner: s, - modalState: models.ModelStateLeaderboardSelection, - finishedOkay: true, - finalStatus: "", - } - m.leaderboardsList.Title = "Leaderboards" - m.runnersList.Title = "Runners" - - p = tea.NewProgram(m) - finalModel, err := p.Run() - if err != nil { - fmt.Println("Error running program:", err) - return - } - - m, ok := finalModel.(model) - if ok && m.finishedOkay { - fmt.Printf("\nResult:\n\n%s\n", m.finalStatus) - } else if ok && !m.finishedOkay { - fmt.Printf("\nError:\n\n%s\n", m.finalStatus) - } -} diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs new file mode 100644 index 0000000..06e490b --- /dev/null +++ b/src/cmd/setup.rs @@ -0,0 +1,587 @@ +use anyhow::{anyhow, Context, Result}; +use chrono::{NaiveDate, NaiveDateTime, Utc}; +use serde::Deserialize; +use serde_json::json; +use std::env; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +const SKILL_NAME: &str = "popcorn-submission-workflow"; +const NATIVE_SKILL_NAME: &str = "load-inline-native-code"; +const SUBMISSION_FILENAME: &str = "submission.py"; +const SKILL_TEMPLATE: &str = + include_str!("../../templates/setup/skills/popcorn-submission-workflow/SKILL.md"); +const NATIVE_SKILL_TEMPLATE: &str = + include_str!("../../templates/setup/skills/load-inline-native-code/SKILL.md"); +const AGENTS_TEMPLATE: &str = include_str!("../../templates/setup/AGENTS.md"); + +const RAW_GITHUB_BASE: &str = + "https://raw.githubusercontent.com/gpu-mode/reference-kernels/main/problems"; + +const GITHUB_API_CONTENTS: &str = + "https://api.github.com/repos/gpu-mode/reference-kernels/contents/problems"; + +#[derive(Deserialize)] +struct CompetitionIndex { + name: String, + #[serde(default)] + deadline: String, + problems: Vec, +} + +fn is_active(deadline: &str) -> bool { + let deadline = deadline.trim(); + if deadline.is_empty() { + return true; // No deadline means always open + } + let now = Utc::now().naive_utc(); + if let Ok(dt) = NaiveDateTime::parse_from_str(deadline, "%Y-%m-%d %H:%M") { + return now < dt; + } + if let Ok(d) = NaiveDate::parse_from_str(deadline, "%Y-%m-%d") { + return now.date() <= d; + } + true // Can't parse — show it rather than hide it +} + +#[derive(Deserialize)] +struct ProblemEntry { + directory: String, + name: String, + gpus: Vec, +} + +#[derive(Clone, Copy)] +enum ActionStatus { + Created, + Updated, + Skipped, +} + +impl ActionStatus { + fn label(self) -> &'static str { + match self { + Self::Created => "created", + Self::Updated => "updated", + Self::Skipped => "skipped", + } + } +} + +async fn discover_competition_yamls(client: &reqwest::Client) -> Result> { + let resp = client + .get(GITHUB_API_CONTENTS) + .header("User-Agent", "popcorn-cli") + .header("Accept", "application/vnd.github.v3+json") + .send() + .await + .context("Failed to list problems directory from GitHub API")?; + + if !resp.status().is_success() { + return Err(anyhow!( + "GitHub API returned status {} when listing problems directory", + resp.status() + )); + } + + let items: Vec = resp + .json() + .await + .context("Failed to parse GitHub API response")?; + let yamls: Vec = items + .iter() + .filter_map(|item| { + let name = item.get("name")?.as_str()?; + if name.ends_with(".yaml") || name.ends_with(".yml") { + Some(name.to_string()) + } else { + None + } + }) + .collect(); + + if yamls.is_empty() { + return Err(anyhow!( + "No competition YAML files found in problems directory" + )); + } + Ok(yamls) +} + +async fn fetch_competition_index(client: &reqwest::Client) -> Result> { + let yaml_files = discover_competition_yamls(client).await?; + let mut entries = Vec::new(); + for filename in &yaml_files { + let url = format!("{}/{}", RAW_GITHUB_BASE, filename); + let resp = client + .get(&url) + .send() + .await + .with_context(|| format!("Failed to fetch {}", url))?; + if !resp.status().is_success() { + eprintln!( + "Warning: could not fetch {} (status {})", + filename, + resp.status() + ); + continue; + } + let text = resp.text().await?; + let index: CompetitionIndex = + serde_yaml::from_str(&text).with_context(|| format!("Failed to parse {}", filename))?; + if !is_active(&index.deadline) { + continue; + } + let comp_name = index.name.clone(); + for problem in index.problems { + entries.push((comp_name.clone(), problem)); + } + } + if entries.is_empty() { + return Err(anyhow!( + "No competitions found. Check your network connection." + )); + } + Ok(entries) +} + +async fn download_submission( + client: &reqwest::Client, + directory: &str, + leaderboard_name: &str, + gpu: &str, +) -> Result { + let url = format!("{}/{}/submission.py", RAW_GITHUB_BASE, directory); + let resp = client + .get(&url) + .send() + .await + .with_context(|| format!("Failed to fetch {}", url))?; + if !resp.status().is_success() { + return Err(anyhow!( + "Failed to download submission.py from {} (status {})", + url, + resp.status() + )); + } + let body = resp.text().await?; + + // Strip existing #!POPCORN directives and leading blank lines + let content: String = body + .lines() + .skip_while(|line| line.starts_with("#!POPCORN") || line.trim().is_empty()) + .collect::>() + .join("\n"); + + Ok(format!( + "#!POPCORN leaderboard {}\n#!POPCORN gpu {}\n\n{}\n", + leaderboard_name, gpu, content + )) +} + +fn prompt_choice(prompt_text: &str, max: usize) -> Result { + loop { + print!("{}", prompt_text); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + match input.trim().parse::() { + Ok(n) if n >= 1 && n <= max => return Ok(n - 1), + _ => println!("Please enter a number between 1 and {}", max), + } + } +} + +pub async fn run_setup() -> Result<()> { + let cwd = env::current_dir().context("Failed to determine current directory")?; + + // Fetch competitions from GitHub + println!("Fetching competitions from gpu-mode/reference-kernels..."); + let client = reqwest::Client::new(); + let entries = fetch_competition_index(&client).await?; + + // Build unique competition list preserving order + let mut comp_names: Vec = Vec::new(); + for (name, _) in &entries { + if !comp_names.contains(name) { + comp_names.push(name.clone()); + } + } + + // Select competition + println!("\nAvailable competitions:"); + for (i, name) in comp_names.iter().enumerate() { + println!(" {}. {}", i + 1, name); + } + let comp_idx = prompt_choice( + &format!("\nSelect a competition [1-{}]: ", comp_names.len()), + comp_names.len(), + )?; + let chosen_comp = &comp_names[comp_idx]; + + // Filter problems for chosen competition + let problems: Vec<&ProblemEntry> = entries + .iter() + .filter(|(name, _)| name == chosen_comp) + .map(|(_, p)| p) + .collect(); + + // Select problem + println!("\nProblems in \"{}\":", chosen_comp); + for (i, p) in problems.iter().enumerate() { + println!(" {}. {}", i + 1, p.name); + } + let prob_idx = prompt_choice( + &format!("\nSelect a problem [1-{}]: ", problems.len()), + problems.len(), + )?; + let chosen_problem = problems[prob_idx]; + + // Select GPU + println!("\nAvailable GPUs for \"{}\":", chosen_problem.name); + for (i, gpu) in chosen_problem.gpus.iter().enumerate() { + println!(" {}. {}", i + 1, gpu); + } + let gpu_idx = prompt_choice( + &format!("\nSelect a GPU [1-{}]: ", chosen_problem.gpus.len()), + chosen_problem.gpus.len(), + )?; + let chosen_gpu = &chosen_problem.gpus[gpu_idx]; + + // Download submission template + println!( + "\nDownloading submission template for {} on {}...", + chosen_problem.name, chosen_gpu + ); + let submission_content = download_submission( + &client, + &chosen_problem.directory, + &chosen_problem.name, + chosen_gpu, + ) + .await?; + + // Create a unique project folder for this kernel + let project_dir = unique_folder_name(&cwd, &chosen_problem.directory); + fs::create_dir_all(&project_dir).with_context(|| { + format!( + "Failed to create project directory at {}", + project_dir.to_string_lossy() + ) + })?; + println!( + "\nCreated project folder: {}", + relative_display(&cwd, &project_dir) + ); + + // Write scaffolding files into the project folder + let popcorn_dir = project_dir.join(".popcorn"); + let skill_dir = popcorn_dir.join("skills").join(SKILL_NAME); + let skill_path = skill_dir.join("SKILL.md"); + let native_skill_dir = popcorn_dir.join("skills").join(NATIVE_SKILL_NAME); + let native_skill_path = native_skill_dir.join("SKILL.md"); + let manifest_path = popcorn_dir.join("setup.json"); + let submission_path = project_dir.join(SUBMISSION_FILENAME); + let agents_path = project_dir.join("AGENTS.md"); + + fs::create_dir_all(&skill_dir).with_context(|| { + format!( + "Failed to create skill directory at {}", + skill_dir.to_string_lossy() + ) + })?; + fs::create_dir_all(&native_skill_dir).with_context(|| { + format!( + "Failed to create skill directory at {}", + native_skill_dir.to_string_lossy() + ) + })?; + + let readme_path = cwd.join("README.md"); + let readme_content = fs::read_to_string(&readme_path).unwrap_or_default(); + let skill_markdown = build_skill_markdown(&readme_content); + let skill_status = write_text_file(&skill_path, &skill_markdown, true)?; + + let native_skill_status = write_text_file(&native_skill_path, NATIVE_SKILL_TEMPLATE, true)?; + + let manifest = json!({ + "schema_version": 1, + "setup_source": "popcorn setup", + "skills": [ + { + "name": SKILL_NAME, + "path": format!(".popcorn/skills/{SKILL_NAME}") + }, + { + "name": NATIVE_SKILL_NAME, + "path": format!(".popcorn/skills/{NATIVE_SKILL_NAME}") + } + ], + "agents": ["codex", "claude"] + }); + let manifest_text = serde_json::to_string_pretty(&manifest)?; + let manifest_status = write_text_file(&manifest_path, &manifest_text, true)?; + + let agents_md = build_agents_markdown(&skill_path, &native_skill_path); + let agents_status = write_text_file(&agents_path, &agents_md, true)?; + + let codex_link_status = create_agent_skill_view(&project_dir, "codex", &skill_dir, true)?; + let claude_link_status = create_agent_skill_view(&project_dir, "claude", &skill_dir, true)?; + let codex_native_link_status = + create_agent_skill_view(&project_dir, "codex", &native_skill_dir, true)?; + let claude_native_link_status = + create_agent_skill_view(&project_dir, "claude", &native_skill_dir, true)?; + + let submission_status = write_text_file(&submission_path, &submission_content, true)?; + + println!( + "{} {}", + skill_status.label(), + relative_display(&cwd, &skill_path) + ); + println!( + "{} {}", + native_skill_status.label(), + relative_display(&cwd, &native_skill_path) + ); + println!( + "{} {}", + manifest_status.label(), + relative_display(&cwd, &manifest_path) + ); + println!( + "{} {}", + agents_status.label(), + relative_display(&cwd, &agents_path) + ); + println!( + "{} {}", + codex_link_status.label(), + relative_display( + &cwd, + &project_dir.join(".codex").join("skills").join(SKILL_NAME) + ) + ); + println!( + "{} {}", + codex_native_link_status.label(), + relative_display( + &cwd, + &project_dir + .join(".codex") + .join("skills") + .join(NATIVE_SKILL_NAME) + ) + ); + println!( + "{} {}", + claude_link_status.label(), + relative_display( + &cwd, + &project_dir.join(".claude").join("skills").join(SKILL_NAME) + ) + ); + println!( + "{} {}", + claude_native_link_status.label(), + relative_display( + &cwd, + &project_dir + .join(".claude") + .join("skills") + .join(NATIVE_SKILL_NAME) + ) + ); + println!( + "{} {}", + submission_status.label(), + relative_display(&cwd, &submission_path) + ); + + Ok(()) +} + +fn unique_folder_name(parent: &Path, base_name: &str) -> PathBuf { + let candidate = parent.join(base_name); + if !candidate.exists() { + return candidate; + } + let mut n = 1; + loop { + let candidate = parent.join(format!("{}-{}", base_name, n)); + if !candidate.exists() { + return candidate; + } + n += 1; + } +} + +fn relative_display(cwd: &Path, target: &Path) -> String { + match target.strip_prefix(cwd) { + Ok(relative) => relative.to_string_lossy().to_string(), + Err(_) => target.to_string_lossy().to_string(), + } +} + +fn write_text_file(path: &Path, content: &str, force: bool) -> Result { + let existed_before = path_exists(path); + if existed_before && !force { + return Ok(ActionStatus::Skipped); + } + + if existed_before { + remove_existing_path(path)?; + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(path, content)?; + if existed_before { + Ok(ActionStatus::Updated) + } else { + Ok(ActionStatus::Created) + } +} + +fn create_agent_skill_view( + cwd: &Path, + agent_name: &str, + skill_source_dir: &Path, + force: bool, +) -> Result { + let skill_dir_name = skill_source_dir + .file_name() + .ok_or_else(|| anyhow!("skill source dir has no file name"))?; + let agent_skills_dir = cwd.join(format!(".{}", agent_name)).join("skills"); + fs::create_dir_all(&agent_skills_dir)?; + + let link_path = agent_skills_dir.join(skill_dir_name); + let existed_before = path_exists(&link_path); + if existed_before && !force { + return Ok(ActionStatus::Skipped); + } + + if existed_before { + remove_existing_path(&link_path)?; + } + + let relative_target = PathBuf::from("../../.popcorn/skills").join(skill_dir_name); + let symlink_result = create_symlink_dir(&relative_target, &link_path); + if symlink_result.is_err() { + copy_dir_all(skill_source_dir, &link_path)?; + } + + if existed_before { + Ok(ActionStatus::Updated) + } else { + Ok(ActionStatus::Created) + } +} + +fn path_exists(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok() +} + +fn remove_existing_path(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + let file_type = metadata.file_type(); + if file_type.is_symlink() || file_type.is_file() { + fs::remove_file(path)?; + } else if file_type.is_dir() { + fs::remove_dir_all(path)?; + } + Ok(()) +} + +fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_all(&from, &to)?; + } else { + fs::copy(from, to)?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn create_symlink_dir(target: &Path, link_path: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, link_path) +} + +#[cfg(windows)] +fn create_symlink_dir(target: &Path, link_path: &Path) -> std::io::Result<()> { + std::os::windows::fs::symlink_dir(target, link_path) +} + +fn extract_top_level_section(content: &str, heading: &str) -> Option { + let lines: Vec<&str> = content.lines().collect(); + let start = lines + .iter() + .position(|line| line.trim() == heading) + .map(|idx| idx + 1)?; + + let mut end = lines.len(); + for (idx, line) in lines.iter().enumerate().skip(start) { + if line.trim_start().starts_with("## ") { + end = idx; + break; + } + } + + let section = lines[start..end].join("\n").trim().to_string(); + if section.is_empty() { + None + } else { + Some(section) + } +} + +fn build_skill_markdown(readme_content: &str) -> String { + let authentication = extract_top_level_section(readme_content, "## Authentication") + .unwrap_or_else(|| "See project README for authentication details.".to_string()); + let commands = extract_top_level_section(readme_content, "## Commands") + .unwrap_or_else(|| "See project README for command usage.".to_string()); + let submission_format = extract_top_level_section(readme_content, "## Submission Format") + .unwrap_or_else(|| "Submissions are expected as a single Python file.".to_string()); + + render_template( + SKILL_TEMPLATE, + &[ + ("{{SKILL_NAME}}", SKILL_NAME), + ("{{AUTHENTICATION_SECTION}}", &authentication), + ("{{COMMANDS_SECTION}}", &commands), + ("{{SUBMISSION_FORMAT_SECTION}}", &submission_format), + ], + ) +} + +fn build_agents_markdown(skill_path: &Path, native_skill_path: &Path) -> String { + let skill_path_text = skill_path.to_string_lossy().to_string(); + let native_skill_path_text = native_skill_path.to_string_lossy().to_string(); + render_template( + AGENTS_TEMPLATE, + &[ + ("{{SKILL_NAME}}", SKILL_NAME), + ("{{SKILL_PATH}}", &skill_path_text), + ("{{NATIVE_SKILL_NAME}}", NATIVE_SKILL_NAME), + ("{{NATIVE_SKILL_PATH}}", &native_skill_path_text), + ], + ) +} + +fn render_template(template: &str, replacements: &[(&str, &str)]) -> String { + let mut output = template.to_string(); + for (needle, value) in replacements { + output = output.replace(needle, value); + } + output +} diff --git a/src/cmd/submissions.rs b/src/cmd/submissions.rs new file mode 100644 index 0000000..4a9fddc --- /dev/null +++ b/src/cmd/submissions.rs @@ -0,0 +1,169 @@ +use anyhow::Result; +use std::io::{self, Write}; + +use crate::service; + +/// List user's submissions for a leaderboard +pub async fn list_submissions( + cli_id: String, + leaderboard: String, + limit: Option, +) -> Result<()> { + let client = service::create_client(Some(cli_id))?; + let submissions = service::get_user_submissions(&client, Some(&leaderboard), limit).await?; + + if submissions.is_empty() { + println!("No submissions found."); + return Ok(()); + } + + // Print header + println!( + "{:<8} {:<20} {:<20} {:<20} {:<12} {:<10} {:>10}", + "ID", "Leaderboard", "File", "Time", "GPU(s)", "Status", "Score" + ); + println!("{}", "-".repeat(105)); + + // Print each submission + for sub in submissions { + let status = if sub.done { "done" } else { "pending" }; + + // Collect all GPU types and best score from runs + let gpus: Vec<&str> = sub.runs.iter().map(|r| r.gpu_type.as_str()).collect(); + let gpu_display = if gpus.is_empty() { + "-".to_string() + } else { + gpus.join(",") + }; + + // Get best score (lowest) + let best_score = sub + .runs + .iter() + .filter_map(|r| r.score) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let score_display = best_score + .map(|s| format!("{:.4}", s)) + .unwrap_or_else(|| "-".to_string()); + + let time = truncate(&sub.submission_time, 19); + + println!( + "{:<8} {:<20} {:<20} {:<20} {:<12} {:<10} {:>10}", + sub.id, + truncate(&sub.leaderboard_name, 19), + truncate(&sub.file_name, 19), + time, + truncate(&gpu_display, 11), + status, + score_display + ); + } + + Ok(()) +} + +/// Show a specific submission with full details +pub async fn show_submission(cli_id: String, submission_id: i64) -> Result<()> { + let client = service::create_client(Some(cli_id))?; + let sub = service::get_user_submission(&client, submission_id).await?; + + println!("Submission #{}", sub.id); + println!("{}", "=".repeat(60)); + println!( + "Leaderboard: {} (id: {})", + sub.leaderboard_name, sub.leaderboard_id + ); + println!("File: {}", sub.file_name); + println!("User ID: {}", sub.user_id); + println!("Submitted: {}", sub.submission_time); + println!( + "Status: {}", + if sub.done { "done" } else { "pending" } + ); + + if !sub.runs.is_empty() { + println!("\nRuns:"); + for run in &sub.runs { + let score_str = run + .score + .map(|s| format!("{:.4}", s)) + .unwrap_or_else(|| "-".to_string()); + let status = if run.passed { "passed" } else { "failed" }; + let secret_marker = if run.secret { " [secret]" } else { "" }; + let time_info = match (&run.start_time, &run.end_time) { + (Some(start), Some(end)) => format!(" ({} - {})", start, end), + (Some(start), None) => format!(" (started: {})", start), + _ => String::new(), + }; + println!( + " - {} on {}: {} (score: {}){}{}", + run.mode, run.runner, status, score_str, secret_marker, time_info + ); + } + } + + println!("\nCode:"); + println!("{}", "-".repeat(60)); + println!("{}", sub.code); + + Ok(()) +} + +/// Delete a submission with confirmation +pub async fn delete_submission(cli_id: String, submission_id: i64, force: bool) -> Result<()> { + let client = service::create_client(Some(cli_id))?; + + // Fetch submission first to show preview + let sub = service::get_user_submission(&client, submission_id).await?; + + println!("Submission #{}", sub.id); + println!("Leaderboard: {}", sub.leaderboard_name); + println!("File: {}", sub.file_name); + println!("Submitted: {}", sub.submission_time); + + // Show first 20 lines of code + println!("\nCode preview:"); + println!("{}", "-".repeat(60)); + let lines: Vec<&str> = sub.code.lines().take(20).collect(); + for line in &lines { + println!("{}", line); + } + if sub.code.lines().count() > 20 { + println!("... ({} more lines)", sub.code.lines().count() - 20); + } + println!("{}", "-".repeat(60)); + + // Ask for confirmation unless --force + if !force { + print!("\nDelete this submission? [y/N]: "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if !input.trim().eq_ignore_ascii_case("y") { + println!("Cancelled."); + return Ok(()); + } + } + + // Delete the submission + let result = service::delete_user_submission(&client, submission_id).await?; + if result.get("status").and_then(|s| s.as_str()) == Some("ok") { + println!("Submission {} deleted successfully.", submission_id); + } else { + println!("Submission deleted."); + } + + Ok(()) +} + +/// Truncate a string to max length, adding "..." if truncated +fn truncate(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}...", &s[..max_len - 3]) + } +} diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs new file mode 100644 index 0000000..ec8e6fb --- /dev/null +++ b/src/cmd/submit.rs @@ -0,0 +1,788 @@ +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}; +use tokio::task::JoinHandle; + +use crate::models::{AppState, GpuItem, LeaderboardItem, SubmissionModeItem}; +use crate::service; +use crate::utils; +use crate::views::loading_page::{LoadingPage, LoadingPageState}; +use crate::views::result_page::{ResultPage, ResultPageState}; + +#[derive(Default, Debug)] +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 app_state: AppState, + pub final_status: Option, + + pub should_quit: bool, + pub submission_task: Option>>, + pub leaderboards_task: Option, anyhow::Error>>>, + pub gpus_task: Option, anyhow::Error>>>, + + pub loading_page_state: LoadingPageState, + + pub result_page_state: ResultPageState, +} + +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( + "Profile".to_string(), + "Profile the solution using Nsight Compute (NVIDIA) or rocPROF (AMD). Downloads profiling data to current directory.".to_string(), + "profile".to_string(), + ), + ]; + + let mut app = Self { + filepath: filepath.as_ref().to_string_lossy().to_string(), + cli_id, + submission_modes, + selected_submission_mode: None, + ..Default::default() + }; + + app.leaderboards_state.select(Some(0)); + app.gpus_state.select(Some(0)); + app.submission_modes_state.select(Some(0)); + app + } + + pub fn update_loading_page_state(&mut self, terminal_width: u16) { + if self.app_state != AppState::WaitingForResult { + return; + } + + let st = &mut self.loading_page_state; + st.progress_column = { + if st.progress_column < terminal_width { + st.progress_column + 1 + } else { + st.loop_count += 1; + 0 + } + }; + st.progress_bar = f64::from(st.progress_column) * 100.0 / f64::from(terminal_width); + } + + 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.app_state = AppState::SubmissionModeSelection; + } else { + self.app_state = AppState::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.app_state = AppState::SubmissionModeSelection; + } else { + self.app_state = AppState::LeaderboardSelection; + } + } else { + self.app_state = AppState::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); + } + + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + return Ok(true); + } + KeyCode::Enter => match self.app_state { + AppState::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.app_state = AppState::GpuSelection; + if let Err(e) = self.spawn_load_gpus() { + self.set_error_and_quit(format!( + "Error starting GPU fetch: {}", + e + )); + } + } else { + self.app_state = AppState::SubmissionModeSelection; + } + return Ok(true); + } + } + } + AppState::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.app_state = AppState::SubmissionModeSelection; + return Ok(true); + } + } + } + AppState::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.app_state = AppState::WaitingForResult; + if let Err(e) = self.spawn_submit_solution() { + self.set_error_and_quit(format!( + "Error starting submission: {}", + e + )); + } + return Ok(true); + } + } + } + _ => {} + }, + KeyCode::Up => { + self.move_selection_up(); + return Ok(true); + } + KeyCode::Down => { + self.move_selection_down(); + return Ok(true); + } + _ => {} + } + Ok(false) + } + + fn set_error_and_quit(&mut self, error_message: String) { + self.final_status = Some(error_message); + self.should_quit = true; + } + + fn move_selection_up(&mut self) { + match self.app_state { + AppState::LeaderboardSelection => { + if let Some(idx) = self.leaderboards_state.selected() { + if idx > 0 { + self.leaderboards_state.select(Some(idx - 1)); + } + } + } + AppState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx > 0 { + self.gpus_state.select(Some(idx - 1)); + } + } + } + AppState::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.app_state { + AppState::LeaderboardSelection => { + if let Some(idx) = self.leaderboards_state.selected() { + if idx < self.leaderboards.len().saturating_sub(1) { + self.leaderboards_state.select(Some(idx + 1)); + } + } + } + AppState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx < self.gpus.len().saturating_sub(1) { + self.gpus_state.select(Some(idx + 1)); + } + } + } + AppState::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 + })); + 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 + })); + 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 as bytes (supports both text and archive files) + let mut file = File::open(&filepath)?; + let mut file_content = Vec::new(); + file.read_to_end(&mut file_content)?; + + self.submission_task = Some(tokio::spawn(async move { + service::submit_solution( + &client, + &filepath, + &file_content, + &leaderboard, + &gpu, + &mode, + None, + ) + .await + })); + 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 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 self.selected_gpu.is_some() { + self.app_state = AppState::SubmissionModeSelection; + } else { + self.app_state = AppState::GpuSelection; + if let Err(e) = self.spawn_load_gpus() { + self.set_error_and_quit(format!( + "Error starting GPU fetch: {}", + e + )); + } + } + } else { + self.selected_leaderboard = None; + self.leaderboards_state.select(Some(0)); + self.app_state = AppState::LeaderboardSelection; + } + } else { + self.leaderboards_state.select(Some(0)); + } + } + 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 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.app_state = AppState::SubmissionModeSelection; + } else { + self.selected_gpu = None; + self.gpus_state.select(Some(0)); + self.app_state = AppState::GpuSelection; + } + } else { + self.gpus_state.select(Some(0)); + } + } + 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 + } + 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()); + + let list_area = main_layout[0]; + let available_width = list_area.width.saturating_sub(4) as usize; + + let list_block = Block::default().borders(Borders::ALL); + let list_style = Style::default().fg(Color::White); + + match app.app_state { + AppState::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(), + )); + 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) + }) + .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()); + } + AppState::GpuSelection => { + let items: Vec = app + .gpus + .iter() + .map(|gpu| { + let line = Line::from(vec![Span::styled( + gpu.title_text.clone(), + Style::default().fg(Color::White).bold(), + )]); + ListItem::new(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()); + } + AppState::SubmissionModeSelection => { + let items: Vec = app + .submission_modes + .iter() + .map(|mode| { + let strings = utils::custom_wrap( + mode.title_text.clone(), + mode.description_text.clone(), + available_width, + ); + + let lines: Vec = strings + .into_iter() + .enumerate() + .map(|(i, line)| { + if i == 0 { + Line::from(Span::styled( + line, + Style::default().fg(Color::White).bold(), + )) + } else { + Line::from(Span::styled( + line.clone(), + Style::default().fg(Color::Gray).dim(), + )) + } + }) + .collect::>(); + 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(), + ); + } + AppState::WaitingForResult => { + let loading_page = LoadingPage::default(); + frame.render_stateful_widget( + &loading_page, + main_layout[0], + &mut app.loading_page_state.clone(), + ) + } + } +} + +pub async fn run_submit_tui( + filepath: Option, + gpu: Option, + leaderboard: Option, + mode: Option, + cli_id: String, + output: 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, cli_id); + + // Override directives with CLI flags if provided + if let Some(gpu_flag) = gpu { + app.selected_gpu = Some(gpu_flag); + } + if let Some(leaderboard_flag) = leaderboard { + app.selected_leaderboard = Some(leaderboard_flag); + } + if let Some(mode_flag) = mode { + app.selected_submission_mode = Some(mode_flag); + // Skip to submission if we have all required fields + if app.selected_gpu.is_some() && app.selected_leaderboard.is_some() { + app.app_state = AppState::WaitingForResult; + } + } + + // If no CLI flags, use directives + if app.selected_gpu.is_none() && app.selected_leaderboard.is_none() { + app.initialize_with_directives(directives); + } + + // Spawn the initial task based on the starting state BEFORE setting up the TUI + // If spawning fails here, we just return the error directly without TUI cleanup. + match app.app_state { + AppState::LeaderboardSelection => { + if let Err(e) = app.spawn_load_leaderboards() { + return Err(anyhow!("Error starting leaderboard fetch: {}", e)); + } + } + AppState::GpuSelection => { + if let Err(e) = app.spawn_load_gpus() { + return Err(anyhow!("Error starting GPU fetch: {}", e)); + } + } + AppState::WaitingForResult => { + if let Err(e) = app.spawn_submit_solution() { + return Err(anyhow!("Error starting submission: {}", e)); + } + } + _ => {} + } + + // Now, set up the TUI + enable_raw_mode()?; + let mut stdout = io::stdout(); + crossterm::execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + while !app.should_quit { + terminal.draw(|f| ui(&app, f))?; + + app.check_leaderboard_task().await; + app.check_gpu_task().await; + app.check_submission_task().await; + + app.update_loading_page_state(terminal.size()?.width); + + 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)?; + } + } + } + } + + let mut result_text = "Submission cancelled.".to_string(); + + if let Some(status) = app.final_status { + let trimmed = status.trim(); + let content = if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2 { + &trimmed[1..trimmed.len() - 1] + } else { + trimmed + }; + + let content = content.replace("\\n", "\n"); + + result_text = content.to_string(); + } + + // write to file if output is specified + if let Some(output_path) = output { + // create parent directories if they don't exist + if let Some(parent) = Path::new(&output_path).parent() { + std::fs::create_dir_all(parent) + .map_err(|e| anyhow!("Failed to create directories for {}: {}", output_path, e))?; + } + std::fs::write(&output_path, &result_text) + .map_err(|e| anyhow!("Failed to write result to file {}: {}", output_path, e))?; + } + + let state = &mut app.result_page_state; + + let mut result_page = ResultPage::new(result_text.clone(), state); + let mut last_draw = std::time::Instant::now(); + while !state.ack { + // Force redraw every 100ms for smooth animation + let now = std::time::Instant::now(); + if now.duration_since(last_draw) >= std::time::Duration::from_millis(100) { + terminal + .draw(|frame: &mut Frame| { + frame.render_stateful_widget(&result_page, frame.size(), state); + }) + .unwrap(); + last_draw = now; + } + result_page.handle_key_event(state); + } + + // Restore terminal + disable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen + )?; + terminal.show_cursor()?; + + // utils::display_ascii_art(); + + Ok(()) +} + +pub async fn run_submit_plain( + filepath: Option, + gpu: Option, + leaderboard: Option, + mode: Option, + cli_id: String, + output: Option, +) -> Result<()> { + let file_to_submit = match filepath { + Some(fp) => fp, + None => { + return Err(anyhow!("File path is required when using --no-tui")); + } + }; + + 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." + )); + } + + // Determine final values + let final_gpu = gpu + .or_else(|| { + if !directives.gpus.is_empty() { + Some(directives.gpus[0].clone()) + } else { + None + } + }) + .ok_or_else(|| anyhow!("GPU not specified. Use --gpu flag or add GPU directive to file"))?; + + let final_leaderboard = leaderboard + .or_else(|| { + if !directives.leaderboard_name.is_empty() { + Some(directives.leaderboard_name.clone()) + } else { + None + } + }) + .ok_or_else(|| { + anyhow!("Leaderboard not specified. Use --leaderboard flag or add leaderboard directive to file") + })?; + + let final_mode = mode.ok_or_else(|| { + anyhow!("Submission mode not specified. Use --mode flag (test, benchmark, leaderboard, profile)") + })?; + + // Read file content as bytes (supports both text and archive files) + let mut file = File::open(&file_to_submit)?; + let mut file_content = Vec::new(); + file.read_to_end(&mut file_content)?; + + eprintln!("Submitting to leaderboard: {}", final_leaderboard); + eprintln!("GPU: {}", final_gpu); + eprintln!("Mode: {}", final_mode); + eprintln!("File: {}", file_to_submit); + eprintln!("\nWaiting for results..."); + + // Create client and submit + let client = service::create_client(Some(cli_id))?; + let result = service::submit_solution( + &client, + &file_to_submit, + &file_content, + &final_leaderboard, + &final_gpu, + &final_mode, + Some(Box::new(|msg| { + eprintln!("{}", msg); + })), + ) + .await?; + + // Clean up the result text + let trimmed = result.trim(); + let content = if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2 { + &trimmed[1..trimmed.len() - 1] + } else { + trimmed + }; + + let content = content.replace("\\n", "\n"); + + // Write to file if output is specified + if let Some(output_path) = output { + if let Some(parent) = Path::new(&output_path).parent() { + std::fs::create_dir_all(parent) + .map_err(|e| anyhow!("Failed to create directories for {}: {}", output_path, e))?; + } + std::fs::write(&output_path, &content) + .map_err(|e| anyhow!("Failed to write result to file {}: {}", output_path, e))?; + eprintln!("\nResults written to: {}", output_path); + } + + // Print to stdout + println!("\n{}", content); + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..03e1c68 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,29 @@ +mod cmd; +mod models; +mod service; +mod utils; +mod views; + +use crate::cmd::Cli; +use clap::Parser; +use std::env; +use std::process; + +#[tokio::main] +async fn main() { + // Set the API URL FIRST - before anything else + if env::var("POPCORN_API_URL").is_err() { + env::set_var( + "POPCORN_API_URL", + "https://site--bot--dxfjds728w5v.code.run", + ); + } + // Parse command line arguments + let cli = Cli::parse(); + + // Execute the parsed command + if let Err(e) = cmd::execute(cli).await { + eprintln!("Application error: {}", e); + process::exit(1); + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..4853d23 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,95 @@ +#[derive(Clone, Debug)] +pub struct LeaderboardItem { + pub title_text: String, + pub task_description: String, +} + +impl LeaderboardItem { + pub fn new(title_text: String, task_description: String) -> Self { + Self { + title_text, + task_description, + } + } +} + +#[derive(Clone, Debug)] +pub struct GpuItem { + pub title_text: String, +} + +impl GpuItem { + pub fn new(title_text: String) -> Self { + Self { title_text } + } +} + +#[derive(Clone, Debug)] +pub struct SubmissionModeItem { + pub title_text: String, + pub description_text: String, + pub value: String, +} + +impl SubmissionModeItem { + pub fn new(title_text: String, description_text: String, value: String) -> Self { + Self { + title_text, + description_text, + value, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum AppState { + #[default] + LeaderboardSelection, + GpuSelection, + SubmissionModeSelection, + WaitingForResult, +} + +/// Summary of a user submission for list view +#[derive(Clone, Debug)] +pub struct UserSubmission { + pub id: i64, + pub leaderboard_name: String, + pub file_name: String, + pub submission_time: String, + pub done: bool, + pub runs: Vec, +} + +/// A run summary for list view (gpu_type and score only) +#[derive(Clone, Debug)] +pub struct UserSubmissionRun { + pub gpu_type: String, + pub score: Option, +} + +/// Full submission details including code and runs +#[derive(Clone, Debug)] +pub struct SubmissionDetails { + pub id: i64, + pub leaderboard_id: i64, + pub leaderboard_name: String, + pub file_name: String, + pub user_id: String, + pub submission_time: String, + pub done: bool, + pub code: String, + pub runs: Vec, +} + +/// A single run within a submission +#[derive(Clone, Debug)] +pub struct SubmissionRun { + pub start_time: Option, + pub end_time: Option, + pub mode: String, + pub secret: bool, + pub runner: String, + pub score: Option, + pub passed: bool, +} diff --git a/src/models/types.go b/src/models/types.go deleted file mode 100644 index bcf9d60..0000000 --- a/src/models/types.go +++ /dev/null @@ -1,54 +0,0 @@ -package models - -type LeaderboardItem struct { - TitleText string - TaskDescription string -} - -func (i LeaderboardItem) FilterValue() string { return i.TitleText } -func (i LeaderboardItem) Title() string { return i.TitleText } -func (i LeaderboardItem) Description() string { return i.TaskDescription } - -type GpuItem struct { - TitleText string -} - -func (i GpuItem) FilterValue() string { return i.TitleText } -func (i GpuItem) Title() string { return i.TitleText } -func (i GpuItem) Description() string { return "" } - -type RunnerItem struct { - TitleText string - DescriptionText string - Value string -} - -func (i RunnerItem) FilterValue() string { return i.TitleText } -func (i RunnerItem) Title() string { return i.TitleText } -func (i RunnerItem) Description() string { return i.DescriptionText } - -type SubmissionModeItem struct { - TitleText string - DescriptionText string - Value string -} - -func (i SubmissionModeItem) FilterValue() string { return i.TitleText } -func (i SubmissionModeItem) Title() string { return i.TitleText } -func (i SubmissionModeItem) Description() string { return i.DescriptionText } - -type ModelState int - -const ( - ModelStateLeaderboardSelection ModelState = iota - ModelStateRunnerSelection - ModelStateGpuSelection - ModelStateSubmissionModeSelection - ModelStateWaitingForResult -) - -type ErrorMsg struct { - Err error -} - -type SubmissionResultMsg string diff --git a/src/service/api.go b/src/service/api.go deleted file mode 100644 index 9fde6fa..0000000 --- a/src/service/api.go +++ /dev/null @@ -1,162 +0,0 @@ -package service - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "github.com/charmbracelet/bubbles/list" - - "github.com/S1ro1/popcorn-cli/src/models" -) - -var BASE_URL = os.Getenv("POPCORN_API_URL") - -func FetchLeaderboards() ([]models.LeaderboardItem, error) { - resp, err := http.Get(BASE_URL + "/leaderboards") - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch leaderboards: %s", resp.Status) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var leaderboards []map[string]interface{} - err = json.Unmarshal(body, &leaderboards) - if err != nil { - return nil, err - } - - leaderboardNames := make([]models.LeaderboardItem, len(leaderboards)) - for i, lb := range leaderboards { - task := lb["task"].(map[string]interface{}) - leaderboardNames[i] = models.LeaderboardItem{ - TitleText: lb["name"].(string), - TaskDescription: task["description"].(string), - } - } - - return leaderboardNames, nil -} - -func FetchAvailableGpus(leaderboard string, runner string) ([]models.GpuItem, error) { - resp, err := http.Get(BASE_URL + "/" + leaderboard + "/" + runner + "/gpus") - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch GPUs: %s", resp.Status) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var gpus []string - err = json.Unmarshal(body, &gpus) - if err != nil { - return nil, err - } - - gpuItems := make([]models.GpuItem, len(gpus)) - for i, gpu := range gpus { - gpuItems[i] = models.GpuItem{TitleText: gpu} - } - - return gpuItems, nil -} - -func SubmitSolution(leaderboard string, runner string, gpu string, submissionMode string, filename string, fileContent []byte) (string, error) { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - part, err := writer.CreateFormFile("file", filepath.Base(filename)) - if err != nil { - return "", fmt.Errorf("error creating form file: %s", err) - } - - if _, err := part.Write(fileContent); err != nil { - return "", fmt.Errorf("error writing file to form: %s", err) - } - - if err := writer.Close(); err != nil { - return "", fmt.Errorf("error closing form: %s", err) - } - - url := fmt.Sprintf("%s/%s/%s/%s/%s", - BASE_URL, - strings.ToLower(leaderboard), - strings.ToLower(runner), - strings.ToLower(gpu), - strings.ToLower(submissionMode)) - - req, err := http.NewRequest("POST", url, body) - if err != nil { - return "", fmt.Errorf("error creating request: %s", err) - } - - req.Header.Set("Content-Type", writer.FormDataContentType()) - - client := &http.Client{Timeout: 60 * time.Second} - - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("error sending request: %s", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("error reading response body: %s", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(respBody)) - } - - var result struct { - Status string `json:"status"` - Result map[string]any `json:"result"` - } - if err := json.Unmarshal(respBody, &result); err != nil { - return "", fmt.Errorf("error unmarshalling response body: %s", err) - } - - prettyResult, err := json.MarshalIndent(result.Result, "", " ") - if err != nil { - return "", fmt.Errorf("error marshalling response body: %s", err) - } - - return string(prettyResult), nil -} - -func GetListItems[T list.Item](fetchFn func() ([]T, error)) ([]list.Item, error) { - items, err := fetchFn() - if err != nil { - return nil, err - } - - listItems := make([]list.Item, len(items)) - for i, item := range items { - listItems[i] = list.Item(item) - } - - return listItems, nil -} diff --git a/src/service/mod.rs b/src/service/mod.rs new file mode 100644 index 0000000..bcdfa80 --- /dev/null +++ b/src/service/mod.rs @@ -0,0 +1,1044 @@ +use anyhow::{anyhow, Result}; +use base64::Engine; +use chrono::{DateTime, Utc}; +use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::multipart::{Form, Part}; +use reqwest::Client; +use serde_json::Value; +use std::env; +use std::path::Path; +use std::time::Duration; +use tokio::io::AsyncWriteExt; + +use crate::models::{ + GpuItem, LeaderboardItem, SubmissionDetails, SubmissionRun, UserSubmission, UserSubmissionRun, +}; + +// Helper function to create a reusable reqwest client +pub fn create_client(cli_id: Option) -> Result { + let mut default_headers = HeaderMap::new(); + + if let Some(id) = cli_id { + match HeaderValue::from_str(&id) { + Ok(val) => { + default_headers.insert("X-Popcorn-Cli-Id", val); + } + Err(_) => { + return Err(anyhow!("Invalid cli_id format for HTTP header")); + } + } + } + + Client::builder() + .timeout(Duration::from_secs(180)) + .default_headers(default_headers) + .build() + .map_err(|e| anyhow!("Failed to create HTTP client: {}", e)) +} + +/// Create an HTTP client with admin token authentication +pub fn create_admin_client(admin_token: &str) -> Result { + let mut default_headers = HeaderMap::new(); + + let auth_value = format!("Bearer {}", admin_token); + match HeaderValue::from_str(&auth_value) { + Ok(val) => { + default_headers.insert("Authorization", val); + } + Err(_) => { + return Err(anyhow!("Invalid admin token format for HTTP header")); + } + } + + Client::builder() + .timeout(Duration::from_secs(60)) + .default_headers(default_headers) + .build() + .map_err(|e| anyhow!("Failed to create HTTP client: {}", e)) +} + +/// Start accepting jobs on the server +pub async fn admin_start(client: &Client) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .post(format!("{}/admin/start", base_url)) + .header("Content-Length", "0") + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Stop accepting jobs on the server +pub async fn admin_stop(client: &Client) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .post(format!("{}/admin/stop", base_url)) + .header("Content-Length", "0") + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Get server stats +pub async fn admin_stats(client: &Client, last_day_only: bool) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let url = if last_day_only { + format!("{}/admin/stats?last_day_only=true", base_url) + } else { + format!("{}/admin/stats", base_url) + }; + + let resp = client + .get(url) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Get a submission by ID +pub async fn admin_get_submission(client: &Client, submission_id: i64) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .get(format!("{}/admin/submissions/{}", base_url, submission_id)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Delete a submission by ID +pub async fn admin_delete_submission(client: &Client, submission_id: i64) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .delete(format!("{}/admin/submissions/{}", base_url, submission_id)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Create a dev leaderboard from a problem directory +pub async fn admin_create_leaderboard(client: &Client, directory: &str) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let payload = serde_json::json!({ + "directory": directory + }); + + let resp = client + .post(format!("{}/admin/leaderboards", base_url)) + .json(&payload) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Delete a leaderboard +pub async fn admin_delete_leaderboard( + client: &Client, + leaderboard_name: &str, + force: bool, +) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let url = if force { + format!( + "{}/admin/leaderboards/{}?force=true", + base_url, leaderboard_name + ) + } else { + format!("{}/admin/leaderboards/{}", base_url, leaderboard_name) + }; + + let resp = client + .delete(url) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Update problems from a GitHub repository +pub async fn admin_update_problems( + client: &Client, + problem_set: Option<&str>, + repository: &str, + branch: &str, + force: bool, + closed: bool, +) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let mut payload = serde_json::json!({ + "repository": repository, + "branch": branch, + "force": force + }); + + if let Some(ps) = problem_set { + payload["problem_set"] = serde_json::Value::String(ps.to_string()); + } + + if closed { + payload["visibility"] = serde_json::Value::String("closed".to_string()); + } + + let resp = client + .post(format!("{}/admin/update-problems", base_url)) + .json(&payload) + .timeout(Duration::from_secs(120)) // Longer timeout for repo download + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Generate invite codes for one or more leaderboards +pub async fn admin_generate_invites( + client: &Client, + leaderboards: &[String], + count: u32, +) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let payload = serde_json::json!({ + "leaderboards": leaderboards, + "count": count, + }); + + let resp = client + .post(format!("{}/admin/invites", base_url)) + .json(&payload) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// List invite codes for a leaderboard +pub async fn admin_list_invites(client: &Client, leaderboard_name: &str) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .get(format!( + "{}/admin/leaderboards/{}/invites", + base_url, leaderboard_name + )) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Revoke an invite code +pub async fn admin_revoke_invite(client: &Client, code: &str) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .delete(format!("{}/admin/invites/{}", base_url, code)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Helper to handle admin API responses +async fn handle_admin_response(resp: reqwest::Response) -> Result { + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + resp.json() + .await + .map_err(|e| anyhow!("Failed to parse response: {}", e)) +} + +pub async fn fetch_leaderboards(client: &Client) -> Result> { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .get(format!("{}/leaderboards", base_url)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + return Err(anyhow!("Server returned status {}: {}", status, error_text)); + } + + let leaderboards: Vec = resp.json().await?; + + let mut leaderboard_items = Vec::new(); + for lb in leaderboards { + let _task = lb["task"] + .as_object() + .ok_or_else(|| anyhow!("Invalid JSON structure"))?; + let name = lb["name"] + .as_str() + .ok_or_else(|| anyhow!("Invalid JSON structure"))?; + let description = lb["description"] + .as_str() + .ok_or_else(|| anyhow!("Invalid JSON structure"))?; + + leaderboard_items.push(LeaderboardItem::new( + name.to_string(), + description.to_string(), + )); + } + + Ok(leaderboard_items) +} + +pub async fn fetch_gpus(client: &Client, leaderboard: &str) -> Result> { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .get(format!("{}/gpus/{}", base_url, leaderboard)) + .timeout(Duration::from_secs(120)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + return Err(anyhow!("Server returned status {}: {}", status, error_text)); + } + + let gpus: Vec = resp.json().await?; + + let gpu_items = gpus.into_iter().map(GpuItem::new).collect(); + + Ok(gpu_items) +} + +/// Get the authenticated user's submissions +pub async fn get_user_submissions( + client: &Client, + leaderboard: Option<&str>, + limit: Option, +) -> Result> { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let mut url = format!("{}/user/submissions", base_url); + let mut params = Vec::new(); + if let Some(lb) = leaderboard { + params.push(format!("leaderboard={}", lb)); + } + if let Some(l) = limit { + params.push(format!("limit={}", l)); + } + if !params.is_empty() { + url = format!("{}?{}", url, params.join("&")); + } + + let resp = client + .get(&url) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + + let submissions: Vec = resp.json().await?; + + let mut result = Vec::new(); + for sub in submissions { + let runs = sub["runs"] + .as_array() + .map(|arr| { + arr.iter() + .map(|r| UserSubmissionRun { + gpu_type: r["gpu_type"].as_str().unwrap_or("").to_string(), + score: r["score"].as_f64(), + }) + .collect() + }) + .unwrap_or_default(); + + result.push(UserSubmission { + id: sub["id"].as_i64().unwrap_or(0), + leaderboard_name: sub["leaderboard_name"].as_str().unwrap_or("").to_string(), + file_name: sub["file_name"].as_str().unwrap_or("").to_string(), + submission_time: sub["submission_time"].as_str().unwrap_or("").to_string(), + done: sub["done"].as_bool().unwrap_or(false), + runs, + }); + } + + Ok(result) +} + +/// Get a specific submission by ID (with code) +pub async fn get_user_submission(client: &Client, submission_id: i64) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .get(format!("{}/user/submissions/{}", base_url, submission_id)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + + let sub: Value = resp.json().await?; + + let runs = sub["runs"] + .as_array() + .map(|arr| { + arr.iter() + .map(|r| SubmissionRun { + start_time: r["start_time"].as_str().map(str::to_string), + end_time: r["end_time"].as_str().map(str::to_string), + mode: r["mode"].as_str().unwrap_or("").to_string(), + secret: r["secret"].as_bool().unwrap_or(false), + runner: r["runner"].as_str().unwrap_or("").to_string(), + score: r["score"].as_f64(), + passed: r["passed"].as_bool().unwrap_or(false), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(SubmissionDetails { + id: sub["id"].as_i64().unwrap_or(0), + leaderboard_id: sub["leaderboard_id"].as_i64().unwrap_or(0), + leaderboard_name: sub["leaderboard_name"].as_str().unwrap_or("").to_string(), + file_name: sub["file_name"].as_str().unwrap_or("").to_string(), + user_id: sub["user_id"].as_str().unwrap_or("").to_string(), + submission_time: sub["submission_time"].as_str().unwrap_or("").to_string(), + done: sub["done"].as_bool().unwrap_or(false), + code: sub["code"].as_str().unwrap_or("").to_string(), + runs, + }) +} + +/// Delete a user's submission by ID +pub async fn delete_user_submission(client: &Client, submission_id: i64) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .delete(format!("{}/user/submissions/{}", base_url, submission_id)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + + resp.json() + .await + .map_err(|e| anyhow!("Failed to parse response: {}", e)) +} + +/// Claim an invite code to join closed leaderboard(s) +pub async fn join_with_invite(client: &Client, code: &str) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let payload = serde_json::json!({ "code": code }); + + let resp = client + .post(format!("{}/user/join", base_url)) + .json(&payload) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + + resp.json() + .await + .map_err(|e| anyhow!("Failed to parse response: {}", e)) +} + +pub async fn submit_solution>( + client: &Client, + filepath: P, + file_content: &[u8], + leaderboard: &str, + gpu: &str, + submission_mode: &str, + on_log: Option>, +) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let filename = filepath + .as_ref() + .file_name() + .ok_or_else(|| anyhow!("Invalid filepath"))? + .to_string_lossy(); + + let part = Part::bytes(file_content.to_vec()).file_name(filename.to_string()); + + let form = Form::new().part("file", part); + + let url = format!( + "{}/{}/{}/{}", + base_url, + leaderboard.to_lowercase(), + gpu, + submission_mode.to_lowercase() + ); + + let resp = client + .post(&url) + .multipart(form) + .timeout(Duration::from_secs(3600)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + + if resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|s| s.starts_with("text/event-stream")) + { + let mut resp = resp; + let mut buffer = String::new(); + let mut stderr = tokio::io::stderr(); + + while let Some(chunk) = resp.chunk().await? { + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(pos) = buffer.find("\n\n") { + let message_str = buffer.drain(..pos + 2).collect::(); + let mut event_type = None; + let mut data_json = None; + + for line in message_str.lines() { + if let Some(stripped) = line.strip_prefix("event:") { + event_type = Some(stripped.trim()); + } else if let Some(stripped) = line.strip_prefix("data:") { + data_json = Some(stripped.trim()); + } + } + + if let (Some(event), Some(data)) = (event_type, data_json) { + match event { + "status" => { + if let Some(ref cb) = on_log { + // Try to parse as JSON and extract "message" or just return raw data + if let Ok(val) = serde_json::from_str::(data) { + if let Some(msg) = val.get("message").and_then(|m| m.as_str()) { + cb(msg.to_string()); + } else { + cb(data.to_string()); + } + } else { + cb(data.to_string()); + } + } + } + "result" => { + let result_val: Value = serde_json::from_str(data)?; + + if let Some(ref cb) = on_log { + // Handle "results" array + if let Some(results_array) = + result_val.get("results").and_then(|v| v.as_array()) + { + let mode_key = submission_mode.to_lowercase(); + + // Special handling for profile mode + if mode_key == "profile" { + for (i, result_item) in results_array.iter().enumerate() { + if let Some(runs) = + result_item.get("runs").and_then(|r| r.as_object()) + { + for (key, run_data) in runs.iter() { + if key.starts_with("profile") { + handle_profile_result(cb, run_data, i, key); + } + } + } + } + } else { + // Existing handling for non-profile modes + for (i, result_item) in results_array.iter().enumerate() { + if let Some(run_obj) = result_item + .get("runs") + .and_then(|r| r.get(&mode_key)) + .and_then(|t| t.get("run")) + { + if let Some(stdout) = + run_obj.get("stdout").and_then(|s| s.as_str()) + { + if !stdout.is_empty() { + cb(format!( + "STDOUT (Run {}):\n{}", + i + 1, + stdout + )); + } + } + // Also check stderr + if let Some(stderr) = + run_obj.get("stderr").and_then(|s| s.as_str()) + { + if !stderr.is_empty() { + cb(format!( + "STDERR (Run {}):\n{}", + i + 1, + stderr + )); + } + } + } + } + } + } else { + // Fallback for single object or different structure + if let Some(stdout) = + result_val.get("stdout").and_then(|s| s.as_str()) + { + if !stdout.is_empty() { + cb(format!("STDOUT:\n{}", stdout)); + } + } + } + } + + if let Some(reports) = result_val.get("reports") { + return Ok(reports.to_string()); + } else { + // If no reports, return the whole result as a string + return Ok(serde_json::to_string_pretty(&result_val)?); + } + } + "error" => { + let error_val: Value = serde_json::from_str(data)?; + let detail = error_val + .get("detail") + .and_then(|d| d.as_str()) + .unwrap_or("Unknown server error"); + let status_code = error_val.get("status_code").and_then(|s| s.as_i64()); + let raw_error = error_val.get("raw_error").and_then(|e| e.as_str()); + + let mut error_msg = format!("Server processing error: {}", detail); + if let Some(sc) = status_code { + error_msg.push_str(&format!(" (Status Code: {})", sc)); + } + if let Some(re) = raw_error { + error_msg.push_str(&format!(" | Raw Error: {}", re)); + } + + return Err(anyhow!(error_msg)); + } + _ => { + let msg = format!("Ignoring unknown SSE event: {}\n", event); + if let Some(ref cb) = on_log { + cb(msg.clone()); + } + stderr.write_all(msg.as_bytes()).await?; + stderr.flush().await?; + } + } + } + } + } + Err(anyhow!( + "Stream ended unexpectedly without a final result or error event." + )) + } else { + let result: Value = resp.json().await?; + let pretty_result = match result.get("results") { + Some(result_obj) => serde_json::to_string_pretty(result_obj)?, + None => return Err(anyhow!("Invalid non-streaming response structure")), + }; + Ok(pretty_result) + } +} + +/// Handle profile mode results by decoding and displaying profile data, +/// and saving trace files to the current directory. +fn handle_profile_result( + cb: &(dyn Fn(String) + Send + Sync), + run_data: &Value, + result_idx: usize, + run_key: &str, +) { + // 1. Get profiler type and display it + if let Some(profile) = run_data.get("profile") { + let profiler = profile + .get("profiler") + .and_then(|p| p.as_str()) + .unwrap_or("Unknown"); + cb(format!("\n=== Profiler: {} ===", profiler)); + + // 2. Decode and display profile report from run.result + if let Some(run) = run_data.get("run") { + // Display stdout/stderr if present + if let Some(stdout) = run.get("stdout").and_then(|s| s.as_str()) { + if !stdout.is_empty() { + cb(format!("STDOUT:\n{}", stdout)); + } + } + if let Some(stderr) = run.get("stderr").and_then(|s| s.as_str()) { + if !stderr.is_empty() { + cb(format!("STDERR:\n{}", stderr)); + } + } + + // Extract and decode profile report from result + if let Some(result) = run.get("result").and_then(|r| r.as_object()) { + let bench_count = result + .get("benchmark-count") + .and_then(|c| c.as_i64()) + .unwrap_or(0); + + for i in 0..bench_count { + // Get benchmark spec + let spec_key = format!("benchmark.{}.spec", i); + let spec = result + .get(&spec_key) + .and_then(|s| s.as_str()) + .unwrap_or("unknown"); + cb(format!("\nBenchmark: {}", spec)); + + // Decode and display the profile report + let report_key = format!("benchmark.{}.report", i); + if let Some(encoded_report) = result.get(&report_key).and_then(|r| r.as_str()) { + match base64::engine::general_purpose::STANDARD.decode(encoded_report) { + Ok(decoded) => { + if let Ok(report_text) = String::from_utf8(decoded) { + cb(format!("\n{}", report_text)); + } + } + Err(e) => cb(format!("Failed to decode profile report: {}", e)), + } + } + } + } + } + + // 3. Save trace file with unique timestamp + if let Some(trace_b64) = profile.get("trace").and_then(|t| t.as_str()) { + if !trace_b64.is_empty() { + match base64::engine::general_purpose::STANDARD.decode(trace_b64) { + Ok(trace_data) => { + match write_profile_trace_file(&trace_data, Utc::now(), result_idx, run_key) + { + Ok(filename) => cb(format!("\nSaved profile trace to: {}", filename)), + Err(e) => cb(format!("Failed to save trace file: {}", e)), + } + } + Err(e) => cb(format!("Failed to decode trace data: {}", e)), + } + } + } + + // 4. Show download URL if available + if let Some(url) = profile.get("download_url").and_then(|u| u.as_str()) { + if !url.is_empty() { + cb(format!("Download full profile: {}", url)); + } + } + } +} + +fn sanitize_profile_run_key(run_key: &str) -> String { + let sanitized: String = run_key + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + ch + } else { + '_' + } + }) + .collect(); + + if sanitized.is_empty() { + "profile".to_string() + } else { + sanitized + } +} + +fn build_profile_trace_filename( + timestamp: DateTime, + result_idx: usize, + run_key: &str, +) -> String { + let run_key = sanitize_profile_run_key(run_key); + format!( + "profile_{}_result{}_{}.zip", + timestamp.format("%Y%m%d_%H%M%S"), + result_idx, + run_key + ) +} + +fn write_profile_trace_file( + trace_data: &[u8], + timestamp: DateTime, + result_idx: usize, + run_key: &str, +) -> std::io::Result { + let filename = build_profile_trace_filename(timestamp, result_idx, run_key); + std::fs::write(&filename, trace_data)?; + Ok(filename) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use tempfile::tempdir; + + #[test] + fn test_create_client_without_cli_id() { + let client = create_client(None); + + assert!(client.is_ok()); + } + + #[test] + fn test_create_client_with_valid_cli_id() { + let client = create_client(Some("valid-cli-id-123".to_string())); + + assert!(client.is_ok()); + } + + #[test] + fn test_create_client_with_empty_cli_id() { + let client = create_client(Some("".to_string())); + + assert!(client.is_ok()); + } + + #[test] + fn test_create_client_with_invalid_header_chars() { + // Headers cannot contain newlines or certain control characters + let client = create_client(Some("invalid\nheader".to_string())); + + assert!(client.is_err()); + let err_msg = client.unwrap_err().to_string(); + assert!(err_msg.contains("Invalid cli_id format")); + } + + #[tokio::test] + async fn test_fetch_leaderboards_missing_env_var() { + // Temporarily unset the env var if set + let original = std::env::var("POPCORN_API_URL").ok(); + std::env::remove_var("POPCORN_API_URL"); + + let client = create_client(None).unwrap(); + let result = fetch_leaderboards(&client).await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("POPCORN_API_URL")); + + // Restore original value if it existed + if let Some(val) = original { + std::env::set_var("POPCORN_API_URL", val); + } + } + + #[tokio::test] + async fn test_fetch_gpus_missing_env_var() { + let original = std::env::var("POPCORN_API_URL").ok(); + std::env::remove_var("POPCORN_API_URL"); + + let client = create_client(None).unwrap(); + let result = fetch_gpus(&client, "test-leaderboard").await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("POPCORN_API_URL")); + + if let Some(val) = original { + std::env::set_var("POPCORN_API_URL", val); + } + } + + #[tokio::test] + async fn test_submit_solution_missing_env_var() { + let original = std::env::var("POPCORN_API_URL").ok(); + std::env::remove_var("POPCORN_API_URL"); + + let client = create_client(None).unwrap(); + let result = submit_solution( + &client, + "test.py", + b"print('hello')", + "test-leaderboard", + "H100", + "test", + None, + ) + .await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("POPCORN_API_URL")); + + if let Some(val) = original { + std::env::set_var("POPCORN_API_URL", val); + } + } + + #[test] + fn test_build_profile_trace_filename_uses_result_index_and_run_key() { + let timestamp = Utc + .with_ymd_and_hms(2026, 3, 27, 9, 38, 46) + .single() + .unwrap(); + + let filename = build_profile_trace_filename(timestamp, 0, "profile3"); + + assert_eq!(filename, "profile_20260327_093846_result0_profile3.zip"); + } + + #[test] + fn test_build_profile_trace_filename_sanitizes_run_key() { + let timestamp = Utc + .with_ymd_and_hms(2026, 3, 27, 9, 38, 46) + .single() + .unwrap(); + + let filename = build_profile_trace_filename(timestamp, 1, "profile:1/a b"); + + assert_eq!( + filename, + "profile_20260327_093846_result1_profile_1_a_b.zip" + ); + } + + #[test] + fn test_build_profile_trace_filename_uses_default_run_key_when_empty() { + let timestamp = Utc + .with_ymd_and_hms(2026, 3, 27, 9, 38, 46) + .single() + .unwrap(); + + let filename = build_profile_trace_filename(timestamp, 2, ""); + + assert_eq!(filename, "profile_20260327_093846_result2_profile.zip"); + } + + #[test] + fn test_write_profile_trace_file_writes_expected_contents() { + let temp_dir = tempdir().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + let timestamp = Utc + .with_ymd_and_hms(2026, 3, 27, 9, 38, 46) + .single() + .unwrap(); + let trace_data = b"trace-bytes"; + + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let filename = write_profile_trace_file(trace_data, timestamp, 3, "profile/3").unwrap(); + let written_path = temp_dir.path().join(&filename); + + assert_eq!(filename, "profile_20260327_093846_result3_profile_3.zip"); + assert_eq!(std::fs::read(&written_path).unwrap(), trace_data); + + std::env::set_current_dir(original_dir).unwrap(); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..7bf7b7a --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,338 @@ +use anyhow::Result; +use std::fs; +use std::path::Path; + +pub struct PopcornDirectives { + pub leaderboard_name: String, + pub gpus: Vec, +} + +pub fn is_archive_file>(filepath: P) -> bool { + let path = filepath.as_ref(); + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + name.ends_with(".tar.gz") || name.ends_with(".tgz") || name.ends_with(".zip") +} + +pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDirectives, bool)> { + // Archive files (tarballs, zips) are binary and cannot contain directives + if is_archive_file(&filepath) { + return Ok(( + PopcornDirectives { + leaderboard_name: String::new(), + gpus: Vec::new(), + }, + false, + )); + } + + let content = fs::read_to_string(filepath)?; + + let mut gpus: Vec = Vec::new(); + let mut leaderboard_name = String::new(); + let mut has_multiple_gpus = false; + + for line in content.lines() { + if !line.starts_with("//") && !line.starts_with("#") { + continue; + } + + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + + if parts[0] == "//!POPCORN" || parts[0] == "#!POPCORN" { + let arg = parts[1].to_lowercase(); + if arg == "gpu" || arg == "gpus" { + gpus = parts[2..].iter().map(|s| s.to_string()).collect(); + } else if arg == "leaderboard" && parts.len() > 2 { + leaderboard_name = parts[2].to_string(); + } + } + } + + if gpus.len() > 1 { + has_multiple_gpus = true; + gpus = vec![gpus[0].clone()]; + } + + Ok(( + PopcornDirectives { + leaderboard_name, + gpus, + }, + has_multiple_gpus, + )) +} + +pub fn get_ascii_art_frame(frame: u16) -> String { + let frame = frame % 3; + match frame { + 0 => r#" + ▗▖ ▗▖▗▄▄▄▖▗▄▄▖ ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▄▄▖ ▗▄▖ ▗▄▄▄▖ + ▐▌▗▞▘▐▌ ▐▌ ▐▌▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌ █ + ▐▛▚▖ ▐▛▀▀▘▐▛▀▚▖▐▌ ▝▜▌▐▛▀▀▘▐▌ ▐▛▀▚▖▐▌ ▐▌ █ + ▐▌ ▐▌▐▙▄▄▖▐▌ ▐▌▐▌ ▐▌▐▙▄▄▖▐▙▄▄▖▐▙▄▞▘▝▚▄▞▘ █ + + POPCORN CLI - GPU MODE + + ┌────────────────────────────────────────────┐ + │ ╔══════════════════════════════════╗ ϟ │ + │ ║ ▄▄ Graphics Processing Unit ▄▄║ ║ │▒ + │ ║ ██████ 80GB HBM3 MEMORY █║ ║ │▒ + │ ║ ▀▀▀▀▀▀ 700W TDP █║ ║ │▒ + │ ╚══════════════════════════════════╝ │▒ + │ ┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐ │▒ + │ │:::::││:::::││:::::││:::::││:::::│ │▒ + │ └─────┘└─────┘└─────┘└─────┘└─────┘ │▒ + │ ┌──────────────────────────────────┐ │▒ + │ │ discord.com/invite/gpumode │ │▒ + │ │ ═══╧═══╧═══╧═══╧═══╧═══╧═══ │ │▒ + │ └──────────────────────────────────┘ │▒ + └────────────────────────────────────────────┘▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"# + .to_string(), + 1 => r#" + ▗▖ ▗▖▗▄▄▄▖▗▄▄▖ ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▄▄▖ ▗▄▖ ▗▄▄▄▖ + ▐▌▗▞▘▐▌ ▐▌ ▐▌▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌ █ + ▐▛▚▖ ▐▛▀▀▘▐▛▀▚▖▐▌ ▝▜▌▐▛▀▀▘▐▌ ▐▛▀▚▖▐▌ ▐▌ █ + ▐▌ ▐▌▐▙▄▄▖▐▌ ▐▌▐▌ ▐▌▐▙▄▄▖▐▙▄▄▖▐▙▄▞▘▝▚▄▞▘ █ + + POPCORN CLI - GPU MODE + + ┌────────────────────────────────────────────┐ + │ ╔══════════════════════════════════╗ ϟϟ │ + │ ║ ▄▄ Graphics Processing Unit ▄▄║ ║ │▒ + │ ║ ██████ 80GB HBM3 MEMORY ███║ ║ │▒ + │ ║ ▀▀▀▀▀▀ 700W TDP ███║ ║ │▒ + │ ╚══════════════════════════════════╝ │▒ + │ ┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐ │▒ + │ │:::::││:::::││:::::││:::::││:::::│ │▒ + │ └─────┘└─────┘└─────┘└─────┘└─────┘ │▒ + │ ┌──────────────────────────────────┐ │▒ + │ │ discord.com/invite/gpumode │ │▒ + │ │ ═══╧═══╧═══╧═══╧═══╧═══╧═══ │ │▒ + │ └──────────────────────────────────┘ │▒ + └────────────────────────────────────────────┘▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"# + .to_string(), + _ => r#" + ▗▖ ▗▖▗▄▄▄▖▗▄▄▖ ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▄▄▖ ▗▄▖ ▗▄▄▄▖ + ▐▌▗▞▘▐▌ ▐▌ ▐▌▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌ █ + ▐▛▚▖ ▐▛▀▀▘▐▛▀▚▖▐▌ ▝▜▌▐▛▀▀▘▐▌ ▐▛▀▚▖▐▌ ▐▌ █ + ▐▌ ▐▌▐▙▄▄▖▐▌ ▐▌▐▌ ▐▌▐▙▄▄▖▐▙▄▄▖▐▙▄▞▘▝▚▄▞▘ █ + + POPCORN CLI - GPU MODE + + ┌────────────────────────────────────────────┐ + │ ╔══════════════════════════════════╗ ϟϟϟ │ + │ ║ ▄▄ Graphics Processing Unit ▄▄║ ║ │▒ + │ ║ ██████ 80GB HBM3 MEMORY █████║ ║ │▒ + │ ║ ▀▀▀▀▀▀ 700W TDP █████║ ║ │▒ + │ ╚══════════════════════════════════╝ │▒ + │ ┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐ │▒ + │ │:::::││:::::││:::::││:::::││:::::│ │▒ + │ └─────┘└─────┘└─────┘└─────┘└─────┘ │▒ + │ ┌──────────────────────────────────┐ │▒ + │ │ discord.com/invite/gpumode │ │▒ + │ │ ═══╧═══╧═══╧═══╧═══╧═══╧═══ │ │▒ + │ └──────────────────────────────────┘ │▒ + └────────────────────────────────────────────┘▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"# + .to_string(), + } +} + +pub fn custom_wrap( + initial_text: String, + remaining_text: String, + available_width: usize, +) -> Vec { + let mut lines = vec![initial_text]; + let mut current_line = String::with_capacity(available_width); + for word in remaining_text.split_whitespace() { + if word.len() > available_width { + if !current_line.is_empty() { + lines.push(current_line.clone()); + current_line.clear(); + } + lines.push(word.to_string()); + } else if current_line.is_empty() { + current_line.push_str(word); + } else if current_line.len() + word.len() < available_width { + current_line.push(' '); + current_line.push_str(word); + } else { + lines.push(current_line.clone()); + current_line.clear(); + current_line.push_str(word); + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + lines +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + // Tests for get_popcorn_directives + + #[test] + fn test_parse_python_style_directives() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "#!POPCORN leaderboard my-leaderboard").unwrap(); + writeln!(file, "#!POPCORN gpu H100").unwrap(); + writeln!(file).unwrap(); + writeln!(file, "def main():").unwrap(); + writeln!(file, " pass").unwrap(); + + let (directives, has_multiple_gpus) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, "my-leaderboard"); + assert_eq!(directives.gpus, vec!["H100"]); + assert!(!has_multiple_gpus); + } + + #[test] + fn test_parse_cpp_style_directives() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "//!POPCORN leaderboard amd-fp8-mm").unwrap(); + writeln!(file, "//!POPCORN gpu MI300").unwrap(); + writeln!(file).unwrap(); + writeln!(file, "int main() {{ return 0; }}").unwrap(); + + let (directives, has_multiple_gpus) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, "amd-fp8-mm"); + assert_eq!(directives.gpus, vec!["MI300"]); + assert!(!has_multiple_gpus); + } + + #[test] + fn test_parse_multiple_gpus_truncates_to_first() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "#!POPCORN leaderboard test").unwrap(); + writeln!(file, "#!POPCORN gpus H100 MI300 A100").unwrap(); + + let (directives, has_multiple_gpus) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, "test"); + assert_eq!(directives.gpus, vec!["H100"]); + assert!(has_multiple_gpus); + } + + #[test] + fn test_parse_gpu_vs_gpus_keyword() { + // Test "gpu" keyword + let mut file1 = NamedTempFile::new().unwrap(); + writeln!(file1, "#!POPCORN gpu A100").unwrap(); + let (directives1, _) = get_popcorn_directives(file1.path()).unwrap(); + assert_eq!(directives1.gpus, vec!["A100"]); + + // Test "gpus" keyword + let mut file2 = NamedTempFile::new().unwrap(); + writeln!(file2, "#!POPCORN gpus V100").unwrap(); + let (directives2, _) = get_popcorn_directives(file2.path()).unwrap(); + assert_eq!(directives2.gpus, vec!["V100"]); + } + + #[test] + fn test_parse_empty_file_returns_empty_directives() { + let file = NamedTempFile::new().unwrap(); + + let (directives, has_multiple_gpus) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, ""); + assert!(directives.gpus.is_empty()); + assert!(!has_multiple_gpus); + } + + #[test] + fn test_parse_ignores_non_directive_comments() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "# This is a regular comment").unwrap(); + writeln!(file, "// Another regular comment").unwrap(); + writeln!(file, "#!POPCORN leaderboard real-leaderboard").unwrap(); + writeln!(file, "# POPCORN gpu should-be-ignored").unwrap(); + + let (directives, _) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, "real-leaderboard"); + assert!(directives.gpus.is_empty()); + } + + #[test] + fn test_parse_case_insensitive_directive_args() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "#!POPCORN GPU H100").unwrap(); + writeln!(file, "#!POPCORN LEADERBOARD TEST").unwrap(); + + let (directives, _) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.gpus, vec!["H100"]); + assert_eq!(directives.leaderboard_name, "TEST"); + } + + #[test] + fn test_parse_nonexistent_file_returns_error() { + let result = get_popcorn_directives("/nonexistent/path/file.py"); + assert!(result.is_err()); + } + + // Tests for custom_wrap + + #[test] + fn test_wrap_simple_text() { + let result = custom_wrap("Header:".to_string(), "hello world".to_string(), 20); + + assert_eq!(result, vec!["Header:", "hello world"]); + } + + #[test] + fn test_wrap_breaks_at_width() { + let result = custom_wrap("".to_string(), "one two three four".to_string(), 10); + + assert_eq!(result, vec!["", "one two", "three four"]); + } + + #[test] + fn test_wrap_handles_long_words() { + let result = custom_wrap( + "".to_string(), + "short verylongwordthatexceedswidth short".to_string(), + 10, + ); + + assert_eq!( + result, + vec!["", "short", "verylongwordthatexceedswidth", "short"] + ); + } + + #[test] + fn test_wrap_empty_remaining_text() { + let result = custom_wrap("Header".to_string(), "".to_string(), 20); + + assert_eq!(result, vec!["Header"]); + } + + #[test] + fn test_wrap_preserves_initial_text() { + let result = custom_wrap("PREFIX: ".to_string(), "some text".to_string(), 20); + + assert_eq!(result[0], "PREFIX: "); + } +} diff --git a/src/views/loading_page.rs b/src/views/loading_page.rs new file mode 100644 index 0000000..02a809c --- /dev/null +++ b/src/views/loading_page.rs @@ -0,0 +1,83 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Layout, Rect}, + style::{Color, Stylize}, + widgets::{Block, Gauge, Padding, Paragraph, StatefulWidget, Widget}, +}; + +#[derive(Debug, Default, Clone)] +pub struct LoadingPageState { + pub loop_count: u16, + pub progress_column: u16, + pub progress_bar: f64, +} + +#[derive(Default, Debug, PartialEq, Eq, Clone)] +pub struct LoadingPage { + header_area: Rect, + gauge_area: Rect, + footer_area: Rect, +} + +fn get_gradient_color(progress: f64) -> Color { + // Convert progress from 0-100 to 0-1 + let t = progress / 100.0; + + // Start with red (255, 0, 0) and end with green (0, 255, 0) + let r = ((1.0 - t) * 255.0) as u8; + let g = (t * 255.0) as u8; + let b = 0; + + Color::Rgb(r, g, b) +} + +impl StatefulWidget for &LoadingPage { + type State = LoadingPageState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + use ratatui::layout::Constraint::Percentage; + + let layout = Layout::vertical([Percentage(45), Percentage(10), Percentage(45)]); + + let [_, gauge_area, footer_area] = layout.areas(area); + + render_gauge(gauge_area, buf, state); + render_footer(footer_area, buf, state); + } +} + +fn render_gauge(area: Rect, buf: &mut Buffer, state: &mut LoadingPageState) { + let blk = Block::default().padding(Padding::horizontal(20)); + Gauge::default() + .block(blk) + .gauge_style(get_gradient_color(state.progress_bar)) + .ratio(state.progress_bar / 100.0) + .render(area, buf); +} + +fn get_footer_text(state: &LoadingPageState) -> String { + let percentage = state.progress_bar; + + if state.loop_count > 0 { + return "Did you know we have zero idea how long this will take?".to_string(); + } + + if percentage > 75.0 { + "Almost there!".to_string() + } else if percentage > 35.0 { + "Crunching numbers...".to_string() + } else { + "This is taking a while, huh?".to_string() + } +} + +fn render_footer(area: Rect, buf: &mut Buffer, state: &LoadingPageState) { + let blk = Block::default().padding(Padding::vertical(1)); + let text = Paragraph::new(get_footer_text(state)) + .alignment(Alignment::Center) + .fg(Color::White) + .bold() + .block(blk); + + text.render(area, buf); +} diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 0000000..f396fbb --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,2 @@ +pub mod loading_page; +pub mod result_page; diff --git a/src/views/result_page.rs b/src/views/result_page.rs new file mode 100644 index 0000000..16f6f4d --- /dev/null +++ b/src/views/result_page.rs @@ -0,0 +1,159 @@ +use crate::utils; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::{ + layout::{Alignment, Constraint, Layout, Margin, Rect}, + prelude::Buffer, + style::{Color, Style}, + symbols::scrollbar, + widgets::{Block, BorderType, Paragraph, Scrollbar, ScrollbarState, StatefulWidget, Widget}, +}; + +#[derive(Default, Debug)] +pub struct ResultPageState { + pub vertical_scroll: u16, + pub vertical_scroll_state: ScrollbarState, + pub horizontal_scroll: u16, + pub horizontal_scroll_state: ScrollbarState, + pub ack: bool, + pub animation_frame: u16, +} + +#[derive(Default, Debug)] +pub struct ResultPage { + result_text: Paragraph<'static>, +} + +impl ResultPage { + pub fn new(result_text: String, state: &mut ResultPageState) -> Self { + let max_width = result_text + .lines() + .map(|line| line.len()) + .max() + .unwrap_or(0); + + let num_lines = result_text.lines().count(); + + state.vertical_scroll_state = state.vertical_scroll_state.content_length(num_lines); + + state.horizontal_scroll_state = state.horizontal_scroll_state.content_length(max_width); + state.animation_frame = 0; + + Self { + result_text: Paragraph::new(result_text), + } + } + + fn render_left(&self, buf: &mut Buffer, left: Rect, state: &mut ResultPageState) { + let left_block = Block::bordered() + .border_type(BorderType::Plain) + .border_style(Style::default().fg(Color::Rgb(255, 165, 0))) + .title("GPU MODE") + .title_alignment(Alignment::Center); + + let left_text = Paragraph::new(utils::get_ascii_art_frame(state.animation_frame / 5)); + + left_text.block(left_block).render(left, buf); + } + + fn render_right(&self, buf: &mut Buffer, right: Rect, state: &mut ResultPageState) { + let right_block = Block::bordered() + .border_type(BorderType::Plain) + .border_style(Style::default().fg(Color::Rgb(255, 165, 0))) + .title_alignment(Alignment::Center) + .title("Submission Results") + .title_bottom("Press q to quit...") + .title_style(Style::default().fg(Color::Magenta)); + + let result_text = self + .result_text + .clone() + .block(right_block) + .scroll((state.vertical_scroll, state.horizontal_scroll)); + result_text.render(right, buf); + } + + pub fn handle_key_event(&mut self, state: &mut ResultPageState) { + // Use a non-blocking poll + if let Ok(true) = event::poll(std::time::Duration::from_millis(0)) { + if let Ok(Event::Key(key)) = event::read() { + if key.kind != KeyEventKind::Press { + return; + } + if key.code == KeyCode::Char('q') { + state.ack = true; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + state.ack = true; + } + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + state.vertical_scroll = state.vertical_scroll.saturating_add(1); + state.vertical_scroll_state = state + .vertical_scroll_state + .position(state.vertical_scroll as usize); + } + KeyCode::Char('k') | KeyCode::Up => { + state.vertical_scroll = state.vertical_scroll.saturating_sub(1); + state.vertical_scroll_state = state + .vertical_scroll_state + .position(state.vertical_scroll as usize); + } + KeyCode::Char('h') | KeyCode::Left => { + state.horizontal_scroll = state.horizontal_scroll.saturating_sub(1); + state.horizontal_scroll_state = state + .horizontal_scroll_state + .position(state.horizontal_scroll as usize); + } + KeyCode::Char('l') | KeyCode::Right => { + state.horizontal_scroll = state.horizontal_scroll.saturating_add(1); + state.horizontal_scroll_state = state + .horizontal_scroll_state + .position(state.horizontal_scroll as usize); + } + _ => {} + } + } + } + } +} + +impl StatefulWidget for &ResultPage { + type State = ResultPageState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut ResultPageState) { + // Increment animation frame on every render + state.animation_frame = state.animation_frame.wrapping_add(1); + + let layout = Layout::horizontal([Constraint::Percentage(45), Constraint::Percentage(55)]); + let [left, right] = layout.areas(area); + + self.render_left(buf, left, state); + self.render_right(buf, right, state); + + let vertical_scrollbar = + Scrollbar::new(ratatui::widgets::ScrollbarOrientation::VerticalLeft) + .symbols(scrollbar::VERTICAL); + + let horizontal_scrollbar = + Scrollbar::new(ratatui::widgets::ScrollbarOrientation::HorizontalBottom) + .symbols(scrollbar::HORIZONTAL); + + vertical_scrollbar.render( + right.inner(&Margin { + vertical: 1, + horizontal: 0, + }), + buf, + &mut state.vertical_scroll_state, + ); + horizontal_scrollbar.render( + right.inner(&Margin { + vertical: 0, + horizontal: 1, + }), + buf, + &mut state.horizontal_scroll_state, + ); + } +} diff --git a/templates/setup/AGENTS.md b/templates/setup/AGENTS.md new file mode 100644 index 0000000..15177cb --- /dev/null +++ b/templates/setup/AGENTS.md @@ -0,0 +1,21 @@ +# Kernel Bot Submissions + +The Popcorn CLI is intended to submit kernels to leaderboards on GPU Mode's Kernel Bot. + +## Objective + +Agents must write CUDA C and C++ kernels and integrate them into the single-file `submission.py` workflow. + +Do not submit pure PyTorch based optimization. The objective of the task is to use any kernel DSL like CUDA or Triton to improved *beyond* the performance of native PyTorch. + +## Skills +A skill is a local instruction bundle stored in `SKILL.md`. + +### Available skills +- {{SKILL_NAME}}: Helps with Popcorn CLI registration, submission setup, submission modes, and file directives. (file: {{SKILL_PATH}}) +- {{NATIVE_SKILL_NAME}}: Helps write CUDA and HIP kernels using torch.utils.cpp_extension.load_inline(). Use when writing native GPU code inside a Python submission. (file: {{NATIVE_SKILL_PATH}}) + +### How to use skills +- Load the skill by reading its `SKILL.md` file when user requests match the description. +- Follow progressive disclosure: read only relevant referenced files/scripts as needed. +- Keep the workspace setup aligned with `popcorn setup`. diff --git a/templates/setup/skills/load-inline-native-code/SKILL.md b/templates/setup/skills/load-inline-native-code/SKILL.md new file mode 100644 index 0000000..ee23351 --- /dev/null +++ b/templates/setup/skills/load-inline-native-code/SKILL.md @@ -0,0 +1,143 @@ +--- +name: load-inline-native-code +description: Helps write CUDA and HIP kernels using torch.utils.cpp_extension.load_inline(). Use when users want to write native GPU code (CUDA/HIP) inside a Python submission file. +compatibility: Intended for popcorn-cli submissions targeting NVIDIA or AMD GPUs with native kernel code. +--- + +# Writing Native GPU Kernels with load_inline() + +Use this skill when the user wants to write a custom CUDA or HIP kernel inside their Python submission file using `torch.utils.cpp_extension.load_inline()`. + +## Overview + +`load_inline()` compiles C++/CUDA/HIP source code at runtime and loads it as a Python module. This lets you write raw GPU kernels directly in your `submission.py` without a separate build system. + +## CUDA Template (NVIDIA GPUs) + +```python +import torch +from torch.utils.cpp_extension import load_inline +from task import input_t, output_t + +CUDA_SRC = """ +template +__global__ void my_kernel(const scalar_t* __restrict__ input, + scalar_t* __restrict__ output, + int N) { + int idx = blockIdx.x * blockDim.x + threadIdx.x; + if (idx < N) { + output[idx] = input[idx]; + } +} + +torch::Tensor my_op(torch::Tensor input, torch::Tensor output) { + int N = input.numel(); + const int threads = 256; + const int blocks = (N + threads - 1) / threads; + + AT_DISPATCH_FLOATING_TYPES_AND_HALF(input.scalar_type(), "my_kernel", ([&] { + my_kernel<<>>( + input.data_ptr(), + output.data_ptr(), + N + ); + })); + + cudaError_t err = cudaGetLastError(); + if (err != cudaSuccess) { + throw std::runtime_error(cudaGetErrorString(err)); + } + return output; +} +""" + +CPP_SRC = """ +torch::Tensor my_op(torch::Tensor input, torch::Tensor output); +""" + +module = load_inline( + name='my_module', + cpp_sources=[CPP_SRC], + cuda_sources=[CUDA_SRC], + functions=['my_op'], + verbose=True, +) + +def custom_kernel(data: input_t) -> output_t: + input, output = data + return module.my_op(input, output) +``` + +## HIP Template (AMD GPUs) + +```python +import os +os.environ['PYTORCH_ROCM_ARCH'] = 'gfx942' +os.environ['CXX'] = 'clang++' + +import torch +from torch.utils.cpp_extension import load_inline +from task import input_t, output_t + +CUDA_SRC = """ +#include + +__global__ void my_kernel(const float* input, float* output, int N) { + int idx = blockIdx.x * blockDim.x + threadIdx.x; + if (idx < N) { + output[idx] = input[idx]; + } +} + +void my_op(torch::Tensor input, torch::Tensor output) { + int N = input.numel(); + const int threads = 256; + const int blocks = (N + threads - 1) / threads; + my_kernel<<>>( + input.data_ptr(), + output.data_ptr(), + N + ); +} +""" + +CPP_SRC = """ +void my_op(torch::Tensor input, torch::Tensor output); +""" + +module = load_inline( + name='my_module', + cpp_sources=[CPP_SRC], + cuda_sources=[CUDA_SRC], + functions=['my_op'], + verbose=True, + extra_cuda_cflags=["--offload-arch=gfx942", "-std=c++20"], +) + +def custom_kernel(data: input_t) -> output_t: + input, output = data + module.my_op(input, output) + return output +``` + +## Key Points + +- **cpp_sources**: C++ header declaring the functions you want to call from Python. These are the bindings. +- **cuda_sources**: The actual CUDA/HIP kernel code and the C++ wrapper that launches it. +- **functions**: List of function names to expose to Python. Must match the C++ function signatures exactly. +- **verbose=True**: Prints compilation output so you can debug build errors. +- **extra_cuda_cflags**: Pass extra compiler flags. Needed for AMD HIP (`--offload-arch=gfx942`) or C++ standard selection. + +## Common Patterns + +- Use `AT_DISPATCH_FLOATING_TYPES_AND_HALF` to handle multiple dtypes in CUDA kernels. +- For AMD/HIP, set `PYTORCH_ROCM_ARCH` and `CXX` env vars **before** importing torch. +- Always check `cudaGetLastError()` after kernel launches for NVIDIA targets. +- The `load_inline` call compiles on first run and caches the result. Subsequent runs reuse the cache unless the source changes. +- Keep the module-level `load_inline()` call **outside** `custom_kernel()` so compilation happens once at import time, not on every call. + +## Guardrails +- The `custom_kernel` function signature must match `def custom_kernel(data: input_t) -> output_t:`. +- The module is compiled at import time. Do not call `load_inline()` inside `custom_kernel()`. +- For AMD GPUs, always set `PYTORCH_ROCM_ARCH` before any torch import. +- Use `torch::Tensor` in C++ signatures for seamless Python-C++ tensor passing. diff --git a/templates/setup/skills/popcorn-submission-workflow/SKILL.md b/templates/setup/skills/popcorn-submission-workflow/SKILL.md new file mode 100644 index 0000000..2b794df --- /dev/null +++ b/templates/setup/skills/popcorn-submission-workflow/SKILL.md @@ -0,0 +1,32 @@ +--- +name: {{SKILL_NAME}} +description: Helps prepare and submit popcorn-cli GPU Mode solutions. Use when users ask to set up a project, create a submission template, or run/register submissions. +compatibility: Intended for popcorn-cli repositories with README.md and shell access. +--- + +# Popcorn Submission Workflow + +Use this skill when the user is working on Popcorn CLI submissions and needs a reliable flow from setup to submit. + +## Recommended workflow +1. Ensure the project has a `submission.py` file with POPCORN directives. +2. Register once with `popcorn register discord` (or `github`) if `.popcorn.yaml` is missing. +3. Use `popcorn submit submission.py` for interactive mode, or `popcorn submit --no-tui ...` for scripts/CI. +4. Use `popcorn submissions list/show/delete` to inspect previous runs. + +## Reference: Authentication (from README) + +{{AUTHENTICATION_SECTION}} + +## Reference: Commands (from README) + +{{COMMANDS_SECTION}} + +## Reference: Submission Format (from README) + +{{SUBMISSION_FORMAT_SECTION}} + +## Guardrails +- Keep submissions as a single Python file. +- Prefer POPCORN directives (`#!POPCORN leaderboard ...`, `#!POPCORN gpu ...`) so defaults are embedded. +- Use `test` or `benchmark` mode before `leaderboard` submissions when iterating.