garbage_code_hunter/trend/
history.rs1use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::PathBuf;
7
8#[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#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ToolScore {
20 pub name: String,
21 pub score: f64,
22 pub item_count: usize,
23}
24
25fn 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
37pub 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
47pub 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
75pub 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
82pub 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
91pub 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 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 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 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 #[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 #[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 #[test]
172 fn test_days_to_ymd_non_leap() {
173 let (y, m, d) = days_to_ymd(19358); 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); assert_eq!(
182 (y2, m2, d2),
183 (2023, 3, 1),
184 "19358+59 = 2023-03-01, got {y2}-{m2}-{d2}"
185 );
186 }
187
188 #[test]
190 fn test_days_to_ymd_year_2000() {
191 let (y, m, d) = days_to_ymd(10957); assert_eq!(
193 (y, m, d),
194 (2000, 1, 1),
195 "10957 days = 2000-01-01, got {y}-{m}-{d}"
196 );
197 }
198
199 #[test]
201 fn test_days_to_ymd_century_boundary() {
202 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 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 #[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 #[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 #[test]
241 fn test_epoch_to_datetime_leap_day() {
242 let dt = epoch_to_datetime(1709208000);
244 assert!(dt.starts_with("2024-02-29T12:"), "leap day noon, got {dt}");
245 }
246
247 #[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 #[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 #[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}