Skip to main content

garbage_code_hunter/trend/
history.rs

1//! History record storage and retrieval.
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::PathBuf;
7
8/// A single scan history record.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct HistoryRecord {
11    pub timestamp: String,
12    pub project_path: String,
13    pub overall_score: f64,
14    pub tools: Vec<ToolScore>,
15}
16
17/// Score for a single tool.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ToolScore {
20    pub name: String,
21    pub score: f64,
22    pub item_count: usize,
23}
24
25/// Get the history directory path (~/.garbage-code-hunter/history/).
26fn history_dir() -> Result<PathBuf> {
27    let home = dirs_home().context("Could not determine home directory")?;
28    let dir = home.join(".garbage-code-hunter").join("history");
29    fs::create_dir_all(&dir)?;
30    Ok(dir)
31}
32
33fn dirs_home() -> Option<PathBuf> {
34    std::env::var("HOME").ok().map(PathBuf::from)
35}
36
37/// Save a history record to disk.
38pub fn save(record: &HistoryRecord) -> Result<PathBuf> {
39    let dir = history_dir()?;
40    let filename = format!("{}.json", record.timestamp.replace(':', "-"));
41    let path = dir.join(&filename);
42    let json = serde_json::to_string_pretty(record)?;
43    fs::write(&path, json)?;
44    Ok(path)
45}
46
47/// Load all history records, sorted by timestamp ascending.
48pub fn load_all() -> Result<Vec<HistoryRecord>> {
49    let dir = match history_dir() {
50        Ok(d) => d,
51        Err(_) => return Ok(vec![]),
52    };
53
54    let mut records = Vec::new();
55    if !dir.exists() {
56        return Ok(records);
57    }
58
59    for entry in fs::read_dir(&dir)? {
60        let entry = entry?;
61        let path = entry.path();
62        if path.extension().is_some_and(|ext| ext == "json") {
63            if let Ok(content) = fs::read_to_string(&path) {
64                if let Ok(record) = serde_json::from_str::<HistoryRecord>(&content) {
65                    records.push(record);
66                }
67            }
68        }
69    }
70
71    records.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
72    Ok(records)
73}
74
75/// Load the last N records.
76pub fn load_last(n: usize) -> Result<Vec<HistoryRecord>> {
77    let all = load_all()?;
78    let start = all.len().saturating_sub(n);
79    Ok(all[start..].to_vec())
80}
81
82/// Load records since a given date (YYYY-MM-DD).
83pub fn load_since(date: &str) -> Result<Vec<HistoryRecord>> {
84    let all = load_all()?;
85    Ok(all
86        .into_iter()
87        .filter(|r| r.timestamp.as_str() >= date)
88        .collect())
89}
90
91/// Generate a timestamp string for now.
92pub fn now_timestamp() -> String {
93    let secs = std::time::SystemTime::now()
94        .duration_since(std::time::UNIX_EPOCH)
95        .unwrap_or_default()
96        .as_secs();
97    // Simple epoch -> datetime conversion
98    epoch_to_datetime(secs)
99}
100
101fn epoch_to_datetime(secs: u64) -> String {
102    let days = secs / 86400;
103    let time_of_day = secs % 86400;
104    let hour = time_of_day / 3600;
105    let minute = (time_of_day % 3600) / 60;
106    let second = time_of_day % 60;
107
108    // Days since 1970-01-01 to Y-M-D
109    let (y, m, d) = days_to_ymd(days);
110
111    format!(
112        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
113        y, m, d, hour, minute, second
114    )
115}
116
117fn days_to_ymd(days: u64) -> (i64, u32, u32) {
118    // Simplified civil calendar from days since epoch
119    let z = days + 719468;
120    let era = z / 146097;
121    let doe = z - era * 146097;
122    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
123    let y = yoe as i64 + era as i64 * 400;
124    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
125    let mp = (5 * doy + 2) / 153;
126    let d = doy - (153 * mp + 2) / 5 + 1;
127    let m = if mp < 10 { mp + 3 } else { mp - 9 };
128    let y = if m <= 2 { y + 1 } else { y };
129    (y, m as u32, d as u32)
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    // ── days_to_ymd ────────────────────────────────────────────────
137
138    /// Objective: Verify days_to_ymd returns the correct date for the Unix epoch.
139    /// Invariants: 0 days since epoch → 1970-01-01.
140    #[test]
141    fn test_days_to_ymd_epoch() {
142        let (y, m, d) = days_to_ymd(0);
143        assert_eq!(
144            (y, m, d),
145            (1970, 1, 1),
146            "epoch = 1970-01-01, got {y}-{m}-{d}"
147        );
148    }
149
150    /// Objective: Verify days_to_ymd correctly computes dates around leap-year boundary.
151    /// Invariants: 2024 is a leap year; 2024-03-01 is 60 days after 2024-01-01 (31 Jan + 29 Feb).
152    #[test]
153    fn test_days_to_ymd_leap_year() {
154        let (y, m, d) = days_to_ymd(19723);
155        assert_eq!(
156            (y, m, d),
157            (2024, 1, 1),
158            "19723 days = 2024-01-01, got {y}-{m}-{d}"
159        );
160
161        let (y2, m2, d2) = days_to_ymd(19783);
162        assert_eq!(
163            (y2, m2, d2),
164            (2024, 3, 1),
165            "19783 days = 2024-03-01, got {y2}-{m2}-{d2}"
166        );
167    }
168
169    /// Objective: Verify days_to_ymd handles a non-leap year correctly.
170    /// Invariants: 2023-03-01 is 60 days after 2023-01-01 (non-leap Feb has 28).
171    #[test]
172    fn test_days_to_ymd_non_leap() {
173        let (y, m, d) = days_to_ymd(19358); // 2023-01-01
174        assert_eq!(
175            (y, m, d),
176            (2023, 1, 1),
177            "19358 days = 2023-01-01, got {y}-{m}-{d}"
178        );
179
180        let (y2, m2, d2) = days_to_ymd(19358 + 59); // 2023-03-01
181        assert_eq!(
182            (y2, m2, d2),
183            (2023, 3, 1),
184            "19358+59 = 2023-03-01, got {y2}-{m2}-{d2}"
185        );
186    }
187
188    /// Objective: Verify boundary at year 2000, a leap year.
189    #[test]
190    fn test_days_to_ymd_year_2000() {
191        let (y, m, d) = days_to_ymd(10957); // 2000-01-01
192        assert_eq!(
193            (y, m, d),
194            (2000, 1, 1),
195            "10957 days = 2000-01-01, got {y}-{m}-{d}"
196        );
197    }
198
199    /// Objective: Verify days_to_ymd for 2000-01-01 (leap-year century) and 2001-01-01.
200    #[test]
201    fn test_days_to_ymd_century_boundary() {
202        // 30 years from epoch (1970-2000) = 10957 days including 7 leap years
203        let (y, m, d) = days_to_ymd(10957);
204        assert_eq!(
205            (y, m, d),
206            (2000, 1, 1),
207            "10957 days = 2000-01-01, got {y}-{m}-{d}"
208        );
209
210        // 2000 is leap (366 days), so 10957 + 366 = 11323 → 2001-01-01
211        let (y2, m2, d2) = days_to_ymd(11323);
212        assert_eq!(
213            (y2, m2, d2),
214            (2001, 1, 1),
215            "11323 days = 2001-01-01, got {y2}-{m2}-{d2}"
216        );
217    }
218
219    // ── epoch_to_datetime ──────────────────────────────────────────
220
221    /// Objective: Verify epoch_to_datetime for a known value (2024-01-15T14:20:00Z).
222    #[test]
223    fn test_epoch_to_datetime_known() {
224        let dt = epoch_to_datetime(1705328400);
225        assert_eq!(
226            dt, "2024-01-15T14:20:00Z",
227            "1705328400 → 2024-01-15T14:20:00Z, got {dt}"
228        );
229    }
230
231    /// Objective: Verify epoch_to_datetime for the Unix epoch itself (0 seconds).
232    /// Invariants: 0 seconds since epoch → 1970-01-01T00:00:00Z.
233    #[test]
234    fn test_epoch_to_datetime_zero() {
235        let dt = epoch_to_datetime(0);
236        assert_eq!(dt, "1970-01-01T00:00:00Z", "0 seconds → epoch, got {dt}");
237    }
238
239    /// Objective: Verify epoch_to_datetime for a leap-year date (2024-02-29 12:00:00Z).
240    #[test]
241    fn test_epoch_to_datetime_leap_day() {
242        // 2024-02-29T12:00:00Z = 1709208000
243        let dt = epoch_to_datetime(1709208000);
244        assert!(dt.starts_with("2024-02-29T12:"), "leap day noon, got {dt}");
245    }
246
247    // ── load_last / load_since ─────────────────────────────────────
248
249    // ── Serialization ──────────────────────────────────────────────
250
251    /// Objective: Verify HistoryRecord round-trips through JSON without data loss.
252    /// Invariants: All fields are preserved after serde round-trip.
253    #[test]
254    fn test_history_record_serde() {
255        let record = HistoryRecord {
256            timestamp: "2024-01-15T14:30:00Z".to_string(),
257            project_path: "/tmp/test".to_string(),
258            overall_score: 72.5,
259            tools: vec![
260                ToolScore {
261                    name: "code-hunter".to_string(),
262                    score: 65.0,
263                    item_count: 45,
264                },
265                ToolScore {
266                    name: "commit-roaster".to_string(),
267                    score: 80.0,
268                    item_count: 12,
269                },
270            ],
271        };
272        let json = serde_json::to_string(&record).unwrap();
273        let parsed: HistoryRecord = serde_json::from_str(&json).unwrap();
274        assert_eq!(parsed.overall_score, 72.5, "score after round-trip");
275        assert_eq!(parsed.tools.len(), 2, "tool count after round-trip");
276        assert_eq!(
277            parsed.timestamp, "2024-01-15T14:30:00Z",
278            "timestamp preserved"
279        );
280        assert_eq!(parsed.project_path, "/tmp/test", "project path preserved");
281    }
282
283    /// Objective: Verify HistoryRecord with empty tools vector round-trips correctly.
284    #[test]
285    fn test_history_record_empty_tools() {
286        let record = HistoryRecord {
287            timestamp: "2024-06-01T00:00:00Z".to_string(),
288            project_path: "/tmp/empty".to_string(),
289            overall_score: 0.0,
290            tools: vec![],
291        };
292        let json = serde_json::to_string(&record).unwrap();
293        let parsed: HistoryRecord = serde_json::from_str(&json).unwrap();
294        assert_eq!(parsed.tools.len(), 0, "empty tools preserved");
295        assert_eq!(parsed.overall_score, 0.0, "score 0.0 preserved");
296    }
297
298    // ── now_timestamp ──────────────────────────────────────────────
299
300    /// Objective: Verify now_timestamp produces a valid ISO-like timestamp.
301    /// Invariants: Contains 'T' separator and ends with 'Z'.
302    #[test]
303    fn test_now_timestamp_format() {
304        let ts = now_timestamp();
305        assert!(ts.contains('T'), "timestamp must contain T separator: {ts}");
306        assert!(ts.ends_with('Z'), "timestamp must end with Z: {ts}");
307        assert_eq!(
308            ts.len(),
309            20,
310            "expected 20-char ISO timestamp, got len {}: {ts}",
311            ts.len()
312        );
313    }
314}