Skip to main content

prodigy/cook/interaction/
display.rs

1//! Progress and message display implementation
2//!
3//! ## Formatting Guidelines
4//!
5//! This module provides centralized message formatting for consistent CLI output.
6//! All display operations should use the semantic message types rather than
7//! embedding icons directly in format strings.
8//!
9//! ### Message Type Usage
10//! - `info()`: General information messages
11//! - `warning()`: Non-critical issues or cautions
12//! - `error()`: Critical errors (always shown, even in quiet mode)
13//! - `progress()`: Ongoing operations or status updates
14//! - `success()`: Successful completion of operations
15//! - `action()`: User-initiated actions or commands
16//! - `metric()`: Quantitative data (timings, counts, measurements)
17//! - `status()`: State changes or current status
18//!
19//! ### Icon Management
20//! Icons are centrally configured in `IconConfig` and automatically applied
21//! based on the message type. Never embed icons directly in message strings.
22//!
23//! ### Examples
24//! ```rust,ignore
25//! // Good: Use semantic methods
26//! display.metric("Total time", "15.2s");
27//! display.progress("Processing items...");
28//! display.status("Ready to continue");
29//!
30//! // Bad: Don't embed icons in strings
31//! display.info("📊 Total time: 15.2s");  // Wrong!
32//! display.info("🔄 Processing...");      // Wrong!
33//! ```
34
35use super::SpinnerHandle;
36use std::sync::{Arc, Mutex};
37use std::time::Duration;
38
39/// Semantic message types for consistent formatting
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum DisplayMessageType {
42    Info,
43    Warning,
44    Error,
45    Progress,
46    Success,
47    Action, // User-initiated actions
48    Metric, // Quantitative information
49    Status, // State changes
50}
51
52/// Centralized icon configuration
53#[derive(Clone, Copy)]
54pub struct IconConfig {
55    info: &'static str,
56    warning: &'static str,
57    error: &'static str,
58    progress: &'static str,
59    success: &'static str,
60    action: &'static str,
61    metric: &'static str,
62    status: &'static str,
63    debug: &'static str,
64}
65
66impl Default for IconConfig {
67    fn default() -> Self {
68        Self {
69            info: "â„šī¸",
70            warning: "âš ī¸",
71            error: "❌",
72            progress: "🔄",
73            success: "✅",
74            action: "📝",
75            metric: "📊",
76            status: "📋",
77            debug: "🔍",
78        }
79    }
80}
81
82/// Verbosity level for output control
83#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
84pub enum VerbosityLevel {
85    Quiet = 0,   // Minimal output (errors only)
86    Normal = 1,  // Default: progress + results
87    Verbose = 2, // -v: command names + exit codes
88    Debug = 3,   // -vv: + stdout/stderr
89    Trace = 4,   // -vvv: + Claude output + internal details
90}
91
92impl VerbosityLevel {
93    /// Create from CLI arguments
94    pub fn from_args(verbosity_count: u8, quiet: bool) -> Self {
95        if quiet {
96            VerbosityLevel::Quiet
97        } else {
98            match verbosity_count {
99                0 => VerbosityLevel::Normal,
100                1 => VerbosityLevel::Verbose,
101                2 => VerbosityLevel::Debug,
102                _ => VerbosityLevel::Trace,
103            }
104        }
105    }
106}
107
108/// Trait for displaying progress and messages
109pub trait ProgressDisplay: Send + Sync {
110    /// Display information message
111    fn info(&self, message: &str);
112
113    /// Display warning message
114    fn warning(&self, message: &str);
115
116    /// Display error message
117    fn error(&self, message: &str);
118
119    /// Display progress message
120    fn progress(&self, message: &str);
121
122    /// Display success message
123    fn success(&self, message: &str);
124
125    /// Display action message (user-initiated actions)
126    fn action(&self, message: &str);
127
128    /// Display metric message (quantitative information)
129    fn metric(&self, label: &str, value: &str);
130
131    /// Display status message (state changes)
132    fn status(&self, message: &str);
133
134    /// Start a spinner
135    fn start_spinner(&self, message: &str) -> Box<dyn SpinnerHandle>;
136
137    /// Display iteration start boundary
138    fn iteration_start(&self, current: u32, total: u32);
139
140    /// Display iteration end summary
141    fn iteration_end(&self, current: u32, duration: Duration, success: bool);
142
143    /// Display step start
144    fn step_start(&self, step: u32, total: u32, description: &str);
145
146    /// Display step end
147    fn step_end(&self, step: u32, success: bool);
148
149    /// Display command output based on verbosity
150    fn command_output(&self, output: &str, verbosity: VerbosityLevel);
151
152    /// Display debug output if verbosity allows
153    fn debug_output(&self, message: &str, min_verbosity: VerbosityLevel);
154
155    /// Get current verbosity level
156    fn verbosity(&self) -> VerbosityLevel;
157}
158
159/// Real implementation of progress display
160pub struct ProgressDisplayImpl {
161    verbosity: VerbosityLevel,
162    use_unicode: bool,
163    icons: IconConfig,
164}
165
166impl Default for ProgressDisplayImpl {
167    fn default() -> Self {
168        Self::new(VerbosityLevel::Normal)
169    }
170}
171
172impl ProgressDisplayImpl {
173    pub fn new(verbosity: VerbosityLevel) -> Self {
174        // Detect terminal capabilities
175        let use_unicode = Self::supports_unicode();
176
177        Self {
178            verbosity,
179            use_unicode,
180            icons: IconConfig::default(),
181        }
182    }
183
184    /// Create from CLI arguments
185    pub fn from_args(verbosity_count: u8, quiet: bool) -> Self {
186        let verbosity = VerbosityLevel::from_args(verbosity_count, quiet);
187        Self::new(verbosity)
188    }
189
190    /// Check if terminal supports Unicode
191    fn supports_unicode() -> bool {
192        // Check LANG/LC_ALL environment variables
193        if let Ok(lang) = std::env::var("LANG") {
194            if lang.contains("UTF-8") || lang.contains("utf8") {
195                return true;
196            }
197        }
198        if let Ok(lc_all) = std::env::var("LC_ALL") {
199            if lc_all.contains("UTF-8") || lc_all.contains("utf8") {
200                return true;
201            }
202        }
203        // Default to ASCII on Windows, Unicode elsewhere
204        !cfg!(windows)
205    }
206
207    /// Get box drawing characters based on Unicode support
208    fn box_chars(&self) -> BoxChars {
209        if self.use_unicode {
210            BoxChars::unicode()
211        } else {
212            BoxChars::ascii()
213        }
214    }
215
216    /// Format duration for display
217    fn format_duration(duration: Duration) -> String {
218        let secs = duration.as_secs();
219        let millis = duration.subsec_millis();
220
221        if secs >= 60 {
222            let mins = secs / 60;
223            let secs = secs % 60;
224            format!("{mins}m {secs}s")
225        } else if secs > 0 {
226            format!("{secs}.{millis:03}s")
227        } else {
228            format!("{millis}ms")
229        }
230    }
231}
232
233/// Box drawing characters for terminal UI
234struct BoxChars {
235    horizontal: char,
236    vertical: char,
237    top_left: char,
238    top_right: char,
239    bottom_left: char,
240    bottom_right: char,
241}
242
243impl BoxChars {
244    fn unicode() -> Self {
245        Self {
246            horizontal: '═',
247            vertical: '║',
248            top_left: '╔',
249            top_right: '╗',
250            bottom_left: '╚',
251            bottom_right: '╝',
252        }
253    }
254
255    fn ascii() -> Self {
256        Self {
257            horizontal: '=',
258            vertical: '|',
259            top_left: '+',
260            top_right: '+',
261            bottom_left: '+',
262            bottom_right: '+',
263        }
264    }
265}
266
267impl ProgressDisplay for ProgressDisplayImpl {
268    fn info(&self, message: &str) {
269        if self.verbosity >= VerbosityLevel::Normal {
270            println!("{} {message}", self.icons.info);
271        }
272    }
273
274    fn warning(&self, message: &str) {
275        if self.verbosity >= VerbosityLevel::Normal {
276            eprintln!("{} {message}", self.icons.warning);
277        }
278    }
279
280    fn error(&self, message: &str) {
281        // Always show errors, even in quiet mode
282        eprintln!("{} {message}", self.icons.error);
283    }
284
285    fn progress(&self, message: &str) {
286        if self.verbosity >= VerbosityLevel::Normal {
287            println!("{} {message}", self.icons.progress);
288        }
289    }
290
291    fn success(&self, message: &str) {
292        if self.verbosity >= VerbosityLevel::Normal {
293            println!("{} {message}", self.icons.success);
294        }
295    }
296
297    fn action(&self, message: &str) {
298        if self.verbosity >= VerbosityLevel::Normal {
299            println!("{} {message}", self.icons.action);
300        }
301    }
302
303    fn metric(&self, label: &str, value: &str) {
304        if self.verbosity >= VerbosityLevel::Normal {
305            println!("{} {label}: {value}", self.icons.metric);
306        }
307    }
308
309    fn status(&self, message: &str) {
310        if self.verbosity >= VerbosityLevel::Normal {
311            println!("{} {message}", self.icons.status);
312        }
313    }
314
315    fn start_spinner(&self, message: &str) -> Box<dyn SpinnerHandle> {
316        if self.verbosity >= VerbosityLevel::Normal {
317            println!("âŗ {message}");
318        }
319        Box::new(SimpleSpinnerHandle::new(self.verbosity, self.icons))
320    }
321
322    fn iteration_start(&self, current: u32, total: u32) {
323        if self.verbosity >= VerbosityLevel::Normal {
324            let chars = self.box_chars();
325            let width = 60;
326            let title = format!(" ITERATION {current}/{total} ");
327            let padding = (width - title.len()) / 2;
328
329            println!();
330            println!(
331                "{}{}{}",
332                chars.top_left,
333                std::iter::repeat_n(chars.horizontal, width).collect::<String>(),
334                chars.top_right
335            );
336            println!(
337                "{}{:padding$}{}{:padding$}{}",
338                chars.vertical,
339                "",
340                title,
341                "",
342                chars.vertical,
343                padding = padding
344            );
345            println!(
346                "{}{}{}",
347                chars.bottom_left,
348                std::iter::repeat_n(chars.horizontal, width).collect::<String>(),
349                chars.bottom_right
350            );
351            println!();
352        }
353    }
354
355    fn iteration_end(&self, current: u32, duration: Duration, success: bool) {
356        if self.verbosity >= VerbosityLevel::Normal {
357            let duration_str = Self::format_duration(duration);
358            let status = if success {
359                format!("{} Success", self.icons.success)
360            } else {
361                format!("{} Failed", self.icons.error)
362            };
363
364            println!();
365            println!("┌─ Iteration {current} Summary ──────────────────────────────────────┐");
366            println!("│ Duration: {:<49}│", duration_str);
367            println!("│ Status: {:<51}│", status);
368            println!("└────────────────────────────────────────────────────────────┘");
369            println!();
370        }
371    }
372
373    fn step_start(&self, step: u32, total: u32, description: &str) {
374        if self.verbosity >= VerbosityLevel::Verbose {
375            println!("[Step {step}/{total}] {description}");
376        }
377    }
378
379    fn step_end(&self, step: u32, success: bool) {
380        if self.verbosity >= VerbosityLevel::Verbose {
381            let status = if success {
382                self.icons.success
383            } else {
384                self.icons.error
385            };
386            println!("[Step {step}] {status}");
387        }
388    }
389
390    fn command_output(&self, output: &str, verbosity: VerbosityLevel) {
391        if self.verbosity >= verbosity && !output.trim().is_empty() {
392            println!("{output}");
393        }
394    }
395
396    fn debug_output(&self, message: &str, min_verbosity: VerbosityLevel) {
397        if self.verbosity >= min_verbosity {
398            println!("{} {message}", self.icons.debug);
399        }
400    }
401
402    fn verbosity(&self) -> VerbosityLevel {
403        self.verbosity
404    }
405}
406
407/// Simple spinner handle implementation
408struct SimpleSpinnerHandle {
409    active: Arc<Mutex<bool>>,
410    verbosity: VerbosityLevel,
411    icons: IconConfig,
412}
413
414impl SimpleSpinnerHandle {
415    fn new(verbosity: VerbosityLevel, icons: IconConfig) -> Self {
416        Self {
417            active: Arc::new(Mutex::new(true)),
418            verbosity,
419            icons,
420        }
421    }
422}
423
424impl SpinnerHandle for SimpleSpinnerHandle {
425    fn update_message(&mut self, message: &str) {
426        if *self.active.lock().unwrap() && self.verbosity >= VerbosityLevel::Normal {
427            println!("âŗ {message}");
428        }
429    }
430
431    fn success(&mut self, message: &str) {
432        *self.active.lock().unwrap() = false;
433        if self.verbosity >= VerbosityLevel::Normal {
434            println!("{} {message}", self.icons.success);
435        }
436    }
437
438    fn fail(&mut self, message: &str) {
439        *self.active.lock().unwrap() = false;
440        if self.verbosity >= VerbosityLevel::Normal {
441            println!("{} {message}", self.icons.error);
442        }
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    pub struct MockProgressDisplay {
451        messages: Arc<Mutex<Vec<String>>>,
452        verbosity: VerbosityLevel,
453    }
454
455    impl MockProgressDisplay {
456        pub fn new() -> Self {
457            Self {
458                messages: Arc::new(Mutex::new(Vec::new())),
459                verbosity: VerbosityLevel::Normal,
460            }
461        }
462
463        pub fn get_messages(&self) -> Vec<String> {
464            self.messages.lock().unwrap().clone()
465        }
466    }
467
468    impl ProgressDisplay for MockProgressDisplay {
469        fn info(&self, message: &str) {
470            self.messages
471                .lock()
472                .unwrap()
473                .push(format!("INFO: {message}"));
474        }
475
476        fn warning(&self, message: &str) {
477            self.messages
478                .lock()
479                .unwrap()
480                .push(format!("WARN: {message}"));
481        }
482
483        fn error(&self, message: &str) {
484            self.messages
485                .lock()
486                .unwrap()
487                .push(format!("ERROR: {message}"));
488        }
489
490        fn progress(&self, message: &str) {
491            self.messages
492                .lock()
493                .unwrap()
494                .push(format!("PROGRESS: {message}"));
495        }
496
497        fn success(&self, message: &str) {
498            self.messages
499                .lock()
500                .unwrap()
501                .push(format!("SUCCESS: {message}"));
502        }
503
504        fn action(&self, message: &str) {
505            self.messages
506                .lock()
507                .unwrap()
508                .push(format!("ACTION: {message}"));
509        }
510
511        fn metric(&self, label: &str, value: &str) {
512            self.messages
513                .lock()
514                .unwrap()
515                .push(format!("METRIC: {label}: {value}"));
516        }
517
518        fn status(&self, message: &str) {
519            self.messages
520                .lock()
521                .unwrap()
522                .push(format!("STATUS: {message}"));
523        }
524
525        fn start_spinner(&self, message: &str) -> Box<dyn SpinnerHandle> {
526            self.messages
527                .lock()
528                .unwrap()
529                .push(format!("SPINNER: {message}"));
530            Box::new(MockSpinnerHandle::new(self.messages.clone()))
531        }
532
533        fn iteration_start(&self, current: u32, total: u32) {
534            self.messages
535                .lock()
536                .unwrap()
537                .push(format!("ITERATION_START: {current}/{total}"));
538        }
539
540        fn iteration_end(&self, current: u32, duration: Duration, success: bool) {
541            self.messages
542                .lock()
543                .unwrap()
544                .push(format!("ITERATION_END: {current} {:?} {success}", duration));
545        }
546
547        fn step_start(&self, step: u32, total: u32, description: &str) {
548            self.messages
549                .lock()
550                .unwrap()
551                .push(format!("STEP_START: {step}/{total} {description}"));
552        }
553
554        fn step_end(&self, step: u32, success: bool) {
555            self.messages
556                .lock()
557                .unwrap()
558                .push(format!("STEP_END: {step} {success}"));
559        }
560
561        fn command_output(&self, output: &str, _verbosity: VerbosityLevel) {
562            self.messages
563                .lock()
564                .unwrap()
565                .push(format!("COMMAND_OUTPUT: {output}"));
566        }
567
568        fn debug_output(&self, message: &str, _min_verbosity: VerbosityLevel) {
569            self.messages
570                .lock()
571                .unwrap()
572                .push(format!("DEBUG: {message}"));
573        }
574
575        fn verbosity(&self) -> VerbosityLevel {
576            self.verbosity
577        }
578    }
579
580    struct MockSpinnerHandle {
581        messages: Arc<Mutex<Vec<String>>>,
582    }
583
584    impl MockSpinnerHandle {
585        fn new(messages: Arc<Mutex<Vec<String>>>) -> Self {
586            Self { messages }
587        }
588    }
589
590    impl SpinnerHandle for MockSpinnerHandle {
591        fn update_message(&mut self, message: &str) {
592            self.messages
593                .lock()
594                .unwrap()
595                .push(format!("SPINNER_UPDATE: {message}"));
596        }
597
598        fn success(&mut self, message: &str) {
599            self.messages
600                .lock()
601                .unwrap()
602                .push(format!("SPINNER_SUCCESS: {message}"));
603        }
604
605        fn fail(&mut self, message: &str) {
606            self.messages
607                .lock()
608                .unwrap()
609                .push(format!("SPINNER_FAIL: {message}"));
610        }
611    }
612
613    #[test]
614    fn test_mock_display() {
615        let display = MockProgressDisplay::new();
616
617        display.info("Test info");
618        display.warning("Test warning");
619        display.error("Test error");
620        display.progress("Test progress");
621        display.success("Test success");
622
623        let messages = display.get_messages();
624        assert_eq!(messages.len(), 5);
625        assert_eq!(messages[0], "INFO: Test info");
626        assert_eq!(messages[1], "WARN: Test warning");
627        assert_eq!(messages[2], "ERROR: Test error");
628        assert_eq!(messages[3], "PROGRESS: Test progress");
629        assert_eq!(messages[4], "SUCCESS: Test success");
630    }
631
632    #[test]
633    fn test_mock_spinner() {
634        let display = MockProgressDisplay::new();
635        let mut spinner = display.start_spinner("Starting");
636
637        spinner.update_message("Processing");
638        spinner.success("Done");
639
640        let messages = display.get_messages();
641        assert_eq!(messages.len(), 3);
642        assert_eq!(messages[0], "SPINNER: Starting");
643        assert_eq!(messages[1], "SPINNER_UPDATE: Processing");
644        assert_eq!(messages[2], "SPINNER_SUCCESS: Done");
645    }
646
647    #[test]
648    fn test_progress_display_info() {
649        let display = ProgressDisplayImpl::new(VerbosityLevel::Normal);
650        // Test that info messages are displayed correctly
651        display.info("Test info message");
652        // Verify output contains the message with info icon
653    }
654
655    #[test]
656    fn test_progress_display_warning() {
657        let display = ProgressDisplayImpl::new(VerbosityLevel::Normal);
658        // Test warning messages go to stderr
659        display.warning("Test warning");
660        // Verify stderr output
661    }
662
663    #[test]
664    fn test_progress_display_error() {
665        let display = ProgressDisplayImpl::new(VerbosityLevel::Normal);
666        display.error("Test error");
667        // Verify error formatting
668    }
669
670    #[test]
671    fn test_spinner_lifecycle() {
672        let display = ProgressDisplayImpl::new(VerbosityLevel::Normal);
673        let mut spinner = display.start_spinner("Loading...");
674        // Test spinner starts
675        spinner.update_message("Still processing");
676        spinner.success("Done");
677        // Verify spinner completes
678    }
679
680    #[test]
681    fn test_progress_display_progress() {
682        let display = ProgressDisplayImpl::new(VerbosityLevel::Normal);
683        display.progress("Test progress message");
684        // Verify progress formatting
685    }
686
687    #[test]
688    fn test_progress_display_success() {
689        let display = ProgressDisplayImpl::new(VerbosityLevel::Normal);
690        display.success("Test success message");
691        // Verify success formatting
692    }
693
694    #[test]
695    fn test_simple_spinner_handle_fail() {
696        let display = ProgressDisplayImpl::new(VerbosityLevel::Normal);
697        let mut spinner = display.start_spinner("Starting task");
698        spinner.fail("Failed to complete");
699        // Verify failure message
700    }
701
702    #[test]
703    fn test_verbosity_levels() {
704        let quiet = VerbosityLevel::from_args(0, true);
705        assert_eq!(quiet, VerbosityLevel::Quiet);
706
707        let normal = VerbosityLevel::from_args(0, false);
708        assert_eq!(normal, VerbosityLevel::Normal);
709
710        let verbose = VerbosityLevel::from_args(1, false);
711        assert_eq!(verbose, VerbosityLevel::Verbose);
712
713        let debug = VerbosityLevel::from_args(2, false);
714        assert_eq!(debug, VerbosityLevel::Debug);
715
716        let trace = VerbosityLevel::from_args(3, false);
717        assert_eq!(trace, VerbosityLevel::Trace);
718    }
719
720    #[test]
721    fn test_iteration_display() {
722        let display = MockProgressDisplay::new();
723        display.iteration_start(1, 10);
724        display.iteration_end(1, Duration::from_secs(5), true);
725
726        let messages = display.get_messages();
727        assert!(messages.contains(&"ITERATION_START: 1/10".to_string()));
728        assert!(messages.iter().any(|m| m.starts_with("ITERATION_END: 1")));
729    }
730
731    #[test]
732    fn test_step_display() {
733        let display = MockProgressDisplay::new();
734        display.step_start(1, 5, "Running tests");
735        display.step_end(1, true);
736
737        let messages = display.get_messages();
738        assert!(messages.contains(&"STEP_START: 1/5 Running tests".to_string()));
739        assert!(messages.contains(&"STEP_END: 1 true".to_string()));
740    }
741
742    #[test]
743    fn test_verbosity_filtering() {
744        let quiet_display = ProgressDisplayImpl::new(VerbosityLevel::Quiet);
745        quiet_display.info("Should not appear");
746        quiet_display.error("Should appear");
747        // In quiet mode, only errors should be shown
748
749        let verbose_display = ProgressDisplayImpl::new(VerbosityLevel::Verbose);
750        verbose_display.step_start(1, 3, "test");
751        // In verbose mode, step information should be shown
752    }
753
754    #[test]
755    fn test_command_output_display() {
756        let display = MockProgressDisplay::new();
757        display.command_output("test output", VerbosityLevel::Debug);
758
759        let messages = display.get_messages();
760        assert!(messages.contains(&"COMMAND_OUTPUT: test output".to_string()));
761    }
762
763    #[test]
764    fn test_debug_output() {
765        let display = MockProgressDisplay::new();
766        display.debug_output("debug info", VerbosityLevel::Trace);
767
768        let messages = display.get_messages();
769        assert!(messages.contains(&"DEBUG: debug info".to_string()));
770    }
771
772    #[test]
773    fn test_format_duration() {
774        assert_eq!(
775            ProgressDisplayImpl::format_duration(Duration::from_millis(500)),
776            "500ms"
777        );
778        assert_eq!(
779            ProgressDisplayImpl::format_duration(Duration::from_secs(5)),
780            "5.000s"
781        );
782        assert_eq!(
783            ProgressDisplayImpl::format_duration(Duration::from_secs(65)),
784            "1m 5s"
785        );
786    }
787}