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