1use core::fmt::Write as _;
14
15use super::{human_size, PhysicalDisk};
16
17const PALETTE: [u8; 8] = [39, 208, 46, 201, 226, 51, 129, 214];
21const GLYPHS: [char; 8] = ['#', '=', '+', '*', 'o', '~', 'x', '%'];
22const FREE_ANSI: u8 = 240;
24const FREE_GLYPH: char = '.';
25
26fn push_slice(out: &mut String, color: bool, ansi: u8, ascii: char, w: usize) {
29 if w == 0 {
30 return;
31 }
32 if color {
33 let _ = write!(out, "\x1b[38;5;{ansi}m{}\x1b[0m", "█".repeat(w));
34 } else {
35 out.extend(std::iter::repeat_n(ascii, w));
36 }
37}
38
39fn swatch(color: bool, ansi: u8, ascii: char) -> String {
41 if color {
42 format!("\x1b[38;5;{ansi}m█\x1b[0m")
43 } else {
44 ascii.to_string()
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub(super) struct Segment {
51 pub size_bytes: u64,
53 pub index: Option<usize>,
55 pub label: String,
57}
58
59pub(super) fn segments(disk: &PhysicalDisk) -> Vec<Segment> {
63 let mut sorted: Vec<&super::Partition> = disk.partitions.iter().collect();
64 sorted.sort_by_key(|p| p.start_offset);
65
66 let mut segs = Vec::with_capacity(sorted.len() * 2 + 1);
67 let mut cursor = 0u64;
68 for (i, p) in sorted.iter().enumerate() {
69 if p.start_offset > cursor {
70 segs.push(Segment {
71 size_bytes: p.start_offset - cursor,
72 index: None,
73 label: "free".to_string(),
74 });
75 }
76 let ty = p.partition_type.as_deref().unwrap_or("-");
77 segs.push(Segment {
78 size_bytes: p.size_bytes,
79 index: Some(i + 1),
80 label: format!("{} {ty}", p.name),
81 });
82 cursor = cursor.max(p.start_offset.saturating_add(p.size_bytes));
83 }
84 if disk.size_bytes > cursor {
85 segs.push(Segment {
86 size_bytes: disk.size_bytes - cursor,
87 index: None,
88 label: "free".to_string(),
89 });
90 }
91 segs
92}
93
94pub(super) fn allocate_widths(weights: &[u64], total: usize) -> Vec<usize> {
99 let n = weights.len();
100 let sum: u128 = weights.iter().map(|&w| u128::from(w)).sum();
101 if n == 0 || total == 0 || sum == 0 {
102 return vec![0; n];
103 }
104
105 let mut widths = vec![0usize; n];
108 let mut remainders = vec![0u128; n];
109 let mut allocated = 0usize;
110 for (i, &w) in weights.iter().enumerate() {
111 let exact = u128::from(w) * total as u128;
112 widths[i] = (exact / sum) as usize;
113 remainders[i] = exact % sum;
114 allocated += widths[i];
115 }
116 let mut order: Vec<usize> = (0..n).collect();
117 order.sort_by(|&a, &b| remainders[b].cmp(&remainders[a]));
118 let mut leftover = total - allocated;
119 for &i in &order {
120 if leftover == 0 {
121 break;
122 }
123 widths[i] += 1;
124 leftover -= 1;
125 }
126
127 for i in 0..n {
130 if weights[i] > 0 && widths[i] == 0 {
131 if let Some(j) = (0..n).filter(|&j| widths[j] > 1).max_by_key(|&j| widths[j]) {
132 widths[j] -= 1;
133 widths[i] += 1;
134 }
135 }
136 }
137 widths
138}
139
140#[must_use]
145pub fn render_disk_bar(disk: &PhysicalDisk, width: usize, color: bool) -> String {
146 disk_bar(disk, width, color, 0)
147}
148
149pub(crate) fn disk_bar(disk: &PhysicalDisk, width: usize, color: bool, accent: usize) -> String {
154 let accent = accent % PALETTE.len();
155 let segs = segments(disk);
156 let weights: Vec<u64> = segs.iter().map(|s| s.size_bytes).collect();
157 let widths = allocate_widths(&weights, width);
158 let slots = partition_slots(&segs, accent);
159
160 let mut out = String::new();
162 out.push('[');
163 for (seg, &w) in segs.iter().zip(&widths) {
164 match seg.index {
165 Some(idx) => push_slice(&mut out, color, PALETTE[slots[idx]], GLYPHS[slots[idx]], w),
166 None => push_slice(&mut out, color, FREE_ANSI, FREE_GLYPH, w),
167 }
168 }
169 out.push(']');
170 out.push('\n');
171
172 let total = disk.size_bytes.max(1);
174 for seg in &segs {
175 let pct = seg.size_bytes as f64 * 100.0 / total as f64;
176 match seg.index {
177 Some(idx) => {
178 let _ = writeln!(
179 out,
180 " {} {idx:>2} {:<28} {:>10} {pct:>4.1}%",
181 swatch(color, PALETTE[slots[idx]], GLYPHS[slots[idx]]),
182 seg.label,
183 human_size(seg.size_bytes),
184 );
185 }
186 None => {
187 let _ = writeln!(
188 out,
189 " {} - {:<28} {:>10} {pct:>4.1}%",
190 swatch(color, FREE_ANSI, FREE_GLYPH),
191 "free (unallocated)",
192 human_size(seg.size_bytes),
193 );
194 }
195 }
196 }
197 out
198}
199
200fn partition_slots(segs: &[Segment], accent: usize) -> Vec<usize> {
204 let parts: Vec<(usize, u64)> = segs
205 .iter()
206 .filter_map(|s| s.index.map(|i| (i, s.size_bytes)))
207 .collect();
208 let largest = parts.iter().max_by_key(|(_, sz)| *sz).map(|(i, _)| *i);
209 let mut slots = vec![0usize; parts.len() + 1];
210 let mut next = 0usize;
211 for (i, _) in &parts {
212 slots[*i] = if Some(*i) == largest {
213 accent
214 } else {
215 while next % PALETTE.len() == accent {
216 next += 1;
217 }
218 let s = next % PALETTE.len();
219 next += 1;
220 s
221 };
222 }
223 slots
224}
225
226pub fn render_overview(disks: &[PhysicalDisk], width: usize, color: bool) -> String {
234 let physical: Vec<&PhysicalDisk> = disks.iter().filter(|d| !d.synthesized).collect();
235 if physical.len() < 2 {
236 return String::new();
237 }
238 let total: u64 = physical.iter().map(|d| d.size_bytes).sum();
239 let max = physical
240 .iter()
241 .map(|d| d.size_bytes)
242 .max()
243 .unwrap_or(0)
244 .max(1);
245 let name_w = physical
246 .iter()
247 .map(|d| d.name.chars().count())
248 .max()
249 .unwrap_or(0);
250
251 let mut out = String::new();
252 let _ = writeln!(
253 out,
254 "All storage ({} physical disks, {} total):",
255 physical.len(),
256 human_size(total)
257 );
258 for (i, d) in physical.iter().enumerate() {
259 let slot = i % PALETTE.len();
260 let mut fill = (u128::from(d.size_bytes) * width as u128 / u128::from(max)) as usize;
263 if d.size_bytes > 0 && fill == 0 {
264 fill = 1;
265 }
266 fill = fill.min(width);
267 let pct = d.size_bytes as f64 * 100.0 / total.max(1) as f64;
268
269 let mut bar = String::new();
270 push_slice(&mut bar, color, PALETTE[slot], GLYPHS[slot], fill);
271 bar.extend(std::iter::repeat_n(' ', width - fill));
272 let _ = writeln!(
273 out,
274 " {:<name_w$} [{bar}] {:>10} {pct:>4.1}%",
275 d.name,
276 human_size(d.size_bytes),
277 );
278 }
279 out
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::Partition;
286
287 fn part(name: &str, start: u64, size: u64, ty: &str) -> Partition {
288 Partition {
289 device_path: format!("/dev/{name}"),
290 name: name.to_string(),
291 start_offset: start,
292 size_bytes: size,
293 partition_type: Some(ty.to_string()),
294 mount_point: None,
295 filesystem: None,
296 label: None,
297 }
298 }
299
300 fn disk(size: u64, partitions: Vec<Partition>) -> PhysicalDisk {
301 PhysicalDisk {
302 device_path: "/dev/disk0".into(),
303 name: "disk0".into(),
304 size_bytes: size,
305 logical_sector_size: 512,
306 physical_sector_size: 512,
307 model: None,
308 serial: None,
309 removable: false,
310 read_only: false,
311 synthesized: false,
312 partitions,
313 }
314 }
315
316 #[test]
317 fn allocate_widths_sums_to_total() {
318 let w = allocate_widths(&[1, 1, 1], 64);
319 assert_eq!(w.iter().sum::<usize>(), 64);
320 assert_eq!(w, vec![22, 21, 21]);
322 }
323
324 #[test]
325 fn allocate_widths_is_proportional() {
326 let w = allocate_widths(&[900, 100], 100);
327 assert_eq!(w, vec![90, 10]);
328 }
329
330 #[test]
331 fn allocate_widths_gives_tiny_segment_at_least_one_column() {
332 let w = allocate_widths(&[1_000_000_000_000, 1], 50);
334 assert_eq!(w.iter().sum::<usize>(), 50);
335 assert!(w[1] >= 1, "tiny segment must be visible: {w:?}");
336 }
337
338 #[test]
339 fn allocate_widths_handles_all_zero_and_empty() {
340 assert_eq!(allocate_widths(&[], 10), Vec::<usize>::new());
341 assert_eq!(allocate_widths(&[0, 0], 10).iter().sum::<usize>(), 0);
342 }
343
344 #[test]
345 fn segments_inserts_unallocated_gaps() {
346 let d = disk(100, vec![part("p1", 10, 20, "A"), part("p2", 40, 20, "B")]);
349 let segs = segments(&d);
350 assert_eq!(segs.len(), 5);
352 assert_eq!(segs[0].index, None);
353 assert_eq!(segs[0].size_bytes, 10);
354 assert_eq!(segs[1].index, Some(1));
355 assert_eq!(segs[1].size_bytes, 20);
356 assert_eq!(segs[2].index, None); assert_eq!(segs[2].size_bytes, 10);
358 assert_eq!(segs[3].index, Some(2));
359 assert_eq!(segs[4].index, None); assert_eq!(segs[4].size_bytes, 40);
361 assert!(segs.last().unwrap().label.contains("free"));
362 }
363
364 #[test]
365 fn segments_no_gap_when_fully_covered() {
366 let d = disk(50, vec![part("p1", 0, 25, "A"), part("p2", 25, 25, "B")]);
367 let segs = segments(&d);
368 assert_eq!(segs.len(), 2);
369 assert!(segs.iter().all(|s| s.index.is_some()));
370 }
371
372 #[test]
373 fn render_bar_ascii_has_exact_width_and_legend() {
374 let d = disk(
375 100,
376 vec![part("p1", 0, 50, "TypeA"), part("p2", 50, 50, "TypeB")],
377 );
378 let out = render_disk_bar(&d, 40, false);
379 let bar_line = out.lines().next().unwrap();
380 let inner: String = bar_line
382 .trim_start_matches('[')
383 .trim_end_matches(']')
384 .to_string();
385 assert_eq!(inner.chars().count(), 40);
386 assert!(out.contains("p1"));
388 assert!(out.contains("p2"));
389 assert!(out.contains("TypeA"));
390 assert!(out.contains(&human_size(50)));
391 }
392
393 #[test]
394 fn render_bar_color_emits_ansi_escapes() {
395 let d = disk(100, vec![part("p1", 0, 100, "T")]);
396 let out = render_disk_bar(&d, 20, true);
397 assert!(out.contains("\x1b["), "color mode must emit ANSI escapes");
398 }
399
400 #[test]
401 fn disk_bar_paints_largest_partition_with_accent() {
402 let d = disk(100, vec![part("p1", 0, 10, "A"), part("p2", 10, 90, "B")]);
405 let out = disk_bar(&d, 40, false, 2);
406 let line = |name: &str| {
407 out.lines()
408 .find(|l| l.contains(name))
409 .unwrap()
410 .trim_start()
411 .chars()
412 .next()
413 .unwrap()
414 };
415 assert_eq!(
416 line("p2"),
417 GLYPHS[2],
418 "largest partition uses the accent glyph"
419 );
420 assert_ne!(line("p1"), GLYPHS[2], "non-largest avoids the accent glyph");
421 }
422
423 fn whole(name: &str, size: u64, synthesized: bool) -> PhysicalDisk {
424 let mut d = disk(size, vec![]);
425 d.name = name.into();
426 d.device_path = format!("/dev/{name}");
427 d.synthesized = synthesized;
428 d
429 }
430
431 fn bar_inner(line: &str) -> (usize, usize) {
434 let open = line.find('[').unwrap();
435 let close = line[open..].find(']').unwrap() + open;
436 let inner = &line[open + 1..close];
437 (
438 inner.chars().count(),
439 inner.chars().filter(|c| *c != ' ').count(),
440 )
441 }
442
443 #[test]
444 fn overview_is_a_per_disk_bar_chart_excluding_synthesized() {
445 let disks = vec![
448 whole("disk0", 4_000_000_000_000, false),
449 whole("disk3", 4_000_000_000_000, true),
450 whole("disk4", 2_000_000_000_000, false),
451 whole("disk5", 8_000_000_000_000, false),
452 ];
453 let out = render_overview(&disks, 80, false);
454 let header = out.lines().next().unwrap();
455 assert!(header.contains("3 physical disks"), "{header}");
456 assert!(
457 header.contains("14.0 TB"),
458 "total excludes synthesized: {header}"
459 );
460
461 let line = |name: &str| out.lines().find(|l| l.contains(name)).unwrap();
463 let (w0, f0) = bar_inner(line("disk0"));
464 let (w4, f4) = bar_inner(line("disk4"));
465 let (w5, f5) = bar_inner(line("disk5"));
466 assert_eq!((w0, w4, w5), (80, 80, 80), "every bar spans the full width");
467 assert_eq!(f5, 80, "largest disk fills its bar");
469 assert_eq!(f0, 40, "4 TB is half of the 8 TB max");
470 assert_eq!(f4, 20, "2 TB is a quarter of the 8 TB max");
471 assert!(out.contains("57.1%")); assert!(
474 !out.contains("disk3"),
475 "synthesized disk excluded from overview"
476 );
477 }
478
479 #[test]
480 fn overview_empty_when_fewer_than_two_physical_disks() {
481 assert_eq!(render_overview(&[], 70, false), "");
482 assert_eq!(
483 render_overview(&[whole("disk0", 1_000_000_000_000, false)], 70, false),
484 ""
485 );
486 let one_physical = vec![
488 whole("disk0", 1_000_000_000_000, false),
489 whole("disk1", 500_000_000, true),
490 ];
491 assert_eq!(render_overview(&one_physical, 70, false), "");
492 }
493}