From a62f1dcdaa5c3fdfccfbab44d6f885dbf948db09 Mon Sep 17 00:00:00 2001 From: Arran Ireland Date: Sun, 23 Aug 2026 09:47:30 +0100 Subject: [PATCH 1/5] cli: add json output, fix enum handling and error propagation - Global --json flag with a shared output helper, replacing ad-hoc println!("{:#?}", ...) everywhere. - Replace hand-rolled enum matches (PerfType, VariantKey, Color, Room) with clap::ValueEnum + From impls, matching the existing puzzles.rs pattern. - Replace println!+Ok(()) short-circuits on invalid input with real clap validation/errors so exit codes reflect failure. - Wrap fallible calls with color-eyre's wrap_err_with for readable error context instead of raw library error Debug output. - Add assert_cmd-based CLI tests covering help output, invalid-enum rejection, and the --json flag. - Add missing Serialize derives on two external_engine response types (and their nested structs) needed for the json output path. --- cli/Cargo.toml | 8 +- cli/src/commands/board.rs | 196 ++++++++++++----- cli/src/commands/challenges.rs | 109 ++++++---- cli/src/commands/external_engine.rs | 61 ++++-- cli/src/commands/puzzles.rs | 60 ++++-- cli/src/commands/users.rs | 197 ++++++++++-------- cli/src/main.rs | 16 +- cli/src/output.rs | 14 ++ cli/tests/cli.rs | 137 ++++++++++++ .../model/external_engine/acquire_analysis.rs | 6 +- lib/src/model/external_engine/analyse.rs | 4 +- 11 files changed, 589 insertions(+), 219 deletions(-) create mode 100644 cli/src/output.rs create mode 100644 cli/tests/cli.rs diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 6d0dda0..4b6534d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -20,6 +20,12 @@ color-eyre = "0.6.5" futures = "0.3.34" rand = "0.10.2" reqwest = "0.13.4" +serde = "1.0.229" +serde_json = "1.0.151" tokio = { version = "1.53.1", features = ["macros", "rt"] } tracing = "0.1.44" -tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } \ No newline at end of file +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } + +[dev-dependencies] +assert_cmd = "2.0.19" +predicates = "3.1.4" \ No newline at end of file diff --git a/cli/src/commands/board.rs b/cli/src/commands/board.rs index 8111032..a2f28db 100644 --- a/cli/src/commands/board.rs +++ b/cli/src/commands/board.rs @@ -1,13 +1,77 @@ -use clap::Subcommand; +use clap::{Subcommand, ValueEnum}; use color_eyre::Result; +use color_eyre::eyre::WrapErr; use futures::StreamExt; use lichess_api::client::LichessApi; use lichess_api::model::board::*; -use lichess_api::model::{Color, Room, VariantKey}; +use lichess_api::model::{Color as LichessColor, Room, VariantKey}; use reqwest; +use crate::output; + type Lichess = LichessApi; +#[derive(Debug, Clone, ValueEnum)] +pub enum ChatRoom { + Player, + Spectator, +} + +impl From for Room { + fn from(room: ChatRoom) -> Self { + match room { + ChatRoom::Player => Room::Player, + ChatRoom::Spectator => Room::Spectator, + } + } +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum Variant { + Standard, + Chess960, + Crazyhouse, + Antichess, + Atomic, + Horde, + KingOfTheHill, + RacingKings, + ThreeCheck, +} + +impl From for VariantKey { + fn from(variant: Variant) -> Self { + match variant { + Variant::Standard => VariantKey::Standard, + Variant::Chess960 => VariantKey::Chess960, + Variant::Crazyhouse => VariantKey::Crazyhouse, + Variant::Antichess => VariantKey::Antichess, + Variant::Atomic => VariantKey::Atomic, + Variant::Horde => VariantKey::Horde, + Variant::KingOfTheHill => VariantKey::KingOfTheHill, + Variant::RacingKings => VariantKey::RacingKings, + Variant::ThreeCheck => VariantKey::ThreeCheck, + } + } +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum SeekColor { + Random, + White, + Black, +} + +impl From for LichessColor { + fn from(color: SeekColor) -> Self { + match color { + SeekColor::Random => LichessColor::Random, + SeekColor::White => LichessColor::White, + SeekColor::Black => LichessColor::Black, + } + } +} + #[derive(Debug, Subcommand)] pub enum BoardCommand { /// Abort a game @@ -29,9 +93,9 @@ pub enum BoardCommand { WriteChat { /// Game ID game_id: String, - /// Room (player or spectator) - #[arg(long, default_value = "player")] - room: String, + /// Room + #[arg(long, value_enum, default_value = "player")] + room: ChatRoom, /// Message text text: String, }, @@ -40,6 +104,11 @@ pub enum BoardCommand { /// Game ID game_id: String, }, + /// Claim a draw, or agree to an opponent's draw offer + ClaimDraw { + /// Game ID + game_id: String, + }, /// Create/accept/decline draw offers HandleDraw { /// Game ID @@ -78,11 +147,11 @@ pub enum BoardCommand { #[arg(long)] days: Option, /// Chess variant - #[arg(long, default_value = "standard")] - variant: String, - /// Color preference (random, white, black) - #[arg(long, default_value = "random")] - color: String, + #[arg(long, value_enum, default_value = "standard")] + variant: Variant, + /// Color preference + #[arg(long, value_enum, default_value = "random")] + color: SeekColor, /// Rating range minimum #[arg(long)] rating_range_min: Option, @@ -108,23 +177,32 @@ pub enum BoardCommand { } impl BoardCommand { - pub async fn run(self, lichess: Lichess) -> Result<()> { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { match self { BoardCommand::Abort { game_id } => { let request = abort::PostRequest::new(&game_id); - let result = lichess.board_abort_game(request).await?; + let result = lichess + .board_abort_game(request) + .await + .wrap_err_with(|| format!("failed to abort game '{game_id}'"))?; println!("Game aborted: {}", result); Ok(()) } BoardCommand::Berserk { game_id } => { let request = berserk::PostRequest::new(&game_id); - let result = lichess.board_berserk_game(request).await?; + let result = lichess + .board_berserk_game(request) + .await + .wrap_err_with(|| format!("failed to berserk game '{game_id}'"))?; println!("Berserk activated: {}", result); Ok(()) } BoardCommand::StreamChat { game_id } => { let request = chat::GetRequest::new(&game_id); - let mut stream = lichess.board_stream_game_chat(request).await?; + let mut stream = lichess + .board_stream_game_chat(request) + .await + .wrap_err_with(|| format!("failed to stream chat for game '{game_id}'"))?; println!("Streaming chat messages:"); while let Some(Ok(messages)) = stream.next().await { for chat_line in messages { @@ -138,24 +216,40 @@ impl BoardCommand { room, text, } => { - let room_enum = match room.as_str() { - "spectator" => Room::Spectator, - _ => Room::Player, - }; - let request = chat::PostRequest::new(&game_id, room_enum, &text); - let result = lichess.board_write_in_chat(request).await?; + let request = chat::PostRequest::new(&game_id, room.into(), &text); + let result = lichess + .board_write_in_chat(request) + .await + .wrap_err_with(|| { + format!("failed to write chat message to game '{game_id}'") + })?; println!("Message sent: {}", result); Ok(()) } BoardCommand::ClaimVictory { game_id } => { let request = claim_victory::PostRequest::new(&game_id); - let result = lichess.board_claim_victory(request).await?; + let result = lichess + .board_claim_victory(request) + .await + .wrap_err_with(|| format!("failed to claim victory for game '{game_id}'"))?; println!("Victory claimed: {}", result); Ok(()) } + BoardCommand::ClaimDraw { game_id } => { + let request = claim_draw::PostRequest::new(&game_id); + let result = lichess + .board_claim_draw(request) + .await + .wrap_err_with(|| format!("failed to claim draw for game '{game_id}'"))?; + println!("Draw claimed: {}", result); + Ok(()) + } BoardCommand::HandleDraw { game_id, accept } => { let request = draw::PostRequest::new(&game_id, accept); - let result = lichess.board_handle_draw(request).await?; + let result = lichess + .board_handle_draw(request) + .await + .wrap_err_with(|| format!("failed to handle draw for game '{game_id}'"))?; println!("Draw handled: {}", result); Ok(()) } @@ -165,13 +259,18 @@ impl BoardCommand { offering_draw, } => { let request = r#move::PostRequest::new(&game_id, &r#move, offering_draw); - let result = lichess.board_make_move(request).await?; + let result = lichess.board_make_move(request).await.wrap_err_with(|| { + format!("failed to make move '{move}' in game '{game_id}'") + })?; println!("Move made: {}", result); Ok(()) } BoardCommand::Resign { game_id } => { let request = resign::PostRequest::new(&game_id); - let result = lichess.board_resign_game(request).await?; + let result = lichess + .board_resign_game(request) + .await + .wrap_err_with(|| format!("failed to resign game '{game_id}'"))?; println!("Game resigned: {}", result); Ok(()) } @@ -185,25 +284,6 @@ impl BoardCommand { rating_range_min, rating_range_max, } => { - let variant_key = match variant.as_str() { - "standard" => VariantKey::Standard, - "chess960" => VariantKey::Chess960, - "crazyhouse" => VariantKey::Crazyhouse, - "antichess" => VariantKey::Antichess, - "atomic" => VariantKey::Atomic, - "horde" => VariantKey::Horde, - "kingOfTheHill" => VariantKey::KingOfTheHill, - "racingKings" => VariantKey::RacingKings, - "threeCheck" => VariantKey::ThreeCheck, - _ => VariantKey::Standard, - }; - - let color_choice = match color.as_str() { - "white" => Color::White, - "black" => Color::Black, - _ => Color::Random, - }; - let seek_type = if let Some(days) = days { seek::SeekType::Correspondence { days: days.into() } } else { @@ -228,17 +308,20 @@ impl BoardCommand { let query = seek::PostQuery { seek_type, rated, - variant: variant_key, - color: color_choice, + variant: variant.into(), + color: color.into(), rating_range, }; let request = seek::PostRequest::new(query); - let mut stream = lichess.board_create_a_seek(request).await?; + let mut stream = lichess + .board_create_a_seek(request) + .await + .wrap_err("failed to create seek")?; println!("Creating seek:"); while let Some(event) = stream.next().await { match event { - Ok(json) => println!("Event: {}", json), + Ok(value) => output::print(&value, json), Err(e) => eprintln!("Error: {}", e), } } @@ -246,11 +329,14 @@ impl BoardCommand { } BoardCommand::StreamEvents => { let request = stream::events::GetRequest::new(); - let mut stream = lichess.board_stream_incoming_events(request).await?; + let mut stream = lichess + .board_stream_incoming_events(request) + .await + .wrap_err("failed to stream incoming events")?; println!("Streaming incoming events:"); while let Some(event) = stream.next().await { match event { - Ok(event) => println!("Event: {:#?}", event), + Ok(event) => output::print(&event, json), Err(e) => eprintln!("Error: {}", e), } } @@ -258,11 +344,14 @@ impl BoardCommand { } BoardCommand::StreamGame { game_id } => { let request = stream::game::GetRequest::new(&game_id); - let mut stream = lichess.board_stream_board_state(request).await?; + let mut stream = lichess + .board_stream_board_state(request) + .await + .wrap_err_with(|| format!("failed to stream game state for '{game_id}'"))?; println!("Streaming game state:"); while let Some(event) = stream.next().await { match event { - Ok(event) => println!("Event: {:#?}", event), + Ok(event) => output::print(&event, json), Err(e) => eprintln!("Error: {}", e), } } @@ -270,7 +359,10 @@ impl BoardCommand { } BoardCommand::HandleTakeback { game_id, accept } => { let request = takeback::PostRequest::new(&game_id, accept); - let result = lichess.board_handle_takeback(request).await?; + let result = lichess + .board_handle_takeback(request) + .await + .wrap_err_with(|| format!("failed to handle takeback for game '{game_id}'"))?; println!("Takeback handled: {}", result); Ok(()) } diff --git a/cli/src/commands/challenges.rs b/cli/src/commands/challenges.rs index ef78e37..41f046a 100644 --- a/cli/src/commands/challenges.rs +++ b/cli/src/commands/challenges.rs @@ -1,12 +1,44 @@ -use clap::Subcommand; +use clap::{Subcommand, ValueEnum}; use color_eyre::Result; +use color_eyre::eyre::WrapErr; use lichess_api::client::LichessApi; use lichess_api::model::VariantKey; use lichess_api::model::challenges::*; use reqwest; +use crate::output; + type Lichess = LichessApi; +#[derive(Debug, Clone, ValueEnum)] +pub enum Variant { + Standard, + Chess960, + Crazyhouse, + Antichess, + Atomic, + Horde, + KingOfTheHill, + RacingKings, + ThreeCheck, +} + +impl From for VariantKey { + fn from(variant: Variant) -> Self { + match variant { + Variant::Standard => VariantKey::Standard, + Variant::Chess960 => VariantKey::Chess960, + Variant::Crazyhouse => VariantKey::Crazyhouse, + Variant::Antichess => VariantKey::Antichess, + Variant::Atomic => VariantKey::Atomic, + Variant::Horde => VariantKey::Horde, + Variant::KingOfTheHill => VariantKey::KingOfTheHill, + Variant::RacingKings => VariantKey::RacingKings, + Variant::ThreeCheck => VariantKey::ThreeCheck, + } + } +} + #[derive(Debug, Subcommand)] pub enum ChallengesCommand { /// List your challenges @@ -28,8 +60,8 @@ pub enum ChallengesCommand { #[arg(long)] days: Option, /// Chess variant - #[arg(long, default_value = "standard")] - variant: String, + #[arg(long, value_enum, default_value = "standard")] + variant: Variant, /// Custom starting position (FEN) #[arg(long)] fen: Option, @@ -94,18 +126,14 @@ impl From for decline::Reason { } impl ChallengesCommand { - pub async fn run(self, lichess: Lichess) -> Result<()> { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { match self { ChallengesCommand::List => { - let challenges = lichess.list_challenges().await?; - println!("Incoming challenges:"); - for challenge in &challenges.r#in { - println!(" {} - {}", challenge.base.id, challenge.base.url); - } - println!("Outgoing challenges:"); - for challenge in &challenges.out { - println!(" {} - {}", challenge.base.id, challenge.base.url); - } + let challenges = lichess + .list_challenges() + .await + .wrap_err("failed to list challenges")?; + output::print(&challenges, json); Ok(()) } ChallengesCommand::Create { @@ -118,45 +146,35 @@ impl ChallengesCommand { fen, message, } => { - let variant_key = match variant.as_str() { - "standard" => VariantKey::Standard, - "chess960" => VariantKey::Chess960, - "crazyhouse" => VariantKey::Crazyhouse, - "antichess" => VariantKey::Antichess, - "atomic" => VariantKey::Atomic, - "horde" => VariantKey::Horde, - "kingOfTheHill" => VariantKey::KingOfTheHill, - "racingKings" => VariantKey::RacingKings, - "threeCheck" => VariantKey::ThreeCheck, - _ => { - println!("Invalid variant: {}", variant); - return Ok(()); - } - }; - let challenge = CreateChallenge { base: ChallengeBase { - clock_limit: clock_limit, - clock_increment: clock_increment, + clock_limit, + clock_increment, days: days.map(|d| d.into()), - variant: variant_key, - fen: fen, + variant: variant.into(), + fen, }, - rated: rated, + rated, keep_alive_stream: false, accept_by_token: None, - message: message, + message, rules: String::new(), }; let request = create::PostRequest::new(&username, challenge); - let result = lichess.create_challenge(request).await?; - println!("Challenge created: {:#?}", result); + let result = lichess + .create_challenge(request) + .await + .wrap_err_with(|| format!("failed to create challenge for '{username}'"))?; + output::print(&result, json); Ok(()) } ChallengesCommand::Accept { challenge_id } => { let request = accept::PostRequest::new(&challenge_id); - let result = lichess.accept_challenge(request).await?; + let result = lichess + .accept_challenge(request) + .await + .wrap_err_with(|| format!("failed to accept challenge '{challenge_id}'"))?; println!("Challenge accepted: {}", result); Ok(()) } @@ -165,8 +183,12 @@ impl ChallengesCommand { reason, } => { let decline_reason = reason.unwrap_or(DeclineReason::Generic); - let request = decline::PostRequest::new(challenge_id, decline_reason.into()); - let result = lichess.decline_challenge(request).await?; + let request = + decline::PostRequest::new(challenge_id.clone(), decline_reason.into()); + let result = lichess + .decline_challenge(request) + .await + .wrap_err_with(|| format!("failed to decline challenge '{challenge_id}'"))?; println!("Challenge declined: {}", result); Ok(()) } @@ -174,8 +196,11 @@ impl ChallengesCommand { challenge_id, opponent_token, } => { - let request = cancel::PostRequest::new(challenge_id, opponent_token); - let result = lichess.cancel_challenge(request).await?; + let request = cancel::PostRequest::new(challenge_id.clone(), opponent_token); + let result = lichess + .cancel_challenge(request) + .await + .wrap_err_with(|| format!("failed to cancel challenge '{challenge_id}'"))?; println!("Challenge cancelled: {}", result); Ok(()) } diff --git a/cli/src/commands/external_engine.rs b/cli/src/commands/external_engine.rs index 4062143..c9a3a45 100644 --- a/cli/src/commands/external_engine.rs +++ b/cli/src/commands/external_engine.rs @@ -1,11 +1,14 @@ use clap::{Parser, Subcommand}; use color_eyre::Result; +use color_eyre::eyre::WrapErr; use futures::StreamExt; use lichess_api::client::LichessApi; use lichess_api::model::external_engine::{self, *}; use rand::RngExt; use reqwest; +use crate::output; + type Lichess = LichessApi; #[derive(Debug, Subcommand)] @@ -111,17 +114,23 @@ pub struct AnalyseArgs { } impl ExternalEngineCommand { - pub async fn run(self, lichess: Lichess) -> Result<()> { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { match self { ExternalEngineCommand::List => { - let engines = lichess.list_external_engines().await?; - println!("{:#?}", engines); + let engines = lichess + .list_external_engines() + .await + .wrap_err("failed to list external engines")?; + output::print(&engines, json); Ok(()) } ExternalEngineCommand::Get { id } => { let request = external_engine::id::GetRequest::new(&id); - let engine = lichess.get_external_engine(request).await?; - println!("{:#?}", engine); + let engine = lichess + .get_external_engine(request) + .await + .wrap_err_with(|| format!("failed to fetch external engine '{id}'"))?; + output::print(&engine, json); Ok(()) } ExternalEngineCommand::Create(args) => { @@ -136,8 +145,11 @@ impl ExternalEngineCommand { provider_secret: provider_secret.clone(), }; let request = create::PostRequest::new(engine); - let engine = lichess.create_external_engine(request).await?; - println!("{:#?}", engine); + let engine = lichess + .create_external_engine(request) + .await + .wrap_err("failed to create external engine")?; + output::print(&engine, json); println!("provider_secret: {}", provider_secret); Ok(()) } @@ -153,14 +165,20 @@ impl ExternalEngineCommand { provider_secret: provider_secret.clone(), }; let request = update::PutRequest::new(&args.id, engine); - let engine = lichess.update_external_engine(request).await?; - println!("{:#?}", engine); + let engine = lichess + .update_external_engine(request) + .await + .wrap_err_with(|| format!("failed to update external engine '{}'", args.id))?; + output::print(&engine, json); println!("provider_secret: {}", provider_secret); Ok(()) } ExternalEngineCommand::Delete { id } => { let request = delete::DeleteRequest::new(&id); - let ok = lichess.delete_external_engine(request).await?; + let ok = lichess + .delete_external_engine(request) + .await + .wrap_err_with(|| format!("failed to delete external engine '{id}'"))?; println!("Deleted engine {}. {}", id, ok); Ok(()) } @@ -180,10 +198,15 @@ impl ExternalEngineCommand { }; let request = analyse::PostRequest::new(&args.id, analysis_request); tracing::debug!("{:#?}", request); - let mut stream = lichess.analyse_with_external_engine(request).await?; + let mut stream = lichess + .analyse_with_external_engine(request) + .await + .wrap_err_with(|| { + format!("failed to analyse with external engine '{}'", args.id) + })?; while let Some(analysis) = stream.next().await { - let analysis = analysis?; - println!("{:#?}", analysis); + let analysis = analysis.wrap_err("failed to read analysis event")?; + output::print(&analysis, json); } Ok(()) } @@ -193,12 +216,18 @@ impl ExternalEngineCommand { } => { let acquire_analysis = acquire_analysis::AcquireAnalysis { provider_secret }; let request = acquire_analysis::PostRequest::new(acquire_analysis); - let mut analysis = lichess.acquire_analysis_request(request.clone()).await?; + let mut analysis = lichess + .acquire_analysis_request(request.clone()) + .await + .wrap_err("failed to acquire analysis request")?; while wait && analysis.is_none() { tracing::debug!("No analysis request available"); - analysis = lichess.acquire_analysis_request(request.clone()).await?; + analysis = lichess + .acquire_analysis_request(request.clone()) + .await + .wrap_err("failed to acquire analysis request")?; } - println!("{:#?}", analysis); + output::print(&analysis, json); Ok(()) } } diff --git a/cli/src/commands/puzzles.rs b/cli/src/commands/puzzles.rs index 11a9710..cb68747 100644 --- a/cli/src/commands/puzzles.rs +++ b/cli/src/commands/puzzles.rs @@ -1,10 +1,13 @@ use clap::{Subcommand, ValueEnum}; use color_eyre::Result; +use color_eyre::eyre::WrapErr; use futures::StreamExt; use lichess_api::client::LichessApi; use lichess_api::model::puzzles::{self, *}; use reqwest; +use crate::output; + type Lichess = LichessApi; #[derive(Debug, Clone, ValueEnum)] @@ -59,50 +62,75 @@ pub enum PuzzlesCommand { } impl PuzzlesCommand { - pub async fn run(self, lichess: Lichess) -> Result<()> { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { match self { PuzzlesCommand::Daily => { - let puzzle = lichess.get_daily_puzzle().await?; - println!("{puzzle:#?}"); + let puzzle = lichess + .get_daily_puzzle() + .await + .wrap_err("failed to fetch daily puzzle")?; + output::print(&puzzle, json); Ok(()) } PuzzlesCommand::Get { id } => { let request = puzzles::id::GetRequest::new(&id); - let puzzle = lichess.get_puzzle(request).await?; - println!("{puzzle:#?}"); + let puzzle = lichess + .get_puzzle(request) + .await + .wrap_err_with(|| format!("failed to fetch puzzle '{id}'"))?; + output::print(&puzzle, json); Ok(()) } PuzzlesCommand::Activity { max_rounds } => { let request = activity::GetRequest::new(max_rounds); - let mut stream = lichess.get_puzzle_activity(request).await?; + let mut stream = lichess + .get_puzzle_activity(request) + .await + .wrap_err("failed to fetch puzzle activity")?; while let Some(round) = stream.next().await { - let round = round?; - println!("Round: {round:#?}"); + let round = round.wrap_err("failed to read puzzle activity round")?; + output::print(&round, json); } Ok(()) } PuzzlesCommand::Dashboard { days } => { let request = dashboard::GetRequest::new(days.unwrap_or(30)); - let dashboard = lichess.get_puzzle_dashboard(request).await?; - println!("{dashboard:#?}"); + let dashboard = lichess + .get_puzzle_dashboard(request) + .await + .wrap_err("failed to fetch puzzle dashboard")?; + output::print(&dashboard, json); Ok(()) } PuzzlesCommand::Storm { username, days } => { let request = storm_dashboard::GetRequest::new(&username, days); - let dashboard = lichess.get_puzzle_storm_dashboard(request).await?; - println!("{dashboard:#?}"); + let dashboard = lichess + .get_puzzle_storm_dashboard(request) + .await + .wrap_err_with(|| { + format!("failed to fetch storm dashboard for '{username}'") + })?; + output::print(&dashboard, json); Ok(()) } PuzzlesCommand::Next { angle, difficulty } => { let request = next::GetRequest::new(angle, difficulty.map(|d| d.into())); - let puzzle = lichess.get_new_puzzle(request).await?; - println!("{puzzle:#?}"); + let puzzle = lichess + .get_new_puzzle(request) + .await + .wrap_err("failed to fetch new puzzle")?; + output::print(&puzzle, json); Ok(()) } PuzzlesCommand::Replay { days, theme } => { let request = replay::GetRequest::new(days, &theme); - let replay = lichess.get_puzzles_to_replay(request).await?; - println!("{replay:#?}"); + let replay = lichess + .get_puzzles_to_replay(request) + .await + .wrap_err_with(|| { + format!("failed to fetch puzzles to replay for theme '{theme}'") + })?; + output::print(&replay, json); Ok(()) } } diff --git a/cli/src/commands/users.rs b/cli/src/commands/users.rs index 9022bf4..052873b 100644 --- a/cli/src/commands/users.rs +++ b/cli/src/commands/users.rs @@ -1,11 +1,51 @@ -use clap::Subcommand; +use clap::{Subcommand, ValueEnum}; use color_eyre::Result; +use color_eyre::eyre::WrapErr; use lichess_api::client::LichessApi; use lichess_api::model::{PerfType, users}; use reqwest; +use crate::output; + type Lichess = LichessApi; +#[derive(Debug, Clone, ValueEnum)] +pub enum Performance { + UltraBullet, + Bullet, + Blitz, + Rapid, + Classical, + Chess960, + Crazyhouse, + Antichess, + Atomic, + Horde, + KingOfTheHill, + RacingKings, + ThreeCheck, +} + +impl From for PerfType { + fn from(perf: Performance) -> Self { + match perf { + Performance::UltraBullet => PerfType::UltraBullet, + Performance::Bullet => PerfType::Bullet, + Performance::Blitz => PerfType::Blitz, + Performance::Rapid => PerfType::Rapid, + Performance::Classical => PerfType::Classical, + Performance::Chess960 => PerfType::Chess960, + Performance::Crazyhouse => PerfType::Crazyhouse, + Performance::Antichess => PerfType::Antichess, + Performance::Atomic => PerfType::Atomic, + Performance::Horde => PerfType::Horde, + Performance::KingOfTheHill => PerfType::KingOfTheHill, + Performance::RacingKings => PerfType::RacingKings, + Performance::ThreeCheck => PerfType::ThreeCheck, + } + } +} + #[derive(Debug, Subcommand)] pub enum UsersCommand { /// Get public data of a user @@ -33,8 +73,9 @@ pub enum UsersCommand { Performance { /// Username username: String, - /// Performance type (e.g., bullet, blitz, rapid, classical, etc.) - perf: String, + /// Performance type + #[arg(value_enum)] + perf: Performance, }, /// Get users by their IDs ByIds { @@ -56,6 +97,7 @@ pub enum UsersCommand { /// Autocomplete usernames Autocomplete { /// Search term (at least 3 characters) + #[arg(value_parser = parse_autocomplete_term)] term: String, /// Include friend names #[arg(long)] @@ -65,8 +107,9 @@ pub enum UsersCommand { Top10, /// Get one leaderboard Leaderboard { - /// Variant (e.g., bullet, blitz, rapid, classical, etc.) - perf: String, + /// Performance type + #[arg(value_enum)] + perf: Performance, /// Number of users to fetch (1-200) #[arg(default_value = "10")] count: u8, @@ -78,13 +121,24 @@ pub enum UsersCommand { }, } +fn parse_autocomplete_term(term: &str) -> std::result::Result { + if term.len() < 3 { + Err("search term must be at least 3 characters".to_string()) + } else { + Ok(term.to_string()) + } +} + impl UsersCommand { - pub async fn run(self, lichess: Lichess) -> Result<()> { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { match self { UsersCommand::Get { username, trophies } => { let request = users::public::GetRequest::new(&username, trophies); - let user = lichess.get_public_user_data(request).await?; - println!("{:#?}", user); + let user = lichess + .get_public_user_data(request) + .await + .wrap_err_with(|| format!("failed to fetch public data for '{username}'"))?; + output::print(&user, json); Ok(()) } UsersCommand::Status { @@ -94,57 +148,49 @@ impl UsersCommand { let user_ids: Vec = users.split(',').map(|s| s.trim().to_string()).collect(); let request = users::status::GetRequest::new(user_ids, with_game_ids); - let statuses = lichess.get_status_of_users(request).await?; - for status in statuses { - println!("{:#?}", status); - } + let statuses = lichess + .get_status_of_users(request) + .await + .wrap_err("failed to fetch user statuses")?; + output::print(&statuses, json); Ok(()) } UsersCommand::RatingHistory { username } => { let request = users::rating_history::GetRequest::new(&username); - let history = lichess.get_rating_history(request).await?; - println!("{:#?}", history); + let history = lichess + .get_rating_history(request) + .await + .wrap_err_with(|| format!("failed to fetch rating history for '{username}'"))?; + output::print(&history, json); Ok(()) } UsersCommand::Performance { username, perf } => { - let perf_type = match perf.as_str() { - "ultraBullet" => PerfType::UltraBullet, - "bullet" => PerfType::Bullet, - "blitz" => PerfType::Blitz, - "rapid" => PerfType::Rapid, - "classical" => PerfType::Classical, - "chess960" => PerfType::Chess960, - "crazyhouse" => PerfType::Crazyhouse, - "antichess" => PerfType::Antichess, - "atomic" => PerfType::Atomic, - "horde" => PerfType::Horde, - "kingOfTheHill" => PerfType::KingOfTheHill, - "racingKings" => PerfType::RacingKings, - "threeCheck" => PerfType::ThreeCheck, - _ => { - println!("Invalid performance type: {}", perf); - return Ok(()); - } - }; - let request = users::performance::GetRequest::new(&username, perf_type); - let perf_stat = lichess.get_user_performance_statistics(request).await?; - println!("{:#?}", perf_stat); + let request = users::performance::GetRequest::new(&username, perf.into()); + let perf_stat = lichess + .get_user_performance_statistics(request) + .await + .wrap_err_with(|| { + format!("failed to fetch performance statistics for '{username}'") + })?; + output::print(&perf_stat, json); Ok(()) } UsersCommand::ByIds { ids } => { let user_ids: Vec = ids.split(',').map(|s| s.trim().to_string()).collect(); let request = users::by_id::PostRequest::new(user_ids); - let users = lichess.get_users_by_id(request).await?; - for user in users { - println!("{:#?}", user); - } + let users = lichess + .get_users_by_id(request) + .await + .wrap_err("failed to fetch users by id")?; + output::print(&users, json); Ok(()) } UsersCommand::LiveStreamers => { - let streamers = lichess.get_live_streamers().await?; - for streamer in streamers { - println!("{:#?}", streamer); - } + let streamers = lichess + .get_live_streamers() + .await + .wrap_err("failed to fetch live streamers")?; + output::print(&streamers, json); Ok(()) } UsersCommand::Crosstable { @@ -153,58 +199,45 @@ impl UsersCommand { matchup, } => { let request = users::crosstable::GetRequest::new(&user1, &user2, Some(matchup)); - let crosstable = lichess.get_crosstable(request).await?; - println!("{:#?}", crosstable); + let crosstable = lichess.get_crosstable(request).await.wrap_err_with(|| { + format!("failed to fetch crosstable for '{user1}' vs '{user2}'") + })?; + output::print(&crosstable, json); Ok(()) } UsersCommand::Autocomplete { term, friend } => { - if term.len() < 3 { - println!("Search term must be at least 3 characters"); - return Ok(()); - } let request = users::autocomplete::GetRequest::new(&term, Some(friend)); - let suggestions = lichess.autocomplete_users(request).await?; - for user in suggestions.result { - println!("{} ({})", user.name, user.id); - } + let suggestions = lichess + .autocomplete_users(request) + .await + .wrap_err_with(|| format!("failed to autocomplete users for '{term}'"))?; + output::print(&suggestions.result, json); Ok(()) } UsersCommand::Top10 => { - let leaderboards = lichess.get_all_top_10().await?; - println!("{:#?}", leaderboards); + let leaderboards = lichess + .get_all_top_10() + .await + .wrap_err("failed to fetch top 10 leaderboards")?; + output::print(&leaderboards, json); Ok(()) } UsersCommand::Leaderboard { count, perf } => { - let perf_type = match perf.as_str() { - "ultraBullet" => PerfType::UltraBullet, - "bullet" => PerfType::Bullet, - "blitz" => PerfType::Blitz, - "rapid" => PerfType::Rapid, - "classical" => PerfType::Classical, - "chess960" => PerfType::Chess960, - "crazyhouse" => PerfType::Crazyhouse, - "antichess" => PerfType::Antichess, - "atomic" => PerfType::Atomic, - "horde" => PerfType::Horde, - "kingOfTheHill" => PerfType::KingOfTheHill, - "racingKings" => PerfType::RacingKings, - "threeCheck" => PerfType::ThreeCheck, - _ => { - println!("Invalid performance type: {}", perf); - return Ok(()); - } - }; - let request = users::leaderboard::GetRequest::new(count, perf_type); - let leaderboard = lichess.get_one_leaderboard(request).await?; - println!("{:#?}", leaderboard); + let request = users::leaderboard::GetRequest::new(count, perf.into()); + let leaderboard = lichess + .get_one_leaderboard(request) + .await + .wrap_err("failed to fetch leaderboard")?; + output::print(&leaderboard, json); Ok(()) } UsersCommand::Activity { username } => { let request = users::activity::GetRequest::new(&username); - let activities = lichess.get_user_activity(request).await?; - for activity in activities { - println!("{:#?}", activity); - } + let activities = lichess + .get_user_activity(request) + .await + .wrap_err_with(|| format!("failed to fetch activity for '{username}'"))?; + output::print(&activities, json); Ok(()) } } diff --git a/cli/src/main.rs b/cli/src/main.rs index 6701569..63642ef 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,4 +1,5 @@ mod commands; +mod output; use clap::builder::Styles; use clap::builder::styling::AnsiColor; @@ -31,6 +32,10 @@ struct Cli { /// Enable verbose logging #[arg(long, short)] verbose: bool, + + /// Print output as pretty-printed JSON instead of Rust debug format + #[arg(long, global = true)] + json: bool, } #[derive(Debug, Subcommand)] @@ -99,12 +104,13 @@ impl App { } async fn run(self, args: Cli) -> Result<()> { + let json = args.json; match args.command { - Command::Board { command } => command.run(self.lichess).await, - Command::Puzzles { command } => command.run(self.lichess).await, - Command::Engine { command } => command.run(self.lichess).await, - Command::Challenges { command } => command.run(self.lichess).await, - Command::Users { command } => command.run(self.lichess).await, + Command::Board { command } => command.run(self.lichess, json).await, + Command::Puzzles { command } => command.run(self.lichess, json).await, + Command::Engine { command } => command.run(self.lichess, json).await, + Command::Challenges { command } => command.run(self.lichess, json).await, + Command::Users { command } => command.run(self.lichess, json).await, } } } diff --git a/cli/src/output.rs b/cli/src/output.rs new file mode 100644 index 0000000..d0e9f32 --- /dev/null +++ b/cli/src/output.rs @@ -0,0 +1,14 @@ +use serde::Serialize; + +/// Print a value either as pretty-printed JSON or as Rust `Debug` output, +/// depending on the global `--json` flag. +pub fn print(value: &T, json: bool) { + if json { + match serde_json::to_string_pretty(value) { + Ok(s) => println!("{s}"), + Err(e) => eprintln!("failed to serialize output as json: {e}"), + } + } else { + println!("{value:#?}"); + } +} diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs new file mode 100644 index 0000000..fd6e258 --- /dev/null +++ b/cli/tests/cli.rs @@ -0,0 +1,137 @@ +use assert_cmd::Command; +use predicates::prelude::*; + +fn lichess() -> Command { + Command::cargo_bin("lichess").unwrap() +} + +#[test] +fn top_level_help_lists_all_categories() { + lichess() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("board")) + .stdout(predicate::str::contains("puzzles")) + .stdout(predicate::str::contains("engine")) + .stdout(predicate::str::contains("challenges")) + .stdout(predicate::str::contains("users")); +} + +#[test] +fn no_subcommand_fails_with_usage() { + lichess().assert().failure(); +} + +#[test] +fn unknown_subcommand_fails() { + lichess().arg("not-a-real-command").assert().failure(); +} + +#[test] +fn subcommand_help_succeeds_for_every_category() { + for subcommand in ["board", "puzzles", "engine", "challenges", "users"] { + lichess().args([subcommand, "--help"]).assert().success(); + } +} + +#[test] +fn users_performance_rejects_invalid_perf_type() { + lichess() + .args(["users", "performance", "some-user", "not-a-real-perf"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn users_leaderboard_rejects_invalid_perf_type() { + lichess() + .args(["users", "leaderboard", "not-a-real-perf"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn users_autocomplete_rejects_short_term() { + lichess() + .args(["users", "autocomplete", "ab"]) + .assert() + .failure() + .stderr(predicate::str::contains("at least 3 characters")); +} + +#[test] +fn board_create_seek_rejects_invalid_variant() { + lichess() + .args(["board", "create-seek", "--variant", "not-a-real-variant"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn board_write_chat_rejects_invalid_room() { + lichess() + .args([ + "board", + "write-chat", + "some-game-id", + "--room", + "not-a-real-room", + "hello", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn challenges_create_rejects_invalid_variant() { + lichess() + .args([ + "challenges", + "create", + "some-user", + "--variant", + "not-a-real-variant", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn challenges_decline_rejects_invalid_reason() { + lichess() + .args([ + "challenges", + "decline", + "some-challenge-id", + "--reason", + "not-a-real-reason", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn puzzles_next_rejects_invalid_difficulty() { + lichess() + .args(["puzzles", "next", "--difficulty", "not-a-real-difficulty"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn json_flag_is_accepted_globally() { + // --json is a global flag; it should parse successfully even placed after the subcommand, + // without requiring network access (help exits before any request is made). + lichess() + .args(["--json", "users", "--help"]) + .assert() + .success(); +} diff --git a/lib/src/model/external_engine/acquire_analysis.rs b/lib/src/model/external_engine/acquire_analysis.rs index 5db088d..dcebddb 100644 --- a/lib/src/model/external_engine/acquire_analysis.rs +++ b/lib/src/model/external_engine/acquire_analysis.rs @@ -25,7 +25,7 @@ pub struct AcquireAnalysis { pub provider_secret: String, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AcquireAnalysisResponse { pub id: String, @@ -33,7 +33,7 @@ pub struct AcquireAnalysisResponse { pub engine: Engine, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalEngineWork { pub session_id: String, @@ -46,7 +46,7 @@ pub struct ExternalEngineWork { pub moves: Vec, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Engine { pub id: String, diff --git a/lib/src/model/external_engine/analyse.rs b/lib/src/model/external_engine/analyse.rs index 762c3be..95be6c8 100644 --- a/lib/src/model/external_engine/analyse.rs +++ b/lib/src/model/external_engine/analyse.rs @@ -39,7 +39,7 @@ pub struct ExternalEngineWork { pub moves: Vec, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AnalysisResponse { pub time: u64, @@ -48,7 +48,7 @@ pub struct AnalysisResponse { pub pvs: Vec, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PV { pub depth: u8, From a1c5c0c5daa9d57a55407d304a8b642270963c84 Mon Sep 17 00:00:00 2001 From: Arran Ireland Date: Sun, 23 Aug 2026 10:21:54 +0100 Subject: [PATCH 2/5] cli: add commands for all remaining lib endpoint categories Adds account, relations, simuls, tv, openings, tablebase, analysis, messaging, fide, games, studies, bot, teams, arena-tournaments, swiss-tournaments, broadcasts, and bulk-pairings command modules, wired into main.rs alongside the existing board/puzzles/engine/ challenges/users. --- cli/src/commands/account.rs | 93 ++ cli/src/commands/analysis.rs | 85 ++ cli/src/commands/arena_tournaments.rs | 575 ++++++++++++ cli/src/commands/bot.rs | 250 ++++++ cli/src/commands/broadcasts.rs | 832 ++++++++++++++++++ cli/src/commands/bulk_pairings.rs | 243 +++++ cli/src/commands/fide.rs | 61 ++ cli/src/commands/games.rs | 708 +++++++++++++++ cli/src/commands/messaging.rs | 35 + cli/src/commands/mod.rs | 34 + cli/src/commands/openings.rs | 262 ++++++ cli/src/commands/relations.rs | 89 ++ cli/src/commands/simuls.rs | 30 + cli/src/commands/studies.rs | 421 +++++++++ cli/src/commands/swiss_tournaments.rs | 461 ++++++++++ cli/src/commands/tablebase.rs | 59 ++ cli/src/commands/teams.rs | 396 +++++++++ cli/src/commands/tv.rs | 162 ++++ cli/src/main.rs | 91 +- cli/tests/cli.rs | 44 +- .../model/studies/import_pgn_into_study.rs | 4 +- 21 files changed, 4921 insertions(+), 14 deletions(-) create mode 100644 cli/src/commands/account.rs create mode 100644 cli/src/commands/analysis.rs create mode 100644 cli/src/commands/arena_tournaments.rs create mode 100644 cli/src/commands/bot.rs create mode 100644 cli/src/commands/broadcasts.rs create mode 100644 cli/src/commands/bulk_pairings.rs create mode 100644 cli/src/commands/fide.rs create mode 100644 cli/src/commands/games.rs create mode 100644 cli/src/commands/messaging.rs create mode 100644 cli/src/commands/openings.rs create mode 100644 cli/src/commands/relations.rs create mode 100644 cli/src/commands/simuls.rs create mode 100644 cli/src/commands/studies.rs create mode 100644 cli/src/commands/swiss_tournaments.rs create mode 100644 cli/src/commands/tablebase.rs create mode 100644 cli/src/commands/teams.rs create mode 100644 cli/src/commands/tv.rs diff --git a/cli/src/commands/account.rs b/cli/src/commands/account.rs new file mode 100644 index 0000000..67272df --- /dev/null +++ b/cli/src/commands/account.rs @@ -0,0 +1,93 @@ +use clap::Subcommand; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use lichess_api::client::LichessApi; +use lichess_api::model::account::*; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Subcommand)] +pub enum AccountCommand { + /// Get your public profile + Profile, + /// Get your email address + Email, + /// Get your preferences + Preferences, + /// Get your kid mode status + KidModeStatus, + /// Set your kid mode status + SetKidMode { + /// Turn kid mode on or off + #[arg(long)] + on: bool, + }, + /// Get your timeline + Timeline { + /// Only include entries since this timestamp (ms) + #[arg(long)] + since: Option, + /// Max number of entries to fetch + #[arg(long)] + nb: Option, + }, +} + +impl AccountCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + AccountCommand::Profile => { + let profile = lichess + .get_profile() + .await + .wrap_err("failed to fetch profile")?; + output::print(&profile, json); + Ok(()) + } + AccountCommand::Email => { + let email = lichess + .get_email_address() + .await + .wrap_err("failed to fetch email address")?; + output::print(&email, json); + Ok(()) + } + AccountCommand::Preferences => { + let preferences = lichess + .get_preferences() + .await + .wrap_err("failed to fetch preferences")?; + output::print(&preferences, json); + Ok(()) + } + AccountCommand::KidModeStatus => { + let status = lichess + .get_kid_mode_status() + .await + .wrap_err("failed to fetch kid mode status")?; + output::print(&status, json); + Ok(()) + } + AccountCommand::SetKidMode { on } => { + let result = lichess + .set_kid_mode_status(on) + .await + .wrap_err("failed to set kid mode status")?; + println!("Kid mode set to {on}: {}", result); + Ok(()) + } + AccountCommand::Timeline { since, nb } => { + let query = timeline::GetQuery { since, nb }; + let timeline = lichess + .get_timeline(query) + .await + .wrap_err("failed to fetch timeline")?; + output::print(&timeline, json); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/analysis.rs b/cli/src/commands/analysis.rs new file mode 100644 index 0000000..697a2cb --- /dev/null +++ b/cli/src/commands/analysis.rs @@ -0,0 +1,85 @@ +use clap::{Subcommand, ValueEnum}; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use lichess_api::client::LichessApi; +use lichess_api::model::VariantKey; +use lichess_api::model::analysis::*; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Clone, ValueEnum)] +pub enum Variant { + Standard, + Chess960, + Crazyhouse, + Antichess, + Atomic, + Horde, + KingOfTheHill, + RacingKings, + ThreeCheck, + FromPosition, +} + +impl From for VariantKey { + fn from(variant: Variant) -> Self { + match variant { + Variant::Standard => VariantKey::Standard, + Variant::Chess960 => VariantKey::Chess960, + Variant::Crazyhouse => VariantKey::Crazyhouse, + Variant::Antichess => VariantKey::Antichess, + Variant::Atomic => VariantKey::Atomic, + Variant::Horde => VariantKey::Horde, + Variant::KingOfTheHill => VariantKey::KingOfTheHill, + Variant::RacingKings => VariantKey::RacingKings, + Variant::ThreeCheck => VariantKey::ThreeCheck, + Variant::FromPosition => VariantKey::FromPosition, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum AnalysisCommand { + /// Get the cloud evaluation of a position + CloudEval { + /// FEN of the position + fen: String, + /// Number of principal variations + #[arg(long)] + multi_pv: Option, + /// Chess variant + #[arg(long, value_enum)] + variant: Option, + }, +} + +impl AnalysisCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + AnalysisCommand::CloudEval { + fen, + multi_pv, + variant, + } => { + let query = cloud::GetQuery { + fen: fen.clone(), + variation_count: multi_pv, + variant: variant.map(Into::into), + }; + let evaluation = lichess + .get_cloud_evaluation(query) + .await + .wrap_err_with(|| { + format!( + "failed to fetch cloud evaluation for fen '{fen}' (position may not be evaluated)" + ) + })?; + output::print(&evaluation, json); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/arena_tournaments.rs b/cli/src/commands/arena_tournaments.rs new file mode 100644 index 0000000..18c832d --- /dev/null +++ b/cli/src/commands/arena_tournaments.rs @@ -0,0 +1,575 @@ +use clap::{Subcommand, ValueEnum}; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use futures::StreamExt; +use lichess_api::client::LichessApi; +use lichess_api::model::VariantKey; +use lichess_api::model::arena_tournaments::*; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Clone, ValueEnum)] +pub enum Variant { + Standard, + Chess960, + Crazyhouse, + Antichess, + Atomic, + Horde, + KingOfTheHill, + RacingKings, + ThreeCheck, +} + +impl From for VariantKey { + fn from(variant: Variant) -> Self { + match variant { + Variant::Standard => VariantKey::Standard, + Variant::Chess960 => VariantKey::Chess960, + Variant::Crazyhouse => VariantKey::Crazyhouse, + Variant::Antichess => VariantKey::Antichess, + Variant::Atomic => VariantKey::Atomic, + Variant::Horde => VariantKey::Horde, + Variant::KingOfTheHill => VariantKey::KingOfTheHill, + Variant::RacingKings => VariantKey::RacingKings, + Variant::ThreeCheck => VariantKey::ThreeCheck, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum ArenaTournamentsCommand { + /// Get currently created, started, and finished arena tournaments + Current, + /// Create a new arena tournament + Create { + /// Tournament name + #[arg(long)] + name: Option, + /// Clock time in minutes + #[arg(long)] + clock_time: f64, + /// Clock increment in seconds + #[arg(long)] + clock_increment: u32, + /// Duration of the tournament in minutes + #[arg(long)] + minutes: u32, + /// Minutes to wait before starting the tournament + #[arg(long)] + wait_minutes: Option, + /// Start date as a unix timestamp in milliseconds + #[arg(long)] + start_date: Option, + /// Chess variant + #[arg(long, value_enum)] + variant: Option, + /// Whether the tournament is rated + #[arg(long)] + rated: Option, + /// Custom starting position (FEN) + #[arg(long)] + position: Option, + /// Whether berserk is allowed + #[arg(long)] + berserkable: Option, + /// Whether streaks are allowed + #[arg(long)] + streakable: Option, + /// Whether a chat is enabled + #[arg(long)] + has_chat: Option, + /// Tournament description + #[arg(long)] + description: Option, + /// Password to join the tournament + #[arg(long)] + password: Option, + /// Team ID that hosts a team battle + #[arg(long)] + team_battle_by_team: Option, + /// Restrict entry to members of this team + #[arg(long)] + conditions_team_member_team_id: Option, + /// Minimum rating to join + #[arg(long)] + conditions_min_rating: Option, + /// Maximum rating to join + #[arg(long)] + conditions_max_rating: Option, + /// Minimum number of rated games required to join + #[arg(long)] + conditions_nb_rated_game: Option, + /// Comma-separated list of usernames always allowed to join + #[arg(long)] + conditions_allow_list: Option, + /// Whether bots are allowed to join + #[arg(long)] + conditions_bots: Option, + /// Minimum account age in days required to join + #[arg(long)] + conditions_account_age: Option, + }, + /// Get info about an arena tournament + Get { + /// Tournament ID + id: String, + /// Standings page to include + #[arg(long)] + page: Option, + }, + /// Update an arena tournament + Update { + /// Tournament ID + id: String, + /// Tournament name + #[arg(long)] + name: Option, + /// Clock time in minutes + #[arg(long)] + clock_time: f64, + /// Clock increment in seconds + #[arg(long)] + clock_increment: u32, + /// Duration of the tournament in minutes + #[arg(long)] + minutes: u32, + /// Minutes to wait before starting the tournament + #[arg(long)] + wait_minutes: Option, + /// Start date as a unix timestamp in milliseconds + #[arg(long)] + start_date: Option, + /// Chess variant + #[arg(long, value_enum)] + variant: Option, + /// Whether the tournament is rated + #[arg(long)] + rated: Option, + /// Custom starting position (FEN) + #[arg(long)] + position: Option, + /// Whether berserk is allowed + #[arg(long)] + berserkable: Option, + /// Whether streaks are allowed + #[arg(long)] + streakable: Option, + /// Whether a chat is enabled + #[arg(long)] + has_chat: Option, + /// Tournament description + #[arg(long)] + description: Option, + /// Password to join the tournament + #[arg(long)] + password: Option, + /// Minimum rating to join + #[arg(long)] + conditions_min_rating: Option, + /// Maximum rating to join + #[arg(long)] + conditions_max_rating: Option, + /// Minimum number of rated games required to join + #[arg(long)] + conditions_nb_rated_game: Option, + /// Comma-separated list of usernames always allowed to join + #[arg(long)] + conditions_allow_list: Option, + /// Whether bots are allowed to join + #[arg(long)] + conditions_bots: Option, + /// Minimum account age in days required to join + #[arg(long)] + conditions_account_age: Option, + }, + /// Export games of an arena tournament + ExportGames { + /// Tournament ID + id: String, + /// Only games of this player + #[arg(long)] + player: Option, + /// Include the PGN moves + #[arg(long)] + moves: Option, + /// Include the PGN moves as a JSON array + #[arg(long)] + pgn_in_json: Option, + /// Include the PGN tags + #[arg(long)] + tags: Option, + /// Include clock comments + #[arg(long)] + clocks: Option, + /// Include analysis evaluations + #[arg(long)] + evals: Option, + /// Include weighted error values + #[arg(long)] + accuracy: Option, + /// Include the opening name + #[arg(long)] + opening: Option, + /// Include the division of the game into opening/middlegame/endgame + #[arg(long)] + division: Option, + }, + /// Join an arena tournament + Join { + /// Tournament ID + id: String, + /// Password to join, if required + #[arg(long)] + password: Option, + /// Team ID to join with, for team battles + #[arg(long)] + team: Option, + /// Whether to be paired as soon as possible + #[arg(long)] + pair_me_asap: Option, + }, + /// Get the results of an arena tournament + Results { + /// Tournament ID + id: String, + /// Max number of results to fetch + #[arg(long)] + nb: Option, + /// Include the score sheet + #[arg(long)] + sheet: Option, + }, + /// Get the team standing of a team battle + Teams { + /// Tournament ID + id: String, + }, + /// Terminate an arena tournament + Terminate { + /// Tournament ID + id: String, + }, + /// Pause or leave an arena tournament + Withdraw { + /// Tournament ID + id: String, + }, + /// Update a team battle + UpdateTeamBattle { + /// Tournament ID + id: String, + /// Comma-separated list of team IDs + #[arg(long)] + teams: String, + /// Number of leaders per team + #[arg(long)] + nb_leaders: u32, + }, + /// Get tournaments created by a user + CreatedByUser { + /// Username + username: String, + /// Max number of tournaments to fetch + #[arg(long)] + nb: Option, + /// Filter by status (10 = created, 20 = started, 30 = finished) + #[arg(long)] + status: Option, + }, + /// Get tournaments played by a user + PlayedByUser { + /// Username + username: String, + /// Max number of tournaments to fetch + #[arg(long)] + nb: Option, + /// Include performance rating + #[arg(long)] + performance: Option, + }, +} + +impl ArenaTournamentsCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + ArenaTournamentsCommand::Current => { + let tournaments = lichess + .get_current_arena_tournaments() + .await + .wrap_err("failed to fetch current arena tournaments")?; + output::print(&tournaments, json); + Ok(()) + } + ArenaTournamentsCommand::Create { + name, + clock_time, + clock_increment, + minutes, + wait_minutes, + start_date, + variant, + rated, + position, + berserkable, + streakable, + has_chat, + description, + password, + team_battle_by_team, + conditions_team_member_team_id, + conditions_min_rating, + conditions_max_rating, + conditions_nb_rated_game, + conditions_allow_list, + conditions_bots, + conditions_account_age, + } => { + let form = create::CreateArenaTournamentForm { + name, + clock_time, + clock_increment, + minutes, + wait_minutes, + start_date, + variant: variant.map(|v| v.into()), + rated, + position, + berserkable, + streakable, + has_chat, + description, + password, + team_battle_by_team, + conditions_team_member_team_id, + conditions_min_rating, + conditions_max_rating, + conditions_nb_rated_game, + conditions_allow_list, + conditions_bots, + conditions_account_age, + }; + let tournament = lichess + .create_arena_tournament(form) + .await + .wrap_err("failed to create arena tournament")?; + output::print(&tournament, json); + Ok(()) + } + ArenaTournamentsCommand::Get { id, page } => { + let query = show::GetQuery { page }; + let tournament = lichess + .get_arena_tournament(&id, query) + .await + .wrap_err_with(|| format!("failed to fetch arena tournament '{id}'"))?; + output::print(&tournament, json); + Ok(()) + } + ArenaTournamentsCommand::Update { + id, + name, + clock_time, + clock_increment, + minutes, + wait_minutes, + start_date, + variant, + rated, + position, + berserkable, + streakable, + has_chat, + description, + password, + conditions_min_rating, + conditions_max_rating, + conditions_nb_rated_game, + conditions_allow_list, + conditions_bots, + conditions_account_age, + } => { + let form = update::UpdateArenaTournamentForm { + name, + clock_time, + clock_increment, + minutes, + wait_minutes, + start_date, + variant: variant.map(|v| v.into()), + rated, + position, + berserkable, + streakable, + has_chat, + description, + password, + conditions_min_rating, + conditions_max_rating, + conditions_nb_rated_game, + conditions_allow_list, + conditions_bots, + conditions_account_age, + }; + let tournament = lichess + .update_arena_tournament(&id, form) + .await + .wrap_err_with(|| format!("failed to update arena tournament '{id}'"))?; + output::print(&tournament, json); + Ok(()) + } + ArenaTournamentsCommand::ExportGames { + id, + player, + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + division, + } => { + let query = games::GetQuery { + player, + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + division, + }; + let mut stream = lichess + .export_arena_tournament_games(&id, query) + .await + .wrap_err_with(|| { + format!("failed to export games for arena tournament '{id}'") + })?; + while let Some(game) = stream.next().await { + match game { + Ok(game) => output::print(&game, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + ArenaTournamentsCommand::Join { + id, + password, + team, + pair_me_asap, + } => { + let form = join::JoinArenaTournamentForm { + password, + team, + pair_me_asap, + }; + let result = lichess + .join_arena_tournament(&id, form) + .await + .wrap_err_with(|| format!("failed to join arena tournament '{id}'"))?; + println!("Joined tournament: {}", result); + Ok(()) + } + ArenaTournamentsCommand::Results { id, nb, sheet } => { + let query = results::GetQuery { nb, sheet }; + let mut stream = lichess + .get_arena_tournament_results(&id, query) + .await + .wrap_err_with(|| { + format!("failed to fetch results for arena tournament '{id}'") + })?; + while let Some(result) = stream.next().await { + match result { + Ok(result) => output::print(&result, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + ArenaTournamentsCommand::Teams { id } => { + let standing = lichess + .get_arena_tournament_team_standing(id.as_str()) + .await + .wrap_err_with(|| { + format!("failed to fetch team standing for arena tournament '{id}'") + })?; + output::print(&standing, json); + Ok(()) + } + ArenaTournamentsCommand::Terminate { id } => { + let result = lichess + .terminate_arena_tournament(id.as_str()) + .await + .wrap_err_with(|| format!("failed to terminate arena tournament '{id}'"))?; + println!("Tournament terminated: {}", result); + Ok(()) + } + ArenaTournamentsCommand::Withdraw { id } => { + let result = lichess + .withdraw_from_arena_tournament(id.as_str()) + .await + .wrap_err_with(|| format!("failed to withdraw from arena tournament '{id}'"))?; + println!("Withdrawn from tournament: {}", result); + Ok(()) + } + ArenaTournamentsCommand::UpdateTeamBattle { + id, + teams, + nb_leaders, + } => { + let form = team_battle::TeamBattleForm { teams, nb_leaders }; + let tournament = lichess + .update_arena_team_battle(&id, form) + .await + .wrap_err_with(|| format!("failed to update team battle '{id}'"))?; + output::print(&tournament, json); + Ok(()) + } + ArenaTournamentsCommand::CreatedByUser { + username, + nb, + status, + } => { + let query = created_by_user::GetQuery { nb, status }; + let mut stream = lichess + .get_arena_tournaments_created_by_user(&username, query) + .await + .wrap_err_with(|| { + format!("failed to fetch tournaments created by '{username}'") + })?; + while let Some(tournament) = stream.next().await { + match tournament { + Ok(tournament) => output::print(&tournament, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + ArenaTournamentsCommand::PlayedByUser { + username, + nb, + performance, + } => { + let query = played_by_user::GetQuery { nb, performance }; + let mut stream = lichess + .get_arena_tournaments_played_by_user(&username, query) + .await + .wrap_err_with(|| { + format!("failed to fetch tournaments played by '{username}'") + })?; + while let Some(tournament) = stream.next().await { + match tournament { + Ok(tournament) => output::print(&tournament, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + } + } +} diff --git a/cli/src/commands/bot.rs b/cli/src/commands/bot.rs new file mode 100644 index 0000000..ecb1090 --- /dev/null +++ b/cli/src/commands/bot.rs @@ -0,0 +1,250 @@ +use clap::{Subcommand, ValueEnum}; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use futures::StreamExt; +use lichess_api::client::LichessApi; +use lichess_api::model::Room; +use lichess_api::model::bot::*; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Clone, ValueEnum)] +pub enum ChatRoom { + Player, + Spectator, +} + +impl From for Room { + fn from(room: ChatRoom) -> Self { + match room { + ChatRoom::Player => Room::Player, + ChatRoom::Spectator => Room::Spectator, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum BotCommand { + /// Abort a game + Abort { + /// Game ID + game_id: String, + }, + /// Stream the messages of a game chat + StreamChat { + /// Game ID + game_id: String, + }, + /// Write in the chat of a game + WriteChat { + /// Game ID + game_id: String, + /// Room + #[arg(long, value_enum, default_value = "player")] + room: ChatRoom, + /// Message text + text: String, + }, + /// Claim a draw, or agree to an opponent's draw offer + ClaimDraw { + /// Game ID + game_id: String, + }, + /// Claim victory when the opponent has left the game for a while + ClaimVictory { + /// Game ID + game_id: String, + }, + /// Create/accept/decline draw offers + HandleDraw { + /// Game ID + game_id: String, + /// Accept a draw offer + #[arg(long)] + accept: bool, + }, + /// Make a move in a game + MakeMove { + /// Game ID + game_id: String, + /// Move in UCI format (e.g., e2e4) + r#move: String, + /// Whether to offer a draw + #[arg(long)] + offering_draw: bool, + }, + /// Get online bot accounts + Online { + /// Number of bots to fetch + #[arg(default_value = "50")] + nb: u32, + }, + /// Resign a game + Resign { + /// Game ID + game_id: String, + }, + /// Stream incoming events (challenges, game starts) + StreamEvents, + /// Stream the state of a game being played + StreamGame { + /// Game ID + game_id: String, + }, + /// Propose/accept/decline takebacks + HandleTakeback { + /// Game ID + game_id: String, + /// Accept a takeback offer + #[arg(long)] + accept: bool, + }, + /// Upgrade your account to a Bot account (irreversible) + UpgradeAccount, +} + +impl BotCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + BotCommand::Abort { game_id } => { + let result = lichess + .bot_abort_game(game_id.as_str()) + .await + .wrap_err_with(|| format!("failed to abort game '{game_id}'"))?; + println!("Game aborted: {}", result); + Ok(()) + } + BotCommand::StreamChat { game_id } => { + let mut stream = lichess + .bot_stream_game_chat(game_id.as_str()) + .await + .wrap_err_with(|| format!("failed to stream chat for game '{game_id}'"))?; + println!("Streaming chat messages:"); + while let Some(Ok(line)) = stream.next().await { + println!("{}: {}", line.user, line.text); + } + Ok(()) + } + BotCommand::WriteChat { + game_id, + room, + text, + } => { + let request = chat::PostRequest::new(&game_id, room.into(), &text); + let result = lichess.bot_write_in_chat(request).await.wrap_err_with(|| { + format!("failed to write chat message to game '{game_id}'") + })?; + println!("Message sent: {}", result); + Ok(()) + } + BotCommand::ClaimDraw { game_id } => { + let result = lichess + .bot_claim_draw(game_id.as_str()) + .await + .wrap_err_with(|| format!("failed to claim draw for game '{game_id}'"))?; + println!("Draw claimed: {}", result); + Ok(()) + } + BotCommand::ClaimVictory { game_id } => { + let result = lichess + .bot_claim_victory(game_id.as_str()) + .await + .wrap_err_with(|| format!("failed to claim victory for game '{game_id}'"))?; + println!("Victory claimed: {}", result); + Ok(()) + } + BotCommand::HandleDraw { game_id, accept } => { + let request = draw::PostRequest::new(&game_id, accept); + let result = lichess + .bot_draw_game(request) + .await + .wrap_err_with(|| format!("failed to handle draw for game '{game_id}'"))?; + println!("Draw handled: {}", result); + Ok(()) + } + BotCommand::MakeMove { + game_id, + r#move, + offering_draw, + } => { + let request = r#move::PostRequest::new(&game_id, &r#move, offering_draw); + let result = lichess.bot_make_move(request).await.wrap_err_with(|| { + format!("failed to make move '{move}' in game '{game_id}'") + })?; + println!("Move made: {}", result); + Ok(()) + } + BotCommand::Online { nb } => { + let mut stream = lichess + .bot_get_online(nb) + .await + .wrap_err("failed to fetch online bots")?; + while let Some(bot) = stream.next().await { + match bot { + Ok(bot) => output::print(&bot, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + BotCommand::Resign { game_id } => { + let result = lichess + .bot_resign_game(game_id.as_str()) + .await + .wrap_err_with(|| format!("failed to resign game '{game_id}'"))?; + println!("Game resigned: {}", result); + Ok(()) + } + BotCommand::StreamEvents => { + let request = stream::events::GetRequest::new(); + let mut stream = lichess + .bot_stream_incoming_events(request) + .await + .wrap_err("failed to stream incoming events")?; + println!("Streaming incoming events:"); + while let Some(event) = stream.next().await { + match event { + Ok(event) => output::print(&event, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + BotCommand::StreamGame { game_id } => { + let request = stream::game::GetRequest::new(&game_id); + let mut stream = lichess + .bot_stream_board_state(request) + .await + .wrap_err_with(|| format!("failed to stream game state for '{game_id}'"))?; + println!("Streaming game state:"); + while let Some(event) = stream.next().await { + match event { + Ok(event) => output::print(&event, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + BotCommand::HandleTakeback { game_id, accept } => { + let request = takeback::PostRequest::new(&game_id, accept); + let result = lichess + .bot_handle_takeback(request) + .await + .wrap_err_with(|| format!("failed to handle takeback for game '{game_id}'"))?; + println!("Takeback handled: {}", result); + Ok(()) + } + BotCommand::UpgradeAccount => { + let result = lichess + .bot_upgrade_account(upgrade::PostRequest::new()) + .await + .wrap_err("failed to upgrade account to a bot account")?; + println!("Account upgraded to Bot account: {}", result); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/broadcasts.rs b/cli/src/commands/broadcasts.rs new file mode 100644 index 0000000..3e07018 --- /dev/null +++ b/cli/src/commands/broadcasts.rs @@ -0,0 +1,832 @@ +use clap::Subcommand; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use futures::StreamExt; +use lichess_api::client::LichessApi; +use lichess_api::model::broadcasts::*; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Subcommand)] +pub enum BroadcastsCommand { + /// Export all rounds of a broadcast tournament as PGN + ExportPgn { + /// Broadcast tournament ID + broadcast_tournament_id: String, + /// Include clock comments + #[arg(long)] + clocks: Option, + /// Include move comments + #[arg(long)] + comments: Option, + }, + /// Export one round as PGN + ExportRoundPgn { + /// Broadcast round ID + broadcast_round_id: String, + /// Include clock comments + #[arg(long)] + clocks: Option, + /// Include move comments + #[arg(long)] + comments: Option, + }, + /// Stream ongoing broadcast rounds of a group as PGN + StreamGroupPgn { + /// Broadcast group ID + broadcast_group_id: String, + /// Include clock comments + #[arg(long)] + clocks: Option, + /// Include move comments + #[arg(long)] + comments: Option, + }, + /// Stream an ongoing broadcast round as PGN + StreamRoundPgn { + /// Broadcast round ID + broadcast_round_id: String, + /// Include clock comments + #[arg(long)] + clocks: Option, + /// Include move comments + #[arg(long)] + comments: Option, + }, + /// Stream ongoing broadcast rounds of a tournament as PGN + StreamTournamentPgn { + /// Broadcast tournament ID + broadcast_tour_id: String, + /// Include clock comments + #[arg(long)] + clocks: Option, + /// Include move comments + #[arg(long)] + comments: Option, + }, + /// Get a broadcast round + GetRound { + /// Broadcast tournament slug + broadcast_tournament_slug: String, + /// Broadcast round slug + broadcast_round_slug: String, + /// Broadcast round ID + broadcast_round_id: String, + }, + /// Get your broadcast rounds + MyRounds { + /// Max number of rounds to fetch + #[arg(long)] + nb: Option, + }, + /// Get broadcasts created by a user + ByUser { + /// Username + username: String, + /// Page number + #[arg(long)] + page: Option, + /// Return HTML instead of markdown for the description + #[arg(long)] + html: Option, + }, + /// Get official broadcasts + Official { + /// Max number of broadcasts to fetch + #[arg(long)] + nb: Option, + /// Return HTML instead of markdown for the description + #[arg(long)] + html: Option, + /// Only fetch broadcasts that are live + #[arg(long)] + live: Option, + }, + /// Search broadcasts + Search { + /// Page number + #[arg(long)] + page: Option, + /// Search query + #[arg(long)] + q: Option, + }, + /// Get paginated top broadcast previews + Top { + /// Page number + #[arg(long)] + page: Option, + /// Return HTML instead of markdown for the description + #[arg(long)] + html: Option, + }, + /// Get a broadcast tournament + GetTournament { + /// Broadcast tournament ID + broadcast_tournament_id: String, + }, + /// Create a broadcast tournament + CreateTournament { + /// Tournament name + name: String, + /// Format, e.g. "Swiss" + #[arg(long)] + info_format: Option, + /// Time control, e.g. "Classical" + #[arg(long)] + info_tc: Option, + /// FIDE time control category + #[arg(long)] + info_fide_tc: Option, + /// Time zone, e.g. "Europe/London" + #[arg(long)] + info_time_zone: Option, + /// Location + #[arg(long)] + info_location: Option, + /// Short list of notable players + #[arg(long)] + info_players: Option, + /// Official website URL + #[arg(long)] + info_website: Option, + /// Standings page URL + #[arg(long)] + info_standings: Option, + /// Regulations page URL + #[arg(long)] + info_regulations: Option, + /// Markdown description + #[arg(long)] + markdown: Option, + /// Show player scores + #[arg(long)] + show_scores: Option, + /// Show player rating diffs + #[arg(long)] + show_rating_diffs: Option, + /// Show a team table + #[arg(long)] + team_table: Option, + /// Visibility: public, unlisted, or private + #[arg(long)] + visibility: Option, + /// Player names/ratings/titles, one per line + #[arg(long)] + players: Option, + /// Team names/tags, one per line + #[arg(long)] + teams: Option, + /// Tier, for official broadcasts + #[arg(long)] + tier: Option, + }, + /// Update your broadcast tournament + UpdateTournament { + /// Broadcast tournament ID + broadcast_tournament_id: String, + /// Tournament name + name: String, + /// Format, e.g. "Swiss" + #[arg(long)] + info_format: Option, + /// Time control, e.g. "Classical" + #[arg(long)] + info_tc: Option, + /// FIDE time control category + #[arg(long)] + info_fide_tc: Option, + /// Time zone, e.g. "Europe/London" + #[arg(long)] + info_time_zone: Option, + /// Location + #[arg(long)] + info_location: Option, + /// Short list of notable players + #[arg(long)] + info_players: Option, + /// Official website URL + #[arg(long)] + info_website: Option, + /// Standings page URL + #[arg(long)] + info_standings: Option, + /// Regulations page URL + #[arg(long)] + info_regulations: Option, + /// Markdown description + #[arg(long)] + markdown: Option, + /// Show player scores + #[arg(long)] + show_scores: Option, + /// Show player rating diffs + #[arg(long)] + show_rating_diffs: Option, + /// Show a team table + #[arg(long)] + team_table: Option, + /// Visibility: public, unlisted, or private + #[arg(long)] + visibility: Option, + /// Player names/ratings/titles, one per line + #[arg(long)] + players: Option, + /// Team names/tags, one per line + #[arg(long)] + teams: Option, + /// Tier, for official broadcasts + #[arg(long)] + tier: Option, + }, + /// Create a broadcast round + CreateRound { + /// Broadcast tournament ID + broadcast_tournament_id: String, + /// Round name + name: String, + /// URL to sync the PGN from + #[arg(long)] + sync_url: Option, + /// Multiple sync URLs, one per line + #[arg(long)] + sync_urls: Option, + /// Sync from existing broadcast round IDs, one per line + #[arg(long)] + sync_ids: Option, + /// Sync from Lichess usernames currently playing, one per line + #[arg(long)] + sync_users: Option, + /// Only sync this board number + #[arg(long)] + only_round: Option, + /// Slice the PGN source + #[arg(long)] + slices: Option, + /// Source type override for syncing + #[arg(long)] + sync_source: Option, + /// Start time as a unix timestamp in milliseconds + #[arg(long)] + starts_at: Option, + /// Start automatically after the previous round completes + #[arg(long)] + starts_after_previous: Option, + /// Delay in seconds before broadcasting moves + #[arg(long)] + delay: Option, + /// Round status: new, started, or finished + #[arg(long)] + status: Option, + /// Whether the round is rated + #[arg(long)] + rated: Option, + /// Time between synchronizations in seconds + #[arg(long)] + period: Option, + }, + /// Get a player of a broadcast + GetPlayer { + /// Broadcast tournament ID + broadcast_tournament_id: String, + /// Player ID + player_id: String, + }, + /// Get players of a broadcast + GetPlayers { + /// Broadcast tournament ID + broadcast_tournament_id: String, + }, + /// Get the team leaderboard of a broadcast + GetTeamStandings { + /// Broadcast tournament ID + broadcast_tournament_id: String, + }, + /// Update a broadcast round + UpdateRound { + /// Broadcast round ID + broadcast_round_id: String, + /// Apply only the fields that were passed, instead of resetting the rest + #[arg(long)] + patch: Option, + /// Round name + name: String, + /// URL to sync the PGN from + #[arg(long)] + sync_url: Option, + /// Multiple sync URLs, one per line + #[arg(long)] + sync_urls: Option, + /// Sync from existing broadcast round IDs, one per line + #[arg(long)] + sync_ids: Option, + /// Sync from Lichess usernames currently playing, one per line + #[arg(long)] + sync_users: Option, + /// Only sync this board number + #[arg(long)] + only_round: Option, + /// Slice the PGN source + #[arg(long)] + slices: Option, + /// Source type override for syncing + #[arg(long)] + sync_source: Option, + /// Start time as a unix timestamp in milliseconds + #[arg(long)] + starts_at: Option, + /// Start automatically after the previous round completes + #[arg(long)] + starts_after_previous: Option, + /// Delay in seconds before broadcasting moves + #[arg(long)] + delay: Option, + /// Round status: new, started, or finished + #[arg(long)] + status: Option, + /// Whether the round is rated + #[arg(long)] + rated: Option, + /// Time between synchronizations in seconds + #[arg(long)] + period: Option, + }, + /// Push PGN to a broadcast round + PushPgn { + /// Broadcast round ID + broadcast_round_id: String, + /// PGN text to push + pgn: String, + }, + /// Reset a broadcast round + ResetRound { + /// Broadcast round ID + broadcast_round_id: String, + }, +} + +impl BroadcastsCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + BroadcastsCommand::ExportPgn { + broadcast_tournament_id, + clocks, + comments, + } => { + let query = export_pgn::GetQuery { + options: PgnStreamQuery { clocks, comments }, + }; + let mut stream = lichess + .export_broadcast_pgn(&broadcast_tournament_id, query) + .await + .wrap_err_with(|| { + format!( + "failed to export pgn of broadcast tournament '{broadcast_tournament_id}'" + ) + })?; + while let Some(chunk) = stream.next().await { + let chunk = chunk.wrap_err("failed to read pgn stream")?; + println!("{chunk}"); + } + Ok(()) + } + BroadcastsCommand::ExportRoundPgn { + broadcast_round_id, + clocks, + comments, + } => { + let query = export_round_pgn::GetQuery { + options: PgnStreamQuery { clocks, comments }, + }; + let mut stream = lichess + .export_broadcast_round_pgn(&broadcast_round_id, query) + .await + .wrap_err_with(|| { + format!("failed to export pgn of broadcast round '{broadcast_round_id}'") + })?; + while let Some(chunk) = stream.next().await { + let chunk = chunk.wrap_err("failed to read pgn stream")?; + println!("{chunk}"); + } + Ok(()) + } + BroadcastsCommand::StreamGroupPgn { + broadcast_group_id, + clocks, + comments, + } => { + let query = stream_group_pgn::GetQuery { + options: PgnStreamQuery { clocks, comments }, + }; + let mut stream = lichess + .stream_broadcast_group_pgn(&broadcast_group_id, query) + .await + .wrap_err_with(|| { + format!("failed to stream pgn of broadcast group '{broadcast_group_id}'") + })?; + while let Some(chunk) = stream.next().await { + match chunk { + Ok(chunk) => println!("{chunk}"), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + BroadcastsCommand::StreamRoundPgn { + broadcast_round_id, + clocks, + comments, + } => { + let query = stream_round_pgn::GetQuery { + options: PgnStreamQuery { clocks, comments }, + }; + let mut stream = lichess + .stream_broadcast_round_pgn(&broadcast_round_id, query) + .await + .wrap_err_with(|| { + format!("failed to stream pgn of broadcast round '{broadcast_round_id}'") + })?; + while let Some(chunk) = stream.next().await { + match chunk { + Ok(chunk) => println!("{chunk}"), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + BroadcastsCommand::StreamTournamentPgn { + broadcast_tour_id, + clocks, + comments, + } => { + let query = stream_tournament_pgn::GetQuery { + options: PgnStreamQuery { clocks, comments }, + }; + let mut stream = lichess + .stream_broadcast_tournament_pgn(&broadcast_tour_id, query) + .await + .wrap_err_with(|| { + format!( + "failed to stream pgn of broadcast tournament '{broadcast_tour_id}'" + ) + })?; + while let Some(chunk) = stream.next().await { + match chunk { + Ok(chunk) => println!("{chunk}"), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + BroadcastsCommand::GetRound { + broadcast_tournament_slug, + broadcast_round_slug, + broadcast_round_id, + } => { + let round = lichess + .get_broadcast_round( + &broadcast_tournament_slug, + &broadcast_round_slug, + &broadcast_round_id, + ) + .await + .wrap_err_with(|| { + format!("failed to fetch broadcast round '{broadcast_round_id}'") + })?; + output::print(&round, json); + Ok(()) + } + BroadcastsCommand::MyRounds { nb } => { + let query = list_my_rounds::GetQuery { nb }; + let mut stream = lichess + .get_my_broadcast_rounds(query) + .await + .wrap_err("failed to fetch your broadcast rounds")?; + while let Some(round) = stream.next().await { + match round { + Ok(round) => output::print(&round, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + BroadcastsCommand::ByUser { + username, + page, + html, + } => { + let query = list_by_user::GetQuery { page, html }; + let broadcasts = lichess + .get_broadcasts_by_user(&username, query) + .await + .wrap_err_with(|| format!("failed to fetch broadcasts by '{username}'"))?; + output::print(&broadcasts, json); + Ok(()) + } + BroadcastsCommand::Official { nb, html, live } => { + let query = list_official::GetQuery { nb, html, live }; + let mut stream = lichess + .get_official_broadcasts(query) + .await + .wrap_err("failed to fetch official broadcasts")?; + while let Some(broadcast) = stream.next().await { + match broadcast { + Ok(broadcast) => output::print(&broadcast, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + BroadcastsCommand::Search { page, q } => { + let query = search::GetQuery { page, q }; + let results = lichess + .search_broadcasts(query) + .await + .wrap_err("failed to search broadcasts")?; + output::print(&results, json); + Ok(()) + } + BroadcastsCommand::Top { page, html } => { + let query = top::GetQuery { page, html }; + let top = lichess + .get_top_broadcasts(query) + .await + .wrap_err("failed to fetch top broadcasts")?; + output::print(&top, json); + Ok(()) + } + BroadcastsCommand::GetTournament { + broadcast_tournament_id, + } => { + let tournament = lichess + .get_broadcast_tournament(broadcast_tournament_id.as_str()) + .await + .wrap_err_with(|| { + format!("failed to fetch broadcast tournament '{broadcast_tournament_id}'") + })?; + output::print(&tournament, json); + Ok(()) + } + BroadcastsCommand::CreateTournament { + name, + info_format, + info_tc, + info_fide_tc, + info_time_zone, + info_location, + info_players, + info_website, + info_standings, + info_regulations, + markdown, + show_scores, + show_rating_diffs, + team_table, + visibility, + players, + teams, + tier, + } => { + let form = create_tournament::CreateBroadcastTournamentForm { + name, + info_format, + info_tc, + info_fide_tc, + info_time_zone, + info_location, + info_players, + info_website, + info_standings, + info_regulations, + markdown, + show_scores, + show_rating_diffs, + team_table, + visibility, + players, + teams, + tier, + tiebreaks: None, + grouping: None, + }; + let tournament = lichess + .create_broadcast_tournament(form) + .await + .wrap_err("failed to create broadcast tournament")?; + output::print(&tournament, json); + Ok(()) + } + BroadcastsCommand::UpdateTournament { + broadcast_tournament_id, + name, + info_format, + info_tc, + info_fide_tc, + info_time_zone, + info_location, + info_players, + info_website, + info_standings, + info_regulations, + markdown, + show_scores, + show_rating_diffs, + team_table, + visibility, + players, + teams, + tier, + } => { + let form = create_tournament::CreateBroadcastTournamentForm { + name, + info_format, + info_tc, + info_fide_tc, + info_time_zone, + info_location, + info_players, + info_website, + info_standings, + info_regulations, + markdown, + show_scores, + show_rating_diffs, + team_table, + visibility, + players, + teams, + tier, + tiebreaks: None, + grouping: None, + }; + let result = lichess + .update_broadcast_tournament(&broadcast_tournament_id, form) + .await + .wrap_err_with(|| { + format!("failed to update broadcast tournament '{broadcast_tournament_id}'") + })?; + println!("Updated tournament '{broadcast_tournament_id}': {}", result); + Ok(()) + } + BroadcastsCommand::CreateRound { + broadcast_tournament_id, + name, + sync_url, + sync_urls, + sync_ids, + sync_users, + only_round, + slices, + sync_source, + starts_at, + starts_after_previous, + delay, + status, + rated, + period, + } => { + let form = create_round::BroadcastRoundForm { + name, + sync_url, + sync_urls, + sync_ids, + sync_users, + only_round, + slices, + sync_source, + starts_at, + starts_after_previous, + delay, + status, + rated, + custom_scoring: None, + team_custom_scoring: None, + period, + }; + let round = lichess + .create_broadcast_round(&broadcast_tournament_id, form) + .await + .wrap_err_with(|| { + format!( + "failed to create round for broadcast tournament '{broadcast_tournament_id}'" + ) + })?; + output::print(&round, json); + Ok(()) + } + BroadcastsCommand::GetPlayer { + broadcast_tournament_id, + player_id, + } => { + let player = lichess + .get_broadcast_player(&broadcast_tournament_id, &player_id) + .await + .wrap_err_with(|| { + format!( + "failed to fetch player '{player_id}' of broadcast '{broadcast_tournament_id}'" + ) + })?; + output::print(&player, json); + Ok(()) + } + BroadcastsCommand::GetPlayers { + broadcast_tournament_id, + } => { + let players = lichess + .get_broadcast_players(broadcast_tournament_id.as_str()) + .await + .wrap_err_with(|| { + format!("failed to fetch players of broadcast '{broadcast_tournament_id}'") + })?; + output::print(&players, json); + Ok(()) + } + BroadcastsCommand::GetTeamStandings { + broadcast_tournament_id, + } => { + let standings = lichess + .get_broadcast_team_standings(broadcast_tournament_id.as_str()) + .await + .wrap_err_with(|| { + format!( + "failed to fetch team standings of broadcast '{broadcast_tournament_id}'" + ) + })?; + output::print(&standings, json); + Ok(()) + } + BroadcastsCommand::UpdateRound { + broadcast_round_id, + patch, + name, + sync_url, + sync_urls, + sync_ids, + sync_users, + only_round, + slices, + sync_source, + starts_at, + starts_after_previous, + delay, + status, + rated, + period, + } => { + let query = update_round::PostQuery { patch }; + let form = create_round::BroadcastRoundForm { + name, + sync_url, + sync_urls, + sync_ids, + sync_users, + only_round, + slices, + sync_source, + starts_at, + starts_after_previous, + delay, + status, + rated, + custom_scoring: None, + team_custom_scoring: None, + period, + }; + let round = lichess + .update_broadcast_round(&broadcast_round_id, query, form) + .await + .wrap_err_with(|| { + format!("failed to update broadcast round '{broadcast_round_id}'") + })?; + output::print(&round, json); + Ok(()) + } + BroadcastsCommand::PushPgn { + broadcast_round_id, + pgn, + } => { + let result = lichess + .push_broadcast_round_pgn(&broadcast_round_id, pgn) + .await + .wrap_err_with(|| { + format!("failed to push pgn to broadcast round '{broadcast_round_id}'") + })?; + output::print(&result, json); + Ok(()) + } + BroadcastsCommand::ResetRound { broadcast_round_id } => { + let result = lichess + .reset_broadcast_round(broadcast_round_id.as_str()) + .await + .wrap_err_with(|| { + format!("failed to reset broadcast round '{broadcast_round_id}'") + })?; + println!("Reset round '{broadcast_round_id}': {}", result); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/bulk_pairings.rs b/cli/src/commands/bulk_pairings.rs new file mode 100644 index 0000000..d487c2a --- /dev/null +++ b/cli/src/commands/bulk_pairings.rs @@ -0,0 +1,243 @@ +use clap::{Subcommand, ValueEnum}; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use futures::StreamExt; +use lichess_api::client::LichessApi; +use lichess_api::model::bulk_pairings::*; +use lichess_api::model::{Days, VariantKey}; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Clone, ValueEnum)] +pub enum CorrespondenceDays { + One, + Two, + Three, + Five, + Seven, + Ten, + Fourteen, +} + +impl From for Days { + fn from(days: CorrespondenceDays) -> Self { + match days { + CorrespondenceDays::One => Days::One, + CorrespondenceDays::Two => Days::Two, + CorrespondenceDays::Three => Days::Three, + CorrespondenceDays::Five => Days::Five, + CorrespondenceDays::Seven => Days::Seven, + CorrespondenceDays::Ten => Days::Ten, + CorrespondenceDays::Fourteen => Days::Fourteen, + } + } +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum Variant { + Standard, + Chess960, + Crazyhouse, + Antichess, + Atomic, + Horde, + KingOfTheHill, + RacingKings, + ThreeCheck, +} + +impl From for VariantKey { + fn from(variant: Variant) -> Self { + match variant { + Variant::Standard => VariantKey::Standard, + Variant::Chess960 => VariantKey::Chess960, + Variant::Crazyhouse => VariantKey::Crazyhouse, + Variant::Antichess => VariantKey::Antichess, + Variant::Atomic => VariantKey::Atomic, + Variant::Horde => VariantKey::Horde, + Variant::KingOfTheHill => VariantKey::KingOfTheHill, + Variant::RacingKings => VariantKey::RacingKings, + Variant::ThreeCheck => VariantKey::ThreeCheck, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum BulkPairingsCommand { + /// View your bulk pairings + List, + /// Create a bulk pairing + Create { + /// Comma-separated list of paired player tokens, e.g. "token1:token2,token3:token4" + #[arg(long)] + players: String, + /// Clock limit in seconds + #[arg(long)] + clock_limit: Option, + /// Clock increment in seconds + #[arg(long)] + clock_increment: Option, + /// Days per turn for correspondence games + #[arg(long, value_enum)] + days: Option, + /// Unix timestamp (seconds) at which to schedule the pairings + #[arg(long)] + pair_at: Option, + /// Unix timestamp (seconds) at which to start the clocks + #[arg(long)] + start_clocks_at: Option, + /// Whether the games are rated + #[arg(long)] + rated: bool, + /// Chess variant + #[arg(long, value_enum, default_value = "standard")] + variant: Variant, + /// Custom starting position (FEN) + #[arg(long)] + fen: Option, + /// Message sent to each player, templated + #[arg(long)] + message: Option, + /// Extra game rules + #[arg(long)] + rules: Option, + }, + /// Show a bulk pairing + Get { + /// Bulk pairing ID + id: String, + }, + /// Cancel a bulk pairing + Cancel { + /// Bulk pairing ID + id: String, + }, + /// Export games of a bulk pairing + ExportGames { + /// Bulk pairing ID + id: String, + /// Include the PGN moves + #[arg(long)] + moves: bool, + /// Include clock comments in the PGN moves + #[arg(long)] + clocks: bool, + /// Include analysis evaluation comments in the PGN moves + #[arg(long)] + evals: bool, + /// Include the opening name + #[arg(long)] + opening: bool, + }, + /// Manually start the clocks of a bulk pairing + StartClocks { + /// Bulk pairing ID + id: String, + }, +} + +impl BulkPairingsCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + BulkPairingsCommand::List => { + let pairings = lichess + .get_bulk_pairings() + .await + .wrap_err("failed to fetch bulk pairings")?; + output::print(&pairings, json); + Ok(()) + } + BulkPairingsCommand::Create { + players, + clock_limit, + clock_increment, + days, + pair_at, + start_clocks_at, + rated, + variant, + fen, + message, + rules, + } => { + let form = create::CreateBulkPairingForm { + players, + clock_limit, + clock_increment, + days: days.map(|d| d.into()), + pair_at, + start_clocks_at, + rated: Some(rated), + variant: Some(variant.into()), + fen, + message, + rules, + }; + let pairing = lichess + .create_bulk_pairing(form) + .await + .wrap_err("failed to create bulk pairing")?; + output::print(&pairing, json); + Ok(()) + } + BulkPairingsCommand::Get { id } => { + let request = show::GetRequest::new(&id); + let pairing = lichess + .get_bulk_pairing(request) + .await + .wrap_err_with(|| format!("failed to fetch bulk pairing '{id}'"))?; + output::print(&pairing, json); + Ok(()) + } + BulkPairingsCommand::Cancel { id } => { + let request = remove::DeleteRequest::new(&id); + let result = lichess + .cancel_bulk_pairing(request) + .await + .wrap_err_with(|| format!("failed to cancel bulk pairing '{id}'"))?; + println!("Bulk pairing cancelled: {}", result); + Ok(()) + } + BulkPairingsCommand::ExportGames { + id, + moves, + clocks, + evals, + opening, + } => { + let query = games::GetQuery { + moves: Some(moves), + pgn_in_json: None, + tags: None, + clocks: Some(clocks), + evals: Some(evals), + accuracy: None, + opening: Some(opening), + division: None, + literate: None, + }; + let mut stream = lichess + .export_bulk_pairing_games(&id, query) + .await + .wrap_err_with(|| format!("failed to export games for bulk pairing '{id}'"))?; + while let Some(game) = stream.next().await { + let game = game.wrap_err("failed to read exported game")?; + output::print(&game, json); + } + Ok(()) + } + BulkPairingsCommand::StartClocks { id } => { + let request = start_clocks::PostRequest::new(&id); + let result = lichess + .start_bulk_pairing_clocks(request) + .await + .wrap_err_with(|| format!("failed to start clocks for bulk pairing '{id}'"))?; + println!("Clocks started: {}", result); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/fide.rs b/cli/src/commands/fide.rs new file mode 100644 index 0000000..0e0e38a --- /dev/null +++ b/cli/src/commands/fide.rs @@ -0,0 +1,61 @@ +use clap::Subcommand; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use lichess_api::client::LichessApi; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Subcommand)] +pub enum FideCommand { + /// Search FIDE players by name + Search { + /// Search query + query: String, + }, + /// Get a FIDE player by ID + Player { + /// FIDE player ID + id: u32, + }, + /// Get the rating history of a FIDE player + Ratings { + /// FIDE player ID + id: u32, + }, +} + +impl FideCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + FideCommand::Search { query } => { + let players = lichess + .search_fide_player(query.as_str()) + .await + .wrap_err_with(|| format!("failed to search fide players for '{query}'"))?; + output::print(&players, json); + Ok(()) + } + FideCommand::Player { id } => { + let player = lichess + .get_fide_player(id) + .await + .wrap_err_with(|| format!("failed to fetch fide player '{id}'"))?; + output::print(&player, json); + Ok(()) + } + FideCommand::Ratings { id } => { + let ratings = lichess + .get_fide_player_ratings(id) + .await + .wrap_err_with(|| { + format!("failed to fetch rating history for fide player '{id}'") + })?; + output::print(&ratings, json); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/games.rs b/cli/src/commands/games.rs new file mode 100644 index 0000000..ecd2708 --- /dev/null +++ b/cli/src/commands/games.rs @@ -0,0 +1,708 @@ +use clap::{Subcommand, ValueEnum}; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use futures::StreamExt; +use lichess_api::client::LichessApi; +use lichess_api::model::games::*; +use lichess_api::model::{Color, PerfType}; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Clone, ValueEnum)] +pub enum Sort { + DateAsc, + DateDesc, +} + +impl From for export::by_user::Sort { + fn from(sort: Sort) -> Self { + match sort { + Sort::DateAsc => export::by_user::Sort::DateAsc, + Sort::DateDesc => export::by_user::Sort::DateDesc, + } + } +} + +impl From for export::bookmarks::Sort { + fn from(sort: Sort) -> Self { + match sort { + Sort::DateAsc => export::bookmarks::Sort::DateAsc, + Sort::DateDesc => export::bookmarks::Sort::DateDesc, + } + } +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum GameColor { + White, + Black, + Random, +} + +impl From for Color { + fn from(color: GameColor) -> Self { + match color { + GameColor::White => Color::White, + GameColor::Black => Color::Black, + GameColor::Random => Color::Random, + } + } +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum Performance { + UltraBullet, + Bullet, + Blitz, + Rapid, + Classical, + Correspondence, + Chess960, + Crazyhouse, + Antichess, + Atomic, + Horde, + KingOfTheHill, + RacingKings, + ThreeCheck, +} + +impl From for PerfType { + fn from(perf: Performance) -> Self { + match perf { + Performance::UltraBullet => PerfType::UltraBullet, + Performance::Bullet => PerfType::Bullet, + Performance::Blitz => PerfType::Blitz, + Performance::Rapid => PerfType::Rapid, + Performance::Classical => PerfType::Classical, + Performance::Correspondence => PerfType::Correspondence, + Performance::Chess960 => PerfType::Chess960, + Performance::Crazyhouse => PerfType::Crazyhouse, + Performance::Antichess => PerfType::Antichess, + Performance::Atomic => PerfType::Atomic, + Performance::Horde => PerfType::Horde, + Performance::KingOfTheHill => PerfType::KingOfTheHill, + Performance::RacingKings => PerfType::RacingKings, + Performance::ThreeCheck => PerfType::ThreeCheck, + } + } +} + +struct BaseArgs { + moves: Option, + pgn_in_json: Option, + tags: Option, + clocks: Option, + evals: Option, + accuracy: Option, + opening: Option, + literate: Option, + players: Option, +} + +impl From for export::Base { + fn from(args: BaseArgs) -> Self { + let default = export::Base::default(); + export::Base { + moves: args.moves.unwrap_or(default.moves), + pgn_in_json: args.pgn_in_json.unwrap_or(default.pgn_in_json), + tags: args.tags.unwrap_or(default.tags), + clocks: args.clocks.unwrap_or(default.clocks), + evals: args.evals.unwrap_or(default.evals), + accuracy: args.accuracy.unwrap_or(default.accuracy), + opening: args.opening.unwrap_or(default.opening), + literate: args.literate.unwrap_or(default.literate), + players: args.players, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum GamesCommand { + /// Export one game as JSON + ExportOne { + /// Game ID + game_id: String, + /// Include the PGN moves + #[arg(long)] + moves: Option, + /// Include the PGN moves as a JSON array + #[arg(long)] + pgn_in_json: Option, + /// Include the PGN tags + #[arg(long)] + tags: Option, + /// Include clock comments in the PGN moves + #[arg(long)] + clocks: Option, + /// Include analysis evaluation comments in the PGN moves + #[arg(long)] + evals: Option, + /// Include the accuracy percent of each player, when available + #[arg(long)] + accuracy: Option, + /// Include the opening name + #[arg(long)] + opening: Option, + /// Include a textual description of the game + #[arg(long)] + literate: Option, + /// URL of a text file containing real names and ratings for each player + #[arg(long)] + players: Option, + }, + /// Export the ongoing game of a user, if any + ExportOngoing { + /// Username + username: String, + /// Include the PGN moves + #[arg(long)] + moves: Option, + /// Include the PGN moves as a JSON array + #[arg(long)] + pgn_in_json: Option, + /// Include the PGN tags + #[arg(long)] + tags: Option, + /// Include clock comments in the PGN moves + #[arg(long)] + clocks: Option, + /// Include analysis evaluation comments in the PGN moves + #[arg(long)] + evals: Option, + /// Include the accuracy percent of each player, when available + #[arg(long)] + accuracy: Option, + /// Include the opening name + #[arg(long)] + opening: Option, + /// Include a textual description of the game + #[arg(long)] + literate: Option, + /// URL of a text file containing real names and ratings for each player + #[arg(long)] + players: Option, + }, + /// Export all games of a user + ExportByUser { + /// Username + username: String, + /// Only export games since this timestamp (ms) + #[arg(long)] + since: Option, + /// Only export games until this timestamp (ms) + #[arg(long)] + until: Option, + /// Max number of games to export + #[arg(long, default_value_t = 0)] + max: u64, + /// Only export games played against this opponent + #[arg(long)] + vs: Option, + /// Only export rated (true) or casual (false) games + #[arg(long)] + rated: Option, + /// Only export games of this performance type + #[arg(long, value_enum)] + perf_type: Option, + /// Only export games where the user played this color + #[arg(long, value_enum)] + color: Option, + /// Only export analysed games + #[arg(long)] + analysed: Option, + /// Only export ongoing games + #[arg(long)] + ongoing: Option, + /// Only export finished games + #[arg(long)] + finished: Option, + /// Include the last position's FEN + #[arg(long)] + last_fen: Option, + /// Sort order + #[arg(long, value_enum)] + sort: Option, + /// Include the PGN moves + #[arg(long)] + moves: Option, + /// Include the PGN moves as a JSON array + #[arg(long)] + pgn_in_json: Option, + /// Include the PGN tags + #[arg(long)] + tags: Option, + /// Include clock comments in the PGN moves + #[arg(long)] + clocks: Option, + /// Include analysis evaluation comments in the PGN moves + #[arg(long)] + evals: Option, + /// Include the accuracy percent of each player, when available + #[arg(long)] + accuracy: Option, + /// Include the opening name + #[arg(long)] + opening: Option, + /// Include a textual description of the game + #[arg(long)] + literate: Option, + /// URL of a text file containing real names and ratings for each player + #[arg(long)] + players: Option, + }, + /// Export games by their IDs + ExportByIds { + /// Comma-separated list of game IDs (up to 300) + ids: String, + /// Include the PGN moves + #[arg(long)] + moves: Option, + /// Include the PGN moves as a JSON array + #[arg(long)] + pgn_in_json: Option, + /// Include the PGN tags + #[arg(long)] + tags: Option, + /// Include clock comments in the PGN moves + #[arg(long)] + clocks: Option, + /// Include analysis evaluation comments in the PGN moves + #[arg(long)] + evals: Option, + /// Include the accuracy percent of each player, when available + #[arg(long)] + accuracy: Option, + /// Include the opening name + #[arg(long)] + opening: Option, + /// Include a textual description of the game + #[arg(long)] + literate: Option, + /// URL of a text file containing real names and ratings for each player + #[arg(long)] + players: Option, + }, + /// Stream games as they start and finish for a list of users + StreamByUsers { + /// Comma-separated list of usernames + usernames: String, + /// Also stream the current games of these users, if any + #[arg(long)] + with_current_games: bool, + }, + /// Stream games as they start and finish for a list of game IDs + StreamByIds { + /// Stream ID, chosen by you + stream_id: String, + /// Comma-separated list of game IDs (up to 500) + ids: String, + }, + /// Add game IDs to an existing stream + AddIds { + /// Stream ID, as passed to `stream-by-ids` + stream_id: String, + /// Comma-separated list of game IDs to add + ids: String, + }, + /// Get your ongoing games + Ongoing { + /// Max number of games to fetch + #[arg(default_value = "9")] + max_games: u8, + }, + /// Stream the moves of a game + StreamMoves { + /// Game ID + game_id: String, + }, + /// Import a game from PGN + Import { + /// PGN text of the game + pgn: String, + }, + /// Export your bookmarked games + ExportBookmarks { + /// Only export games since this timestamp (ms) + #[arg(long)] + since: Option, + /// Only export games until this timestamp (ms) + #[arg(long)] + until: Option, + /// Max number of games to export + #[arg(long)] + max: Option, + /// Include the last position's FEN + #[arg(long)] + last_fen: Option, + /// Sort order + #[arg(long, value_enum)] + sort: Option, + /// Include the PGN moves + #[arg(long)] + moves: Option, + /// Include the PGN moves as a JSON array + #[arg(long)] + pgn_in_json: Option, + /// Include the PGN tags + #[arg(long)] + tags: Option, + /// Include clock comments in the PGN moves + #[arg(long)] + clocks: Option, + /// Include analysis evaluation comments in the PGN moves + #[arg(long)] + evals: Option, + /// Include the accuracy percent of each player, when available + #[arg(long)] + accuracy: Option, + /// Include the opening name + #[arg(long)] + opening: Option, + /// Include a textual description of the game + #[arg(long)] + literate: Option, + /// URL of a text file containing real names and ratings for each player + #[arg(long)] + players: Option, + }, + /// Export your imported games + ExportImports, + /// Get the spectator chat of a game + Chat { + /// Game ID + game_id: String, + }, + /// Bookmark a game + Bookmark { + /// Game ID + game_id: String, + /// Unbookmark instead of bookmark + #[arg(long)] + remove: bool, + }, +} + +impl GamesCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + GamesCommand::ExportOne { + game_id, + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + literate, + players, + } => { + let query = export::one::GetQuery { + base: BaseArgs { + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + literate, + players, + } + .into(), + }; + let game = lichess + .export_one_game(export::one::GetRequest::new(&game_id, query)) + .await + .wrap_err_with(|| format!("failed to export game '{game_id}'"))?; + output::print(&game, json); + Ok(()) + } + GamesCommand::ExportOngoing { + username, + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + literate, + players, + } => { + let query = export::ongoing::GetQuery { + base: BaseArgs { + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + literate, + players, + } + .into(), + }; + let game = lichess + .export_ongoing_game(export::ongoing::GetRequest::new(&username, query)) + .await + .wrap_err_with(|| format!("failed to export ongoing game for '{username}'"))?; + output::print(&game, json); + Ok(()) + } + GamesCommand::ExportByUser { + username, + since, + until, + max, + vs, + rated, + perf_type, + color, + analysed, + ongoing, + finished, + last_fen, + sort, + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + literate, + players, + } => { + let query = export::by_user::GetQuery { + base: BaseArgs { + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + literate, + players, + } + .into(), + since, + until, + max, + vs, + rated, + perf_type: perf_type.map(Into::into), + color: color.map(Into::into), + analysed, + ongoing, + finished, + last_fen, + sort: sort.map(Into::into), + }; + let mut stream = lichess + .export_games_of_user(export::by_user::GetRequest::new(&username, query)) + .await + .wrap_err_with(|| format!("failed to export games of user '{username}'"))?; + while let Some(game) = stream.next().await { + let game = game.wrap_err("failed to read exported game")?; + output::print(&game, json); + } + Ok(()) + } + GamesCommand::ExportByIds { + ids, + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + literate, + players, + } => { + let game_ids: Vec = ids.split(',').map(|s| s.trim().to_string()).collect(); + let query = export::by_ids::PostQuery { + base: BaseArgs { + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + literate, + players, + } + .into(), + }; + let mut stream = lichess + .export_games_by_ids(export::by_ids::PostRequest::new(game_ids, query)) + .await + .wrap_err("failed to export games by ids")?; + while let Some(game) = stream.next().await { + let game = game.wrap_err("failed to read exported game")?; + output::print(&game, json); + } + Ok(()) + } + GamesCommand::StreamByUsers { + usernames, + with_current_games, + } => { + let user_ids: Vec = + usernames.split(',').map(|s| s.trim().to_string()).collect(); + let mut stream = lichess + .stream_games_of_users(stream::by_users::PostRequest::new( + user_ids, + with_current_games, + )) + .await + .wrap_err("failed to stream games of users")?; + while let Some(event) = stream.next().await { + match event { + Ok(event) => output::print(&event, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + GamesCommand::StreamByIds { stream_id, ids } => { + let game_ids: Vec = ids.split(',').map(|s| s.trim().to_string()).collect(); + let mut stream = lichess + .stream_games_by_ids(stream::by_ids::PostRequest::new(&stream_id, game_ids)) + .await + .wrap_err_with(|| format!("failed to stream games for stream '{stream_id}'"))?; + while let Some(event) = stream.next().await { + match event { + Ok(event) => output::print(&event, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + GamesCommand::AddIds { stream_id, ids } => { + let game_ids: Vec = ids.split(',').map(|s| s.trim().to_string()).collect(); + let result = lichess + .add_game_ids_to_stream(stream::add_ids::PostRequest::new(&stream_id, game_ids)) + .await + .wrap_err_with(|| format!("failed to add game ids to stream '{stream_id}'"))?; + println!("Game ids added to stream '{stream_id}': {}", result); + Ok(()) + } + GamesCommand::Ongoing { max_games } => { + let games = lichess + .get_my_ongoing_games(ongoing::GetRequest::new(max_games)) + .await + .wrap_err("failed to fetch ongoing games")?; + output::print(&games, json); + Ok(()) + } + GamesCommand::StreamMoves { game_id } => { + let mut stream = lichess + .stream_game_moves(game_id.as_str()) + .await + .wrap_err_with(|| format!("failed to stream moves of game '{game_id}'"))?; + while let Some(event) = stream.next().await { + match event { + Ok(event) => output::print(&event, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + GamesCommand::Import { pgn } => { + let import = lichess + .import_game(pgn) + .await + .wrap_err("failed to import game")?; + output::print(&import, json); + Ok(()) + } + GamesCommand::ExportBookmarks { + since, + until, + max, + last_fen, + sort, + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + literate, + players, + } => { + let query = export::bookmarks::GetQuery { + base: BaseArgs { + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + literate, + players, + } + .into(), + since, + until, + max, + last_fen, + sort: sort.map(Into::into), + }; + let mut stream = lichess + .export_bookmarked_games(export::bookmarks::GetRequest::new(query)) + .await + .wrap_err("failed to export bookmarked games")?; + while let Some(game) = stream.next().await { + let game = game.wrap_err("failed to read exported game")?; + output::print(&game, json); + } + Ok(()) + } + GamesCommand::ExportImports => { + let mut stream = lichess + .export_imported_games() + .await + .wrap_err("failed to export imported games")?; + while let Some(chunk) = stream.next().await { + let chunk = chunk.wrap_err("failed to read imported games stream")?; + println!("{chunk}"); + } + Ok(()) + } + GamesCommand::Chat { game_id } => { + let mut stream = lichess + .get_game_chat(game_id.as_str()) + .await + .wrap_err_with(|| format!("failed to fetch chat for game '{game_id}'"))?; + while let Some(line) = stream.next().await { + let line = line.wrap_err("failed to read chat line")?; + println!("{}: {}", line.user, line.text); + } + Ok(()) + } + GamesCommand::Bookmark { game_id, remove } => { + let query = bookmark::PostQuery { + v: remove.then_some(false), + }; + lichess + .bookmark_game(bookmark::PostRequest::new(&game_id, query)) + .await + .wrap_err_with(|| format!("failed to bookmark game '{game_id}'"))?; + println!("Bookmark toggled for game '{game_id}'"); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/messaging.rs b/cli/src/commands/messaging.rs new file mode 100644 index 0000000..b554d7b --- /dev/null +++ b/cli/src/commands/messaging.rs @@ -0,0 +1,35 @@ +use clap::Subcommand; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use lichess_api::client::LichessApi; +use lichess_api::model::messaging::*; +use reqwest; + +type Lichess = LichessApi; + +#[derive(Debug, Subcommand)] +pub enum MessagingCommand { + /// Send a private message to a user + Send { + /// Username to message + username: String, + /// Message text + text: String, + }, +} + +impl MessagingCommand { + pub async fn run(self, lichess: Lichess, _json: bool) -> Result<()> { + match self { + MessagingCommand::Send { username, text } => { + let request = inbox::PostRequest::new(&username, &text); + let result = lichess + .send_message(request) + .await + .wrap_err_with(|| format!("failed to send message to '{username}'"))?; + println!("Message sent to '{username}': {}", result); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 6b1aa6e..8c62d65 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -1,11 +1,45 @@ +pub mod account; +pub mod analysis; +pub mod arena_tournaments; pub mod board; +pub mod bot; +pub mod broadcasts; +pub mod bulk_pairings; pub mod challenges; pub mod external_engine; +pub mod fide; +pub mod games; +pub mod messaging; +pub mod openings; pub mod puzzles; +pub mod relations; +pub mod simuls; +pub mod studies; +pub mod swiss_tournaments; +pub mod tablebase; +pub mod teams; +pub mod tv; pub mod users; +pub use account::AccountCommand; +pub use analysis::AnalysisCommand; +pub use arena_tournaments::ArenaTournamentsCommand; pub use board::BoardCommand; +pub use bot::BotCommand; +pub use broadcasts::BroadcastsCommand; +pub use bulk_pairings::BulkPairingsCommand; pub use challenges::ChallengesCommand; pub use external_engine::ExternalEngineCommand; +pub use fide::FideCommand; +pub use games::GamesCommand; +pub use messaging::MessagingCommand; +pub use openings::OpeningsCommand; pub use puzzles::PuzzlesCommand; +pub use relations::RelationsCommand; +pub use simuls::SimulsCommand; +pub use studies::StudiesCommand; +pub use swiss_tournaments::SwissTournamentsCommand; +pub use tablebase::TablebaseCommand; +pub use teams::TeamsCommand; +pub use tv::TvCommand; pub use users::UsersCommand; diff --git a/cli/src/commands/openings.rs b/cli/src/commands/openings.rs new file mode 100644 index 0000000..3038eeb --- /dev/null +++ b/cli/src/commands/openings.rs @@ -0,0 +1,262 @@ +use clap::{Subcommand, ValueEnum}; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use futures::StreamExt; +use lichess_api::client::LichessApi; +use lichess_api::model::openings::*; +use lichess_api::model::{Color, VariantKey}; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Clone, ValueEnum)] +pub enum Variant { + Standard, + Chess960, + Crazyhouse, + Antichess, + Atomic, + Horde, + KingOfTheHill, + RacingKings, + ThreeCheck, + FromPosition, +} + +impl From for VariantKey { + fn from(variant: Variant) -> Self { + match variant { + Variant::Standard => VariantKey::Standard, + Variant::Chess960 => VariantKey::Chess960, + Variant::Crazyhouse => VariantKey::Crazyhouse, + Variant::Antichess => VariantKey::Antichess, + Variant::Atomic => VariantKey::Atomic, + Variant::Horde => VariantKey::Horde, + Variant::KingOfTheHill => VariantKey::KingOfTheHill, + Variant::RacingKings => VariantKey::RacingKings, + Variant::ThreeCheck => VariantKey::ThreeCheck, + Variant::FromPosition => VariantKey::FromPosition, + } + } +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum OpeningColor { + White, + Black, + Random, +} + +impl From for Color { + fn from(color: OpeningColor) -> Self { + match color { + OpeningColor::White => Color::White, + OpeningColor::Black => Color::Black, + OpeningColor::Random => Color::Random, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum OpeningsCommand { + /// Query masters games + Masters { + /// Starting FEN position + fen: String, + /// Comma-separated list of moves to reach the queried position + play: String, + /// Only games since this year + #[arg(long)] + since: Option, + /// Only games until this year + #[arg(long)] + until: Option, + /// Number of most common moves to look up + #[arg(long)] + moves: Option, + /// Number of top games to fetch + #[arg(long)] + top_games: Option, + }, + /// Query rated Lichess games + Lichess { + /// Chess variant + #[arg(long, value_enum, default_value = "standard")] + variant: Variant, + /// Starting FEN position + fen: String, + /// Comma-separated list of moves to reach the queried position + play: String, + /// Comma-separated list of speeds to filter by + #[arg(long)] + speeds: Option, + /// Comma-separated list of rating groups to filter by + #[arg(long)] + ratings: Option, + /// Only games since this month, e.g. "2020-01" + #[arg(long)] + since: Option, + /// Only games until this month, e.g. "2023-12" + #[arg(long)] + until: Option, + /// Number of most common moves to look up + #[arg(long)] + moves: Option, + /// Number of top games to fetch + #[arg(long)] + top_games: Option, + /// Number of recent games to fetch + #[arg(long)] + recent_games: Option, + /// Include the move history by month + #[arg(long)] + history: Option, + }, + /// Query a specific player's games + Player { + /// Username + player: String, + /// Starting FEN position + fen: String, + /// Which color the player played + #[arg(value_enum)] + color: OpeningColor, + /// Comma-separated list of moves to reach the queried position + play: String, + /// Chess variant + #[arg(long, value_enum, default_value = "standard")] + variant: Variant, + /// Comma-separated list of speeds to filter by + #[arg(long)] + speeds: Option, + /// Comma-separated list of game modes to filter by + #[arg(long)] + modes: Option, + /// Only games since this month, e.g. "2020-01" + #[arg(long)] + since: Option, + /// Only games until this month, e.g. "2023-12" + #[arg(long)] + until: Option, + /// Number of most common moves to look up + #[arg(long)] + moves: Option, + /// Number of recent games to fetch + #[arg(long)] + recent_games: Option, + }, + /// Fetch a masters game's PGN by ID + Otb { + /// Masters game ID + game_id: String, + }, +} + +impl OpeningsCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + OpeningsCommand::Masters { + fen, + play, + since, + until, + moves, + top_games, + } => { + let query = masters::GetQuery { + fen, + play, + since, + until, + moves, + top_games, + }; + let result = lichess + .openings_masters(query) + .await + .wrap_err("failed to query masters opening explorer")?; + output::print(&result, json); + Ok(()) + } + OpeningsCommand::Lichess { + variant, + fen, + play, + speeds, + ratings, + since, + until, + moves, + top_games, + recent_games, + history, + } => { + let query = lichess::GetQuery { + variant: variant.into(), + fen, + play, + speeds, + ratings, + since, + until, + moves, + top_games, + recent_games, + history, + }; + let result = lichess + .openings_lichess(query) + .await + .wrap_err("failed to query lichess opening explorer")?; + output::print(&result, json); + Ok(()) + } + OpeningsCommand::Player { + player, + fen, + color, + play, + variant, + speeds, + modes, + since, + until, + moves, + recent_games, + } => { + let query = player::GetQuery { + player, + fen, + color: color.into(), + play, + variant: variant.into(), + speeds, + modes, + since, + until, + moves, + recent_games, + }; + let result = lichess + .openings_player(query) + .await + .wrap_err("failed to query player opening explorer")?; + output::print(&result, json); + Ok(()) + } + OpeningsCommand::Otb { game_id } => { + let mut stream = lichess + .openings_otb(game_id.as_str()) + .await + .wrap_err_with(|| format!("failed to fetch masters game '{game_id}'"))?; + while let Some(chunk) = stream.next().await { + let chunk = chunk.wrap_err("failed to read pgn stream")?; + println!("{chunk}"); + } + Ok(()) + } + } + } +} diff --git a/cli/src/commands/relations.rs b/cli/src/commands/relations.rs new file mode 100644 index 0000000..fb2b48e --- /dev/null +++ b/cli/src/commands/relations.rs @@ -0,0 +1,89 @@ +use clap::Subcommand; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use futures::StreamExt; +use lichess_api::client::LichessApi; +use lichess_api::model::relations::*; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Subcommand)] +pub enum RelationsCommand { + /// Get users you follow + Following, + /// Follow a user + Follow { + /// Username + username: String, + }, + /// Unfollow a user + Unfollow { + /// Username + username: String, + }, + /// Block a user + Block { + /// Username + username: String, + }, + /// Unblock a user + Unblock { + /// Username + username: String, + }, +} + +impl RelationsCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + RelationsCommand::Following => { + let mut stream = lichess + .get_following(following::GetRequest::new()) + .await + .wrap_err("failed to fetch followed users")?; + while let Some(user) = stream.next().await { + match user { + Ok(user) => output::print(&user, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + RelationsCommand::Follow { username } => { + let result = lichess + .follow_user(username.as_str()) + .await + .wrap_err_with(|| format!("failed to follow '{username}'"))?; + println!("Followed '{username}': {}", result); + Ok(()) + } + RelationsCommand::Unfollow { username } => { + let result = lichess + .unfollow_user(username.as_str()) + .await + .wrap_err_with(|| format!("failed to unfollow '{username}'"))?; + println!("Unfollowed '{username}': {}", result); + Ok(()) + } + RelationsCommand::Block { username } => { + let result = lichess + .block_user(username.as_str()) + .await + .wrap_err_with(|| format!("failed to block '{username}'"))?; + println!("Blocked '{username}': {}", result); + Ok(()) + } + RelationsCommand::Unblock { username } => { + let result = lichess + .unblock_user(username.as_str()) + .await + .wrap_err_with(|| format!("failed to unblock '{username}'"))?; + println!("Unblocked '{username}': {}", result); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/simuls.rs b/cli/src/commands/simuls.rs new file mode 100644 index 0000000..b0b1ee1 --- /dev/null +++ b/cli/src/commands/simuls.rs @@ -0,0 +1,30 @@ +use clap::Subcommand; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use lichess_api::client::LichessApi; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Subcommand)] +pub enum SimulsCommand { + /// Get current simuls + Current, +} + +impl SimulsCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + SimulsCommand::Current => { + let simuls = lichess + .get_current_simuls() + .await + .wrap_err("failed to fetch current simuls")?; + output::print(&simuls, json); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/studies.rs b/cli/src/commands/studies.rs new file mode 100644 index 0000000..6ff30db --- /dev/null +++ b/cli/src/commands/studies.rs @@ -0,0 +1,421 @@ +use clap::{Subcommand, ValueEnum}; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use futures::StreamExt; +use lichess_api::client::LichessApi; +use lichess_api::model::VariantKey; +use lichess_api::model::studies::*; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Clone, ValueEnum)] +pub enum Variant { + Standard, + Chess960, + Crazyhouse, + Antichess, + Atomic, + Horde, + KingOfTheHill, + RacingKings, + ThreeCheck, + FromPosition, +} + +impl From for VariantKey { + fn from(variant: Variant) -> Self { + match variant { + Variant::Standard => VariantKey::Standard, + Variant::Chess960 => VariantKey::Chess960, + Variant::Crazyhouse => VariantKey::Crazyhouse, + Variant::Antichess => VariantKey::Antichess, + Variant::Atomic => VariantKey::Atomic, + Variant::Horde => VariantKey::Horde, + Variant::KingOfTheHill => VariantKey::KingOfTheHill, + Variant::RacingKings => VariantKey::RacingKings, + Variant::ThreeCheck => VariantKey::ThreeCheck, + Variant::FromPosition => VariantKey::FromPosition, + } + } +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum StudyVisibility { + Public, + Unlisted, + Private, +} + +impl From for create::Visibility { + fn from(visibility: StudyVisibility) -> Self { + match visibility { + StudyVisibility::Public => create::Visibility::Public, + StudyVisibility::Unlisted => create::Visibility::Unlisted, + StudyVisibility::Private => create::Visibility::Private, + } + } +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum UserSelection { + Nobody, + Owner, + Contributor, + Member, + Everyone, +} + +impl From for create::StudyUserSelection { + fn from(selection: UserSelection) -> Self { + match selection { + UserSelection::Nobody => create::StudyUserSelection::Nobody, + UserSelection::Owner => create::StudyUserSelection::Owner, + UserSelection::Contributor => create::StudyUserSelection::Contributor, + UserSelection::Member => create::StudyUserSelection::Member, + UserSelection::Everyone => create::StudyUserSelection::Everyone, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum StudiesCommand { + /// Create a study + Create { + /// Study name + name: String, + /// Visibility + #[arg(long, value_enum, default_value = "public")] + visibility: StudyVisibility, + /// Flair emoji code + #[arg(long)] + flair: Option, + /// Who can use the computer analysis + #[arg(long, value_enum, default_value = "everyone")] + computer: UserSelection, + /// Who can use the opening explorer + #[arg(long, value_enum, default_value = "everyone")] + explorer: UserSelection, + /// Who can clone the study + #[arg(long, value_enum, default_value = "everyone")] + cloneable: UserSelection, + /// Who can view/share the study + #[arg(long, value_enum, default_value = "everyone")] + shareable: UserSelection, + /// Who can use the study chat + #[arg(long, value_enum, default_value = "everyone")] + chat: UserSelection, + /// Direct new contributions to the last chapter + #[arg(long)] + sticky: Option, + }, + /// Import a PGN into a study, as a new chapter + ImportPgn { + /// Study ID + study_id: String, + /// Chapter name + name: String, + /// PGN text + pgn: String, + /// Chess variant + #[arg(long, value_enum)] + variant: Option, + /// Board orientation, "white" or "black" + #[arg(long)] + orientation: Option, + }, + /// Export one chapter of a study as PGN + ExportChapterPgn { + /// Study ID + study_id: String, + /// Chapter ID + chapter_id: String, + /// Include clock comments + #[arg(long)] + clocks: Option, + /// Include move comments + #[arg(long)] + comments: Option, + /// Include variations + #[arg(long)] + variations: Option, + /// Include the board orientation as a PGN tag + #[arg(long)] + orientation: Option, + }, + /// Export a whole study as PGN + ExportStudyPgn { + /// Study ID + study_id: String, + /// Include clock comments + #[arg(long)] + clocks: Option, + /// Include move comments + #[arg(long)] + comments: Option, + /// Include variations + #[arg(long)] + variations: Option, + /// Include the board orientation as a PGN tag + #[arg(long)] + orientation: Option, + }, + /// Check whether a study exists and you have access to it + Metadata { + /// Study ID + study_id: String, + }, + /// Update the PGN tags of a study chapter + UpdateChapterTags { + /// Study ID + study_id: String, + /// Chapter ID + chapter_id: String, + /// PGN containing the new tags + pgn: String, + }, + /// Update the move tree of a study chapter + UpdateChapterMoves { + /// Study ID + study_id: String, + /// Chapter ID + chapter_id: String, + /// PGN containing the new moves + pgn: String, + }, + /// Export all studies of a user as PGN + ExportUserStudiesPgn { + /// Username + username: String, + /// Include clock comments + #[arg(long)] + clocks: Option, + /// Include move comments + #[arg(long)] + comments: Option, + /// Include variations + #[arg(long)] + variations: Option, + /// Include the board orientation as a PGN tag + #[arg(long)] + orientation: Option, + }, + /// List the metadata of all studies of a user + ListUserStudies { + /// Username + username: String, + }, + /// Delete a study chapter + DeleteChapter { + /// Study ID + study_id: String, + /// Chapter ID + chapter_id: String, + }, +} + +impl StudiesCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + StudiesCommand::Create { + name, + visibility, + flair, + computer, + explorer, + cloneable, + shareable, + chat, + sticky, + } => { + let form = create::CreateStudyForm { + name, + visibility: visibility.into(), + flair, + computer: computer.into(), + explorer: explorer.into(), + cloneable: cloneable.into(), + shareable: shareable.into(), + chat: chat.into(), + sticky, + description: None, + }; + let study = lichess + .create_study(form) + .await + .wrap_err("failed to create study")?; + output::print(&study, json); + Ok(()) + } + StudiesCommand::ImportPgn { + study_id, + name, + pgn, + variant, + orientation, + } => { + let body = import_pgn_into_study::ImportPgnBody { + name, + pgn, + variant: variant.map(Into::into), + orientation, + }; + let request = import_pgn_into_study::PostRequest::new(study_id.clone(), body); + let chapters = lichess + .import_pgn_into_study(request) + .await + .wrap_err_with(|| format!("failed to import pgn into study '{study_id}'"))?; + output::print(&chapters, json); + Ok(()) + } + StudiesCommand::ExportChapterPgn { + study_id, + chapter_id, + clocks, + comments, + variations, + orientation, + } => { + let query = export_chapter::GetQuery { + options: PgnExportQuery { + clocks, + comments, + variations, + orientation, + }, + }; + let mut stream = lichess + .export_study_chapter_pgn(&study_id, &chapter_id, query) + .await + .wrap_err_with(|| { + format!("failed to export chapter '{chapter_id}' of study '{study_id}'") + })?; + while let Some(chunk) = stream.next().await { + let chunk = chunk.wrap_err("failed to read pgn stream")?; + println!("{chunk}"); + } + Ok(()) + } + StudiesCommand::ExportStudyPgn { + study_id, + clocks, + comments, + variations, + orientation, + } => { + let query = export_study::GetQuery { + options: PgnExportQuery { + clocks, + comments, + variations, + orientation, + }, + }; + let mut stream = lichess + .export_study_pgn(&study_id, query) + .await + .wrap_err_with(|| format!("failed to export study '{study_id}'"))?; + while let Some(chunk) = stream.next().await { + let chunk = chunk.wrap_err("failed to read pgn stream")?; + println!("{chunk}"); + } + Ok(()) + } + StudiesCommand::Metadata { study_id } => { + lichess + .get_study_metadata(study_id.as_str()) + .await + .wrap_err_with(|| format!("failed to fetch metadata for study '{study_id}'"))?; + println!("Study '{study_id}' exists and is accessible"); + Ok(()) + } + StudiesCommand::UpdateChapterTags { + study_id, + chapter_id, + pgn, + } => { + let form = update_chapter_tags::UpdateChapterTagsForm { pgn }; + lichess + .update_study_chapter_tags(&study_id, &chapter_id, form) + .await + .wrap_err_with(|| { + format!( + "failed to update tags of chapter '{chapter_id}' in study '{study_id}'" + ) + })?; + println!("Tags updated for chapter '{chapter_id}' in study '{study_id}'"); + Ok(()) + } + StudiesCommand::UpdateChapterMoves { + study_id, + chapter_id, + pgn, + } => { + let form = update_chapter_moves::UpdateChapterMovesForm { pgn }; + lichess + .update_study_chapter_moves(&study_id, &chapter_id, form) + .await + .wrap_err_with(|| { + format!( + "failed to update moves of chapter '{chapter_id}' in study '{study_id}'" + ) + })?; + println!("Moves updated for chapter '{chapter_id}' in study '{study_id}'"); + Ok(()) + } + StudiesCommand::ExportUserStudiesPgn { + username, + clocks, + comments, + variations, + orientation, + } => { + let query = export_user_studies::GetQuery { + options: PgnExportQuery { + clocks, + comments, + variations, + orientation, + }, + }; + let mut stream = lichess + .export_user_studies_pgn(&username, query) + .await + .wrap_err_with(|| format!("failed to export studies of user '{username}'"))?; + while let Some(chunk) = stream.next().await { + let chunk = chunk.wrap_err("failed to read pgn stream")?; + println!("{chunk}"); + } + Ok(()) + } + StudiesCommand::ListUserStudies { username } => { + let mut stream = lichess + .list_user_studies(username.as_str()) + .await + .wrap_err_with(|| format!("failed to list studies of user '{username}'"))?; + while let Some(study) = stream.next().await { + match study { + Ok(study) => output::print(&study, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + StudiesCommand::DeleteChapter { + study_id, + chapter_id, + } => { + lichess + .delete_study_chapter(&study_id, &chapter_id) + .await + .wrap_err_with(|| { + format!("failed to delete chapter '{chapter_id}' from study '{study_id}'") + })?; + println!("Chapter '{chapter_id}' deleted from study '{study_id}'"); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/swiss_tournaments.rs b/cli/src/commands/swiss_tournaments.rs new file mode 100644 index 0000000..4cb3168 --- /dev/null +++ b/cli/src/commands/swiss_tournaments.rs @@ -0,0 +1,461 @@ +use clap::{Subcommand, ValueEnum}; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use futures::StreamExt; +use lichess_api::client::LichessApi; +use lichess_api::model::VariantKey; +use lichess_api::model::swiss_tournaments::*; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Clone, ValueEnum)] +pub enum Variant { + Standard, + Chess960, + Crazyhouse, + Antichess, + Atomic, + Horde, + KingOfTheHill, + RacingKings, + ThreeCheck, +} + +impl From for VariantKey { + fn from(variant: Variant) -> Self { + match variant { + Variant::Standard => VariantKey::Standard, + Variant::Chess960 => VariantKey::Chess960, + Variant::Crazyhouse => VariantKey::Crazyhouse, + Variant::Antichess => VariantKey::Antichess, + Variant::Atomic => VariantKey::Atomic, + Variant::Horde => VariantKey::Horde, + Variant::KingOfTheHill => VariantKey::KingOfTheHill, + Variant::RacingKings => VariantKey::RacingKings, + Variant::ThreeCheck => VariantKey::ThreeCheck, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum SwissTournamentsCommand { + /// Create a new swiss tournament for a team + Create { + /// Team ID that hosts the tournament + team_id: String, + /// Tournament name + #[arg(long)] + name: Option, + /// Clock limit in seconds + #[arg(long)] + clock_limit: u32, + /// Clock increment in seconds + #[arg(long)] + clock_increment: u32, + /// Number of rounds to play + #[arg(long)] + nb_rounds: u32, + /// Start date as a unix timestamp in milliseconds + #[arg(long)] + starts_at: Option, + /// Interval between rounds in seconds + #[arg(long)] + round_interval: Option, + /// Chess variant + #[arg(long, value_enum)] + variant: Option, + /// Custom starting position (FEN) + #[arg(long)] + position: Option, + /// Tournament description + #[arg(long)] + description: Option, + /// Whether the tournament is rated + #[arg(long)] + rated: Option, + /// Password to join the tournament + #[arg(long)] + password: Option, + /// Comma-separated list of usernames who should not play each other + #[arg(long)] + forbidden_pairings: Option, + /// Manual pairings for the next round + #[arg(long)] + manual_pairings: Option, + /// Who can read/write the chat (0 = nobody, 10 = players, 20 = everybody) + #[arg(long)] + chat_for: Option, + /// Minimum rating to join + #[arg(long)] + conditions_min_rating: Option, + /// Maximum rating to join + #[arg(long)] + conditions_max_rating: Option, + /// Minimum number of rated games required to join + #[arg(long)] + conditions_nb_rated_game: Option, + /// Whether players must play all their games + #[arg(long)] + conditions_play_your_games: Option, + /// Comma-separated list of usernames always allowed to join + #[arg(long)] + conditions_allow_list: Option, + }, + /// Get info about a swiss tournament + Get { + /// Tournament ID + id: String, + }, + /// Update a swiss tournament + Update { + /// Tournament ID + id: String, + /// Tournament name + #[arg(long)] + name: Option, + /// Clock limit in seconds + #[arg(long)] + clock_limit: u32, + /// Clock increment in seconds + #[arg(long)] + clock_increment: u32, + /// Number of rounds to play + #[arg(long)] + nb_rounds: u32, + /// Start date as a unix timestamp in milliseconds + #[arg(long)] + starts_at: Option, + /// Interval between rounds in seconds + #[arg(long)] + round_interval: Option, + /// Chess variant + #[arg(long, value_enum)] + variant: Option, + /// Custom starting position (FEN) + #[arg(long)] + position: Option, + /// Tournament description + #[arg(long)] + description: Option, + /// Whether the tournament is rated + #[arg(long)] + rated: Option, + /// Password to join the tournament + #[arg(long)] + password: Option, + /// Comma-separated list of usernames who should not play each other + #[arg(long)] + forbidden_pairings: Option, + /// Manual pairings for the next round + #[arg(long)] + manual_pairings: Option, + /// Who can read/write the chat (0 = nobody, 10 = players, 20 = everybody) + #[arg(long)] + chat_for: Option, + /// Minimum rating to join + #[arg(long)] + conditions_min_rating: Option, + /// Maximum rating to join + #[arg(long)] + conditions_max_rating: Option, + /// Minimum number of rated games required to join + #[arg(long)] + conditions_nb_rated_game: Option, + /// Whether players must play all their games + #[arg(long)] + conditions_play_your_games: Option, + /// Comma-separated list of usernames always allowed to join + #[arg(long)] + conditions_allow_list: Option, + }, + /// Export games of a swiss tournament + ExportGames { + /// Tournament ID + id: String, + /// Only games of this player + #[arg(long)] + player: Option, + /// Include the PGN moves + #[arg(long)] + moves: Option, + /// Include the PGN moves as a JSON array + #[arg(long)] + pgn_in_json: Option, + /// Include the PGN tags + #[arg(long)] + tags: Option, + /// Include clock comments + #[arg(long)] + clocks: Option, + /// Include analysis evaluations + #[arg(long)] + evals: Option, + /// Include weighted error values + #[arg(long)] + accuracy: Option, + /// Include the opening name + #[arg(long)] + opening: Option, + /// Include the division of the game into opening/middlegame/endgame + #[arg(long)] + division: Option, + }, + /// Join a swiss tournament + Join { + /// Tournament ID + id: String, + /// Password to join, if required + #[arg(long)] + password: Option, + }, + /// Get the results of a swiss tournament + Results { + /// Tournament ID + id: String, + /// Max number of results to fetch + #[arg(long)] + nb: Option, + }, + /// Manually schedule the next round + ScheduleNextRound { + /// Tournament ID + id: String, + /// Date to schedule the round for, as a unix timestamp in milliseconds + #[arg(long)] + date: i64, + }, + /// Terminate a swiss tournament + Terminate { + /// Tournament ID + id: String, + }, + /// Pause or leave a swiss tournament + Withdraw { + /// Tournament ID + id: String, + }, + /// Export a swiss tournament in the Tournament Report File format + Trf { + /// Tournament ID + id: String, + }, +} + +impl SwissTournamentsCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + SwissTournamentsCommand::Create { + team_id, + name, + clock_limit, + clock_increment, + nb_rounds, + starts_at, + round_interval, + variant, + position, + description, + rated, + password, + forbidden_pairings, + manual_pairings, + chat_for, + conditions_min_rating, + conditions_max_rating, + conditions_nb_rated_game, + conditions_play_your_games, + conditions_allow_list, + } => { + let form = create::CreateSwissTournamentForm { + name, + clock_limit, + clock_increment, + nb_rounds, + starts_at, + round_interval, + variant: variant.map(|v| v.into()), + position, + description, + rated, + password, + forbidden_pairings, + manual_pairings, + chat_for, + conditions_min_rating, + conditions_max_rating, + conditions_nb_rated_game, + conditions_play_your_games, + conditions_allow_list, + }; + let tournament = lichess + .create_swiss_tournament(&team_id, form) + .await + .wrap_err_with(|| { + format!("failed to create swiss tournament for team '{team_id}'") + })?; + output::print(&tournament, json); + Ok(()) + } + SwissTournamentsCommand::Get { id } => { + let tournament = lichess + .get_swiss_tournament(id.as_str()) + .await + .wrap_err_with(|| format!("failed to fetch swiss tournament '{id}'"))?; + output::print(&tournament, json); + Ok(()) + } + SwissTournamentsCommand::Update { + id, + name, + clock_limit, + clock_increment, + nb_rounds, + starts_at, + round_interval, + variant, + position, + description, + rated, + password, + forbidden_pairings, + manual_pairings, + chat_for, + conditions_min_rating, + conditions_max_rating, + conditions_nb_rated_game, + conditions_play_your_games, + conditions_allow_list, + } => { + let form = update::UpdateSwissTournamentForm { + name, + clock_limit, + clock_increment, + nb_rounds, + starts_at, + round_interval, + variant: variant.map(|v| v.into()), + position, + description, + rated, + password, + forbidden_pairings, + manual_pairings, + chat_for, + conditions_min_rating, + conditions_max_rating, + conditions_nb_rated_game, + conditions_play_your_games, + conditions_allow_list, + }; + let tournament = lichess + .update_swiss_tournament(&id, form) + .await + .wrap_err_with(|| format!("failed to update swiss tournament '{id}'"))?; + output::print(&tournament, json); + Ok(()) + } + SwissTournamentsCommand::ExportGames { + id, + player, + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + division, + } => { + let query = games::GetQuery { + player, + moves, + pgn_in_json, + tags, + clocks, + evals, + accuracy, + opening, + division, + }; + let mut stream = lichess + .export_swiss_tournament_games(&id, query) + .await + .wrap_err_with(|| { + format!("failed to export games for swiss tournament '{id}'") + })?; + while let Some(game) = stream.next().await { + match game { + Ok(game) => output::print(&game, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + SwissTournamentsCommand::Join { id, password } => { + let form = join::JoinSwissTournamentForm { password }; + let result = lichess + .join_swiss_tournament(&id, form) + .await + .wrap_err_with(|| format!("failed to join swiss tournament '{id}'"))?; + println!("Joined tournament: {}", result); + Ok(()) + } + SwissTournamentsCommand::Results { id, nb } => { + let query = results::GetQuery { nb }; + let mut stream = lichess + .get_swiss_tournament_results(&id, query) + .await + .wrap_err_with(|| { + format!("failed to fetch results for swiss tournament '{id}'") + })?; + while let Some(result) = stream.next().await { + match result { + Ok(result) => output::print(&result, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + SwissTournamentsCommand::ScheduleNextRound { id, date } => { + let form = schedule_next_round::ScheduleNextRoundForm { date }; + lichess + .schedule_next_swiss_round(&id, form) + .await + .wrap_err_with(|| { + format!("failed to schedule next round for swiss tournament '{id}'") + })?; + println!("Next round scheduled for tournament '{}'", id); + Ok(()) + } + SwissTournamentsCommand::Terminate { id } => { + let result = lichess + .terminate_swiss_tournament(id.as_str()) + .await + .wrap_err_with(|| format!("failed to terminate swiss tournament '{id}'"))?; + println!("Tournament terminated: {}", result); + Ok(()) + } + SwissTournamentsCommand::Withdraw { id } => { + let result = lichess + .withdraw_from_swiss_tournament(id.as_str()) + .await + .wrap_err_with(|| format!("failed to withdraw from swiss tournament '{id}'"))?; + println!("Withdrawn from tournament: {}", result); + Ok(()) + } + SwissTournamentsCommand::Trf { id } => { + let trf = lichess + .get_swiss_tournament_trf(id.as_str()) + .await + .wrap_err_with(|| { + format!("failed to export trf for swiss tournament '{id}'") + })?; + println!("{trf}"); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/tablebase.rs b/cli/src/commands/tablebase.rs new file mode 100644 index 0000000..26ad97d --- /dev/null +++ b/cli/src/commands/tablebase.rs @@ -0,0 +1,59 @@ +use clap::Subcommand; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use lichess_api::client::LichessApi; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Subcommand)] +pub enum TablebaseCommand { + /// Look up an antichess position + Antichess { + /// FEN of the position + fen: String, + }, + /// Look up an atomic chess position + Atomic { + /// FEN of the position + fen: String, + }, + /// Look up a standard chess position + Standard { + /// FEN of the position + fen: String, + }, +} + +impl TablebaseCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + TablebaseCommand::Antichess { fen } => { + let result = lichess + .lookup_antichess(fen.as_str()) + .await + .wrap_err_with(|| format!("failed to look up antichess position '{fen}'"))?; + output::print(&result, json); + Ok(()) + } + TablebaseCommand::Atomic { fen } => { + let result = lichess + .lookup_atomic(fen.as_str()) + .await + .wrap_err_with(|| format!("failed to look up atomic position '{fen}'"))?; + output::print(&result, json); + Ok(()) + } + TablebaseCommand::Standard { fen } => { + let result = lichess + .lookup_standard(fen.as_str()) + .await + .wrap_err_with(|| format!("failed to look up standard position '{fen}'"))?; + output::print(&result, json); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/teams.rs b/cli/src/commands/teams.rs new file mode 100644 index 0000000..682b639 --- /dev/null +++ b/cli/src/commands/teams.rs @@ -0,0 +1,396 @@ +use clap::{Subcommand, ValueEnum}; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use futures::StreamExt; +use lichess_api::client::LichessApi; +use lichess_api::model::teams::*; +use lichess_api::model::{ArenaStatusName, SwissStatus}; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Clone, ValueEnum)] +pub enum TeamArenaStatus { + Created, + Started, + Finished, +} + +impl From for ArenaStatusName { + fn from(status: TeamArenaStatus) -> Self { + match status { + TeamArenaStatus::Created => ArenaStatusName::Created, + TeamArenaStatus::Started => ArenaStatusName::Started, + TeamArenaStatus::Finished => ArenaStatusName::Finished, + } + } +} + +#[derive(Debug, Clone, ValueEnum)] +pub enum TeamSwissStatus { + Created, + Started, + Finished, +} + +impl From for SwissStatus { + fn from(status: TeamSwissStatus) -> Self { + match status { + TeamSwissStatus::Created => SwissStatus::Created, + TeamSwissStatus::Started => SwissStatus::Started, + TeamSwissStatus::Finished => SwissStatus::Finished, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum TeamsCommand { + /// Get popular teams + Popular { + /// Page number + #[arg(long)] + page: Option, + }, + /// Search teams + Search { + /// Search text + #[arg(long)] + text: Option, + /// Page number + #[arg(long)] + page: Option, + }, + /// Get teams of a player + Of { + /// Username + username: String, + }, + /// Get a single team + Get { + /// Team ID + team_id: String, + }, + /// Get members of a team + Members { + /// Team ID + team_id: String, + /// Return the full list of members, not paginated + #[arg(long)] + full: bool, + }, + /// Get team Arena tournaments + Arena { + /// Team ID + team_id: String, + /// Max number of tournaments to fetch + #[arg(long)] + max: Option, + /// Filter by tournament status + #[arg(long, value_enum)] + status: Option, + /// Filter by tournament creator + #[arg(long)] + created_by: Option, + /// Filter by tournament name + #[arg(long)] + name: Option, + }, + /// Get team Swiss tournaments + Swiss { + /// Team ID + team_id: String, + /// Max number of tournaments to fetch + #[arg(long)] + max: Option, + /// Filter by tournament status + #[arg(long, value_enum)] + status: Option, + /// Filter by tournament creator + #[arg(long)] + created_by: Option, + /// Filter by tournament name + #[arg(long)] + name: Option, + }, + /// Get join requests for a team you lead + JoinRequests { + /// Team ID + team_id: String, + /// Include declined requests + #[arg(long)] + declined: bool, + }, + /// Accept a join request + AcceptRequest { + /// Team ID + team_id: String, + /// User ID + user_id: String, + }, + /// Decline a join request + DeclineRequest { + /// Team ID + team_id: String, + /// User ID + user_id: String, + }, + /// Kick a user from your team + Kick { + /// Team ID + team_id: String, + /// User ID + user_id: String, + }, + /// Join a team + Join { + /// Team ID + team_id: String, + /// Message to the team leader + #[arg(long)] + message: Option, + /// Password, if the team requires one + #[arg(long)] + password: Option, + }, + /// Leave a team + Quit { + /// Team ID + team_id: String, + }, + /// Send a message to all members of a team you lead + SendUpdate { + /// Team ID + team_id: String, + /// Message text + message: String, + }, + /// Get updates from your teams + Updates { + /// Page number + #[arg(long)] + page: Option, + }, + /// Get updates from one of your teams + UpdatesOfTeam { + /// Team ID + team_id: String, + /// Page number + #[arg(long)] + page: Option, + }, +} + +impl TeamsCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + TeamsCommand::Popular { page } => { + let request = all::GetRequest::new(all::GetQuery { page }); + let teams = lichess + .get_popular_teams(request) + .await + .wrap_err("failed to fetch popular teams")?; + output::print(&teams, json); + Ok(()) + } + TeamsCommand::Search { text, page } => { + let request = search::GetRequest::new(search::GetQuery { text, page }); + let teams = lichess + .search_teams(request) + .await + .wrap_err("failed to search teams")?; + output::print(&teams, json); + Ok(()) + } + TeamsCommand::Of { username } => { + let request = of_username::GetRequest::new(&username); + let teams = lichess + .get_teams_of_player(request) + .await + .wrap_err_with(|| format!("failed to fetch teams of player '{username}'"))?; + output::print(&teams, json); + Ok(()) + } + TeamsCommand::Get { team_id } => { + let request = show::GetRequest::new(&team_id); + let team = lichess + .get_team(request) + .await + .wrap_err_with(|| format!("failed to fetch team '{team_id}'"))?; + output::print(&team, json); + Ok(()) + } + TeamsCommand::Members { team_id, full } => { + let request = + users::GetRequest::new(&team_id, users::GetQuery { full: Some(full) }); + let mut stream = lichess + .get_team_members(request) + .await + .wrap_err_with(|| format!("failed to fetch members of team '{team_id}'"))?; + while let Some(member) = stream.next().await { + let member = member.wrap_err("failed to read team member")?; + output::print(&member, json); + } + Ok(()) + } + TeamsCommand::Arena { + team_id, + max, + status, + created_by, + name, + } => { + let request = arena::GetRequest::new( + &team_id, + arena::GetQuery { + max, + status: status.map(|s| s.into()), + created_by, + name, + }, + ); + let mut stream = lichess + .get_team_arena_tournaments(request) + .await + .wrap_err_with(|| { + format!("failed to fetch arena tournaments for team '{team_id}'") + })?; + while let Some(tournament) = stream.next().await { + let tournament = tournament.wrap_err("failed to read arena tournament")?; + output::print(&tournament, json); + } + Ok(()) + } + TeamsCommand::Swiss { + team_id, + max, + status, + created_by, + name, + } => { + let request = swiss::GetRequest::new( + &team_id, + swiss::GetQuery { + max, + status: status.map(|s| s.into()), + created_by, + name, + }, + ); + let mut stream = lichess + .get_team_swiss_tournaments(request) + .await + .wrap_err_with(|| { + format!("failed to fetch swiss tournaments for team '{team_id}'") + })?; + while let Some(tournament) = stream.next().await { + let tournament = tournament.wrap_err("failed to read swiss tournament")?; + output::print(&tournament, json); + } + Ok(()) + } + TeamsCommand::JoinRequests { team_id, declined } => { + let request = requests::GetRequest::new( + &team_id, + requests::GetQuery { + declined: Some(declined), + }, + ); + let requests = lichess + .get_team_join_requests(request) + .await + .wrap_err_with(|| { + format!("failed to fetch join requests for team '{team_id}'") + })?; + output::print(&requests, json); + Ok(()) + } + TeamsCommand::AcceptRequest { team_id, user_id } => { + let request = request_accept::PostRequest::new(&team_id, &user_id); + let result = lichess + .accept_team_join_request(request) + .await + .wrap_err_with(|| { + format!( + "failed to accept join request from '{user_id}' for team '{team_id}'" + ) + })?; + println!("Join request accepted: {}", result); + Ok(()) + } + TeamsCommand::DeclineRequest { team_id, user_id } => { + let request = request_decline::PostRequest::new(&team_id, &user_id); + let result = lichess + .decline_team_join_request(request) + .await + .wrap_err_with(|| { + format!( + "failed to decline join request from '{user_id}' for team '{team_id}'" + ) + })?; + println!("Join request declined: {}", result); + Ok(()) + } + TeamsCommand::Kick { team_id, user_id } => { + let request = kick::PostRequest::new(&team_id, &user_id); + let result = lichess.kick_team_member(request).await.wrap_err_with(|| { + format!("failed to kick '{user_id}' from team '{team_id}'") + })?; + println!("Member kicked: {}", result); + Ok(()) + } + TeamsCommand::Join { + team_id, + message, + password, + } => { + let request = + join::PostRequest::new(&team_id, join::JoinForm { message, password }); + let result = lichess + .join_team(request) + .await + .wrap_err_with(|| format!("failed to join team '{team_id}'"))?; + println!("Joined team '{}': {}", team_id, result); + Ok(()) + } + TeamsCommand::Quit { team_id } => { + let request = quit::PostRequest::new(&team_id); + let result = lichess + .quit_team(request) + .await + .wrap_err_with(|| format!("failed to quit team '{team_id}'"))?; + println!("Left team '{}': {}", team_id, result); + Ok(()) + } + TeamsCommand::SendUpdate { team_id, message } => { + let request = pm_all::PostRequest::new(&team_id, message); + let result = lichess + .send_team_update(request) + .await + .wrap_err_with(|| format!("failed to send update to team '{team_id}'"))?; + println!("Update sent: {}", result); + Ok(()) + } + TeamsCommand::Updates { page } => { + let request = updates::GetRequest::new(updates::GetQuery { page }); + let updates = lichess + .get_team_updates(request) + .await + .wrap_err("failed to fetch team updates")?; + output::print(&updates, json); + Ok(()) + } + TeamsCommand::UpdatesOfTeam { team_id, page } => { + let request = + updates_of_team::GetRequest::new(&team_id, updates_of_team::GetQuery { page }); + let updates = lichess + .get_team_updates_of_team(request) + .await + .wrap_err_with(|| format!("failed to fetch updates for team '{team_id}'"))?; + output::print(&updates, json); + Ok(()) + } + } + } +} diff --git a/cli/src/commands/tv.rs b/cli/src/commands/tv.rs new file mode 100644 index 0000000..b199168 --- /dev/null +++ b/cli/src/commands/tv.rs @@ -0,0 +1,162 @@ +use clap::{Subcommand, ValueEnum}; +use color_eyre::Result; +use color_eyre::eyre::WrapErr; +use futures::StreamExt; +use lichess_api::client::LichessApi; +use lichess_api::model::tv::*; +use reqwest; + +use crate::output; + +type Lichess = LichessApi; + +#[derive(Debug, Clone, ValueEnum)] +pub enum Channel { + Bot, + Blitz, + RacingKings, + UltraBullet, + Bullet, + Classical, + ThreeCheck, + Antichess, + Computer, + Horde, + Rapid, + Atomic, + Crazyhouse, + Chess960, + KingOfTheHill, + Best, +} + +impl From for ChannelName { + fn from(channel: Channel) -> Self { + match channel { + Channel::Bot => ChannelName::Bot, + Channel::Blitz => ChannelName::Blitz, + Channel::RacingKings => ChannelName::RacingKings, + Channel::UltraBullet => ChannelName::UltraBullet, + Channel::Bullet => ChannelName::Bullet, + Channel::Classical => ChannelName::Classical, + Channel::ThreeCheck => ChannelName::ThreeCheck, + Channel::Antichess => ChannelName::Antichess, + Channel::Computer => ChannelName::Computer, + Channel::Horde => ChannelName::Horde, + Channel::Rapid => ChannelName::Rapid, + Channel::Atomic => ChannelName::Atomic, + Channel::Crazyhouse => ChannelName::Crazyhouse, + Channel::Chess960 => ChannelName::Chess960, + Channel::KingOfTheHill => ChannelName::KingOfTheHill, + Channel::Best => ChannelName::Best, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum TvCommand { + /// Get current TV champions for every channel + Channels, + /// Stream the current featured game overall + StreamCurrent, + /// Stream the current featured game of a channel + StreamChannel { + /// TV channel + #[arg(value_enum)] + channel: Channel, + }, + /// Get ongoing games of a TV channel + ChannelGames { + /// TV channel + #[arg(value_enum)] + channel: Channel, + /// Max number of games to fetch + #[arg(long)] + number_of_games: Option, + /// Include the PGN moves + #[arg(long)] + moves: Option, + /// Include the PGN moves as a JSON array + #[arg(long)] + pgn_in_json: Option, + /// Include the PGN tags + #[arg(long)] + tags: Option, + /// Include clock comments + #[arg(long)] + clocks: Option, + /// Include the opening name + #[arg(long)] + opening: Option, + }, +} + +impl TvCommand { + pub async fn run(self, lichess: Lichess, json: bool) -> Result<()> { + match self { + TvCommand::Channels => { + let channels = lichess + .tv_channels() + .await + .wrap_err("failed to fetch tv channels")?; + output::print(&channels, json); + Ok(()) + } + TvCommand::StreamCurrent => { + let mut stream = lichess + .tv_stream_current() + .await + .wrap_err("failed to stream current tv game")?; + while let Some(event) = stream.next().await { + match event { + Ok(event) => output::print(&event, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + TvCommand::StreamChannel { channel } => { + let request = stream::channel::GetRequest::new(channel.into()); + let mut stream = lichess + .tv_stream_channel_current(request) + .await + .wrap_err("failed to stream tv channel")?; + while let Some(event) = stream.next().await { + match event { + Ok(event) => output::print(&event, json), + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(()) + } + TvCommand::ChannelGames { + channel, + number_of_games, + moves, + pgn_in_json, + tags, + clocks, + opening, + } => { + let query = games::GetQuery { + number_of_games, + moves, + pgn_in_json, + tags, + clocks, + opening, + }; + let request = games::GetRequest::new(channel.into(), Some(query)); + let mut stream = lichess + .tv_channel_games(request) + .await + .wrap_err("failed to fetch tv channel games")?; + while let Some(game) = stream.next().await { + let game = game.wrap_err("failed to read tv channel game")?; + output::print(&game, json); + } + Ok(()) + } + } + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 63642ef..518a1cc 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -6,7 +6,11 @@ use clap::builder::styling::AnsiColor; use clap::{Parser, Subcommand}; use color_eyre::Result; use commands::{ - BoardCommand, ChallengesCommand, ExternalEngineCommand, PuzzlesCommand, UsersCommand, + AccountCommand, AnalysisCommand, ArenaTournamentsCommand, BoardCommand, BotCommand, + BroadcastsCommand, BulkPairingsCommand, ChallengesCommand, ExternalEngineCommand, FideCommand, + GamesCommand, MessagingCommand, OpeningsCommand, PuzzlesCommand, RelationsCommand, + SimulsCommand, StudiesCommand, SwissTournamentsCommand, TablebaseCommand, TeamsCommand, + TvCommand, UsersCommand, }; use lichess_api::client::LichessApi; use reqwest; @@ -40,10 +44,34 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { + Account { + #[clap(subcommand)] + command: AccountCommand, + }, + Analysis { + #[clap(subcommand)] + command: AnalysisCommand, + }, + ArenaTournaments { + #[clap(subcommand)] + command: ArenaTournamentsCommand, + }, Board { #[clap(subcommand)] command: BoardCommand, }, + Bot { + #[clap(subcommand)] + command: BotCommand, + }, + Broadcasts { + #[clap(subcommand)] + command: BroadcastsCommand, + }, + BulkPairings { + #[clap(subcommand)] + command: BulkPairingsCommand, + }, Puzzles { #[clap(subcommand)] command: PuzzlesCommand, @@ -56,6 +84,50 @@ enum Command { #[clap(subcommand)] command: ChallengesCommand, }, + Fide { + #[clap(subcommand)] + command: FideCommand, + }, + Games { + #[clap(subcommand)] + command: GamesCommand, + }, + Messaging { + #[clap(subcommand)] + command: MessagingCommand, + }, + Openings { + #[clap(subcommand)] + command: OpeningsCommand, + }, + Relations { + #[clap(subcommand)] + command: RelationsCommand, + }, + Simuls { + #[clap(subcommand)] + command: SimulsCommand, + }, + Studies { + #[clap(subcommand)] + command: StudiesCommand, + }, + SwissTournaments { + #[clap(subcommand)] + command: SwissTournamentsCommand, + }, + Tablebase { + #[clap(subcommand)] + command: TablebaseCommand, + }, + Teams { + #[clap(subcommand)] + command: TeamsCommand, + }, + Tv { + #[clap(subcommand)] + command: TvCommand, + }, Users { #[clap(subcommand)] command: UsersCommand, @@ -106,10 +178,27 @@ impl App { async fn run(self, args: Cli) -> Result<()> { let json = args.json; match args.command { + Command::Account { command } => command.run(self.lichess, json).await, + Command::Analysis { command } => command.run(self.lichess, json).await, + Command::ArenaTournaments { command } => command.run(self.lichess, json).await, Command::Board { command } => command.run(self.lichess, json).await, + Command::Bot { command } => command.run(self.lichess, json).await, + Command::Broadcasts { command } => command.run(self.lichess, json).await, + Command::BulkPairings { command } => command.run(self.lichess, json).await, Command::Puzzles { command } => command.run(self.lichess, json).await, Command::Engine { command } => command.run(self.lichess, json).await, Command::Challenges { command } => command.run(self.lichess, json).await, + Command::Fide { command } => command.run(self.lichess, json).await, + Command::Games { command } => command.run(self.lichess, json).await, + Command::Messaging { command } => command.run(self.lichess, json).await, + Command::Openings { command } => command.run(self.lichess, json).await, + Command::Relations { command } => command.run(self.lichess, json).await, + Command::Simuls { command } => command.run(self.lichess, json).await, + Command::Studies { command } => command.run(self.lichess, json).await, + Command::SwissTournaments { command } => command.run(self.lichess, json).await, + Command::Tablebase { command } => command.run(self.lichess, json).await, + Command::Teams { command } => command.run(self.lichess, json).await, + Command::Tv { command } => command.run(self.lichess, json).await, Command::Users { command } => command.run(self.lichess, json).await, } } diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index fd6e258..1268852 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -5,17 +5,39 @@ fn lichess() -> Command { Command::cargo_bin("lichess").unwrap() } +const ALL_CATEGORIES: &[&str] = &[ + "account", + "analysis", + "arena-tournaments", + "board", + "bot", + "broadcasts", + "bulk-pairings", + "puzzles", + "engine", + "challenges", + "fide", + "games", + "messaging", + "openings", + "relations", + "simuls", + "studies", + "swiss-tournaments", + "tablebase", + "teams", + "tv", + "users", +]; + #[test] fn top_level_help_lists_all_categories() { - lichess() - .arg("--help") - .assert() - .success() - .stdout(predicate::str::contains("board")) - .stdout(predicate::str::contains("puzzles")) - .stdout(predicate::str::contains("engine")) - .stdout(predicate::str::contains("challenges")) - .stdout(predicate::str::contains("users")); + let mut cmd = lichess(); + cmd.arg("--help"); + let mut assert = cmd.assert().success(); + for category in ALL_CATEGORIES { + assert = assert.stdout(predicate::str::contains(*category)); + } } #[test] @@ -30,8 +52,8 @@ fn unknown_subcommand_fails() { #[test] fn subcommand_help_succeeds_for_every_category() { - for subcommand in ["board", "puzzles", "engine", "challenges", "users"] { - lichess().args([subcommand, "--help"]).assert().success(); + for subcommand in ALL_CATEGORIES { + lichess().args([*subcommand, "--help"]).assert().success(); } } diff --git a/lib/src/model/studies/import_pgn_into_study.rs b/lib/src/model/studies/import_pgn_into_study.rs index 3968e3c..1196453 100644 --- a/lib/src/model/studies/import_pgn_into_study.rs +++ b/lib/src/model/studies/import_pgn_into_study.rs @@ -11,12 +11,12 @@ pub struct ImportPgnBody { pub orientation: Option, } -#[derive(Default, Clone, Debug, Deserialize)] +#[derive(Default, Clone, Debug, Deserialize, Serialize)] pub struct StudyImportPgnChapters { pub chapters: Vec, } -#[derive(Default, Clone, Debug, Deserialize)] +#[derive(Default, Clone, Debug, Deserialize, Serialize)] pub struct StudyChapterListItem { pub id: String, pub name: String, From 68acfce5a36a9e635cf7f35196d385f94b9fee7e Mon Sep 17 00:00:00 2001 From: Arran Ireland Date: Sun, 23 Aug 2026 10:28:39 +0100 Subject: [PATCH 3/5] chore: fix clippy warnings --- cli/src/commands/external_engine.rs | 5 ++--- cli/src/main.rs | 1 - lib/src/api/external_engine.rs | 2 +- lib/src/api/mod.rs | 12 ++++++------ lib/src/client.rs | 14 +++++++------- lib/src/error.rs | 2 -- lib/src/model/board/stream/game.rs | 2 ++ lib/src/model/external_engine/acquire_analysis.rs | 2 +- lib/src/model/games/stream/moves.rs | 2 ++ lib/src/model/mod.rs | 14 +++++++------- lib/src/model/oauth/revoke.rs | 2 +- lib/src/model/oauth/test.rs | 2 +- lib/tests/online.rs | 4 +--- 13 files changed, 31 insertions(+), 33 deletions(-) diff --git a/cli/src/commands/external_engine.rs b/cli/src/commands/external_engine.rs index c9a3a45..c245753 100644 --- a/cli/src/commands/external_engine.rs +++ b/cli/src/commands/external_engine.rs @@ -237,10 +237,9 @@ impl ExternalEngineCommand { /// Generate a random provider secret for the engine /// This is used to authenticate the engine with the lichess server fn generate_provider_secret() -> String { - let provider_secret = rand::rng() + rand::rng() .sample_iter(&rand::distr::Alphanumeric) .take(16) .map(char::from) - .collect::(); - provider_secret + .collect::() } diff --git a/cli/src/main.rs b/cli/src/main.rs index 518a1cc..3e8ffdf 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -13,7 +13,6 @@ use commands::{ TvCommand, UsersCommand, }; use lichess_api::client::LichessApi; -use reqwest; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; diff --git a/lib/src/api/external_engine.rs b/lib/src/api/external_engine.rs index 86dd168..dbd5368 100644 --- a/lib/src/api/external_engine.rs +++ b/lib/src/api/external_engine.rs @@ -69,7 +69,7 @@ impl LichessApi { ) -> Result> { let mut stream = self.get_streamed_models(request.into()).await?; // The response is a stream of 0 or 1 items, so we can just take the first item - Ok((stream.next().await).transpose()?) + (stream.next().await).transpose() } pub async fn submit_analysis( diff --git a/lib/src/api/mod.rs b/lib/src/api/mod.rs index 74df119..d812a88 100644 --- a/lib/src/api/mod.rs +++ b/lib/src/api/mod.rs @@ -38,7 +38,7 @@ impl LichessApi { let result = self .get_single_model::(request) .await; - return Ok(result?.ok); + Ok(result?.ok) } pub async fn get_pgn( @@ -49,7 +49,7 @@ impl LichessApi { Q: QueryBounds, B: BodyBounds, { - let request = request.as_http_request("application/x-chess-pgn")?; + let request = request.into_http_request("application/x-chess-pgn")?; let stream = self.make_request_as_raw_lines(request).await?; Ok(stream) } @@ -59,7 +59,7 @@ impl LichessApi { Q: QueryBounds, B: BodyBounds, { - let request = request.as_http_request("text/plain")?; + let request = request.into_http_request("text/plain")?; let mut stream = self.make_request_as_raw_lines(request).await?; let mut lines = Vec::new(); while let Some(line) = stream.next().await { @@ -73,7 +73,7 @@ impl LichessApi { Q: QueryBounds, B: BodyBounds, { - let request = request.as_http_request("application/json")?; + let request = request.into_http_request("application/json")?; let mut stream = self.make_request(request).await?; self.expect_empty(&mut stream).await?; Ok(()) @@ -85,7 +85,7 @@ impl LichessApi { B: BodyBounds, M: ModelBounds, { - let request = request.as_http_request("application/json")?; + let request = request.into_http_request("application/json")?; let mut stream = self.make_request(request).await?; let res: Response = self.expect_one_model(&mut stream).await?; match res { @@ -103,7 +103,7 @@ impl LichessApi { B: BodyBounds, M: ModelBounds, { - let request = request.as_http_request("application/x-ndjson")?; + let request = request.into_http_request("application/x-ndjson")?; self.make_request(request).await } } diff --git a/lib/src/client.rs b/lib/src/client.rs index 23e1ca0..92fc90c 100644 --- a/lib/src/client.rs +++ b/lib/src/client.rs @@ -57,7 +57,7 @@ impl LichessApi { self.make_request_as_raw_lines(http_request) .await? .map(|l| -> Result { - serde_json::from_str(&l?).map_err(|e| crate::error::Error::Json(e)) + serde_json::from_str(&l?).map_err(crate::error::Error::Json) }); Ok(stream) @@ -68,7 +68,7 @@ impl LichessApi { mut http_request: http::Request, ) -> Result>> { if let Some(auth) = &self.bearer_auth { - let mut auth_header = http::HeaderValue::from_str(&auth) + let mut auth_header = http::HeaderValue::from_str(auth) .map_err(|e| Error::HttpRequestBuilder(http::Error::from(e)))?; // exclude the auth header from being logged auth_header.set_sensitive(true); @@ -93,7 +93,7 @@ impl LichessApi { let stream = response .map_err(convert_err)? .bytes_stream() - .map_err(|e| futures::io::Error::new(futures::io::ErrorKind::Other, e)) + .map_err(futures::io::Error::other) .into_async_read() .lines() .filter(|l| { @@ -111,10 +111,10 @@ impl LichessApi { } // Check for error responses returned as json before model serialization is attempted. // This can happen when not authorized to access an endpoint. - if let Ok(error_value) = serde_json::from_str::(&line) { - if let Some(error_msg) = error_value.get("error").and_then(|e| e.as_str()) { - return Err(crate::error::Error::Response(error_msg.to_string())); - } + if let Ok(error_value) = serde_json::from_str::(&line) + && let Some(error_msg) = error_value.get("error").and_then(|e| e.as_str()) + { + return Err(crate::error::Error::Response(error_msg.to_string())); } Ok(line) }); diff --git a/lib/src/error.rs b/lib/src/error.rs index 772221b..aaf9891 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -1,5 +1,3 @@ -//! - #[derive(Debug, thiserror::Error)] pub enum Error { #[error("urlencoded serde error: {0}")] diff --git a/lib/src/model/board/stream/game.rs b/lib/src/model/board/stream/game.rs index d272c17..f1025c9 100644 --- a/lib/src/model/board/stream/game.rs +++ b/lib/src/model/board/stream/game.rs @@ -48,6 +48,8 @@ impl> From for GetRequest { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type")] #[serde(rename_all = "camelCase")] +// Boxing would ripple through every match arm across the crate for a rarely-streamed enum; not worth it. +#[allow(clippy::large_enum_variant)] pub enum Event { GameFull { #[serde(flatten)] diff --git a/lib/src/model/external_engine/acquire_analysis.rs b/lib/src/model/external_engine/acquire_analysis.rs index dcebddb..8d8e369 100644 --- a/lib/src/model/external_engine/acquire_analysis.rs +++ b/lib/src/model/external_engine/acquire_analysis.rs @@ -12,7 +12,7 @@ impl PostRequest { Self { domain: Domain::Engine, method: http::Method::POST, - path: format!("/api/external-engine/work"), + path: "/api/external-engine/work".to_string(), query: Default::default(), body: Body::Json(acquire_analysis), } diff --git a/lib/src/model/games/stream/moves.rs b/lib/src/model/games/stream/moves.rs index 4df8775..14fc003 100644 --- a/lib/src/model/games/stream/moves.rs +++ b/lib/src/model/games/stream/moves.rs @@ -24,6 +24,8 @@ pub type Move = MoveStream; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(untagged)] +// Boxing would ripple through every match arm across the crate for a rarely-streamed enum; not worth it. +#[allow(clippy::large_enum_variant)] pub enum MoveStream { #[serde(rename_all = "camelCase")] Start { diff --git a/lib/src/model/mod.rs b/lib/src/model/mod.rs index 17f9b10..4086f3a 100644 --- a/lib/src/model/mod.rs +++ b/lib/src/model/mod.rs @@ -169,7 +169,7 @@ where Q: QueryBounds, B: BodyBounds, { - pub(crate) fn as_http_request( + pub(crate) fn into_http_request( self, accept: &str, ) -> error::Result> { @@ -212,7 +212,7 @@ where .method(method) .uri(url.as_str()) .body(body) - .map_err(|e| error::Error::HttpRequestBuilder(e))?; + .map_err(error::Error::HttpRequestBuilder)?; Ok(request) } @@ -236,11 +236,11 @@ where } fn to_json_string(body: &B) -> error::Result { - serde_json::to_string(&body).map_err(|e| error::Error::Json(e)) + serde_json::to_string(&body).map_err(error::Error::Json) } fn to_form_string(body: &B) -> error::Result { - serde_urlencoded::to_string(&body).map_err(|e| error::Error::UrlEncoded(e)) + serde_urlencoded::to_string(body).map_err(error::Error::UrlEncoded) } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -453,9 +453,9 @@ impl From for Days { } } -impl Into for Days { - fn into(self) -> u32 { - match self { +impl From for u32 { + fn from(value: Days) -> Self { + match value { Days::One => 1, Days::Two => 2, Days::Three => 3, diff --git a/lib/src/model/oauth/revoke.rs b/lib/src/model/oauth/revoke.rs index 03e9e05..0cc5fd5 100644 --- a/lib/src/model/oauth/revoke.rs +++ b/lib/src/model/oauth/revoke.rs @@ -8,7 +8,7 @@ pub type DeleteRequest = Request; impl DeleteRequest { pub fn new() -> Self { - let path = format!("/api/token"); + let path = "/api/token".to_string(); Self::delete(path, None, None, None) } } diff --git a/lib/src/model/oauth/test.rs b/lib/src/model/oauth/test.rs index 2a624d7..8c4e2fb 100644 --- a/lib/src/model/oauth/test.rs +++ b/lib/src/model/oauth/test.rs @@ -8,7 +8,7 @@ pub type PostRequest = Request; impl PostRequest { pub fn new(tokens: Vec) -> Self { - let path = format!("/api/token/test"); + let path = "/api/token/test".to_string(); Self::post(path, None, Body::PlainText(tokens.join(",")), None) } } diff --git a/lib/tests/online.rs b/lib/tests/online.rs index a61d7a3..c08c47a 100644 --- a/lib/tests/online.rs +++ b/lib/tests/online.rs @@ -1,6 +1,4 @@ use lichess_api::client::*; -use reqwest; -use tokio; #[tokio::test(flavor = "current_thread")] pub async fn daily_puzzle() { @@ -30,7 +28,7 @@ pub async fn fide_player() { pub async fn fide_search() { let api = make_api(); let response = api.search_fide_player("Magnus Carlsen").await.unwrap(); - assert!(response.len() > 0); + assert!(!response.is_empty()); for player in response { println!("{:?}", player); } From d41fa88dc075cf7aa08e25be94085c28992fd34a Mon Sep 17 00:00:00 2001 From: Arran Ireland Date: Sun, 23 Aug 2026 10:36:39 +0100 Subject: [PATCH 4/5] chore: update readme --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 30a6198..dffb44d 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,21 @@ Most endpoints require a bearer token but some don't. E.g. the daily puzzle, etc |---------|:-------:|-------------| | `oauth` | yes | OAuth2 authorization code flow with PKCE, for acting on behalf of other users. | +## CLI + +```sh +cargo install --path cli +lichess users get thibault +lichess --api-token "$LICHESS_TOKEN" challenges create --rated +lichess --json puzzles daily +``` + +- `--api-token` / `-a`: for endpoints that require authentication. +- `--json`: pretty-printed JSON instead of Rust debug format. +- `--verbose` / `-v`: enable debug logging. + +Run `lichess --help` or `lichess --help` for the full list of categories and commands. + ## Contributing If you have any ideas, bug reports, feature requests, or fixes, please make an issue or submit a pull request. From 2078fe2e0b5f8484e76a7ca1b43e4d66c0050cfa Mon Sep 17 00:00:00 2001 From: Arran Ireland Date: Sun, 23 Aug 2026 10:47:20 +0100 Subject: [PATCH 5/5] cli: fix stack overflow on windows --- cli/Cargo.toml | 2 +- cli/src/main.rs | 40 ++++++++++++++++++++++++++++------------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 4b6534d..b044387 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -22,7 +22,7 @@ rand = "0.10.2" reqwest = "0.13.4" serde = "1.0.229" serde_json = "1.0.151" -tokio = { version = "1.53.1", features = ["macros", "rt"] } +tokio = { version = "1.53.1", features = ["rt", "time", "net"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/cli/src/main.rs b/cli/src/main.rs index 3e8ffdf..3442691 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -140,18 +140,34 @@ struct App { lichess: Lichess, } -#[tokio::main(flavor = "current_thread")] -async fn main() -> Result<()> { - let args = Cli::parse(); - let level = if args.verbose { - LevelFilter::DEBUG - } else { - LevelFilter::INFO - }; - init_tracing(level)?; - color_eyre::install()?; - let app = App::new(args.api_token.clone()); - app.run(args).await +fn main() -> Result<()> { + // clap's derive-generated `Command` graph for this many nested subcommands is deep enough to + // overflow the default 1 MiB main-thread stack on Windows (Linux/macOS default to 8 MiB), so + // run everything on a thread with an explicitly larger stack. + std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(run) + .expect("failed to spawn main thread") + .join() + .expect("main thread panicked") +} + +fn run() -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let args = Cli::parse(); + let level = if args.verbose { + LevelFilter::DEBUG + } else { + LevelFilter::INFO + }; + init_tracing(level)?; + color_eyre::install()?; + let app = App::new(args.api_token.clone()); + app.run(args).await + }) } fn init_tracing(directive: LevelFilter) -> Result<()> {