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