1#![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#[derive(Debug, Clone)]
17pub struct CliRendererConfig {
18 pub colors: bool,
20 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#[derive(Debug)]
35pub struct CliRenderer {
36 config: CliRendererConfig,
37}
38
39impl CliRenderer {
40 #[must_use]
42 pub fn new() -> Self {
43 Self {
44 config: CliRendererConfig::default(),
45 }
46 }
47
48 #[must_use]
50 pub const fn with_config(config: CliRendererConfig) -> Self {
51 Self { config }
52 }
53
54 pub async fn run(self, mut receiver: EventReceiver) {
59 while let Some(event) = receiver.recv().await {
60 self.render(&event);
61 if matches!(event.category, EventCategory::System(SystemEvent::Shutdown)) {
63 break;
64 }
65 }
66 }
67
68 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; 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; 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 }
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; 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 assert!(!config.verbose);
289 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}