1use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::PathBuf;
7
8use crate::config::Config;
9use crate::Result;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct GameStats {
14 pub app_name: String,
15 pub total_playtime_seconds: u64,
16 pub launch_count: u32,
17 pub last_played: Option<DateTime<Utc>>,
18 pub first_played: Option<DateTime<Utc>>,
19 #[serde(default)]
21 pub sessions: Vec<PlaySession>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PlaySession {
26 pub started: DateTime<Utc>,
27 pub ended: DateTime<Utc>,
28 pub duration_seconds: u64,
29}
30
31impl GameStats {
32 pub fn new(app_name: String) -> Self {
33 Self {
34 app_name,
35 total_playtime_seconds: 0,
36 launch_count: 0,
37 last_played: None,
38 first_played: None,
39 sessions: Vec::new(),
40 }
41 }
42
43 pub fn record_launch(&mut self) {
44 self.launch_count += 1;
45 let now = Utc::now();
46
47 if self.first_played.is_none() {
48 self.first_played = Some(now);
49 }
50 }
51
52 pub fn record_session(&mut self, started: DateTime<Utc>, ended: DateTime<Utc>) {
53 let duration = (ended - started).num_seconds().max(0) as u64;
54
55 self.total_playtime_seconds += duration;
56 self.last_played = Some(ended);
57
58 self.sessions.push(PlaySession {
59 started,
60 ended,
61 duration_seconds: duration,
62 });
63 }
64
65 pub fn total_playtime_hours(&self) -> f64 {
66 self.total_playtime_seconds as f64 / 3600.0
67 }
68
69 pub fn save(&self, config: &Config) -> Result<()> {
70 let stats_dir = Self::stats_dir(config)?;
71 fs::create_dir_all(&stats_dir)?;
72
73 let stats_file = stats_dir.join(format!("{}.json", self.app_name));
74 let contents = serde_json::to_string_pretty(self)?;
75 fs::write(&stats_file, contents)?;
76
77 Ok(())
78 }
79
80 pub fn load(config: &Config, app_name: &str) -> Result<Self> {
81 let stats_dir = Self::stats_dir(config)?;
82 let stats_file = stats_dir.join(format!("{}.json", app_name));
83
84 if !stats_file.exists() {
85 return Ok(Self::new(app_name.to_string()));
86 }
87
88 let contents = fs::read_to_string(&stats_file)?;
89 Ok(serde_json::from_str(&contents)?)
90 }
91
92 fn stats_dir(_config: &Config) -> Result<PathBuf> {
93 let data_dir = Config::data_dir()?;
94 Ok(data_dir.join("stats"))
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct Achievement {
101 pub id: String,
102 pub name: String,
103 pub description: String,
104 pub unlocked: bool,
105 pub unlock_time: Option<DateTime<Utc>>,
106 pub icon_url: Option<String>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct GameAchievements {
111 pub app_name: String,
112 pub achievements: Vec<Achievement>,
113 pub total_achievements: usize,
114 pub unlocked_count: usize,
115}
116
117impl GameAchievements {
118 pub fn new(app_name: String) -> Self {
119 Self {
120 app_name,
121 achievements: Vec::new(),
122 total_achievements: 0,
123 unlocked_count: 0,
124 }
125 }
126
127 pub fn completion_percentage(&self) -> f32 {
128 if self.total_achievements == 0 {
129 return 0.0;
130 }
131 (self.unlocked_count as f32 / self.total_achievements as f32) * 100.0
132 }
133}
134
135pub struct StatsManager {
137 config: Config,
138 stats_cache: HashMap<String, GameStats>,
139}
140
141impl StatsManager {
142 pub fn new(config: Config) -> Self {
143 Self {
144 config,
145 stats_cache: HashMap::new(),
146 }
147 }
148
149 pub fn get_stats(&mut self, app_name: &str) -> Result<&GameStats> {
150 if !self.stats_cache.contains_key(app_name) {
151 let stats = GameStats::load(&self.config, app_name)?;
152 self.stats_cache.insert(app_name.to_string(), stats);
153 }
154
155 Ok(self.stats_cache.get(app_name).unwrap())
156 }
157
158 pub fn record_launch(&mut self, app_name: &str) -> Result<()> {
159 let stats = self.stats_cache
160 .entry(app_name.to_string())
161 .or_insert_with(|| GameStats::new(app_name.to_string()));
162
163 stats.record_launch();
164 stats.save(&self.config)?;
165
166 Ok(())
167 }
168
169 pub fn record_session(
170 &mut self,
171 app_name: &str,
172 started: DateTime<Utc>,
173 ended: DateTime<Utc>,
174 ) -> Result<()> {
175 let stats = self.stats_cache
176 .entry(app_name.to_string())
177 .or_insert_with(|| GameStats::new(app_name.to_string()));
178
179 stats.record_session(started, ended);
180 stats.save(&self.config)?;
181
182 Ok(())
183 }
184
185 pub fn get_all_stats(&self) -> Vec<&GameStats> {
186 self.stats_cache.values().collect()
187 }
188
189 pub fn total_playtime_hours(&self) -> f64 {
190 self.stats_cache
191 .values()
192 .map(|s| s.total_playtime_hours())
193 .sum()
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn test_game_stats_creation() {
203 let stats = GameStats::new("test_game".to_string());
204 assert_eq!(stats.app_name, "test_game");
205 assert_eq!(stats.total_playtime_seconds, 0);
206 assert_eq!(stats.launch_count, 0);
207 }
208
209 #[test]
210 fn test_record_launch() {
211 let mut stats = GameStats::new("test_game".to_string());
212 stats.record_launch();
213 assert_eq!(stats.launch_count, 1);
214 assert!(stats.first_played.is_some());
215 }
216
217 #[test]
218 fn test_record_session() {
219 let mut stats = GameStats::new("test_game".to_string());
220 let start = Utc::now();
221 let end = start + chrono::Duration::hours(2);
222
223 stats.record_session(start, end);
224
225 assert_eq!(stats.total_playtime_hours(), 2.0);
226 assert_eq!(stats.sessions.len(), 1);
227 assert!(stats.last_played.is_some());
228 }
229}