Skip to main content

aptu_core/
history.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Local contribution history tracking.
4//!
5//! Stores contribution records in `~/.local/share/aptu/history.json`.
6//! Each contribution tracks repo, issue, action, timestamp, and status.
7
8use 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/// AI usage statistics for a contribution.
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct AiStats {
21    /// Provider name (e.g., "openrouter", "anthropic").
22    pub provider: String,
23    /// Model used for analysis.
24    pub model: String,
25    /// Number of input tokens.
26    pub input_tokens: u64,
27    /// Number of output tokens.
28    pub output_tokens: u64,
29    /// Duration of the API call in milliseconds.
30    pub duration_ms: u64,
31    /// Cost in USD (from `OpenRouter` API, `None` if not reported).
32    #[serde(default)]
33    pub cost_usd: Option<f64>,
34    /// Fallback provider used if primary failed (None if primary succeeded).
35    #[serde(default)]
36    pub fallback_provider: Option<String>,
37    /// Prompt size in characters.
38    #[serde(default)]
39    pub prompt_chars: usize,
40    /// Number of cache read tokens (from Anthropic API).
41    #[serde(default)]
42    pub cache_read_tokens: u64,
43    /// Number of cache write tokens (from Anthropic API).
44    #[serde(default)]
45    pub cache_write_tokens: u64,
46}
47
48/// Status of a contribution.
49#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "lowercase")]
51pub enum ContributionStatus {
52    /// Contribution submitted, awaiting maintainer response.
53    #[default]
54    Pending,
55    /// Maintainer accepted the contribution.
56    Accepted,
57    /// Maintainer rejected the contribution.
58    Rejected,
59}
60
61/// A single contribution record.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct Contribution {
64    /// Unique identifier.
65    pub id: Uuid,
66    /// Repository in "owner/repo" format.
67    pub repo: String,
68    /// Issue number.
69    pub issue: u64,
70    /// Action type (e.g., "triage").
71    pub action: String,
72    /// When the contribution was made.
73    pub timestamp: DateTime<Utc>,
74    /// URL to the posted comment.
75    pub comment_url: String,
76    /// Current status of the contribution.
77    #[serde(default)]
78    pub status: ContributionStatus,
79    /// AI usage statistics for this contribution.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub ai_stats: Option<AiStats>,
82}
83
84/// Container for all contribution history.
85#[derive(Debug, Default, Serialize, Deserialize)]
86pub struct HistoryData {
87    /// List of contributions.
88    pub contributions: Vec<Contribution>,
89}
90
91impl HistoryData {
92    /// Calculate total tokens used across all contributions.
93    #[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    /// Calculate total cost in USD across all contributions.
103    #[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    /// Calculate average tokens per triage.
113    #[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    /// Calculate total cost grouped by model.
135    #[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/// Returns the path to the history file.
152#[must_use]
153pub fn history_file_path() -> PathBuf {
154    data_dir().join("history.json")
155}
156
157/// Load contribution history from disk.
158///
159/// Returns empty history if file doesn't exist.
160pub 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
176/// Save contribution history to disk.
177///
178/// Creates parent directories if they don't exist.
179pub fn save(data: &HistoryData) -> Result<()> {
180    let path = history_file_path();
181
182    // Create parent directories if needed
183    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
197/// Add a contribution to history.
198///
199/// Loads existing history, appends the new contribution, and saves.
200pub 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    /// Create a test contribution.
212    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()); // No stats
377
378        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}