use std::{
sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
},
time::{Duration, SystemTime},
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpReport {
pub label: String,
pub detail: Option<String>,
pub elapsed_secs: u64,
}
impl OpReport {
pub fn describe(&self) -> String {
match &self.detail {
Some(detail) => {
format!("{} — {} ({}s)", self.label, detail, self.elapsed_secs)
}
None => format!("{} ({}s)", self.label, self.elapsed_secs),
}
}
}
struct Op {
id: u64,
label: String,
detail: Option<String>,
started_at: SystemTime,
owner: Option<String>,
}
#[derive(Clone, Default)]
pub struct OpSlot {
inner: Arc<Mutex<Option<Op>>>,
next: Arc<AtomicU64>,
}
pub struct OpGuard {
slot: OpSlot,
id: u64,
}
impl Drop for OpGuard {
fn drop(&mut self) {
self.slot.clear_if(self.id);
}
}
impl OpSlot {
pub fn new() -> Self {
Self::default()
}
pub fn begin(&self, label: impl Into<String>) -> u64 {
let id = self.next.fetch_add(1, Ordering::Relaxed) + 1;
if let Ok(mut guard) = self.inner.lock()
&& guard.as_ref().is_none_or(|op| op.id < id)
{
*guard = Some(Op {
id,
label: label.into(),
detail: None,
started_at: SystemTime::now(),
owner: None,
});
}
id
}
pub fn guard(&self, label: impl Into<String>) -> OpGuard {
let id = self.begin(label);
OpGuard {
slot: self.clone(),
id,
}
}
pub fn detail(&self, detail: impl Into<String>) {
if let Ok(mut guard) = self.inner.lock()
&& let Some(op) = guard.as_mut()
{
op.detail = Some(detail.into());
op.owner = None;
}
}
pub fn detail_for(&self, owner: &str, detail: impl Into<String>) {
if let Ok(mut guard) = self.inner.lock()
&& let Some(op) = guard.as_mut()
&& op.label.contains(owner)
{
op.detail = Some(detail.into());
op.owner = Some(owner.to_string());
}
}
pub fn clear(&self) {
if let Ok(mut guard) = self.inner.lock() {
*guard = None;
}
}
pub fn clear_if(&self, id: u64) {
if let Ok(mut guard) = self.inner.lock()
&& guard.as_ref().is_some_and(|op| op.id == id)
{
*guard = None;
}
}
pub fn report(&self) -> Option<OpReport> {
let guard = self.inner.lock().ok()?;
let op = guard.as_ref()?;
let elapsed = op.started_at.elapsed().unwrap_or(Duration::ZERO).as_secs();
Some(OpReport {
label: op.label.clone(),
detail: op.detail.clone(),
elapsed_secs: elapsed,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_slot_reports_nothing() {
assert!(OpSlot::new().report().is_none());
}
#[test]
fn begin_then_detail_is_reported() {
let slot = OpSlot::new();
slot.begin("starting proj");
slot.detail("waiting on dep");
let report = slot.report().expect("report present");
assert_eq!(report.label, "starting proj");
assert_eq!(report.detail.as_deref(), Some("waiting on dep"));
assert!(report.describe().contains("waiting on dep"));
}
#[test]
fn detail_for_ignores_a_foreign_owner() {
let slot = OpSlot::new();
slot.begin("starting project 'alpha'");
slot.detail_for("beta", "waiting on beta's dependency");
let report = slot.report().expect("report present");
assert_eq!(report.detail, None, "beta's detail leaked into alpha's op");
}
#[test]
fn detail_for_accepts_the_matching_owner() {
let slot = OpSlot::new();
slot.begin("starting project 'alpha'");
slot.detail_for("alpha", "waiting on dependency 'db'");
let report = slot.report().expect("report present");
assert_eq!(report.detail.as_deref(), Some("waiting on dependency 'db'"));
}
#[test]
fn clear_empties_the_slot() {
let slot = OpSlot::new();
slot.begin("work");
slot.clear();
assert!(slot.report().is_none());
}
#[test]
fn begin_resets_detail() {
let slot = OpSlot::new();
slot.begin("first");
slot.detail("phase one");
slot.begin("second");
let report = slot.report().expect("report present");
assert_eq!(report.label, "second");
assert!(report.detail.is_none());
}
}