kopiur_api/error.rs
1//! Typed validation errors shared by the admission webhook and the controller.
2//!
3//! Per ADR-0003 §2.2 (principle 8) and the SKILL "one validator, two callers"
4//! rule, cross-field validation lives in [`crate::validate`] as pure functions
5//! returning these typed errors. The webhook rejects at admission; the controller
6//! calls the same functions defensively before reconcile. The error type is the
7//! contract between them, so messages must be **actionable** — they end up in a
8//! `kubectl apply` rejection and in controller logs verbatim.
9//!
10//! ## Accumulation vs. fail-fast
11//!
12//! Per-field helpers (e.g. [`crate::validate::validate_repository_ref`]) are
13//! **fail-fast**: they return the first problem they find as `ValidationResult`.
14//! The per-CRD aggregate validators (`validate_backup_config`, …) **accumulate**
15//! every independent problem into a `Vec<ValidationError>` so a user fixing one
16//! manifest sees all issues at once rather than playing whack-a-mole across
17//! re-applies. Both styles share this one error enum.
18//!
19//! ```
20//! use kopiur_api::ValidationError;
21//!
22//! // Messages are written for a human reading a rejected `kubectl apply` — they
23//! // say what is wrong and why, embedding the offending value.
24//! let err = ValidationError::DiscoveredMustRetain { got: "Delete".to_string() };
25//! assert!(err.to_string().contains("origin: discovered"));
26//! assert!(err.to_string().contains("Delete"));
27//!
28//! // `ValidationResult` defaults its Ok type to `()` for the pass/fail case.
29//! let ok: kopiur_api::ValidationResult = Ok(());
30//! assert!(ok.is_ok());
31//! ```
32
33use thiserror::Error;
34
35/// A single cross-field validation failure. `PartialEq` so tests can assert the
36/// exact variant; messages are written for an end user reading a rejected apply.
37#[derive(Debug, Error, PartialEq, Eq, Clone)]
38pub enum ValidationError {
39 /// The maintenance `run-requested`/`run-mode` annotations are malformed
40 /// (message produced by [`crate::maintenance::parse_run_annotations`],
41 /// already what/why/fix).
42 #[error("invalid maintenance run annotation: {message}")]
43 InvalidRunAnnotation {
44 /// The shared parser's actionable message.
45 message: String,
46 },
47
48 /// A `Repository`/`ClusterRepository`'s own credential refs, or a consumer's
49 /// `repository.namespace`, set a namespace that the variant forbids.
50 /// For `kind: ClusterRepository`, `repository.namespace` MUST be absent
51 /// (ADR §3.2/§3.3) — the reference is cluster-scoped by name alone.
52 #[error(
53 "repository.namespace must not be set when repository.kind is ClusterRepository \
54 (a ClusterRepository is referenced by name only; got namespace {namespace:?})"
55 )]
56 ClusterRepoNamespaceForbidden {
57 /// The forbidden namespace that was set on the reference.
58 namespace: String,
59 },
60
61 /// A consumer namespace is not permitted by the target `ClusterRepository`'s
62 /// `allowedNamespaces` tenancy gate (ADR §3.2/§4.3).
63 #[error(
64 "namespace {namespace:?} is not in the allowedNamespaces of ClusterRepository {repo:?}"
65 )]
66 ConsumerNamespaceNotAllowed {
67 /// The consumer namespace that was denied.
68 namespace: String,
69 /// The `ClusterRepository` whose tenancy gate denied it.
70 repo: String,
71 },
72
73 /// A `Snapshot` with `origin: discovered` tried to set a `deletionPolicy` other
74 /// than `Retain`. Discovered snapshots are forced `Retain` so the operator
75 /// never deletes data it did not create (ADR §4.5).
76 #[error(
77 "origin: discovered snapshots must use deletionPolicy: Retain (got {got:?}); \
78 the operator never deletes snapshots it did not create"
79 )]
80 DiscoveredMustRetain {
81 /// The rejected `deletionPolicy` that was set (anything but `Retain`).
82 got: String,
83 },
84
85 /// A `Snapshot` with `origin: discovered` or `origin: adopted` set
86 /// `spec.onScheduleDelete`. Neither has an owning `SnapshotSchedule` for the
87 /// field to apply to: a `discovered` snapshot's owner is a repository, and an
88 /// `adopted` snapshot's owner is the `SnapshotPolicy` it was re-attached to —
89 /// a stamped cascade policy on either is meaningless, so it is forbidden,
90 /// exactly like a non-`Retain` `deletionPolicy` ([`Self::DiscoveredMustRetain`]).
91 #[error(
92 "origin: {origin} snapshots must not set onScheduleDelete (got {got:?}); a {origin} \
93 snapshot has no owning SnapshotSchedule for this field to apply to. Remove \
94 spec.onScheduleDelete"
95 )]
96 DiscoveredCannotSetOnScheduleDelete {
97 /// The origin that forbids the field (`"discovered"` or `"adopted"`).
98 origin: &'static str,
99 /// The rejected `onScheduleDelete` value that was set.
100 got: String,
101 },
102
103 /// A `Restore` with `source.identity` did not set `spec.repository`. Identity
104 /// sources cannot derive a repository, so it is required (ADR §3.6/§4.6).
105 #[error(
106 "restore source.identity requires spec.repository to be set (no Snapshot/SnapshotPolicy to derive it from)"
107 )]
108 RestoreSourceRepositoryRequired,
109
110 /// A `Repository`/`ClusterRepository` spec carried kopia-side (repo-level)
111 /// retention policy fields, which conflict with CR-driven GFS retention and
112 /// risk double-deletion (ADR §4.4 exclusivity).
113 #[error(
114 "inline kopia-side retention policy on a Repository spec is unsupported (field {field:?}); retention is driven exclusively by SnapshotPolicy.spec.retention (ADR §4.4)"
115 )]
116 InlineRetentionForbidden {
117 /// The offending repo-level retention field that was set.
118 field: String,
119 },
120
121 /// A cron expression failed to parse with the same parser the controller uses
122 /// at runtime, so it is rejected at apply time rather than at first reconcile
123 /// (ADR §4.1).
124 #[error("invalid cron expression {expr:?}: {reason}")]
125 InvalidCron {
126 /// The cron expression that failed to parse.
127 expr: String,
128 /// The parser's reason for rejecting it.
129 reason: String,
130 },
131
132 /// A schedule's `timezone` is not a recognized IANA timezone name (e.g. a typo
133 /// like `America/Chicgo`), rejected at apply time rather than silently falling
134 /// back to UTC at reconcile.
135 #[error("invalid timezone {name:?}: not a recognized IANA timezone name")]
136 InvalidTimezone {
137 /// The timezone string that failed to parse.
138 name: String,
139 },
140
141 /// Two fields that may not both be set were both set (e.g. a `Source` with
142 /// both `pvc` and `pvcSelector`).
143 #[error("fields {a:?} and {b:?} are mutually exclusive but both were set ({context})")]
144 MutuallyExclusive {
145 /// The first of the two conflicting fields.
146 a: String,
147 /// The second of the two conflicting fields.
148 b: String,
149 /// Where the conflict occurred (e.g. `"snapshot source"`), for the message.
150 context: String,
151 },
152
153 /// A required field (or "at least one of" surface) was empty.
154 #[error("missing required field: {field}")]
155 MissingRequiredField {
156 /// The required field (or "at least one of" surface) that was empty.
157 field: String,
158 },
159
160 /// A field was set but its value is malformed (e.g. an NFS export path that is
161 /// not absolute). The schema can't express the constraint, so the webhook does.
162 #[error("invalid value for {field}: {reason}")]
163 InvalidFieldValue {
164 /// The offending field (e.g. `"snapshot source nfs.path"`).
165 field: String,
166 /// What's wrong and how to fix it (e.g. `"must be an absolute path"`).
167 reason: String,
168 },
169
170 /// A `Repository`/`ClusterRepository` `identityDefaults` CEL expression
171 /// (`hostnameExpr` / `usernameExpr`) failed to **compile** (a syntax error, or
172 /// it exceeds the length budget). Surfaced at admission so a bad expression
173 /// never reaches status (ADR-0004 §5).
174 #[error("identity CEL expression {expr:?} failed to compile: {reason} (check the CEL syntax)")]
175 IdentityExprCompile {
176 /// The offending CEL expression.
177 expr: String,
178 /// The parser's reason (or the length-budget message).
179 reason: String,
180 },
181
182 /// A `Repository`/`ClusterRepository` `identityDefaults` CEL expression
183 /// referenced a variable outside its environment (e.g. a typo), or otherwise
184 /// failed to evaluate at admission (ADR-0004 §5). The environment is
185 /// `namespace`, `policyName`, `labels`, `annotations`, `cluster`.
186 #[error(
187 "identity CEL expression {expr:?} failed to evaluate: {reason} \
188 (available variables: namespace, policyName, labels, annotations, cluster)"
189 )]
190 IdentityExprEval {
191 /// The offending CEL expression.
192 expr: String,
193 /// The evaluation error (e.g. an undeclared-variable reference).
194 reason: String,
195 },
196
197 /// A `Repository`/`ClusterRepository` `identityDefaults` CEL expression
198 /// evaluated to a non-string value. `hostnameExpr`/`usernameExpr` must return
199 /// a string (ADR-0004 §5).
200 #[error(
201 "identity CEL expression {expr:?} must return a string, got {got} \
202 (hostnameExpr/usernameExpr must evaluate to a string)"
203 )]
204 IdentityExprType {
205 /// The offending CEL expression.
206 expr: String,
207 /// The CEL value type it returned instead of a string.
208 got: String,
209 },
210
211 /// `spec.server.auth.insecure` was selected without `acknowledgeInsecure: true`.
212 /// The no-auth server exposes full read/write/delete of the repository with no
213 /// login, so it must be explicitly acknowledged (server addendum).
214 #[error(
215 "spec.server.auth.insecure requires acknowledgeInsecure: true — a no-auth kopia \
216 server exposes full read/write/delete of every backup with no login"
217 )]
218 InsecureServerNotAcknowledged,
219
220 /// `spec.server.service.port` was set to an invalid value (0).
221 #[error("spec.server.service.port {port} is invalid (must be 1–65535)")]
222 InvalidServerPort {
223 /// The rejected port value (always `0` today).
224 port: u16,
225 },
226
227 /// A `ClusterRepository.spec.server` did not set the required target `namespace`.
228 #[error(
229 "spec.server.namespace is required for a ClusterRepository server (cluster-scoped \
230 resources have no implicit namespace)"
231 )]
232 ServerNamespaceRequired,
233
234 /// A label selector was supplied as the tenancy gate but the caller could not
235 /// provide the consumer namespace's labels to match against. We fail closed
236 /// (deny) rather than guess (ADR §3.2 — the webhook never trusts unfiltered
237 /// input).
238 #[error(
239 "ClusterRepository {repo:?} gates by label selector but namespace {namespace:?} labels \
240 were not available to evaluate; denying (fail-closed)"
241 )]
242 SelectorLabelsUnavailable {
243 /// The consumer namespace whose labels could not be evaluated.
244 namespace: String,
245 /// The `ClusterRepository` gating by label selector.
246 repo: String,
247 },
248
249 /// An UPDATE changed a repository field that is fixed at repository-creation
250 /// time (`encryption`, `create.splitter`, `create.hash`, `create.encryption`).
251 /// Kopia bakes these into the repository's on-disk format, so they cannot change
252 /// after creation — the webhook rejects the edit rather than silently ignoring it
253 /// (ADR-0005 §7).
254 #[error(
255 "{field} is immutable after repository creation (it is fixed in the kopia repository \
256 format); create a new Repository/ClusterRepository instead of editing this field"
257 )]
258 Immutable {
259 /// The immutable field that an UPDATE attempted to change.
260 field: String,
261 },
262
263 /// A `SnapshotPolicy`'s resolved kopia identity (`username@hostname[:path]`)
264 /// collides with an already-admitted `SnapshotPolicy`'s identity in the **same**
265 /// repository. Two recipes interleaving snapshots into one kopia identity corrupts
266 /// the snapshot history, so the webhook rejects the second one (ADR-0005 §6).
267 #[error(
268 "resolved identity {identity:?} collides with existing SnapshotPolicy {conflict:?} in the \
269 same repository; two policies must not share a kopia identity (give this policy a distinct \
270 spec.identity, or target a different repository)"
271 )]
272 IdentityCollision {
273 /// The resolved `username@hostname[:path]` identity that collided.
274 identity: String,
275 /// `namespace/name` of the already-admitted conflicting `SnapshotPolicy`.
276 conflict: String,
277 },
278
279 /// A kopia identity component (`username` or `hostname`) — whether an explicit
280 /// `spec.identity` override or the value an `identityDefaults` CEL expression
281 /// resolved to — contains a character that breaks kopia's
282 /// `username@hostname:path` contract. kopia parses a source on the **first** `@`
283 /// and **first** `:` with no escaping, so an embedded `@`, `:`, ASCII whitespace,
284 /// or control character silently misparses the identity into a *different* one
285 /// (or makes the snapshot un-findable on `snapshot list --source`). Rejected at
286 /// admission/resolution so it never reaches a mover Job. Shape-only — every other
287 /// character (dots, dashes, slashes, unicode letters) is allowed.
288 #[error(
289 "{field} {value:?} is not a valid kopia identity component: {reason} \
290 (kopia parses username@hostname:path on the first @ and first :, with no escaping)"
291 )]
292 IdentityComponentInvalid {
293 /// The offending field (e.g. `"spec.identity.username"` or `"resolved hostname"`).
294 field: String,
295 /// The rejected value.
296 value: String,
297 /// What's wrong (e.g. `"must not contain ':'"`).
298 reason: String,
299 },
300
301 /// A kopia identity `sourcePath` is malformed — empty, or it contains a newline
302 /// or an ASCII control character. The path is everything after the first `:` in
303 /// `username@hostname:path`; it may legitimately contain spaces and further `:`,
304 /// but not control characters, and must be non-empty when set.
305 #[error("{field} {value:?} is not a valid kopia source path: {reason}")]
306 IdentitySourcePathInvalid {
307 /// The offending field (e.g. `"spec.sources[0].sourcePathOverride"`).
308 field: String,
309 /// The rejected value.
310 value: String,
311 /// What's wrong and how to fix it.
312 reason: String,
313 },
314
315 /// A `Repository`/`ClusterRepository` `identityDefaults.cluster` is not a
316 /// valid RFC 1123 label, or contains a `.`. `cluster` is appended onto the namespace as
317 /// `<namespace>.<cluster>` for the default hostname, and
318 /// [`crate::identity::classify_hostname`] splits that hostname back apart at
319 /// the FIRST `.` — a `cluster` value with an embedded dot would just shift
320 /// which suffix classifies as "own cluster" rather than error, so the value is
321 /// rejected outright at admission instead of letting classification silently
322 /// disagree with intent.
323 #[error("identityDefaults.cluster {value:?} is not a valid cluster identity suffix: {reason}")]
324 ClusterNameInvalid {
325 /// The rejected cluster name.
326 value: String,
327 /// What's wrong and how to fix it (e.g. the RFC 1123 shape, or the dot-as-delimiter rule).
328 reason: String,
329 },
330
331 /// An UPDATE to a `SnapshotPolicy` would change its resolved kopia identity
332 /// (`username@hostname`, or a source's path) while the policy already has snapshot
333 /// history. New snapshots would land under the new kopia source: Kopiur's own GFS
334 /// retention pools ALL of a policy's `Snapshot` CRs regardless of identity, so the
335 /// old and new lineages don't get independent retention — they compete for the same
336 /// `keepLatest`/`keepDaily`/etc. buckets in one merged timeline, and restore/verify/
337 /// `fromPolicy` resolve only the new identity (the old lineage stays reachable via
338 /// `Restore.source.identity`). Rejected unless the change is acknowledged with the
339 /// `kopiur.home-operations.com/allow-identity-change` annotation
340 /// ([`crate::consts::ALLOW_IDENTITY_CHANGE_ANNOTATION`]).
341 #[error(
342 "this edit changes the policy's resolved kopia identity from {old:?} to {new:?}, but the \
343 policy already has snapshot history; new snapshots would land under a new kopia source \
344 while the old lineage's Snapshot CRs keep competing in the same GFS retention timeline \
345 (not independent retention), and restore/verify resolve only the new identity. To \
346 intentionally re-identify, set annotation \
347 kopiur.home-operations.com/allow-identity-change (any non-empty value)"
348 )]
349 IdentityWouldFork {
350 /// The previously-pinned identity (or source path).
351 old: String,
352 /// The new identity (or source path) this edit would resolve to.
353 new: String,
354 },
355
356 /// An UPDATE to a `Repository`/`ClusterRepository`'s `identityDefaults`
357 /// (`cluster`, `hostnameExpr`, or `usernameExpr`) would silently re-identify
358 /// every consumer `SnapshotPolicy` that resolves through those defaults —
359 /// identity is re-resolved from the LIVE repository on every reconcile/backup
360 /// (nothing about a *repository's* defaults is pinned the way a policy's own
361 /// `spec.identity` is), so this edit changes what each affected policy
362 /// resolves to on its very next backup with **no per-policy edit** to
363 /// acknowledge it. Exactly like [`Self::IdentityWouldFork`], new snapshots
364 /// would land under a new kopia lineage while the old lineage's `Snapshot`
365 /// CRs keep competing with it in the same merged GFS retention timeline
366 /// (Kopiur pools a policy's CRs regardless of identity, so nothing is
367 /// independently retained) — but here it happens fleet-wide in one apply.
368 /// Rejected unless the repository carries the
369 /// `kopiur.home-operations.com/allow-identity-change` annotation
370 /// ([`crate::consts::ALLOW_IDENTITY_CHANGE_ANNOTATION`]).
371 #[error(
372 "this edit changes identityDefaults, which would re-identify {} — new snapshots would \
373 fork to a new kopia lineage while the old and new lineages keep competing in the same \
374 GFS retention timeline (not independent retention), and restore/verify resolve only the \
375 new identity. To intentionally re-identify, set annotation \
376 kopiur.home-operations.com/allow-identity-change (any non-empty value, e.g. \
377 \"intentional\") on this repository; or pin an explicit spec.identity (both username \
378 AND hostname) on the affected policies first",
379 describe_identity_change_consumers(consumers)
380 )]
381 RepositoryIdentityWouldFork {
382 /// `namespace/name` of every consumer `SnapshotPolicy` with existing
383 /// snapshot history that this edit would re-identify (the message
384 /// truncates the rendered list to 5 names; this field carries all of
385 /// them).
386 consumers: Vec<String>,
387 },
388
389 /// A verification `successExpr` (ADR-0005 §4/§15) failed to **compile** (a
390 /// syntax error, or it exceeds the length budget). Surfaced at admission.
391 #[error("successExpr {expr:?} failed to compile: {reason} (check the CEL syntax)")]
392 SuccessExprCompile {
393 /// The offending CEL expression.
394 expr: String,
395 /// The parser's reason (or the length-budget message).
396 reason: String,
397 },
398
399 /// A verification `successExpr` referenced a variable outside its environment
400 /// (e.g. a typo), or otherwise failed to evaluate (ADR-0005 §4/§15). The
401 /// environment is `stats{files,bytes,errors}`, `snapshot`, `restored`.
402 #[error(
403 "successExpr {expr:?} failed to evaluate: {reason} \
404 (available variables: stats, snapshot, restored)"
405 )]
406 SuccessExprEval {
407 /// The offending CEL expression.
408 expr: String,
409 /// The evaluation error (e.g. an undeclared-variable reference).
410 reason: String,
411 },
412
413 /// A verification `successExpr` evaluated to a non-bool value. A `successExpr`
414 /// is a pass/fail predicate and must return a bool (ADR-0005 §4/§15).
415 #[error("successExpr {expr:?} must return a bool, got {got} (it is a pass/fail predicate)")]
416 SuccessExprType {
417 /// The offending CEL expression.
418 expr: String,
419 /// The CEL value type it returned instead of a bool.
420 got: String,
421 },
422
423 /// A `SnapshotPolicy.spec.preflight` check expression failed to compile (CEL
424 /// syntax error, or it exceeds the length budget). Surfaced at admission.
425 #[error(
426 "preflight check expression {expr:?} failed to compile: {reason} (check the CEL syntax)"
427 )]
428 PreflightExprCompile {
429 /// The offending CEL expression.
430 expr: String,
431 /// The parser's reason (or the length-budget message).
432 reason: String,
433 },
434
435 /// A preflight check expression referenced a variable outside its environment
436 /// (e.g. a typo), or otherwise failed to evaluate. The environment is the
437 /// `repository` and `maintenance` maps.
438 #[error(
439 "preflight check expression {expr:?} failed to evaluate: {reason} \
440 (available variables: repository.{{phase,ready,backendReachable,snapshotCountKnown,\
441 snapshotCount,indexBlobCountKnown,indexBlobCount,sizeBytesKnown,sizeBytes,\
442 lastHealthyKnown,lastHealthyAgeSeconds,lastReverifyKnown,lastReverifyAgeSeconds}}, \
443 maintenance.{{hasRun,lastSuccessAgeSeconds}})"
444 )]
445 PreflightExprEval {
446 /// The offending CEL expression.
447 expr: String,
448 /// The evaluation error (e.g. an undeclared-variable reference).
449 reason: String,
450 },
451
452 /// A preflight check expression evaluated to a non-bool value. A preflight
453 /// check is a pass/fail predicate and must return a bool.
454 #[error(
455 "preflight check expression {expr:?} must return a bool, got {got} \
456 (it is a pass/fail predicate)"
457 )]
458 PreflightExprType {
459 /// The offending CEL expression.
460 expr: String,
461 /// The CEL value type it returned instead of a bool.
462 got: String,
463 },
464
465 /// A `RepositoryReplication`'s `destination` backend is identical to its
466 /// source repository's backend (ADR-0005 §13(d)) — replicating a repository to
467 /// itself is a no-op (or worse, a loop). The webhook rejects it.
468 #[error(
469 "RepositoryReplication destination must differ from the source repository's backend \
470 (both resolved to the same {backend} target); pick a distinct destination backend"
471 )]
472 ReplicationDestinationSameAsSource {
473 /// The backend kind that both source and destination resolved to.
474 backend: String,
475 },
476
477 /// A namespaced `Repository` set `spec.maintenance.namespace`, which only
478 /// applies to a cluster-scoped `ClusterRepository` (a namespaced
479 /// `Repository`'s managed `Maintenance` always lives in the repository's own
480 /// namespace). ADR §3.7.
481 #[error(
482 "spec.maintenance.namespace ({namespace:?}) is only valid on a ClusterRepository; \
483 a namespaced Repository's managed Maintenance always lives in the repository's namespace"
484 )]
485 MaintenanceNamespaceOnNamespacedRepo {
486 /// The `spec.maintenance.namespace` value set on the namespaced `Repository`.
487 namespace: String,
488 },
489
490 /// `catalog.foreignSnapshots` is set, but there is no cluster identity to
491 /// classify a snapshot's origin against — `Ignore`/`Fallback` decide what
492 /// to do with a snapshot [`crate::identity::classify_hostname`] classifies
493 /// as another cluster's, and that classification is undecidable without
494 /// `identityDefaults.cluster`. Fires on either repository kind (`Repository`
495 /// or `ClusterRepository`) whose `identityDefaults.cluster` is unset —
496 /// kind-neutral wording, since the rule is identical either way.
497 #[error(
498 "catalog.foreignSnapshots is set, but classifying a snapshot as \"foreign\" requires a \
499 cluster identity (`identityDefaults.cluster`); without one there is nothing to compare \
500 it against. Fix: set identityDefaults.cluster, or remove catalog.foreignSnapshots"
501 )]
502 ForeignSnapshotsRequiresCluster,
503
504 /// A `ClusterRepository` sets both `identityDefaults.cluster` and
505 /// `catalog.fallbackNamespace` but leaves `catalog.foreignSnapshots`
506 /// unset. Both being set at once is a strong signal the fallback
507 /// collector is actually relied upon, so adopting a cluster identity must
508 /// never silently switch it off by defaulting to `Ignore` — the choice is
509 /// forced explicit instead.
510 #[error(
511 "catalog.foreignSnapshots must be set explicitly: both identityDefaults.cluster and \
512 catalog.fallbackNamespace are set, so adopting a cluster identity must not silently \
513 change what the fallback collector does. `Ignore` stops materializing foreign \
514 snapshots (existing rows in fallbackNamespace age out under catalog.retain); \
515 `Fallback` keeps collecting them there. Set one explicitly"
516 )]
517 ForeignSnapshotsChoiceRequired,
518}
519
520/// Render the consumer list for [`ValidationError::RepositoryIdentityWouldFork`]:
521/// the count, then up to [`CONSUMER_LIST_SHOWN`] `namespace/name`s, then
522/// `"and N more"` for the rest. Also reused verbatim by the webhook to build the
523/// admission WARNING when the same change is acknowledged, so the deny message
524/// and the warning always name the same policies the same way.
525///
526/// ```
527/// use kopiur_api::error::describe_identity_change_consumers;
528///
529/// assert_eq!(
530/// describe_identity_change_consumers(&["billing/pg".to_string()]),
531/// "1 SnapshotPolicy consumer(s) with existing snapshot history (billing/pg)",
532/// );
533/// let many: Vec<String> = (0..7).map(|i| format!("ns/pg-{i}")).collect();
534/// let rendered = describe_identity_change_consumers(&many);
535/// assert!(rendered.starts_with("7 SnapshotPolicy consumer(s)"));
536/// assert!(rendered.ends_with("and 2 more)"), "{rendered}");
537/// ```
538pub fn describe_identity_change_consumers(consumers: &[String]) -> String {
539 let total = consumers.len();
540 let mut names = consumers
541 .iter()
542 .take(CONSUMER_LIST_SHOWN)
543 .cloned()
544 .collect::<Vec<_>>()
545 .join(", ");
546 if total > CONSUMER_LIST_SHOWN {
547 names.push_str(&format!(", and {} more", total - CONSUMER_LIST_SHOWN));
548 }
549 format!("{total} SnapshotPolicy consumer(s) with existing snapshot history ({names})")
550}
551
552/// How many consumer names [`describe_identity_change_consumers`] spells out
553/// before collapsing the rest into `"and N more"`.
554const CONSUMER_LIST_SHOWN: usize = 5;
555
556/// Result alias for validators. Defaults to `()` for the common "pass/fail with no
557/// value" case.
558pub type ValidationResult<T = ()> = Result<T, ValidationError>;