1use std::borrow::Cow;
34use std::fmt;
35
36#[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 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 #[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 #[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 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
106fn trim_clause(s: &str) -> &str {
110 s.trim()
111 .trim_end_matches(|c: char| c == '.' || c == ';' || c == ',' || c.is_whitespace())
112}
113
114pub 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
177pub const MAX_MESSAGE_CHARS: usize = 400;
181
182fn 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 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 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 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 assert_eq!(
297 message_shape_issue("spec.retention keeps nothing; every keep* bucket is 0"),
298 None
299 );
300 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 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 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}