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