1use std::fs;
9use std::path::PathBuf;
10
11use anyhow::{Context, Result};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::config::data_dir;
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct AiStats {
21 pub model: String,
23 pub input_tokens: u64,
25 pub output_tokens: u64,
27 pub duration_ms: u64,
29 #[serde(default)]
31 pub cost_usd: Option<f64>,
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "lowercase")]
37pub enum ContributionStatus {
38 #[default]
40 Pending,
41 Accepted,
43 Rejected,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Contribution {
50 pub id: Uuid,
52 pub repo: String,
54 pub issue: u64,
56 pub action: String,
58 pub timestamp: DateTime<Utc>,
60 pub comment_url: String,
62 #[serde(default)]
64 pub status: ContributionStatus,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub ai_stats: Option<AiStats>,
68}
69
70#[derive(Debug, Default, Serialize, Deserialize)]
72pub struct HistoryData {
73 pub contributions: Vec<Contribution>,
75}
76
77impl HistoryData {
78 #[must_use]
80 pub fn total_tokens(&self) -> u64 {
81 self.contributions
82 .iter()
83 .filter_map(|c| c.ai_stats.as_ref())
84 .map(|stats| stats.input_tokens + stats.output_tokens)
85 .sum()
86 }
87
88 #[must_use]
90 pub fn total_cost(&self) -> f64 {
91 self.contributions
92 .iter()
93 .filter_map(|c| c.ai_stats.as_ref())
94 .filter_map(|stats| stats.cost_usd)
95 .sum()
96 }
97
98 #[must_use]
100 #[allow(clippy::cast_precision_loss)]
101 pub fn avg_tokens_per_triage(&self) -> f64 {
102 let contributions_with_stats: Vec<_> = self
103 .contributions
104 .iter()
105 .filter_map(|c| c.ai_stats.as_ref())
106 .collect();
107
108 if contributions_with_stats.is_empty() {
109 return 0.0;
110 }
111
112 let total: u64 = contributions_with_stats
113 .iter()
114 .map(|stats| stats.input_tokens + stats.output_tokens)
115 .sum();
116
117 total as f64 / contributions_with_stats.len() as f64
118 }
119
120 #[must_use]
122 pub fn cost_by_model(&self) -> std::collections::HashMap<String, f64> {
123 let mut costs = std::collections::HashMap::new();
124
125 for contribution in &self.contributions {
126 if let Some(stats) = &contribution.ai_stats
127 && let Some(cost) = stats.cost_usd
128 {
129 *costs.entry(stats.model.clone()).or_insert(0.0) += cost;
130 }
131 }
132
133 costs
134 }
135}
136
137#[must_use]
139pub fn history_file_path() -> PathBuf {
140 data_dir().join("history.json")
141}
142
143pub fn load() -> Result<HistoryData> {
147 let path = history_file_path();
148
149 if !path.exists() {
150 return Ok(HistoryData::default());
151 }
152
153 let contents = fs::read_to_string(&path)
154 .with_context(|| format!("Failed to read history file: {}", path.display()))?;
155
156 let data: HistoryData = serde_json::from_str(&contents)
157 .with_context(|| format!("Failed to parse history file: {}", path.display()))?;
158
159 Ok(data)
160}
161
162pub fn save(data: &HistoryData) -> Result<()> {
166 let path = history_file_path();
167
168 if let Some(parent) = path.parent() {
170 fs::create_dir_all(parent)
171 .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
172 }
173
174 let contents =
175 serde_json::to_string_pretty(data).context("Failed to serialize history data")?;
176
177 fs::write(&path, contents)
178 .with_context(|| format!("Failed to write history file: {}", path.display()))?;
179
180 Ok(())
181}
182
183pub fn add_contribution(contribution: Contribution) -> Result<()> {
187 let mut data = load()?;
188 data.contributions.push(contribution);
189 save(&data)?;
190 Ok(())
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn test_contribution() -> Contribution {
199 Contribution {
200 id: Uuid::new_v4(),
201 repo: "owner/repo".to_string(),
202 issue: 123,
203 action: "triage".to_string(),
204 timestamp: Utc::now(),
205 comment_url: "https://github.com/owner/repo/issues/123#issuecomment-1".to_string(),
206 status: ContributionStatus::Pending,
207 ai_stats: None,
208 }
209 }
210
211 #[test]
212 fn test_contribution_serialization_roundtrip() {
213 let contribution = test_contribution();
214 let json = serde_json::to_string(&contribution).expect("serialize");
215 let parsed: Contribution = serde_json::from_str(&json).expect("deserialize");
216
217 assert_eq!(contribution.id, parsed.id);
218 assert_eq!(contribution.repo, parsed.repo);
219 assert_eq!(contribution.issue, parsed.issue);
220 assert_eq!(contribution.action, parsed.action);
221 assert_eq!(contribution.comment_url, parsed.comment_url);
222 assert_eq!(contribution.status, parsed.status);
223 }
224
225 #[test]
226 fn test_history_data_serialization_roundtrip() {
227 let data = HistoryData {
228 contributions: vec![test_contribution(), test_contribution()],
229 };
230
231 let json = serde_json::to_string_pretty(&data).expect("serialize");
232 let parsed: HistoryData = serde_json::from_str(&json).expect("deserialize");
233
234 assert_eq!(parsed.contributions.len(), 2);
235 }
236
237 #[test]
238 fn test_contribution_status_default() {
239 let status = ContributionStatus::default();
240 assert_eq!(status, ContributionStatus::Pending);
241 }
242
243 #[test]
244 fn test_contribution_status_serialization() {
245 assert_eq!(
246 serde_json::to_string(&ContributionStatus::Pending).unwrap(),
247 "\"pending\""
248 );
249 assert_eq!(
250 serde_json::to_string(&ContributionStatus::Accepted).unwrap(),
251 "\"accepted\""
252 );
253 assert_eq!(
254 serde_json::to_string(&ContributionStatus::Rejected).unwrap(),
255 "\"rejected\""
256 );
257 }
258
259 #[test]
260 fn test_empty_history_default() {
261 let data = HistoryData::default();
262 assert!(data.contributions.is_empty());
263 }
264
265 #[test]
266 fn test_ai_stats_serialization_roundtrip() {
267 let stats = AiStats {
268 model: "google/gemini-2.0-flash-exp:free".to_string(),
269 input_tokens: 1000,
270 output_tokens: 500,
271 duration_ms: 1500,
272 cost_usd: Some(0.0),
273 };
274
275 let json = serde_json::to_string(&stats).expect("serialize");
276 let parsed: AiStats = serde_json::from_str(&json).expect("deserialize");
277
278 assert_eq!(stats, parsed);
279 }
280
281 #[test]
282 fn test_contribution_with_ai_stats() {
283 let mut contribution = test_contribution();
284 contribution.ai_stats = Some(AiStats {
285 model: "google/gemini-2.0-flash-exp:free".to_string(),
286 input_tokens: 1000,
287 output_tokens: 500,
288 duration_ms: 1500,
289 cost_usd: Some(0.0),
290 });
291
292 let json = serde_json::to_string(&contribution).expect("serialize");
293 let parsed: Contribution = serde_json::from_str(&json).expect("deserialize");
294
295 assert!(parsed.ai_stats.is_some());
296 assert_eq!(
297 parsed.ai_stats.unwrap().model,
298 "google/gemini-2.0-flash-exp:free"
299 );
300 }
301
302 #[test]
303 fn test_contribution_without_ai_stats_backward_compat() {
304 let json = r#"{
305 "id": "550e8400-e29b-41d4-a716-446655440000",
306 "repo": "owner/repo",
307 "issue": 123,
308 "action": "triage",
309 "timestamp": "2024-01-01T00:00:00Z",
310 "comment_url": "https://github.com/owner/repo/issues/123#issuecomment-1",
311 "status": "pending"
312 }"#;
313
314 let parsed: Contribution = serde_json::from_str(json).expect("deserialize");
315 assert!(parsed.ai_stats.is_none());
316 }
317
318 #[test]
319 fn test_total_tokens() {
320 let mut data = HistoryData::default();
321
322 let mut c1 = test_contribution();
323 c1.ai_stats = Some(AiStats {
324 model: "model1".to_string(),
325 input_tokens: 100,
326 output_tokens: 50,
327 duration_ms: 1000,
328 cost_usd: Some(0.01),
329 });
330
331 let mut c2 = test_contribution();
332 c2.ai_stats = Some(AiStats {
333 model: "model2".to_string(),
334 input_tokens: 200,
335 output_tokens: 100,
336 duration_ms: 2000,
337 cost_usd: Some(0.02),
338 });
339
340 data.contributions.push(c1);
341 data.contributions.push(c2);
342 data.contributions.push(test_contribution()); assert_eq!(data.total_tokens(), 450);
345 }
346
347 #[test]
348 fn test_total_cost() {
349 let mut data = HistoryData::default();
350
351 let mut c1 = test_contribution();
352 c1.ai_stats = Some(AiStats {
353 model: "model1".to_string(),
354 input_tokens: 100,
355 output_tokens: 50,
356 duration_ms: 1000,
357 cost_usd: Some(0.01),
358 });
359
360 let mut c2 = test_contribution();
361 c2.ai_stats = Some(AiStats {
362 model: "model2".to_string(),
363 input_tokens: 200,
364 output_tokens: 100,
365 duration_ms: 2000,
366 cost_usd: Some(0.02),
367 });
368
369 data.contributions.push(c1);
370 data.contributions.push(c2);
371
372 assert!((data.total_cost() - 0.03).abs() < f64::EPSILON);
373 }
374
375 #[test]
376 fn test_avg_tokens_per_triage() {
377 let mut data = HistoryData::default();
378
379 let mut c1 = test_contribution();
380 c1.ai_stats = Some(AiStats {
381 model: "model1".to_string(),
382 input_tokens: 100,
383 output_tokens: 50,
384 duration_ms: 1000,
385 cost_usd: Some(0.01),
386 });
387
388 let mut c2 = test_contribution();
389 c2.ai_stats = Some(AiStats {
390 model: "model2".to_string(),
391 input_tokens: 200,
392 output_tokens: 100,
393 duration_ms: 2000,
394 cost_usd: Some(0.02),
395 });
396
397 data.contributions.push(c1);
398 data.contributions.push(c2);
399
400 assert!((data.avg_tokens_per_triage() - 225.0).abs() < f64::EPSILON);
401 }
402
403 #[test]
404 fn test_avg_tokens_per_triage_empty() {
405 let data = HistoryData::default();
406 assert!((data.avg_tokens_per_triage() - 0.0).abs() < f64::EPSILON);
407 }
408
409 #[test]
410 fn test_cost_by_model() {
411 let mut data = HistoryData::default();
412
413 let mut c1 = test_contribution();
414 c1.ai_stats = Some(AiStats {
415 model: "model1".to_string(),
416 input_tokens: 100,
417 output_tokens: 50,
418 duration_ms: 1000,
419 cost_usd: Some(0.01),
420 });
421
422 let mut c2 = test_contribution();
423 c2.ai_stats = Some(AiStats {
424 model: "model1".to_string(),
425 input_tokens: 200,
426 output_tokens: 100,
427 duration_ms: 2000,
428 cost_usd: Some(0.02),
429 });
430
431 let mut c3 = test_contribution();
432 c3.ai_stats = Some(AiStats {
433 model: "model2".to_string(),
434 input_tokens: 150,
435 output_tokens: 75,
436 duration_ms: 1500,
437 cost_usd: Some(0.015),
438 });
439
440 data.contributions.push(c1);
441 data.contributions.push(c2);
442 data.contributions.push(c3);
443
444 let costs = data.cost_by_model();
445 assert_eq!(costs.len(), 2);
446 assert!((costs.get("model1").unwrap() - 0.03).abs() < f64::EPSILON);
447 assert!((costs.get("model2").unwrap() - 0.015).abs() < f64::EPSILON);
448 }
449}