Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 156 additions & 2 deletions src/achievements.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,41 @@
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;

/// 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,
identifier: String,
#[serde(rename = "displayName")]
display_name: String,
}
Comment thread
cloud9c marked this conversation as resolved.

#[derive(Debug, Deserialize, Serialize)]
struct Achievement {
_id: String,
identifier: String,
#[serde(rename = "displayName")]
display_name: String,
description: String,
image: String,
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
secret: bool,
Comment on lines +27 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

description, image, and secret are required for deserialization, so any achievement document missing one of them fails the whole list call with a raw serde error rather than degrading gracefully.

Your E2E run confirms today's backend returns "image": "" even for an achievement created without one, so this works right now. But handle_achievement_create only sets image when --image is passed, so the field being present at all depends entirely on a backend default — and older documents (or ones written by a different code path) may not have it.

Cheap hardening:

Suggested change
description: String,
image: String,
secret: bool,
#[serde(default)]
description: String,
#[serde(default)]
image: String,
#[serde(default)]
secret: bool,

Your parses_the_achievement_list_response test would still pass, and a follow-up test with a minimal document would lock the behavior in.

@cloud9c cloud9c Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backend contract requires description, image, and secret: the Convex storage schema and API return validator use non-optional validators, and optional write inputs are normalized before storage (image ?? '', imported description ?? '', secret ?? false). I am intentionally keeping the Rust fields required so a backend contract regression fails visibly rather than being silently defaulted.

#[serde(rename = "statId", skip_serializing_if = "Option::is_none")]
stat_id: Option<String>,
#[serde(rename = "statThreshold", skip_serializing_if = "Option::is_none")]
stat_threshold: Option<f64>,
}

#[derive(Debug, Deserialize)]
struct AchievementsResponse {
achievements: Vec<Achievement>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -91,6 +116,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()?;

Expand Down Expand Up @@ -133,7 +217,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
Expand Down Expand Up @@ -247,3 +331,73 @@ 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",
"image": "achievements/first-win.png",
"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));
}
Comment thread
cloud9c marked this conversation as resolved.

#[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 {
_id: "achievement-id".to_string(),
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,
};

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());
}
}
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,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 {
Expand Down
60 changes: 57 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -362,6 +362,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<String>,
#[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(
Expand Down Expand Up @@ -699,6 +717,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)?;
Comment thread
cloud9c marked this conversation as resolved.
handle_achievement_list(&game_id, json).await?;
}
AchievementCommands::Create {
game_id,
config,
Expand Down Expand Up @@ -861,12 +887,40 @@ 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
Comment thread
cloud9c marked this conversation as resolved.
);
}

#[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"),
}
}

#[test]
fn upload_source_is_hidden_and_only_offers_the_godot_plugin() {
fn walk(cmd: &clap::Command, path: &[String], found: &mut Vec<String>) {
Expand Down
Loading