Skip to main content

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>;