Skip to main content

cuenv_ci/report/
terminal.rs

1//! Terminal Progress Reporter
2//!
3//! Reports pipeline progress to the terminal with formatted output.
4//! Uses tracing for output to integrate with the event system.
5
6use async_trait::async_trait;
7use std::sync::RwLock;
8
9use super::{
10    PipelineReport,
11    progress::{LivePipelineProgress, LiveTaskProgress, LiveTaskStatus, ProgressReporter},
12};
13
14/// Terminal-based progress reporter.
15///
16/// Outputs task progress to the terminal via tracing macros.
17/// Thread-safe for concurrent task execution.
18pub struct TerminalReporter {
19    /// Current pipeline progress state.
20    progress: RwLock<Option<LivePipelineProgress>>,
21    /// Whether to use verbose output.
22    verbose: bool,
23}
24
25impl TerminalReporter {
26    /// Create a new terminal reporter.
27    #[must_use]
28    pub fn new() -> Self {
29        Self {
30            progress: RwLock::new(None),
31            verbose: false,
32        }
33    }
34
35    /// Create a verbose terminal reporter.
36    #[must_use]
37    pub fn verbose() -> Self {
38        Self {
39            progress: RwLock::new(None),
40            verbose: true,
41        }
42    }
43
44    /// Format a task status line.
45    fn format_task_line(progress: &LiveTaskProgress) -> String {
46        let icon = progress.status.icon();
47        let duration = progress
48            .duration
49            .map(|d| format!(" ({:.2}s)", d.as_secs_f64()))
50            .unwrap_or_default();
51
52        format!("{} {}{}", icon, progress.name, duration)
53    }
54}
55
56impl Default for TerminalReporter {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62#[async_trait]
63impl ProgressReporter for TerminalReporter {
64    async fn pipeline_started(&self, name: &str, task_count: usize) {
65        let progress = LivePipelineProgress::new(name, task_count);
66        if let Ok(mut guard) = self.progress.write() {
67            *guard = Some(progress);
68        }
69
70        tracing::info!(pipeline = name, tasks = task_count, "Starting CI pipeline");
71    }
72
73    async fn task_started(&self, task_id: &str, task_name: &str) {
74        if let Ok(mut guard) = self.progress.write()
75            && let Some(ref mut progress) = *guard
76        {
77            let task = LiveTaskProgress::pending(task_id, task_name).running();
78            progress.tasks.push(task);
79        }
80
81        if self.verbose {
82            tracing::info!(task = task_id, "Starting task: {}", task_name);
83        }
84    }
85
86    async fn task_completed(&self, task_progress: &LiveTaskProgress) {
87        if let Ok(mut guard) = self.progress.write()
88            && let Some(ref mut progress) = *guard
89        {
90            progress.completed_tasks += 1;
91            if task_progress.status == LiveTaskStatus::Cached {
92                progress.cached_tasks += 1;
93            }
94
95            // Update the task in our list
96            if let Some(task) = progress.tasks.iter_mut().find(|t| t.id == task_progress.id) {
97                *task = task_progress.clone();
98            }
99        }
100
101        let line = Self::format_task_line(task_progress);
102        match task_progress.status {
103            LiveTaskStatus::Success => {
104                tracing::info!(task = %task_progress.id, "{}", line);
105            }
106            LiveTaskStatus::Failed => {
107                if let Some(ref error) = task_progress.error {
108                    tracing::error!(task = %task_progress.id, error = %error, "{}", line);
109                } else {
110                    tracing::error!(task = %task_progress.id, "{}", line);
111                }
112            }
113            LiveTaskStatus::Cached => {
114                tracing::info!(task = %task_progress.id, "{} (cached)", line);
115            }
116            _ => {
117                tracing::info!(task = %task_progress.id, "{}", line);
118            }
119        }
120    }
121
122    async fn task_cached(&self, task_id: &str, task_name: &str) {
123        if let Ok(mut guard) = self.progress.write()
124            && let Some(ref mut progress) = *guard
125        {
126            progress.completed_tasks += 1;
127            progress.cached_tasks += 1;
128
129            let task = LiveTaskProgress::pending(task_id, task_name).cached();
130            progress.tasks.push(task);
131        }
132
133        tracing::info!(
134            task = task_id,
135            "{} {} (cached)",
136            LiveTaskStatus::Cached.icon(),
137            task_name
138        );
139    }
140
141    async fn task_progress(&self, task_id: &str, message: &str) {
142        if self.verbose {
143            tracing::debug!(task = task_id, "{}", message);
144        }
145    }
146
147    #[allow(clippy::cast_precision_loss)] // u64 ms to f64 secs is fine for display
148    async fn pipeline_completed(&self, report: &PipelineReport) {
149        let total = report.tasks.len();
150        let failed = report
151            .tasks
152            .iter()
153            .filter(|t| t.status == super::TaskStatus::Failed)
154            .count();
155        let cached = report.cache_hits();
156        let duration_secs = report.duration_ms.map_or(0.0, |ms| ms as f64 / 1000.0);
157
158        if report.status == super::PipelineStatus::Success {
159            tracing::info!(
160                pipeline = %report.pipeline,
161                total = total,
162                cached = cached,
163                duration = format!("{:.2}s", duration_secs),
164                "Pipeline completed successfully"
165            );
166        } else {
167            tracing::error!(
168                pipeline = %report.pipeline,
169                total = total,
170                failed = failed,
171                duration = format!("{:.2}s", duration_secs),
172                "Pipeline failed"
173            );
174        }
175    }
176}
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used)] // unwrap is fine in tests
180mod tests {
181    use super::*;
182    use std::time::Duration;
183
184    #[test]
185    fn test_terminal_reporter_new() {
186        let reporter = TerminalReporter::new();
187        assert!(!reporter.verbose);
188    }
189
190    #[test]
191    fn test_terminal_reporter_verbose() {
192        let reporter = TerminalReporter::verbose();
193        assert!(reporter.verbose);
194    }
195
196    #[test]
197    fn test_terminal_reporter_default() {
198        let reporter = TerminalReporter::default();
199        assert!(!reporter.verbose);
200    }
201
202    #[test]
203    fn test_format_task_line_success() {
204        let progress = LiveTaskProgress::pending("build", "Build project")
205            .completed(true, Duration::from_secs(5));
206        let line = TerminalReporter::format_task_line(&progress);
207        assert!(line.contains("Build project"));
208        assert!(line.contains("5.00s"));
209    }
210
211    #[test]
212    fn test_format_task_line_no_duration() {
213        let progress = LiveTaskProgress::pending("build", "Build project");
214        let line = TerminalReporter::format_task_line(&progress);
215        assert!(line.contains("Build project"));
216        assert!(!line.contains("s)")); // No duration
217    }
218
219    #[tokio::test]
220    async fn test_terminal_reporter_pipeline_lifecycle() {
221        let reporter = TerminalReporter::new();
222
223        reporter.pipeline_started("test", 2).await;
224
225        {
226            let guard = reporter.progress.read().unwrap();
227            let progress = guard.as_ref().unwrap();
228            assert_eq!(progress.name, "test");
229            assert_eq!(progress.total_tasks, 2);
230        }
231
232        reporter.task_started("t1", "Task 1").await;
233
234        {
235            let guard = reporter.progress.read().unwrap();
236            let progress = guard.as_ref().unwrap();
237            assert_eq!(progress.tasks.len(), 1);
238            assert_eq!(progress.tasks[0].status, LiveTaskStatus::Running);
239        }
240
241        let task =
242            LiveTaskProgress::pending("t1", "Task 1").completed(true, Duration::from_secs(1));
243        reporter.task_completed(&task).await;
244
245        {
246            let guard = reporter.progress.read().unwrap();
247            let progress = guard.as_ref().unwrap();
248            assert_eq!(progress.completed_tasks, 1);
249        }
250    }
251
252    #[tokio::test]
253    async fn test_terminal_reporter_cached_task() {
254        let reporter = TerminalReporter::new();
255
256        reporter.pipeline_started("test", 1).await;
257        reporter.task_cached("t1", "Task 1").await;
258
259        {
260            let guard = reporter.progress.read().unwrap();
261            let progress = guard.as_ref().unwrap();
262            assert_eq!(progress.completed_tasks, 1);
263            assert_eq!(progress.cached_tasks, 1);
264        }
265    }
266}