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