Add requirements traceability gate and Gitea pipelines

Ports JellyTau's traceability tooling to Rust, carrying across the bug it
was repaired for. That gate divided a traced count by frozen literal
denominators; the requirements file outgrew them and it reported 158%
coverage, so it could never fail its own threshold.

Two rules, both enforced by the extractor's own tests:

  - denominators parsed from docs/requirements.md at run time
  - coverage is |traced ∩ defined| / |defined|, never a raw traced count

The gate additionally fails hard on a misconfigured run — zero
requirements parsed or zero files scanned — rather than reporting a
plausible 0%, and on any orphan tag naming a requirement that does not
exist.

Adapted for DarkRoom: IDs are FR-CAT-1 / NFR-P13 / FR-DEV-3a shapes
rather than JellyTau's fixed three digits, and decisions (D), spikes (S),
milestone items (M) and test ids remain taggable while being excluded
from the denominator — counting them inflated it by 25.

Also adds dr-sync: the RemoteBackend trait and capability model, so the
Nextcloud connector is one implementation rather than the only shape the
engine understands. No mature Nextcloud crate exists (reqwest_dav is too
thin), so the connector will be hand-rolled over reqwest per D7.

Gitea workflows follow the same style: containerised, commented with the
reasoning, desktop and Android on every push, plus a CI check that no
core/ crate depends on the UI toolkit (ARCH §6.5a).

Coverage today: 13.3% (19/143). 50 tests passing.
This commit is contained in:
2026-08-09 08:01:32 +02:00
parent 82a5e21ec6
commit 0f202fd3f9
19 changed files with 1913 additions and 22 deletions
+202
View File
@@ -0,0 +1,202 @@
//! Pluggable remote storage for DarkRoom.
//!
//! Defines the [`RemoteBackend`] trait and the capability model the sync
//! engine adapts to. Only the Nextcloud connector is implemented
//! (`dr-sync-nextcloud`), but the boundary is designed so other backends can
//! be added without touching the engine.
//!
//! # Why capabilities rather than a common denominator
//!
//! Nextcloud's fast path relies on a behaviour that is *not* a WebDAV
//! guarantee: directory ETags propagate up the tree, so an unchanged root
//! ETag proves nothing anywhere in the library changed. That single property
//! turns a no-op sync over 50k images into one HTTP request.
//!
//! A trait built to what every backend can do would force full enumeration
//! every time — the exact cost the design exists to avoid. So backends
//! declare what they support and the engine picks a strategy (ARCH §8.1).
use std::ops::Range;
use async_trait::async_trait;
pub mod capability;
pub mod error;
pub mod types;
pub use capability::{Capabilities, ChangeDetection, ChunkConstraints, ServerPreviews};
pub use error::RemoteError;
pub use types::{
Cursor, Identity, Precondition, RemoteChange, RemoteEntry, RemoteId, RemotePath, Validator,
};
/// TRACES: FR-NC-12
/// A remote storage backend.
///
/// Implementations are expected to be cheap to clone or to be used behind an
/// `Arc`; the engine may call them concurrently.
#[async_trait]
pub trait RemoteBackend: Send + Sync {
/// What this backend supports. Read once at connect time and used to pick
/// a sync strategy.
fn capabilities(&self) -> &Capabilities;
/// Human-readable backend name, for logs and the UI.
fn name(&self) -> &str;
// ---- discovery --------------------------------------------------------
/// List one directory level.
///
/// `since` carries the validator the caller last saw, so backends able to
/// skip unchanged entries may do so. Backends that cannot simply ignore
/// it.
async fn list(
&self,
dir: &RemotePath,
since: Option<&Validator>,
) -> Result<Vec<RemoteEntry>, RemoteError>;
/// Fetch a directory's validator without listing its contents.
///
/// The cheap probe that makes ETag pruning work: one request against the
/// root answers "did anything change?". Backends without
/// [`ChangeDetection::PropagatingEtags`] return
/// [`RemoteError::Unsupported`].
async fn dir_validator(&self, dir: &RemotePath) -> Result<Validator, RemoteError>;
/// Ask what changed since a cursor.
///
/// Only meaningful for [`ChangeDetection::DeltaCursor`] backends; others
/// return [`RemoteError::Unsupported`].
async fn delta(&self, cursor: &Cursor) -> Result<(Vec<RemoteChange>, Cursor), RemoteError>;
// ---- transfer ---------------------------------------------------------
/// Fetch an object, optionally a byte range.
///
/// The range is a hint, not a guarantee: backends without range support
/// may return the whole object, and the caller slices. Correctness holds
/// either way; [`Capabilities::range_reads`] says whether it was cheap.
async fn get(&self, id: &RemoteId, range: Option<Range<u64>>) -> Result<Vec<u8>, RemoteError>;
/// Upload, optionally guarded by a precondition.
///
/// Backends handle chunking internally based on body size — chunked
/// upload is an implementation detail, not part of this interface, since
/// exposing it would leak one server's protocol into the abstraction.
async fn put(
&self,
path: &RemotePath,
body: Vec<u8>,
precond: Option<Precondition>,
) -> Result<Validator, RemoteError>;
/// Upload many small objects.
///
/// Defaults to sequential [`put`](Self::put) calls; backends with a bulk
/// endpoint override it. Sidecars are the motivating case — hundreds of
/// a few KB each.
async fn put_many(
&self,
items: Vec<(RemotePath, Vec<u8>)>,
) -> Result<Vec<Result<Validator, RemoteError>>, RemoteError> {
let mut out = Vec::with_capacity(items.len());
for (path, body) in items {
out.push(self.put(&path, body, None).await);
}
Ok(out)
}
/// Delete an object.
async fn delete(&self, id: &RemoteId, precond: Option<Precondition>)
-> Result<(), RemoteError>;
// ---- optional ---------------------------------------------------------
/// Server-rendered thumbnail, where available.
///
/// `Ok(None)` means the server has no preview for this object — which is
/// common for RAW, since stock Nextcloud ships no RAW preview provider
/// (ARCH §6.7). Callers must have a local fallback.
async fn thumbnail(&self, _id: &RemoteId, _size: u32) -> Result<Option<Vec<u8>>, RemoteError> {
Ok(None)
}
}
/// TRACES: FR-NC-4 | FR-NC-12
/// Which sync strategy the engine should use for a backend.
///
/// Derived from capabilities at connect time. Reported to the user so a slow
/// backend is visibly slow rather than mysteriously slow (FR-NC-12).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncStrategy {
/// Ask the server what changed. Cheapest.
Delta,
/// Probe the root ETag; recurse only where it differs. One request when
/// nothing changed.
EtagPruning,
/// Enumerate the tree, using per-entry ETags to avoid re-downloading.
FullListing,
/// Enumerate and compare modification times. Degraded — clock skew and
/// second-granularity timestamps both cause misses.
TimestampCompare,
}
impl SyncStrategy {
pub fn for_capabilities(caps: &Capabilities) -> Self {
match caps.change_detection {
ChangeDetection::DeltaCursor => SyncStrategy::Delta,
ChangeDetection::PropagatingEtags => SyncStrategy::EtagPruning,
ChangeDetection::LocalEtags => SyncStrategy::FullListing,
ChangeDetection::Timestamps => SyncStrategy::TimestampCompare,
}
}
/// A short description for the UI.
pub fn describe(self) -> &'static str {
match self {
SyncStrategy::Delta => "server change feed",
SyncStrategy::EtagPruning => "incremental (ETag pruning)",
SyncStrategy::FullListing => "full listing, cached by ETag",
SyncStrategy::TimestampCompare => "full listing by timestamp (degraded)",
}
}
/// Whether this strategy can prove "nothing changed" cheaply.
///
/// Where false, every sync costs at least one request per folder, which
/// the UI should warn about on large libraries.
pub fn has_cheap_noop(self) -> bool {
matches!(self, SyncStrategy::Delta | SyncStrategy::EtagPruning)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strategy_follows_capability() {
let mut caps = Capabilities::minimal();
assert_eq!(
SyncStrategy::for_capabilities(&caps),
SyncStrategy::TimestampCompare
);
caps.change_detection = ChangeDetection::PropagatingEtags;
assert_eq!(
SyncStrategy::for_capabilities(&caps),
SyncStrategy::EtagPruning
);
}
#[test]
fn only_delta_and_pruning_have_cheap_noop() {
assert!(SyncStrategy::Delta.has_cheap_noop());
assert!(SyncStrategy::EtagPruning.has_cheap_noop());
// These cost at least one request per folder, every time.
assert!(!SyncStrategy::FullListing.has_cheap_noop());
assert!(!SyncStrategy::TimestampCompare.has_cheap_noop());
}
}