Skip to content

Commit beade68

Browse files
authored
Merge pull request #49 from wvdsh/feat/list-achievements
WVDSH-1924: Add achievement list command
2 parents 13f83e9 + 33eafe5 commit beade68

3 files changed

Lines changed: 214 additions & 6 deletions

File tree

src/achievements.rs

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,41 @@
11
use crate::auth::require_api_key;
22
use crate::config;
33
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};
58
use serde_json::json;
69
use std::path::Path;
710

11+
/// The create response, narrowed to the fields printed by the command. This is
12+
/// deliberately separate from `Achievement`, whose list payload is larger.
813
#[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)]
922
struct Achievement {
1023
_id: String,
1124
identifier: String,
1225
#[serde(rename = "displayName")]
1326
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>,
1439
}
1540

1641
#[derive(Debug, Deserialize)]
@@ -91,6 +116,65 @@ pub struct CreateAchievementArgs<'a> {
91116
pub image_path: Option<&'a Path>,
92117
}
93118

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+
94178
pub async fn handle_achievement_create(args: CreateAchievementArgs<'_>) -> Result<()> {
95179
let api_key = require_api_key()?;
96180

@@ -133,7 +217,7 @@ pub async fn handle_achievement_create(args: CreateAchievementArgs<'_>) -> Resul
133217
.await?;
134218

135219
let resp = config::check_api_response(resp).await?;
136-
let achievement: Achievement = resp.json().await?;
220+
let achievement: CreatedAchievement = resp.json().await?;
137221
println!(
138222
"✓ Created achievement \"{}\" (id: {}, identifier: {})",
139223
achievement.display_name, achievement._id, achievement.identifier
@@ -247,3 +331,73 @@ pub async fn handle_achievement_delete(
247331
println!("✓ Deleted achievement {}", achievement_id);
248332
Ok(())
249333
}
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+
}

src/config.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ impl Field {
283283
/// Single place the notice prefix lives, since overrides get announced both from
284284
/// the config accessors and from `resolve_game_id`.
285285
fn print_override_notice(text: &str) {
286-
println!("{} {}", "env override:".yellow(), text);
286+
eprintln!("{} {}", "env override:".yellow(), text);
287287
}
288288

289289
fn game_id_notice(value: &str) -> String {

src/main.rs

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ mod updater;
1313
mod welcome;
1414

1515
use achievements::{
16-
handle_achievement_create, handle_achievement_delete, handle_achievement_update,
17-
CreateAchievementArgs, UpdateAchievementArgs,
16+
handle_achievement_create, handle_achievement_delete, handle_achievement_list,
17+
handle_achievement_update, CreateAchievementArgs, UpdateAchievementArgs,
1818
};
1919
use anyhow::Result;
2020
use auth::{login_with_browser, AuthManager, AuthSource};
@@ -369,6 +369,24 @@ enum StatCommands {
369369

370370
#[derive(Subcommand)]
371371
enum AchievementCommands {
372+
#[command(about = "List achievements for a game")]
373+
List {
374+
#[arg(
375+
long = "game-id",
376+
value_parser = parse_non_empty_arg,
377+
help = "Game ID (defaults to game_id in wavedash.toml. override with WAVEDASH_GAME_ID)"
378+
)]
379+
game_id: Option<String>,
380+
#[arg(
381+
short = 'c',
382+
long = "config",
383+
help = "Path to wavedash.toml config file",
384+
default_value = "./wavedash.toml"
385+
)]
386+
config: PathBuf,
387+
#[arg(long, help = "Output as JSON")]
388+
json: bool,
389+
},
372390
#[command(about = "Create a new achievement for a game")]
373391
Create {
374392
#[arg(
@@ -708,6 +726,14 @@ async fn run() -> Result<()> {
708726
},
709727
Commands::Achievement { action } => {
710728
match action {
729+
AchievementCommands::List {
730+
game_id,
731+
config,
732+
json,
733+
} => {
734+
let game_id = resolve_game_id(game_id.as_deref(), &config)?;
735+
handle_achievement_list(&game_id, json).await?;
736+
}
711737
AchievementCommands::Create {
712738
game_id,
713739
config,
@@ -870,12 +896,40 @@ mod tests {
870896
walk(&cli, &["wavedash".to_string()], &mut checked);
871897

872898
assert!(
873-
checked.len() >= 7,
899+
checked.len() >= 8,
874900
"expected every --game-id arg to be checked, only saw: {:?}",
875901
checked
876902
);
877903
}
878904

905+
#[test]
906+
fn achievement_list_accepts_game_id_and_json_output() {
907+
let cli = Cli::try_parse_from([
908+
"wavedash",
909+
"achievement",
910+
"list",
911+
"--game-id",
912+
"game-id",
913+
"--json",
914+
])
915+
.expect("achievement list should be a valid command");
916+
917+
match cli.command {
918+
Some(Commands::Achievement {
919+
action:
920+
AchievementCommands::List {
921+
game_id,
922+
json,
923+
..
924+
},
925+
}) => {
926+
assert_eq!(game_id.as_deref(), Some("game-id"));
927+
assert!(json);
928+
}
929+
_ => panic!("parsed the wrong command"),
930+
}
931+
}
932+
879933
#[test]
880934
fn upload_source_is_hidden_and_only_offers_the_godot_plugin() {
881935
fn walk(cmd: &clap::Command, path: &[String], found: &mut Vec<String>) {

0 commit comments

Comments
 (0)