1use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use std::time::Duration;
9
10use super::{PipelineReport, TaskStatus};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum LiveTaskStatus {
16 Pending,
18 Running,
20 Success,
22 Failed,
24 Cached,
26 Skipped,
28}
29
30impl From<TaskStatus> for LiveTaskStatus {
31 fn from(status: TaskStatus) -> Self {
32 match status {
33 TaskStatus::Success => Self::Success,
34 TaskStatus::Failed => Self::Failed,
35 TaskStatus::Cached => Self::Cached,
36 TaskStatus::Skipped => Self::Skipped,
37 }
38 }
39}
40
41impl LiveTaskStatus {
42 #[must_use]
44 pub const fn icon(&self) -> &'static str {
45 match self {
46 Self::Pending => "\u{23f3}", Self::Running => "\u{2699}", Self::Success => "\u{2705}", Self::Failed => "\u{274c}", Self::Cached => "\u{26a1}", Self::Skipped => "\u{23ed}", }
53 }
54
55 #[must_use]
57 pub const fn is_terminal(&self) -> bool {
58 matches!(
59 self,
60 Self::Success | Self::Failed | Self::Cached | Self::Skipped
61 )
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct LiveTaskProgress {
68 pub id: String,
70 pub name: String,
72 pub status: LiveTaskStatus,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub duration: Option<Duration>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub error: Option<String>,
80}
81
82impl LiveTaskProgress {
83 #[must_use]
85 pub fn pending(id: impl Into<String>, name: impl Into<String>) -> Self {
86 Self {
87 id: id.into(),
88 name: name.into(),
89 status: LiveTaskStatus::Pending,
90 duration: None,
91 error: None,
92 }
93 }
94
95 #[must_use]
97 pub fn running(mut self) -> Self {
98 self.status = LiveTaskStatus::Running;
99 self
100 }
101
102 #[must_use]
104 pub fn completed(mut self, success: bool, duration: Duration) -> Self {
105 self.status = if success {
106 LiveTaskStatus::Success
107 } else {
108 LiveTaskStatus::Failed
109 };
110 self.duration = Some(duration);
111 self
112 }
113
114 #[must_use]
116 pub fn cached(mut self) -> Self {
117 self.status = LiveTaskStatus::Cached;
118 self
119 }
120
121 #[must_use]
123 pub fn failed(mut self, error: impl Into<String>, duration: Duration) -> Self {
124 self.status = LiveTaskStatus::Failed;
125 self.duration = Some(duration);
126 self.error = Some(error.into());
127 self
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct LivePipelineProgress {
134 pub name: String,
136 pub total_tasks: usize,
138 pub completed_tasks: usize,
140 pub cached_tasks: usize,
142 pub tasks: Vec<LiveTaskProgress>,
144}
145
146impl LivePipelineProgress {
147 #[must_use]
149 pub fn new(name: impl Into<String>, task_count: usize) -> Self {
150 Self {
151 name: name.into(),
152 total_tasks: task_count,
153 completed_tasks: 0,
154 cached_tasks: 0,
155 tasks: Vec::with_capacity(task_count),
156 }
157 }
158
159 #[must_use]
161 pub fn percentage(&self) -> f32 {
162 if self.total_tasks == 0 {
163 100.0
164 } else {
165 #[allow(clippy::cast_precision_loss)]
166 let completed = self.completed_tasks as f32;
167 #[allow(clippy::cast_precision_loss)]
168 let total = self.total_tasks as f32;
169 (completed / total) * 100.0
170 }
171 }
172}
173
174#[async_trait]
181pub trait ProgressReporter: Send + Sync {
182 async fn pipeline_started(&self, name: &str, task_count: usize);
184
185 async fn task_started(&self, task_id: &str, task_name: &str);
187
188 async fn task_completed(&self, progress: &LiveTaskProgress);
190
191 async fn task_cached(&self, task_id: &str, task_name: &str);
193
194 async fn task_progress(&self, task_id: &str, message: &str);
196
197 async fn pipeline_completed(&self, report: &PipelineReport);
199}
200
201#[derive(Debug, Default)]
203pub struct NoOpReporter;
204
205#[async_trait]
206impl ProgressReporter for NoOpReporter {
207 async fn pipeline_started(&self, _name: &str, _task_count: usize) {}
208 async fn task_started(&self, _task_id: &str, _task_name: &str) {}
209 async fn task_completed(&self, _progress: &LiveTaskProgress) {}
210 async fn task_cached(&self, _task_id: &str, _task_name: &str) {}
211 async fn task_progress(&self, _task_id: &str, _message: &str) {}
212 async fn pipeline_completed(&self, _report: &PipelineReport) {}
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn test_live_task_status_icon() {
221 assert_eq!(LiveTaskStatus::Pending.icon(), "\u{23f3}");
222 assert_eq!(LiveTaskStatus::Running.icon(), "\u{2699}");
223 assert_eq!(LiveTaskStatus::Success.icon(), "\u{2705}");
224 assert_eq!(LiveTaskStatus::Failed.icon(), "\u{274c}");
225 assert_eq!(LiveTaskStatus::Cached.icon(), "\u{26a1}");
226 assert_eq!(LiveTaskStatus::Skipped.icon(), "\u{23ed}");
227 }
228
229 #[test]
230 fn test_live_task_status_is_terminal() {
231 assert!(!LiveTaskStatus::Pending.is_terminal());
232 assert!(!LiveTaskStatus::Running.is_terminal());
233 assert!(LiveTaskStatus::Success.is_terminal());
234 assert!(LiveTaskStatus::Failed.is_terminal());
235 assert!(LiveTaskStatus::Cached.is_terminal());
236 assert!(LiveTaskStatus::Skipped.is_terminal());
237 }
238
239 #[test]
240 fn test_live_task_status_from_task_status() {
241 assert_eq!(
242 LiveTaskStatus::from(TaskStatus::Success),
243 LiveTaskStatus::Success
244 );
245 assert_eq!(
246 LiveTaskStatus::from(TaskStatus::Failed),
247 LiveTaskStatus::Failed
248 );
249 assert_eq!(
250 LiveTaskStatus::from(TaskStatus::Cached),
251 LiveTaskStatus::Cached
252 );
253 assert_eq!(
254 LiveTaskStatus::from(TaskStatus::Skipped),
255 LiveTaskStatus::Skipped
256 );
257 }
258
259 #[test]
260 fn test_live_task_progress_pending() {
261 let progress = LiveTaskProgress::pending("build", "Build project");
262 assert_eq!(progress.id, "build");
263 assert_eq!(progress.name, "Build project");
264 assert_eq!(progress.status, LiveTaskStatus::Pending);
265 assert!(progress.duration.is_none());
266 assert!(progress.error.is_none());
267 }
268
269 #[test]
270 fn test_live_task_progress_running() {
271 let progress = LiveTaskProgress::pending("build", "Build project").running();
272 assert_eq!(progress.status, LiveTaskStatus::Running);
273 }
274
275 #[test]
276 fn test_live_task_progress_completed_success() {
277 let progress = LiveTaskProgress::pending("build", "Build project")
278 .completed(true, Duration::from_secs(5));
279 assert_eq!(progress.status, LiveTaskStatus::Success);
280 assert_eq!(progress.duration, Some(Duration::from_secs(5)));
281 }
282
283 #[test]
284 fn test_live_task_progress_completed_failure() {
285 let progress = LiveTaskProgress::pending("build", "Build project")
286 .completed(false, Duration::from_secs(3));
287 assert_eq!(progress.status, LiveTaskStatus::Failed);
288 }
289
290 #[test]
291 fn test_live_task_progress_cached() {
292 let progress = LiveTaskProgress::pending("build", "Build project").cached();
293 assert_eq!(progress.status, LiveTaskStatus::Cached);
294 }
295
296 #[test]
297 fn test_live_task_progress_failed_with_error() {
298 let progress = LiveTaskProgress::pending("build", "Build project")
299 .failed("Compilation error", Duration::from_secs(2));
300 assert_eq!(progress.status, LiveTaskStatus::Failed);
301 assert_eq!(progress.error, Some("Compilation error".to_string()));
302 assert_eq!(progress.duration, Some(Duration::from_secs(2)));
303 }
304
305 #[test]
306 fn test_live_pipeline_progress_new() {
307 let progress = LivePipelineProgress::new("default", 10);
308 assert_eq!(progress.name, "default");
309 assert_eq!(progress.total_tasks, 10);
310 assert_eq!(progress.completed_tasks, 0);
311 assert_eq!(progress.cached_tasks, 0);
312 assert!(progress.tasks.is_empty());
313 }
314
315 #[test]
316 fn test_live_pipeline_progress_percentage() {
317 let mut progress = LivePipelineProgress::new("default", 10);
318 assert!((progress.percentage() - 0.0).abs() < f32::EPSILON);
319
320 progress.completed_tasks = 5;
321 assert!((progress.percentage() - 50.0).abs() < f32::EPSILON);
322
323 progress.completed_tasks = 10;
324 assert!((progress.percentage() - 100.0).abs() < f32::EPSILON);
325 }
326
327 #[test]
328 fn test_live_pipeline_progress_percentage_empty() {
329 let progress = LivePipelineProgress::new("default", 0);
330 assert!((progress.percentage() - 100.0).abs() < f32::EPSILON);
331 }
332
333 #[tokio::test]
334 async fn test_noop_reporter() {
335 let reporter = NoOpReporter;
336
337 reporter.pipeline_started("test", 5).await;
339 reporter.task_started("t1", "Task 1").await;
340 reporter.task_cached("t1", "Task 1").await;
341 reporter.task_progress("t1", "Working...").await;
342
343 let progress =
344 LiveTaskProgress::pending("t1", "Task 1").completed(true, Duration::from_secs(1));
345 reporter.task_completed(&progress).await;
346 }
347}