aster_forge_cloud_files_core/
capabilities.rs

1//! Parameterized backend, platform, and product-host capability negotiation.
2
3use std::num::NonZeroUsize;
4
5use crate::{CloudFilesCoreError, Result};
6
7/// Non-zero physical range alignment in bytes.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub struct Alignment(u64);
10
11impl Alignment {
12    /// Byte alignment with no additional physical constraint.
13    pub const ONE: Self = Self(1);
14
15    /// Creates a non-zero byte alignment.
16    #[must_use]
17    pub const fn new(bytes: u64) -> Option<Self> {
18        if bytes == 0 { None } else { Some(Self(bytes)) }
19    }
20
21    /// Returns the alignment in bytes.
22    #[must_use]
23    pub const fn get(self) -> u64 {
24        self.0
25    }
26
27    /// Combines two physical alignment requirements using their least common multiple.
28    /// # Errors
29    ///
30    /// Returns an error when validation fails or an underlying backend, store, or platform
31    /// operation fails.
32    pub fn intersection(self, other: Self) -> Result<Self> {
33        let divisor = greatest_common_divisor(self.0, other.0);
34        let left = self.0 / divisor;
35        let Some(bytes) = left.checked_mul(other.0) else {
36            return Err(CloudFilesCoreError::AlignmentIntersectionOverflow {
37                left: self.0,
38                right: other.0,
39            });
40        };
41        Ok(Self(bytes))
42    }
43}
44
45const fn greatest_common_divisor(mut left: u64, mut right: u64) -> u64 {
46    while right != 0 {
47        let remainder = left % right;
48        left = right;
49        right = remainder;
50    }
51    left
52}
53
54/// Stable identity properties exposed by a backend/adapter boundary.
55#[expect(
56    clippy::struct_excessive_bools,
57    reason = "identity guarantees are independent capabilities rather than mutually exclusive state"
58)]
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
60pub struct IdentityCapabilities {
61    /// Item identifiers remain stable for the supported lifetime.
62    pub stable_item_ids: bool,
63    /// Item identifiers do not encode the current path or filename.
64    pub path_independent_item_ids: bool,
65    /// Rename and move within one root preserve item identity.
66    pub same_root_move_preserves_identity: bool,
67    /// Move between roots can preserve item identity.
68    pub cross_root_move_preserves_identity: bool,
69}
70
71impl IdentityCapabilities {
72    /// Computes capabilities guaranteed by both sides of an adapter boundary.
73    #[must_use]
74    pub const fn intersection(self, other: Self) -> Self {
75        Self {
76            stable_item_ids: self.stable_item_ids && other.stable_item_ids,
77            path_independent_item_ids: self.path_independent_item_ids
78                && other.path_independent_item_ids,
79            same_root_move_preserves_identity: self.same_root_move_preserves_identity
80                && other.same_root_move_preserves_identity,
81            cross_root_move_preserves_identity: self.cross_root_move_preserves_identity
82                && other.cross_root_move_preserves_identity,
83        }
84    }
85
86    /// Validates the identity invariants required by the core model.
87    /// # Errors
88    ///
89    /// Returns an error when validation fails or an underlying backend, store, or platform
90    /// operation fails.
91    pub fn validate_core_requirements(self) -> Result<()> {
92        if !self.stable_item_ids {
93            return Err(CloudFilesCoreError::MissingRequiredCapability {
94                capability: "stable_item_ids",
95            });
96        }
97        if !self.path_independent_item_ids {
98            return Err(CloudFilesCoreError::MissingRequiredCapability {
99                capability: "path_independent_item_ids",
100            });
101        }
102        if !self.same_root_move_preserves_identity {
103            return Err(CloudFilesCoreError::MissingRequiredCapability {
104                capability: "same_root_move_preserves_identity",
105            });
106        }
107        Ok(())
108    }
109}
110
111/// Metadata and content revision guarantees.
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
113pub struct RevisionCapabilities {
114    /// Metadata and content can carry independently changing tokens.
115    pub separate_metadata_and_content: bool,
116    /// Metadata mutations accept a metadata revision precondition.
117    pub conditional_metadata_mutation: bool,
118    /// Content reads and mutations accept a content revision precondition.
119    pub conditional_content_access: bool,
120}
121
122impl RevisionCapabilities {
123    /// Computes revision guarantees shared by both sides.
124    #[must_use]
125    pub const fn intersection(self, other: Self) -> Self {
126        Self {
127            separate_metadata_and_content: self.separate_metadata_and_content
128                && other.separate_metadata_and_content,
129            conditional_metadata_mutation: self.conditional_metadata_mutation
130                && other.conditional_metadata_mutation,
131            conditional_content_access: self.conditional_content_access
132                && other.conditional_content_access,
133        }
134    }
135}
136
137/// Directory enumeration guarantees.
138#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
139pub struct EnumerationCapabilities {
140    /// Directory listing can continue using an opaque page cursor.
141    pub paged: bool,
142    /// A multi-page listing observes a stable backend snapshot.
143    pub stable_snapshot: bool,
144}
145
146impl EnumerationCapabilities {
147    /// Computes enumeration guarantees shared by both sides.
148    #[must_use]
149    pub const fn intersection(self, other: Self) -> Self {
150        Self {
151            paged: self.paged && other.paged,
152            stable_snapshot: self.stable_snapshot && other.stable_snapshot,
153        }
154    }
155}
156
157/// Extra guarantees attached to an anchored backend change stream.
158#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
159pub struct AnchoredChangeCapabilities {
160    /// The backend reports an expired/reset cursor explicitly.
161    pub cursor_reset: bool,
162    /// Change batches contain durable deletion tombstones.
163    pub tombstones: bool,
164}
165
166impl AnchoredChangeCapabilities {
167    const fn intersection(self, other: Self) -> Self {
168        Self {
169            cursor_reset: self.cursor_reset && other.cursor_reset,
170            tombstones: self.tombstones && other.tombstones,
171        }
172    }
173}
174
175/// Supported strategies for discovering remote changes.
176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
177pub struct ChangeCapabilities {
178    /// Anchored change-feed support and its guarantees.
179    pub anchored: Option<AnchoredChangeCapabilities>,
180    /// Core may compare a persisted baseline with a current full snapshot.
181    pub snapshot_diff: bool,
182    /// An external caller may explicitly invalidate a scope or item.
183    pub external_invalidation: bool,
184}
185
186impl ChangeCapabilities {
187    /// Computes change-discovery strategies shared by both sides.
188    #[must_use]
189    pub const fn intersection(self, other: Self) -> Self {
190        let anchored = match (self.anchored, other.anchored) {
191            (Some(left), Some(right)) => Some(left.intersection(right)),
192            _ => None,
193        };
194        Self {
195            anchored,
196            snapshot_diff: self.snapshot_diff && other.snapshot_diff,
197            external_invalidation: self.external_invalidation && other.external_invalidation,
198        }
199    }
200}
201
202/// Range-read and cancellation constraints for hydration.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub struct RangeHydrationCapabilities {
205    /// Required physical transfer alignment.
206    pub alignment: Alignment,
207    /// A cancellation request may release only a subset of the original range.
208    pub partial_cancel: bool,
209}
210
211impl RangeHydrationCapabilities {
212    /// Computes range guarantees and physical constraints shared by both sides.
213    /// # Errors
214    ///
215    /// Returns an error when validation fails or an underlying backend, store, or platform
216    /// operation fails.
217    pub fn intersection(self, other: Self) -> Result<Self> {
218        Ok(Self {
219            alignment: self.alignment.intersection(other.alignment)?,
220            partial_cancel: self.partial_cancel && other.partial_cancel,
221        })
222    }
223}
224
225/// Supported content hydration strategies.
226#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
227pub struct HydrationCapabilities {
228    /// Complete-file hydration is available.
229    pub whole_file: bool,
230    /// Arbitrary or aligned range hydration is available.
231    pub range: Option<RangeHydrationCapabilities>,
232    /// Progressive background range hydration is available.
233    pub progressive_range: Option<RangeHydrationCapabilities>,
234    /// Content can be updated relative to an existing local revision.
235    pub incremental_from_existing: bool,
236}
237
238impl HydrationCapabilities {
239    /// Computes hydration strategies shared by both sides.
240    /// # Errors
241    ///
242    /// Returns an error when validation fails or an underlying backend, store, or platform
243    /// operation fails.
244    pub fn intersection(self, other: Self) -> Result<Self> {
245        let range = match (self.range, other.range) {
246            (Some(left), Some(right)) => Some(left.intersection(right)?),
247            _ => None,
248        };
249        let progressive_range = match (self.progressive_range, other.progressive_range) {
250            (Some(left), Some(right)) => Some(left.intersection(right)?),
251            _ => None,
252        };
253        Ok(Self {
254            whole_file: self.whole_file && other.whole_file,
255            range,
256            progressive_range,
257            incremental_from_existing: self.incremental_from_existing
258                && other.incremental_from_existing,
259        })
260    }
261}
262
263/// Mutation operations supported across a backend/adapter boundary.
264#[expect(
265    clippy::struct_excessive_bools,
266    reason = "mutation support is an independent capability set rather than mutually exclusive state"
267)]
268#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
269pub struct MutationCapabilities {
270    /// Create file or directory items.
271    pub create: bool,
272    /// Modify item metadata.
273    pub modify_metadata: bool,
274    /// Replace or upload item content.
275    pub modify_content: bool,
276    /// Delete items.
277    pub delete: bool,
278    /// Move or rename an item atomically within one root.
279    pub atomic_move: bool,
280    /// Move an item between roots.
281    pub cross_root_move: bool,
282    /// Resume an interrupted upload session.
283    pub resumable_upload: bool,
284}
285
286impl MutationCapabilities {
287    /// Computes mutation operations shared by both sides.
288    #[must_use]
289    pub const fn intersection(self, other: Self) -> Self {
290        Self {
291            create: self.create && other.create,
292            modify_metadata: self.modify_metadata && other.modify_metadata,
293            modify_content: self.modify_content && other.modify_content,
294            delete: self.delete && other.delete,
295            atomic_move: self.atomic_move && other.atomic_move,
296            cross_root_move: self.cross_root_move && other.cross_root_move,
297            resumable_upload: self.resumable_upload && other.resumable_upload,
298        }
299    }
300}
301
302/// Physical content-storage modes supported by a platform/host combination.
303#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
304pub struct ContentStorageModes {
305    /// The operating system owns the materialized local copy.
306    pub platform_managed: bool,
307    /// The provider owns the backing content cache.
308    pub provider_managed: bool,
309    /// Platform and provider each own distinct physical cache layers.
310    pub hybrid: bool,
311}
312
313impl ContentStorageModes {
314    /// Computes storage modes shared by both sides.
315    #[must_use]
316    pub const fn intersection(self, other: Self) -> Self {
317        Self {
318            platform_managed: self.platform_managed && other.platform_managed,
319            provider_managed: self.provider_managed && other.provider_managed,
320            hybrid: self.hybrid && other.hybrid,
321        }
322    }
323
324    /// Returns whether at least one materialization mode remains available.
325    #[must_use]
326    pub const fn is_supported(self) -> bool {
327        self.platform_managed || self.provider_managed || self.hybrid
328    }
329}
330
331/// Materialization ownership capabilities.
332#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
333pub struct MaterializationCapabilities {
334    /// Supported physical ownership modes.
335    pub storage_modes: ContentStorageModes,
336}
337
338impl MaterializationCapabilities {
339    /// Computes materialization modes shared by both sides.
340    #[must_use]
341    pub const fn intersection(self, other: Self) -> Self {
342        Self {
343            storage_modes: self.storage_modes.intersection(other.storage_modes),
344        }
345    }
346}
347
348/// Eviction and pinning mechanics available to the effective adapter.
349#[expect(
350    clippy::struct_excessive_bools,
351    reason = "eviction guarantees are independent capabilities rather than mutually exclusive state"
352)]
353#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
354pub struct EvictionCapabilities {
355    /// Physical content can be evicted or dehydrated.
356    pub supported: bool,
357    /// A native or provider-owned pin state is available.
358    pub pinning: bool,
359    /// Eviction can preserve dirty-content guards.
360    pub dirty_guard: bool,
361    /// Eviction can preserve open/read/write lease guards.
362    pub open_lease_guard: bool,
363}
364
365impl EvictionCapabilities {
366    /// Computes eviction guarantees shared by both sides.
367    #[must_use]
368    pub const fn intersection(self, other: Self) -> Self {
369        let supported = self.supported && other.supported;
370        Self {
371            supported,
372            pinning: supported && self.pinning && other.pinning,
373            dirty_guard: supported && self.dirty_guard && other.dirty_guard,
374            open_lease_guard: supported && self.open_lease_guard && other.open_lease_guard,
375        }
376    }
377}
378
379/// Cancellation precision supported by an operation boundary.
380#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
381pub enum CancellationLevel {
382    /// Cancellation is not propagated.
383    #[default]
384    None,
385    /// Cancellation is advisory and may race with completion.
386    BestEffortRequest,
387    /// One complete request can be cancelled precisely.
388    ExactRequest,
389    /// A subset of one range request can be cancelled while other ranges continue.
390    RangeSubset,
391}
392
393impl CancellationLevel {
394    /// Returns the strongest cancellation level guaranteed by both sides.
395    #[must_use]
396    pub fn intersection(self, other: Self) -> Self {
397        std::cmp::min(self, other)
398    }
399}
400
401/// Filename comparison and preservation properties.
402#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
403pub struct NamingCapabilities {
404    /// Distinct names may differ only by case.
405    pub case_sensitive: bool,
406    /// Original filename casing is preserved.
407    pub case_preserving: bool,
408    /// The boundary preserves the caller's Unicode normalization form.
409    pub normalization_preserving: bool,
410}
411
412impl NamingCapabilities {
413    /// Computes naming guarantees shared by both sides.
414    #[must_use]
415    pub const fn intersection(self, other: Self) -> Self {
416        Self {
417            case_sensitive: self.case_sensitive && other.case_sensitive,
418            case_preserving: self.case_preserving && other.case_preserving,
419            normalization_preserving: self.normalization_preserving
420                && other.normalization_preserving,
421        }
422    }
423}
424
425/// Quantitative limits applied to an effective cloud-files session.
426#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
427pub struct CloudFilesLimits {
428    /// Maximum encoded native item identity length; `None` means no extra boundary limit.
429    pub native_identity_max_bytes: Option<NonZeroUsize>,
430    /// Maximum bytes transferred in one operation; `None` means no extra boundary limit.
431    pub max_transfer_chunk: Option<NonZeroUsize>,
432    /// Maximum concurrent requests; `None` means no extra boundary limit.
433    pub max_in_flight_requests: Option<NonZeroUsize>,
434    /// Preferred upper page size; `None` means no shared hint.
435    pub directory_page_size_hint: Option<NonZeroUsize>,
436    /// Maximum encoded filename length; `None` means no extra boundary limit.
437    pub name_max_encoded_bytes: Option<NonZeroUsize>,
438}
439
440impl CloudFilesLimits {
441    /// Computes the tightest quantitative limits imposed by either side.
442    #[must_use]
443    pub fn intersection(self, other: Self) -> Self {
444        Self {
445            native_identity_max_bytes: minimum_optional_limit(
446                self.native_identity_max_bytes,
447                other.native_identity_max_bytes,
448            ),
449            max_transfer_chunk: minimum_optional_limit(
450                self.max_transfer_chunk,
451                other.max_transfer_chunk,
452            ),
453            max_in_flight_requests: minimum_optional_limit(
454                self.max_in_flight_requests,
455                other.max_in_flight_requests,
456            ),
457            directory_page_size_hint: minimum_optional_limit(
458                self.directory_page_size_hint,
459                other.directory_page_size_hint,
460            ),
461            name_max_encoded_bytes: minimum_optional_limit(
462                self.name_max_encoded_bytes,
463                other.name_max_encoded_bytes,
464            ),
465        }
466    }
467
468    /// Validates an adapter's encoded native identity against the effective platform limit.
469    /// # Errors
470    ///
471    /// Returns an error when validation fails or an underlying backend, store, or platform
472    /// operation fails.
473    pub fn validate_native_identity(self, encoded: &[u8]) -> Result<()> {
474        let Some(max_bytes) = self.native_identity_max_bytes else {
475            return Ok(());
476        };
477        if encoded.len() > max_bytes.get() {
478            return Err(CloudFilesCoreError::NativeIdentityTooLarge {
479                actual_bytes: encoded.len(),
480                max_bytes: max_bytes.get(),
481            });
482        }
483        Ok(())
484    }
485}
486
487fn minimum_optional_limit(
488    left: Option<NonZeroUsize>,
489    right: Option<NonZeroUsize>,
490) -> Option<NonZeroUsize> {
491    match (left, right) {
492        (Some(left), Some(right)) => Some(std::cmp::min(left, right)),
493        (Some(limit), None) | (None, Some(limit)) => Some(limit),
494        (None, None) => None,
495    }
496}
497
498/// Complete product-neutral capability description for one cloud-files boundary.
499#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
500pub struct CloudFilesCapabilities {
501    /// Stable identity guarantees.
502    pub identity: IdentityCapabilities,
503    /// Metadata/content revision guarantees.
504    pub revisions: RevisionCapabilities,
505    /// Directory enumeration guarantees.
506    pub enumeration: EnumerationCapabilities,
507    /// Remote change discovery strategies.
508    pub changes: ChangeCapabilities,
509    /// Content hydration strategies.
510    pub hydration: HydrationCapabilities,
511    /// Local/remote mutation operations.
512    pub mutations: MutationCapabilities,
513    /// Physical content ownership modes.
514    pub materialization: MaterializationCapabilities,
515    /// Eviction and pinning guarantees.
516    pub eviction: EvictionCapabilities,
517    /// Cancellation precision.
518    pub cancellation: CancellationLevel,
519    /// Filename comparison and preservation guarantees.
520    pub naming: NamingCapabilities,
521    /// Quantitative limits.
522    pub limits: CloudFilesLimits,
523}
524
525impl CloudFilesCapabilities {
526    /// Returns the identity element for capability intersection.
527    ///
528    /// Use this as a pass-through baseline when one boundary does not constrain some dimensions,
529    /// then replace every dimension that boundary actually owns. It is not a declaration that a
530    /// concrete backend or platform has been validated to support every operation.
531    #[must_use]
532    pub const fn unconstrained() -> Self {
533        let unconstrained_range = RangeHydrationCapabilities {
534            alignment: Alignment::ONE,
535            partial_cancel: true,
536        };
537        Self {
538            identity: IdentityCapabilities {
539                stable_item_ids: true,
540                path_independent_item_ids: true,
541                same_root_move_preserves_identity: true,
542                cross_root_move_preserves_identity: true,
543            },
544            revisions: RevisionCapabilities {
545                separate_metadata_and_content: true,
546                conditional_metadata_mutation: true,
547                conditional_content_access: true,
548            },
549            enumeration: EnumerationCapabilities {
550                paged: true,
551                stable_snapshot: true,
552            },
553            changes: ChangeCapabilities {
554                anchored: Some(AnchoredChangeCapabilities {
555                    cursor_reset: true,
556                    tombstones: true,
557                }),
558                snapshot_diff: true,
559                external_invalidation: true,
560            },
561            hydration: HydrationCapabilities {
562                whole_file: true,
563                range: Some(unconstrained_range),
564                progressive_range: Some(unconstrained_range),
565                incremental_from_existing: true,
566            },
567            mutations: MutationCapabilities {
568                create: true,
569                modify_metadata: true,
570                modify_content: true,
571                delete: true,
572                atomic_move: true,
573                cross_root_move: true,
574                resumable_upload: true,
575            },
576            materialization: MaterializationCapabilities {
577                storage_modes: ContentStorageModes {
578                    platform_managed: true,
579                    provider_managed: true,
580                    hybrid: true,
581                },
582            },
583            eviction: EvictionCapabilities {
584                supported: true,
585                pinning: true,
586                dirty_guard: true,
587                open_lease_guard: true,
588            },
589            cancellation: CancellationLevel::RangeSubset,
590            naming: NamingCapabilities {
591                case_sensitive: true,
592                case_preserving: true,
593                normalization_preserving: true,
594            },
595            limits: CloudFilesLimits {
596                native_identity_max_bytes: None,
597                max_transfer_chunk: None,
598                max_in_flight_requests: None,
599                directory_page_size_hint: None,
600                name_max_encoded_bytes: None,
601            },
602        }
603    }
604
605    /// Computes effective capabilities shared by two boundaries.
606    ///
607    /// Call this successively for backend, platform, and product-host descriptions. Unsupported
608    /// optional operations remain ordinary capability state rather than producing an error.
609    /// # Errors
610    ///
611    /// Returns an error when validation fails or an underlying backend, store, or platform
612    /// operation fails.
613    pub fn intersection(self, other: Self) -> Result<Self> {
614        Ok(Self {
615            identity: self.identity.intersection(other.identity),
616            revisions: self.revisions.intersection(other.revisions),
617            enumeration: self.enumeration.intersection(other.enumeration),
618            changes: self.changes.intersection(other.changes),
619            hydration: self.hydration.intersection(other.hydration)?,
620            mutations: self.mutations.intersection(other.mutations),
621            materialization: self.materialization.intersection(other.materialization),
622            eviction: self.eviction.intersection(other.eviction),
623            cancellation: self.cancellation.intersection(other.cancellation),
624            naming: self.naming.intersection(other.naming),
625            limits: self.limits.intersection(other.limits),
626        })
627    }
628
629    /// Validates the non-negotiable identity invariants required by the core model.
630    /// # Errors
631    ///
632    /// Returns an error when validation fails or an underlying backend, store, or platform
633    /// operation fails.
634    pub fn validate_core_requirements(self) -> Result<()> {
635        self.identity.validate_core_requirements()
636    }
637}