Skip to main content

rom_core/
cache.rs

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
15/// Maximum number of historical builds to keep per derivation
16const HISTORY_LIMIT: usize = 10;
17
18/// Build report cache for CSV persistence
19pub struct BuildReportCache {
20  cache_path: PathBuf,
21}
22
23/// CSV row format for build reports
24#[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  /// Create a new cache instance with the given path
34  #[must_use]
35  pub const fn new(cache_path: PathBuf) -> Self {
36    Self { cache_path }
37  }
38
39  /// Get the default cache file path
40  #[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  /// Load build reports from CSV
51  ///
52  /// Returns empty [`HashMap`] if file doesn't exist or parsing fails
53  #[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, // only successful builds are cached
87
88        // FIXME: not stored in CSV. This is for simplicity, and because I'm
89        // lazy
90        platform: String::new(),
91      };
92
93      let key = (row.hostname, row.derivation_name);
94      reports.entry(key).or_default().push(report);
95    }
96
97    // Sort each entry by timestamp (newest first) and limit to HISTORY_LIMIT
98    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  /// Save build reports to CSV
107  ///
108  /// Merges with existing reports and enforces history limit
109  pub fn save(
110    &self,
111    reports: &HashMap<(String, String), Vec<BuildReport>>,
112  ) -> Result<(), std::io::Error> {
113    // Ensure directory exists
114    if let Some(parent) = self.cache_path.parent() {
115      fs::create_dir_all(parent)?;
116    }
117
118    // Load existing reports to merge
119    let mut merged = self.load();
120
121    // Merge new reports
122    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      // Add new reports
127      existing.extend(new_reports.iter().cloned());
128
129      // Sort by timestamp (newest first)
130      existing.sort_by(|a, b| b.completed_at.cmp(&a.completed_at));
131
132      // Keep only most recent HISTORY_LIMIT entries
133      existing.truncate(HISTORY_LIMIT);
134    }
135
136    // Write to a temp file in the same directory, then rename atomically.
137    // This prevents a concurrent save() from corrupting the cache file.
138    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    // Flatten and write all reports
150    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    // Atomic replace
166    fs::rename(&tmp_path, &self.cache_path)?;
167
168    Ok(())
169  }
170
171  /// Calculate median build time from historical reports
172  ///
173  /// Returns [`None`] if there are no reports
174  #[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  /// Get median build time for a specific derivation on a host
195  #[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}