Skip to main content

kopiur_kopia/
humanize.rs

1//! Turn kopia's raw stderr tail into a tight, operator-readable extract.
2//!
3//! kopia prints a lot to stderr: a running progress meter (`… 95.0% 12s left`),
4//! per-shard bookkeeping, and — buried among it — the one line that actually
5//! explains the failure. The raw tail also carries **per-attempt volatile
6//! fragments** like `.shards.tmp.9f3ac1`, whose randomness is exactly what once
7//! caused a reconcile hot-loop when it leaked into a status condition.
8//!
9//! [`humanize_tail`] extracts the salient error line(s), drops the progress
10//! noise, and strips the volatile temp fragments — deterministically, so the same
11//! failure always renders the same string. It feeds
12//! [`KopiaError`](crate::error::KopiaError)'s `Display` (which flows to Warning
13//! Events, `status.failure.message`, and logs); the full raw tail is preserved
14//! separately in the error's `stderr_tail` field for `status.failure.stderrTail`.
15//!
16//! Pure string work only — no allocation-heavy regex crate, no new dependencies.
17
18/// Render a process exit code for an operator, without the `Some(1)` `Debug`
19/// leak the old `{code:?}` interpolation produced.
20///
21/// ```
22/// use kopiur_kopia::humanize::exit_code_desc;
23/// assert_eq!(exit_code_desc(&Some(1)), "exit code 1");
24/// assert_eq!(exit_code_desc(&None), "no exit code (process killed by a signal)");
25/// ```
26pub 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
33/// Extract the actionable part of a kopia stderr tail: drop progress/noise lines,
34/// strip volatile temp-path fragments, and keep at most the last few salient
35/// lines (newest last), joined with `; ` so the result stays a single line.
36///
37/// Deterministic and volatile-free: the same failure yields the same string, and
38/// no per-attempt `.tmp.<hex>` / `.shards` fragment survives — safe to render into
39/// any operator-facing surface.
40///
41/// ```
42/// use kopiur_kopia::humanize::humanize_tail;
43///
44/// // Progress noise is dropped; the error line is kept and de-noised.
45/// let raw = "| 3 hashing, 12 hashed (2.1 GB), uploaded 1.9 GB (95.0%) 12s left\n\
46///            ERROR unable to create directory /repo/.shards.tmp.9f3ac1: permission denied";
47/// let out = humanize_tail(raw);
48/// assert_eq!(out, "unable to create directory /repo: permission denied");
49/// assert!(!out.contains(".tmp"));
50/// assert!(!out.contains(".shards"));
51/// ```
52pub fn humanize_tail(stderr: &str) -> String {
53    // Split on \n, and for each line take the text after the last carriage
54    // return — kopia's progress meter overwrites one line with \r, so the final
55    // segment is what a terminal would actually show.
56    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    // Prefer lines that actually name an error; otherwise fall back to all
69    // signal lines, and finally to the raw cleaned lines if everything looked
70    // like progress.
71    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        // All noise: keep the last cleaned line so we say *something* concrete.
82        return cleaned.last().cloned().unwrap_or_default();
83    };
84
85    // Keep the last up to 3 (newest = most actionable), dropping consecutive
86    // duplicates, then restore chronological order.
87    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
102/// Strip a leading kopia log prefix and any volatile temp-path fragments from one
103/// line, and trim it.
104fn clean_line(line: &str) -> String {
105    let stripped = strip_volatile_paths(line);
106    let trimmed = stripped.trim();
107    // kopia prefixes error lines with `ERROR ` (and occasionally `error: ` /
108    // `FATAL `); the prefix adds no information once the line is clearly the error.
109    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
117/// Remove kopia per-attempt temp-path segments (`…/.shards.tmp.<hex>`,
118/// `…/.tmp.<hex>`) so they never reach an operator-facing message. The whole
119/// path segment (including its leading `/`) is excised, leaving the stable prefix.
120fn strip_volatile_paths(line: &str) -> String {
121    let mut out = line.to_string();
122    while let Some(marker) = find_volatile_marker(&out) {
123        // Segment start: the last '/' before the marker (drop it too), else the
124        // marker itself.
125        let seg_start = out[..marker].rfind('/').unwrap_or(marker);
126        // Segment end: first whitespace or ':' at/after the marker.
127        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
136/// Locate the start of a volatile fragment: `.shards` (kopia shard dirs) or a
137/// `.tmp.` followed by at least two hex digits (a random temp suffix).
138fn 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
163/// A running-progress / bookkeeping line, not an error. Kopia's meter always
164/// carries a `%`, and its per-shard lines carry these hashing/upload counters.
165fn 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
189/// Whether a line names an actual failure (worth surfacing over a bare
190/// informational line like `Snapshotting app@host:/data …`).
191fn 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        // The plan's named fixture: progress interleaved with the real error,
246        // which carries a random `.shards.tmp.<hex>` segment.
247        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        // kopia overwrites one physical line with \r; only the final segment shows.
276        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        // No error keyword anywhere → fall back to the last signal lines.
292        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        // The strings the classifier is tested on must remain readable and keep
299        // the substrings that make them actionable.
300        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}