|
1 | 1 | use crate::auth::require_api_key; |
2 | 2 | use crate::config; |
3 | 3 | use anyhow::{Context, Result}; |
4 | | -use serde::Deserialize; |
| 4 | +use comfy_table::modifiers::UTF8_ROUND_CORNERS; |
| 5 | +use comfy_table::presets::UTF8_FULL; |
| 6 | +use comfy_table::{Cell, ContentArrangement, Table}; |
| 7 | +use serde::{Deserialize, Serialize}; |
5 | 8 | use serde_json::json; |
6 | 9 | use std::path::Path; |
7 | 10 |
|
| 11 | +/// The create response, narrowed to the fields printed by the command. This is |
| 12 | +/// deliberately separate from `Achievement`, whose list payload is larger. |
8 | 13 | #[derive(Debug, Deserialize)] |
| 14 | +struct CreatedAchievement { |
| 15 | + _id: String, |
| 16 | + identifier: String, |
| 17 | + #[serde(rename = "displayName")] |
| 18 | + display_name: String, |
| 19 | +} |
| 20 | + |
| 21 | +#[derive(Debug, Deserialize, Serialize)] |
9 | 22 | struct Achievement { |
10 | 23 | _id: String, |
11 | 24 | identifier: String, |
12 | 25 | #[serde(rename = "displayName")] |
13 | 26 | display_name: String, |
| 27 | + description: String, |
| 28 | + image: String, |
| 29 | + secret: bool, |
| 30 | + #[serde(rename = "statId", skip_serializing_if = "Option::is_none")] |
| 31 | + stat_id: Option<String>, |
| 32 | + #[serde(rename = "statThreshold", skip_serializing_if = "Option::is_none")] |
| 33 | + stat_threshold: Option<f64>, |
| 34 | +} |
| 35 | + |
| 36 | +#[derive(Debug, Deserialize)] |
| 37 | +struct AchievementsResponse { |
| 38 | + achievements: Vec<Achievement>, |
14 | 39 | } |
15 | 40 |
|
16 | 41 | #[derive(Debug, Deserialize)] |
@@ -91,6 +116,65 @@ pub struct CreateAchievementArgs<'a> { |
91 | 116 | pub image_path: Option<&'a Path>, |
92 | 117 | } |
93 | 118 |
|
| 119 | +pub async fn handle_achievement_list(game_id: &str, json: bool) -> Result<()> { |
| 120 | + let api_key = require_api_key()?; |
| 121 | + let client = config::create_http_client()?; |
| 122 | + let api_host = config::get("api_host")?; |
| 123 | + let url = format!("{}/api/games/{}/achievements", api_host, game_id); |
| 124 | + |
| 125 | + let resp = client |
| 126 | + .get(&url) |
| 127 | + .header("Authorization", format!("Bearer {}", api_key)) |
| 128 | + .send() |
| 129 | + .await?; |
| 130 | + |
| 131 | + let resp = config::check_api_response(resp).await?; |
| 132 | + let data: AchievementsResponse = resp.json().await?; |
| 133 | + |
| 134 | + if json { |
| 135 | + println!("{}", serde_json::to_string_pretty(&data.achievements)?); |
| 136 | + return Ok(()); |
| 137 | + } |
| 138 | + |
| 139 | + if data.achievements.is_empty() { |
| 140 | + println!("No achievements found."); |
| 141 | + return Ok(()); |
| 142 | + } |
| 143 | + |
| 144 | + let mut table = Table::new(); |
| 145 | + table |
| 146 | + .load_preset(UTF8_FULL) |
| 147 | + .apply_modifier(UTF8_ROUND_CORNERS) |
| 148 | + .set_content_arrangement(ContentArrangement::Dynamic) |
| 149 | + .set_header(vec![ |
| 150 | + Cell::new("ID"), |
| 151 | + Cell::new("Identifier"), |
| 152 | + Cell::new("Title"), |
| 153 | + Cell::new("Description"), |
| 154 | + Cell::new("Secret"), |
| 155 | + Cell::new("Stat ID"), |
| 156 | + Cell::new("Threshold"), |
| 157 | + ]); |
| 158 | + |
| 159 | + for achievement in data.achievements { |
| 160 | + table.add_row(vec![ |
| 161 | + achievement._id, |
| 162 | + achievement.identifier, |
| 163 | + achievement.display_name, |
| 164 | + achievement.description, |
| 165 | + (if achievement.secret { "yes" } else { "no" }).to_string(), |
| 166 | + achievement.stat_id.unwrap_or_else(|| "-".to_string()), |
| 167 | + achievement |
| 168 | + .stat_threshold |
| 169 | + .map(|threshold| threshold.to_string()) |
| 170 | + .unwrap_or_else(|| "-".to_string()), |
| 171 | + ]); |
| 172 | + } |
| 173 | + |
| 174 | + println!("{table}"); |
| 175 | + Ok(()) |
| 176 | +} |
| 177 | + |
94 | 178 | pub async fn handle_achievement_create(args: CreateAchievementArgs<'_>) -> Result<()> { |
95 | 179 | let api_key = require_api_key()?; |
96 | 180 |
|
@@ -133,7 +217,7 @@ pub async fn handle_achievement_create(args: CreateAchievementArgs<'_>) -> Resul |
133 | 217 | .await?; |
134 | 218 |
|
135 | 219 | let resp = config::check_api_response(resp).await?; |
136 | | - let achievement: Achievement = resp.json().await?; |
| 220 | + let achievement: CreatedAchievement = resp.json().await?; |
137 | 221 | println!( |
138 | 222 | "✓ Created achievement \"{}\" (id: {}, identifier: {})", |
139 | 223 | achievement.display_name, achievement._id, achievement.identifier |
@@ -247,3 +331,73 @@ pub async fn handle_achievement_delete( |
247 | 331 | println!("✓ Deleted achievement {}", achievement_id); |
248 | 332 | Ok(()) |
249 | 333 | } |
| 334 | + |
| 335 | +#[cfg(test)] |
| 336 | +mod tests { |
| 337 | + use super::*; |
| 338 | + |
| 339 | + #[test] |
| 340 | + fn parses_the_achievement_list_response() { |
| 341 | + let response: AchievementsResponse = serde_json::from_value(json!({ |
| 342 | + "achievements": [{ |
| 343 | + "_id": "achievement-id", |
| 344 | + "identifier": "FIRST_WIN", |
| 345 | + "displayName": "First Win", |
| 346 | + "description": "Win a match", |
| 347 | + "image": "achievements/first-win.png", |
| 348 | + "secret": false, |
| 349 | + "statId": "wins-stat-id", |
| 350 | + "statThreshold": 1 |
| 351 | + }] |
| 352 | + })) |
| 353 | + .expect("the API response should deserialize"); |
| 354 | + |
| 355 | + let achievement = &response.achievements[0]; |
| 356 | + assert_eq!(achievement._id, "achievement-id"); |
| 357 | + assert_eq!(achievement.identifier, "FIRST_WIN"); |
| 358 | + assert_eq!(achievement.display_name, "First Win"); |
| 359 | + assert_eq!(achievement.stat_id.as_deref(), Some("wins-stat-id")); |
| 360 | + assert_eq!(achievement.stat_threshold, Some(1.0)); |
| 361 | + } |
| 362 | + |
| 363 | + #[test] |
| 364 | + fn parses_an_achievement_without_a_stat_link() { |
| 365 | + let response: AchievementsResponse = serde_json::from_value(json!({ |
| 366 | + "achievements": [{ |
| 367 | + "_id": "achievement-id", |
| 368 | + "identifier": "WELCOME", |
| 369 | + "displayName": "Welcome", |
| 370 | + "description": "Start the game", |
| 371 | + "image": "", |
| 372 | + "secret": true |
| 373 | + }] |
| 374 | + })) |
| 375 | + .expect("an achievement with no stat link should deserialize"); |
| 376 | + |
| 377 | + let achievement = &response.achievements[0]; |
| 378 | + assert!(achievement.secret); |
| 379 | + assert_eq!(achievement.stat_id, None); |
| 380 | + assert_eq!(achievement.stat_threshold, None); |
| 381 | + } |
| 382 | + |
| 383 | + #[test] |
| 384 | + fn json_output_uses_api_field_names_and_omits_empty_stat_fields() { |
| 385 | + let achievement = Achievement { |
| 386 | + _id: "achievement-id".to_string(), |
| 387 | + identifier: "WELCOME".to_string(), |
| 388 | + display_name: "Welcome".to_string(), |
| 389 | + description: "Start the game".to_string(), |
| 390 | + image: "achievements/welcome.png".to_string(), |
| 391 | + secret: false, |
| 392 | + stat_id: None, |
| 393 | + stat_threshold: None, |
| 394 | + }; |
| 395 | + |
| 396 | + let value = serde_json::to_value(achievement).expect("achievement should serialize"); |
| 397 | + assert_eq!(value["displayName"], "Welcome"); |
| 398 | + assert_eq!(value["image"], "achievements/welcome.png"); |
| 399 | + assert!(value.get("display_name").is_none()); |
| 400 | + assert!(value.get("statId").is_none()); |
| 401 | + assert!(value.get("statThreshold").is_none()); |
| 402 | + } |
| 403 | +} |
0 commit comments