1use owo_colors::OwoColorize;
2use unicode_width::UnicodeWidthStr;
3use upgate_domain::{ManagerId, PackageName, VersionText};
4
5use crate::theme::OutputTheme;
6
7const MAX_NAME_LENGTH: usize = 45;
8
9#[derive(Debug, Clone, PartialEq, Eq, Default)]
10pub struct OutcomeTable {
11 pub rows: Vec<OutcomeRow>,
12}
13
14impl OutcomeTable {
15 pub const fn new(rows: Vec<OutcomeRow>) -> Self {
16 Self { rows }
17 }
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct OutcomeRow {
22 pub status: OutcomeStatusView,
23 pub manager_id: ManagerId,
24 pub subject: OutcomeSubjectView,
25 pub versions: OutcomeVersionsView,
26 pub notes: Vec<OutcomeNote>,
27 pub visibility: OutcomeVisibility,
28}
29
30impl OutcomeRow {
31 pub const fn item(
32 status: OutcomeStatusView,
33 manager_id: ManagerId,
34 package_name: PackageName,
35 versions: OutcomeVersionsView,
36 ) -> Self {
37 Self {
38 status,
39 manager_id,
40 subject: OutcomeSubjectView::Item { package_name },
41 versions,
42 notes: Vec::new(),
43 visibility: OutcomeVisibility::Always,
44 }
45 }
46 pub const fn manager(status: OutcomeStatusView, manager_id: ManagerId) -> Self {
47 Self {
48 status,
49 manager_id,
50 subject: OutcomeSubjectView::Manager,
51 versions: OutcomeVersionsView::None,
52 notes: Vec::new(),
53 visibility: OutcomeVisibility::Always,
54 }
55 }
56 pub fn with_note(mut self, note: OutcomeNote) -> Self {
57 self.notes.push(note);
58 self
59 }
60 pub const fn with_visibility(mut self, visibility: OutcomeVisibility) -> Self {
61 self.visibility = visibility;
62 self
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum OutcomeStatusView {
68 Current,
69 Update,
70 Delayed,
71 Blocked,
72 Skipped,
73 Error,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum OutcomeSubjectView {
78 Manager,
79 Item { package_name: PackageName },
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum OutcomeVersionsView {
84 None,
85 Current {
86 version: VersionText,
87 },
88 Change {
89 from: VersionText,
90 to: OutcomeTargetView,
91 emphasis: OutcomeVersionEmphasis,
92 },
93}
94
95impl OutcomeVersionsView {
96 pub const fn change(from: VersionText, to: VersionText) -> Self {
97 Self::Change {
98 from,
99 to: OutcomeTargetView::Known(to),
100 emphasis: OutcomeVersionEmphasis::Target,
101 }
102 }
103 pub const fn manager_resolved(from: VersionText) -> Self {
104 Self::Change {
105 from,
106 to: OutcomeTargetView::ManagerResolved,
107 emphasis: OutcomeVersionEmphasis::Target,
108 }
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum OutcomeTargetView {
114 Known(VersionText),
115 ManagerResolved,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum OutcomeVersionEmphasis {
120 Current,
121 Target,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct OutcomeNote {
126 pub text: String,
127 pub visibility: OutcomeVisibility,
128}
129
130impl OutcomeNote {
131 pub fn normal(text: impl Into<String>) -> Self {
132 Self {
133 text: text.into(),
134 visibility: OutcomeVisibility::Always,
135 }
136 }
137 pub const fn with_visibility(mut self, visibility: OutcomeVisibility) -> Self {
138 self.visibility = visibility;
139 self
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum OutcomeVisibility {
145 Always,
146 VerboseOnly,
147}
148pub(crate) fn render_outcome_table(table: &OutcomeTable, theme: OutputTheme) -> String {
149 let rows = table
150 .rows
151 .iter()
152 .filter(|row| row.visibility == OutcomeVisibility::Always || theme.verbose)
153 .map(|row| outcome_table_row(row, theme))
154 .collect::<Vec<_>>();
155
156 if rows.is_empty() {
157 return String::new();
158 }
159
160 render_table_rows(&rows, theme.color()).join("\n")
161}
162pub(crate) fn version_label(version: &str) -> String {
163 match version.chars().next() {
164 Some(ch) if ch.is_ascii_digit() => format!("v{version}"),
165 _ => version.to_owned(),
166 }
167}
168pub(crate) fn changed_version_segment_index(from: &str, to: &str) -> Option<usize> {
169 let from_core = strip_v_prefix(from);
170 let to_core = strip_v_prefix(to);
171 let from_parts = from_core.split('.').collect::<Vec<_>>();
172 let to_parts = to_core.split('.').collect::<Vec<_>>();
173
174 first_changed_part_index(&from_parts, &to_parts)
175}
176fn render_to_version(from: &str, to: &str, theme: OutputTheme, emphasize: bool) -> String {
177 if !theme.color() {
178 return to.to_owned();
179 }
180
181 let from_core = strip_v_prefix(from);
182 let to_core = strip_v_prefix(to);
183 let from_parts = from_core.split('.').collect::<Vec<_>>();
184 let to_parts = to_core.split('.').collect::<Vec<_>>();
185 let changed_from = first_changed_part_index(&from_parts, &to_parts);
186
187 let mut output = String::new();
188 if to.starts_with('v') {
189 output.push_str(&style_version_part("v", false, emphasize));
190 }
191
192 for (index, part) in to_parts.iter().enumerate() {
193 if index > 0 {
194 output.push('.');
195 }
196
197 let changed = changed_from.is_some_and(|first| index >= first);
198 output.push_str(&style_version_part(part, changed, emphasize));
199 }
200
201 output
202}
203fn strip_ansi_codes(text: &str) -> String {
204 let mut stripped = String::with_capacity(text.len());
205 let mut chars = text.chars().peekable();
206
207 while let Some(ch) = chars.next() {
208 if ch == '\u{1b}' && chars.next_if_eq(&'[').is_some() {
209 for code_ch in chars.by_ref() {
210 if code_ch.is_ascii_alphabetic() {
211 break;
212 }
213 }
214 continue;
215 }
216
217 stripped.push(ch);
218 }
219
220 stripped
221}
222
223fn outcome_table_row(row: &OutcomeRow, theme: OutputTheme) -> RenderedOutcomeTableRow {
224 let name = match &row.subject {
225 OutcomeSubjectView::Manager => TableCell::new(String::new()),
226 OutcomeSubjectView::Item { package_name } => {
227 TableCell::new(render_name(package_name.as_str(), theme.color()))
228 }
229 };
230 let (current, target) = version_cells(&row.versions, theme);
231
232 RenderedOutcomeTableRow {
233 status: TableCell::new(status_prefix(row.status, theme.color())),
234 manager: TableCell::new(render_manager(&row.manager_id, theme.color())),
235 name,
236 current,
237 target,
238 note: TableCell::new(render_notes(&row.notes, theme)),
239 }
240}
241
242fn render_notes(notes: &[OutcomeNote], theme: OutputTheme) -> String {
243 let separator = if theme.color() {
244 "; ".dimmed().to_string()
245 } else {
246 "; ".to_owned()
247 };
248 notes
249 .iter()
250 .filter(|note| note.visibility == OutcomeVisibility::Always || theme.verbose)
251 .map(|note| render_note(note, theme.color()))
252 .collect::<Vec<_>>()
253 .join(&separator)
254}
255
256fn render_note(note: &OutcomeNote, color: bool) -> String {
257 if !color {
258 return note.text.clone();
259 }
260
261 note.text.dimmed().to_string()
262}
263
264fn version_cells(versions: &OutcomeVersionsView, theme: OutputTheme) -> (TableCell, TableCell) {
265 match versions {
266 OutcomeVersionsView::None => (TableCell::new(String::new()), TableCell::new(String::new())),
267 OutcomeVersionsView::Current { version } => (
268 TableCell::new(version_label(version.as_str())),
269 TableCell::new(String::new()),
270 ),
271 OutcomeVersionsView::Change { from, to, emphasis } => {
272 let from = version_label(from.as_str());
273 let to = match to {
274 OutcomeTargetView::Known(version) => version_label(version.as_str()),
275 OutcomeTargetView::ManagerResolved => manager_resolved_label().to_owned(),
276 };
277 (
278 TableCell::new(render_from_version(
279 &from,
280 theme.color(),
281 *emphasis == OutcomeVersionEmphasis::Current,
282 )),
283 TableCell::new(render_to_version(
284 &from,
285 &to,
286 theme,
287 *emphasis == OutcomeVersionEmphasis::Target,
288 )),
289 )
290 }
291 }
292}
293
294fn status_prefix(status: OutcomeStatusView, color: bool) -> String {
295 match status {
296 OutcomeStatusView::Current => {
297 if color {
298 format!("{} {}", "=".cyan().bold(), "Current".cyan().bold())
299 } else {
300 "= Current".to_owned()
301 }
302 }
303 OutcomeStatusView::Update => {
304 if color {
305 format!("{} {}", "+".green().bold(), "Update".green().bold())
306 } else {
307 "+ Update".to_owned()
308 }
309 }
310 OutcomeStatusView::Delayed => {
311 if color {
312 format!("{} {}", "~".yellow().bold(), "Delayed".yellow().bold())
313 } else {
314 "~ Delayed".to_owned()
315 }
316 }
317 OutcomeStatusView::Blocked => {
318 if color {
319 format!("{} {}", "x".red().bold(), "Blocked".red().bold())
320 } else {
321 "x Blocked".to_owned()
322 }
323 }
324 OutcomeStatusView::Skipped => {
325 if color {
326 format!("{} {}", "-".blue().bold(), "Skipped".blue().bold())
327 } else {
328 "- Skipped".to_owned()
329 }
330 }
331 OutcomeStatusView::Error => {
332 if color {
333 format!("{} {}", "!".red().bold(), "Error".red().bold())
334 } else {
335 "! Error".to_owned()
336 }
337 }
338 }
339}
340
341fn render_manager(manager_id: &ManagerId, color: bool) -> String {
342 if color {
343 format!("[{}]", manager_id.bold())
344 } else {
345 format!("[{manager_id}]")
346 }
347}
348
349fn render_name(name: &str, color: bool) -> String {
350 let name = if name.chars().count() > MAX_NAME_LENGTH {
351 let side_length = (MAX_NAME_LENGTH - 1) / 2;
352 let start = name.chars().take(side_length).collect::<String>();
353 let end = name
354 .chars()
355 .skip(name.chars().count() - side_length)
356 .collect::<String>();
357 format!("{start}…{end}")
358 } else {
359 name.to_owned()
360 };
361
362 if color { name.bold().to_string() } else { name }
363}
364
365fn render_from_version(version: &str, color: bool, emphasize: bool) -> String {
366 if color && emphasize {
367 return version.bold().to_string();
368 }
369
370 version.to_owned()
371}
372
373fn style_version_part(part: &str, changed: bool, emphasize: bool) -> String {
374 if changed {
375 if emphasize {
376 return part.blue().bold().to_string();
377 }
378 return part.blue().to_string();
379 }
380
381 if emphasize {
382 return part.bold().to_string();
383 }
384
385 part.to_owned()
386}
387
388fn strip_v_prefix(text: &str) -> &str {
389 text.strip_prefix('v').unwrap_or(text)
390}
391
392fn first_changed_part_index(from_parts: &[&str], to_parts: &[&str]) -> Option<usize> {
393 (0..from_parts.len().max(to_parts.len()))
394 .find(|&index| from_parts.get(index) != to_parts.get(index))
395}
396
397pub(crate) const fn manager_resolved_label() -> &'static str {
398 "selected by manager"
399}
400
401#[derive(Debug, Clone, PartialEq, Eq)]
402struct TableCell {
403 rendered: String,
404 plain: String,
405}
406
407impl TableCell {
408 fn new(rendered: impl Into<String>) -> Self {
409 let rendered = rendered.into();
410 let plain = strip_ansi_codes(&rendered);
411 Self { rendered, plain }
412 }
413
414 fn header(label: &'static str, color: bool) -> Self {
415 let rendered = if color {
416 label.magenta().bold().to_string()
417 } else {
418 label.to_owned()
419 };
420
421 Self {
422 rendered,
423 plain: label.to_owned(),
424 }
425 }
426
427 fn visible_width(&self) -> usize {
428 UnicodeWidthStr::width(self.plain.as_str())
429 }
430}
431
432#[derive(Debug, Clone, PartialEq, Eq)]
433struct RenderedOutcomeTableRow {
434 status: TableCell,
435 manager: TableCell,
436 name: TableCell,
437 current: TableCell,
438 target: TableCell,
439 note: TableCell,
440}
441
442impl RenderedOutcomeTableRow {
443 const fn cell(&self, column: OutcomeTableColumn) -> &TableCell {
444 match column {
445 OutcomeTableColumn::Status => &self.status,
446 OutcomeTableColumn::Manager => &self.manager,
447 OutcomeTableColumn::Name => &self.name,
448 OutcomeTableColumn::Current => &self.current,
449 OutcomeTableColumn::Target => &self.target,
450 OutcomeTableColumn::Note => &self.note,
451 }
452 }
453}
454
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456enum OutcomeTableColumn {
457 Status,
458 Manager,
459 Name,
460 Current,
461 Target,
462 Note,
463}
464
465impl OutcomeTableColumn {
466 fn header(self, color: bool) -> TableCell {
467 match self {
468 Self::Status => TableCell::header("Status", color),
469 Self::Manager => TableCell::header("Manager", color),
470 Self::Name => TableCell::header("Name", color),
471 Self::Current => TableCell::header("Current", color),
472 Self::Target => TableCell::header("Target", color),
473 Self::Note => TableCell::header("Note", color),
474 }
475 }
476}
477
478fn render_table_rows(rows: &[RenderedOutcomeTableRow], color: bool) -> Vec<String> {
479 let columns = outcome_table_columns(rows);
480 let headers = columns
481 .iter()
482 .map(|column| column.header(color))
483 .collect::<Vec<_>>();
484 let widths = table_widths(rows, &headers, &columns);
485
486 std::iter::once(render_table_cells(headers.iter(), &widths))
487 .chain(
488 rows.iter().map(|row| {
489 render_table_cells(columns.iter().map(|column| row.cell(*column)), &widths)
490 }),
491 )
492 .collect()
493}
494
495fn outcome_table_columns(rows: &[RenderedOutcomeTableRow]) -> Vec<OutcomeTableColumn> {
496 let mut columns = vec![OutcomeTableColumn::Status, OutcomeTableColumn::Manager];
497
498 if rows.iter().any(|row| !row.name.plain.is_empty()) {
499 columns.push(OutcomeTableColumn::Name);
500 }
501 if rows.iter().any(|row| !row.current.plain.is_empty()) {
502 columns.push(OutcomeTableColumn::Current);
503 }
504 if rows.iter().any(|row| !row.target.plain.is_empty()) {
505 columns.push(OutcomeTableColumn::Target);
506 }
507 if rows.iter().any(|row| !row.note.plain.is_empty()) {
508 columns.push(OutcomeTableColumn::Note);
509 }
510
511 columns
512}
513
514fn table_widths(
515 rows: &[RenderedOutcomeTableRow],
516 headers: &[TableCell],
517 columns: &[OutcomeTableColumn],
518) -> Vec<usize> {
519 columns
520 .iter()
521 .zip(headers)
522 .map(|(column, header)| {
523 rows.iter()
524 .map(|row| row.cell(*column).visible_width())
525 .max()
526 .unwrap_or(0)
527 .max(header.visible_width())
528 })
529 .collect()
530}
531
532fn render_table_cells<'a, I>(cells: I, widths: &[usize]) -> String
533where
534 I: IntoIterator<Item = &'a TableCell>,
535{
536 let mut line = String::new();
537
538 for (index, (cell, width)) in cells.into_iter().zip(widths).enumerate() {
539 if index > 0 {
540 line.push_str(" ");
541 }
542
543 line.push_str(&cell.rendered);
544
545 if index + 1 < widths.len() {
546 let padding = width.saturating_sub(cell.visible_width());
547 line.extend(std::iter::repeat_n(' ', padding));
548 }
549 }
550
551 line
552}