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    /// Trace ID for correlating with context records (optional, not serialized if None).
47    #[serde(default)]
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub trace_id: Option<String>,
50}
51
52/// Status of a contribution.
53#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "lowercase")]
55pub enum ContributionStatus {
56    /// Contribution submitted, awaiting maintainer response.
57    #[default]
58    Pending,
59    /// Maintainer accepted the contribution.
60    Accepted,
61    /// Maintainer rejected the contribution.
62    Rejected,
63}
64
65/// A single contribution record.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Contribution {
68    /// Unique identifier.
69    pub id: Uuid,
70    /// Repository in "owner/repo" format.
71    pub repo: String,
72    /// Issue number.
73    pub issue: u64,
74    /// Action type (e.g., "triage").
75    pub action: String,
76    /// When the contribution was made.
77    pub timestamp: DateTime<Utc>,
78    /// URL to the posted comment.
79    pub comment_url: String,
80    /// Current status of the contribution.
81    #[serde(default)]
82    pub status: ContributionStatus,
83    /// AI usage statistics for this contribution.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub ai_stats: Option<AiStats>,
86}
87
88/// Container for all contribution history.
89#[derive(Debug, Default, Serialize, Deserialize)]
90pub struct HistoryData {
91    /// List of contributions.
92    pub contributions: Vec<Contribution>,
93}
94
95impl HistoryData {
96    /// Calculate total tokens used across all contributions.
97    #[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    /// Calculate total cost in USD across all contributions.
107    #[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    /// Calculate average tokens per triage.
117    #[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    /// Calculate total cost grouped by model.
139    #[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/// Returns the path to the history file.
156#[must_use]
157pub fn history_file_path() -> PathBuf {
158    data_dir().join("history.json")
159}
160
161/// Load contribution history from disk.
162///
163/// Returns empty history if file doesn't exist.
164pub 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
180/// Save contribution history to disk.
181///
182/// Creates parent directories if they don't exist.
183pub fn save(data: &HistoryData) -> Result<()> {
184    let path = history_file_path();
185
186    // Create parent directories if needed
187    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
201/// Add a contribution to history.
202///
203/// Loads existing history, appends the new contribution, and saves.
204pub 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    /// Create a test contribution.
216    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()); // No stats
385
386        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}