1pub fn exit_code_desc(code: &Option<i32>) -> String {
27 match code {
28 Some(c) => format!("exit code {c}"),
29 None => "no exit code (process killed by a signal)".to_string(),
30 }
31}
32
33pub fn humanize_tail(stderr: &str) -> String {
53 let cleaned: Vec<String> = stderr
57 .split('\n')
58 .map(|raw| raw.rsplit('\r').next().unwrap_or(raw))
59 .map(clean_line)
60 .filter(|l| !l.is_empty())
61 .collect();
62
63 if cleaned.is_empty() {
64 return "(kopia produced no stderr)".to_string();
65 }
66
67 let signal: Vec<&String> = cleaned.iter().filter(|l| !is_progress_noise(l)).collect();
68 let error_lines: Vec<&String> = signal
72 .iter()
73 .copied()
74 .filter(|l| has_error_keyword(l))
75 .collect();
76 let source: &[&String] = if !error_lines.is_empty() {
77 &error_lines
78 } else if !signal.is_empty() {
79 &signal
80 } else {
81 return cleaned.last().cloned().unwrap_or_default();
83 };
84
85 let mut kept: Vec<&str> = Vec::new();
88 for line in source.iter().rev() {
89 let s = line.as_str();
90 if kept.last() == Some(&s) {
91 continue;
92 }
93 kept.push(s);
94 if kept.len() == 3 {
95 break;
96 }
97 }
98 kept.reverse();
99 kept.join("; ")
100}
101
102fn clean_line(line: &str) -> String {
105 let stripped = strip_volatile_paths(line);
106 let trimmed = stripped.trim();
107 for prefix in ["ERROR ", "error: ", "FATAL ", "fatal: "] {
110 if let Some(rest) = trimmed.strip_prefix(prefix) {
111 return rest.trim().to_string();
112 }
113 }
114 trimmed.to_string()
115}
116
117fn strip_volatile_paths(line: &str) -> String {
121 let mut out = line.to_string();
122 while let Some(marker) = find_volatile_marker(&out) {
123 let seg_start = out[..marker].rfind('/').unwrap_or(marker);
126 let seg_end = out[marker..]
128 .find(|c: char| c.is_whitespace() || c == ':')
129 .map(|i| marker + i)
130 .unwrap_or(out.len());
131 out.replace_range(seg_start..seg_end, "");
132 }
133 out
134}
135
136fn find_volatile_marker(s: &str) -> Option<usize> {
139 let shards = s.find(".shards");
140 let tmp = {
141 let mut at = None;
142 let mut from = 0;
143 while let Some(rel) = s[from..].find(".tmp.") {
144 let idx = from + rel;
145 let after = &s.as_bytes()[idx + ".tmp.".len()..];
146 let hex = after.iter().take_while(|b| b.is_ascii_hexdigit()).count();
147 if hex >= 2 {
148 at = Some(idx);
149 break;
150 }
151 from = idx + ".tmp.".len();
152 }
153 at
154 };
155 match (shards, tmp) {
156 (Some(a), Some(b)) => Some(a.min(b)),
157 (Some(a), None) => Some(a),
158 (None, Some(b)) => Some(b),
159 (None, None) => None,
160 }
161}
162
163fn is_progress_noise(line: &str) -> bool {
166 let l = line.to_ascii_lowercase();
167 if l.contains('%') {
168 return true;
169 }
170 const PROGRESS_TOKENS: &[&str] = &[
171 " hashing,",
172 " hashed ",
173 " hashed(",
174 " cached ",
175 "uploaded ",
176 "estimating",
177 "estimated ",
178 " b/s",
179 "kb/s",
180 "mb/s",
181 "gb/s",
182 " eta ",
183 "s left",
184 "processed ",
185 ];
186 PROGRESS_TOKENS.iter().any(|t| l.contains(t))
187}
188
189fn has_error_keyword(line: &str) -> bool {
192 let l = line.to_ascii_lowercase();
193 const ERROR_TOKENS: &[&str] = &[
194 "error",
195 "fatal",
196 "failed",
197 "failure",
198 "unable",
199 "cannot",
200 "can't",
201 "denied",
202 "not found",
203 "not initialized",
204 "no such",
205 "does not exist",
206 "refused",
207 "unauthorized",
208 "forbidden",
209 "invalid",
210 "timeout",
211 "timed out",
212 "permission",
213 "no route",
214 "x509",
215 "tls:",
216 "certificate",
217 ];
218 ERROR_TOKENS.iter().any(|t| l.contains(t))
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn exit_code_renders_cleanly() {
227 assert_eq!(exit_code_desc(&Some(1)), "exit code 1");
228 assert_eq!(exit_code_desc(&Some(137)), "exit code 137");
229 assert_eq!(
230 exit_code_desc(&None),
231 "no exit code (process killed by a signal)"
232 );
233 }
234
235 #[test]
236 fn keeps_a_plain_error_line_unchanged() {
237 assert_eq!(
238 humanize_tail("repository is locked by another process"),
239 "repository is locked by another process"
240 );
241 }
242
243 #[test]
244 fn drops_progress_and_strips_volatile_temp_path() {
245 let raw = "| 3 hashing, 1092 hashed (2.1 GB), 0 cached, uploaded 1.9 GB (95.0%) 12s left\n\
248 ERROR unable to create directory /repo/.shards.tmp.9f3ac1: permission denied";
249 let out = humanize_tail(raw);
250 assert_eq!(out, "unable to create directory /repo: permission denied");
251 assert!(!out.contains(".tmp"), "{out}");
252 assert!(!out.contains(".shards"), "{out}");
253 assert!(!out.contains("9f3ac1"), "{out}");
254 assert!(out.contains("permission denied"), "{out}");
255 }
256
257 #[test]
258 fn strips_bare_tmp_hex_segment() {
259 let out = humanize_tail("wrote /cache/.tmp.a1b2 then failed to sync");
260 assert!(!out.contains(".tmp"), "{out}");
261 assert!(!out.contains("a1b2"), "{out}");
262 assert!(out.contains("failed to sync"), "{out}");
263 }
264
265 #[test]
266 fn prefers_the_error_line_over_informational_ones() {
267 let raw = "Snapshotting app@host:/pvc/data ...\n\
268 uploaded 500 MB (100.0%)\n\
269 ERROR upload error: connection reset by peer";
270 assert_eq!(humanize_tail(raw), "upload error: connection reset by peer");
271 }
272
273 #[test]
274 fn collapses_carriage_return_progress_overwrites() {
275 let raw = "hashing 10%\rhashing 50%\rhashing 100%\nERROR unable to open repository: dial tcp: connection refused";
277 assert_eq!(
278 humanize_tail(raw),
279 "unable to open repository: dial tcp: connection refused"
280 );
281 }
282
283 #[test]
284 fn empty_stderr_is_reported_not_blank() {
285 assert_eq!(humanize_tail(""), "(kopia produced no stderr)");
286 assert_eq!(humanize_tail(" \n \n"), "(kopia produced no stderr)");
287 }
288
289 #[test]
290 fn keeps_last_few_when_no_explicit_error_keyword() {
291 let raw = "step one\nstep two\nstep three\nstep four";
293 assert_eq!(humanize_tail(raw), "step two; step three; step four");
294 }
295
296 #[test]
297 fn real_classifier_fixtures_survive_humanization() {
298 for (raw, needle) in [
301 ("invalid repository password", "invalid repository password"),
302 (
303 "ERROR error connecting to repository: dial tcp ...",
304 "connecting to repository",
305 ),
306 (
307 "unable to open repository: lookup minio.storage.svc on 10.96.0.10:53: no such host",
308 "no such host",
309 ),
310 ("x509: certificate signed by unknown authority", "x509"),
311 (
312 "repository not initialized in the provided storage",
313 "not initialized",
314 ),
315 (
316 "can't connect to storage: error retrieving storage config from bucket \"kopiur\": Access Denied",
317 "access denied",
318 ),
319 ] {
320 let out = humanize_tail(raw);
321 assert!(
322 out.to_ascii_lowercase().contains(needle),
323 "humanized {out:?} lost {needle:?}"
324 );
325 }
326 }
327}