use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
const TIMINGS_REL: &str = ".xbp/timings/release-steps.json";
const MAX_SAMPLES: usize = 20;
const EMA_ALPHA: f64 = 0.3;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct ReleaseTimingsStore {
#[serde(default)]
pub schema: u32,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub steps: BTreeMap<String, StepTiming>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct StepTiming {
#[serde(default)]
pub samples: Vec<TimingSample>,
#[serde(default)]
pub ema_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct TimingSample {
pub at: String,
pub ms: u64,
pub ok: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub meta: BTreeMap<String, String>,
}
pub(crate) fn timings_path(project_root: &Path) -> PathBuf {
project_root.join(TIMINGS_REL)
}
pub(crate) fn load_release_timings(project_root: &Path) -> ReleaseTimingsStore {
let path = timings_path(project_root);
let Ok(content) = fs::read_to_string(path) else {
return ReleaseTimingsStore {
schema: 1,
..Default::default()
};
};
serde_json::from_str(&content).unwrap_or_else(|_| ReleaseTimingsStore {
schema: 1,
..Default::default()
})
}
pub(crate) fn save_release_timings(
project_root: &Path,
store: &ReleaseTimingsStore,
) -> Result<(), String> {
let path = timings_path(project_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
}
let mut store = store.clone();
store.schema = 1;
store.updated_at = Some(Utc::now().to_rfc3339());
let rendered = serde_json::to_string_pretty(&store)
.map_err(|e| format!("Failed to serialize release timings: {e}"))?;
fs::write(&path, format!("{rendered}\n"))
.map_err(|e| format!("Failed to write {}: {e}", path.display()))
}
pub(crate) fn record_step_timing(
store: &mut ReleaseTimingsStore,
step_id: &str,
elapsed: Duration,
ok: bool,
meta: BTreeMap<String, String>,
) {
let ms = elapsed.as_millis().min(u128::from(u64::MAX)) as u64;
let entry = store.steps.entry(step_id.to_string()).or_default();
entry.samples.push(TimingSample {
at: Utc::now().to_rfc3339(),
ms,
ok,
meta,
});
if entry.samples.len() > MAX_SAMPLES {
let drop = entry.samples.len() - MAX_SAMPLES;
entry.samples.drain(0..drop);
}
if ok {
entry.ema_ms = Some(match entry.ema_ms {
Some(prev) => {
let next = (EMA_ALPHA * ms as f64) + ((1.0 - EMA_ALPHA) * prev as f64);
next.round() as u64
}
None => ms,
});
}
}
pub(crate) fn estimate_remaining_ms(store: &ReleaseTimingsStore, remaining_step_ids: &[String]) -> u64 {
remaining_step_ids
.iter()
.map(|id| estimate_step_ms(store, id))
.sum()
}
pub(crate) fn estimate_step_ms(store: &ReleaseTimingsStore, step_id: &str) -> u64 {
if let Some(entry) = store.steps.get(step_id) {
if let Some(ema) = entry.ema_ms {
return ema;
}
if let Some(last) = entry.samples.iter().rev().find(|s| s.ok) {
return last.ms;
}
}
default_step_ms(step_id)
}
fn default_step_ms(step_id: &str) -> u64 {
if step_id.starts_with("dist:global") {
240_000
} else if step_id.starts_with("dist:") {
90_000
} else if step_id.starts_with("publish:crates:") {
120_000
} else if step_id.starts_with("publish:npm:") {
60_000
} else {
30_000
}
}
pub(crate) fn dist_step_id(mode: &str) -> String {
format!("dist:{}", mode.trim())
}
pub(crate) fn publish_step_id(kind: &str, package: &str) -> String {
format!("publish:{}:{}", kind.trim(), package.trim())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ema_moves_toward_new_samples() {
let mut store = ReleaseTimingsStore {
schema: 1,
..Default::default()
};
record_step_timing(
&mut store,
"dist:host",
Duration::from_millis(100_000),
true,
BTreeMap::new(),
);
assert_eq!(store.steps["dist:host"].ema_ms, Some(100_000));
record_step_timing(
&mut store,
"dist:host",
Duration::from_millis(200_000),
true,
BTreeMap::new(),
);
let ema = store.steps["dist:host"].ema_ms.unwrap();
assert!(ema > 100_000 && ema < 200_000);
}
#[test]
fn estimate_sums_remaining() {
let mut store = ReleaseTimingsStore::default();
record_step_timing(
&mut store,
"dist:host",
Duration::from_millis(10_000),
true,
BTreeMap::new(),
);
record_step_timing(
&mut store,
"dist:global",
Duration::from_millis(20_000),
true,
BTreeMap::new(),
);
let total = estimate_remaining_ms(
&store,
&["dist:host".into(), "dist:global".into()],
);
assert_eq!(total, 30_000);
}
}