From 80bb9fb7ba1e36da53a95aeaa7fbf0cf46bfac47 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 7 Aug 2026 09:50:48 -0700 Subject: [PATCH 1/4] Add achievement list command --- src/achievements.rs | 132 +++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 58 ++++++++++++++++++- 2 files changed, 186 insertions(+), 4 deletions(-) diff --git a/src/achievements.rs b/src/achievements.rs index f7d5bd3..c13b44b 100644 --- a/src/achievements.rs +++ b/src/achievements.rs @@ -1,16 +1,38 @@ use crate::auth::require_api_key; use crate::config; use anyhow::{Context, Result}; -use serde::Deserialize; +use comfy_table::modifiers::UTF8_ROUND_CORNERS; +use comfy_table::presets::UTF8_FULL; +use comfy_table::{Cell, ContentArrangement, Table}; +use serde::{Deserialize, Serialize}; use serde_json::json; use std::path::Path; #[derive(Debug, Deserialize)] +struct CreatedAchievement { + _id: String, + identifier: String, + #[serde(rename = "displayName")] + display_name: String, +} + +#[derive(Debug, Deserialize, Serialize)] struct Achievement { _id: String, identifier: String, #[serde(rename = "displayName")] display_name: String, + description: String, + secret: bool, + #[serde(rename = "statId", skip_serializing_if = "Option::is_none")] + stat_id: Option, + #[serde(rename = "statThreshold", skip_serializing_if = "Option::is_none")] + stat_threshold: Option, +} + +#[derive(Debug, Deserialize)] +struct AchievementsResponse { + achievements: Vec, } #[derive(Debug, Deserialize)] @@ -91,6 +113,65 @@ pub struct CreateAchievementArgs<'a> { pub image_path: Option<&'a Path>, } +pub async fn handle_achievement_list(game_id: &str, json: bool) -> Result<()> { + let api_key = require_api_key()?; + let client = config::create_http_client()?; + let api_host = config::get("api_host")?; + let url = format!("{}/api/games/{}/achievements", api_host, game_id); + + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .await?; + + let resp = config::check_api_response(resp).await?; + let data: AchievementsResponse = resp.json().await?; + + if json { + println!("{}", serde_json::to_string_pretty(&data.achievements)?); + return Ok(()); + } + + if data.achievements.is_empty() { + println!("No achievements found."); + return Ok(()); + } + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + Cell::new("ID"), + Cell::new("Identifier"), + Cell::new("Title"), + Cell::new("Description"), + Cell::new("Secret"), + Cell::new("Stat ID"), + Cell::new("Threshold"), + ]); + + for achievement in data.achievements { + table.add_row(vec![ + achievement._id, + achievement.identifier, + achievement.display_name, + achievement.description, + if achievement.secret { "yes" } else { "no" }.to_string(), + achievement.stat_id.unwrap_or_else(|| "-".to_string()), + achievement + .stat_threshold + .map(|threshold| threshold.to_string()) + .unwrap_or_else(|| "-".to_string()), + ]); + } + + println!("{table}"); + Ok(()) +} + pub async fn handle_achievement_create(args: CreateAchievementArgs<'_>) -> Result<()> { let api_key = require_api_key()?; @@ -133,7 +214,7 @@ pub async fn handle_achievement_create(args: CreateAchievementArgs<'_>) -> Resul .await?; let resp = config::check_api_response(resp).await?; - let achievement: Achievement = resp.json().await?; + let achievement: CreatedAchievement = resp.json().await?; println!( "✓ Created achievement \"{}\" (id: {}, identifier: {})", achievement.display_name, achievement._id, achievement.identifier @@ -247,3 +328,50 @@ pub async fn handle_achievement_delete( println!("✓ Deleted achievement {}", achievement_id); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_achievement_list_response() { + let response: AchievementsResponse = serde_json::from_value(json!({ + "achievements": [{ + "_id": "achievement-id", + "identifier": "FIRST_WIN", + "displayName": "First Win", + "description": "Win a match", + "secret": false, + "statId": "wins-stat-id", + "statThreshold": 1 + }] + })) + .expect("the API response should deserialize"); + + let achievement = &response.achievements[0]; + assert_eq!(achievement._id, "achievement-id"); + assert_eq!(achievement.identifier, "FIRST_WIN"); + assert_eq!(achievement.display_name, "First Win"); + assert_eq!(achievement.stat_id.as_deref(), Some("wins-stat-id")); + assert_eq!(achievement.stat_threshold, Some(1.0)); + } + + #[test] + fn json_output_uses_api_field_names_and_omits_empty_stat_fields() { + let achievement = Achievement { + _id: "achievement-id".to_string(), + identifier: "WELCOME".to_string(), + display_name: "Welcome".to_string(), + description: "Start the game".to_string(), + secret: false, + stat_id: None, + stat_threshold: None, + }; + + let value = serde_json::to_value(achievement).expect("achievement should serialize"); + assert_eq!(value["displayName"], "Welcome"); + assert!(value.get("display_name").is_none()); + assert!(value.get("statId").is_none()); + assert!(value.get("statThreshold").is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index 37e350d..a4ac88d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,8 +13,8 @@ mod updater; mod welcome; use achievements::{ - handle_achievement_create, handle_achievement_delete, handle_achievement_update, - CreateAchievementArgs, UpdateAchievementArgs, + handle_achievement_create, handle_achievement_delete, handle_achievement_list, + handle_achievement_update, CreateAchievementArgs, UpdateAchievementArgs, }; use anyhow::Result; use auth::{login_with_browser, AuthManager, AuthSource}; @@ -348,6 +348,24 @@ enum StatCommands { #[derive(Subcommand)] enum AchievementCommands { + #[command(about = "List achievements for a game")] + List { + #[arg( + long = "game-id", + value_parser = parse_non_empty_arg, + help = "Game ID (defaults to game_id in wavedash.toml. override with WAVEDASH_GAME_ID)" + )] + game_id: Option, + #[arg( + short = 'c', + long = "config", + help = "Path to wavedash.toml config file", + default_value = "./wavedash.toml" + )] + config: PathBuf, + #[arg(long, help = "Output as JSON")] + json: bool, + }, #[command(about = "Create a new achievement for a game")] Create { #[arg( @@ -665,6 +683,14 @@ async fn run() -> Result<()> { }, Commands::Achievement { action } => { match action { + AchievementCommands::List { + game_id, + config, + json, + } => { + let game_id = resolve_game_id(game_id.as_deref(), &config)?; + handle_achievement_list(&game_id, json).await?; + } AchievementCommands::Create { game_id, config, @@ -832,4 +858,32 @@ mod tests { checked ); } + + #[test] + fn achievement_list_accepts_game_id_and_json_output() { + let cli = Cli::try_parse_from([ + "wavedash", + "achievement", + "list", + "--game-id", + "game-id", + "--json", + ]) + .expect("achievement list should be a valid command"); + + match cli.command { + Some(Commands::Achievement { + action: + AchievementCommands::List { + game_id, + json, + .. + }, + }) => { + assert_eq!(game_id.as_deref(), Some("game-id")); + assert!(json); + } + _ => panic!("parsed the wrong command"), + } + } } From fe539eb7e2ce2dd6dee9d820d8b0c615ecf8ee12 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 7 Aug 2026 10:12:42 -0700 Subject: [PATCH 2/4] WVDSH-1924 Include achievement images in JSON --- src/achievements.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/achievements.rs b/src/achievements.rs index c13b44b..30349aa 100644 --- a/src/achievements.rs +++ b/src/achievements.rs @@ -23,6 +23,7 @@ struct Achievement { #[serde(rename = "displayName")] display_name: String, description: String, + image: String, secret: bool, #[serde(rename = "statId", skip_serializing_if = "Option::is_none")] stat_id: Option, @@ -341,6 +342,7 @@ mod tests { "identifier": "FIRST_WIN", "displayName": "First Win", "description": "Win a match", + "image": "achievements/first-win.png", "secret": false, "statId": "wins-stat-id", "statThreshold": 1 @@ -363,6 +365,7 @@ mod tests { identifier: "WELCOME".to_string(), display_name: "Welcome".to_string(), description: "Start the game".to_string(), + image: "achievements/welcome.png".to_string(), secret: false, stat_id: None, stat_threshold: None, @@ -370,6 +373,7 @@ mod tests { let value = serde_json::to_value(achievement).expect("achievement should serialize"); assert_eq!(value["displayName"], "Welcome"); + assert_eq!(value["image"], "achievements/welcome.png"); assert!(value.get("display_name").is_none()); assert!(value.get("statId").is_none()); assert!(value.get("statThreshold").is_none()); From 8d2f61d4b0e0678d54d4ede247221e1e7dbaa782 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 7 Aug 2026 13:21:46 -0700 Subject: [PATCH 3/4] WVDSH-1924 Address achievement list review --- src/achievements.rs | 24 +++++++++++++++++++++++- src/config.rs | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/achievements.rs b/src/achievements.rs index 30349aa..6c3e84b 100644 --- a/src/achievements.rs +++ b/src/achievements.rs @@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use std::path::Path; +/// The create response, narrowed to the fields printed by the command. This is +/// deliberately separate from `Achievement`, whose list payload is larger. #[derive(Debug, Deserialize)] struct CreatedAchievement { _id: String, @@ -160,7 +162,7 @@ pub async fn handle_achievement_list(game_id: &str, json: bool) -> Result<()> { achievement.identifier, achievement.display_name, achievement.description, - if achievement.secret { "yes" } else { "no" }.to_string(), + (if achievement.secret { "yes" } else { "no" }).to_string(), achievement.stat_id.unwrap_or_else(|| "-".to_string()), achievement .stat_threshold @@ -358,6 +360,26 @@ mod tests { assert_eq!(achievement.stat_threshold, Some(1.0)); } + #[test] + fn parses_an_achievement_without_a_stat_link() { + let response: AchievementsResponse = serde_json::from_value(json!({ + "achievements": [{ + "_id": "achievement-id", + "identifier": "WELCOME", + "displayName": "Welcome", + "description": "Start the game", + "image": "", + "secret": true + }] + })) + .expect("an achievement with no stat link should deserialize"); + + let achievement = &response.achievements[0]; + assert!(achievement.secret); + assert_eq!(achievement.stat_id, None); + assert_eq!(achievement.stat_threshold, None); + } + #[test] fn json_output_uses_api_field_names_and_omits_empty_stat_fields() { let achievement = Achievement { diff --git a/src/config.rs b/src/config.rs index 0686a3f..a7e41a7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -282,7 +282,7 @@ impl Field { /// Single place the notice prefix lives, since overrides get announced both from /// the config accessors and from `resolve_game_id`. fn print_override_notice(text: &str) { - println!("{} {}", "env override:".yellow(), text); + eprintln!("{} {}", "env override:".yellow(), text); } fn game_id_notice(value: &str) -> String { From 3b710d48873585c0088dd5ab69daf632f5a41429 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 7 Aug 2026 13:27:16 -0700 Subject: [PATCH 4/4] WVDSH-1924 Cover every game ID argument --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index a4ac88d..ef05b71 100644 --- a/src/main.rs +++ b/src/main.rs @@ -853,7 +853,7 @@ mod tests { walk(&cli, &["wavedash".to_string()], &mut checked); assert!( - checked.len() >= 7, + checked.len() >= 8, "expected every --game-id arg to be checked, only saw: {:?}", checked );