use serde::{Deserialize, Serialize};
use super::{deserialize_bool_or_null, deserialize_string_or_int};
use crate::output::Tabular;
#[derive(Debug, Deserialize, Serialize)]
pub struct Challenge {
pub id: u64,
pub name: String,
#[serde(default)]
pub difficulty: Option<String>,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub category_id: Option<u64>,
#[serde(default)]
pub category_name: Option<String>,
#[serde(default)]
pub solves: u32,
#[serde(default)]
pub is_owned: bool,
#[serde(default)]
pub rating: Option<f64>,
#[serde(default)]
pub rating_count: Option<u32>,
#[serde(default)]
pub release_date: Option<String>,
#[serde(default)]
pub play_methods: Vec<String>,
#[serde(default)]
pub labels: Vec<super::machine::Label>,
}
#[derive(Debug, Deserialize)]
pub struct ChallengeDetailResponse {
pub challenge: ChallengeDetail,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ChallengeDetail {
pub id: u64,
pub name: String,
#[serde(default)]
pub difficulty: Option<String>,
#[serde(default, deserialize_with = "deserialize_string_or_int")]
pub points: Option<String>,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub solves: u32,
#[serde(default)]
pub stars: Option<f64>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub category_name: Option<String>,
#[serde(default)]
pub first_blood_user: Option<String>,
#[serde(default)]
pub first_blood_time: Option<String>,
#[serde(default)]
pub creator_name: Option<String>,
#[serde(default, deserialize_with = "deserialize_bool_or_null")]
pub download: bool,
#[serde(default)]
pub file_name: Option<String>,
#[serde(default)]
pub file_size: Option<String>,
#[serde(default)]
pub play_info: Option<ChallengePlayInfo>,
#[serde(default)]
pub play_methods: Vec<String>,
#[serde(default)]
pub release_date: Option<String>,
#[serde(
default,
rename = "authUserSolve",
deserialize_with = "deserialize_bool_or_null"
)]
pub auth_user_solve: bool,
#[serde(default)]
pub experience_points: Option<u32>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ChallengePlayInfo {
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub ip: Option<String>,
#[serde(default)]
pub ports: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ChallengeCategory {
pub id: u64,
pub name: String,
#[serde(default)]
pub icon: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ChallengeCategoriesResponse {
pub info: Vec<ChallengeCategory>,
}
#[derive(Debug, Deserialize)]
pub struct ChallengeDownloadResponse {
pub url: String,
#[serde(default)]
pub expires_in: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct ChallengeWriteupResponse {
pub data: ChallengeWriteupData,
}
#[derive(Debug, Deserialize)]
pub struct ChallengeWriteupData {
#[serde(default)]
pub official: Option<ChallengeWriteup>,
}
#[derive(Debug, Deserialize)]
pub struct ChallengeWriteup {
#[serde(default)]
pub filename: Option<String>,
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ChallengeOwnResponse {
pub message: String,
#[serde(default)]
pub user_rank: Option<ChallengeOwnRank>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ChallengeOwnRank {
#[serde(default)]
pub changed: bool,
}
impl Tabular for Challenge {
fn headers() -> Vec<&'static str> {
vec![
"ID",
"Name",
"Difficulty",
"Category",
"Solves",
"Rating",
"Owned",
"State",
]
}
fn row(&self) -> Vec<String> {
vec![
self.id.to_string(),
self.name.clone(),
self.difficulty.clone().unwrap_or_default(),
self.category_name.clone().unwrap_or_default(),
self.solves.to_string(),
self.rating.map(|r| format!("{r:.1}")).unwrap_or_default(),
if self.is_owned { "✓" } else { "" }.to_string(),
self.state.clone().unwrap_or_default(),
]
}
}
impl Tabular for ChallengeCategory {
fn headers() -> Vec<&'static str> {
vec!["ID", "Name"]
}
fn row(&self) -> Vec<String> {
vec![self.id.to_string(), self.name.clone()]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Paginated;
#[test]
fn deserialize_challenges_list() {
let json = include_str!("../../tests/fixtures/challenges-list.json");
let result: Paginated<Challenge> = serde_json::from_str(json).unwrap();
assert_eq!(result.data.len(), 15);
assert_eq!(result.data[0].name, "SpookyPass");
assert_eq!(result.data[0].category_name.as_deref(), Some("Reversing"));
}
#[test]
fn deserialize_challenge_detail() {
let json = include_str!("../../tests/fixtures/challenge-info-poly.json");
let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
assert_eq!(result.challenge.name, "Poly");
assert_eq!(result.challenge.difficulty.as_deref(), Some("Insane"));
assert_eq!(result.challenge.category_name.as_deref(), Some("Reversing"));
}
#[test]
fn deserialize_challenge_detail_int_points() {
let json = include_str!("../../tests/fixtures/challenge-info-int-points.json");
let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
assert_eq!(result.challenge.name, "TestIntPoints");
assert_eq!(result.challenge.points.as_deref(), Some("0"));
}
#[test]
fn deserialize_challenge_categories() {
let json = include_str!("../../tests/fixtures/challenge-categories.json");
let result: ChallengeCategoriesResponse = serde_json::from_str(json).unwrap();
assert!(!result.info.is_empty());
assert!(result.info.iter().any(|c| c.name == "Web"));
}
#[test]
fn deserialize_challenge_own_success() {
let json = include_str!("../../tests/fixtures/challenge-own-success.json");
let result: ChallengeOwnResponse = serde_json::from_str(json).unwrap();
assert_eq!(result.message, "Congratulations!");
}
#[test]
fn deserialize_challenge_reversing_download_only() {
let json = include_str!("../../tests/fixtures/challenge-info-reversing.json");
let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
let c = &result.challenge;
assert_eq!(c.name, "TestChallenge-Rev");
assert_eq!(c.category_name.as_deref(), Some("Reversing"));
assert_eq!(c.points.as_deref(), Some("0"));
assert!(c.download);
assert!(c.file_name.is_some());
assert_eq!(c.play_methods, vec!["download"]);
assert!(c.play_info.is_some());
}
#[test]
fn deserialize_challenge_web_docker_active() {
let json = include_str!("../../tests/fixtures/challenge-info-web-docker.json");
let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
let c = &result.challenge;
assert_eq!(c.name, "TestChallenge-Web");
assert_eq!(c.category_name.as_deref(), Some("Web"));
assert_eq!(c.points.as_deref(), Some("0"));
assert!(!c.download);
assert!(c.file_name.is_none());
assert_eq!(c.play_methods, vec!["container"]);
let info = c.play_info.as_ref().unwrap();
assert_eq!(info.status.as_deref(), Some("ready"));
assert!(info.ip.is_some());
assert!(info.ports.is_some());
assert!(info.expires_at.is_some());
}
#[test]
fn deserialize_challenge_hybrid_docker_and_download() {
let json = include_str!("../../tests/fixtures/challenge-info-hybrid.json");
let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
let c = &result.challenge;
assert_eq!(c.name, "TestChallenge-Hybrid");
assert!(c.download);
assert!(c.file_name.is_some());
assert!(c.play_methods.contains(&"download".to_string()));
assert!(c.play_methods.contains(&"container".to_string()));
}
}