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 #[serde(default)]
39 pub prompt_chars: usize,
40 #[serde(default)]
42 pub cache_read_tokens: u64,
43 #[serde(default)]
45 pub cache_write_tokens: u64,
46 #[serde(default)]
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub trace_id: Option<String>,
50}
51
52#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "lowercase")]
55pub enum ContributionStatus {
56 #[default]
58 Pending,
59 Accepted,
61 Rejected,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Contribution {
68 pub id: Uuid,
70 pub repo: String,
72 pub issue: u64,
74 pub action: String,
76 pub timestamp: DateTime<Utc>,
78 pub comment_url: String,
80 #[serde(default)]
82 pub status: ContributionStatus,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub ai_stats: Option<AiStats>,
86}
87
88#[derive(Debug, Default, Serialize, Deserialize)]
90pub struct HistoryData {
91 pub contributions: Vec<Contribution>,
93}
94
95impl HistoryData {
96 #[must_use]
98 pub fn total_tokens(&self) -> u64 {
99 self.contributions
100 .iter()
101 .filter_map(|c| c.ai_stats.as_ref())
102 .map(|stats| stats.input_tokens + stats.output_tokens)
103 .sum()
104 }
105
106 #[must_use]
108 pub fn total_cost(&self) -> f64 {
109 self.contributions
110 .iter()
111 .filter_map(|c| c.ai_stats.as_ref())
112 .filter_map(|stats| stats.cost_usd)
113 .sum()
114 }
115
116 #[must_use]
118 #[allow(clippy::cast_precision_loss)]
119 pub fn avg_tokens_per_triage(&self) -> f64 {
120 let contributions_with_stats: Vec<_> = self
121 .contributions
122 .iter()
123 .filter_map(|c| c.ai_stats.as_ref())
124 .collect();
125
126 if contributions_with_stats.is_empty() {
127 return 0.0;
128 }
129
130 let total: u64 = contributions_with_stats
131 .iter()
132 .map(|stats| stats.input_tokens + stats.output_tokens)
133 .sum();
134
135 total as f64 / contributions_with_stats.len() as f64
136 }
137
138 #[must_use]
140 pub fn cost_by_model(&self) -> std::collections::HashMap<String, f64> {
141 let mut costs = std::collections::HashMap::new();
142
143 for contribution in &self.contributions {
144 if let Some(stats) = &contribution.ai_stats
145 && let Some(cost) = stats.cost_usd
146 {
147 *costs.entry(stats.model.clone()).or_insert(0.0) += cost;
148 }
149 }
150
151 costs
152 }
153}
154
155#[must_use]
157pub fn history_file_path() -> PathBuf {
158 data_dir().join("history.json")
159}
160
161pub fn load() -> Result<HistoryData> {
165 let path = history_file_path();
166
167 if !path.exists() {
168 return Ok(HistoryData::default());
169 }
170
171 let contents = fs::read_to_string(&path)
172 .with_context(|| format!("Failed to read history file: {}", path.display()))?;
173
174 let data: HistoryData = serde_json::from_str(&contents)
175 .with_context(|| format!("Failed to parse history file: {}", path.display()))?;
176
177 Ok(data)
178}
179
180pub fn save(data: &HistoryData) -> Result<()> {
184 let path = history_file_path();
185
186 if let Some(parent) = path.parent() {
188 fs::create_dir_all(parent)
189 .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
190 }
191
192 let contents =
193 serde_json::to_string_pretty(data).context("Failed to serialize history data")?;
194
195 fs::write(&path, contents)
196 .with_context(|| format!("Failed to write history file: {}", path.display()))?;
197
198 Ok(())
199}
200
201pub fn add_contribution(contribution: Contribution) -> Result<()> {
205 let mut data = load()?;
206 data.contributions.push(contribution);
207 save(&data)?;
208 Ok(())
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn test_contribution() -> Contribution {
217 Contribution {
218 id: Uuid::new_v4(),
219 repo: "owner/repo".to_string(),
220 issue: 123,
221 action: "triage".to_string(),
222 timestamp: Utc::now(),
223 comment_url: "https://github.com/owner/repo/issues/123#issuecomment-1".to_string(),
224 status: ContributionStatus::Pending,
225 ai_stats: None,
226 }
227 }
228
229 #[test]
230 fn test_contribution_serialization_roundtrip() {
231 let contribution = test_contribution();
232 let json = serde_json::to_string(&contribution).expect("serialize");
233 let parsed: Contribution = serde_json::from_str(&json).expect("deserialize");
234
235 assert_eq!(contribution.id, parsed.id);
236 assert_eq!(contribution.repo, parsed.repo);
237 assert_eq!(contribution.issue, parsed.issue);
238 assert_eq!(contribution.action, parsed.action);
239 assert_eq!(contribution.comment_url, parsed.comment_url);
240 assert_eq!(contribution.status, parsed.status);
241 }
242
243 #[test]
244 fn test_history_data_serialization_roundtrip() {
245 let data = HistoryData {
246 contributions: vec![test_contribution(), test_contribution()],
247 };
248
249 let json = serde_json::to_string_pretty(&data).expect("serialize");
250 let parsed: HistoryData = serde_json::from_str(&json).expect("deserialize");
251
252 assert_eq!(parsed.contributions.len(), 2);
253 }
254
255 #[test]
256 fn test_contribution_status_default() {
257 let status = ContributionStatus::default();
258 assert_eq!(status, ContributionStatus::Pending);
259 }
260
261 #[test]
262 fn test_contribution_status_serialization() {
263 assert_eq!(
264 serde_json::to_string(&ContributionStatus::Pending).unwrap(),
265 "\"pending\""
266 );
267 assert_eq!(
268 serde_json::to_string(&ContributionStatus::Accepted).unwrap(),
269 "\"accepted\""
270 );
271 assert_eq!(
272 serde_json::to_string(&ContributionStatus::Rejected).unwrap(),
273 "\"rejected\""
274 );
275 }
276
277 #[test]
278 fn test_empty_history_default() {
279 let data = HistoryData::default();
280 assert!(data.contributions.is_empty());
281 }
282
283 #[test]
284 fn test_ai_stats_serialization_roundtrip() {
285 let stats = AiStats {
286 provider: "openrouter".to_string(),
287 model: "mistralai/mistral-small-2603".to_string(),
288 input_tokens: 1000,
289 output_tokens: 500,
290 duration_ms: 1500,
291 cost_usd: Some(0.0),
292 fallback_provider: None,
293 prompt_chars: 0,
294 cache_read_tokens: 0,
295 cache_write_tokens: 0,
296 trace_id: None,
297 };
298
299 let json = serde_json::to_string(&stats).expect("serialize");
300 let parsed: AiStats = serde_json::from_str(&json).expect("deserialize");
301
302 assert_eq!(stats, parsed);
303 }
304
305 #[test]
306 fn test_contribution_with_ai_stats() {
307 let mut contribution = test_contribution();
308 contribution.ai_stats = Some(AiStats {
309 provider: "openrouter".to_string(),
310 model: "mistralai/mistral-small-2603".to_string(),
311 input_tokens: 1000,
312 output_tokens: 500,
313 duration_ms: 1500,
314 cost_usd: Some(0.0),
315 fallback_provider: None,
316 prompt_chars: 0,
317 cache_read_tokens: 0,
318 cache_write_tokens: 0,
319 trace_id: None,
320 });
321
322 let json = serde_json::to_string(&contribution).expect("serialize");
323 let parsed: Contribution = serde_json::from_str(&json).expect("deserialize");
324
325 assert!(parsed.ai_stats.is_some());
326 assert_eq!(
327 parsed.ai_stats.unwrap().model,
328 "mistralai/mistral-small-2603"
329 );
330 }
331
332 #[test]
333 fn test_contribution_without_ai_stats_backward_compat() {
334 let json = r#"{
335 "id": "550e8400-e29b-41d4-a716-446655440000",
336 "repo": "owner/repo",
337 "issue": 123,
338 "action": "triage",
339 "timestamp": "2024-01-01T00:00:00Z",
340 "comment_url": "https://github.com/owner/repo/issues/123#issuecomment-1",
341 "status": "pending"
342 }"#;
343
344 let parsed: Contribution = serde_json::from_str(json).expect("deserialize");
345 assert!(parsed.ai_stats.is_none());
346 }
347
348 #[test]
349 fn test_total_tokens() {
350 let mut data = HistoryData::default();
351
352 let mut c1 = test_contribution();
353 c1.ai_stats = Some(AiStats {
354 provider: "openrouter".to_string(),
355 model: "model1".to_string(),
356 input_tokens: 100,
357 output_tokens: 50,
358 duration_ms: 1000,
359 cost_usd: Some(0.01),
360 fallback_provider: None,
361 prompt_chars: 0,
362 cache_read_tokens: 0,
363 cache_write_tokens: 0,
364 trace_id: None,
365 });
366
367 let mut c2 = test_contribution();
368 c2.ai_stats = Some(AiStats {
369 provider: "openrouter".to_string(),
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 prompt_chars: 0,
377 cache_read_tokens: 0,
378 cache_write_tokens: 0,
379 trace_id: None,
380 });
381
382 data.contributions.push(c1);
383 data.contributions.push(c2);
384 data.contributions.push(test_contribution()); assert_eq!(data.total_tokens(), 450);
387 }
388
389 #[test]
390 fn test_total_cost() {
391 let mut data = HistoryData::default();
392
393 let mut c1 = test_contribution();
394 c1.ai_stats = Some(AiStats {
395 provider: "openrouter".to_string(),
396 model: "model1".to_string(),
397 input_tokens: 100,
398 output_tokens: 50,
399 duration_ms: 1000,
400 cost_usd: Some(0.01),
401 fallback_provider: None,
402 prompt_chars: 0,
403 cache_read_tokens: 0,
404 cache_write_tokens: 0,
405 trace_id: None,
406 });
407
408 let mut c2 = test_contribution();
409 c2.ai_stats = Some(AiStats {
410 provider: "openrouter".to_string(),
411 model: "model2".to_string(),
412 input_tokens: 200,
413 output_tokens: 100,
414 duration_ms: 2000,
415 cost_usd: Some(0.02),
416 fallback_provider: None,
417 prompt_chars: 0,
418 cache_read_tokens: 0,
419 cache_write_tokens: 0,
420 trace_id: None,
421 });
422
423 data.contributions.push(c1);
424 data.contributions.push(c2);
425
426 assert!((data.total_cost() - 0.03).abs() < f64::EPSILON);
427 }
428
429 #[test]
430 fn test_avg_tokens_per_triage() {
431 let mut data = HistoryData::default();
432
433 let mut c1 = test_contribution();
434 c1.ai_stats = Some(AiStats {
435 provider: "openrouter".to_string(),
436 model: "model1".to_string(),
437 input_tokens: 100,
438 output_tokens: 50,
439 duration_ms: 1000,
440 cost_usd: Some(0.01),
441 fallback_provider: None,
442 prompt_chars: 0,
443 cache_read_tokens: 0,
444 cache_write_tokens: 0,
445 trace_id: None,
446 });
447
448 let mut c2 = test_contribution();
449 c2.ai_stats = Some(AiStats {
450 provider: "openrouter".to_string(),
451 model: "model2".to_string(),
452 input_tokens: 200,
453 output_tokens: 100,
454 duration_ms: 2000,
455 cost_usd: Some(0.02),
456 fallback_provider: None,
457 prompt_chars: 0,
458 cache_read_tokens: 0,
459 cache_write_tokens: 0,
460 trace_id: None,
461 });
462
463 data.contributions.push(c1);
464 data.contributions.push(c2);
465
466 assert!((data.avg_tokens_per_triage() - 225.0).abs() < f64::EPSILON);
467 }
468
469 #[test]
470 fn test_avg_tokens_per_triage_empty() {
471 let data = HistoryData::default();
472 assert!((data.avg_tokens_per_triage() - 0.0).abs() < f64::EPSILON);
473 }
474
475 #[test]
476 fn test_cost_by_model() {
477 let mut data = HistoryData::default();
478
479 let mut c1 = test_contribution();
480 c1.ai_stats = Some(AiStats {
481 provider: "openrouter".to_string(),
482 model: "model1".to_string(),
483 input_tokens: 100,
484 output_tokens: 50,
485 duration_ms: 1000,
486 cost_usd: Some(0.01),
487 fallback_provider: None,
488 prompt_chars: 0,
489 cache_read_tokens: 0,
490 cache_write_tokens: 0,
491 trace_id: None,
492 });
493
494 let mut c2 = test_contribution();
495 c2.ai_stats = Some(AiStats {
496 provider: "openrouter".to_string(),
497 model: "model1".to_string(),
498 input_tokens: 200,
499 output_tokens: 100,
500 duration_ms: 2000,
501 cost_usd: Some(0.02),
502 fallback_provider: None,
503 prompt_chars: 0,
504 cache_read_tokens: 0,
505 cache_write_tokens: 0,
506 trace_id: None,
507 });
508
509 let mut c3 = test_contribution();
510 c3.ai_stats = Some(AiStats {
511 provider: "openrouter".to_string(),
512 model: "model2".to_string(),
513 input_tokens: 150,
514 output_tokens: 75,
515 duration_ms: 1500,
516 cost_usd: Some(0.015),
517 fallback_provider: None,
518 prompt_chars: 0,
519 cache_read_tokens: 0,
520 cache_write_tokens: 0,
521 trace_id: None,
522 });
523
524 data.contributions.push(c1);
525 data.contributions.push(c2);
526 data.contributions.push(c3);
527
528 let costs = data.cost_by_model();
529 assert_eq!(costs.len(), 2);
530 assert!((costs.get("model1").unwrap() - 0.03).abs() < f64::EPSILON);
531 assert!((costs.get("model2").unwrap() - 0.015).abs() < f64::EPSILON);
532 }
533
534 #[test]
535 fn test_ai_stats_cache_tokens_roundtrip() {
536 let stats = AiStats {
537 provider: "anthropic".to_string(),
538 model: "claude-sonnet-4-6".to_string(),
539 input_tokens: 1000,
540 output_tokens: 500,
541 duration_ms: 1500,
542 cost_usd: Some(0.05),
543 fallback_provider: None,
544 prompt_chars: 5000,
545 cache_read_tokens: 100,
546 cache_write_tokens: 50,
547 trace_id: None,
548 };
549
550 let json = serde_json::to_string(&stats).expect("serialize");
551 let parsed: AiStats = serde_json::from_str(&json).expect("deserialize");
552
553 assert_eq!(stats, parsed);
554 assert_eq!(parsed.cache_read_tokens, 100);
555 assert_eq!(parsed.cache_write_tokens, 50);
556 }
557
558 #[test]
559 fn test_ai_stats_cache_tokens_default() {
560 let json = r#"{
561 "provider": "openrouter",
562 "model": "mistralai/mistral-small-2603",
563 "input_tokens": 1000,
564 "output_tokens": 500,
565 "duration_ms": 1500,
566 "cost_usd": 0.0,
567 "fallback_provider": null,
568 "prompt_chars": 0
569 }"#;
570
571 let parsed: AiStats = serde_json::from_str(json).expect("deserialize");
572
573 assert_eq!(parsed.cache_read_tokens, 0);
574 assert_eq!(parsed.cache_write_tokens, 0);
575 }
576}