1use std::{
2 collections::HashMap,
3 fs::{self, File, OpenOptions},
4 io::{BufReader, BufWriter},
5 path::PathBuf,
6 time::SystemTime,
7};
8
9use chrono::{DateTime, NaiveDateTime, Utc};
10use csv::{Reader, Writer};
11use serde::{Deserialize, Serialize};
12
13use crate::state::BuildReport;
14
15const HISTORY_LIMIT: usize = 10;
17
18pub struct BuildReportCache {
20 cache_path: PathBuf,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25struct BuildReportRow {
26 hostname: String,
27 derivation_name: String,
28 utc_time: String,
29 build_seconds: u64,
30}
31
32impl BuildReportCache {
33 #[must_use]
35 pub const fn new(cache_path: PathBuf) -> Self {
36 Self { cache_path }
37 }
38
39 #[must_use]
41 pub fn default_cache_path() -> PathBuf {
42 dirs::state_dir()
43 .unwrap_or_else(|| {
44 dirs::home_dir().unwrap_or_default().join(".local/state")
45 })
46 .join("rom")
47 .join("build-reports.csv")
48 }
49
50 #[must_use]
54 pub fn load(&self) -> HashMap<(String, String), Vec<BuildReport>> {
55 if !self.cache_path.exists() {
56 return HashMap::new();
57 }
58
59 let file = match File::open(&self.cache_path) {
60 Ok(f) => f,
61 Err(_) => return HashMap::new(),
62 };
63
64 let reader = BufReader::new(file);
65 let mut csv_reader = Reader::from_reader(reader);
66
67 let mut reports: HashMap<(String, String), Vec<BuildReport>> =
68 HashMap::new();
69
70 for result in csv_reader.deserialize() {
71 let row: BuildReportRow = match result {
72 Ok(r) => r,
73 Err(_) => continue,
74 };
75
76 let completed_at = match parse_utc_time(&row.utc_time) {
77 Some(t) => t,
78 None => continue,
79 };
80
81 let report = BuildReport {
82 derivation_name: row.derivation_name.clone(),
83 duration_secs: row.build_seconds as f64,
84 completed_at,
85 host: row.hostname.clone(),
86 success: true, platform: String::new(),
91 };
92
93 let key = (row.hostname, row.derivation_name);
94 reports.entry(key).or_default().push(report);
95 }
96
97 for entries in reports.values_mut() {
99 entries.sort_by(|a, b| b.completed_at.cmp(&a.completed_at));
100 entries.truncate(HISTORY_LIMIT);
101 }
102
103 reports
104 }
105
106 pub fn save(
110 &self,
111 reports: &HashMap<(String, String), Vec<BuildReport>>,
112 ) -> Result<(), std::io::Error> {
113 if let Some(parent) = self.cache_path.parent() {
115 fs::create_dir_all(parent)?;
116 }
117
118 let mut merged = self.load();
120
121 for ((host, drv_name), new_reports) in reports {
123 let key = (host.clone(), drv_name.clone());
124 let existing = merged.entry(key).or_default();
125
126 existing.extend(new_reports.iter().cloned());
128
129 existing.sort_by(|a, b| b.completed_at.cmp(&a.completed_at));
131
132 existing.truncate(HISTORY_LIMIT);
134 }
135
136 let tmp_path = self.cache_path.with_extension("csv.tmp");
139
140 let file = OpenOptions::new()
141 .write(true)
142 .create(true)
143 .truncate(true)
144 .open(&tmp_path)?;
145
146 let writer = BufWriter::new(file);
147 let mut csv_writer = Writer::from_writer(writer);
148
149 for ((hostname, derivation_name), entries) in merged {
151 for report in entries {
152 let row = BuildReportRow {
153 hostname: hostname.clone(),
154 derivation_name: derivation_name.clone(),
155 utc_time: format_utc_time(report.completed_at),
156 build_seconds: report.duration_secs as u64,
157 };
158 csv_writer.serialize(row)?;
159 }
160 }
161
162 csv_writer.flush()?;
163 drop(csv_writer);
164
165 fs::rename(&tmp_path, &self.cache_path)?;
167
168 Ok(())
169 }
170
171 #[must_use]
175 pub fn calculate_median(reports: &[BuildReport]) -> Option<u64> {
176 if reports.is_empty() {
177 return None;
178 }
179
180 let mut durations: Vec<u64> =
181 reports.iter().map(|r| r.duration_secs as u64).collect();
182 durations.sort_unstable();
183
184 let len = durations.len();
185 if len % 2 == 1 {
186 Some(durations[len / 2])
187 } else {
188 let mid1 = durations[len / 2 - 1];
189 let mid2 = durations[len / 2];
190 Some(u64::midpoint(mid1, mid2))
191 }
192 }
193
194 #[must_use]
196 pub fn get_estimate(
197 &self,
198 reports: &HashMap<(String, String), Vec<BuildReport>>,
199 host: &str,
200 derivation_name: &str,
201 ) -> Option<u64> {
202 let key = (host.to_string(), derivation_name.to_string());
203 let entries = reports.get(&key)?;
204 Self::calculate_median(entries)
205 }
206}
207
208pub fn parse_utc_time(s: &str) -> Option<SystemTime> {
209 let ndt = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").ok()?;
210 let dt: DateTime<Utc> = ndt.and_utc();
211 let secs = dt.timestamp();
212 if secs < 0 {
213 return None;
214 }
215 Some(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64))
216}
217
218pub fn format_utc_time(time: SystemTime) -> String {
219 let duration = time
220 .duration_since(SystemTime::UNIX_EPOCH)
221 .unwrap_or_default();
222 let dt = DateTime::<Utc>::from_timestamp(duration.as_secs() as i64, 0)
223 .unwrap_or_default();
224 dt.format("%Y-%m-%d %H:%M:%S").to_string()
225}