Skip to main content

livedisk/
bar.rs

1//! Proportional partition-layout bar — the `disk4n6 list` visual, modelled on
2//! `GParted` / Partition Wizard: a single fixed-width row where each partition
3//! occupies a slice of columns proportional to its size, unallocated gaps
4//! included, followed by a legend keying each slice to its partition.
5//!
6//! The column maths is the load-bearing part and is pure/testable: segment sizes
7//! map to integer column counts via the **largest-remainder method**, so the
8//! slices always sum to exactly the bar width regardless of rounding, and any
9//! non-empty partition gets at least one visible column when space allows.
10//! Colour is a presentation choice passed in by the caller (TTY → true), keeping
11//! this function deterministic under test.
12
13use core::fmt::Write as _;
14
15use super::{human_size, PhysicalDisk};
16
17/// ANSI 256-colour codes and the pipe-safe ASCII glyphs that stand in for them
18/// when stdout is not a terminal. A slot index selects the same entry from each,
19/// so a bar slice and its legend swatch always agree.
20const PALETTE: [u8; 8] = [39, 208, 46, 201, 226, 51, 129, 214];
21const GLYPHS: [char; 8] = ['#', '=', '+', '*', 'o', '~', 'x', '%'];
22/// Dim grey + `.` for unallocated space.
23const FREE_ANSI: u8 = 240;
24const FREE_GLYPH: char = '.';
25
26/// Append `w` columns of a slice to the bar: a coloured solid block (TTY) or the
27/// pipe-safe `ascii` glyph repeated.
28fn 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
39/// A one-character legend swatch matching [`push_slice`]'s colouring.
40fn 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/// One drawable slice of a disk: a partition, or an unallocated gap.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub(super) struct Segment {
51    /// Size in bytes (governs the slice width).
52    pub size_bytes: u64,
53    /// 1-based partition index for the legend; `None` for an unallocated gap.
54    pub index: Option<usize>,
55    /// Legend label (partition name + type, or "free").
56    pub label: String,
57}
58
59/// Decompose a disk into ordered drawable [`Segment`]s: each partition in
60/// on-disk order, with unallocated gaps (including leading and trailing free
61/// space) inserted where partitions do not cover the device.
62pub(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
94/// Allocate `total` columns across `weights` by the largest-remainder method:
95/// the returned widths sum to exactly `total` (when `total > 0` and the weights
96/// are not all zero), proportional to each weight, with every non-zero weight
97/// guaranteed at least one column when `total` is large enough to afford it.
98pub(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    // Largest-remainder (Hare): floor each share, then hand the leftover columns
106    // to the largest fractional remainders so the widths sum to exactly `total`.
107    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    // Guarantee a visible sliver for any non-empty segment that rounded to zero,
128    // borrowing a column from the currently-widest segment.
129    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/// Render the proportional bar plus legend for one disk. `width` is the bar's
141/// inner column count; `color` selects ANSI-coloured solid blocks (TTY) versus
142/// ASCII glyphs (pipe-safe). The disk's **largest partition** is drawn in the
143/// primary palette colour.
144#[must_use]
145pub fn render_disk_bar(disk: &PhysicalDisk, width: usize, color: bool) -> String {
146    disk_bar(disk, width, color, 0)
147}
148
149/// Per-disk bar with an explicit `accent` palette slot for the largest
150/// partition — used by [`render_listing`](crate::render_listing) so a disk's
151/// dominant partition matches the colour that disk has in the all-storage
152/// overview. The remaining partitions take the other palette colours in order.
153pub(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    // ── Bar ──────────────────────────────────────────────────────────────────
161    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    // ── Legend ───────────────────────────────────────────────────────────────
173    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
200/// Map each partition's 1-based index to a palette slot: the largest partition
201/// gets `accent`, the rest take the other palette colours in index order. The
202/// returned vector is indexed by partition index (slot 0 is unused).
203fn 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
226/// Render an at-a-glance overview comparing the **physical** disks' capacities —
227/// a horizontal bar chart, one disk per line, each bar's length proportional to
228/// that disk's size relative to the largest, so the biggest disk fills the row
229/// and the rest read as fractions of it. Each line also shows the absolute size
230/// and the disk's share of total storage. Synthesized disks (APFS containers,
231/// device-mapper) are excluded because they overlay physical space rather than
232/// add to it. Returns empty when fewer than two physical disks exist.
233pub 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        // Bar length scaled to the largest disk; a non-empty disk shows at least
261        // one column so it never vanishes next to a much larger one.
262        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        // Even thirds of 64 → 22/21/21 (largest remainder), never 63 or 65.
321        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        // A 1-byte partition next to a 1 TB one still gets a visible sliver.
333        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        // 100-byte disk: part at [10,30), part at [40,50), leaving free gaps at
347        // [0,10), [30,40), [60,100).
348        let d = disk(100, vec![part("p1", 10, 20, "A"), part("p2", 40, 20, "B")]);
349        let segs = segments(&d);
350        // free, p1, free, p2, free
351        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); // [30,40)
357        assert_eq!(segs[2].size_bytes, 10);
358        assert_eq!(segs[3].index, Some(2));
359        assert_eq!(segs[4].index, None); // [60,100)
360        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        // The bracketed bar's inner content is exactly `width` columns.
381        let inner: String = bar_line
382            .trim_start_matches('[')
383            .trim_end_matches(']')
384            .to_string();
385        assert_eq!(inner.chars().count(), 40);
386        // Legend names both partitions with sizes.
387        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        // p2 is the largest; with accent slot 2 ('+') it must carry that glyph,
403        // and a non-largest partition must not.
404        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    /// The `width` columns inside the first `[...]` on a line, and how many are
432    /// filled (non-space).
433    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        // disk0 4 TB + disk4 2 TB + disk5 8 TB = 14 TB; the APFS-synthesized
446        // disk3 (overlaying disk0) must NOT inflate the total or appear.
447        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        // One bar line per physical disk; each bar is exactly `width` columns.
462        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        // Lengths are proportional to size, scaled so the largest (disk5) fills.
468        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        // Per-disk share of total is shown; the synthesized disk is absent.
472        assert!(out.contains("57.1%")); // 8/14
473        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        // A lone physical disk plus synthesized overlays still has nothing to compare.
487        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}