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