1use serde::{Deserialize, Serialize};
2
3use super::{deserialize_bool_or_null, deserialize_string_or_int};
4use crate::output::Tabular;
5
6#[derive(Debug, Deserialize, Serialize)]
7pub struct Challenge {
8 pub id: u64,
9 pub name: String,
10 #[serde(default)]
11 pub difficulty: Option<String>,
12 #[serde(default)]
13 pub state: Option<String>,
14 #[serde(default)]
15 pub category_id: Option<u64>,
16 #[serde(default)]
17 pub category_name: Option<String>,
18 #[serde(default)]
19 pub solves: u32,
20 #[serde(default)]
21 pub is_owned: bool,
22 #[serde(default)]
23 pub rating: Option<f64>,
24 #[serde(default)]
25 pub rating_count: Option<u32>,
26 #[serde(default)]
27 pub release_date: Option<String>,
28 #[serde(default)]
29 pub play_methods: Vec<String>,
30 #[serde(default)]
31 pub labels: Vec<super::machine::Label>,
32}
33
34#[derive(Debug, Deserialize)]
35pub struct ChallengeDetailResponse {
36 pub challenge: ChallengeDetail,
37}
38
39#[derive(Debug, Deserialize, Serialize)]
40pub struct ChallengeDetail {
41 pub id: u64,
42 pub name: String,
43 #[serde(default)]
44 pub difficulty: Option<String>,
45 #[serde(default, deserialize_with = "deserialize_string_or_int")]
46 pub points: Option<String>,
47 #[serde(default)]
48 pub state: Option<String>,
49 #[serde(default)]
50 pub solves: u32,
51 #[serde(default)]
52 pub stars: Option<f64>,
53 #[serde(default)]
54 pub description: Option<String>,
55 #[serde(default)]
56 pub category_name: Option<String>,
57 #[serde(default)]
58 pub first_blood_user: Option<String>,
59 #[serde(default)]
60 pub first_blood_time: Option<String>,
61 #[serde(default)]
62 pub creator_name: Option<String>,
63 #[serde(default, deserialize_with = "deserialize_bool_or_null")]
64 pub download: bool,
65 #[serde(default)]
66 pub file_name: Option<String>,
67 #[serde(default)]
68 pub file_size: Option<String>,
69 #[serde(default)]
70 pub play_info: Option<ChallengePlayInfo>,
71 #[serde(default)]
72 pub play_methods: Vec<String>,
73 #[serde(default)]
74 pub release_date: Option<String>,
75 #[serde(
76 default,
77 rename = "authUserSolve",
78 deserialize_with = "deserialize_bool_or_null"
79 )]
80 pub auth_user_solve: bool,
81 #[serde(default)]
82 pub experience_points: Option<u32>,
83}
84
85#[derive(Debug, Deserialize, Serialize)]
86pub struct ChallengePlayInfo {
87 #[serde(default)]
88 pub status: Option<String>,
89 #[serde(default)]
90 pub expires_at: Option<String>,
91 #[serde(default)]
92 pub ip: Option<String>,
93 #[serde(default)]
94 pub ports: Option<serde_json::Value>,
95}
96
97#[derive(Debug, Deserialize, Serialize)]
98pub struct ChallengeCategory {
99 pub id: u64,
100 pub name: String,
101 #[serde(default)]
102 pub icon: Option<String>,
103}
104
105#[derive(Debug, Deserialize)]
106pub struct ChallengeCategoriesResponse {
107 pub info: Vec<ChallengeCategory>,
108}
109
110#[derive(Debug, Deserialize)]
111pub struct ChallengeDownloadResponse {
112 pub url: String,
113 #[serde(default)]
114 pub expires_in: Option<u64>,
115}
116
117#[derive(Debug, Deserialize)]
118pub struct ChallengeWriteupResponse {
119 pub data: ChallengeWriteupData,
120}
121
122#[derive(Debug, Deserialize)]
123pub struct ChallengeWriteupData {
124 #[serde(default)]
125 pub official: Option<ChallengeWriteup>,
126}
127
128#[derive(Debug, Deserialize)]
129pub struct ChallengeWriteup {
130 #[serde(default)]
131 pub filename: Option<String>,
132 #[serde(default)]
133 pub url: Option<String>,
134}
135
136#[derive(Debug, Deserialize, Serialize)]
137pub struct ChallengeOwnResponse {
138 pub message: String,
139 #[serde(default)]
140 pub user_rank: Option<ChallengeOwnRank>,
141}
142
143#[derive(Debug, Deserialize, Serialize)]
144pub struct ChallengeOwnRank {
145 #[serde(default)]
146 pub changed: bool,
147}
148
149impl Tabular for Challenge {
150 fn headers() -> Vec<&'static str> {
151 vec![
152 "ID",
153 "Name",
154 "Difficulty",
155 "Category",
156 "Solves",
157 "Rating",
158 "Owned",
159 "State",
160 ]
161 }
162
163 fn row(&self) -> Vec<String> {
164 vec![
165 self.id.to_string(),
166 self.name.clone(),
167 self.difficulty.clone().unwrap_or_default(),
168 self.category_name.clone().unwrap_or_default(),
169 self.solves.to_string(),
170 self.rating.map(|r| format!("{r:.1}")).unwrap_or_default(),
171 if self.is_owned { "✓" } else { "" }.to_string(),
172 self.state.clone().unwrap_or_default(),
173 ]
174 }
175}
176
177impl Tabular for ChallengeCategory {
178 fn headers() -> Vec<&'static str> {
179 vec!["ID", "Name"]
180 }
181
182 fn row(&self) -> Vec<String> {
183 vec![self.id.to_string(), self.name.clone()]
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::models::Paginated;
191
192 #[test]
193 fn deserialize_challenges_list() {
194 let json = include_str!("../../tests/fixtures/challenges-list.json");
195 let result: Paginated<Challenge> = serde_json::from_str(json).unwrap();
196 assert_eq!(result.data.len(), 15);
197 assert_eq!(result.data[0].name, "SpookyPass");
198 assert_eq!(result.data[0].category_name.as_deref(), Some("Reversing"));
199 }
200
201 #[test]
202 fn deserialize_challenge_detail() {
203 let json = include_str!("../../tests/fixtures/challenge-info-poly.json");
204 let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
205 assert_eq!(result.challenge.name, "Poly");
206 assert_eq!(result.challenge.difficulty.as_deref(), Some("Insane"));
207 assert_eq!(result.challenge.category_name.as_deref(), Some("Reversing"));
208 }
209
210 #[test]
211 fn deserialize_challenge_detail_int_points() {
212 let json = include_str!("../../tests/fixtures/challenge-info-int-points.json");
213 let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
214 assert_eq!(result.challenge.name, "TestIntPoints");
215 assert_eq!(result.challenge.points.as_deref(), Some("0"));
216 }
217
218 #[test]
219 fn deserialize_challenge_categories() {
220 let json = include_str!("../../tests/fixtures/challenge-categories.json");
221 let result: ChallengeCategoriesResponse = serde_json::from_str(json).unwrap();
222 assert!(!result.info.is_empty());
223 assert!(result.info.iter().any(|c| c.name == "Web"));
224 }
225
226 #[test]
227 fn deserialize_challenge_own_success() {
228 let json = include_str!("../../tests/fixtures/challenge-own-success.json");
229 let result: ChallengeOwnResponse = serde_json::from_str(json).unwrap();
230 assert_eq!(result.message, "Congratulations!");
231 }
232
233 #[test]
234 fn deserialize_challenge_reversing_download_only() {
235 let json = include_str!("../../tests/fixtures/challenge-info-reversing.json");
236 let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
237 let c = &result.challenge;
238 assert_eq!(c.name, "TestChallenge-Rev");
239 assert_eq!(c.category_name.as_deref(), Some("Reversing"));
240 assert_eq!(c.points.as_deref(), Some("0"));
241 assert!(c.download);
242 assert!(c.file_name.is_some());
243 assert_eq!(c.play_methods, vec!["download"]);
244 assert!(c.play_info.is_some());
245 }
246
247 #[test]
248 fn deserialize_challenge_web_docker_active() {
249 let json = include_str!("../../tests/fixtures/challenge-info-web-docker.json");
250 let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
251 let c = &result.challenge;
252 assert_eq!(c.name, "TestChallenge-Web");
253 assert_eq!(c.category_name.as_deref(), Some("Web"));
254 assert_eq!(c.points.as_deref(), Some("0"));
255 assert!(!c.download);
256 assert!(c.file_name.is_none());
257 assert_eq!(c.play_methods, vec!["container"]);
258 let info = c.play_info.as_ref().unwrap();
259 assert_eq!(info.status.as_deref(), Some("ready"));
260 assert!(info.ip.is_some());
261 assert!(info.ports.is_some());
262 assert!(info.expires_at.is_some());
263 }
264
265 #[test]
266 fn deserialize_challenge_hybrid_docker_and_download() {
267 let json = include_str!("../../tests/fixtures/challenge-info-hybrid.json");
268 let result: ChallengeDetailResponse = serde_json::from_str(json).unwrap();
269 let c = &result.challenge;
270 assert_eq!(c.name, "TestChallenge-Hybrid");
271 assert!(c.download);
272 assert!(c.file_name.is_some());
273 assert!(c.play_methods.contains(&"download".to_string()));
274 assert!(c.play_methods.contains(&"container".to_string()));
275 }
276}