upstream_rs/output/
status.rs1use 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}