cuenv_events/renderers/
cli.rs

1//! CLI renderer for cuenv events.
2//!
3//! Renders events to stdout/stderr for terminal display.
4//! This module is allowed to use println!/eprintln! as it's the output layer.
5
6#![allow(clippy::print_stdout, clippy::print_stderr)]
7
8use crate::bus::EventReceiver;
9use crate::event::{
10    CiEvent, CommandEvent, CuenvEvent, EventCategory, InteractiveEvent, OutputEvent, Stream,
11    SystemEvent, TaskEvent,
12};
13use std::io::{self, IsTerminal, Write};
14
15/// CLI renderer configuration.
16#[derive(Debug, Clone)]
17pub struct CliRendererConfig {
18    /// Whether to use ANSI colors.
19    pub colors: bool,
20    /// Whether to show verbose output.
21    pub verbose: bool,
22}
23
24impl Default for CliRendererConfig {
25    fn default() -> Self {
26        Self {
27            colors: io::stdout().is_terminal(),
28            verbose: false,
29        }
30    }
31}
32
33/// CLI renderer that outputs events to stdout/stderr.
34#[derive(Debug)]
35pub struct CliRenderer {
36    config: CliRendererConfig,
37}
38
39impl CliRenderer {
40    /// Create a new CLI renderer with default configuration.
41    #[must_use]
42    pub fn new() -> Self {
43        Self {
44            config: CliRendererConfig::default(),
45        }
46    }
47
48    /// Create a new CLI renderer with the given configuration.
49    #[must_use]
50    pub fn with_config(config: CliRendererConfig) -> Self {
51        Self { config }
52    }
53
54    /// Run the renderer, consuming events from the receiver.
55    pub async fn run(self, mut receiver: EventReceiver) {
56        while let Some(event) = receiver.recv().await {
57            self.render(&event);
58        }
59    }
60
61    /// Render a single event.
62    pub fn render(&self, event: &CuenvEvent) {
63        match &event.category {
64            EventCategory::Task(task_event) => self.render_task(task_event),
65            EventCategory::Ci(ci_event) => self.render_ci(ci_event),
66            EventCategory::Command(cmd_event) => self.render_command(cmd_event),
67            EventCategory::Interactive(interactive_event) => {
68                self.render_interactive(interactive_event);
69            }
70            EventCategory::System(system_event) => self.render_system(system_event),
71            EventCategory::Output(output_event) => self.render_output(output_event),
72        }
73    }
74
75    fn render_task(&self, event: &TaskEvent) {
76        match event {
77            TaskEvent::Started {
78                name,
79                command,
80                hermetic,
81            } => {
82                let hermetic_indicator = if *hermetic { " (hermetic)" } else { "" };
83                eprintln!("> [{name}] {command}{hermetic_indicator}");
84            }
85            TaskEvent::CacheHit { name, .. } => {
86                eprintln!("> [{name}] (cached)");
87            }
88            TaskEvent::CacheMiss { name } => {
89                if self.config.verbose {
90                    eprintln!("> [{name}] cache miss, executing...");
91                }
92            }
93            TaskEvent::Output {
94                stream, content, ..
95            } => match stream {
96                Stream::Stdout => {
97                    print!("{content}");
98                    let _ = io::stdout().flush();
99                }
100                Stream::Stderr => {
101                    eprint!("{content}");
102                    let _ = io::stderr().flush();
103                }
104            },
105            TaskEvent::Completed {
106                name,
107                success,
108                duration_ms,
109                ..
110            } => {
111                if self.config.verbose {
112                    let status = if *success { "completed" } else { "failed" };
113                    eprintln!("> [{name}] {status} in {duration_ms}ms");
114                }
115            }
116            TaskEvent::GroupStarted {
117                name,
118                sequential,
119                task_count,
120            } => {
121                let mode = if *sequential {
122                    "sequential"
123                } else {
124                    "parallel"
125                };
126                eprintln!("> Running {mode} group: {name} ({task_count} tasks)");
127            }
128            TaskEvent::GroupCompleted {
129                name,
130                success,
131                duration_ms,
132            } => {
133                if self.config.verbose {
134                    let status = if *success { "completed" } else { "failed" };
135                    eprintln!("> Group {name} {status} in {duration_ms}ms");
136                }
137            }
138        }
139    }
140
141    fn render_ci(&self, event: &CiEvent) {
142        let _ = &self.config; // Silence unused_self - config may be used for CI rendering options later
143        match event {
144            CiEvent::ContextDetected {
145                provider,
146                event_type,
147                ref_name,
148            } => {
149                println!("Context: {provider} (event: {event_type}, ref: {ref_name})");
150            }
151            CiEvent::ChangedFilesFound { count } => {
152                println!("Changed files: {count}");
153            }
154            CiEvent::ProjectsDiscovered { count } => {
155                println!("Found {count} projects");
156            }
157            CiEvent::ProjectSkipped { path, reason } => {
158                println!("Project {path}: {reason}");
159            }
160            CiEvent::TaskExecuting { task, .. } => {
161                println!("  -> Executing {task}");
162            }
163            CiEvent::TaskResult {
164                task,
165                success,
166                error,
167                ..
168            } => {
169                if *success {
170                    println!("  -> {task} passed");
171                } else if let Some(err) = error {
172                    println!("  -> {task} failed: {err}");
173                } else {
174                    println!("  -> {task} failed");
175                }
176            }
177            CiEvent::ReportGenerated { path } => {
178                println!("Report written to: {path}");
179            }
180        }
181    }
182
183    fn render_command(&self, event: &CommandEvent) {
184        match event {
185            CommandEvent::Started { command, .. } => {
186                if self.config.verbose {
187                    eprintln!("Starting command: {command}");
188                }
189            }
190            CommandEvent::Progress {
191                progress, message, ..
192            } => {
193                if self.config.verbose {
194                    let pct = progress * 100.0;
195                    eprintln!("[{pct:.0}%] {message}");
196                }
197            }
198            CommandEvent::Completed {
199                command,
200                success,
201                duration_ms,
202            } => {
203                if self.config.verbose {
204                    let status = if *success { "completed" } else { "failed" };
205                    eprintln!("Command {command} {status} in {duration_ms}ms");
206                }
207            }
208        }
209    }
210
211    fn render_interactive(&self, event: &InteractiveEvent) {
212        let _ = &self.config; // Silence unused_self - config may be used for interactive rendering options later
213        match event {
214            InteractiveEvent::PromptRequested {
215                message, options, ..
216            } => {
217                println!("{message}");
218                for (i, option) in options.iter().enumerate() {
219                    println!("  [{i}] {option}");
220                }
221                print!("> ");
222                let _ = io::stdout().flush();
223            }
224            InteractiveEvent::PromptResolved { .. } => {
225                // Response handled elsewhere
226            }
227            InteractiveEvent::WaitProgress {
228                target,
229                elapsed_secs,
230            } => {
231                eprint!("\r\x1b[KWaiting for `{target}`... [{elapsed_secs}s]");
232                let _ = io::stderr().flush();
233            }
234        }
235    }
236
237    fn render_system(&self, event: &SystemEvent) {
238        match event {
239            SystemEvent::SupervisorLog { tag, message } => {
240                eprintln!("[{tag}] {message}");
241            }
242            SystemEvent::Shutdown => {
243                if self.config.verbose {
244                    eprintln!("System shutdown");
245                }
246            }
247        }
248    }
249
250    fn render_output(&self, event: &OutputEvent) {
251        let _ = &self.config; // Silence unused_self - config may be used for output rendering options later
252        match event {
253            OutputEvent::Stdout { content } => {
254                println!("{content}");
255            }
256            OutputEvent::Stderr { content } => {
257                eprintln!("{content}");
258            }
259        }
260    }
261}
262
263impl Default for CliRenderer {
264    fn default() -> Self {
265        Self::new()
266    }
267}