diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml new file mode 100644 index 0000000..a08acc9 --- /dev/null +++ b/.gitea/workflows/build-and-test.yml @@ -0,0 +1,116 @@ +name: Build and test + +# Desktop and Android are built on every push, per the v0.1 decision to carry +# both platforms from the first commit. An Android break is then caught the day +# it lands rather than at a porting milestone. + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + +jobs: + desktop: + runs-on: linux/amd64 + name: Desktop (Linux) + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: desktop-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + + # Slint and winit need these at build time; the runner image is minimal. + - name: Build dependencies + run: | + apt-get update -qq + apt-get install -y -qq pkg-config libfontconfig1-dev libxkbcommon-dev + + - name: Format + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + # GPU tests skip themselves where no adapter is present rather than + # failing — CI runners generally have none, and a test that cannot run is + # not evidence either way. + - name: Test + run: cargo test --workspace + + - name: Build + run: cargo build --workspace --release + + android: + runs-on: linux/amd64 + name: Android (aarch64) + container: + image: gitea.tourolle.paris/dtourolle/darkroom-android:latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + /opt/cargo/registry + target-android + key: android-${{ hashFiles('**/Cargo.lock') }} + + # Only the core crates cross-compile today; the UI and app crates join + # once the Android shell exists (milestone v0.1, FR-PLAT-AND-*). + - name: Cross-compile core + env: + CARGO_TARGET_DIR: target-android + run: cargo check -p dr-types -p dr-gpu -p dr-sync --target aarch64-linux-android + + # The linker targets MIN_API, not the compile SDK. cargo-ndk otherwise + # defaults to API 21, far below the Vulkan floor this app needs — and the + # mismatch is invisible until a device refuses to install. + - name: Verify minimum API level + env: + CARGO_TARGET_DIR: target-android + run: | + set -e + cargo ndk -t arm64-v8a build -p dr-gpu --release + SO=$(find target-android -name 'libdr_gpu*' -o -name '*.so' | head -1) + if [ -n "$SO" ]; then + echo "checking $SO" + readelf -p .comment "$SO" 2>/dev/null | head -5 || true + fi + + layering: + runs-on: linux/amd64 + name: Layer separation + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # ARCH §6.5a: no core/ crate may depend on the UI toolkit. One stray + # `use slint::` costs headless golden-image testing and the + # one-operation-two-presentations property together, and nothing else + # would notice. + - name: Core crates must not depend on the UI + run: | + set -e + FAILED=0 + for crate in dr-types dr-gpu dr-sync; do + if cargo tree -p "$crate" -e normal 2>/dev/null | grep -qE '\bslint\b|\bi-slint'; then + echo "FAIL: $crate depends on Slint (ARCH §6.5a)" + FAILED=1 + else + echo "ok: $crate" + fi + done + exit $FAILED diff --git a/.gitea/workflows/traceability-check.yml b/.gitea/workflows/traceability-check.yml new file mode 100644 index 0000000..96f99f6 --- /dev/null +++ b/.gitea/workflows/traceability-check.yml @@ -0,0 +1,99 @@ +name: Traceability + +# Mirrors JellyTau's traceability gate, including the reason it exists. +# +# That gate divided a traced count by frozen literal denominators while the +# requirements file grew past them, reported 158% coverage, and so could never +# fail its own threshold. Two rules follow, and the extractor's own tests +# enforce both: +# +# 1. Denominators are parsed from docs/requirements.md at run time. +# 2. Coverage is |traced ∩ defined| / |defined|, never a raw traced count. +# +# This job is static analysis of source comments plus markdown parsing, so it +# needs no GPU and no Android SDK — only the Rust toolchain. + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + +jobs: + traceability: + runs-on: linux/amd64 + name: Requirement traces + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: traces-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + + # The gate's own arithmetic is the thing being trusted, so its tests run + # before it does. Untested gate logic is exactly how JellyTau's 158% went + # unnoticed for months. + - name: Test the extractor + run: cargo test -p traceability + + # Structural failures are unconditional and do not depend on the coverage + # threshold: zero requirements parsed, zero files scanned, a ratio above + # 100%, or any orphan tag all fail the build. A misconfigured run must not + # report a plausible-looking 0%. + - name: Traceability gate + run: cargo run -q -p traceability -- check + + - name: Regenerate matrix and check it is committed + run: | + set -e + cargo run -q -p traceability -- report + if ! git diff --quiet docs/traceability.md; then + echo "" + echo "docs/traceability.md is out of date." + echo "Run: cargo run -p traceability -- report" + git diff --stat docs/traceability.md + exit 1 + fi + + # Advisory, not blocking: not every file implements a requirement, and a + # tag on every function is noise that rots faster than it helps. Tag the + # unit that decides. + - name: Check changed files for tags + if: github.event_name == 'pull_request' + run: | + set -e + CHANGED=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" \ + | grep -E '\.(rs|slint|wgsl)$' || true) + [ -z "$CHANGED" ] && { echo "No source files changed."; exit 0; } + + MISSING=0 + for file in $CHANGED; do + case "$file" in + */tests/*|*/test_*|tools/*) continue ;; + esac + [ -f "$file" ] || continue + if ! grep -q 'TRACES:' "$file"; then + echo " no TRACES tag: $file" + MISSING=$((MISSING + 1)) + fi + done + + if [ "$MISSING" -gt 0 ]; then + echo "" + echo "$MISSING changed file(s) carry no requirement tag." + echo "Format: /// TRACES: FR-CAT-1, FR-CAT-2 | NFR-P1" + echo " (comma separates IDs, pipe groups types)" + fi + + - name: Summary + if: always() + run: head -30 docs/traceability.md || true diff --git a/Cargo.lock b/Cargo.lock index 9b24c4c..2bbd117 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1102,6 +1102,16 @@ dependencies = [ "wgpu", ] +[[package]] +name = "dr-sync" +version = "0.1.0" +dependencies = [ + "async-trait", + "dr-types", + "log", + "thiserror 2.0.20", +] + [[package]] name = "dr-types" version = "0.1.0" @@ -5158,6 +5168,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "traceability" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", +] + [[package]] name = "tracing" version = "0.1.44" diff --git a/Cargo.toml b/Cargo.toml index d02a2e7..a129983 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,10 @@ resolver = "2" members = [ "core/dr-types", "core/dr-gpu", + "core/dr-sync", "ui/dr-ui", "apps/darkroom-desktop", + "tools/traceability", ] [workspace.package] @@ -18,6 +20,7 @@ repository = "https://github.com/dtourolle/DarkRoom" # Internal dr-types = { path = "core/dr-types" } dr-gpu = { path = "core/dr-gpu" } +dr-sync = { path = "core/dr-sync" } dr-ui = { path = "ui/dr-ui" } # GPU + UI @@ -31,6 +34,17 @@ thiserror = "2" log = "0.4" env_logger = "0.11" pollster = "0.4" + +# Networking — no mature Nextcloud crate exists; the connector is hand-rolled +# over reqwest (D7). reqwest_dav was evaluated and is too thin to build on. +reqwest = { version = "0.13", default-features = false, features = ["rustls-tls", "stream", "json"] } +quick-xml = "0.41" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +url = "2.5" +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.23" bytemuck = { version = "1", features = ["derive"] } [profile.dev] diff --git a/apps/darkroom-desktop/src/main.rs b/apps/darkroom-desktop/src/main.rs index a9e8b05..1c47fec 100644 --- a/apps/darkroom-desktop/src/main.rs +++ b/apps/darkroom-desktop/src/main.rs @@ -1,9 +1,9 @@ //! DarkRoom desktop entry point. fn main() -> anyhow::Result<()> { - env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or("info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn"), - ) + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or( + "info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn", + )) .init(); log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION")); diff --git a/core/dr-gpu/examples/bench.rs b/core/dr-gpu/examples/bench.rs index 8636db1..d4c0a1d 100644 --- a/core/dr-gpu/examples/bench.rs +++ b/core/dr-gpu/examples/bench.rs @@ -7,16 +7,30 @@ fn main() { env_logger::init(); let ctx = pollster::block_on(GpuContext::new_headless()).unwrap(); println!("adapter: {}\n", ctx.adapter_name()); - println!("{:>12} {:>10} {:>10} {:>8}", "size", "compute", "+readback", "fps"); + println!( + "{:>12} {:>10} {:>10} {:>8}", + "size", "compute", "+readback", "fps" + ); - for &(w, h) in &[(840u32, 692u32), (1280, 720), (1920, 1080), (2048, 1152), (3840, 2160)] { + for &(w, h) in &[ + (840u32, 692u32), + (1280, 720), + (1920, 1080), + (2048, 1152), + (3840, 2160), + ] { let rt = RenderTarget::new(&ctx, w, h).unwrap(); // warm - for i in 0..10 { rt.render(i as f32 * 0.01); let _ = pollster::block_on(rt.read_pixels()); } + for i in 0..10 { + rt.render(i as f32 * 0.01); + let _ = pollster::block_on(rt.read_pixels()); + } let n = 40; let t0 = Instant::now(); - for i in 0..n { rt.render(i as f32 * 0.01); } + for i in 0..n { + rt.render(i as f32 * 0.01); + } ctx.device.poll(wgpu::Maintain::Wait); let compute = t0.elapsed().as_secs_f64() / n as f64; @@ -27,7 +41,13 @@ fn main() { } let full = t1.elapsed().as_secs_f64() / n as f64; - println!("{:>5}x{:<6} {:>8.2}ms {:>8.2}ms {:>8.0}", - w, h, compute * 1000.0, full * 1000.0, 1.0 / full); + println!( + "{:>5}x{:<6} {:>8.2}ms {:>8.2}ms {:>8.0}", + w, + h, + compute * 1000.0, + full * 1000.0, + 1.0 / full + ); } } diff --git a/core/dr-gpu/src/error.rs b/core/dr-gpu/src/error.rs index 762080b..138c2e7 100644 --- a/core/dr-gpu/src/error.rs +++ b/core/dr-gpu/src/error.rs @@ -1,3 +1,4 @@ +/// TRACES: NFR-R7 | NFR-R8 /// Failures from the GPU layer. /// /// `DeviceLost` is deliberately a distinct variant rather than folded into a diff --git a/core/dr-gpu/src/lib.rs b/core/dr-gpu/src/lib.rs index 7b2ddf8..b0520cd 100644 --- a/core/dr-gpu/src/lib.rs +++ b/core/dr-gpu/src/lib.rs @@ -116,6 +116,7 @@ struct Params { /// Stands in for the develop pipeline in v0.1. What matters is the shape: /// compute writes a texture, the texture is handed to the compositor, and /// pixels never travel back through the CPU. +/// TRACES: FR-DEV-4 | R4 pub struct RenderTarget { ctx: GpuContext, texture: wgpu::Texture, @@ -142,9 +143,7 @@ impl RenderTarget { .device .create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("gradient"), - source: wgpu::ShaderSource::Wgsl( - include_str!("shaders/gradient.wgsl").into(), - ), + source: wgpu::ShaderSource::Wgsl(include_str!("shaders/gradient.wgsl").into()), }); let bind_group_layout = @@ -282,12 +281,8 @@ impl RenderTarget { return; } let (texture, view) = Self::create_texture(&self.ctx, width, height); - self.bind_group = Self::create_bind_group( - &self.ctx, - &self.bind_group_layout, - &view, - &self.params_buf, - ); + self.bind_group = + Self::create_bind_group(&self.ctx, &self.bind_group_layout, &view, &self.params_buf); self.texture = texture; self.view = view; self.width = width; @@ -369,10 +364,7 @@ impl RenderTarget { } let buf = &slot.as_ref().unwrap().0; - let mut enc = self - .ctx - .device - .create_command_encoder(&Default::default()); + let mut enc = self.ctx.device.create_command_encoder(&Default::default()); enc.copy_texture_to_buffer( wgpu::ImageCopyTexture { texture: &self.texture, diff --git a/core/dr-sync/Cargo.toml b/core/dr-sync/Cargo.toml new file mode 100644 index 0000000..4837125 --- /dev/null +++ b/core/dr-sync/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "dr-sync" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +dr-types.workspace = true +async-trait.workspace = true +thiserror.workspace = true +log.workspace = true diff --git a/core/dr-sync/src/capability.rs b/core/dr-sync/src/capability.rs new file mode 100644 index 0000000..098d063 --- /dev/null +++ b/core/dr-sync/src/capability.rs @@ -0,0 +1,139 @@ +//! 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-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, +} + +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, + } + } + + /// 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()); + } +} diff --git a/core/dr-sync/src/error.rs b/core/dr-sync/src/error.rs new file mode 100644 index 0000000..8afb69f --- /dev/null +++ b/core/dr-sync/src/error.rs @@ -0,0 +1,82 @@ +/// Failures from a remote backend. +#[derive(Debug, thiserror::Error)] +pub enum RemoteError { + #[error("not authenticated")] + Unauthenticated, + + #[error("authentication rejected")] + AuthFailed, + + #[error("not found: {0}")] + NotFound(String), + + /// The backend does not support this operation. Expected, not a bug — + /// callers check capabilities and adapt. + #[error("operation unsupported by this backend: {0}")] + Unsupported(&'static str), + + /// A conditional write failed: the remote changed underneath us. Triggers + /// the sidecar merge path (ARCH §8.5). + #[error("precondition failed — remote was modified")] + PreconditionFailed, + + #[error("quota exceeded")] + QuotaExceeded, + + #[error("network error: {0}")] + Network(String), + + #[error("unexpected server response: {status} {detail}")] + Server { status: u16, detail: String }, + + #[error("malformed response: {0}")] + Protocol(String), + + #[error("operation cancelled")] + Cancelled, +} + +impl RemoteError { + /// Whether retrying might succeed. + pub fn is_transient(&self) -> bool { + match self { + RemoteError::Network(_) => true, + RemoteError::Server { status, .. } => { + // 5xx and 429 are worth retrying; other 4xx are not. + *status >= 500 || *status == 429 + } + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transient_errors_are_retryable() { + assert!(RemoteError::Network("timeout".into()).is_transient()); + assert!(RemoteError::Server { + status: 503, + detail: String::new() + } + .is_transient()); + assert!(RemoteError::Server { + status: 429, + detail: String::new() + } + .is_transient()); + } + + #[test] + fn client_errors_are_not_retryable() { + assert!(!RemoteError::Server { + status: 404, + detail: String::new() + } + .is_transient()); + assert!(!RemoteError::PreconditionFailed.is_transient()); + assert!(!RemoteError::AuthFailed.is_transient()); + } +} diff --git a/core/dr-sync/src/lib.rs b/core/dr-sync/src/lib.rs new file mode 100644 index 0000000..026c6a1 --- /dev/null +++ b/core/dr-sync/src/lib.rs @@ -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, 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; + + /// Ask what changed since a cursor. + /// + /// Only meaningful for [`ChangeDetection::DeltaCursor`] backends; others + /// return [`RemoteError::Unsupported`]. + async fn delta(&self, cursor: &Cursor) -> Result<(Vec, 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>) -> Result, 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, + precond: Option, + ) -> Result; + + /// 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)>, + ) -> Result>, 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) + -> 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>, 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()); + } +} diff --git a/core/dr-sync/src/types.rs b/core/dr-sync/src/types.rs new file mode 100644 index 0000000..ac12d0a --- /dev/null +++ b/core/dr-sync/src/types.rs @@ -0,0 +1,198 @@ +//! Vocabulary shared by every remote backend. +//! +//! Deliberately protocol-neutral: nothing here names WebDAV, HTTP, or +//! Nextcloud. A backend translates these into its own protocol, which is what +//! keeps the sync engine reusable (ARCH §8.3). + +pub use dr_types::Validator; + +/// A path on the remote, always `/`-separated and rooted at the account's +/// library root — never a local filesystem path. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct RemotePath(String); + +impl RemotePath { + pub fn new(path: impl Into) -> Self { + let p: String = path.into(); + // Normalise so `/a/b`, `a/b` and `a/b/` compare equal — otherwise the + // same directory can be listed twice under different spellings. + let trimmed = p.trim_matches('/'); + RemotePath(trimmed.to_string()) + } + + pub fn root() -> Self { + RemotePath(String::new()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn is_root(&self) -> bool { + self.0.is_empty() + } + + /// Append a single path segment. + pub fn join(&self, segment: &str) -> Self { + let seg = segment.trim_matches('/'); + if self.0.is_empty() { + RemotePath(seg.to_string()) + } else { + RemotePath(format!("{}/{}", self.0, seg)) + } + } + + /// The final segment, for display. + pub fn name(&self) -> &str { + self.0.rsplit('/').next().unwrap_or(&self.0) + } + + /// The containing directory, or `None` at the root. + pub fn parent(&self) -> Option { + let (head, _) = self.0.rsplit_once('/')?; + Some(RemotePath(head.to_string())) + } +} + +impl std::fmt::Display for RemotePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(if self.0.is_empty() { "/" } else { &self.0 }) + } +} + +/// A backend-stable object identifier. +/// +/// Where the backend supports stable ids (`Capabilities::stable_ids`) this +/// survives server-side rename and move, so a move is detected as a move +/// rather than a delete plus a re-download of a large file (FR-NC-5). +/// Otherwise it falls back to the path, and moves cost a transfer. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RemoteId { + /// A server-assigned identifier, e.g. Nextcloud's `oc:fileid`. + Stable(u64), + /// No stable identity available; the path is the identity. + Path(RemotePath), +} + +/// One entry returned by a directory listing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteEntry { + pub id: RemoteId, + pub path: RemotePath, + pub kind: EntryKind, + pub validator: Validator, + pub size: u64, + /// Unix seconds, where the backend reports one. + pub modified: Option, + /// Whether the server claims a renderable preview exists. Advisory: stock + /// Nextcloud reports none for RAW (ARCH §6.7). + pub has_preview: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryKind { + File, + Directory, +} + +/// A change reported by a delta-capable backend. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteChange { + Upserted(RemoteEntry), + Deleted(RemoteId), +} + +/// An opaque position in a backend's change feed. +/// +/// Persisted between syncs. Backends may expire cursors; the engine treats an +/// expired cursor as a signal to fall back to a full listing rather than an +/// error. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cursor(String); + +impl Cursor { + pub fn new(token: impl Into) -> Self { + Cursor(token.into()) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A conditional-write guard. +/// +/// `IfMatch` is optimistic concurrency for sidecar updates: a failure means +/// another device wrote first, which triggers the per-operation merge rather +/// than an overwrite (ARCH §8.5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Precondition { + /// Write only if the remote still matches this validator. + IfMatch(Validator), + /// Write only if nothing exists — creation without clobbering. + IfAbsent, +} + +/// Who we are connected as. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Identity { + pub user_id: String, + pub display_name: Option, + pub server: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_normalise_so_spellings_compare_equal() { + // Otherwise the same directory is listed twice under two spellings. + assert_eq!( + RemotePath::new("/Photos/2026/"), + RemotePath::new("Photos/2026") + ); + assert_eq!( + RemotePath::new("Photos/2026/"), + RemotePath::new("Photos/2026") + ); + } + + #[test] + fn root_is_empty_and_displays_as_slash() { + let r = RemotePath::root(); + assert!(r.is_root()); + assert_eq!(r.to_string(), "/"); + assert_eq!(RemotePath::new("/"), r); + } + + #[test] + fn join_from_root_does_not_double_separator() { + assert_eq!(RemotePath::root().join("Photos").as_str(), "Photos"); + assert_eq!( + RemotePath::new("Photos").join("2026").as_str(), + "Photos/2026" + ); + // A segment arriving with separators is still joined once. + assert_eq!( + RemotePath::new("Photos").join("/2026/").as_str(), + "Photos/2026" + ); + } + + #[test] + fn name_and_parent() { + let p = RemotePath::new("Photos/2026/IMG_0042.CR3"); + assert_eq!(p.name(), "IMG_0042.CR3"); + assert_eq!(p.parent().unwrap().as_str(), "Photos/2026"); + assert_eq!(RemotePath::root().parent(), None); + } + + #[test] + fn stable_ids_differ_from_path_ids() { + // Identity is what makes a server-side move cheap; the two forms must + // not be conflated. + let a = RemoteId::Stable(42); + let b = RemoteId::Path(RemotePath::new("Photos/x.CR3")); + assert_ne!(a, b); + } +} diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index 1aa4c5c..a094313 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -20,6 +20,7 @@ pub struct ImageId(pub u64); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct VersionId(pub u64); +/// TRACES: FR-CAT-1a | FR-PLAT-AND-1 /// An opaque, re-resolvable reference to source image data. /// /// **Never a filesystem path.** Android's Storage Access Framework provides no @@ -66,6 +67,7 @@ impl fmt::Display for SourceRef { } } +/// TRACES: FR-RAW-1 /// Image formats recognised at the catalog level. /// /// Recognition is by extension only; whether a decoder can actually handle the @@ -106,6 +108,7 @@ impl Format { } } +/// TRACES: FR-NC-6c /// How much of an image is available locally (FR-NC-6c). /// /// Surfaced in the UI so a user always knows what they have — the failure diff --git a/docs/traceability.md b/docs/traceability.md new file mode 100644 index 0000000..401ea9e --- /dev/null +++ b/docs/traceability.md @@ -0,0 +1,187 @@ +# Requirements traceability matrix + + + + +Denominators are parsed from [`requirements.md`](requirements.md) at run time, never hardcoded. Coverage is the intersection of tagged and defined IDs over defined IDs, so it cannot exceed 100%. + +## Summary + +| Metric | Value | +|---|---| +| Source files scanned | 16 | +| TRACES tags found | 16 | +| Requirements defined | 143 | +| Requirements covered | 19 | +| **Coverage** | **13.3%** (19/143) | + +### By type + +| Type | Covered | Defined | +|---|---|---| +| FR | 13 | 90 | +| NFR | 4 | 47 | +| R | 2 | 6 | + +## Orphan tags + +A tag naming an ID `requirements.md` does not define — what renumbering produces, and what a typo produces. + +_None._ + +## Tagged requirements + +| ID | Tagged in | +|---|---| +| FR-CAT-1 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473), [`tools/traceability/src/lib.rs:505`](../tools/traceability/src/lib.rs#L505) | +| FR-CAT-1a | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) | +| FR-CAT-2 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) | +| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:119`](../core/dr-gpu/src/lib.rs#L119) | +| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:167`](../ui/dr-ui/src/lib.rs#L167) | +| FR-NC-12 | [`core/dr-sync/src/lib.rs:127`](../core/dr-sync/src/lib.rs#L127), [`core/dr-sync/src/lib.rs:33`](../core/dr-sync/src/lib.rs#L33) | +| FR-NC-3 | [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41) | +| FR-NC-4 | [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:127`](../core/dr-sync/src/lib.rs#L127) | +| FR-NC-6c | [`core/dr-types/src/lib.rs:111`](../core/dr-types/src/lib.rs#L111) | +| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:23`](../core/dr-types/src/lib.rs#L23) | +| FR-RAW-1 | [`core/dr-types/src/lib.rs:70`](../core/dr-types/src/lib.rs#L70) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:188`](../ui/dr-ui/src/lib.rs#L188) | +| FR-UI-2 | [`ui/dr-ui/src/lib.rs:188`](../ui/dr-ui/src/lib.rs#L188) | +| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | +| NFR-P1 | [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) | +| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | +| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | +| R1 | [`tools/traceability/src/lib.rs:489`](../tools/traceability/src/lib.rs#L489), [`tools/traceability/src/lib.rs:493`](../tools/traceability/src/lib.rs#L493) | +| R4 | [`core/dr-gpu/src/lib.rs:119`](../core/dr-gpu/src/lib.rs#L119) | + +## Not yet tagged + +124 of 143 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. + +
Show untagged requirements + +- FR-CAT-10 +- FR-CAT-11 +- FR-CAT-12 +- FR-CAT-13 +- FR-CAT-14 +- FR-CAT-3 +- FR-CAT-4 +- FR-CAT-5 +- FR-CAT-6 +- FR-CAT-7 +- FR-CAT-8 +- FR-CAT-9 +- FR-CULL-1 +- FR-CULL-2 +- FR-CULL-3 +- FR-CULL-4 +- FR-CULL-5 +- FR-CULL-6 +- FR-CULL-7 +- FR-DEV-1 +- FR-DEV-2 +- FR-DEV-3 +- FR-DEV-3a +- FR-DEV-3b +- FR-DEV-3c +- FR-DEV-3d +- FR-DEV-3e +- FR-DEV-3f +- FR-DEV-3g +- FR-DEV-5 +- FR-DEV-6 +- FR-DEV-7 +- FR-DEV-8 +- FR-DSP-2 +- FR-DSP-3 +- FR-DSP-4 +- FR-DSP-5 +- FR-DSP-6 +- FR-DSP-7 +- FR-DSP-8 +- FR-EXP-1 +- FR-EXP-2 +- FR-EXP-3 +- FR-EXP-4 +- FR-EXP-5 +- FR-EXP-6 +- FR-EXP-7 +- FR-EXP-8 +- FR-EXP-9 +- FR-NC-1 +- FR-NC-10 +- FR-NC-11 +- FR-NC-2 +- FR-NC-5 +- FR-NC-6 +- FR-NC-6a +- FR-NC-6b +- FR-NC-7 +- FR-NC-8 +- FR-NC-9 +- FR-PLAT-AND-2 +- FR-PLAT-AND-3 +- FR-PLAT-AND-4 +- FR-PLAT-AND-5 +- FR-PLAT-AND-6 +- FR-PLAT-LIN-1 +- FR-PLAT-LIN-2 +- FR-PLAT-LIN-3 +- FR-RAW-2 +- FR-RAW-3 +- FR-RAW-4 +- FR-RAW-5 +- FR-UI-3 +- FR-UI-4 +- FR-UI-5 +- FR-UI-6 +- FR-UI-7 +- NFR-A11Y-1 +- NFR-A11Y-2 +- NFR-A11Y-3 +- NFR-ARCH-1 +- NFR-ARCH-2 +- NFR-ARCH-3 +- NFR-ARCH-4 +- NFR-COMPAT-1 +- NFR-COMPAT-2 +- NFR-OPS-2 +- NFR-OPS-3 +- NFR-OPS-4 +- NFR-P10 +- NFR-P11 +- NFR-P12 +- NFR-P13 +- NFR-P14 +- NFR-P15 +- NFR-P2 +- NFR-P3 +- NFR-P4 +- NFR-P5 +- NFR-P6 +- NFR-P7 +- NFR-P8 +- NFR-P9 +- NFR-PORT-1 +- NFR-PORT-2 +- NFR-PORT-3 +- NFR-R1 +- NFR-R2 +- NFR-R3 +- NFR-R4 +- NFR-R5 +- NFR-R6 +- NFR-RES-1 +- NFR-RES-2 +- NFR-RES-3 +- NFR-RES-4 +- NFR-SEC-1 +- NFR-SEC-2 +- NFR-SEC-3 +- NFR-SEC-4 +- R2 +- R3 +- R5 +- R6 + +
diff --git a/tools/traceability/Cargo.toml b/tools/traceability/Cargo.toml new file mode 100644 index 0000000..bf95d8a --- /dev/null +++ b/tools/traceability/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "traceability" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[lib] +name = "traceability" +path = "src/lib.rs" + +[[bin]] +name = "traces" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +serde = { workspace = true } +serde_json.workspace = true diff --git a/tools/traceability/src/lib.rs b/tools/traceability/src/lib.rs new file mode 100644 index 0000000..e14fc2e --- /dev/null +++ b/tools/traceability/src/lib.rs @@ -0,0 +1,541 @@ +//! Requirements traceability for DarkRoom. +//! +//! Extracts `TRACES:` tags from source, counts what `requirements.md` actually +//! defines, and reports coverage as the intersection of the two. +//! +//! # Why the arithmetic is written this way +//! +//! Adapted from the JellyTau tooling, including the bug it was repaired for. +//! That gate divided a traced count by *frozen literal* denominators; the +//! requirements file grew past them, and it reported **158% coverage**. A gate +//! reporting over 100% cannot fail its own threshold, so it silently stopped +//! being a gate at all. +//! +//! Two rules follow, and both are enforced by tests here: +//! +//! 1. **Denominators are parsed from `requirements.md` at run time.** Never +//! hardcoded, never cached. +//! 2. **Coverage is `|traced ∩ defined| / |defined|`.** Using the raw traced +//! count as the numerator is precisely what lets a ratio exceed 100%, since +//! a tag naming a deleted requirement would count as covered. Such tags are +//! reported as orphans instead. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +/// Requirement ID prefixes that participate in coverage. +/// +/// Test identifiers (UT, IT) are a separate taxonomy: they are *evidence* for +/// requirements, not requirements themselves. Counting them would inflate both +/// numerator and denominator, and flagging them as orphans would bury real +/// typos in noise. +pub const REQUIREMENT_TYPES: &[&str] = &["FR", "NFR", "R"]; + +/// Prefixes recognised in tags but deliberately excluded from coverage. +/// +/// `D` are decisions, `S` spikes, `M` milestone items, `AC` acceptance +/// criteria, `UT`/`IT` tests. All are legitimate things to tag against, and +/// none is a requirement — counting them would inflate the denominator by 25 +/// and make coverage look worse than it is, which is the same class of defect +/// as JellyTau's inflated ratio, just in the other direction. +pub const NON_REQUIREMENT_TYPES: &[&str] = &["UT", "IT", "AC", "M", "S", "D"]; + +/// One `TRACES:` tag found in source. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct TraceEntry { + pub file: String, + pub line: usize, + pub context: String, + pub requirements: Vec, +} + +/// What `requirements.md` defines — the coverage denominators. +#[derive(Debug, Clone, Default)] +pub struct DefinedRequirements { + pub ids: BTreeSet, + pub by_type: BTreeMap, +} + +impl DefinedRequirements { + pub fn total(&self) -> usize { + self.ids.len() + } +} + +/// The coverage result. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Coverage { + pub covered: usize, + pub total: usize, + pub percent: f64, + /// Tagged in source but absent from `requirements.md` — a typo, or a + /// requirement that was renumbered or deleted. Never counted as covered. + pub orphaned: Vec, + /// Defined but never tagged anywhere. + pub untraced: Vec, +} + +/// Parse the requirement IDs a markdown document *defines*. +/// +/// A requirement is defined by a bolded heading-style declaration +/// (`**FR-CAT-1 — …**`) or as the leading cell of a table row (`| R1 | … |`). +/// Both forms appear in DarkRoom's requirements.md. +/// +/// Deliberately *not* a bare scan for anything matching the ID shape: that +/// counts cross-references in prose and in "relates to" columns as +/// definitions, inflating the denominator. IDs are deduplicated because a +/// requirement may legitimately appear in both a definition and a summary +/// table. +pub fn parse_defined_requirements(markdown: &str) -> DefinedRequirements { + let mut ids = BTreeSet::new(); + + for line in markdown.lines() { + let trimmed = line.trim_start(); + + // Form 1: a bolded definition, e.g. `**FR-CAT-1 — Scan.**` + if let Some(rest) = trimmed.strip_prefix("**") { + if let Some(id) = leading_id(rest) { + ids.insert(id); + continue; + } + } + + // Form 2: leading table cell, e.g. `| **R1** | … |` or `| R1 | … |` + if let Some(rest) = trimmed.strip_prefix('|') { + let cell = rest.trim().trim_start_matches("**"); + if let Some(id) = leading_id(cell) { + ids.insert(id); + } + } + } + + // Only requirement types enter the register. Decisions, spikes, milestone + // items and test ids are all taggable, but none is a requirement, and + // counting them would inflate the denominator. + let ids: BTreeSet = ids.into_iter().filter(|id| is_requirement(id)).collect(); + + let mut by_type: BTreeMap = BTreeMap::new(); + for id in &ids { + *by_type.entry(type_of(id).to_string()).or_insert(0) += 1; + } + + DefinedRequirements { ids, by_type } +} + +/// Extract a requirement ID anchored at the start of `s`. +/// +/// Accepts `FR-CAT-1`, `NFR-P13`, `R1`, `FR-DEV-3a` — DarkRoom uses +/// alphanumeric segments and an optional trailing letter, not the fixed +/// three-digit form JellyTau assumed. +fn leading_id(s: &str) -> Option { + let bytes = s.as_bytes(); + if bytes.is_empty() || !bytes[0].is_ascii_uppercase() { + return None; + } + + let mut end = 0; + let mut seen_digit = false; + for (i, c) in s.char_indices() { + match c { + 'A'..='Z' | '0'..='9' | '-' => { + if c.is_ascii_digit() { + seen_digit = true; + } + end = i + c.len_utf8(); + } + 'a'..='z' if seen_digit => { + // Trailing variant letter, e.g. FR-DEV-3a. + end = i + c.len_utf8(); + } + _ => break, + } + } + + if end == 0 || !seen_digit { + return None; + } + + let id = s[..end].trim_end_matches('-').to_string(); + // Must have a recognised prefix, or arbitrary capitalised words match. + let ty = type_of(&id); + if REQUIREMENT_TYPES.contains(&ty) || NON_REQUIREMENT_TYPES.contains(&ty) { + Some(id) + } else { + None + } +} + +/// The type prefix of an ID: `FR-CAT-1` → `FR`, `R1` → `R`. +pub fn type_of(id: &str) -> &str { + let end = id + .find(|c: char| !c.is_ascii_uppercase()) + .unwrap_or(id.len()); + &id[..end] +} + +/// Whether an ID participates in coverage. +pub fn is_requirement(id: &str) -> bool { + REQUIREMENT_TYPES.contains(&type_of(id)) +} + +/// Extract every `TRACES:` tag from a source file's text. +pub fn extract_from_text(text: &str, path: &str) -> Vec { + let lines: Vec<&str> = text.lines().collect(); + let mut out = Vec::new(); + + for (idx, line) in lines.iter().enumerate() { + let Some(pos) = line.find("TRACES:") else { + continue; + }; + let tail = &line[pos + "TRACES:".len()..]; + let ids = parse_ids(tail); + if ids.is_empty() { + continue; + } + out.push(TraceEntry { + file: path.to_string(), + line: idx + 1, + context: find_context(&lines, idx), + requirements: ids, + }); + } + + out +} + +/// Parse the ID list from a tag body: `FR-CAT-1, FR-CAT-2 | NFR-P1`. +/// +/// The pipe groups types for readability; both separators are treated alike. +fn parse_ids(s: &str) -> Vec { + s.split(['|', ',']) + .filter_map(|part| leading_id(part.trim())) + .collect() +} + +/// The nearest preceding declaration, for the report's context column. +fn find_context(lines: &[&str], from: usize) -> String { + const MARKERS: &[&str] = &[ + "pub fn ", + "fn ", + "pub struct ", + "struct ", + "pub enum ", + "enum ", + "impl ", + "pub trait ", + "trait ", + "#[test]", + "component ", + "export ", + ]; + + // Look forward first — a doc comment precedes what it documents. + for line in lines.iter().skip(from + 1).take(6) { + if MARKERS.iter().any(|m| line.contains(m)) { + return trim_body(line); + } + } + // Then backward, for tags placed inside a body. + for i in (from.saturating_sub(6)..from).rev() { + if MARKERS.iter().any(|m| lines[i].contains(m)) { + return lines[i].trim().trim_end_matches('{').trim().to_string(); + } + } + "—".to_string() +} + +/// Strip a trailing body opener so the context reads as a signature. +/// +/// Handles both `fn f() {` and `fn f() {}` — the latter is why this is a +/// helper rather than a single `trim_end_matches('{')`. +fn trim_body(line: &str) -> String { + line.trim() + .trim_end_matches("{}") + .trim_end() + .trim_end_matches('{') + .trim_end() + .to_string() +} + +/// Compute coverage as the intersection of traced and defined IDs. +/// +/// This signature is the fix for the 158% bug: `defined` is required, so there +/// is nowhere for a frozen denominator to hide. +/// TRACES: NFR-OPS-1 +pub fn compute_coverage(traced: &BTreeSet, defined: &DefinedRequirements) -> Coverage { + let traced_reqs: BTreeSet<&String> = traced.iter().filter(|id| is_requirement(id)).collect(); + + let covered: Vec<&String> = traced_reqs + .iter() + .filter(|id| defined.ids.contains(**id)) + .copied() + .collect(); + + let orphaned: Vec = traced_reqs + .iter() + .filter(|id| !defined.ids.contains(**id)) + .map(|s| s.to_string()) + .collect(); + + let untraced: Vec = defined + .ids + .iter() + .filter(|id| !traced.contains(*id)) + .cloned() + .collect(); + + let total = defined.total(); + Coverage { + covered: covered.len(), + total, + percent: if total == 0 { + 0.0 + } else { + (covered.len() as f64 / total as f64) * 100.0 + }, + orphaned, + untraced, + } +} + +/// Source file extensions scanned for tags. +pub const SOURCE_SUFFIXES: &[&str] = &[".rs", ".slint", ".wgsl"]; + +/// Directories never scanned. +const EXCLUDED: &[&str] = &["target", "target-android", ".git", "node_modules", "temp"]; + +/// Walk `roots` under `base`, returning every source file. +pub fn collect_sources(base: &Path, roots: &[&str]) -> Vec { + let mut out = Vec::new(); + for root in roots { + let dir = base.join(root); + if dir.exists() { + walk(&dir, &mut out); + } + } + out.sort(); + out +} + +fn walk(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + + if EXCLUDED.iter().any(|e| *e == name) { + continue; + } + if path.is_dir() { + walk(&path, out); + } else if SOURCE_SUFFIXES.iter().any(|s| name.ends_with(s)) { + out.push(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- the 158% regression ------------------------------------------ + + #[test] + fn coverage_never_exceeds_one_hundred_percent() { + // The JellyTau failure, reproduced: more tags than the register + // defines. Every extra tag must land in `orphaned`, never inflate + // the numerator. + let defined = parse_defined_requirements("**FR-CAT-1 — Scan.**\n"); + let traced: BTreeSet = ["FR-CAT-1", "FR-CAT-2", "FR-CAT-3", "FR-CAT-4"] + .iter() + .map(|s| s.to_string()) + .collect(); + + let cov = compute_coverage(&traced, &defined); + assert_eq!(cov.covered, 1); + assert_eq!(cov.total, 1); + assert_eq!(cov.percent, 100.0); + assert!(cov.percent <= 100.0, "coverage must never exceed 100%"); + assert_eq!(cov.orphaned, vec!["FR-CAT-2", "FR-CAT-3", "FR-CAT-4"]); + } + + #[test] + fn orphan_tags_are_reported_not_counted() { + let defined = parse_defined_requirements("**FR-CAT-1 — Scan.**\n"); + let traced: BTreeSet = ["FR-CAT-1", "FR-TYPO-9"] + .iter() + .map(|s| s.to_string()) + .collect(); + + let cov = compute_coverage(&traced, &defined); + assert_eq!(cov.covered, 1); + assert_eq!(cov.orphaned, vec!["FR-TYPO-9"]); + } + + #[test] + fn empty_register_is_zero_not_a_division_by_zero() { + let defined = parse_defined_requirements(""); + let traced = BTreeSet::new(); + let cov = compute_coverage(&traced, &defined); + assert_eq!(cov.percent, 0.0); + assert_eq!(cov.total, 0); + } + + // ---- denominator parsing ------------------------------------------ + + #[test] + fn definitions_come_from_declarations_not_cross_references() { + // Only the first line defines FR-CAT-1. The prose reference and the + // "relates to" cell must not each count as another definition. + let md = "\ +**FR-CAT-1 — Scan.** The app shall scan roots. + +This is discussed in FR-CAT-1 and also FR-CAT-1 again. + +| Spike | Answers | Relates to | +|---|---|---| +| S1 | whether it works | FR-CAT-1 | +"; + let defined = parse_defined_requirements(md); + // FR-CAT-1 once, despite three mentions. S1 is a spike, not a + // requirement, so it does not enter the register at all. + assert_eq!(defined.total(), 1); + assert!(defined.ids.contains("FR-CAT-1")); + } + + #[test] + fn decisions_and_spikes_are_not_requirements() { + // D and S are taggable but are not requirements; counting them would + // inflate the denominator and understate real coverage. + let md = "\ +**FR-CAT-1 — Scan.** +| D1 | Language | Rust | +| S1 | Zero-copy spike | proves ARCH 6.1 | +"; + let defined = parse_defined_requirements(md); + assert_eq!(defined.total(), 1, "only FR-CAT-1 is a requirement"); + assert!(defined.ids.contains("FR-CAT-1")); + assert!(!defined.ids.contains("D1")); + assert!(!defined.ids.contains("S1")); + } + + #[test] + fn tagging_a_decision_neither_covers_nor_orphans() { + let defined = parse_defined_requirements("**FR-CAT-1 — Scan.**\n"); + let traced: BTreeSet = ["FR-CAT-1", "D1"].iter().map(|s| s.to_string()).collect(); + let cov = compute_coverage(&traced, &defined); + assert_eq!(cov.covered, 1); + assert!(cov.orphaned.is_empty(), "D1 is a valid tag, not an orphan"); + } + + #[test] + fn table_row_ids_are_definitions() { + let md = "| **R1** | Cross-platform | Same core on both |\n\ + | R2 | Efficient display | 60fps |\n"; + let defined = parse_defined_requirements(md); + assert!(defined.ids.contains("R1")); + assert!(defined.ids.contains("R2")); + } + + #[test] + fn darkroom_id_shapes_parse() { + // Not JellyTau's fixed three-digit form. + assert_eq!(leading_id("FR-CAT-1 — Scan").as_deref(), Some("FR-CAT-1")); + assert_eq!( + leading_id("NFR-P13 — Next image").as_deref(), + Some("NFR-P13") + ); + assert_eq!( + leading_id("FR-DEV-3a — Descriptors").as_deref(), + Some("FR-DEV-3a") + ); + assert_eq!(leading_id("R1 | Cross-platform").as_deref(), Some("R1")); + } + + #[test] + fn prose_is_not_an_id() { + assert_eq!(leading_id("The app shall scan"), None); + assert_eq!(leading_id("GPU results never"), None); + // A recognised prefix with no digits is not an ID either. + assert_eq!(leading_id("FR without a number"), None); + } + + // ---- tag extraction ----------------------------------------------- + + #[test] + fn extracts_tags_with_both_separators() { + let src = "\ +/// TRACES: FR-CAT-1, FR-CAT-2 | NFR-P1 +pub fn scan() {} +"; + let traces = extract_from_text(src, "x.rs"); + assert_eq!(traces.len(), 1); + assert_eq!( + traces[0].requirements, + vec!["FR-CAT-1", "FR-CAT-2", "NFR-P1"] + ); + assert_eq!(traces[0].line, 1); + assert_eq!(traces[0].context, "pub fn scan()"); + } + + #[test] + fn context_looks_forward_then_backward() { + // Doc comments precede their item. + let fwd = extract_from_text("// TRACES: R1\npub struct Catalog;", "x.rs"); + assert_eq!(fwd[0].context, "pub struct Catalog;"); + + // A tag inside a body refers to the enclosing item. + let back = extract_from_text("pub fn render() {\n // TRACES: R1\n}", "x.rs"); + assert_eq!(back[0].context, "pub fn render()"); + } + + #[test] + fn a_tag_with_no_ids_is_ignored() { + let traces = extract_from_text("// TRACES: see the design doc\n", "x.rs"); + assert!(traces.is_empty()); + } + + #[test] + fn test_ids_are_extracted_but_not_counted_as_requirements() { + let traces = extract_from_text("// TRACES: FR-CAT-1 | UT-001\nfn f(){}", "x.rs"); + assert_eq!(traces[0].requirements, vec!["FR-CAT-1", "UT-001"]); + + // UT is a separate taxonomy: evidence, not a requirement. + assert!(is_requirement("FR-CAT-1")); + assert!(!is_requirement("UT-001")); + + // So it neither covers nor orphans. + let defined = parse_defined_requirements("**FR-CAT-1 — Scan.**\n"); + let traced: BTreeSet = ["FR-CAT-1", "UT-001"] + .iter() + .map(|s| s.to_string()) + .collect(); + let cov = compute_coverage(&traced, &defined); + assert_eq!(cov.covered, 1); + assert!( + cov.orphaned.is_empty(), + "UT must not be reported as orphaned" + ); + } + + #[test] + fn type_prefixes_split_correctly() { + assert_eq!(type_of("FR-CAT-1"), "FR"); + assert_eq!(type_of("NFR-P13"), "NFR"); + assert_eq!(type_of("R1"), "R"); + assert_eq!(type_of("UT-001"), "UT"); + } + + #[test] + fn untraced_requirements_are_listed() { + let defined = parse_defined_requirements("**FR-A-1 — One.**\n**FR-B-2 — Two.**\n"); + let traced: BTreeSet = ["FR-A-1"].iter().map(|s| s.to_string()).collect(); + let cov = compute_coverage(&traced, &defined); + assert_eq!(cov.untraced, vec!["FR-B-2"]); + } +} diff --git a/tools/traceability/src/main.rs b/tools/traceability/src/main.rs new file mode 100644 index 0000000..5f872b7 --- /dev/null +++ b/tools/traceability/src/main.rs @@ -0,0 +1,244 @@ +//! Traceability gate and matrix generator. +//! +//! ```text +//! traces report # write docs/traceability.md +//! traces json # machine-readable, to stdout +//! traces check # gate: non-zero exit on failure +//! ``` +//! +//! The gate fails hard on a *misconfigured run* — zero requirements parsed, or +//! zero source files scanned — rather than reporting a plausible-looking 0%. +//! A gate that cannot distinguish "nothing is tagged" from "I read nothing" is +//! how JellyTau's reported 158% went unnoticed for months. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use traceability::*; + +/// Directories scanned for tags. +const SOURCE_ROOTS: &[&str] = &["core", "ui", "apps", "tools"]; + +/// Minimum coverage the gate accepts. +/// +/// 0 today: the requirements register is written but the code that implements +/// it barely exists. Ratchet upward as tags land; never reset downward. +/// Structural failures below are unconditional and do not depend on this. +const MIN_COVERAGE: f64 = 0.0; + +fn main() -> Result<()> { + let base = repo_root()?; + let mode = std::env::args().nth(1).unwrap_or_else(|| "report".into()); + + let req_path = base.join("docs/requirements.md"); + let markdown = std::fs::read_to_string(&req_path) + .with_context(|| format!("reading {}", req_path.display()))?; + let defined = parse_defined_requirements(&markdown); + + let files = collect_sources(&base, SOURCE_ROOTS); + let mut entries = Vec::new(); + for file in &files { + let text = std::fs::read_to_string(file).unwrap_or_default(); + let rel = file + .strip_prefix(&base) + .unwrap_or(file) + .to_string_lossy() + .to_string(); + entries.extend(extract_from_text(&text, &rel)); + } + + let traced: BTreeSet = entries + .iter() + .flat_map(|e| e.requirements.iter().cloned()) + .collect(); + let coverage = compute_coverage(&traced, &defined); + + match mode.as_str() { + "json" => println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "filesScanned": files.len(), + "tagsFound": entries.len(), + "defined": defined.total(), + "coverage": coverage, + }))? + ), + "check" => { + print_summary(&files, &entries, &defined, &coverage); + gate(&files, &defined, &coverage)?; + println!("\ntraceability gate: PASS"); + } + _ => { + let out = base.join("docs/traceability.md"); + let md = render(&files, &entries, &defined, &coverage); + std::fs::write(&out, md)?; + print_summary(&files, &entries, &defined, &coverage); + println!("\nwrote {}", out.display()); + } + } + + Ok(()) +} + +/// Structural checks that fail regardless of the coverage threshold. +fn gate(files: &[PathBuf], defined: &DefinedRequirements, cov: &Coverage) -> Result<()> { + // A run that parsed nothing is misconfigured, not passing. + if defined.total() == 0 { + bail!("no requirements parsed from docs/requirements.md — misconfigured, not 0% coverage"); + } + if files.is_empty() { + bail!("no source files scanned — misconfigured, not 0% coverage"); + } + + // Arithmetic invariant. If this ever trips, the numerator has stopped + // being an intersection — the exact JellyTau defect. + if cov.percent > 100.0 { + bail!( + "coverage {:.1}% exceeds 100% — numerator is not an intersection of traced and defined", + cov.percent + ); + } + + if !cov.orphaned.is_empty() { + bail!( + "{} orphan tag(s) naming requirements that do not exist: {}", + cov.orphaned.len(), + cov.orphaned.join(", ") + ); + } + + if cov.percent < MIN_COVERAGE { + bail!( + "coverage {:.1}% is below the {:.1}% threshold", + cov.percent, + MIN_COVERAGE + ); + } + + Ok(()) +} + +fn print_summary( + files: &[PathBuf], + entries: &[TraceEntry], + defined: &DefinedRequirements, + cov: &Coverage, +) { + println!("files scanned {}", files.len()); + println!("tags found {}", entries.len()); + println!("requirements {}", defined.total()); + println!( + "coverage {:.1}% ({}/{})", + cov.percent, cov.covered, cov.total + ); + if !cov.orphaned.is_empty() { + println!("orphan tags {}", cov.orphaned.join(", ")); + } +} + +fn render( + files: &[PathBuf], + entries: &[TraceEntry], + defined: &DefinedRequirements, + cov: &Coverage, +) -> String { + let mut m = String::new(); + m.push_str("# Requirements traceability matrix\n\n"); + m.push_str("\n"); + m.push_str("\n\n"); + m.push_str( + "Denominators are parsed from [`requirements.md`](requirements.md) at run time, \ + never hardcoded. Coverage is the intersection of tagged and defined IDs over \ + defined IDs, so it cannot exceed 100%.\n\n", + ); + + m.push_str("## Summary\n\n| Metric | Value |\n|---|---|\n"); + m.push_str(&format!("| Source files scanned | {} |\n", files.len())); + m.push_str(&format!("| TRACES tags found | {} |\n", entries.len())); + m.push_str(&format!("| Requirements defined | {} |\n", defined.total())); + m.push_str(&format!("| Requirements covered | {} |\n", cov.covered)); + m.push_str(&format!( + "| **Coverage** | **{:.1}%** ({}/{}) |\n\n", + cov.percent, cov.covered, cov.total + )); + + m.push_str("### By type\n\n| Type | Covered | Defined |\n|---|---|---|\n"); + let mut covered_by_type: BTreeMap<&str, usize> = BTreeMap::new(); + for id in &defined.ids { + if entries.iter().any(|e| e.requirements.contains(id)) { + *covered_by_type.entry(type_of(id)).or_insert(0) += 1; + } + } + for (ty, count) in &defined.by_type { + m.push_str(&format!( + "| {} | {} | {} |\n", + ty, + covered_by_type.get(ty.as_str()).copied().unwrap_or(0), + count + )); + } + m.push('\n'); + + m.push_str("## Orphan tags\n\n"); + m.push_str( + "A tag naming an ID `requirements.md` does not define — what renumbering produces, \ + and what a typo produces.\n\n", + ); + if cov.orphaned.is_empty() { + m.push_str("_None._\n\n"); + } else { + for id in &cov.orphaned { + m.push_str(&format!("- `{id}`\n")); + } + m.push('\n'); + } + + m.push_str("## Tagged requirements\n\n| ID | Tagged in |\n|---|---|\n"); + let mut by_req: BTreeMap<&String, Vec<&TraceEntry>> = BTreeMap::new(); + for e in entries { + for r in &e.requirements { + if defined.ids.contains(r) { + by_req.entry(r).or_default().push(e); + } + } + } + for (id, es) in &by_req { + let mut locs: Vec = es + .iter() + .map(|e| format!("[`{}:{}`](../{}#L{})", e.file, e.line, e.file, e.line)) + .collect(); + locs.sort(); + locs.dedup(); + m.push_str(&format!("| {} | {} |\n", id, locs.join(", "))); + } + m.push('\n'); + + m.push_str("## Not yet tagged\n\n"); + m.push_str(&format!( + "{} of {} requirements have no implementation tag. Expected while the \ + codebase is young; each should gain one as it is built.\n\n", + cov.untraced.len(), + defined.total() + )); + if !cov.untraced.is_empty() { + m.push_str("
Show untagged requirements\n\n"); + for id in &cov.untraced { + m.push_str(&format!("- {id}\n")); + } + m.push_str("\n
\n"); + } + + m +} + +/// The repo root, found by walking up from the executable's manifest dir. +fn repo_root() -> Result { + let mut dir = Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf(); + while !dir.join("docs/requirements.md").exists() { + if !dir.pop() { + bail!("could not locate repo root (no docs/requirements.md above the tool)"); + } + } + Ok(dir) +} diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 202bc90..01a9aab 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -164,6 +164,7 @@ pub fn run() -> Result<()> { /// maximised 4K canvas costs 4× a 1080p one for detail nobody is looking at /// while dragging. Real zoom-to-1:1 will render the visible crop at full /// resolution instead of scaling the whole canvas up. +/// TRACES: FR-DSP-1 const MAX_RENDER_DIM: u32 = 2048; fn clamp_render_size(w: u32, h: u32) -> (u32, u32) { @@ -184,6 +185,7 @@ fn clamp_render_size(w: u32, h: u32) -> (u32, u32) { /// /// A threshold in logical pixels, not a device check — a narrow desktop window /// gets the compact layout exactly as a tablet in portrait would. +/// TRACES: FR-UI-1 | FR-UI-2 const EXPANDED_MIN_WIDTH: f32 = 820.0; fn apply_layout_class(window: &AppWindow, width: f32) {