Skip to main content

upstream_rs/output/
table.rs

1use indicatif::HumanBytes;
2
3use crate::output::{divider, meta, section, truncate_end};
4use crate::services::packaging::disk_impact::{
5    ByteEstimate, DiskImpact, SignedByteEstimate, SizeConfidence,
6};
7
8pub struct TransactionRow {
9    pub package: String,
10    pub old_version: String,
11    pub new_version: Option<String>,
12    pub net_change: SignedByteEstimate,
13    pub download: ByteEstimate,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct SizeImpactRow {
18    pub label: String,
19    pub value: SignedByteEstimate,
20}
21
22impl SizeImpactRow {
23    pub fn new(label: impl Into<String>, value: SignedByteEstimate) -> Self {
24        Self {
25            label: label.into(),
26            value,
27        }
28    }
29}
30
31impl TransactionRow {
32    pub fn new(
33        package: impl Into<String>,
34        old_version: impl Into<String>,
35        new_version: impl Into<String>,
36        net_change: SignedByteEstimate,
37        download: ByteEstimate,
38    ) -> Self {
39        Self {
40            package: package.into(),
41            old_version: old_version.into(),
42            new_version: Some(new_version.into()),
43            net_change,
44            download,
45        }
46    }
47
48    pub fn single_version(
49        package: impl Into<String>,
50        version: impl Into<String>,
51        net_change: SignedByteEstimate,
52        download: ByteEstimate,
53    ) -> Self {
54        Self {
55            package: package.into(),
56            old_version: version.into(),
57            new_version: None,
58            net_change,
59            download,
60        }
61    }
62}
63
64pub fn print_transaction_table(rows: &[TransactionRow], totals: &DiskImpact, net_label: &str) {
65    print_transaction_table_with_size_rows(rows, totals, net_label, &[]);
66}
67
68pub fn print_transaction_table_without_size(rows: &[TransactionRow]) {
69    let layout = TransactionTableLayout::from_rows_without_size(rows);
70    layout.print_header();
71    for row in rows {
72        layout.print_row(row);
73    }
74    println!();
75}
76
77pub fn print_transaction_table_with_size_rows(
78    rows: &[TransactionRow],
79    totals: &DiskImpact,
80    net_label: &str,
81    size_rows: &[SizeImpactRow],
82) {
83    let layout = TransactionTableLayout::from_rows(rows);
84    layout.print_header();
85    for row in rows {
86        layout.print_row(row);
87    }
88    layout.print_totals(totals, net_label, size_rows);
89}
90
91pub struct TransactionTableLayout {
92    package_label: String,
93    package_width: usize,
94    show_download: bool,
95    show_new_version: bool,
96    show_net_change: bool,
97    net_magnitude_width: usize,
98}
99
100const LIVE_UPGRADE_NET_MAGNITUDE_WIDTH: usize = 10;
101
102impl TransactionTableLayout {
103    pub fn from_rows(rows: &[TransactionRow]) -> Self {
104        let package_header = format!("Package ({})", rows.len());
105        let package_width = rows
106            .iter()
107            .map(|row| row.package.chars().count())
108            .chain(std::iter::once(package_header.chars().count()))
109            .max()
110            .unwrap_or(package_header.len())
111            .clamp(11, 44);
112        let show_download = rows.iter().any(|row| row.download.bytes != Some(0));
113        let show_new_version = rows.iter().any(|row| row.new_version.is_some());
114        let net_magnitude_width = rows
115            .iter()
116            .map(|row| compact_signed_magnitude(row.net_change).chars().count())
117            .chain(std::iter::once("Net Change".len().saturating_sub(1)))
118            .max()
119            .unwrap_or(9);
120
121        Self {
122            package_label: format!("Package ({})", rows.len()),
123            package_width,
124            show_download,
125            show_new_version,
126            show_net_change: true,
127            net_magnitude_width,
128        }
129    }
130
131    pub fn from_rows_without_size(rows: &[TransactionRow]) -> Self {
132        let mut layout = Self::from_rows(rows);
133        layout.show_download = false;
134        layout.show_net_change = false;
135        layout
136    }
137
138    pub fn upgrade_preview(package_width: usize) -> Self {
139        Self {
140            package_label: "Package".to_string(),
141            package_width: package_width.max("Package".len()).min(44),
142            show_download: true,
143            show_new_version: true,
144            show_net_change: true,
145            net_magnitude_width: LIVE_UPGRADE_NET_MAGNITUDE_WIDTH,
146        }
147    }
148
149    fn header_line(&self) -> String {
150        let version_header = if self.show_new_version {
151            "Old Version"
152        } else {
153            "Version"
154        };
155        let net_width = self.net_magnitude_width + 1;
156
157        let mut line = format!(
158            "{:<package_width$} {:<12}",
159            self.package_label,
160            version_header,
161            package_width = self.package_width
162        );
163        if self.show_new_version {
164            line.push_str(&format!(" {:<13}", "New Version"));
165        }
166        if self.show_net_change {
167            line.push_str(&format!(" {:>net_width$}", "Net Change"));
168        }
169        if self.show_download {
170            line.push_str(&format!(" {:>14}", "Download Size"));
171        }
172        line
173    }
174
175    fn divider_line(&self) -> String {
176        divider(self.header_line().len())
177    }
178
179    pub fn print_header(&self) {
180        println!("{}", self.header_line());
181        println!("{}", self.divider_line());
182    }
183
184    fn row_line(&self, row: &TransactionRow) -> String {
185        let mut line = format!(
186            "{:<package_width$} {:<12}",
187            truncate_end(&row.package, self.package_width),
188            truncate_end(&row.old_version, 12),
189            package_width = self.package_width
190        );
191        if self.show_new_version {
192            line.push_str(&format!(
193                " {:<13}",
194                truncate_end(row.new_version.as_deref().unwrap_or("-"), 13)
195            ));
196        }
197        if self.show_net_change {
198            line.push_str(&format!(
199                " {}",
200                format_compact_signed_cell(row.net_change, self.net_magnitude_width)
201            ));
202        }
203        if self.show_download {
204            line.push_str(&format!(" {:>14}", format_compact_unsigned(row.download)));
205        }
206        line
207    }
208
209    pub fn print_row(&self, row: &TransactionRow) {
210        print!("{}", self.row_line(row));
211        println!();
212    }
213
214    pub fn print_totals(&self, totals: &DiskImpact, net_label: &str, size_rows: &[SizeImpactRow]) {
215        println!();
216        if self.show_download && !matches!(totals.download.bytes, Some(0)) {
217            println!(
218                "Total Download Size:   {}",
219                format_compact_unsigned(totals.download)
220            );
221        }
222        if size_rows.is_empty() {
223            println!("{net_label:<22} {}", format_compact_signed(totals.net));
224        } else {
225            println!(
226                "{:<22} {}",
227                "Package files:",
228                format_compact_delta(totals.net)
229            );
230            for row in size_rows {
231                println!(
232                    "{:<22} {}",
233                    format!("{}:", row.label),
234                    format_compact_delta(row.value)
235                );
236            }
237            println!(
238                "{:<22} {}",
239                "Net disk change:",
240                format_compact_signed(total_disk_change(totals.net, size_rows))
241            );
242        }
243        println!();
244    }
245}
246
247pub fn print_disk_impact(impact: &DiskImpact, include_download: bool) {
248    print_disk_impact_with_size_rows(impact, &[], include_download);
249}
250
251pub fn print_disk_impact_with_size_rows(
252    impact: &DiskImpact,
253    size_rows: &[SizeImpactRow],
254    include_download: bool,
255) {
256    println!("{}", section("Size impact:"));
257    if include_download && !matches!(impact.download.bytes, Some(0)) {
258        println!(
259            "  {} {}",
260            meta("Download:"),
261            format_unsigned(impact.download)
262        );
263    }
264    if size_rows.is_empty() {
265        println!(
266            "  {} {}",
267            meta("Net disk change:"),
268            format_signed(impact.net)
269        );
270        return;
271    }
272
273    println!(
274        "  {} {}",
275        meta("Package files:"),
276        format_signed_delta(impact.net)
277    );
278    for row in size_rows {
279        println!(
280            "  {} {}",
281            meta(format!("{}:", row.label)),
282            format_signed_delta(row.value)
283        );
284    }
285    println!(
286        "  {} {}",
287        meta("Net disk change:"),
288        format_signed(total_disk_change(impact.net, size_rows))
289    );
290}
291
292fn total_disk_change(
293    package_files: SignedByteEstimate,
294    size_rows: &[SizeImpactRow],
295) -> SignedByteEstimate {
296    size_rows
297        .iter()
298        .fold(package_files, |total, row| total + row.value)
299}
300
301fn format_compact_unsigned(value: ByteEstimate) -> String {
302    match value.bytes {
303        Some(bytes) => format!("{}", HumanBytes(bytes)),
304        None => "unknown".to_string(),
305    }
306}
307
308fn format_compact_signed(value: SignedByteEstimate) -> String {
309    match value.bytes {
310        Some(bytes) => {
311            let magnitude = HumanBytes(bytes.unsigned_abs() as u64);
312            if bytes < 0 {
313                format!("-{magnitude}")
314            } else {
315                format!("{magnitude}")
316            }
317        }
318        None => "unknown".to_string(),
319    }
320}
321
322fn format_compact_delta(value: SignedByteEstimate) -> String {
323    match value.bytes {
324        Some(bytes) if bytes > 0 => format!("+{}", HumanBytes(bytes as u64)),
325        Some(bytes) if bytes < 0 => format!("-{}", HumanBytes(bytes.unsigned_abs() as u64)),
326        Some(_) => "no change".to_string(),
327        None => "unknown".to_string(),
328    }
329}
330
331fn format_compact_signed_cell(value: SignedByteEstimate, magnitude_width: usize) -> String {
332    match value.bytes {
333        Some(bytes) => {
334            let sign = if bytes < 0 { "-" } else { " " };
335            let magnitude = compact_signed_magnitude(value);
336            format!("{sign}{magnitude:<magnitude_width$}")
337        }
338        None => format!(" {:<magnitude_width$}", "unknown"),
339    }
340}
341
342fn compact_signed_magnitude(value: SignedByteEstimate) -> String {
343    match value.bytes {
344        Some(bytes) => {
345            let magnitude = HumanBytes(bytes.unsigned_abs() as u64);
346            format!("{magnitude}")
347        }
348        None => "unknown".to_string(),
349    }
350}
351
352fn format_unsigned(value: ByteEstimate) -> String {
353    match value.bytes {
354        Some(bytes) => format!(
355            "{}{}",
356            HumanBytes(bytes),
357            confidence_suffix(value.confidence)
358        ),
359        None => "unknown".to_string(),
360    }
361}
362
363fn format_signed(value: SignedByteEstimate) -> String {
364    match value.bytes {
365        Some(0) => format!("no change{}", confidence_suffix(value.confidence)),
366        Some(bytes) if bytes > 0 => {
367            format!(
368                "{}{}",
369                HumanBytes(bytes as u64),
370                confidence_suffix(value.confidence)
371            )
372        }
373        Some(bytes) => format!(
374            "-{}{}",
375            HumanBytes(bytes.unsigned_abs() as u64),
376            confidence_suffix(value.confidence)
377        ),
378        None => "unknown".to_string(),
379    }
380}
381
382fn format_signed_delta(value: SignedByteEstimate) -> String {
383    match value.bytes {
384        Some(bytes) if bytes > 0 => format!(
385            "+{}{}",
386            HumanBytes(bytes as u64),
387            confidence_suffix(value.confidence)
388        ),
389        Some(bytes) if bytes < 0 => format!(
390            "-{}{}",
391            HumanBytes(bytes.unsigned_abs() as u64),
392            confidence_suffix(value.confidence)
393        ),
394        Some(_) => format!("no change{}", confidence_suffix(value.confidence)),
395        None => "unknown".to_string(),
396    }
397}
398
399fn confidence_suffix(confidence: SizeConfidence) -> &'static str {
400    match confidence {
401        SizeConfidence::Exact => "",
402        SizeConfidence::Estimated => " (estimated)",
403        SizeConfidence::Unknown => "",
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use crate::services::packaging::disk_impact::{ByteEstimate, SignedByteEstimate};
410
411    use super::{
412        SizeImpactRow, TransactionRow, TransactionTableLayout, format_compact_delta, format_signed,
413        format_signed_delta, total_disk_change,
414    };
415
416    #[test]
417    fn live_upgrade_preview_keeps_download_column_aligned() {
418        let layout = TransactionTableLayout::upgrade_preview("stable/forge".len());
419        let row = TransactionRow::new(
420            "stable/forge",
421            "0.1.2",
422            "0.2.2",
423            SignedByteEstimate::estimated(-227_604),
424            ByteEstimate::exact(5 * 1024 * 1024),
425        );
426
427        let header = layout.header_line();
428        let rendered_row = layout.row_line(&row);
429
430        assert_eq!(header.len(), rendered_row.len());
431        assert_eq!(
432            header.find("Download Size").expect("download header") + "Download Size".len(),
433            rendered_row.find("5.00 MiB").expect("download size") + "5.00 MiB".len()
434        );
435        assert_eq!(layout.divider_line(), "-".repeat(header.len()));
436    }
437
438    #[test]
439    fn live_upgrade_preview_uses_computed_package_width() {
440        let layout = TransactionTableLayout::upgrade_preview("stable/gh".len());
441
442        assert_eq!(layout.package_width, "stable/gh".len());
443        assert!(layout.header_line().starts_with("Package   Old Version"));
444    }
445
446    #[test]
447    fn signed_disk_impact_uses_label_context() {
448        assert_eq!(
449            format_signed(SignedByteEstimate::estimated(5 * 1024 * 1024)),
450            "5.00 MiB (estimated)"
451        );
452        assert_eq!(
453            format_signed(SignedByteEstimate::exact(-5 * 1024 * 1024)),
454            "-5.00 MiB"
455        );
456    }
457
458    #[test]
459    fn auxiliary_size_rows_render_as_deltas() {
460        assert_eq!(
461            format_signed_delta(SignedByteEstimate::exact(5 * 1024 * 1024)),
462            "+5.00 MiB"
463        );
464        assert_eq!(
465            format_signed_delta(SignedByteEstimate::estimated(-5 * 1024 * 1024)),
466            "-5.00 MiB (estimated)"
467        );
468        assert_eq!(
469            format_compact_delta(SignedByteEstimate::exact(5 * 1024 * 1024)),
470            "+5.00 MiB"
471        );
472    }
473
474    #[test]
475    fn total_disk_change_includes_auxiliary_rows() {
476        let total = total_disk_change(
477            SignedByteEstimate::exact(-10),
478            &[SizeImpactRow::new(
479                "Rollback storage",
480                SignedByteEstimate::exact(10),
481            )],
482        );
483
484        assert_eq!(total.bytes, Some(0));
485    }
486}