Skip to main content

netspeed_cli/
common.rs

1//! Common shared utilities used across download, upload, formatting, and progress modules.
2//!
3//! This module consolidates duplicated functionality to follow DRY principles:
4//! - Bandwidth calculation
5//! - Stream count determination
6//! - Distance formatting
7//! - Data size formatting
8//! - Terminal width detection
9
10use crate::test_config::TestConfig;
11use std::io::IsTerminal;
12
13/// Get the terminal width in columns, or a sensible default.
14///
15/// Returns `None` if stdout is not a terminal (piped output).
16/// Returns the width in character columns when available.
17/// Falls back to 100 columns if width cannot be determined.
18///
19/// # Examples
20///
21/// ```
22/// # use netspeed_cli::common::get_terminal_width;
23/// let width = get_terminal_width();
24/// // When not a TTY (doctest), returns None; unwrap_or default ≥ 80
25/// assert!(width.unwrap_or(100) >= 80);
26/// ```
27#[must_use]
28pub fn get_terminal_width() -> Option<u16> {
29    if !std::io::stdout().is_terminal() {
30        return None;
31    }
32    terminal_size::terminal_size().map(|(w, _)| w.0)
33}
34
35/// Get the terminal width with a minimum and maximum bound.
36///
37/// Ensures the width is within reasonable bounds for formatting.
38/// Returns at least `min_width` and at most `max_width`.
39/// If terminal width cannot be determined, returns `default_width`.
40///
41/// # Examples
42///
43/// ```
44/// # use netspeed_cli::common::get_terminal_width_bounded;
45/// let width = get_terminal_width_bounded(60, 120, 80);
46/// assert!(width >= 60 && width <= 120);
47/// ```
48#[must_use]
49pub fn get_terminal_width_bounded(min_width: u16, max_width: u16, default_width: u16) -> u16 {
50    match get_terminal_width() {
51        Some(w) => w.clamp(min_width, max_width),
52        None => default_width,
53    }
54}
55
56/// Calculate bandwidth in bits per second from bytes transferred and elapsed time.
57///
58/// # Examples
59///
60/// ```
61/// # use netspeed_cli::common::calculate_bandwidth;
62/// let bps = calculate_bandwidth(10_000_000, 2.0);
63/// assert_eq!(bps, 40_000_000.0);
64/// ```
65#[must_use]
66pub fn calculate_bandwidth(total_bytes: u64, elapsed_secs: f64) -> f64 {
67    if elapsed_secs > 0.0 {
68        // Safe: total_bytes is bytes transferred during a test lasting seconds;
69        // cannot approach 2^53 (~9 PB) where f64 loses precision.
70        (total_bytes as f64 * 8.0) / elapsed_secs
71    } else {
72        0.0
73    }
74}
75
76/// Determine number of concurrent streams based on single connection flag.
77///
78/// Returns 1 for single connection mode, 4 for multi-stream mode.
79///
80/// # Examples
81///
82/// ```
83/// # use netspeed_cli::common::determine_stream_count;
84/// assert_eq!(determine_stream_count(true), 1);
85/// assert_eq!(determine_stream_count(false), 4);
86/// ```
87#[deprecated(
88    since = "0.9.0",
89    note = "Use TestConfig::stream_count_for(single) instead"
90)]
91#[must_use]
92pub fn determine_stream_count(single: bool) -> usize {
93    TestConfig::stream_count_for(single)
94}
95
96/// Format distance consistently: 1 decimal for < 100 km, 0 decimals for >= 100 km.
97///
98/// # Examples
99///
100/// ```
101/// # use netspeed_cli::common::format_distance;
102/// assert_eq!(format_distance(50.5), "50.5 km");
103/// assert_eq!(format_distance(150.5), "150 km");
104/// ```
105#[must_use]
106pub fn format_distance(km: f64) -> String {
107    if km < 100.0 {
108        format!("{km:.1} km")
109    } else {
110        format!("{km:.0} km")
111    }
112}
113
114/// Format byte count into a human-readable string (KB, MB, GB).
115///
116/// # Examples
117///
118/// ```
119/// # use netspeed_cli::common::format_data_size;
120/// assert!(format_data_size(512).contains("KB"));
121/// assert!(format_data_size(1_048_576).contains("MB"));
122/// assert!(format_data_size(1_073_741_824).contains("GB"));
123/// ```
124#[must_use]
125pub fn format_data_size(bytes: u64) -> String {
126    // Safe: bytes is u64, but human-readable formatting only needs ~3 significant
127    // digits. Even 1 TB = 1e12, well under 2^53 where f64 loses precision.
128    if bytes < 1024 * 1024 {
129        format!("{:.1} KB", bytes as f64 / 1024.0)
130    } else if bytes < 1024 * 1024 * 1024 {
131        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
132    } else {
133        format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
134    }
135}
136
137/// Validate an IPv4 address string.
138///
139/// # Examples
140///
141/// ```
142/// # use netspeed_cli::common::is_valid_ipv4;
143/// assert!(is_valid_ipv4("192.168.1.1"));
144/// assert!(!is_valid_ipv4("999.999.999.999"));
145/// ```
146#[must_use]
147pub fn is_valid_ipv4(s: &str) -> bool {
148    let parts: Vec<&str> = s.split('.').collect();
149    if parts.len() != 4 {
150        return false;
151    }
152    parts.iter().all(|p| p.parse::<u8>().is_ok())
153}
154
155/// Render a horizontal bar chart using Unicode block characters.
156///
157/// `value` and `max` define the proportion. `width` is the bar length in chars.
158/// Returns filled (`█`) and empty (`░`) segments. Pure text — callers apply
159/// color via `owo_colors` for consistency.
160///
161/// # Examples
162///
163/// ```
164/// # use netspeed_cli::common::bar_chart;
165/// let bar = bar_chart(50.0, 100.0, 10);
166/// assert_eq!(bar.chars().count(), 10);
167/// ```
168#[must_use]
169pub fn bar_chart(value: f64, max: f64, width: usize) -> String {
170    if max <= 0.0 || width == 0 {
171        return "░".repeat(width);
172    }
173    let pct = (value / max).clamp(0.0, 1.0);
174    // Safe: pct is 0.0–1.0, width is typically ≤200, so result fits in usize.
175    let filled = (pct * width as f64).round().clamp(0.0, usize::MAX as f64) as usize;
176    let empty = width.saturating_sub(filled);
177    format!("{}{}", "█".repeat(filled), "░".repeat(empty))
178}
179
180// ── Tabular Data Formatting ──────────────────────────────────────────────────
181
182/// Format a numeric value with fixed-width padding for vertical alignment.
183/// Pads with leading spaces so numbers right-align in columns.
184///
185/// # Arguments
186/// * `value` — The numeric value to format
187/// * `width` — Total column width (including decimal point)
188/// * `decimals` — Number of decimal places
189///
190/// # Examples
191///
192/// ```
193/// # use netspeed_cli::common::tabular_number;
194/// assert_eq!(tabular_number(15.2, 8, 1), "    15.2");
195/// assert_eq!(tabular_number(150.0, 8, 1), "   150.0");
196/// ```
197#[must_use]
198pub fn tabular_number(value: f64, width: usize, decimals: usize) -> String {
199    if decimals == 0 {
200        // Safe: tabular numbers are speeds/counts — always non-negative.
201        // Clamp to 0..i64::MAX for explicitness and to catch negative inputs.
202        format!("{:>width$}", value.clamp(0.0, i64::MAX as f64) as i64)
203    } else {
204        format!("{value:>width$.decimals$}")
205    }
206}
207
208/// Format a speed value in Mbps with tabular alignment.
209/// Returns a fixed-width string like `"   150.00 Mb/s"` or `"     0.12 Gb/s"`.
210///
211/// # Arguments
212/// * `bps` — Bits per second
213/// * `total_width` — Total column width including unit
214///
215/// # Examples
216///
217/// ```
218/// # use netspeed_cli::common::format_speed_tabular;
219/// let s = format_speed_tabular(150_000_000.0, 14);
220/// assert!(s.contains("Mb/s"));
221/// ```
222#[must_use]
223pub fn format_speed_tabular(bps: f64, total_width: usize) -> String {
224    let (value, unit) = if bps >= 1_000_000_000.0 {
225        (bps / 1_000_000_000.0, "Gb/s")
226    } else if bps >= 1_000_000.0 {
227        (bps / 1_000_000.0, "Mb/s")
228    } else if bps >= 1_000.0 {
229        (bps / 1_000.0, "Kb/s")
230    } else {
231        // Safe: bps < 1000 is a small non-negative integer, well within i64 range.
232        return format!(
233            "{:>total_width$} b/s",
234            bps.clamp(0.0, i64::MAX as f64) as i64
235        );
236    };
237    let unit_width = unit.len();
238    let val_width = total_width.saturating_sub(unit_width + 1); // +1 for space
239    format!("{value:>val_width$.2} {unit}")
240}
241
242/// Format latency in ms with tabular alignment.
243/// Returns a fixed-width string like `"    12.1 ms"`.
244///
245/// # Examples
246///
247/// ```
248/// # use netspeed_cli::common::format_latency_tabular;
249/// let s = format_latency_tabular(12.1, 10);
250/// assert!(s.contains("ms"));
251/// ```
252#[must_use]
253pub fn format_latency_tabular(ms: f64, width: usize) -> String {
254    format!("{ms:>width$.1} ms")
255}
256
257/// Format jitter in ms with tabular alignment.
258#[must_use]
259pub fn format_jitter_tabular(ms: f64, width: usize) -> String {
260    format!("{ms:>width$.1} ms")
261}
262
263/// Format packet loss percentage with tabular alignment.
264#[must_use]
265pub fn format_loss_tabular(pct: f64, width: usize) -> String {
266    format!("{pct:>width$.1}%")
267}
268
269/// Format data size (bytes) with tabular alignment for data transfer amounts.
270/// Returns a fixed-width string like `"  15.0 MB"`.
271#[must_use]
272pub fn format_data_size_tabular(bytes: u64, width: usize) -> String {
273    // Safe: same rationale as format_data_size — byte counts are bounded.
274    let (value, unit) = if bytes < 1024 * 1024 {
275        (bytes as f64 / 1024.0, "KB")
276    } else if bytes < 1024 * 1024 * 1024 {
277        (bytes as f64 / (1024.0 * 1024.0), "MB")
278    } else {
279        let val = bytes as f64 / (1024.0 * 1024.0 * 1024.0);
280        let unit_width = 2; // "GB"
281        let val_width = width.saturating_sub(unit_width + 1);
282        return format!("{val:>val_width$.2} GB");
283    };
284    let unit_width = unit.len();
285    let val_width = width.saturating_sub(unit_width + 1);
286    format!("{value:>val_width$.1} {unit}")
287}
288
289/// Format duration with tabular alignment.
290#[must_use]
291pub fn format_duration_tabular(secs: f64, width: usize) -> String {
292    if secs < 60.0 {
293        format!("{secs:>width$.1}s")
294    } else {
295        // Safe: duration in seconds from a speed test; always non-negative and small.
296        let mins = (secs / 60.0).clamp(0.0, u64::MAX as f64) as u64;
297        let rem = secs % 60.0;
298        format!("{mins:>width$}m {rem:04.1}s")
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_calculate_bandwidth_normal() {
308        assert!((calculate_bandwidth(10_000_000, 2.0) - 40_000_000.0).abs() < f64::EPSILON);
309    }
310
311    #[test]
312    fn test_calculate_bandwidth_zero_elapsed() {
313        assert!(calculate_bandwidth(10_000_000, 0.0).abs() < f64::EPSILON);
314    }
315
316    #[test]
317    fn test_determine_stream_count_single() {
318        // Deprecated function test - still verify it works
319        #[allow(deprecated)]
320        {
321            assert_eq!(determine_stream_count(true), 1);
322        }
323    }
324
325    #[test]
326    fn test_determine_stream_count_multi() {
327        // Deprecated function test - still verify it works
328        #[allow(deprecated)]
329        {
330            assert_eq!(determine_stream_count(false), 4);
331        }
332    }
333
334    #[test]
335    fn test_format_distance_under_100() {
336        assert_eq!(format_distance(50.5), "50.5 km");
337        assert_eq!(format_distance(99.9), "99.9 km");
338    }
339
340    #[test]
341    fn test_format_distance_100_plus() {
342        assert_eq!(format_distance(100.0), "100 km");
343        assert_eq!(format_distance(150.5), "150 km");
344    }
345
346    #[test]
347    fn test_format_data_size_bytes() {
348        assert!(format_data_size(512).contains("KB"));
349    }
350
351    #[test]
352    fn test_format_data_size_kilobytes() {
353        assert!(format_data_size(500 * 1024).contains("KB"));
354    }
355
356    #[test]
357    fn test_format_data_size_megabytes() {
358        assert!(format_data_size(10 * 1024 * 1024).contains("MB"));
359    }
360
361    #[test]
362    fn test_format_data_size_gigabytes() {
363        assert!(format_data_size(4 * 1024 * 1024 * 1024).contains("GB"));
364    }
365
366    #[test]
367    fn test_is_valid_ipv4_valid() {
368        assert!(is_valid_ipv4("192.168.1.1"));
369        assert!(is_valid_ipv4("0.0.0.0"));
370        assert!(is_valid_ipv4("255.255.255.255"));
371    }
372
373    #[test]
374    fn test_is_valid_ipv4_invalid() {
375        assert!(!is_valid_ipv4("256.1.1.1"));
376        assert!(!is_valid_ipv4("1.2.3"));
377        assert!(!is_valid_ipv4("abc"));
378        assert!(!is_valid_ipv4(""));
379        assert!(!is_valid_ipv4("1.2.3.4.5"));
380    }
381
382    #[test]
383    fn test_bar_chart_half() {
384        let bar = bar_chart(50.0, 100.0, 10);
385        assert_eq!(bar.chars().count(), 10);
386        assert_eq!(bar, "█████░░░░░");
387    }
388
389    #[test]
390    fn test_bar_chart_full() {
391        let bar = bar_chart(100.0, 100.0, 10);
392        assert_eq!(bar.chars().count(), 10);
393        assert_eq!(bar, "██████████");
394    }
395
396    #[test]
397    fn test_bar_chart_empty_val() {
398        let bar = bar_chart(0.0, 100.0, 10);
399        assert_eq!(bar, "░░░░░░░░░░");
400    }
401
402    #[test]
403    fn test_bar_chart_zero_max() {
404        let bar = bar_chart(50.0, 0.0, 10);
405        assert_eq!(bar, "░░░░░░░░░░");
406    }
407
408    #[test]
409    fn test_bar_chart_zero_width() {
410        let bar = bar_chart(50.0, 100.0, 0);
411        assert_eq!(bar, "");
412    }
413
414    #[test]
415    fn test_bar_chart_over_max() {
416        let bar = bar_chart(150.0, 100.0, 10);
417        assert_eq!(bar, "██████████"); // clamped to 100%
418    }
419
420    #[test]
421    fn test_get_terminal_width_bounded_default() {
422        // Should return default width when not in terminal
423        let width = get_terminal_width_bounded(60, 120, 80);
424        assert!((60..=120).contains(&width));
425    }
426
427    #[test]
428    fn test_get_terminal_width_bounded_clamps() {
429        // When not in a terminal, returns the default value (100 in this case since
430        // we can't query terminal width, it uses default from terminal_size crate)
431        let def = get_terminal_width_bounded(80, 100, 90);
432        // Returns default width from terminal_size or the default parameter
433        assert!((80..=120).contains(&def));
434
435        let def2 = get_terminal_width_bounded(60, 80, 70);
436        assert!((60..=120).contains(&def2));
437    }
438
439    // ── Tabular formatting tests ──
440
441    #[test]
442    fn test_tabular_number_right_aligned() {
443        assert_eq!(tabular_number(15.2, 8, 1), "    15.2");
444        assert_eq!(tabular_number(150.0, 8, 1), "   150.0");
445        assert_eq!(tabular_number(1234.5, 8, 1), "  1234.5");
446    }
447
448    #[test]
449    fn test_tabular_number_zero_decimals() {
450        assert_eq!(tabular_number(42.0, 6, 0), "    42");
451        assert_eq!(tabular_number(1000.0, 6, 0), "  1000");
452    }
453
454    #[test]
455    fn test_format_speed_tabular_mbps() {
456        let s = format_speed_tabular(150_000_000.0, 14);
457        assert_eq!(s, "   150.00 Mb/s");
458        assert_eq!(s.len(), 14);
459    }
460
461    #[test]
462    fn test_format_speed_tabular_gbps() {
463        let s = format_speed_tabular(1_200_000_000.0, 14);
464        assert_eq!(s, "     1.20 Gb/s");
465        assert_eq!(s.len(), 14);
466    }
467
468    #[test]
469    fn test_format_speed_tabular_kbps() {
470        let s = format_speed_tabular(50_000.0, 14);
471        assert_eq!(s, "    50.00 Kb/s");
472        assert_eq!(s.len(), 14);
473    }
474
475    #[test]
476    fn test_format_latency_tabular() {
477        assert_eq!(format_latency_tabular(12.1, 10), "      12.1 ms");
478        assert_eq!(format_latency_tabular(150.5, 10), "     150.5 ms");
479    }
480
481    #[test]
482    fn test_format_jitter_tabular() {
483        assert_eq!(format_jitter_tabular(1.5, 10), "       1.5 ms");
484    }
485
486    #[test]
487    fn test_format_loss_tabular() {
488        assert_eq!(format_loss_tabular(0.0, 8), "     0.0%");
489        assert_eq!(format_loss_tabular(5.5, 8), "     5.5%");
490    }
491
492    #[test]
493    fn test_format_data_size_tabular_mb() {
494        let s = format_data_size_tabular(15 * 1024 * 1024, 10);
495        assert_eq!(s, "   15.0 MB");
496        assert_eq!(s.len(), 10);
497    }
498
499    #[test]
500    fn test_format_data_size_tabular_gb() {
501        let s = format_data_size_tabular(4 * 1024 * 1024 * 1024, 10);
502        assert_eq!(s, "   4.00 GB");
503        assert_eq!(s.len(), 10);
504    }
505
506    #[test]
507    fn test_format_duration_tabular_seconds() {
508        assert_eq!(format_duration_tabular(30.5, 8), "    30.5s");
509    }
510
511    #[test]
512    fn test_format_duration_tabular_minutes() {
513        let s = format_duration_tabular(90.5, 10);
514        assert!(s.contains('m'));
515    }
516
517    // Property-based tests via proptest
518    #[cfg(test)]
519    mod proptests {
520        use super::*;
521        use proptest::prelude::*;
522
523        proptest! {
524            #[test]
525            fn prop_bandwidth_always_non_negative(bytes in 0u64..u64::MAX, elapsed in 0.0f64..1e6) {
526                let result = calculate_bandwidth(bytes, elapsed);
527                prop_assert!(result >= 0.0, "bandwidth must never be negative");
528            }
529
530            #[test]
531            fn prop_bandwidth_zero_elapsed_returns_zero(bytes in 0u64..1_000_000) {
532                let result = calculate_bandwidth(bytes, 0.0);
533                prop_assert!(result.abs() < f64::EPSILON);
534            }
535
536            #[test]
537            fn prop_bandwidth_linear_scaling(bytes in 1u64..1_000_000) {
538                let r1 = calculate_bandwidth(bytes, 1.0);
539                let r2 = calculate_bandwidth(bytes, 2.0);
540                prop_assert!((r1 - 2.0 * r2).abs() < f64::EPSILON, "doubling time should halve bandwidth");
541            }
542
543            #[test]
544            fn prop_bar_chart_length(width in 1usize..200, value in 0.0f64..1000.0, max in 1.0f64..1000.0) {
545                let bar = bar_chart(value, max, width);
546                prop_assert_eq!(bar.chars().count(), width, "bar must have exactly width characters");
547            }
548
549            #[test]
550            fn prop_distance_always_ends_with_km(km in 0.0f64..10000.0) {
551                let result = format_distance(km);
552                prop_assert!(result.ends_with(" km"));
553            }
554
555            #[test]
556            fn prop_data_size_always_has_unit(bytes in 0u64..u64::MAX) {
557                let result = format_data_size(bytes);
558                prop_assert!(
559                    result.contains("KB") || result.contains("MB") || result.contains("GB"),
560                    "formatted size must contain a unit"
561                );
562            }
563        }
564    }
565}