simple_fs/common/
pretty.rs

1// region:    --- Pretty Size
2
3use derive_more::From;
4
5#[derive(Debug, Default, Clone, From)]
6pub struct PrettySizeOptions {
7	#[from]
8	lowest_unit: SizeUnit,
9}
10
11impl From<&str> for PrettySizeOptions {
12	fn from(val: &str) -> Self {
13		SizeUnit::new(val).into()
14	}
15}
16
17impl From<&String> for PrettySizeOptions {
18	fn from(val: &String) -> Self {
19		SizeUnit::new(val).into()
20	}
21}
22
23impl From<String> for PrettySizeOptions {
24	fn from(val: String) -> Self {
25		SizeUnit::new(&val).into()
26	}
27}
28
29#[derive(Debug, Clone, Default)]
30pub enum SizeUnit {
31	#[default]
32	B,
33	KB,
34	MB,
35	GB,
36	TB,
37}
38
39impl SizeUnit {
40	/// Will return
41	pub fn new(val: &str) -> Self {
42		match val.to_uppercase().as_str() {
43			"B" => Self::B,
44			"KB" => Self::KB,
45			"MB" => Self::MB,
46			"GB" => Self::GB,
47			"TB" => Self::TB,
48			_ => Self::B,
49		}
50	}
51
52	/// Index of the unit in the `UNITS` array used by [`pretty_size_with_options`].
53	#[inline]
54	pub fn idx(&self) -> usize {
55		match self {
56			Self::B => 0,
57			Self::KB => 1,
58			Self::MB => 2,
59			Self::GB => 3,
60			Self::TB => 4,
61		}
62	}
63}
64
65impl From<&str> for SizeUnit {
66	fn from(val: &str) -> Self {
67		Self::new(val)
68	}
69}
70
71impl From<&String> for SizeUnit {
72	fn from(val: &String) -> Self {
73		Self::new(val)
74	}
75}
76
77impl From<String> for SizeUnit {
78	fn from(val: String) -> Self {
79		Self::new(&val)
80	}
81}
82
83/// Formats a byte size as a pretty, fixed-width (9 char) string with unit alignment.
84/// The output format is tailored to align nicely in monospaced tables.
85///
86/// - Number is always 6 character, always right aligned.
87/// - Empty char
88/// - Unit is always 2 chars, left aligned. So, for Byte, "B", it will be "B "
89/// - When below 1K Byte, do not have any digits
90/// - Otherwise, always 2 digit, rounded
91///
92/// ### Examples
93///
94/// `777`           -> `"   777 B "`
95/// `8777`          -> `"  8.78 KB"`
96/// `88777`         -> `" 88.78 KB"`
97/// `888777`        -> `"888.78 KB"`
98/// `2_345_678_900` -> `"  2.35 GB"`
99///
100/// NOTE: if in simple-fs, migh call it pretty_size()
101pub fn pretty_size(size_in_bytes: u64) -> String {
102	pretty_size_with_options(size_in_bytes, PrettySizeOptions::default())
103}
104
105/// Formats a byte size as a pretty, fixed-width (9 char) string with unit alignment.
106/// The output format is tailored to align nicely in monospaced tables.
107///
108/// - Number is always 6 character, always right aligned.
109/// - Empty char
110/// - Unit is always 2 chars, left aligned. So, for Byte, "B", it will be "B "
111/// - When below 1K Byte, do not have any digits
112/// - Otherwise, always 2 digit, rounded
113///
114/// ### PrettySizeOptions
115///
116/// - `lowest_unit`
117///   Define the lowest unit to consider,
118///   For example, if `MB`, then, B and KB will be expressed in decimal
119///   following the formatting rules.
120///
121/// NOTE: From String, &str, .. are implemented, so `PrettySizeOptions::from("MB")` will default to
122///       `PrettySizeOptions { lowest_unit: SizeUnit::MB }` (if string not match, will default to `SizeUnit::MB`)
123///
124/// ### Examples
125///
126/// `777`           -> `"   777 B "`
127/// `8777`          -> `"  8.78 KB"`
128/// `88777`         -> `" 88.78 KB"`
129/// `888777`        -> `"888.78 KB"`
130/// `2_345_678_900` -> `"  2.35 GB"`
131///
132/// NOTE: if in simple-fs, migh call it pretty_size()
133pub fn pretty_size_with_options(size_in_bytes: u64, options: impl Into<PrettySizeOptions>) -> String {
134	let options = options.into();
135
136	const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
137
138	// -- Step 1: shift the value so that we start at the minimum unit requested.
139	let min_unit_idx = options.lowest_unit.idx();
140	let mut size = size_in_bytes as f64;
141	for _ in 0..min_unit_idx {
142		size /= 1000.0;
143	}
144	let mut unit_idx = min_unit_idx;
145
146	// -- Step 2: continue bubbling up if the number is >= 1000.
147	while size >= 1000.0 && unit_idx < UNITS.len() - 1 {
148		size /= 1000.0;
149		unit_idx += 1;
150	}
151
152	let unit_str = UNITS[unit_idx];
153
154	// -- Step 3: formatting
155	if unit_idx == 0 {
156		// Bytes: integer, pad to 6, then add " B "
157		let number_str = format!("{size_in_bytes:>6}");
158		format!("{number_str} {unit_str} ")
159	} else {
160		// Units KB or above: 2 decimals, pad to width, then add " unit"
161		let number_str = format!("{size:>6.2}");
162		format!("{number_str} {unit_str}")
163	}
164}
165
166// endregion: --- Pretty Size
167
168// region:    --- Tests
169
170#[cfg(test)]
171mod tests {
172	type Result<T> = core::result::Result<T, Box<dyn std::error::Error>>; // For tests.
173
174	use super::*;
175
176	#[test]
177	fn test_pretty_size() -> Result<()> {
178		// -- Setup & Fixtures
179		let cases = [
180			(777, "   777 B "),
181			(8777, "  8.78 KB"),
182			(88777, " 88.78 KB"),
183			(888777, "888.78 KB"),
184			(888700, "888.70 KB"),
185			(200000, "200.00 KB"),
186			(2_000_000, "  2.00 MB"),
187			(900_000_000, "900.00 MB"),
188			(2_345_678_900, "  2.35 GB"),
189			(1_234_567_890_123, "  1.23 TB"),
190			(2_345_678_900_123_456, "  2.35 PB"),
191			(0, "     0 B "),
192		];
193
194		// -- Exec
195		for &(input, expected) in &cases {
196			let actual = pretty_size(input);
197			assert_eq!(actual, expected, "input: {input}");
198		}
199
200		Ok(())
201	}
202
203	#[test]
204	fn test_pretty_size_with_lowest_unit() -> Result<()> {
205		// -- Setup
206		let options = PrettySizeOptions::from("MB");
207		let cases = [
208			//
209			(88777, "  0.09 MB"),
210			(888777, "  0.89 MB"),
211			(1_234_567, "  1.23 MB"),
212		];
213
214		// -- Exec / Check
215		for &(input, expected) in &cases {
216			let actual = pretty_size_with_options(input, options.clone());
217			assert_eq!(actual, expected, "input: {input}");
218		}
219
220		Ok(())
221	}
222}
223
224// endregion: --- Tests