use serde::Deserialize;
use std::sync::Arc;
use tokio::sync::Semaphore;
use crate::models::{Pokemon, PokemonMove};
const USER_AGENT: &str = "pokedex-autobattler/1.0 (contact: https://github.com)";
#[derive(Deserialize)]
struct ApiPokemon {
id: i32,
name: String,
height: i32,
weight: i32,
stats: Vec<ApiStatSlot>,
types: Vec<ApiTypeSlot>,
sprites: ApiSprites,
moves: Vec<ApiMoveSlot>,
}
#[derive(Deserialize)]
struct ApiStatSlot {
base_stat: i32,
stat: ApiNamedResource,
}
#[derive(Deserialize)]
struct ApiTypeSlot {
slot: i32,
#[serde(rename = "type")]
type_info: ApiNamedResource,
}
#[derive(Deserialize)]
struct ApiNamedResource {
name: String,
}
#[derive(Deserialize)]
struct ApiSprites {
front_default: Option<String>,
other: Option<ApiSpritesOther>,
}
#[derive(Deserialize)]
struct ApiSpritesOther {
#[serde(rename = "official-artwork")]
official_artwork: Option<ApiOfficialArtwork>,
}
#[derive(Deserialize)]
struct ApiOfficialArtwork {
front_default: Option<String>,
}
#[derive(Deserialize)]
struct ApiMoveSlot {
#[serde(rename = "move")]
move_info: ApiNamedResource,
version_group_details: Vec<ApiVersionGroupDetail>,
}
#[derive(Deserialize)]
struct ApiVersionGroupDetail {
level_learned_at: i32,
move_learn_method: ApiNamedResource,
version_group: ApiNamedResource,
}
#[derive(Deserialize)]
struct ApiSpecies {
names: Vec<ApiName>,
genera: Vec<ApiGenus>,
flavor_text_entries: Vec<ApiFlavorText>,
}
#[derive(Deserialize)]
struct ApiName {
name: String,
language: ApiNamedResource,
}
#[derive(Deserialize)]
struct ApiGenus {
genus: String,
language: ApiNamedResource,
}
#[derive(Deserialize)]
struct ApiFlavorText {
flavor_text: String,
language: ApiNamedResource,
version: ApiNamedResource,
}
pub fn create_agent() -> ureq::Agent {
ureq::Agent::new_with_defaults()
}
#[allow(clippy::too_many_lines)]
pub fn fetch_single_pokemon(agent: &ureq::Agent, id: i32) -> Result<Pokemon, String> {
let pokemon_url = format!("https://pokeapi.co/api/v2/pokemon/{id}");
let mut resp = agent
.get(&pokemon_url)
.header("User-Agent", USER_AGENT)
.call()
.map_err(|e| format!("Failed to fetch pokemon #{id}: {e}"))?;
let api_pk: ApiPokemon = resp
.body_mut()
.read_json()
.map_err(|e| format!("Failed to parse pokemon #{id} json: {e}"))?;
let species_url = format!("https://pokeapi.co/api/v2/pokemon-species/{id}");
let mut species_resp = agent
.get(&species_url)
.header("User-Agent", USER_AGENT)
.call()
.map_err(|e| format!("Failed to fetch species #{id}: {e}"))?;
let api_sp: ApiSpecies = species_resp
.body_mut()
.read_json()
.map_err(|e| format!("Failed to parse species #{id} json: {e}"))?;
let stat_val = |stat_name: &str| -> i32 {
api_pk
.stats
.iter()
.find(|s| s.stat.name == stat_name)
.map_or(0, |s| s.base_stat)
};
let hp = stat_val("hp");
let attack = stat_val("attack");
let defense = stat_val("defense");
let sp_attack = stat_val("special-attack");
let sp_defense = stat_val("special-defense");
let speed = stat_val("speed");
let mut types_slots = api_pk.types;
types_slots.sort_by_key(|t| t.slot);
let types: Vec<String> = types_slots.into_iter().map(|t| t.type_info.name).collect();
let sprite_url = api_pk.sprites.front_default.unwrap_or_else(|| {
format!("https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{id}.png")
});
let artwork_url = api_pk
.sprites
.other
.and_then(|o| o.official_artwork)
.and_then(|a| a.front_default)
.unwrap_or_else(|| {
format!("https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/{id}.png")
});
let mut moves = Vec::new();
for m in api_pk.moves {
if let Some(vgd) = m
.version_group_details
.into_iter()
.find(|v| v.version_group.name == "firered-leafgreen")
{
moves.push(PokemonMove {
name: m.move_info.name,
level: vgd.level_learned_at,
method: vgd.move_learn_method.name,
});
}
}
let mut level_moves: Vec<PokemonMove> = moves
.iter()
.filter(|m| m.method == "level-up")
.cloned()
.collect();
level_moves.sort_by_key(|m| m.level);
let mut other_moves: Vec<PokemonMove> = moves
.into_iter()
.filter(|m| m.method != "level-up")
.collect();
other_moves.sort_by(|a, b| a.name.cmp(&b.name));
let mut sorted_moves = level_moves;
sorted_moves.extend(other_moves);
let display_name = api_sp
.names
.iter()
.find(|n| n.language.name == "en")
.map_or_else(
|| {
let mut c = api_pk.name.chars();
match c.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
}
},
|n| n.name.clone(),
);
let genus = api_sp
.genera
.iter()
.find(|g| g.language.name == "en")
.map(|g| g.genus.clone())
.unwrap_or_default();
let raw_desc = api_sp
.flavor_text_entries
.iter()
.find(|f| {
f.language.name == "en" && (f.version.name == "firered" || f.version.name == "leafgreen")
})
.or_else(|| api_sp.flavor_text_entries.iter().find(|f| f.language.name == "en"))
.map(|f| f.flavor_text.as_str())
.unwrap_or_default();
let cleaned_desc = raw_desc
.replace(['\n', '\x0c', '\r'], " ")
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ");
Ok(Pokemon {
id: api_pk.id,
name: api_pk.name,
display_name,
types,
hp,
attack,
defense,
sp_attack,
sp_defense,
speed,
height: api_pk.height,
weight: api_pk.weight,
sprite_url,
artwork_url,
genus,
description: cleaned_desc,
moves: sorted_moves,
})
}
pub async fn fetch_gen1_pokemon_concurrent(
ids: Vec<i32>,
concurrency: usize,
) -> Result<Vec<Pokemon>, String> {
let semaphore = Arc::new(Semaphore::new(concurrency));
let agent = Arc::new(create_agent());
let mut tasks = Vec::with_capacity(ids.len());
for id in ids {
let sem = semaphore.clone();
let ag = agent.clone();
tasks.push(tokio::spawn(async move {
let _permit = sem
.acquire()
.await
.map_err(|e| format!("Semaphore acquire error: {e}"))?;
tokio::task::spawn_blocking(move || fetch_single_pokemon(&ag, id))
.await
.map_err(|e| format!("Join error for pokemon #{id}: {e}"))?
}));
}
let mut results = Vec::with_capacity(tasks.len());
for task in tasks {
match task.await {
Ok(Ok(pokemon)) => results.push(pokemon),
Ok(Err(e)) => return Err(e),
Err(e) => return Err(format!("Task execution error: {e}")),
}
}
results.sort_by_key(|p| p.id);
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires network connection to pokeapi.co"]
fn test_fetch_bulbasaur() {
let agent = create_agent();
let pk = fetch_single_pokemon(&agent, 1).expect("fetch bulbasaur");
assert_eq!(pk.id, 1);
assert_eq!(pk.name, "bulbasaur");
assert_eq!(pk.display_name, "Bulbasaur");
assert_eq!(pk.types, vec!["grass", "poison"]);
assert_eq!(pk.genus, "Seed Pokémon");
assert!(pk.hp > 0);
assert!(!pk.moves.is_empty());
}
#[tokio::test]
#[ignore = "requires network connection to pokeapi.co"]
async fn test_fetch_multiple_concurrent() {
let pks = fetch_gen1_pokemon_concurrent(vec![1, 2, 3], 3)
.await
.expect("fetch 1..=3");
assert_eq!(pks.len(), 3);
assert_eq!(pks[0].name, "bulbasaur");
assert_eq!(pks[1].name, "ivysaur");
assert_eq!(pks[2].name, "venusaur");
}
}