use futures_util::stream::BoxStream;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use crate::client::LichessClient;
use crate::config::Host;
use crate::error::Result;
use crate::http;
use crate::model::{LichessColor, LichessSpeed, LichessVariantKey};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RatingGroup {
R0,
R1000,
R1200,
R1400,
R1600,
R1800,
R2000,
R2200,
R2500,
}
impl RatingGroup {
pub const ALL: [RatingGroup; 9] = [
RatingGroup::R0,
RatingGroup::R1000,
RatingGroup::R1200,
RatingGroup::R1400,
RatingGroup::R1600,
RatingGroup::R1800,
RatingGroup::R2000,
RatingGroup::R2200,
RatingGroup::R2500,
];
#[must_use]
pub fn as_u16(self) -> u16 {
match self {
RatingGroup::R0 => 0,
RatingGroup::R1000 => 1000,
RatingGroup::R1200 => 1200,
RatingGroup::R1400 => 1400,
RatingGroup::R1600 => 1600,
RatingGroup::R1800 => 1800,
RatingGroup::R2000 => 2000,
RatingGroup::R2200 => 2200,
RatingGroup::R2500 => 2500,
}
}
#[must_use]
pub fn from_u16(value: u16) -> Option<Self> {
Self::ALL.into_iter().find(|group| group.as_u16() == value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExplorerMode {
Casual,
Rated,
}
impl ExplorerMode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ExplorerMode::Casual => "casual",
ExplorerMode::Rated => "rated",
}
}
}
fn join_csv<'s>(parts: impl Iterator<Item = &'s str>) -> Option<String> {
let joined = parts.collect::<Vec<_>>().join(",");
(!joined.is_empty()).then_some(joined)
}
#[derive(Debug, Default, Serialize)]
struct MastersQuery<'a> {
fen: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
play: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
moves: Option<u32>,
#[serde(rename = "topGames", skip_serializing_if = "Option::is_none")]
top_games: Option<u32>,
}
#[derive(Debug, Default, Serialize)]
struct LichessQuery<'a> {
fen: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
play: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
variant: Option<LichessVariantKey>,
#[serde(skip_serializing_if = "Option::is_none")]
speeds: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ratings: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
moves: Option<u32>,
#[serde(rename = "topGames", skip_serializing_if = "Option::is_none")]
top_games: Option<u32>,
#[serde(rename = "recentGames", skip_serializing_if = "Option::is_none")]
recent_games: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
history: Option<bool>,
}
#[derive(Debug, Default, Serialize)]
struct PlayerQuery<'a> {
player: &'a str,
color: LichessColor,
#[serde(skip_serializing_if = "Option::is_none")]
variant: Option<LichessVariantKey>,
fen: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
play: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
speeds: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
modes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
moves: Option<u32>,
#[serde(rename = "recentGames", skip_serializing_if = "Option::is_none")]
recent_games: Option<u32>,
}
#[derive(Debug)]
pub struct MastersExplorerRequest<'a> {
client: &'a LichessClient,
query: MastersQuery<'a>,
}
impl<'a> MastersExplorerRequest<'a> {
pub(crate) fn new(client: &'a LichessClient, fen: &'a str) -> Self {
Self {
client,
query: MastersQuery {
fen,
..Default::default()
},
}
}
#[must_use]
pub fn play(mut self, play: &'a str) -> Self {
self.query.play = Some(play);
self
}
#[must_use]
pub fn since(mut self, year: u16) -> Self {
self.query.since = Some(year);
self
}
#[must_use]
pub fn until(mut self, year: u16) -> Self {
self.query.until = Some(year);
self
}
#[must_use]
pub fn moves(mut self, moves: u32) -> Self {
self.query.moves = Some(moves);
self
}
#[must_use]
pub fn top_games(mut self, count: u32) -> Self {
self.query.top_games = Some(count);
self
}
pub async fn send(self) -> Result<LichessExplorerResult> {
let request = self
.client
.request(Method::GET, Host::OpeningExplorer, "/masters")
.query(&self.query);
http::json(request, "LichessExplorerResult").await
}
}
#[derive(Debug)]
pub struct LichessExplorerRequest<'a> {
client: &'a LichessClient,
query: LichessQuery<'a>,
}
impl<'a> LichessExplorerRequest<'a> {
pub(crate) fn new(client: &'a LichessClient, fen: &'a str) -> Self {
Self {
client,
query: LichessQuery {
fen,
..Default::default()
},
}
}
#[must_use]
pub fn play(mut self, play: &'a str) -> Self {
self.query.play = Some(play);
self
}
#[must_use]
pub fn variant(mut self, variant: LichessVariantKey) -> Self {
self.query.variant = Some(variant);
self
}
#[must_use]
pub fn speeds(mut self, speeds: &[LichessSpeed]) -> Self {
self.query.speeds = join_csv(speeds.iter().map(|s| s.as_str()));
self
}
#[must_use]
pub fn ratings(mut self, ratings: &[RatingGroup]) -> Self {
let values: Vec<String> = ratings.iter().map(|r| r.as_u16().to_string()).collect();
self.query.ratings = join_csv(values.iter().map(String::as_str));
self
}
#[must_use]
pub fn since(mut self, since: &'a str) -> Self {
self.query.since = Some(since);
self
}
#[must_use]
pub fn until(mut self, until: &'a str) -> Self {
self.query.until = Some(until);
self
}
#[must_use]
pub fn moves(mut self, moves: u32) -> Self {
self.query.moves = Some(moves);
self
}
#[must_use]
pub fn top_games(mut self, count: u32) -> Self {
self.query.top_games = Some(count);
self
}
#[must_use]
pub fn recent_games(mut self, count: u32) -> Self {
self.query.recent_games = Some(count);
self
}
#[must_use]
pub fn history(mut self, history: bool) -> Self {
self.query.history = Some(history);
self
}
pub async fn send(self) -> Result<LichessExplorerResult> {
let request = self
.client
.request(Method::GET, Host::OpeningExplorer, "/lichess")
.query(&self.query);
http::json(request, "LichessExplorerResult").await
}
}
#[derive(Debug)]
pub struct PlayerExplorerRequest<'a> {
client: &'a LichessClient,
query: PlayerQuery<'a>,
}
impl<'a> PlayerExplorerRequest<'a> {
pub(crate) fn new(
client: &'a LichessClient,
player: &'a str,
color: LichessColor,
fen: &'a str,
) -> Self {
Self {
client,
query: PlayerQuery {
player,
color,
fen,
..Default::default()
},
}
}
#[must_use]
pub fn play(mut self, play: &'a str) -> Self {
self.query.play = Some(play);
self
}
#[must_use]
pub fn variant(mut self, variant: LichessVariantKey) -> Self {
self.query.variant = Some(variant);
self
}
#[must_use]
pub fn speeds(mut self, speeds: &[LichessSpeed]) -> Self {
self.query.speeds = join_csv(speeds.iter().map(|s| s.as_str()));
self
}
#[must_use]
pub fn modes(mut self, modes: &[ExplorerMode]) -> Self {
self.query.modes = join_csv(modes.iter().map(|m| m.as_str()));
self
}
#[must_use]
pub fn since(mut self, since: &'a str) -> Self {
self.query.since = Some(since);
self
}
#[must_use]
pub fn until(mut self, until: &'a str) -> Self {
self.query.until = Some(until);
self
}
#[must_use]
pub fn moves(mut self, moves: u32) -> Self {
self.query.moves = Some(moves);
self
}
#[must_use]
pub fn recent_games(mut self, count: u32) -> Self {
self.query.recent_games = Some(count);
self
}
pub async fn stream(self) -> Result<BoxStream<'static, Result<LichessExplorerResult>>> {
let request = self
.client
.request(Method::GET, Host::OpeningExplorer, "/player")
.query(&self.query);
http::stream(request, self.client.max_line_bytes()).await
}
}
#[derive(Debug)]
pub struct OpeningExplorerApi<'a> {
client: &'a LichessClient,
}
impl<'a> OpeningExplorerApi<'a> {
pub(crate) fn new(client: &'a LichessClient) -> Self {
Self { client }
}
#[must_use]
pub fn masters(&self, fen: &'a str) -> MastersExplorerRequest<'a> {
MastersExplorerRequest::new(self.client, fen)
}
#[must_use]
pub fn lichess(&self, fen: &'a str) -> LichessExplorerRequest<'a> {
LichessExplorerRequest::new(self.client, fen)
}
#[must_use]
pub fn player(
&self,
player: &'a str,
color: LichessColor,
fen: &'a str,
) -> PlayerExplorerRequest<'a> {
PlayerExplorerRequest::new(self.client, player, color, fen)
}
pub async fn masters_pgn(&self, game_id: &str) -> Result<String> {
let path = format!("/masters/pgn/{}", http::segment(game_id));
let request = self
.client
.request(Method::GET, Host::OpeningExplorer, &path);
http::text(request).await
}
}
impl LichessClient {
#[must_use]
pub fn opening_explorer(&self) -> OpeningExplorerApi<'_> {
OpeningExplorerApi::new(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessExplorerOpening {
pub eco: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessExplorerGamePlayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessExplorerGame {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uci: Option<String>,
#[serde(default)]
pub winner: Option<LichessColor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub white: Option<LichessExplorerGamePlayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub black: Option<LichessExplorerGamePlayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub year: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub month: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessExplorerMove {
pub uci: String,
pub san: String,
pub white: u64,
pub draws: u64,
pub black: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub average_rating: Option<u32>,
#[serde(default)]
pub game: Option<LichessExplorerGame>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessExplorerResult {
#[serde(default)]
pub opening: Option<LichessExplorerOpening>,
pub white: u64,
pub draws: u64,
pub black: u64,
#[serde(default)]
pub moves: Vec<LichessExplorerMove>,
#[serde(default)]
pub top_games: Vec<LichessExplorerGame>,
#[serde(default)]
pub recent_games: Vec<LichessExplorerGame>,
}
#[cfg(test)]
mod tests {
use super::*;
fn client() -> LichessClient {
LichessClient::builder().build().expect("client builds")
}
#[test]
fn parses_explorer_result() {
let json = r#"{"opening":{"eco":"B01","name":"Scandinavian"},
"white":100,"draws":40,"black":60,
"moves":[{"uci":"e2e4","san":"e4","white":50,"draws":20,"black":30,
"averageRating":2400}],
"topGames":[{"id":"g","uci":"e2e4","winner":"white",
"white":{"name":"A","rating":2700},"year":2020}]}"#;
let result: LichessExplorerResult = serde_json::from_str(json).unwrap();
assert_eq!(result.white, 100);
assert_eq!(result.moves[0].average_rating, Some(2400));
assert_eq!(result.top_games[0].winner, Some(LichessColor::White));
}
#[test]
fn join_csv_omits_empty_and_comma_joins() {
assert_eq!(join_csv(std::iter::empty()), None);
assert_eq!(
join_csv(["a", "b", "c"].into_iter()),
Some("a,b,c".to_owned())
);
}
#[test]
fn lichess_default_query_is_fen_only() {
let client = client();
let req = client.opening_explorer().lichess("thefen");
assert_eq!(req.query.fen, "thefen");
assert!(req.query.play.is_none() && req.query.speeds.is_none());
}
#[test]
fn lichess_speeds_and_ratings_are_comma_joined() {
let client = client();
let req = client
.opening_explorer()
.lichess("f")
.speeds(&[LichessSpeed::Blitz, LichessSpeed::Rapid])
.ratings(&[RatingGroup::R1600, RatingGroup::R1800]);
assert_eq!(req.query.speeds.as_deref(), Some("blitz,rapid"));
assert_eq!(req.query.ratings.as_deref(), Some("1600,1800"));
}
#[test]
fn empty_filters_are_omitted_but_play_is_kept() {
let client = client();
let req = client
.opening_explorer()
.lichess("f")
.play("e2e4")
.speeds(&[])
.ratings(&[]);
assert_eq!(req.query.play, Some("e2e4"));
assert!(req.query.speeds.is_none() && req.query.ratings.is_none());
}
#[test]
fn masters_query_uses_year_bounds() {
let client = client();
let req = client
.opening_explorer()
.masters("f")
.since(1952)
.until(2020)
.top_games(15);
assert_eq!(req.query.since, Some(1952));
assert_eq!(req.query.until, Some(2020));
assert_eq!(req.query.top_games, Some(15));
}
#[test]
fn player_query_joins_speeds_and_modes() {
let client = client();
let req = client
.opening_explorer()
.player("bobby", LichessColor::Black, "f")
.speeds(&[LichessSpeed::Bullet])
.modes(&[ExplorerMode::Rated, ExplorerMode::Casual])
.recent_games(5);
assert_eq!(req.query.player, "bobby");
assert_eq!(req.query.color, LichessColor::Black);
assert_eq!(req.query.speeds.as_deref(), Some("bullet"));
assert_eq!(req.query.modes.as_deref(), Some("rated,casual"));
assert_eq!(req.query.recent_games, Some(5));
}
#[test]
fn rating_group_round_trips() {
for group in RatingGroup::ALL {
assert_eq!(RatingGroup::from_u16(group.as_u16()), Some(group));
}
assert_eq!(RatingGroup::from_u16(1234), None);
}
#[test]
fn explorer_mode_wire_values() {
assert_eq!(ExplorerMode::Casual.as_str(), "casual");
assert_eq!(ExplorerMode::Rated.as_str(), "rated");
}
}