use core::fmt;
use std::borrow::Cow;
use std::sync::Arc;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ProgressToken {
Number(i64),
Text(Arc<str>),
}
impl ProgressToken {
#[must_use]
pub const fn number(value: i64) -> Self {
Self::Number(value)
}
#[must_use]
pub fn text(value: impl AsRef<str>) -> Self {
Self::Text(Arc::from(value.as_ref()))
}
#[must_use]
pub fn from_json(value: &Value) -> Option<Self> {
match value {
Value::String(text) => Some(Self::text(text)),
Value::Number(number) => number.as_i64().map(Self::number),
_ => None,
}
}
#[must_use]
pub fn to_json(&self) -> Cow<'_, str> {
match self {
Self::Number(value) => Cow::Owned(value.to_string()),
Self::Text(value) => {
serde_json::to_string(value.as_ref()).map_or(Cow::Borrowed("\"\""), Cow::Owned)
}
}
}
}
impl fmt::Display for ProgressToken {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Number(value) => write!(formatter, "{value}"),
Self::Text(value) => formatter.write_str(value),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProgressUpdate {
pub token: ProgressToken,
pub progress: f64,
pub total: Option<f64>,
pub message: Option<Cow<'static, str>>,
}
impl ProgressUpdate {
#[must_use]
pub const fn new(token: ProgressToken, progress: f64) -> Self {
Self {
token,
progress,
total: None,
message: None,
}
}
#[must_use]
pub const fn with_total(mut self, total: f64) -> Self {
self.total = Some(total);
self
}
#[must_use]
pub fn with_message(mut self, message: impl Into<Cow<'static, str>>) -> Self {
self.message = Some(message.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct ProgressCursor {
furthest: Option<f64>,
}
impl ProgressCursor {
#[must_use]
pub const fn new() -> Self {
Self { furthest: None }
}
#[must_use]
pub fn advance(&mut self, update: &ProgressUpdate) -> bool {
let progress = update.progress;
if !progress.is_finite() {
return false;
}
if let Some(furthest) = self.furthest
&& progress <= furthest
{
return false;
}
self.furthest = Some(progress);
true
}
#[must_use]
pub const fn furthest(&self) -> Option<f64> {
self.furthest
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::{ProgressCursor, ProgressToken, ProgressUpdate};
use crate::domain::RequestId;
fn token() -> ProgressToken {
ProgressToken::number(1)
}
#[test]
fn the_first_update_always_moves_the_cursor() {
let mut cursor = ProgressCursor::new();
assert!(cursor.advance(&ProgressUpdate::new(token(), 0.0)));
assert_eq!(cursor.furthest(), Some(0.0));
}
#[test]
fn a_repeated_or_going_backwards_update_is_not_emitted() {
let mut cursor = ProgressCursor::new();
for (value, expected) in [
(1.0, true), (2.0, true), (2.0, false), (1.5, false), (2.0, false), (3.0, true), ] {
let emitted = cursor.advance(&ProgressUpdate::new(token(), value));
assert_eq!(emitted, expected, "progress {value}");
}
assert_eq!(cursor.furthest(), Some(3.0));
}
#[test]
fn a_non_finite_progress_is_not_progress() {
let mut cursor = ProgressCursor::new();
assert!(!cursor.advance(&ProgressUpdate::new(token(), f64::NAN)));
assert!(!cursor.advance(&ProgressUpdate::new(token(), f64::INFINITY)));
assert_eq!(cursor.furthest(), None, "a refused value was recorded");
assert!(cursor.advance(&ProgressUpdate::new(token(), 1.0)));
}
#[test]
fn a_token_reads_out_of_every_shape_the_schema_allows() {
assert_eq!(
ProgressToken::from_json(&serde_json::json!("abc")),
Some(ProgressToken::text("abc"))
);
assert_eq!(
ProgressToken::from_json(&serde_json::json!(7)),
Some(ProgressToken::number(7))
);
}
#[test]
fn every_other_shape_is_not_a_token() {
for value in [
serde_json::json!(true),
serde_json::json!({ "a": 1 }),
serde_json::json!([1]),
serde_json::json!(null),
serde_json::json!(1.5),
] {
assert_eq!(ProgressToken::from_json(&value), None, "{value}");
}
}
#[test]
fn a_token_writes_back_as_the_json_it_arrived_as() {
assert_eq!(ProgressToken::number(7).to_json(), "7");
assert_eq!(ProgressToken::text("abc").to_json(), "\"abc\"");
}
#[test]
fn a_control_character_in_a_token_survives_into_json() {
let written = ProgressToken::text("a\u{7f}b").to_json().into_owned();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&written).ok(),
Some(serde_json::json!("a\u{7f}b")),
"the written document should be JSON carrying the same character: {written:?}"
);
}
#[test]
fn a_cloned_token_shares_its_allocation() {
let a = ProgressToken::text("shared");
let b = a.clone();
assert_matches::assert_matches!(
(&a, &b),
(ProgressToken::Text(left), ProgressToken::Text(right))
if Arc::ptr_eq(left, right),
"clone copied the string instead of sharing it"
);
let mut seen = std::collections::HashMap::new();
seen.insert(RequestId::text("req"), a);
assert_eq!(seen.len(), 1);
}
#[test]
fn total_and_message_are_optional_and_attach_once() {
let update = ProgressUpdate::new(token(), 3.0)
.with_total(10.0)
.with_message(String::from("reading"));
assert_eq!(update.total, Some(10.0));
assert_eq!(update.message.as_deref(), Some("reading"));
}
}