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