Skip to main content

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, clippy::too_many_lines)]
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 const fn with_config(config: CliRendererConfig) -> Self {
51        Self { config }
52    }
53
54    /// Run the renderer, consuming events from the receiver.
55    ///
56    /// The renderer will exit gracefully when it receives a `SystemEvent::Shutdown` event,
57    /// ensuring all pending events are processed before termination.
58    pub async fn run(self, mut receiver: EventReceiver) {
59        while let Some(event) = receiver.recv().await {
60            self.render(&event);
61            // Exit after rendering shutdown event
62            if matches!(event.category, EventCategory::System(SystemEvent::Shutdown)) {
63                break;
64            }
65        }
66    }
67
68    /// Render a single event.
69    pub fn render(&self, event: &CuenvEvent) {
70        match &event.category {
71            EventCategory::Task(task_event) => self.render_task(task_event),
72            EventCategory::Ci(ci_event) => self.render_ci(ci_event),
73            EventCategory::Command(cmd_event) => self.render_command(cmd_event),
74            EventCategory::Interactive(interactive_event) => {
75                self.render_interactive(interactive_event);
76            }
77            EventCategory::System(system_event) => self.render_system(system_event),
78            EventCategory::Output(output_event) => self.render_output(output_event),
79        }
80    }
81
82    fn render_task(&self, event: &TaskEvent) {
83        match event {
84            TaskEvent::Started {
85                name,
86                command,
87                hermetic,
88            } => {
89                let hermetic_indicator = if *hermetic { " (hermetic)" } else { "" };
90                eprintln!("> [{name}] {command}{hermetic_indicator}");
91            }
92            TaskEvent::CacheHit { name, .. } => {
93                eprintln!("> [{name}] (cached)");
94            }
95            TaskEvent::CacheMiss { name } => {
96                if self.config.verbose {
97                    eprintln!("> [{name}] cache miss, executing...");
98                }
99            }
100            TaskEvent::Output {
101                stream, content, ..
102            } => match stream {
103                Stream::Stdout => {
104                    println!("{content}");
105                }
106                Stream::Stderr => {
107                    eprintln!("{content}");
108                }
109            },
110            TaskEvent::Completed {
111                name,
112                success,
113                duration_ms,
114                ..
115            } => {
116                if self.config.verbose {
117                    let status = if *success { "completed" } else { "failed" };
118                    eprintln!("> [{name}] {status} in {duration_ms}ms");
119                }
120            }
121            TaskEvent::GroupStarted {
122                name,
123                sequential,
124                task_count,
125            } => {
126                let mode = if *sequential {
127                    "sequential"
128                } else {
129                    "parallel"
130                };
131                eprintln!("> Running {mode} group: {name} ({task_count} tasks)");
132            }
133            TaskEvent::GroupCompleted {
134                name,
135                success,
136                duration_ms,
137            } => {
138                if self.config.verbose {
139                    let status = if *success { "completed" } else { "failed" };
140                    eprintln!("> Group {name} {status} in {duration_ms}ms");
141                }
142            }
143        }
144    }
145
146    fn render_ci(&self, event: &CiEvent) {
147        let _ = &self.config; // Silence unused_self - config may be used for CI rendering options later
148        match event {
149            CiEvent::ContextDetected {
150                provider,
151                event_type,
152                ref_name,
153            } => {
154                println!("Context: {provider} (event: {event_type}, ref: {ref_name})");
155            }
156            CiEvent::ChangedFilesFound { count } => {
157                println!("Changed files: {count}");
158            }
159            CiEvent::ProjectsDiscovered { count } => {
160                println!("Found {count} projects");
161            }
162            CiEvent::ProjectSkipped { path, reason } => {
163                println!("Project {path}: {reason}");
164            }
165            CiEvent::TaskExecuting { task, .. } => {
166                println!("  -> Executing {task}");
167            }
168            CiEvent::TaskResult {
169                task,
170                success,
171                error,
172                ..
173            } => {
174                if *success {
175                    println!("  -> {task} passed");
176                } else if let Some(err) = error {
177                    println!("  -> {task} failed: {err}");
178                } else {
179                    println!("  -> {task} failed");
180                }
181            }
182            CiEvent::ReportGenerated { path } => {
183                println!("Report written to: {path}");
184            }
185        }
186    }
187
188    fn render_command(&self, event: &CommandEvent) {
189        match event {
190            CommandEvent::Started { command, .. } => {
191                if self.config.verbose {
192                    eprintln!("Starting command: {command}");
193                }
194            }
195            CommandEvent::Progress {
196                progress, message, ..
197            } => {
198                if self.config.verbose {
199                    let pct = progress * 100.0;
200                    eprintln!("[{pct:.0}%] {message}");
201                }
202            }
203            CommandEvent::Completed {
204                command,
205                success,
206                duration_ms,
207            } => {
208                if self.config.verbose {
209                    let status = if *success { "completed" } else { "failed" };
210                    eprintln!("Command {command} {status} in {duration_ms}ms");
211                }
212            }
213        }
214    }
215
216    fn render_interactive(&self, event: &InteractiveEvent) {
217        let _ = &self.config; // Silence unused_self - config may be used for interactive rendering options later
218        match event {
219            InteractiveEvent::PromptRequested {
220                message, options, ..
221            } => {
222                println!("{message}");
223                for (i, option) in options.iter().enumerate() {
224                    println!("  [{i}] {option}");
225                }
226                print!("> ");
227                let _ = io::stdout().flush();
228            }
229            InteractiveEvent::PromptResolved { .. } => {
230                // Response handled elsewhere
231            }
232            InteractiveEvent::WaitProgress {
233                target,
234                elapsed_secs,
235            } => {
236                eprint!("\r\x1b[KWaiting for `{target}`... [{elapsed_secs}s]");
237                let _ = io::stderr().flush();
238            }
239        }
240    }
241
242    fn render_system(&self, event: &SystemEvent) {
243        match event {
244            SystemEvent::SupervisorLog { tag, message } => {
245                eprintln!("[{tag}] {message}");
246            }
247            SystemEvent::Shutdown => {
248                if self.config.verbose {
249                    eprintln!("System shutdown");
250                }
251            }
252        }
253    }
254
255    fn render_output(&self, event: &OutputEvent) {
256        let _ = &self.config; // Silence unused_self - config may be used for output rendering options later
257        match event {
258            OutputEvent::Stdout { content } => {
259                println!("{content}");
260            }
261            OutputEvent::Stderr { content } => {
262                eprintln!("{content}");
263            }
264        }
265    }
266}
267
268impl Default for CliRenderer {
269    fn default() -> Self {
270        Self::new()
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::event::{EventSource, Stream};
278    use uuid::Uuid;
279
280    fn make_event(category: EventCategory) -> CuenvEvent {
281        CuenvEvent::new(Uuid::new_v4(), EventSource::new("test"), category)
282    }
283
284    #[test]
285    fn test_cli_renderer_config_default() {
286        let config = CliRendererConfig::default();
287        // Verbose should default to false
288        assert!(!config.verbose);
289        // Colors depends on terminal, we just verify it doesn't panic
290        let _ = config.colors;
291    }
292
293    #[test]
294    fn test_cli_renderer_config_debug() {
295        let config = CliRendererConfig {
296            colors: true,
297            verbose: true,
298        };
299        let debug = format!("{config:?}");
300        assert!(debug.contains("CliRendererConfig"));
301        assert!(debug.contains("true"));
302    }
303
304    #[test]
305    fn test_cli_renderer_config_clone() {
306        let config = CliRendererConfig {
307            colors: false,
308            verbose: true,
309        };
310        let cloned = config.clone();
311        assert_eq!(config.colors, cloned.colors);
312        assert_eq!(config.verbose, cloned.verbose);
313    }
314
315    #[test]
316    fn test_cli_renderer_new() {
317        let renderer = CliRenderer::new();
318        assert!(!renderer.config.verbose);
319    }
320
321    #[test]
322    fn test_cli_renderer_default() {
323        let renderer = CliRenderer::default();
324        assert!(!renderer.config.verbose);
325    }
326
327    #[test]
328    fn test_cli_renderer_with_config() {
329        let config = CliRendererConfig {
330            colors: true,
331            verbose: true,
332        };
333        let renderer = CliRenderer::with_config(config);
334        assert!(renderer.config.verbose);
335        assert!(renderer.config.colors);
336    }
337
338    #[test]
339    fn test_cli_renderer_debug() {
340        let renderer = CliRenderer::new();
341        let debug = format!("{renderer:?}");
342        assert!(debug.contains("CliRenderer"));
343    }
344
345    #[test]
346    fn test_render_task_started() {
347        let renderer = CliRenderer::new();
348        let event = make_event(EventCategory::Task(TaskEvent::Started {
349            name: "test-task".to_string(),
350            command: "echo hello".to_string(),
351            hermetic: false,
352        }));
353        renderer.render(&event);
354    }
355
356    #[test]
357    fn test_render_task_started_hermetic() {
358        let renderer = CliRenderer::new();
359        let event = make_event(EventCategory::Task(TaskEvent::Started {
360            name: "test-task".to_string(),
361            command: "echo hello".to_string(),
362            hermetic: true,
363        }));
364        renderer.render(&event);
365    }
366
367    #[test]
368    fn test_render_task_cache_hit() {
369        let renderer = CliRenderer::new();
370        let event = make_event(EventCategory::Task(TaskEvent::CacheHit {
371            name: "cached-task".to_string(),
372            cache_key: "abc123".to_string(),
373        }));
374        renderer.render(&event);
375    }
376
377    #[test]
378    fn test_render_task_cache_miss_verbose() {
379        let config = CliRendererConfig {
380            colors: false,
381            verbose: true,
382        };
383        let renderer = CliRenderer::with_config(config);
384        let event = make_event(EventCategory::Task(TaskEvent::CacheMiss {
385            name: "uncached-task".to_string(),
386        }));
387        renderer.render(&event);
388    }
389
390    #[test]
391    fn test_render_task_output_stdout() {
392        let renderer = CliRenderer::new();
393        let event = make_event(EventCategory::Task(TaskEvent::Output {
394            name: "task".to_string(),
395            stream: Stream::Stdout,
396            content: "stdout content".to_string(),
397        }));
398        renderer.render(&event);
399    }
400
401    #[test]
402    fn test_render_task_output_stderr() {
403        let renderer = CliRenderer::new();
404        let event = make_event(EventCategory::Task(TaskEvent::Output {
405            name: "task".to_string(),
406            stream: Stream::Stderr,
407            content: "stderr content".to_string(),
408        }));
409        renderer.render(&event);
410    }
411
412    #[test]
413    fn test_render_task_completed_verbose() {
414        let config = CliRendererConfig {
415            colors: false,
416            verbose: true,
417        };
418        let renderer = CliRenderer::with_config(config);
419        let event = make_event(EventCategory::Task(TaskEvent::Completed {
420            name: "task".to_string(),
421            success: true,
422            exit_code: Some(0),
423            duration_ms: 1000,
424        }));
425        renderer.render(&event);
426    }
427
428    #[test]
429    fn test_render_task_completed_failed_verbose() {
430        let config = CliRendererConfig {
431            colors: false,
432            verbose: true,
433        };
434        let renderer = CliRenderer::with_config(config);
435        let event = make_event(EventCategory::Task(TaskEvent::Completed {
436            name: "task".to_string(),
437            success: false,
438            exit_code: Some(1),
439            duration_ms: 500,
440        }));
441        renderer.render(&event);
442    }
443
444    #[test]
445    fn test_render_task_group_started_sequential() {
446        let renderer = CliRenderer::new();
447        let event = make_event(EventCategory::Task(TaskEvent::GroupStarted {
448            name: "group".to_string(),
449            sequential: true,
450            task_count: 5,
451        }));
452        renderer.render(&event);
453    }
454
455    #[test]
456    fn test_render_task_group_started_parallel() {
457        let renderer = CliRenderer::new();
458        let event = make_event(EventCategory::Task(TaskEvent::GroupStarted {
459            name: "group".to_string(),
460            sequential: false,
461            task_count: 3,
462        }));
463        renderer.render(&event);
464    }
465
466    #[test]
467    fn test_render_task_group_completed_verbose() {
468        let config = CliRendererConfig {
469            colors: false,
470            verbose: true,
471        };
472        let renderer = CliRenderer::with_config(config);
473        let event = make_event(EventCategory::Task(TaskEvent::GroupCompleted {
474            name: "group".to_string(),
475            success: true,
476            duration_ms: 2000,
477        }));
478        renderer.render(&event);
479    }
480
481    #[test]
482    fn test_render_ci_context_detected() {
483        let renderer = CliRenderer::new();
484        let event = make_event(EventCategory::Ci(CiEvent::ContextDetected {
485            provider: "github".to_string(),
486            event_type: "push".to_string(),
487            ref_name: "main".to_string(),
488        }));
489        renderer.render(&event);
490    }
491
492    #[test]
493    fn test_render_ci_changed_files_found() {
494        let renderer = CliRenderer::new();
495        let event = make_event(EventCategory::Ci(CiEvent::ChangedFilesFound { count: 10 }));
496        renderer.render(&event);
497    }
498
499    #[test]
500    fn test_render_ci_projects_discovered() {
501        let renderer = CliRenderer::new();
502        let event = make_event(EventCategory::Ci(CiEvent::ProjectsDiscovered { count: 3 }));
503        renderer.render(&event);
504    }
505
506    #[test]
507    fn test_render_ci_project_skipped() {
508        let renderer = CliRenderer::new();
509        let event = make_event(EventCategory::Ci(CiEvent::ProjectSkipped {
510            path: "path/to/project".to_string(),
511            reason: "No affected tasks".to_string(),
512        }));
513        renderer.render(&event);
514    }
515
516    #[test]
517    fn test_render_ci_task_executing() {
518        let renderer = CliRenderer::new();
519        let event = make_event(EventCategory::Ci(CiEvent::TaskExecuting {
520            task: "build".to_string(),
521            project: "myproject".to_string(),
522        }));
523        renderer.render(&event);
524    }
525
526    #[test]
527    fn test_render_ci_task_result_success() {
528        let renderer = CliRenderer::new();
529        let event = make_event(EventCategory::Ci(CiEvent::TaskResult {
530            task: "test".to_string(),
531            project: "myproject".to_string(),
532            success: true,
533            error: None,
534        }));
535        renderer.render(&event);
536    }
537
538    #[test]
539    fn test_render_ci_task_result_failed_with_error() {
540        let renderer = CliRenderer::new();
541        let event = make_event(EventCategory::Ci(CiEvent::TaskResult {
542            task: "test".to_string(),
543            project: "myproject".to_string(),
544            success: false,
545            error: Some("assertion failed".to_string()),
546        }));
547        renderer.render(&event);
548    }
549
550    #[test]
551    fn test_render_ci_task_result_failed_no_error() {
552        let renderer = CliRenderer::new();
553        let event = make_event(EventCategory::Ci(CiEvent::TaskResult {
554            task: "test".to_string(),
555            project: "myproject".to_string(),
556            success: false,
557            error: None,
558        }));
559        renderer.render(&event);
560    }
561
562    #[test]
563    fn test_render_ci_report_generated() {
564        let renderer = CliRenderer::new();
565        let event = make_event(EventCategory::Ci(CiEvent::ReportGenerated {
566            path: "/path/to/report.json".to_string(),
567        }));
568        renderer.render(&event);
569    }
570
571    #[test]
572    fn test_render_command_started_verbose() {
573        let config = CliRendererConfig {
574            colors: false,
575            verbose: true,
576        };
577        let renderer = CliRenderer::with_config(config);
578        let event = make_event(EventCategory::Command(CommandEvent::Started {
579            command: "build".to_string(),
580            args: vec!["--release".to_string()],
581        }));
582        renderer.render(&event);
583    }
584
585    #[test]
586    fn test_render_command_progress_verbose() {
587        let config = CliRendererConfig {
588            colors: false,
589            verbose: true,
590        };
591        let renderer = CliRenderer::with_config(config);
592        let event = make_event(EventCategory::Command(CommandEvent::Progress {
593            command: "build".to_string(),
594            progress: 0.5,
595            message: "Compiling...".to_string(),
596        }));
597        renderer.render(&event);
598    }
599
600    #[test]
601    fn test_render_command_completed_verbose() {
602        let config = CliRendererConfig {
603            colors: false,
604            verbose: true,
605        };
606        let renderer = CliRenderer::with_config(config);
607        let event = make_event(EventCategory::Command(CommandEvent::Completed {
608            command: "build".to_string(),
609            success: true,
610            duration_ms: 1000,
611        }));
612        renderer.render(&event);
613    }
614
615    #[test]
616    fn test_render_interactive_prompt_requested() {
617        let renderer = CliRenderer::new();
618        let event = make_event(EventCategory::Interactive(
619            InteractiveEvent::PromptRequested {
620                prompt_id: "test-prompt-1".to_string(),
621                message: "Select an option:".to_string(),
622                options: vec!["Option A".to_string(), "Option B".to_string()],
623            },
624        ));
625        renderer.render(&event);
626    }
627
628    #[test]
629    fn test_render_interactive_prompt_resolved() {
630        let renderer = CliRenderer::new();
631        let event = make_event(EventCategory::Interactive(
632            InteractiveEvent::PromptResolved {
633                prompt_id: "test-prompt-1".to_string(),
634                response: "Option A".to_string(),
635            },
636        ));
637        renderer.render(&event);
638    }
639
640    #[test]
641    fn test_render_interactive_wait_progress() {
642        let renderer = CliRenderer::new();
643        let event = make_event(EventCategory::Interactive(InteractiveEvent::WaitProgress {
644            target: "database".to_string(),
645            elapsed_secs: 10,
646        }));
647        renderer.render(&event);
648    }
649
650    #[test]
651    fn test_render_system_supervisor_log() {
652        let renderer = CliRenderer::new();
653        let event = make_event(EventCategory::System(SystemEvent::SupervisorLog {
654            tag: "supervisor".to_string(),
655            message: "Process started".to_string(),
656        }));
657        renderer.render(&event);
658    }
659
660    #[test]
661    fn test_render_system_shutdown_verbose() {
662        let config = CliRendererConfig {
663            colors: false,
664            verbose: true,
665        };
666        let renderer = CliRenderer::with_config(config);
667        let event = make_event(EventCategory::System(SystemEvent::Shutdown));
668        renderer.render(&event);
669    }
670
671    #[test]
672    fn test_render_output_stdout() {
673        let renderer = CliRenderer::new();
674        let event = make_event(EventCategory::Output(OutputEvent::Stdout {
675            content: "Hello, world!".to_string(),
676        }));
677        renderer.render(&event);
678    }
679
680    #[test]
681    fn test_render_output_stderr() {
682        let renderer = CliRenderer::new();
683        let event = make_event(EventCategory::Output(OutputEvent::Stderr {
684            content: "Error message".to_string(),
685        }));
686        renderer.render(&event);
687    }
688}