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}