chore: gate test helpers, code style, stricter clippy

This commit is contained in:
2026-10-25 20:26:50 +01:00
parent 2ce7db12c2
commit 18c179e14c
40 changed files with 332 additions and 49 deletions

View File

@@ -36,7 +36,7 @@ jobs:
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --all-targets -- -D warnings
run: cargo clippy --workspace --all-targets -- -D warnings
- name: test
run: cargo test

View File

@@ -36,7 +36,7 @@ jobs:
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --all-targets -- -D warnings
run: cargo clippy --workspace --all-targets -- -D warnings
- name: test
run: cargo test

283
CODE_STYLE.md Normal file
View File

@@ -0,0 +1,283 @@
# Code Style Guide
This document defines the coding standards for this project. All contributors (human and agent) must follow these rules. When in doubt, prefer clarity and correctness over cleverness and speed.
## Philosophy
- Code reads like prose
- Do things properly — no shortcuts
- SOLID principles — always, no exceptions
- KISS — simplest solution that solves the problem **correctly and properly**. KISS means no unnecessary complexity, NOT skipping proper architecture. If the design calls for a trait, a newtype, or a pattern — that IS the simple solution. When in doubt, follow the architecture (CONTEXT.md, ADRs) over perceived simplicity.
- DRY — one source of truth for every piece of knowledge
- Use design patterns where they fit (Strategy, Observer, Builder, etc.) — name them in code so intent is clear
- Single responsibility everywhere: files, functions, types, modules
- Single point of entry, single control flow — no parallel data/control paths
- Open/Closed — extend via new types, don't modify existing ones
- Depend on abstractions (traits), not concretions
- Keep things as private as possible
- Zero magic numbers, strings, or unnamed constants
- Newtypes for self-documenting code
- Descriptive variable names — `review_rating`, not `rr`. `watched_on`, not `wd`. `poster_url`, not `pu`. No abbreviations unless universally understood (e.g., `id`, `url`, `i` for a trivial loop index).
## Project Structure
### Module Organization
Deep module hierarchy, grouped by concern. No flat file dumps. Each file is its own small, enclosed thing.
```
crates/domain/src/
├── models/
│ ├── review.rs
│ ├── diary_entry.rs
│ └── movie.rs
├── value_objects/
│ ├── movie_id.rs
│ ├── rating.rs
│ └── watched_on.rs
├── ports/
│ ├── review_repository.rs
│ └── event_publisher.rs
└── ...
```
### File Size
Small files with a single concern. If a file grows past ~100-150 lines, it's probably doing too much — split it.
### Tests
Never in the same file as production code. Use cases keep their tests in a sibling `tests/` module, one file per use case:
```
crates/application/src/diary/log_review.rs
crates/application/src/diary/tests/log_review.rs
```
## Naming
### Conventions
- Files and modules: `snake_case`
- Types: `PascalCase` with semantic suffixes for clarity and navigation
- Functions and methods: `snake_case`, verb-first, descriptive
### Type Suffixes
| Suffix | Usage | Example |
|---|---|---|
| (none) | Value objects, entities | `Review`, `Movie`, `DiaryEntry` |
| `Id` | Identity newtypes | `MovieId`, `ReviewId`, `UserId` |
| `Config` | Configuration data | `ServerConfig`, `StorageConfig` |
| `Service` | Domain services | `LocalSocialService` |
| `Error` | Error types | `DomainError`, `AuthError` |
| `UseCase` | Application use cases | `LogReviewUseCase`, `GetDiaryUseCase` |
| `Strategy` | Strategy trait/impls | `RatingAggregationStrategy` |
Ports are named for the capability they provide (`ReviewRepository`, `EventPublisher`); their implementations are named for the technology (`SqliteReviewRepository`).
### Newtypes
Use newtypes everywhere a primitive carries domain meaning:
```rust
// YES
struct MovieId(Uuid);
struct ReviewId(Uuid);
struct Rating(u8);
struct PosterUrl(Url);
// NO
type MovieId = String;
fn log_review(movie: &str, rating: u8, watched_on: &str) -> ...
```
Newtypes are self-documenting and prevent accidental misuse (can't pass a `MovieId` where a `ReviewId` is expected).
## Functions and Methods
### Single Responsibility
Each function does ONE thing. Compose small functions into larger behaviors.
```rust
// YES
fn resolve_movie(...) -> Movie { ... }
fn build_review(movie: &Movie, draft: ReviewDraft) -> Review { ... }
fn record_diary_entry(review: &Review) -> DiaryEntry { ... }
// NO
fn log_review(...) -> DiaryEntry {
// 80 lines doing movie resolution, review construction and diary recording
}
```
### Visibility
Default to private. Only `pub` what the module's consumers actually need. Use `pub(crate)` for intra-crate sharing that shouldn't leak outside.
### impl Blocks
Split by concern:
```rust
// Construction
impl Review {
pub fn new(movie: MovieId, rating: Rating, watched_on: WatchedOn) -> Self { ... }
}
// Queries
impl Review {
pub fn is_rewatch(&self) -> bool { ... }
pub fn rating(&self) -> Rating { ... }
}
// Mutations
impl Review {
pub fn revise_rating(&mut self, rating: Rating) { ... }
}
```
## Error Handling
### Domain and Application: `thiserror`
Typed, matchable error enums. Every error variant is meaningful.
```rust
#[derive(Debug, thiserror::Error)]
pub enum DiaryError {
#[error("movie {0} not found")]
MovieNotFound(MovieId),
#[error("review rejected: {0}")]
ReviewRejected(String),
}
```
### Adapters and Bootstrap: `anyhow`
Pragmatic error handling. Context strings for debugging.
```rust
fn fetch_poster_blob(poster_url: &PosterUrl) -> anyhow::Result<Bytes> {
let data = http_client.get(poster_url)
.context("failed to fetch poster")?;
// ...
}
```
### Never `unwrap` or `expect` in production code
Errors propagate with `?` and bubble up to a single handler. No scattered panic points.
`unwrap` is allowed **only** in test code.
## Configuration
Secrets (API keys, JWT secrets) and deployment settings live in environment variables, read once at startup into a typed config struct.
Zero magic numbers or strings. Everything named, everything explained by its name.
```rust
// YES
const MAX_REVIEW_BODY_LENGTH: usize = 10_000;
// NO
if body.len() > 10_000 { ... }
```
Constants are only for true invariants that never change. Tunable values belong in config.
## Comments
Comments only where a name, test or ADR cannot carry the reason. A comment is noise that drifts out of date while the code moves on, so prefer to put the reason somewhere that cannot rot silently:
- **A name.** Rename the function, the variable, or the type until the reason is visible in the code.
- **A test.** A constraint worth a comment is worth a test that fails when someone breaks it. `media_is_resolved_before_the_cascade_runs` outlives any note explaining why the order matters.
- **An ADR.** Architectural reasoning belongs in `docs/adr/`, where it is versioned and discoverable.
When none of those can carry it, a short comment stating the reason (never restating the code) is acceptable.
## Dependencies
- Use good crates when they exist — don't reinvent
- Prefer crates with minimal transitive dependencies
- Aim for small memory footprint and executable size
- Every dependency that has a `[workspace.dependencies]` entry is declared `workspace = true`; crates inherit `edition` from `[workspace.package]`
- Domain crate: zero external dependencies beyond `thiserror`, `serde` and small value-type crates
- Application crate: depends only on domain
- Adapters: free to pull in platform crates (`axum`, `sqlx`, `nats`, etc.)
- Bootstrap: wiring only
## Trait Objects for Ports
Ports are `dyn` behind `Arc`. Use cases hold their dependencies as trait objects:
```rust
pub struct Deps {
pub reviews: Arc<dyn ReviewRepository>,
pub events: Arc<dyn EventPublisher>,
}
```
Prefer a closed enum over `dyn` only where exhaustiveness is the point — when adding a variant must force every consumer to handle it. That is a decision about compiler-enforced coverage, not about dispatch cost.
## Testing
### Test Helpers Are Gated
`domain::testing` (in-memory fakes and noops) compiles only with the `test-helpers` feature, which crates enable in their `[dev-dependencies]`. Production builds must never contain fakes. `application::test_helpers` is `#[cfg(test)]`.
### Coverage Targets
| Crate | Approach | Target |
|---|---|---|
| Domain | TDD (red-green-refactor) | 100% |
| Application | TDD | High coverage |
| Adapters | Integration tests where practical | Best effort |
| Bootstrap | No tests | — |
### Fakes Must Not Be Kinder Than Production
A fake that is more forgiving than the real adapter turns a test into a lie: it passes whichever way the code is written, which is worse than having no test. Two rules follow.
**A fake must reproduce the constraints the database enforces.** SQLite removes dependent rows through `ON DELETE CASCADE`, so an in-memory store must cascade the same way, and enforce the same uniqueness constraints. Without that, an ordering requirement such as "resolve the poster before deleting the `DiaryEntry`" could not be tested at all.
**A fake must be able to fail.** Every port whose failure is meant to degrade rather than propagate needs a fake that refuses (a poster fetcher that errors, an event publisher that rejects). A best-effort path with no failing test is a path that has never run.
When a claim cannot be observed through the fake, move the test to where the real thing runs rather than asserting it against the fake's own behaviour.
### Test Naming
Tests read as specifications:
```rust
#[tokio::test]
async fn logging_review_creates_diary_entry() { ... }
#[tokio::test]
async fn rewatch_review_keeps_original_entry() { ... }
```
## Control Flow
### Single Point of Entry
One path through the code. No parallel flows that do similar things in different places.
```rust
// YES — one function handles movie resolution
let movie = movie_resolver.resolve(movie_id, requester);
// NO — movie resolution duplicated in two places
// diary_handler.rs: resolves movie
// import_handler.rs: also resolves movie with slightly different logic
```
### Single Call, Cascading Changes
When something changes, it should cascade from one point. Don't require updating multiple files for a single logical change.
### No God Objects
If a struct has more than 5-6 fields, question whether it's doing too much. If a function takes more than 4-5 parameters, consider grouping them into a dedicated type.

View File

@@ -40,6 +40,9 @@ members = [
]
resolver = "2"
[workspace.package]
edition = "2024"
[workspace.dependencies]
tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
bytes = "1"

View File

@@ -74,10 +74,10 @@ fmt-check:
cargo fmt --check
clippy:
cargo clippy -- -D warnings
cargo clippy --workspace --all-targets -- -D warnings
test:
cargo test
cargo test --workspace
fix:
cargo fmt

View File

@@ -1,7 +1,7 @@
[package]
name = "activitypub"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
k-ap = { version = "0.5.0", registry = "gitea" }

View File

@@ -1,7 +1,7 @@
[package]
name = "adapter-common"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
domain = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "auth"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
async-trait = { workspace = true }
@@ -9,7 +9,7 @@ domain = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
serde = { version = "1.0", features = ["derive"] }
serde = { workspace = true }
jsonwebtoken = "9"
argon2 = { version = "0.5", features = ["std"] }
rand_core = { version = "0.6", features = ["getrandom"] }

View File

@@ -1,7 +1,7 @@
[package]
name = "event-payload"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
domain = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "event-publisher"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
domain = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "export"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
domain = { workspace = true }

View File

@@ -15,5 +15,5 @@ webp = "0.3"
[dev-dependencies]
object-storage = { workspace = true }
object_store = "0.11"
object_store = { workspace = true }
uuid = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "importer"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[features]
xlsx = ["dep:calamine"]

View File

@@ -1,7 +1,7 @@
[package]
name = "jellyfin"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
domain = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "metadata"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
anyhow = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "nats"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
async-nats = "0.48.0"

View File

@@ -1,7 +1,7 @@
[package]
name = "object-storage"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
domain = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "plex"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
domain = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "poster-sync"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
domain = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "postgres-event-queue"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros", "chrono"] }

View File

@@ -1,7 +1,7 @@
[package]
name = "postgres-federation"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
sqlx = { version = "0.8.6", features = [

View File

@@ -1,7 +1,7 @@
[package]
name = "postgres-social"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
sqlx = { version = "0.8.6", features = [

View File

@@ -1,7 +1,7 @@
[package]
name = "postgres"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
sqlx = { version = "0.8.6", features = [

View File

@@ -1,7 +1,7 @@
[package]
name = "rss"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
rss-feed = { package = "rss", version = "2", features = ["atom"] }

View File

@@ -60,7 +60,9 @@ fn channel_links_are_absolute_with_atom_self() {
fn item_links_to_absolute_movie_page() {
let adapter = RssAdapter::new("http://example.com".into());
let e = entry(false);
let xml = adapter.render_feed(&[e.clone()], &channel("Feed")).unwrap();
let xml = adapter
.render_feed(std::slice::from_ref(&e), &channel("Feed"))
.unwrap();
let expected = format!(
"<link>http://example.com/movies/{}</link>",
e.movie().id().value()

View File

@@ -1,10 +1,10 @@
[package]
name = "sqlite-event-queue"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "macros", "chrono"] }
sqlx = { workspace = true, features = ["macros", "chrono"] }
domain = { workspace = true }
event-payload = { workspace = true }
anyhow = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "sqlite-federation"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
sqlx = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "sqlite-social"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
sqlx = { workspace = true }

View File

@@ -1,15 +1,10 @@
[package]
name = "sqlite"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
sqlx = { version = "0.8.6", features = [
"runtime-tokio-rustls",
"sqlite",
"uuid",
"macros",
] }
sqlx = { workspace = true, features = ["macros"] }
adapter-common = { workspace = true }
domain = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "template-askama"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
askama = { version = "0.16.0" }

View File

@@ -1,7 +1,7 @@
[package]
name = "tmdb-enrichment"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
domain = { workspace = true }

View File

@@ -1,13 +1,13 @@
[package]
name = "api-types"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
serde = { workspace = true }
uuid = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
domain = { path = "../domain" }
domain = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "application"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
async-trait = { workspace = true }

View File

@@ -1,7 +1,7 @@
[package]
name = "composition"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[features]
default = []

View File

@@ -1,7 +1,7 @@
[package]
name = "domain"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[dependencies]
uuid = { workspace = true }

View File

@@ -5,5 +5,5 @@ pub mod ports;
pub mod services;
pub mod value_objects;
#[cfg(any(test, feature = "test-helpers"))]
#[cfg(feature = "test-helpers")]
pub mod testing;

View File

@@ -1,7 +1,7 @@
[package]
name = "presentation"
version = "0.1.0"
edition = "2024"
edition.workspace = true
description = "Self-hosted movie diary with REST API and ActivityPub federation"
license = "MIT"

View File

@@ -1,7 +1,7 @@
[package]
name = "server"
version = "0.1.0"
edition = "2024"
edition.workspace = true
description = "Self-hosted movie diary with REST API and ActivityPub federation"
license = "MIT"

View File

@@ -1,7 +1,7 @@
[package]
name = "tui"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[features]
default = []
@@ -16,7 +16,7 @@ windows = ["dep:windows-native-keyring-store"]
ratatui = "0.30.0"
keyring-core = "1.0.0"
directories = "6"
csv = "1"
csv = { workspace = true }
apple-native-keyring-store = { version = "1.0.0", optional = true, features = [
"keychain",

View File

@@ -1,7 +1,7 @@
[package]
name = "worker"
version = "0.1.0"
edition = "2024"
edition.workspace = true
[features]
default = ["sqlite", "sqlite-federation"]