romm-api 1.2.0

HTTP client and domain logic for the RomM API
Documentation
use std::collections::HashMap;

use crate::types::achievements::{
    AchievementRow, EarnedAchievement, MergedRaMetadata, RaAchievement, RaUserGameProgression,
    RaUserProgression,
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AchievementLoadResult {
    Loaded {
        rows: Vec<AchievementRow>,
        summary: (usize, usize),
    },
    Empty(String),
}

fn badge_key(achievement: &RaAchievement) -> Option<&str> {
    achievement
        .badge_id
        .as_deref()
        .or(achievement.badge_name.as_deref())
        .filter(|s| !s.is_empty())
}

pub fn merge_achievements(
    metadata: &MergedRaMetadata,
    progression: Option<&RaUserGameProgression>,
) -> Vec<AchievementRow> {
    let earned_by_badge: HashMap<&str, &EarnedAchievement> = progression
        .map(|p| {
            p.earned_achievements
                .iter()
                .map(|e| (e.id.as_str(), e))
                .collect()
        })
        .unwrap_or_default();

    let mut indexed: Vec<(i64, AchievementRow)> = metadata
        .achievements
        .iter()
        .map(|a| {
            let order = a.display_order.unwrap_or(i64::MAX);
            let earned = badge_key(a).and_then(|k| earned_by_badge.get(k).copied());
            (
                order,
                AchievementRow {
                    title: a.title.clone(),
                    description: a.description.clone(),
                    points: a.points,
                    earned: earned.is_some(),
                    earned_at: earned.and_then(|e| e.date.clone()),
                },
            )
        })
        .collect();

    indexed.sort_by_key(|(order, _)| *order);
    indexed.into_iter().map(|(_, row)| row).collect()
}

pub fn summary(rows: &[AchievementRow]) -> (usize, usize) {
    let earned = rows.iter().filter(|r| r.earned).count();
    (earned, rows.len())
}

pub fn achievement_empty_message(
    ra_id: Option<i64>,
    ra_username: Option<&str>,
    metadata: Option<&MergedRaMetadata>,
) -> Option<&'static str> {
    if ra_id.is_none() {
        return Some("Not matched to RetroAchievements");
    }
    if ra_username
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .is_none()
    {
        return Some("Set RA username in RomM profile (web UI)");
    }
    if metadata.map(|m| m.achievements.is_empty()).unwrap_or(true) {
        return Some("No achievement metadata on server");
    }
    None
}

pub fn prepare_achievements(
    ra_id: Option<i64>,
    metadata: Option<&MergedRaMetadata>,
    ra_username: Option<&str>,
    progression: Option<&RaUserProgression>,
) -> AchievementLoadResult {
    if let Some(msg) = achievement_empty_message(ra_id, ra_username, metadata) {
        return AchievementLoadResult::Empty(msg.to_string());
    }
    let ra_id = ra_id.expect("checked above");
    let metadata = metadata.expect("checked above");
    let game_progression =
        progression.and_then(|p| p.results.iter().find(|r| r.rom_ra_id == Some(ra_id)));
    let rows = merge_achievements(metadata, game_progression);
    AchievementLoadResult::Loaded {
        summary: summary(&rows),
        rows,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::achievements::{EarnedAchievement, RaAchievement, RaUserGameProgression};

    fn sample_metadata() -> MergedRaMetadata {
        MergedRaMetadata {
            achievements: vec![
                RaAchievement {
                    title: "First steps".into(),
                    description: Some("Begin the adventure".into()),
                    points: Some(5),
                    badge_id: Some("85541".into()),
                    badge_name: None,
                    display_order: Some(0),
                },
                RaAchievement {
                    title: "Speed run".into(),
                    description: None,
                    points: Some(10),
                    badge_id: Some("85542".into()),
                    badge_name: None,
                    display_order: Some(1),
                },
            ],
        }
    }

    #[test]
    fn merge_marks_earned_by_badge_id() {
        let progression = RaUserGameProgression {
            rom_ra_id: Some(14402),
            num_awarded: Some(1),
            max_possible: Some(2),
            earned_achievements: vec![EarnedAchievement {
                id: "85541".into(),
                date: Some("2022-08-23 22:56:38".into()),
                date_hardcore: None,
            }],
        };
        let rows = merge_achievements(&sample_metadata(), Some(&progression));
        assert_eq!(rows.len(), 2);
        assert!(rows[0].earned);
        assert!(!rows[1].earned);
        assert_eq!(summary(&rows), (1, 2));
    }

    #[test]
    fn prepare_returns_empty_when_ra_id_missing() {
        let result = prepare_achievements(None, None, Some("player"), None);
        assert_eq!(
            result,
            AchievementLoadResult::Empty("Not matched to RetroAchievements".into())
        );
    }

    #[test]
    fn current_user_fixture_deserializes() {
        let json = r#"{"id":1,"ra_username":"player1","ra_progression":{"total":1,"results":[]}}"#;
        let user: crate::types::CurrentUser = serde_json::from_str(json).unwrap();
        assert_eq!(user.ra_username.as_deref(), Some("player1"));
    }
}