use futures_util::stream::BoxStream;
use reqwest::{Method, StatusCode};
use serde::{Deserialize, Serialize};
use crate::api::gameplay::games::LichessGame;
use crate::client::LichessClient;
use crate::config::Host;
use crate::error::{ApiErrorKind, LichessError, Result};
use crate::http;
use crate::model::{GameExportOptions, LichessTitle, LichessVariantKey};
fn map_unauthorized_edit(err: LichessError) -> LichessError {
match err {
LichessError::Api(api) if api.status == StatusCode::UNAUTHORIZED => {
LichessError::Api(api.with_kind(ApiErrorKind::SwissUnauthorizedEdit))
}
other => other,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(into = "i32")]
#[non_exhaustive]
pub enum SwissRoundInterval {
Auto,
Manual,
Seconds(u32),
}
impl From<SwissRoundInterval> for i32 {
fn from(interval: SwissRoundInterval) -> Self {
match interval {
SwissRoundInterval::Auto => -1,
SwissRoundInterval::Manual => 99_999_999,
SwissRoundInterval::Seconds(seconds) => i32::try_from(seconds).unwrap_or(i32::MAX),
}
}
}
#[derive(Debug, Default, Serialize)]
struct CreateForm<'a> {
#[serde(rename = "clock.limit")]
clock_limit: u32,
#[serde(rename = "clock.increment")]
clock_increment: u32,
#[serde(rename = "nbRounds")]
nb_rounds: u32,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
rated: Option<bool>,
#[serde(rename = "roundInterval", skip_serializing_if = "Option::is_none")]
round_interval: Option<SwissRoundInterval>,
#[serde(rename = "startsAt", skip_serializing_if = "Option::is_none")]
starts_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
variant: Option<LichessVariantKey>,
#[serde(skip_serializing_if = "Option::is_none")]
position: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
password: Option<&'a str>,
#[serde(rename = "forbiddenPairings", skip_serializing_if = "Option::is_none")]
forbidden_pairings: Option<&'a str>,
#[serde(rename = "manualPairings", skip_serializing_if = "Option::is_none")]
manual_pairings: Option<&'a str>,
#[serde(rename = "chatFor", skip_serializing_if = "Option::is_none")]
chat_for: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct SwissConditions<'a> {
#[serde(
rename = "conditions.minRating.rating",
skip_serializing_if = "Option::is_none"
)]
min_rating: Option<u32>,
#[serde(
rename = "conditions.maxRating.rating",
skip_serializing_if = "Option::is_none"
)]
max_rating: Option<u32>,
#[serde(
rename = "conditions.nbRatedGame.nb",
skip_serializing_if = "Option::is_none"
)]
nb_rated_games: Option<u32>,
#[serde(
rename = "conditions.allowList",
skip_serializing_if = "Option::is_none"
)]
allow_list: Option<&'a str>,
#[serde(
rename = "conditions.playYourGames",
skip_serializing_if = "Option::is_none"
)]
play_your_games: Option<bool>,
}
impl<'a> SwissConditions<'a> {
#[must_use]
pub fn min_rating(mut self, rating: u32) -> Self {
self.min_rating = Some(rating);
self
}
#[must_use]
pub fn max_rating(mut self, rating: u32) -> Self {
self.max_rating = Some(rating);
self
}
#[must_use]
pub fn nb_rated_games(mut self, count: u32) -> Self {
self.nb_rated_games = Some(count);
self
}
#[must_use]
pub fn allow_list(mut self, usernames: &'a str) -> Self {
self.allow_list = Some(usernames);
self
}
#[must_use]
pub fn play_your_games(mut self, value: bool) -> Self {
self.play_your_games = Some(value);
self
}
}
#[derive(Debug)]
pub struct SwissApi<'a> {
client: &'a LichessClient,
}
impl<'a> SwissApi<'a> {
pub(crate) fn new(client: &'a LichessClient) -> Self {
Self { client }
}
pub async fn get(&self, id: &str) -> Result<LichessSwiss> {
let path = format!("/api/swiss/{}", http::segment(id));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "LichessSwiss").await
}
#[must_use]
pub fn create(
&self,
team_id: &'a str,
clock_limit: u32,
clock_increment: u32,
nb_rounds: u32,
) -> CreateSwissRequest<'a> {
CreateSwissRequest::new(
self.client,
team_id,
false,
clock_limit,
clock_increment,
nb_rounds,
)
}
#[must_use]
pub fn edit(
&self,
id: &'a str,
clock_limit: u32,
clock_increment: u32,
nb_rounds: u32,
) -> CreateSwissRequest<'a> {
CreateSwissRequest::new(
self.client,
id,
true,
clock_limit,
clock_increment,
nb_rounds,
)
}
pub async fn join(&self, id: &str, password: Option<&str>) -> Result<()> {
let path = format!("/api/swiss/{}/join", http::segment(id));
let request = self
.client
.request(Method::POST, Host::Default, &path)
.form(&[("password", password)]);
http::ok(request).await
}
pub async fn withdraw(&self, id: &str) -> Result<()> {
self.post_action(id, "withdraw").await
}
pub async fn terminate(&self, id: &str) -> Result<()> {
self.post_action(id, "terminate").await
}
pub async fn schedule_next_round(&self, id: &str, date: Option<i64>) -> Result<()> {
let path = format!("/api/swiss/{}/schedule-next-round", http::segment(id));
let request = self
.client
.request(Method::POST, Host::Default, &path)
.form(&[("date", date)]);
http::ok(request).await.map_err(map_unauthorized_edit)
}
pub async fn trf(&self, id: &str) -> Result<String> {
let path = format!("/swiss/{}.trf", http::segment(id));
http::text(self.client.request(Method::GET, Host::Default, &path)).await
}
pub async fn results(
&self,
id: &str,
nb: Option<u32>,
) -> Result<BoxStream<'static, Result<LichessSwissResult>>> {
let path = format!("/api/swiss/{}/results", http::segment(id));
let request = self
.client
.request(Method::GET, Host::Default, &path)
.query(&[("nb", nb)]);
http::stream(request, self.client.max_line_bytes()).await
}
#[must_use]
pub fn games(&self, id: &'a str) -> SwissGamesRequest<'a> {
SwissGamesRequest::new(self.client, id)
}
async fn post_action(&self, id: &str, action: &str) -> Result<()> {
let path = format!("/api/swiss/{}/{}", http::segment(id), http::segment(action));
http::ok(self.client.request(Method::POST, Host::Default, &path)).await
}
}
#[derive(Debug)]
pub struct CreateSwissRequest<'a> {
client: &'a LichessClient,
target_id: &'a str,
edit: bool,
form: CreateForm<'a>,
conditions: SwissConditions<'a>,
}
impl<'a> CreateSwissRequest<'a> {
fn new(
client: &'a LichessClient,
target_id: &'a str,
edit: bool,
clock_limit: u32,
clock_increment: u32,
nb_rounds: u32,
) -> Self {
Self {
client,
target_id,
edit,
form: CreateForm {
clock_limit,
clock_increment,
nb_rounds,
..Default::default()
},
conditions: SwissConditions::default(),
}
}
#[must_use]
pub fn name(mut self, name: &'a str) -> Self {
self.form.name = Some(name);
self
}
#[must_use]
pub fn rated(mut self, rated: bool) -> Self {
self.form.rated = Some(rated);
self
}
#[must_use]
pub fn round_interval(mut self, interval: SwissRoundInterval) -> Self {
self.form.round_interval = Some(interval);
self
}
#[must_use]
pub fn starts_at(mut self, timestamp: i64) -> Self {
self.form.starts_at = Some(timestamp);
self
}
#[must_use]
pub fn variant(mut self, variant: LichessVariantKey) -> Self {
self.form.variant = Some(variant);
self
}
#[must_use]
pub fn position(mut self, fen: &'a str) -> Self {
self.form.position = Some(fen);
self
}
#[must_use]
pub fn description(mut self, description: &'a str) -> Self {
self.form.description = Some(description);
self
}
#[must_use]
pub fn password(mut self, password: &'a str) -> Self {
self.form.password = Some(password);
self
}
#[must_use]
pub fn forbidden_pairings(mut self, pairings: &'a str) -> Self {
self.form.forbidden_pairings = Some(pairings);
self
}
#[must_use]
pub fn manual_pairings(mut self, pairings: &'a str) -> Self {
self.form.manual_pairings = Some(pairings);
self
}
#[must_use]
pub fn chat_for(mut self, chat_for: u32) -> Self {
self.form.chat_for = Some(chat_for);
self
}
#[must_use]
pub fn conditions(mut self, conditions: SwissConditions<'a>) -> Self {
self.conditions = conditions;
self
}
pub async fn send(self) -> Result<LichessSwiss> {
let path = self.path();
let request = self
.client
.request(Method::POST, Host::Default, &path)
.form_parts(&self.form, &self.conditions);
let result = http::json(request, "LichessSwiss").await;
if self.edit {
result.map_err(map_unauthorized_edit)
} else {
result
}
}
fn path(&self) -> String {
if self.edit {
format!("/api/swiss/{}/edit", http::segment(self.target_id))
} else {
format!("/api/swiss/new/{}", http::segment(self.target_id))
}
}
}
#[derive(Debug)]
pub struct SwissGamesRequest<'a> {
client: &'a LichessClient,
id: &'a str,
player: Option<&'a str>,
export: GameExportOptions,
}
impl<'a> SwissGamesRequest<'a> {
pub(crate) fn new(client: &'a LichessClient, id: &'a str) -> Self {
Self {
client,
id,
player: None,
export: GameExportOptions::default(),
}
}
#[must_use]
pub fn player(mut self, player: &'a str) -> Self {
self.player = Some(player);
self
}
#[must_use]
pub fn export(mut self, options: GameExportOptions) -> Self {
self.export = options;
self
}
pub async fn stream(self) -> Result<BoxStream<'static, Result<LichessGame>>> {
let request = self.request(http::ACCEPT_NDJSON);
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn pgn(self) -> Result<String> {
http::text(self.request(http::ACCEPT_PGN)).await
}
fn request(&self, accept: &'static str) -> http::ApiRequest {
let path = format!("/api/swiss/{}/games", http::segment(self.id));
self.client
.request(Method::GET, Host::Default, &path)
.header(reqwest::header::ACCEPT, accept)
.query(&[("player", self.player)])
.query(&self.export)
}
}
impl LichessClient {
#[must_use]
pub fn swiss(&self) -> SwissApi<'_> {
SwissApi::new(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessSwissClock {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub increment: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessSwissNextRound {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at: Option<i64>,
#[serde(rename = "in", default, skip_serializing_if = "Option::is_none")]
pub in_seconds: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessSwiss {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub starts_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clock: Option<LichessSwissClock>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub round: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nb_rounds: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nb_players: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nb_ongoing: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rated: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_round: Option<LichessSwissNextRound>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessSwissResult {
pub rank: u32,
pub points: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tie_break: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating: Option<u32>,
pub username: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<LichessTitle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub performance: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ApiError;
#[test]
fn maps_401_to_swiss_unauthorized_edit() {
let err = LichessError::Api(ApiError::new(StatusCode::UNAUTHORIZED, None, None));
match map_unauthorized_edit(err) {
LichessError::Api(api) => assert_eq!(api.kind, ApiErrorKind::SwissUnauthorizedEdit),
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn leaves_non_401_errors_unchanged() {
let err = LichessError::Api(ApiError::new(StatusCode::NOT_FOUND, None, None));
match map_unauthorized_edit(err) {
LichessError::Api(api) => assert_eq!(api.kind, ApiErrorKind::NotFound),
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn parses_swiss_with_next_round() {
let json = r#"{"id":"abc","name":"Weekly","clock":{"limit":300,"increment":0},
"variant":"standard","round":2,"nbRounds":7,"nbPlayers":40,
"status":"started","rated":true,"nextRound":{"at":1700000000000,"in":120}}"#;
let swiss: LichessSwiss = serde_json::from_str(json).unwrap();
assert_eq!(swiss.nb_rounds, Some(7));
assert_eq!(swiss.next_round.unwrap().in_seconds, Some(120));
}
#[test]
fn parses_swiss_result_with_fractional_points() {
let json = r#"{"rank":1,"points":5.5,"tieBreak":18.0,"username":"A","rating":2400}"#;
let result: LichessSwissResult = serde_json::from_str(json).unwrap();
assert!((result.points - 5.5).abs() < f64::EPSILON);
}
#[test]
fn conditions_serialize_to_dotted_keys() {
let query = serde_urlencoded::to_string(
SwissConditions::default()
.max_rating(2200)
.play_your_games(true),
)
.unwrap();
assert!(query.contains("conditions.maxRating.rating=2200"));
assert!(query.contains("conditions.playYourGames=true"));
}
#[test]
fn empty_conditions_serialize_to_nothing() {
assert_eq!(
serde_urlencoded::to_string(SwissConditions::default()).unwrap(),
""
);
}
#[test]
fn round_interval_serializes_sentinels_and_seconds() {
let cases = [
(SwissRoundInterval::Auto, "roundInterval=-1"),
(SwissRoundInterval::Manual, "roundInterval=99999999"),
(SwissRoundInterval::Seconds(60), "roundInterval=60"),
];
for (interval, expected) in cases {
let form = serde_urlencoded::to_string([("roundInterval", interval)]).unwrap();
assert_eq!(form, expected);
}
}
}