Skip to main content

upstream_rs/output/
status.rs

1use console::{StyledObject, style};
2use std::{collections::HashSet, fmt};
3
4const STATUS_CELL_WIDTH: usize = 7;
5const STATUS_SUBJECT_MARGIN: usize = 3;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Status {
9    Ok,
10    Warn,
11    Fail,
12    Plan,
13    Skip,
14}
15
16pub fn status_label(status: Status) -> StyledObject<&'static str> {
17    style_status(status_label_text(status), status)
18}
19
20pub fn status_cell(status: Status) -> StyledObject<String> {
21    style_status(
22        format!("{:<STATUS_CELL_WIDTH$}", status_label_text(status)),
23        status,
24    )
25}
26
27pub fn status_line(status: Status, subject: impl fmt::Display, detail: impl fmt::Display) {
28    println!("{}", status_line_text(status, subject, detail));
29}
30
31pub fn status_line_text(
32    status: Status,
33    subject: impl fmt::Display,
34    detail: impl fmt::Display,
35) -> String {
36    let subject = subject.to_string();
37    status_line_text_with_width(
38        status,
39        &subject,
40        detail,
41        status_subject_width([subject.as_str()]),
42    )
43}
44
45pub fn status_line_text_with_width(
46    status: Status,
47    subject: impl fmt::Display,
48    detail: impl fmt::Display,
49    subject_width: usize,
50) -> String {
51    format!(
52        "{} {:<subject_width$} {}",
53        status_cell(status),
54        subject.to_string(),
55        detail
56    )
57}
58
59pub fn status_subject_width<'a>(subjects: impl IntoIterator<Item = &'a str>) -> usize {
60    subjects
61        .into_iter()
62        .map(|subject| subject.chars().count())
63        .max()
64        .unwrap_or(0)
65        + STATUS_SUBJECT_MARGIN
66}
67
68pub fn summary_line(status: Status, detail: impl fmt::Display) {
69    println!("{} {}", status_cell(status), detail);
70}
71
72const ERROR_SUMMARY_MAX_CHARS: usize = 160;
73
74pub fn error_summary(err: &anyhow::Error) -> String {
75    error_summary_with_limit(err, ERROR_SUMMARY_MAX_CHARS)
76}
77
78pub fn error_summary_with_limit(err: &anyhow::Error, max: usize) -> String {
79    let mut seen = HashSet::new();
80    let mut parts = Vec::new();
81    for message in err.chain().map(|cause| cause.to_string()) {
82        if seen.insert(message.clone()) {
83            parts.push(message);
84        }
85    }
86
87    let Some(root) = parts.last() else {
88        return truncate_for_error(&err.to_string(), max);
89    };
90    let Some(parent) = parts.iter().rev().nth(1) else {
91        return truncate_for_error(root, max);
92    };
93
94    let value = format!("{parent}: {root}");
95    if value.chars().count() <= max {
96        return value;
97    }
98
99    let root_len = root.chars().count();
100    if root_len.saturating_add(2) >= max {
101        return truncate_for_error(root, max);
102    }
103
104    let parent_max = max - root_len - 2;
105    format!("{}: {}", truncate_for_error(parent, parent_max), root)
106}
107
108fn truncate_for_error(value: &str, max: usize) -> String {
109    let char_count = value.chars().count();
110    if char_count <= max {
111        return value.to_string();
112    }
113    if max <= 3 {
114        return ".".repeat(max);
115    }
116
117    let mut out = String::new();
118    for ch in value.chars().take(max - 3) {
119        out.push(ch);
120    }
121    out.push_str("...");
122    out
123}
124
125fn status_label_text(status: Status) -> &'static str {
126    match status {
127        Status::Ok => "[ok]",
128        Status::Warn => "[warn]",
129        Status::Fail => "[fail]",
130        Status::Plan => "[plan]",
131        Status::Skip => "[skip]",
132    }
133}
134
135fn style_status<T: fmt::Display>(text: T, status: Status) -> StyledObject<T> {
136    match status {
137        Status::Ok => style(text).green(),
138        Status::Warn => style(text).yellow(),
139        Status::Fail => style(text).red(),
140        Status::Plan => style(text).yellow(),
141        Status::Skip => style(text).dim(),
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::{
148        Status, error_summary, status_line_text, status_line_text_with_width, status_subject_width,
149    };
150
151    #[test]
152    fn error_summary_returns_single_layer_error() {
153        let err = anyhow::anyhow!("plain failure");
154
155        assert_eq!(error_summary(&err), "plain failure");
156    }
157
158    #[test]
159    fn status_line_uses_compact_subject_spacing() {
160        assert_eq!(
161            console::strip_ansi_codes(&status_line_text(Status::Ok, "gh", "upgraded to 2.94.0"))
162                .to_string(),
163            "[ok]    gh    upgraded to 2.94.0"
164        );
165    }
166
167    #[test]
168    fn status_line_can_align_to_batch_subject_width() {
169        let width = status_subject_width(["gh", "ripgrep"]);
170
171        assert_eq!(
172            console::strip_ansi_codes(&status_line_text_with_width(
173                Status::Ok,
174                "gh",
175                "upgraded",
176                width
177            ))
178            .to_string(),
179            "[ok]    gh         upgraded"
180        );
181        assert_eq!(
182            console::strip_ansi_codes(&status_line_text_with_width(
183                Status::Ok,
184                "ripgrep",
185                "upgraded",
186                width
187            ))
188            .to_string(),
189            "[ok]    ripgrep    upgraded"
190        );
191    }
192
193    #[test]
194    fn error_summary_returns_parent_and_root_cause() {
195        let err = anyhow::anyhow!("os error 5")
196            .context("file does not exist")
197            .context("Failed to install archive");
198
199        assert_eq!(error_summary(&err), "file does not exist: os error 5");
200    }
201
202    #[test]
203    fn error_summary_omits_outer_wrappers() {
204        let err = anyhow::anyhow!("Access is denied. (os error 5)")
205            .context("Failed to move extracted directory")
206            .context("Failed to install archive")
207            .context("Failed to perform installation for 'just'");
208
209        assert_eq!(
210            error_summary(&err),
211            "Failed to move extracted directory: Access is denied. (os error 5)"
212        );
213    }
214
215    #[test]
216    fn error_summary_truncates_parent_before_root() {
217        let err = anyhow::anyhow!("Access is denied. (os error 5)")
218            .context("Failed to move extracted directory from a very long source path")
219            .context("Failed to install archive");
220
221        let formatted = super::error_summary_with_limit(&err, 52);
222
223        assert!(formatted.ends_with(": Access is denied. (os error 5)"));
224        assert!(formatted.starts_with("Failed to move"));
225        assert!(formatted.contains("...: Access is denied"));
226        assert!(formatted.chars().count() <= 52);
227    }
228
229    #[test]
230    fn error_summary_truncates_root_when_root_is_too_long() {
231        let err = anyhow::anyhow!("this root cause is too long to fit").context("context");
232
233        let formatted = super::error_summary_with_limit(&err, 12);
234
235        assert_eq!(formatted, "this root...");
236    }
237}