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:
@@ -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
|
||||||
@@ -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
|
||||||
Generated
+19
@@ -1102,6 +1102,16 @@ dependencies = [
|
|||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dr-sync"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"dr-types",
|
||||||
|
"log",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-types"
|
name = "dr-types"
|
||||||
version = "0.1.0"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "traceability"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
|||||||
+14
@@ -3,8 +3,10 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"core/dr-types",
|
"core/dr-types",
|
||||||
"core/dr-gpu",
|
"core/dr-gpu",
|
||||||
|
"core/dr-sync",
|
||||||
"ui/dr-ui",
|
"ui/dr-ui",
|
||||||
"apps/darkroom-desktop",
|
"apps/darkroom-desktop",
|
||||||
|
"tools/traceability",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -18,6 +20,7 @@ repository = "https://github.com/dtourolle/DarkRoom"
|
|||||||
# Internal
|
# Internal
|
||||||
dr-types = { path = "core/dr-types" }
|
dr-types = { path = "core/dr-types" }
|
||||||
dr-gpu = { path = "core/dr-gpu" }
|
dr-gpu = { path = "core/dr-gpu" }
|
||||||
|
dr-sync = { path = "core/dr-sync" }
|
||||||
dr-ui = { path = "ui/dr-ui" }
|
dr-ui = { path = "ui/dr-ui" }
|
||||||
|
|
||||||
# GPU + UI
|
# GPU + UI
|
||||||
@@ -31,6 +34,17 @@ thiserror = "2"
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
pollster = "0.4"
|
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"] }
|
bytemuck = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! DarkRoom desktop entry point.
|
//! DarkRoom desktop entry point.
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
env_logger::Builder::from_env(
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
|
||||||
env_logger::Env::default().default_filter_or("info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn"),
|
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn",
|
||||||
)
|
))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
|||||||
@@ -7,16 +7,30 @@ fn main() {
|
|||||||
env_logger::init();
|
env_logger::init();
|
||||||
let ctx = pollster::block_on(GpuContext::new_headless()).unwrap();
|
let ctx = pollster::block_on(GpuContext::new_headless()).unwrap();
|
||||||
println!("adapter: {}\n", ctx.adapter_name());
|
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();
|
let rt = RenderTarget::new(&ctx, w, h).unwrap();
|
||||||
// warm
|
// 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 n = 40;
|
||||||
let t0 = Instant::now();
|
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);
|
ctx.device.poll(wgpu::Maintain::Wait);
|
||||||
let compute = t0.elapsed().as_secs_f64() / n as f64;
|
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;
|
let full = t1.elapsed().as_secs_f64() / n as f64;
|
||||||
|
|
||||||
println!("{:>5}x{:<6} {:>8.2}ms {:>8.2}ms {:>8.0}",
|
println!(
|
||||||
w, h, compute * 1000.0, full * 1000.0, 1.0 / full);
|
"{:>5}x{:<6} {:>8.2}ms {:>8.2}ms {:>8.0}",
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
compute * 1000.0,
|
||||||
|
full * 1000.0,
|
||||||
|
1.0 / full
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// TRACES: NFR-R7 | NFR-R8
|
||||||
/// Failures from the GPU layer.
|
/// Failures from the GPU layer.
|
||||||
///
|
///
|
||||||
/// `DeviceLost` is deliberately a distinct variant rather than folded into a
|
/// `DeviceLost` is deliberately a distinct variant rather than folded into a
|
||||||
|
|||||||
+5
-13
@@ -116,6 +116,7 @@ struct Params {
|
|||||||
/// Stands in for the develop pipeline in v0.1. What matters is the shape:
|
/// 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
|
/// compute writes a texture, the texture is handed to the compositor, and
|
||||||
/// pixels never travel back through the CPU.
|
/// pixels never travel back through the CPU.
|
||||||
|
/// TRACES: FR-DEV-4 | R4
|
||||||
pub struct RenderTarget {
|
pub struct RenderTarget {
|
||||||
ctx: GpuContext,
|
ctx: GpuContext,
|
||||||
texture: wgpu::Texture,
|
texture: wgpu::Texture,
|
||||||
@@ -142,9 +143,7 @@ impl RenderTarget {
|
|||||||
.device
|
.device
|
||||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
label: Some("gradient"),
|
label: Some("gradient"),
|
||||||
source: wgpu::ShaderSource::Wgsl(
|
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/gradient.wgsl").into()),
|
||||||
include_str!("shaders/gradient.wgsl").into(),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let bind_group_layout =
|
let bind_group_layout =
|
||||||
@@ -282,12 +281,8 @@ impl RenderTarget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (texture, view) = Self::create_texture(&self.ctx, width, height);
|
let (texture, view) = Self::create_texture(&self.ctx, width, height);
|
||||||
self.bind_group = Self::create_bind_group(
|
self.bind_group =
|
||||||
&self.ctx,
|
Self::create_bind_group(&self.ctx, &self.bind_group_layout, &view, &self.params_buf);
|
||||||
&self.bind_group_layout,
|
|
||||||
&view,
|
|
||||||
&self.params_buf,
|
|
||||||
);
|
|
||||||
self.texture = texture;
|
self.texture = texture;
|
||||||
self.view = view;
|
self.view = view;
|
||||||
self.width = width;
|
self.width = width;
|
||||||
@@ -369,10 +364,7 @@ impl RenderTarget {
|
|||||||
}
|
}
|
||||||
let buf = &slot.as_ref().unwrap().0;
|
let buf = &slot.as_ref().unwrap().0;
|
||||||
|
|
||||||
let mut enc = self
|
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
||||||
.ctx
|
|
||||||
.device
|
|
||||||
.create_command_encoder(&Default::default());
|
|
||||||
enc.copy_texture_to_buffer(
|
enc.copy_texture_to_buffer(
|
||||||
wgpu::ImageCopyTexture {
|
wgpu::ImageCopyTexture {
|
||||||
texture: &self.texture,
|
texture: &self.texture,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<ChunkConstraints>,
|
||||||
|
/// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>) -> 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<RemotePath> {
|
||||||
|
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<i64>,
|
||||||
|
/// 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<String>) -> 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<String>,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ pub struct ImageId(pub u64);
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
pub struct VersionId(pub u64);
|
pub struct VersionId(pub u64);
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-1a | FR-PLAT-AND-1
|
||||||
/// An opaque, re-resolvable reference to source image data.
|
/// An opaque, re-resolvable reference to source image data.
|
||||||
///
|
///
|
||||||
/// **Never a filesystem path.** Android's Storage Access Framework provides no
|
/// **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.
|
/// Image formats recognised at the catalog level.
|
||||||
///
|
///
|
||||||
/// Recognition is by extension only; whether a decoder can actually handle the
|
/// 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).
|
/// 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
|
/// Surfaced in the UI so a user always knows what they have — the failure
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Requirements traceability matrix
|
||||||
|
|
||||||
|
<!-- GENERATED FILE — do not edit by hand. -->
|
||||||
|
<!-- Regenerate: cargo run -p traceability -- report -->
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<details><summary>Show untagged requirements</summary>
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
</details>
|
||||||
@@ -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
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `requirements.md` defines — the coverage denominators.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DefinedRequirements {
|
||||||
|
pub ids: BTreeSet<String>,
|
||||||
|
pub by_type: BTreeMap<String, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
/// Defined but never tagged anywhere.
|
||||||
|
pub untraced: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String> = ids.into_iter().filter(|id| is_requirement(id)).collect();
|
||||||
|
|
||||||
|
let mut by_type: BTreeMap<String, usize> = 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<String> {
|
||||||
|
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<TraceEntry> {
|
||||||
|
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<String> {
|
||||||
|
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<String>, 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<String> = traced_reqs
|
||||||
|
.iter()
|
||||||
|
.filter(|id| !defined.ids.contains(**id))
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let untraced: Vec<String> = 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<PathBuf> {
|
||||||
|
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<PathBuf>) {
|
||||||
|
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<String> = ["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<String> = ["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<String> = ["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<String> = ["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<String> = ["FR-A-1"].iter().map(|s| s.to_string()).collect();
|
||||||
|
let cov = compute_coverage(&traced, &defined);
|
||||||
|
assert_eq!(cov.untraced, vec!["FR-B-2"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> = 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("<!-- GENERATED FILE — do not edit by hand. -->\n");
|
||||||
|
m.push_str("<!-- Regenerate: cargo run -p traceability -- report -->\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<String> = 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("<details><summary>Show untagged requirements</summary>\n\n");
|
||||||
|
for id in &cov.untraced {
|
||||||
|
m.push_str(&format!("- {id}\n"));
|
||||||
|
}
|
||||||
|
m.push_str("\n</details>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The repo root, found by walking up from the executable's manifest dir.
|
||||||
|
fn repo_root() -> Result<PathBuf> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -164,6 +164,7 @@ pub fn run() -> Result<()> {
|
|||||||
/// maximised 4K canvas costs 4× a 1080p one for detail nobody is looking at
|
/// 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
|
/// while dragging. Real zoom-to-1:1 will render the visible crop at full
|
||||||
/// resolution instead of scaling the whole canvas up.
|
/// resolution instead of scaling the whole canvas up.
|
||||||
|
/// TRACES: FR-DSP-1
|
||||||
const MAX_RENDER_DIM: u32 = 2048;
|
const MAX_RENDER_DIM: u32 = 2048;
|
||||||
|
|
||||||
fn clamp_render_size(w: u32, h: u32) -> (u32, u32) {
|
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
|
/// A threshold in logical pixels, not a device check — a narrow desktop window
|
||||||
/// gets the compact layout exactly as a tablet in portrait would.
|
/// 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;
|
const EXPANDED_MIN_WIDTH: f32 = 820.0;
|
||||||
|
|
||||||
fn apply_layout_class(window: &AppWindow, width: f32) {
|
fn apply_layout_class(window: &AppWindow, width: f32) {
|
||||||
|
|||||||
Reference in New Issue
Block a user