Skip to main content

cuenv_ci/report/
progress.rs

1//! Live Progress Reporter Trait
2//!
3//! Defines the interface for reporting pipeline execution progress in real-time.
4//! Implementations can target terminals, GitHub Check Runs, or other backends.
5
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use std::time::Duration;
9
10use super::{PipelineReport, TaskStatus};
11
12/// Status of a task during live execution (extends TaskStatus with Running state).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum LiveTaskStatus {
16    /// Task is waiting for dependencies.
17    Pending,
18    /// Task is currently executing.
19    Running,
20    /// Task completed successfully.
21    Success,
22    /// Task failed.
23    Failed,
24    /// Task was restored from cache.
25    Cached,
26    /// Task was skipped.
27    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    /// Get an icon representing this status.
43    #[must_use]
44    pub const fn icon(&self) -> &'static str {
45        match self {
46            Self::Pending => "\u{23f3}", // hourglass
47            Self::Running => "\u{2699}", // gear
48            Self::Success => "\u{2705}", // check mark
49            Self::Failed => "\u{274c}",  // x
50            Self::Cached => "\u{26a1}",  // lightning bolt
51            Self::Skipped => "\u{23ed}", // skip forward
52        }
53    }
54
55    /// Check if this is a terminal state.
56    #[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/// Live progress information for a single task.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct LiveTaskProgress {
68    /// Task identifier.
69    pub id: String,
70    /// Human-readable task name.
71    pub name: String,
72    /// Current status.
73    pub status: LiveTaskStatus,
74    /// Execution duration (if started).
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub duration: Option<Duration>,
77    /// Error message (if failed).
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub error: Option<String>,
80}
81
82impl LiveTaskProgress {
83    /// Create a new pending task progress.
84    #[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    /// Mark task as running.
96    #[must_use]
97    pub fn running(mut self) -> Self {
98        self.status = LiveTaskStatus::Running;
99        self
100    }
101
102    /// Mark task as completed with duration.
103    #[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    /// Mark task as cached.
115    #[must_use]
116    pub fn cached(mut self) -> Self {
117        self.status = LiveTaskStatus::Cached;
118        self
119    }
120
121    /// Mark task as failed with error message.
122    #[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/// Live progress of a pipeline execution.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct LivePipelineProgress {
134    /// Pipeline name.
135    pub name: String,
136    /// Total number of tasks.
137    pub total_tasks: usize,
138    /// Number of completed tasks (success or failure).
139    pub completed_tasks: usize,
140    /// Number of cached tasks.
141    pub cached_tasks: usize,
142    /// Current task statuses.
143    pub tasks: Vec<LiveTaskProgress>,
144}
145
146impl LivePipelineProgress {
147    /// Create a new pipeline progress tracker.
148    #[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    /// Calculate completion percentage.
160    #[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/// Trait for reporting pipeline execution progress in real-time.
175///
176/// Implementations can target different output backends:
177/// - Terminal (progress bars, spinners)
178/// - GitHub Check Runs (live status updates)
179/// - JSON output (for CI integration)
180#[async_trait]
181pub trait ProgressReporter: Send + Sync {
182    /// Called when a pipeline starts execution.
183    async fn pipeline_started(&self, name: &str, task_count: usize);
184
185    /// Called when a task starts executing.
186    async fn task_started(&self, task_id: &str, task_name: &str);
187
188    /// Called when a task completes (success or failure).
189    async fn task_completed(&self, progress: &LiveTaskProgress);
190
191    /// Called when a task is restored from cache.
192    async fn task_cached(&self, task_id: &str, task_name: &str);
193
194    /// Called periodically for long-running tasks.
195    async fn task_progress(&self, task_id: &str, message: &str);
196
197    /// Called when the pipeline completes.
198    async fn pipeline_completed(&self, report: &PipelineReport);
199}
200
201/// No-op reporter for when progress reporting is disabled.
202#[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        // All methods should succeed without error
338        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}