Skip to main content

kopiur_api/
message.rs

1//! A tiny, dependency-free builder for **operator-facing** diagnostic messages.
2//!
3//! Kopiur's house style is that every message a human reads — an admission
4//! denial, a Warning Event `note`, a `status.conditions[].message` — says
5//! *what* failed, *why*, and *how to fix it*. That rule was enforced only by
6//! discipline and code review. [`Diagnostic`] makes the **shape** mechanical:
7//! it renders in one canonical order — **lead → why → fix** — so the specific
8//! problem is always first.
9//!
10//! Leading with the specific problem is not cosmetic. `kubectl get` truncates a
11//! condition message to its column width, and a `kubectl describe` reader scans
12//! the first clause of a wall of Events. If the lead is a generic
13//! `"reconcile failed: …"` or the raw first line of a stack of kopia stderr, the
14//! useful part is exactly what gets cut. A tight lead survives truncation; the
15//! *why* and *fix* trail behind it where there is room.
16//!
17//! This module is pure `core::fmt` — no `serde`, no `kube`, no `tokio` — so both
18//! `kopiur-api` (validators) and the controller/mover can build messages the same
19//! way without pulling controller-runtime into the API crate.
20//!
21//! ```
22//! use kopiur_api::message::Diagnostic;
23//!
24//! let msg = Diagnostic::new("a repository lock is held by another writer")
25//!     .fix("it usually clears on its own; retry")
26//!     .to_string();
27//! assert_eq!(msg, "a repository lock is held by another writer. Fix: it usually clears on its own; retry");
28//!
29//! // Lead alone is a valid message; why + fix are optional.
30//! assert_eq!(Diagnostic::new("nothing to do").to_string(), "nothing to do");
31//! ```
32
33use std::borrow::Cow;
34use std::fmt;
35
36/// A structured operator-facing message rendered as **lead → why → fix**.
37///
38/// Build it with [`Diagnostic::new`] (the lead — the specific problem, stated
39/// tightly), then optionally chain [`because`](Self::because) (why it happened)
40/// and [`fix`](Self::fix) (the concrete remedy: a field to set, a command to
41/// run, an expected value). `Display` renders the canonical string.
42///
43/// Content sources are `&'static str` (fixed prose) or `String` (an interpolated
44/// `format!`); a borrowed non-`'static` `&str` intentionally does not fit, which
45/// keeps callers from smuggling a short-lived borrow into a persisted status.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Diagnostic {
48    summary: Cow<'static, str>,
49    because: Option<Cow<'static, str>>,
50    fix: Option<Cow<'static, str>>,
51}
52
53impl Diagnostic {
54    /// Start a diagnostic from its **lead**: the specific problem, stated as a
55    /// short clause with no trailing punctuation (e.g. `"source PVC data/db does
56    /// not exist"`). This is the part that must survive `kubectl get` truncation.
57    pub fn new(summary: impl Into<Cow<'static, str>>) -> Self {
58        Self {
59            summary: summary.into(),
60            because: None,
61            fix: None,
62        }
63    }
64
65    /// Add the **why**: the cause or consequence, as a clause (no leading/trailing
66    /// period). Rendered after the lead, joined with ` — `.
67    #[must_use]
68    pub fn because(mut self, why: impl Into<Cow<'static, str>>) -> Self {
69        self.because = Some(why.into());
70        self
71    }
72
73    /// Add the **fix**: the concrete remedy, imperative (e.g. `"set
74    /// spec.create.enabled: true"`). Rendered last, introduced by `. Fix: `.
75    #[must_use]
76    pub fn fix(mut self, how: impl Into<Cow<'static, str>>) -> Self {
77        self.fix = Some(how.into());
78        self
79    }
80
81    /// The canonical rendered string (identical to `Display`/`to_string`).
82    pub fn render(&self) -> String {
83        self.to_string()
84    }
85}
86
87impl fmt::Display for Diagnostic {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.write_str(trim_clause(&self.summary))?;
90        if let Some(because) = &self.because {
91            let because = trim_clause(because);
92            if !because.is_empty() {
93                write!(f, " — {because}")?;
94            }
95        }
96        if let Some(fix) = &self.fix {
97            let fix = trim_clause(fix);
98            if !fix.is_empty() {
99                write!(f, ". Fix: {fix}")?;
100            }
101        }
102        Ok(())
103    }
104}
105
106/// Trim surrounding whitespace and trailing sentence punctuation from a clause so
107/// the renderer can add exactly the connectors it wants without doubling them
108/// (`"foo." + " — bar"` would read `"foo. — bar"`).
109fn trim_clause(s: &str) -> &str {
110    s.trim()
111        .trim_end_matches(|c: char| c == '.' || c == ';' || c == ',' || c.is_whitespace())
112}
113
114/// Report why `msg` violates the operator-message shape rules, or `None` if it is
115/// well-formed. Pure and always-compiled so tests in **any** crate (validators in
116/// `kopiur-api`, event/condition builders in the controller and mover) can assert
117/// their user-facing strings against one checker.
118///
119/// The rules are deliberately conservative — they flag the failure modes this
120/// overhaul removes, not stylistic taste, so they can run over the existing
121/// (already-good) messages without false positives:
122///
123/// * empty / whitespace-only,
124/// * a doubled space or a `". ."` gap (copy/format slips),
125/// * a leaked volatile kopia temp fragment (`.shards` / `.tmp.<hex>`) — these must
126///   only ever live in `status.failure.stderrTail`, never in a built message,
127/// * a generic filler lead (`"error:"`, `"failed:"`, …) that buries the specific
128///   problem behind a word truncation would waste,
129/// * a message longer than [`MAX_MESSAGE_CHARS`] — the anti-ramble cap: an
130///   operator-facing message that long is narrating, not naming what's wrong.
131pub fn message_shape_issue(msg: &str) -> Option<String> {
132    let trimmed = msg.trim();
133    if trimmed.is_empty() {
134        return Some("message is empty or whitespace-only".to_string());
135    }
136    if msg.contains("  ") {
137        return Some("message contains a doubled space".to_string());
138    }
139    if msg.contains(". .") {
140        return Some("message contains a `. .` gap".to_string());
141    }
142    if msg.contains(".shards") || contains_temp_hex_fragment(msg) {
143        return Some(
144            "message leaks a volatile kopia temp-path fragment (belongs only in \
145             status.failure.stderrTail)"
146                .to_string(),
147        );
148    }
149    let lead = trimmed.to_ascii_lowercase();
150    const FILLER_LEADS: &[&str] = &[
151        "error:",
152        "error ",
153        "failed:",
154        "failed ",
155        "failure:",
156        "an error occurred",
157        "unknown error",
158        "invalid input",
159    ];
160    for filler in FILLER_LEADS {
161        if lead.starts_with(filler) {
162            return Some(format!(
163                "lead starts with the generic filler {filler:?}; lead with the specific problem"
164            ));
165        }
166    }
167    if trimmed.chars().count() > MAX_MESSAGE_CHARS {
168        return Some(format!(
169            "message is {} chars (> {MAX_MESSAGE_CHARS}); it is narrating, not naming what's \
170             wrong — lead with the specific fault, then a tight why + fix",
171            trimmed.chars().count()
172        ));
173    }
174    None
175}
176
177/// The most an operator-facing message may be: enough for a packed what/why/fix,
178/// short enough to read at a glance in `kubectl describe`. Past this a message is
179/// almost always explaining rather than saying what is wrong.
180pub const MAX_MESSAGE_CHARS: usize = 400;
181
182/// Detect a kopia per-attempt temp suffix like `.tmp.9f3ac1` (a `.tmp.` followed
183/// by hex). These are random per run and are the classic volatility that must
184/// never reach a condition/event message.
185fn contains_temp_hex_fragment(msg: &str) -> bool {
186    let bytes = msg.as_bytes();
187    if let Some(pos) = msg.find(".tmp.") {
188        let after = &bytes[pos + ".tmp.".len()..];
189        // At least two hex digits immediately after `.tmp.` marks the random suffix.
190        let hex = after.iter().take_while(|b| b.is_ascii_hexdigit()).count();
191        return hex >= 2;
192    }
193    false
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn renders_lead_only() {
202        assert_eq!(
203            Diagnostic::new("source PVC data/db does not exist").to_string(),
204            "source PVC data/db does not exist"
205        );
206    }
207
208    #[test]
209    fn renders_lead_why_fix_in_order() {
210        let msg = Diagnostic::new("the storage backend denied access")
211            .because("the credentials Secret may lack permission, or the bucket does not exist")
212            .fix("verify the credentials Secret and that the bucket exists")
213            .to_string();
214        assert_eq!(
215            msg,
216            "the storage backend denied access — the credentials Secret may lack permission, \
217             or the bucket does not exist. Fix: verify the credentials Secret and that the \
218             bucket exists"
219        );
220        // The lead — the specific problem — is first, so it survives truncation.
221        assert!(msg.starts_with("the storage backend denied access"));
222    }
223
224    #[test]
225    fn fix_without_because() {
226        assert_eq!(
227            Diagnostic::new("a repository lock is held by another writer")
228                .fix("it usually clears on its own; retry")
229                .to_string(),
230            "a repository lock is held by another writer. Fix: it usually clears on its own; retry"
231        );
232    }
233
234    #[test]
235    fn trims_trailing_punctuation_so_connectors_do_not_double() {
236        // Callers may pass clauses with their own trailing period; the renderer
237        // must not produce `foo. — bar` or `bar.. Fix:`.
238        let msg = Diagnostic::new("the mover pod is stuck.")
239            .because("the securityContext is invalid for the namespace's Pod Security policy.")
240            .fix("fix mover.securityContext, then re-run.")
241            .to_string();
242        assert_eq!(
243            msg,
244            "the mover pod is stuck — the securityContext is invalid for the namespace's Pod \
245             Security policy. Fix: fix mover.securityContext, then re-run"
246        );
247        assert!(!msg.contains(". —"));
248        assert!(!msg.contains(".."));
249    }
250
251    #[test]
252    fn empty_optional_clauses_are_dropped() {
253        assert_eq!(
254            Diagnostic::new("nothing to do")
255                .because("   ")
256                .fix("")
257                .to_string(),
258            "nothing to do"
259        );
260    }
261
262    #[test]
263    fn accepts_static_and_owned() {
264        let name = "db";
265        let _owned = Diagnostic::new(format!("source PVC {name} missing"));
266        let _static = Diagnostic::new("source PVC missing");
267    }
268
269    #[test]
270    fn diagnostic_output_is_well_formed() {
271        let msg = Diagnostic::new("the repository backend is unreachable")
272            .because("the endpoint did not answer within the connect deadline")
273            .fix("check the endpoint/network and retry")
274            .to_string();
275        assert_eq!(message_shape_issue(&msg), None, "{msg}");
276    }
277
278    #[test]
279    fn shape_checker_flags_empty() {
280        assert!(message_shape_issue("").is_some());
281        assert!(message_shape_issue("   ").is_some());
282    }
283
284    #[test]
285    fn shape_checker_flags_doubled_space_and_gap() {
286        assert!(message_shape_issue("two  spaces").is_some());
287        assert!(message_shape_issue("a gap . . here").is_some());
288    }
289
290    #[test]
291    fn shape_checker_flags_generic_filler_lead() {
292        assert!(message_shape_issue("error: something went wrong").is_some());
293        assert!(message_shape_issue("failed: could not connect").is_some());
294        assert!(message_shape_issue("an error occurred while reconciling").is_some());
295        // A specific lead that merely contains those words later is fine.
296        assert_eq!(
297            message_shape_issue("spec.retention keeps nothing; every keep* bucket is 0"),
298            None
299        );
300        // The InvalidFieldValue prefix names the field, so it is specific enough.
301        assert_eq!(
302            message_shape_issue("invalid value for spec.sources[0].nfs.path: must be absolute"),
303            None
304        );
305    }
306
307    #[test]
308    fn shape_checker_flags_volatile_temp_fragments() {
309        assert!(
310            message_shape_issue("unable to create /repo/.shards.tmp.9f3ac1: permission denied")
311                .is_some()
312        );
313        assert!(message_shape_issue("wrote /cache/.tmp.a1b2 then failed").is_some());
314        // A plain path with no hex temp suffix is fine.
315        assert_eq!(
316            message_shape_issue("repository path /repo is not writable by the operator's UID"),
317            None
318        );
319    }
320
321    #[test]
322    fn shape_checker_flags_rambles() {
323        let ramble = "x".repeat(950);
324        assert!(message_shape_issue(&ramble).is_some());
325    }
326
327    #[test]
328    fn shape_checker_passes_representative_real_messages() {
329        // A sample of the existing house-style strings must pass unchanged — the
330        // checker enforces the failure modes we remove, not stylistic taste.
331        let samples = [
332            "repository.namespace must not be set when repository.kind is ClusterRepository \
333             (a ClusterRepository is referenced by name only; got namespace \"prod\")",
334            "the storage backend denied access; check the credentials Secret and that the \
335             bucket/container/path exists and is reachable",
336            "spec.server.auth.insecure requires acknowledgeInsecure: true — a no-auth kopia \
337             server exposes full read/write/delete of every backup with no login",
338            "invalid value for spec.sync.parallel: must be >= 1 (got 0)",
339        ];
340        for s in samples {
341            assert_eq!(message_shape_issue(s), None, "unexpected issue for: {s}");
342        }
343    }
344}