Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Closes #
- [ ] `cargo clippy --workspace --all-features -- -D warnings` passes.
- [ ] `cargo test --workspace --all-features` passes.
- [ ] Relevant compatibility/conformance fixtures pass.
- [ ] Any `stackarr-compat-*` change is DTOs, route wiring, or translation only;
logic went to the core (docs/COMPAT-FACADE-RULE.md).
- [ ] Documentation and checked-in contracts are updated.
- [ ] No published crate was vendored and no private dependency source was added.
- [ ] No frozen or deferred subsystem was widened.
Expand Down
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ The following list is checked against `Cargo.toml` in CI.
- `crates/stackarr-cardigann-parity`
- `crates/stackarr-stream`
- `crates/stackarr-postgres`
- `crates/stackarr-compat-core`
<!-- workspace-members:end -->

The final entry is renamed to `stackarr-mariadb` during P1. Update this list in
the same commit as any workspace-member change.
`crates/stackarr-postgres` is renamed to `stackarr-mariadb` during P1. Update
this list in the same commit as any workspace-member change.

The torrent engine is consumed from crates.io through the `swarmforge` package
family and historical `librtbit` dependency aliases. The Usenet engine is the
Expand All @@ -56,7 +57,8 @@ Until P5 is complete, `stackarr-stream`, Stremio routes, and
trending, requests, and watchlist features are deferred.

Compatibility crates contain DTOs, route wiring, and translation only. Put
business rules in the core.
business rules in the core. The rule, what it rejects, and how it is checked are
in [docs/COMPAT-FACADE-RULE.md](docs/COMPAT-FACADE-RULE.md).

## Required workflow

Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ only with executable evidence against the governing specification.
- Use property tests for hostile input spaces such as release parsing and
custom-format scoring.
- Façade crates contain DTOs, route wiring, and translation only. Domain logic
belongs in the core.
belongs in the core. Read
[docs/COMPAT-FACADE-RULE.md](docs/COMPAT-FACADE-RULE.md) before adding to a
`stackarr-compat-*` crate; breaking that rule is grounds for rejection.
- A new `#[allow(...)]` must have an adjacent comment that names the concrete
reason it is necessary.

Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ members = [
"crates/stackarr-cardigann-parity",
"crates/stackarr-stream",
"crates/stackarr-postgres",
"crates/stackarr-compat-core",
]

[workspace.package]
Expand Down Expand Up @@ -194,6 +195,7 @@ stackarr-plex = { path = "crates/stackarr-plex" }
stackarr-cardigann = { path = "crates/stackarr-cardigann" }
stackarr-stream = { path = "crates/stackarr-stream" }
stackarr-postgres = { path = "crates/stackarr-postgres" }
stackarr-compat-core = { path = "crates/stackarr-compat-core" }

# SwarmForge 0.1.0, published by rustTorrent. Cargo aliases preserve the
# existing librtbit API names while the canonical crates.io package names make
Expand Down
11 changes: 11 additions & 0 deletions crates/stackarr-compat-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[package]
name = "stackarr-compat-core"
version.workspace = true
edition.workspace = true
license.workspace = true

[lints]
workspace = true

[dev-dependencies]
toml = { workspace = true }
187 changes: 187 additions & 0 deletions crates/stackarr-compat-core/src/facade_rule.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
//! The façade rule, expressed as data a test can check.
//!
//! A façade crate — any `stackarr-compat-*` member other than
//! `stackarr-compat-core` — contains DTOs, route wiring and translation only.
//! The full statement of the rule, including the parts a machine cannot judge,
//! is in `docs/COMPAT-FACADE-RULE.md`.
//!
//! What is checked here is the part a machine *can* judge: what a façade is
//! allowed to depend on. A façade that reaches past
//! [`FACADE_WORKSPACE_DEPENDENCIES`] is holding domain knowledge, and a façade
//! that opens a database connection or drives a download engine is holding
//! behavior. Both are the rule being broken in the one place it is visible
//! before the code is written.
//!
//! The lists are deliberately short and deliberately editable. Widening
//! [`FACADE_WORKSPACE_DEPENDENCIES`] is a legitimate move when a shared concern
//! genuinely grows a new home in the core — but it is a one-line diff in this
//! file, which is exactly the point: it surfaces in review instead of arriving
//! buried in a manifest.

use std::fmt;

/// Workspace crates a façade may depend on.
///
/// `stackarr-compat-core` carries the shared arr concerns; `stackarr-core`
/// carries the domain types a DTO translates to and from. A façade that needs
/// anything else needs it through one of these two.
pub const FACADE_WORKSPACE_DEPENDENCIES: &[&str] = &["stackarr-compat-core", "stackarr-core"];

/// Dependencies that place storage or an embedded engine inside a façade.
///
/// These are entry points, not implementation details: a façade holding one of
/// them is talking to a database or an engine directly rather than translating
/// a request for the core to answer.
pub const FACADE_FORBIDDEN_DEPENDENCIES: &[&str] =
&["librtbit", "nzb-web", "nzbdav-core", "rusqlite", "sqlx"];

/// Why a dependency breaks the façade rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
/// The façade reaches into a workspace crate it is not allowed to know
/// about, which means it is carrying domain knowledge of its own.
UnlistedWorkspaceCrate,
/// The façade talks to storage or an embedded engine directly, which means
/// it is carrying behavior that belongs in the core.
StorageOrEngine,
}

impl fmt::Display for Reason {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnlistedWorkspaceCrate => formatter
.write_str("a façade may only depend on stackarr-compat-core and stackarr-core"),
Self::StorageOrEngine => {
formatter.write_str("a façade may not reach storage or an embedded engine directly")
}
}
}
}

/// A single breach of the façade rule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
/// The façade crate that declares the dependency.
pub crate_name: String,
/// The dependency that breaks the rule.
pub dependency: String,
/// Why it breaks the rule.
pub reason: Reason,
}

impl fmt::Display for Violation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{} depends on {}: {}",
self.crate_name, self.dependency, self.reason
)
}
}

/// Whether `crate_name` names a compatibility façade.
///
/// `stackarr-compat-core` is not a façade — it is the shared core the façades
/// sit on, and it is the one compat crate that is allowed to hold arr-generic
/// behavior.
#[must_use]
pub fn is_facade_crate(crate_name: &str) -> bool {
crate_name.starts_with("stackarr-compat-") && crate_name != "stackarr-compat-core"
}

/// Every way `dependencies` breaks the façade rule for `crate_name`.
///
/// Returns an empty vector for any crate that is not a façade, so this can be
/// applied to a whole workspace without filtering first.
#[must_use]
pub fn violations(crate_name: &str, dependencies: &[&str]) -> Vec<Violation> {
if !is_facade_crate(crate_name) {
return Vec::new();
}

dependencies
.iter()
.filter_map(|dependency| {
let reason = if FACADE_FORBIDDEN_DEPENDENCIES.contains(dependency) {
Reason::StorageOrEngine
} else if dependency.starts_with("stackarr-")
&& !FACADE_WORKSPACE_DEPENDENCIES.contains(dependency)
{
Reason::UnlistedWorkspaceCrate
} else {
return None;
};

Some(Violation {
crate_name: crate_name.to_owned(),
dependency: (*dependency).to_owned(),
reason,
})
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn facades_are_the_compat_crates_other_than_this_one() {
assert!(is_facade_crate("stackarr-compat-sonarr-v3"));
assert!(is_facade_crate("stackarr-compat-prowlarr-v1"));
assert!(!is_facade_crate("stackarr-compat-core"));
assert!(!is_facade_crate("stackarr-web"));
}

#[test]
fn dtos_route_wiring_and_translation_are_allowed() {
let dependencies = ["stackarr-compat-core", "stackarr-core", "axum", "serde"];
assert_eq!(
violations("stackarr-compat-sonarr-v3", &dependencies),
Vec::new()
);
}

#[test]
fn a_facade_may_not_reach_another_workspace_crate() {
let violations = violations("stackarr-compat-radarr-v3", &["stackarr-quality"]);
assert_eq!(
violations,
vec![Violation {
crate_name: "stackarr-compat-radarr-v3".to_owned(),
dependency: "stackarr-quality".to_owned(),
reason: Reason::UnlistedWorkspaceCrate,
}]
);
}

#[test]
fn a_facade_may_not_reach_storage_or_an_engine() {
let violations = violations("stackarr-compat-sonarr-v3", &["sqlx", "librtbit"]);
let reasons: Vec<Reason> = violations.iter().map(|entry| entry.reason).collect();
assert_eq!(
reasons,
vec![Reason::StorageOrEngine, Reason::StorageOrEngine]
);
}

#[test]
fn the_rule_does_not_apply_to_non_facade_crates() {
assert_eq!(violations("stackarr-web", &["sqlx"]), Vec::new());
assert_eq!(violations("stackarr-compat-core", &["sqlx"]), Vec::new());
}

#[test]
fn a_violation_reads_as_a_review_comment() {
let violation = Violation {
crate_name: "stackarr-compat-sonarr-v3".to_owned(),
dependency: "sqlx".to_owned(),
reason: Reason::StorageOrEngine,
};
assert_eq!(
violation.to_string(),
"stackarr-compat-sonarr-v3 depends on sqlx: \
a façade may not reach storage or an embedded engine directly"
);
}
}
35 changes: 35 additions & 0 deletions crates/stackarr-compat-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//! Shared foundation for the arr compatibility façades.
//!
//! The Sonarr v3, Radarr v3 and Prowlarr v1 façades are three views of one
//! product. Everything they have in common — authentication, error shapes,
//! `ProviderResource` field reflection and the SignalR hub — belongs here, and
//! everything that decides anything belongs in the core crates behind them.
//!
//! # The façade rule
//!
//! **A compat crate contains DTOs, route wiring and translation only. Any logic
//! that appears in a façade belongs in the core.**
//!
//! That rule is what keeps StackArr one product instead of three. It is
//! documented in full in `docs/COMPAT-FACADE-RULE.md` and expressed as
//! checkable data in [`facade_rule`], which the `facade_rule` integration test
//! applies to every workspace member.
//!
//! # Scope of this crate
//!
//! This crate is a skeleton. It currently carries the façade rule itself; the
//! shared arr concerns land on top of it:
//!
//! - authentication — `X-Api-Key` header, `?apikey=` querystring, forms-auth
//! cookie;
//! - arr error response shapes and status codes;
//! - `ProviderResource` field reflection, preserving option shape, privacy,
//! visibility and ordering; and
//! - the SignalR negotiate handshake and JSON hub protocol.
//!
//! Each arrives with the golden files and generated conformance tests that
//! govern it, never ahead of them.

#![warn(missing_docs)]

pub mod facade_rule;
Loading
Loading