//! What a backend can do. //! //! Declared rather than assumed, because the operations that matter most for //! performance are not universal (ARCH §8.1). /// TRACES: FR-NC-4 /// How a backend reports what changed. /// /// This is the single most consequential capability: it determines whether a /// no-op sync over a large library costs one request or thousands. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChangeDetection { /// The backend maintains a change feed; we present a cursor and receive /// what changed since. Cheapest possible. DeltaCursor, /// Directory ETags propagate upward, so an unchanged parent proves an /// unchanged subtree. Nextcloud. One request proves a whole library /// unchanged. PropagatingEtags, /// ETags exist per entry but do not propagate. The tree must be walked, /// though ETags still prevent re-downloading unchanged content. LocalEtags, /// Modification times only. Walk and compare — vulnerable to clock skew /// and coarse timestamp granularity. Timestamps, } /// Constraints on chunked upload. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ChunkConstraints { pub min_chunk: u64, pub max_chunk: u64, /// Bodies at or below this go in a single request. pub single_shot_below: u64, pub max_chunks: u32, } /// TRACES: FR-NC-6c /// Whether every listed object's content is actually reachable. /// /// Every backend but a virtual-filesystem folder answers [`Always`](Self::Always). /// A VFS folder is the case this exists for: the sync client leaves a /// placeholder where a file is catalogued but not downloaded, so the name is /// listable and the bytes are not (ARCH §9.0). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Materialisation { /// Listing an object means its content can be read. Every server backend, /// and a plain directory. Always, /// Some objects are placeholders, and nothing this process can do will /// change that — the sync client is not running, or the platform offers no /// way to ask. Such an object reads as /// [`RemoteError::NotMaterialised`](crate::RemoteError::NotMaterialised) /// and is shown as offline rather than broken. Placeholders, /// Some objects are placeholders, and this backend can ask for their /// content — and give it back. /// /// **Whole-file, and that is the whole difficulty.** Hydration has two /// states, one byte or all bytes, so using it to fill a grid transfers the /// entire library to produce thumbnails (ARCH §9.0). It belongs to the /// originals tier — an image opened in develop, exported, or deliberately /// pinned — and to passes the user has asked for and been quoted a price /// on. Never to browsing. OnDemand, } impl Materialisation { /// Whether content can be fetched on request. pub fn can_materialise(self) -> bool { matches!(self, Materialisation::OnDemand) } /// Whether some objects may have no content locally. pub fn has_placeholders(self) -> bool { !matches!(self, Materialisation::Always) } } /// TRACES: FR-NC-3 /// Whether the server can render thumbnails, and for what. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ServerPreviews { /// No server-side rendering. None, /// Common web formats only. **This is stock Nextcloud** — no RAW preview /// provider ships with it, so RAW thumbnails must come from local /// embedded-preview extraction (ARCH §6.7). CommonFormatsOnly, /// RAW included, e.g. via the `camerarawpreviews` app or an Imaginary /// backend. Detected per-account, never assumed. IncludingRaw, } /// The full capability set. #[derive(Debug, Clone)] pub struct Capabilities { pub change_detection: ChangeDetection, /// Identity survives server-side rename and move, so a move is not /// mistaken for delete-plus-add of a large file. pub stable_ids: bool, /// Byte-range reads. Without these, embedded-preview extraction is /// impossible and remote browsing must download whole files. pub range_reads: bool, pub chunked_upload: Option, /// Many small objects in one request. pub bulk_upload: bool, /// Conditional write (If-Match), for conflict-safe sidecar updates. pub conditional_write: bool, pub server_previews: ServerPreviews, /// Whether a listed object's content is necessarily present. pub materialisation: Materialisation, } impl Capabilities { /// The weakest backend the engine will still drive: listing and whole-file /// transfer, nothing more. Everything degrades but stays correct. pub fn minimal() -> Self { Self { change_detection: ChangeDetection::Timestamps, stable_ids: false, range_reads: false, chunked_upload: None, bulk_upload: false, conditional_write: false, server_previews: ServerPreviews::None, // The weakest backend still answers for everything it lists; // placeholders are a property a backend opts into. materialisation: Materialisation::Always, } } /// Whether remote browsing can avoid downloading whole files. /// /// Where false, the UI must not browse a remote library on a metered /// connection without explicit consent (FR-NC-12). pub fn can_browse_cheaply(&self) -> bool { self.range_reads || matches!(self.server_previews, ServerPreviews::IncludingRaw) } /// Whether sidecar conflicts can be detected reliably. /// /// Without conditional writes the engine falls back to comparing revision /// counters inside the sidecar, which narrows the race but does not close /// it — surfaced as a reduced-safety mode. pub fn safe_concurrent_writes(&self) -> bool { self.conditional_write } } #[cfg(test)] mod tests { use super::*; #[test] fn minimal_backend_degrades_but_stays_usable() { let caps = Capabilities::minimal(); assert!(!caps.can_browse_cheaply()); assert!(!caps.safe_concurrent_writes()); } #[test] fn server_raw_previews_substitute_for_range_reads() { // A server that renders RAW thumbnails makes browsing cheap even // without range support. let caps = Capabilities { server_previews: ServerPreviews::IncludingRaw, ..Capabilities::minimal() }; assert!(caps.can_browse_cheaply()); } #[test] fn common_format_previews_do_not_help_raw() { // Stock Nextcloud: previews exist, but not for RAW, so range reads // remain the only cheap path. let caps = Capabilities { server_previews: ServerPreviews::CommonFormatsOnly, ..Capabilities::minimal() }; assert!(!caps.can_browse_cheaply()); } }