Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
61 changes: 61 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ thiserror = "2"
anyhow = "1"
steamworks = { version = "0.12", features = ["raw-bindings"] }
libloading = "0.8"
indicatif = "0.17"
66 changes: 66 additions & 0 deletions src/cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedAchievement {
pub last_played: u64,
pub achieved: u32,
pub total: u32,
pub rarest_name: Option<String>,
pub rarest_percent: Option<f64>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AchievementCache {
games: HashMap<u32, CachedAchievement>,
}

impl AchievementCache {
pub fn load() -> Self {
cache_path()
.and_then(|p| fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}

pub fn save(&self) {
if let Some(path) = cache_path() {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(path, serde_json::to_string(self).unwrap_or_default());
}
}
Comment on lines +28 to +35
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

unwrap_or_default() on serialization failure will silently wipe the cache file.

If serde_json::to_string(self) ever fails, unwrap_or_default() yields "", and fs::write overwrites the cache with an empty string — destroying all previously cached data. Guard the write behind a successful serialization instead.

Proposed fix
     pub fn save(&self) {
         if let Some(path) = cache_path() {
             if let Some(parent) = path.parent() {
                 let _ = fs::create_dir_all(parent);
             }
-            let _ = fs::write(path, serde_json::to_string(self).unwrap_or_default());
+            if let Ok(json) = serde_json::to_string(self) {
+                let _ = fs::write(path, json);
+            }
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn save(&self) {
if let Some(path) = cache_path() {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(path, serde_json::to_string(self).unwrap_or_default());
}
}
pub fn save(&self) {
if let Some(path) = cache_path() {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string(self) {
let _ = fs::write(path, json);
}
}
}
🤖 Prompt for AI Agents
In `@src/cache.rs` around lines 28 - 35, The save method currently uses
serde_json::to_string(self).unwrap_or_default() which will yield an empty string
on serialization errors and then fs::write will overwrite the existing cache;
change save (the pub fn save) to first attempt serialization via
serde_json::to_string(self) and only call fs::write(path, data) when
serialization returns Ok(data); handle or log the Err variant (do not write
empty data), and keep the existing directory creation logic around cache_path()
and path.parent().


pub fn get(&self, appid: u32, last_played: u64) -> Option<&CachedAchievement> {
self.games
.get(&appid)
.filter(|c| c.last_played == last_played)
}

pub fn set(
&mut self,
appid: u32,
last_played: u64,
achieved: u32,
total: u32,
rarest: Option<(&str, f64)>,
) {
self.games.insert(
appid,
CachedAchievement {
last_played,
achieved,
total,
rarest_name: rarest.map(|(n, _)| n.to_string()),
rarest_percent: rarest.map(|(_, p)| p),
},
);
}
}

fn cache_path() -> Option<PathBuf> {
dirs::cache_dir().map(|p| p.join("steamfetch").join("achievements.json"))
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod cache;
mod config;
mod display;
mod steam;
Expand Down
92 changes: 63 additions & 29 deletions src/steam/client.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use anyhow::{Context, Result};
use futures::future::join_all;
use indicatif::{ProgressBar, ProgressStyle};
use reqwest::Client;
use std::time::Duration;

use super::models::{
AchievementStats, AchievementsResponse, GameStat, GlobalAchievementsResponse,
OwnedGamesResponse, PlayerSummaryResponse, RarestAchievement, SteamStats,
};
use crate::cache::AchievementCache;

const BASE_URL: &str = "https://api.steampowered.com";

Expand Down Expand Up @@ -104,6 +105,9 @@ impl SteamClient {
.get(&appid)
.map_or(0, |g| g.playtime_forever),
playtime_2weeks: 0,
rtime_last_played: games_with_playtime
.get(&appid)
.map_or(0, |g| g.rtime_last_played),
})
.collect(),
};
Expand Down Expand Up @@ -307,32 +311,75 @@ impl SteamClient {
&self,
games: &super::models::OwnedGamesData,
) -> Option<AchievementStats> {
let all_games = self.get_all_games(games);
let results = join_all(
all_games
.iter()
.map(|(appid, name)| self.fetch_game_achievements(*appid, name.clone())),
)
.await;
let mut cache = AchievementCache::load();
let all_games: Vec<_> = games.games.iter().collect();

let pb = ProgressBar::new(all_games.len() as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} games")
.unwrap()
.progress_chars("#>-"),
);

let mut total_achieved = 0u32;
let mut total_possible = 0u32;
let mut perfect_games = 0u32;
let mut rarest_candidates: Vec<RarestAchievement> = Vec::new();

for result in results.into_iter().flatten() {
total_achieved += result.achieved;
total_possible += result.total;

if result.achieved == result.total && result.total > 0 {
perfect_games += 1;
for game in &all_games {
pb.inc(1);
let game_name = game
.name
.clone()
.unwrap_or_else(|| format!("App {}", game.appid));

// Check cache first
if let Some(cached) = cache.get(game.appid, game.rtime_last_played) {
total_achieved += cached.achieved;
total_possible += cached.total;
if cached.achieved == cached.total && cached.total > 0 {
perfect_games += 1;
}
if let (Some(name), Some(percent)) = (&cached.rarest_name, cached.rarest_percent) {
rarest_candidates.push(RarestAchievement {
name: name.clone(),
game: game_name,
percent,
});
}
continue;
}
Comment on lines +354 to 381
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Steam GetOwnedGames API rtime_last_played field documentation

💡 Result:

rtime_last_played is an optional per-game field in the IPlayerService/GetOwnedGames response (i.e., inside each games[] entry) returned by the Steam Web API endpoint. [1]

  • Meaning: “last time the game was played”
  • Format: Unix epoch timestamp (seconds, UTC) (example: 1677310875). [2]
  • Common edge case: it may be 0 (e.g., for games never played / no last-played time available). [4]
  • Why it may be missing: in practice, Valve often only includes rtime_last_played (and other “extra” fields like playtime_windows_forever, etc.) when you query your own account using your own Web API key, while other users’ results may omit it even if the library is public. [3]

Sources: [1] [2] [3] [4]


🏁 Script executed:

# Let's examine the cache implementation to understand the matching logic
find . -type f -name "*.rs" | head -20

Repository: unhappychoice/steamfetch

Length of output: 225


🏁 Script executed:

# Search for the cache implementation
rg -t rust "AchievementCache" -A 10 -B 2

Repository: unhappychoice/steamfetch

Length of output: 2049


🏁 Script executed:

# Look at the cache.get method implementation
rg -t rust "fn get\(" -A 5 | grep -A 5 "cache\|Cache"

Repository: unhappychoice/steamfetch

Length of output: 334


🏁 Script executed:

# Check the file structure to find cache-related code
fd -e rs -type f | xargs grep -l "cache\|Cache" 2>/dev/null | head -10

Repository: unhappychoice/steamfetch

Length of output: 238


🏁 Script executed:

# Examine the cache.rs file in detail to understand the full implementation
cat -n src/cache.rs

Repository: unhappychoice/steamfetch

Length of output: 2285


🏁 Script executed:

# Check the CachedAchievement structure and how it's populated
rg -t rust "struct CachedAchievement" -A 10

Repository: unhappychoice/steamfetch

Length of output: 511


🏁 Script executed:

# Look for where cache entries are inserted/stored to understand the populate flow
rg -t rust "cache\.save\|insert" -B 5 -A 5

Repository: unhappychoice/steamfetch

Length of output: 50


🏁 Script executed:

# Check steam/models.rs to understand the Game struct and rtime_last_played field
cat -n src/steam/models.rs | head -100

Repository: unhappychoice/steamfetch

Length of output: 2888


🏁 Script executed:

# Find where cache.set is called to understand the full cache populate flow
rg -t rust "cache\.set" -B 10 -A 3

Repository: unhappychoice/steamfetch

Length of output: 928


🏁 Script executed:

# Look at the complete context around lines 330-352 in client.rs
sed -n '300,380p' src/steam/client.rs

Repository: unhappychoice/steamfetch

Length of output: 2888


Bypass cache for games with rtime_last_played == 0 to prevent permanently stale achievement data.

When rtime_last_played is 0 (games never played or API field omitted), using it as a cache key creates an ambiguous state. If Valve releases new achievements for a game the player never plays, the cache will indefinitely serve stale achievement data since rtime_last_played remains 0.

Treat 0 as "unknown last-played time" and skip the cache for these entries:

Proposed fix in cache hit check
             // Check cache first
+            if game.rtime_last_played != 0 {
             if let Some(cached) = cache.get(game.appid, game.rtime_last_played) {
                 total_achieved += cached.achieved;
                 total_possible += cached.total;
                 if cached.achieved == cached.total && cached.total > 0 {
                     perfect_games += 1;
                 }
                 if let (Some(name), Some(percent)) = (&cached.rarest_name, cached.rarest_percent) {
                     rarest_candidates.push(RarestAchievement {
                         name: name.clone(),
                         game: game_name,
                         percent,
                     });
                 }
                 continue;
             }
+            }

Alternatively, centralize the guard in AchievementCache::get():

pub fn get(&self, appid: u32, last_played: u64) -> Option<&CachedAchievement> {
    if last_played == 0 {
        return None;
    }
    self.games
        .get(&appid)
        .filter(|c| c.last_played == last_played)
}
🤖 Prompt for AI Agents
In `@src/steam/client.rs` around lines 330 - 352, The cache lookup is unsafe for
entries with rtime_last_played == 0 because that value should be treated as
"unknown" and can cause permanent stale hits; update the cache logic to skip
cache reads for those cases by either adding a guard in the loop before calling
cache.get (check game.rtime_last_played == 0 and skip cache handling) or by
modifying AchievementCache::get(appid, last_played) to immediately return None
when last_played == 0 (so the rest of the existing logic is unchanged); ensure
references to CachedAchievement, total_achieved/total_possible accumulation, and
rarest candidate handling remain intact.


if let Some(r) = result.rarest {
rarest_candidates.push(r);
// Fetch from API
if let Some(result) = self
.fetch_game_achievements(game.appid, game_name.clone())
.await
{
total_achieved += result.achieved;
total_possible += result.total;
if result.achieved == result.total && result.total > 0 {
perfect_games += 1;
}

let rarest_for_cache = result.rarest.as_ref().map(|r| (r.name.as_str(), r.percent));
cache.set(
game.appid,
game.rtime_last_played,
result.achieved,
result.total,
rarest_for_cache,
);

if let Some(r) = result.rarest {
rarest_candidates.push(r);
}
}
}

pb.finish_and_clear();
cache.save();

// Sort by percent, then by game name, then by achievement name for deterministic results
let rarest = rarest_candidates.into_iter().min_by(|a, b| {
a.percent
Expand Down Expand Up @@ -440,19 +487,6 @@ impl SteamClient {
})
.collect()
}

fn get_all_games(&self, games: &super::models::OwnedGamesData) -> Vec<(u32, String)> {
games
.games
.iter()
.map(|g| {
(
g.appid,
g.name.clone().unwrap_or_else(|| format!("App {}", g.appid)),
)
})
.collect()
}
}

struct GameAchievementResult {
Expand Down
2 changes: 2 additions & 0 deletions src/steam/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub struct Game {
pub playtime_forever: u32,
#[serde(default)]
pub playtime_2weeks: u32,
#[serde(default)]
pub rtime_last_played: u64,
}

// Recently Played Games API
Expand Down