xbp 10.57.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Repository-wide step timing history under `.xbp/timings/` for release ETA.

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);
    }
}