1use std::convert::Infallible;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{OperationId, OperationName, Timestamp};
6
7const MAX_PHASE_CHARS: usize = 128;
8const MAX_UNIT_CHARS: usize = 64;
9const MAX_MESSAGE_CHARS: usize = 1_024;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14pub struct ProgressEvent {
15 operation_id: OperationId,
16 operation: OperationName,
17 sequence: u64,
18 occurred_at: Timestamp,
19 phase: String,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 current: Option<u64>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 total: Option<u64>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 unit: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 message: Option<String>,
28}
29
30impl ProgressEvent {
31 pub fn new(
33 operation_id: OperationId,
34 operation: OperationName,
35 sequence: u64,
36 occurred_at: Timestamp,
37 phase: impl Into<String>,
38 ) -> Result<Self, ProgressError> {
39 if sequence == 0 {
40 return Err(ProgressError::ZeroSequence);
41 }
42 let phase = phase.into();
43 validate_text("phase", &phase, MAX_PHASE_CHARS)?;
44 Ok(Self {
45 operation_id,
46 operation,
47 sequence,
48 occurred_at,
49 phase,
50 current: None,
51 total: None,
52 unit: None,
53 message: None,
54 })
55 }
56
57 pub fn with_amount(
59 mut self,
60 current: u64,
61 total: Option<u64>,
62 unit: Option<impl Into<String>>,
63 ) -> Result<Self, ProgressError> {
64 if total.is_some_and(|total| total == 0 || current > total) {
65 return Err(ProgressError::InvalidAmount { current, total });
66 }
67 let unit = unit.map(Into::into);
68 if let Some(unit) = &unit {
69 validate_text("unit", unit, MAX_UNIT_CHARS)?;
70 }
71 self.current = Some(current);
72 self.total = total;
73 self.unit = unit;
74 Ok(self)
75 }
76
77 pub fn with_message(mut self, message: impl Into<String>) -> Result<Self, ProgressError> {
79 let message = message.into();
80 validate_text("message", &message, MAX_MESSAGE_CHARS)?;
81 self.message = Some(message);
82 Ok(self)
83 }
84
85 #[must_use]
87 pub fn operation_id(&self) -> &OperationId {
88 &self.operation_id
89 }
90
91 #[must_use]
93 pub fn operation(&self) -> &OperationName {
94 &self.operation
95 }
96
97 #[must_use]
99 pub const fn sequence(&self) -> u64 {
100 self.sequence
101 }
102
103 #[must_use]
105 pub const fn occurred_at(&self) -> Timestamp {
106 self.occurred_at
107 }
108
109 #[must_use]
111 pub fn phase(&self) -> &str {
112 &self.phase
113 }
114
115 #[must_use]
117 pub const fn current(&self) -> Option<u64> {
118 self.current
119 }
120
121 #[must_use]
123 pub const fn total(&self) -> Option<u64> {
124 self.total
125 }
126
127 #[must_use]
129 pub fn unit(&self) -> Option<&str> {
130 self.unit.as_deref()
131 }
132
133 #[must_use]
135 pub fn message(&self) -> Option<&str> {
136 self.message.as_deref()
137 }
138}
139
140pub trait ProgressSink: Send + Sync {
142 type Error;
144
145 fn report(&self, event: &ProgressEvent) -> Result<(), Self::Error>;
147}
148
149#[derive(Debug, Clone, Copy, Default)]
151pub struct NoopProgressSink;
152
153impl ProgressSink for NoopProgressSink {
154 type Error = Infallible;
155
156 fn report(&self, _event: &ProgressEvent) -> Result<(), Self::Error> {
157 Ok(())
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
163#[non_exhaustive]
164pub enum ProgressError {
165 #[error("progress sequence must be greater than zero")]
167 ZeroSequence,
168 #[error("invalid progress amount: current={current}, total={total:?}")]
170 InvalidAmount {
171 current: u64,
173 total: Option<u64>,
175 },
176 #[error("invalid progress {field}: expected 1..={max_chars} non-control characters")]
178 InvalidText {
179 field: &'static str,
181 max_chars: usize,
183 },
184}
185
186fn validate_text(field: &'static str, value: &str, max_chars: usize) -> Result<(), ProgressError> {
187 let chars = value.chars().count();
188 if chars == 0 || chars > max_chars || value.chars().any(char::is_control) {
189 return Err(ProgressError::InvalidText { field, max_chars });
190 }
191 Ok(())
192}
193
194#[cfg(test)]
195#[path = "progress_tests.rs"]
196mod tests;