1use crate::theme::Theme;
20use std::time::Duration;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum PoolHealth {
25 Healthy,
27 Busy,
29 Degraded,
31 Exhausted,
33}
34
35impl PoolHealth {
36 #[must_use]
38 pub fn as_str(&self) -> &'static str {
39 match self {
40 Self::Healthy => "HEALTHY",
41 Self::Busy => "BUSY",
42 Self::Degraded => "DEGRADED",
43 Self::Exhausted => "EXHAUSTED",
44 }
45 }
46
47 #[must_use]
49 pub fn color_code(&self) -> &'static str {
50 match self {
51 Self::Healthy => "\x1b[32m", Self::Busy => "\x1b[33m", Self::Degraded => "\x1b[38;5;208m", Self::Exhausted => "\x1b[31m", }
56 }
57}
58
59impl std::fmt::Display for PoolHealth {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 write!(f, "{}", self.as_str())
62 }
63}
64
65pub trait PoolStatsProvider {
70 fn active_connections(&self) -> usize;
72 fn idle_connections(&self) -> usize;
74 fn max_connections(&self) -> usize;
76 fn min_connections(&self) -> usize;
78 fn pending_requests(&self) -> usize;
80 fn connections_created(&self) -> u64;
82 fn connections_closed(&self) -> u64;
84 fn total_acquires(&self) -> u64;
86 fn total_timeouts(&self) -> u64;
88}
89
90#[derive(Debug, Clone)]
92pub struct PoolStatusDisplay {
93 active: usize,
95 idle: usize,
97 max: usize,
99 min: usize,
101 pending: usize,
103 created: u64,
105 closed: u64,
107 acquires: u64,
109 timeouts: u64,
111 theme: Theme,
113 width: Option<usize>,
115 uptime: Option<Duration>,
117 name: Option<String>,
119}
120
121impl PoolStatusDisplay {
122 #[must_use]
124 pub fn from_stats<S: PoolStatsProvider>(stats: &S) -> Self {
125 Self {
126 active: stats.active_connections(),
127 idle: stats.idle_connections(),
128 max: stats.max_connections(),
129 min: stats.min_connections(),
130 pending: stats.pending_requests(),
131 created: stats.connections_created(),
132 closed: stats.connections_closed(),
133 acquires: stats.total_acquires(),
134 timeouts: stats.total_timeouts(),
135 theme: Theme::default(),
136 width: None,
137 uptime: None,
138 name: None,
139 }
140 }
141
142 #[must_use]
144 pub fn new(active: usize, idle: usize, max: usize, min: usize, pending: usize) -> Self {
145 Self {
146 active,
147 idle,
148 max,
149 min,
150 pending,
151 created: 0,
152 closed: 0,
153 acquires: 0,
154 timeouts: 0,
155 theme: Theme::default(),
156 width: None,
157 uptime: None,
158 name: None,
159 }
160 }
161
162 #[must_use]
164 pub fn theme(mut self, theme: Theme) -> Self {
165 self.theme = theme;
166 self
167 }
168
169 #[must_use]
171 pub fn width(mut self, width: usize) -> Self {
172 self.width = Some(width);
173 self
174 }
175
176 #[must_use]
178 pub fn uptime(mut self, uptime: Duration) -> Self {
179 self.uptime = Some(uptime);
180 self
181 }
182
183 #[must_use]
185 pub fn name<S: Into<String>>(mut self, name: S) -> Self {
186 self.name = Some(name.into());
187 self
188 }
189
190 #[must_use]
192 pub fn with_acquisition_stats(mut self, acquires: u64, timeouts: u64) -> Self {
193 self.acquires = acquires;
194 self.timeouts = timeouts;
195 self
196 }
197
198 #[must_use]
200 pub fn with_lifetime_stats(mut self, created: u64, closed: u64) -> Self {
201 self.created = created;
202 self.closed = closed;
203 self
204 }
205
206 #[must_use]
208 pub fn total(&self) -> usize {
209 self.active + self.idle
210 }
211
212 #[must_use]
214 pub fn utilization(&self) -> f64 {
215 if self.max == 0 {
216 0.0
217 } else {
218 (self.active as f64 / self.max as f64) * 100.0
219 }
220 }
221
222 #[must_use]
224 pub fn health(&self) -> PoolHealth {
225 let utilization = self.utilization();
226
227 if self.pending > 0 {
228 if self.pending >= self.max || self.active >= self.max {
229 PoolHealth::Exhausted
230 } else {
231 PoolHealth::Degraded
232 }
233 } else if utilization >= 80.0 {
234 PoolHealth::Busy
235 } else {
236 PoolHealth::Healthy
237 }
238 }
239
240 fn format_uptime(duration: Duration) -> String {
242 let secs = duration.as_secs();
243 if secs < 60 {
244 format!("{}s", secs)
245 } else if secs < 3600 {
246 let mins = secs / 60;
247 let secs = secs % 60;
248 if secs == 0 {
249 format!("{}m", mins)
250 } else {
251 format!("{}m {}s", mins, secs)
252 }
253 } else if secs < 86400 {
254 let hours = secs / 3600;
255 let mins = (secs % 3600) / 60;
256 if mins == 0 {
257 format!("{}h", hours)
258 } else {
259 format!("{}h {}m", hours, mins)
260 }
261 } else {
262 let days = secs / 86400;
263 let hours = (secs % 86400) / 3600;
264 if hours == 0 {
265 format!("{}d", days)
266 } else {
267 format!("{}d {}h", days, hours)
268 }
269 }
270 }
271
272 #[must_use]
274 pub fn render_plain(&self) -> String {
275 let health = self.health();
276 let utilization = self.utilization();
277 let total = self.total();
278
279 let mut lines = Vec::new();
280
281 let name_prefix = self
283 .name
284 .as_ref()
285 .map(|n| format!("{}: ", n))
286 .unwrap_or_default();
287
288 lines.push(format!(
289 "{}Pool: {}/{} active ({:.0}%), {} waiting, {}",
290 name_prefix, self.active, self.max, utilization, self.pending, health
291 ));
292
293 lines.push(format!(
295 " Active: {}, Idle: {}, Total: {}, Max: {}, Min: {}",
296 self.active, self.idle, total, self.max, self.min
297 ));
298
299 if self.acquires > 0 || self.timeouts > 0 || self.created > 0 {
301 let mut stats_parts = Vec::new();
302
303 if self.acquires > 0 {
304 stats_parts.push(format!("Acquires: {}", self.acquires));
305 }
306 if self.timeouts > 0 {
307 stats_parts.push(format!("Timeouts: {}", self.timeouts));
308 }
309 if self.created > 0 {
310 stats_parts.push(format!("Created: {}", self.created));
311 }
312 if self.closed > 0 {
313 stats_parts.push(format!("Closed: {}", self.closed));
314 }
315
316 if !stats_parts.is_empty() {
317 lines.push(format!(" {}", stats_parts.join(", ")));
318 }
319 }
320
321 if let Some(uptime) = self.uptime {
323 lines.push(format!(" Uptime: {}", Self::format_uptime(uptime)));
324 }
325
326 lines.join("\n")
327 }
328
329 #[must_use]
331 #[allow(clippy::cast_possible_truncation)]
332 pub fn render_styled(&self) -> String {
333 let health = self.health();
334 let utilization = self.utilization();
335 let width = self.width.unwrap_or(60).max(24);
336
337 let mut lines = Vec::new();
338
339 let title = self.name.as_ref().map_or_else(
341 || "Connection Pool Status".to_string(),
342 |n| format!("Connection Pool: {}", n),
343 );
344 let title_display = self.truncate_plain_to_width(&title, width.saturating_sub(3));
345
346 let top_border = format!("┌{}┐", "─".repeat(width.saturating_sub(2)));
348 let bottom_border = format!("└{}┘", "─".repeat(width.saturating_sub(2)));
349
350 lines.push(top_border);
351 lines.push(format!(
352 "│ {:<inner_width$}│",
353 title_display,
354 inner_width = width.saturating_sub(3)
355 ));
356 lines.push(format!("├{}┤", "─".repeat(width.saturating_sub(2))));
357
358 let bar_width = width.saturating_sub(20);
360 let filled = ((utilization / 100.0) * bar_width as f64) as usize;
362 let empty = bar_width.saturating_sub(filled);
363 let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty));
364 let status_width = width.saturating_sub(bar_width + 12);
365
366 lines.push(format!(
368 "│ [{}] {:.0}% {:<width$}│",
369 bar,
370 utilization,
371 health.as_str(),
372 width = status_width
373 ));
374
375 lines.push(
377 format!(
378 "│ Active: {:>4} │ Idle: {:>4} │ Max: {:>4} │",
379 self.active, self.idle, self.max
380 )
381 .chars()
382 .take(width.saturating_sub(1))
383 .collect::<String>()
384 + "│",
385 );
386
387 if self.pending > 0 {
389 lines.push(format!(
390 "│ ⚠ Waiting requests: {:<width$}│",
391 self.pending,
392 width = width.saturating_sub(24)
393 ));
394 }
395
396 if self.acquires > 0 || self.timeouts > 0 {
398 let timeout_rate = if self.acquires > 0 {
399 (self.timeouts as f64 / self.acquires as f64) * 100.0
400 } else {
401 0.0
402 };
403 lines.push(format!(
404 "│ Acquires: {} | Timeouts: {} ({:.1}%){:>width$}│",
405 self.acquires,
406 self.timeouts,
407 timeout_rate,
408 "",
409 width = width.saturating_sub(40)
410 ));
411 }
412
413 if let Some(uptime) = self.uptime {
415 lines.push(format!(
416 "│ Uptime: {:<width$}│",
417 Self::format_uptime(uptime),
418 width = width.saturating_sub(12)
419 ));
420 }
421
422 lines.push(bottom_border);
423
424 lines.join("\n")
425 }
426
427 fn truncate_plain_to_width(&self, s: &str, max_visible: usize) -> String {
428 if max_visible == 0 {
429 return String::new();
430 }
431
432 let char_count = s.chars().count();
433 if char_count <= max_visible {
434 return s.to_string();
435 }
436
437 if max_visible <= 3 {
438 return ".".repeat(max_visible);
439 }
440
441 let truncated: String = s.chars().take(max_visible - 3).collect();
442 format!("{truncated}...")
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[derive(Debug, Clone)]
452 struct MockPoolStats {
453 active: usize,
454 idle: usize,
455 max: usize,
456 min: usize,
457 pending: usize,
458 created: u64,
459 closed: u64,
460 acquires: u64,
461 timeouts: u64,
462 }
463
464 impl MockPoolStats {
465 fn healthy() -> Self {
466 Self {
467 active: 3,
468 idle: 2,
469 max: 20,
470 min: 2,
471 pending: 0,
472 created: 100,
473 closed: 95,
474 acquires: 1000,
475 timeouts: 5,
476 }
477 }
478
479 fn busy() -> Self {
480 Self {
481 active: 17,
482 idle: 1,
483 max: 20,
484 min: 2,
485 pending: 0,
486 created: 200,
487 closed: 182,
488 acquires: 5000,
489 timeouts: 10,
490 }
491 }
492
493 fn degraded() -> Self {
494 Self {
496 active: 15,
497 idle: 0,
498 max: 20,
499 min: 2,
500 pending: 3,
501 created: 300,
502 closed: 280,
503 acquires: 8000,
504 timeouts: 50,
505 }
506 }
507
508 fn exhausted() -> Self {
509 Self {
510 active: 20,
511 idle: 0,
512 max: 20,
513 min: 2,
514 pending: 25,
515 created: 500,
516 closed: 480,
517 acquires: 10000,
518 timeouts: 200,
519 }
520 }
521 }
522
523 impl PoolStatsProvider for MockPoolStats {
524 fn active_connections(&self) -> usize {
525 self.active
526 }
527 fn idle_connections(&self) -> usize {
528 self.idle
529 }
530 fn max_connections(&self) -> usize {
531 self.max
532 }
533 fn min_connections(&self) -> usize {
534 self.min
535 }
536 fn pending_requests(&self) -> usize {
537 self.pending
538 }
539 fn connections_created(&self) -> u64 {
540 self.created
541 }
542 fn connections_closed(&self) -> u64 {
543 self.closed
544 }
545 fn total_acquires(&self) -> u64 {
546 self.acquires
547 }
548 fn total_timeouts(&self) -> u64 {
549 self.timeouts
550 }
551 }
552
553 #[test]
554 fn test_pool_health_healthy() {
555 let stats = MockPoolStats::healthy();
556 let display = PoolStatusDisplay::from_stats(&stats);
557 assert_eq!(display.health(), PoolHealth::Healthy);
558 }
559
560 #[test]
561 fn test_pool_health_busy() {
562 let stats = MockPoolStats::busy();
563 let display = PoolStatusDisplay::from_stats(&stats);
564 assert_eq!(display.health(), PoolHealth::Busy);
565 }
566
567 #[test]
568 fn test_pool_health_degraded() {
569 let stats = MockPoolStats::degraded();
570 let display = PoolStatusDisplay::from_stats(&stats);
571 assert_eq!(display.health(), PoolHealth::Degraded);
572 }
573
574 #[test]
575 fn test_pool_health_exhausted() {
576 let stats = MockPoolStats::exhausted();
577 let display = PoolStatusDisplay::from_stats(&stats);
578 assert_eq!(display.health(), PoolHealth::Exhausted);
579 }
580
581 #[test]
582 fn test_pool_health_as_str() {
583 assert_eq!(PoolHealth::Healthy.as_str(), "HEALTHY");
584 assert_eq!(PoolHealth::Busy.as_str(), "BUSY");
585 assert_eq!(PoolHealth::Degraded.as_str(), "DEGRADED");
586 assert_eq!(PoolHealth::Exhausted.as_str(), "EXHAUSTED");
587 }
588
589 #[test]
590 fn test_pool_health_display() {
591 assert_eq!(format!("{}", PoolHealth::Healthy), "HEALTHY");
592 assert_eq!(format!("{}", PoolHealth::Exhausted), "EXHAUSTED");
593 }
594
595 #[test]
596 fn test_utilization_calculation() {
597 let display = PoolStatusDisplay::new(10, 5, 20, 2, 0);
598 assert!((display.utilization() - 50.0).abs() < 0.01);
599 }
600
601 #[test]
602 fn test_utilization_zero_max() {
603 let display = PoolStatusDisplay::new(0, 0, 0, 0, 0);
604 assert!(display.utilization().abs() < f64::EPSILON);
605 }
606
607 #[test]
608 fn test_total_connections() {
609 let display = PoolStatusDisplay::new(8, 4, 20, 2, 0);
610 assert_eq!(display.total(), 12);
611 }
612
613 #[test]
614 fn test_render_plain_healthy() {
615 let stats = MockPoolStats::healthy();
616 let display = PoolStatusDisplay::from_stats(&stats);
617 let output = display.render_plain();
618
619 assert!(output.contains("Pool:"));
620 assert!(output.contains("HEALTHY"));
621 assert!(output.contains("Active: 3"));
622 assert!(output.contains("Idle: 2"));
623 }
624
625 #[test]
626 fn test_render_plain_with_name() {
627 let display = PoolStatusDisplay::new(5, 3, 20, 2, 0).name("PostgreSQL Main");
628 let output = display.render_plain();
629
630 assert!(output.contains("PostgreSQL Main:"));
631 }
632
633 #[test]
634 fn test_render_plain_with_uptime() {
635 let display = PoolStatusDisplay::new(5, 3, 20, 2, 0).uptime(Duration::from_secs(3725)); let output = display.render_plain();
637
638 assert!(output.contains("Uptime:"));
639 assert!(output.contains("1h 2m"));
640 }
641
642 #[test]
643 fn test_render_plain_with_waiting() {
644 let display = PoolStatusDisplay::new(20, 0, 20, 2, 5);
645 let output = display.render_plain();
646
647 assert!(output.contains("5 waiting"));
648 assert!(output.contains("DEGRADED") || output.contains("EXHAUSTED"));
649 }
650
651 #[test]
652 fn test_format_uptime_seconds() {
653 assert_eq!(
654 PoolStatusDisplay::format_uptime(Duration::from_secs(45)),
655 "45s"
656 );
657 }
658
659 #[test]
660 fn test_format_uptime_minutes() {
661 assert_eq!(
662 PoolStatusDisplay::format_uptime(Duration::from_secs(125)),
663 "2m 5s"
664 );
665 assert_eq!(
666 PoolStatusDisplay::format_uptime(Duration::from_secs(120)),
667 "2m"
668 );
669 }
670
671 #[test]
672 fn test_format_uptime_hours() {
673 assert_eq!(
674 PoolStatusDisplay::format_uptime(Duration::from_secs(3725)),
675 "1h 2m"
676 );
677 assert_eq!(
678 PoolStatusDisplay::format_uptime(Duration::from_secs(3600)),
679 "1h"
680 );
681 }
682
683 #[test]
684 fn test_format_uptime_days() {
685 assert_eq!(
686 PoolStatusDisplay::format_uptime(Duration::from_secs(90000)),
687 "1d 1h"
688 );
689 assert_eq!(
690 PoolStatusDisplay::format_uptime(Duration::from_secs(86400)),
691 "1d"
692 );
693 }
694
695 #[test]
696 fn test_new_with_explicit_values() {
697 let display = PoolStatusDisplay::new(10, 5, 30, 3, 2);
698
699 assert_eq!(display.active, 10);
700 assert_eq!(display.idle, 5);
701 assert_eq!(display.max, 30);
702 assert_eq!(display.min, 3);
703 assert_eq!(display.pending, 2);
704 }
705
706 #[test]
707 fn test_builder_pattern() {
708 let display = PoolStatusDisplay::new(5, 3, 20, 2, 0)
709 .theme(Theme::light())
710 .width(80)
711 .name("TestPool")
712 .uptime(Duration::from_secs(60))
713 .with_acquisition_stats(100, 5)
714 .with_lifetime_stats(50, 45);
715
716 assert_eq!(display.width, Some(80));
717 assert_eq!(display.name, Some("TestPool".to_string()));
718 assert!(display.uptime.is_some());
719 assert_eq!(display.acquires, 100);
720 assert_eq!(display.timeouts, 5);
721 assert_eq!(display.created, 50);
722 assert_eq!(display.closed, 45);
723 }
724
725 #[test]
726 fn test_health_color_codes() {
727 assert!(PoolHealth::Healthy.color_code().contains("32")); assert!(PoolHealth::Busy.color_code().contains("33")); assert!(PoolHealth::Exhausted.color_code().contains("31")); }
731
732 #[test]
733 fn test_render_styled_contains_box_drawing() {
734 let display = PoolStatusDisplay::new(5, 3, 20, 2, 0).width(60);
735 let output = display.render_styled();
736
737 assert!(output.contains("┌"));
738 assert!(output.contains("┐"));
739 assert!(output.contains("└"));
740 assert!(output.contains("┘"));
741 assert!(output.contains("│"));
742 }
743
744 #[test]
745 fn test_render_styled_contains_progress_bar() {
746 let display = PoolStatusDisplay::new(10, 5, 20, 2, 0).width(60);
747 let output = display.render_styled();
748
749 assert!(output.contains("█") || output.contains("░"));
751 assert!(output.contains("50%")); }
753
754 #[test]
755 fn test_render_styled_tiny_width_does_not_panic() {
756 let display = PoolStatusDisplay::new(5, 3, 20, 2, 0).width(1);
757 let output = display.render_styled();
758
759 assert!(!output.is_empty());
760 assert!(output.contains("┌"));
761 assert!(output.contains("┘"));
762 }
763
764 #[test]
765 fn test_render_styled_narrow_width_name_is_truncated() {
766 let display = PoolStatusDisplay::new(5, 3, 20, 2, 0)
767 .name("ExtremelyLongPoolNameForNarrowLayout")
768 .width(24);
769 let output = display.render_styled();
770
771 assert!(output.contains("..."));
772 }
773
774 #[test]
775 fn test_from_stats_captures_all_values() {
776 let stats = MockPoolStats {
777 active: 7,
778 idle: 3,
779 max: 25,
780 min: 5,
781 pending: 1,
782 created: 150,
783 closed: 140,
784 acquires: 2000,
785 timeouts: 15,
786 };
787
788 let display = PoolStatusDisplay::from_stats(&stats);
789
790 assert_eq!(display.active, 7);
791 assert_eq!(display.idle, 3);
792 assert_eq!(display.max, 25);
793 assert_eq!(display.min, 5);
794 assert_eq!(display.pending, 1);
795 assert_eq!(display.created, 150);
796 assert_eq!(display.closed, 140);
797 assert_eq!(display.acquires, 2000);
798 assert_eq!(display.timeouts, 15);
799 }
800}