Skip to main content

upgate_presentation/
outcome.rs

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}