From 0cdc93212b6558a01e51194f7a718bd2ae7e6bc2 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Tue, 29 Sep 2026 03:22:01 +0200 Subject: [PATCH 1/7] Test reusable rust-skills2 strict CI gate --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d9b8ac..2da4b52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,10 @@ env: CARGO_BUILD_WARNINGS: deny jobs: + rust-skills2-strict: + name: rust-skills2 strict nightly gate + uses: codeandsolder/rust-skills2/.github/workflows/strict-rust.yml@ci/strict-rust-gate + lockfile: name: Lockfile consistency runs-on: ubuntu-24.04 From 23f1c046f2d01570ccf34ae949abf6b7c385023c Mon Sep 17 00:00:00 2001 From: codeandsolder <23157028+codeandsolder@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:11:05 +0200 Subject: [PATCH 2/7] Fix code exposed by strict nightly policy --- crates/config/src/lib.rs | 151 ++++++++++++++-------------- e2e-tests/src/common/db.rs | 5 +- e2e-tests/src/common/gateway_env.rs | 1 - e2e-tests/src/common/once.rs | 10 +- gateway/benches/auth_bench.rs | 62 +++++++----- gateway/src/auth/refresh.rs | 67 +----------- gateway/src/cors.rs | 64 +++++------- i18n-demo/build.rs | 12 +-- i18n-demo/src/app.rs | 10 +- i18n-demo/src/ws_chat.rs | 19 +--- live-search/benches/db_bench.rs | 5 - live-search/src/app.rs | 24 ++--- live-search/src/cache.rs | 28 +++--- live-search/src/shutdown.rs | 80 +++++++++------ live-search/src/sse.rs | 18 ++-- 15 files changed, 237 insertions(+), 319 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index b2636b6..7d1f8fa 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -157,6 +157,14 @@ pub enum ConfigError { Invalid(String), } +fn i64_default(value: T, name: &str) -> Result +where + i64: TryFrom, +{ + i64::try_from(value) + .map_err(|_| ConfigError::Invalid(format!("{name} default does not fit in i64"))) +} + impl Config { /// Load defaults, an optional TOML file, then `RWF_*` environment /// overrides (`__` separates nested keys), and validate cross-field @@ -167,19 +175,27 @@ impl Config { /// # Errors /// Returns [`ConfigError`] for missing explicit files, parse/deserialization /// failures, or invalid values/invariants. - #[expect( - clippy::cast_possible_wrap, - clippy::cast_lossless, - reason = "documented defaults are small fixed values" - )] pub fn load() -> Result { let config_path = std::env::var("RWF_CONFIG").ok(); - if let Some(path) = config_path.as_deref() + Self::load_from(config_path.as_deref(), None) + } + + fn load_from( + config_path: Option<&str>, + environment_source: Option>, + ) -> Result { + if let Some(path) = config_path && !Path::new(path).exists() { return Err(ConfigError::ConfigPathNotFound(path.to_string())); } + let environment = config::Environment::with_prefix("RWF") + .prefix_separator("_") + .separator("__") + .try_parsing(true) + .source(environment_source); + let builder = config::Config::builder() .set_default("gateway.port", 3001_i64)? .set_default("gateway.proxy_upstream_url", "https://ipapi.co")? @@ -190,15 +206,24 @@ impl Config { .set_default("gateway.session.cookie_secure", true)? .set_default( "gateway.sse_broadcast_buffer", - default_gateway_sse_broadcast_buffer() as i64, + i64_default( + default_gateway_sse_broadcast_buffer(), + "gateway.sse_broadcast_buffer", + )?, )? .set_default( "gateway.refresh_token_ttl_secs", - default_gateway_refresh_token_ttl_secs() as i64, + i64_default( + default_gateway_refresh_token_ttl_secs(), + "gateway.refresh_token_ttl_secs", + )?, )? .set_default( "gateway.access_token_ttl_secs", - default_gateway_access_token_ttl_secs() as i64, + i64_default( + default_gateway_access_token_ttl_secs(), + "gateway.access_token_ttl_secs", + )?, )? .set_default("live_search.port", 3000_i64)? .set_default( @@ -207,41 +232,48 @@ impl Config { )? .set_default( "live_search.pool_max_connections", - default_pool_max_connections() as i64, + i64::from(default_pool_max_connections()), )? .set_default( "live_search.pool_min_connections", - default_pool_min_connections() as i64, + i64::from(default_pool_min_connections()), )? .set_default( "live_search.pool_acquire_timeout_secs", - default_pool_acquire_timeout_secs() as i64, + i64_default( + default_pool_acquire_timeout_secs(), + "live_search.pool_acquire_timeout_secs", + )?, )? .set_default( "live_search.pool_idle_timeout_secs", - default_pool_idle_timeout_secs() as i64, + i64_default( + default_pool_idle_timeout_secs(), + "live_search.pool_idle_timeout_secs", + )?, )? .set_default( "live_search.pool_max_lifetime_secs", - default_pool_max_lifetime_secs() as i64, + i64_default( + default_pool_max_lifetime_secs(), + "live_search.pool_max_lifetime_secs", + )?, )? .set_default( "live_search.sse_broadcast_buffer", - default_live_search_sse_broadcast_buffer() as i64, + i64_default( + default_live_search_sse_broadcast_buffer(), + "live_search.sse_broadcast_buffer", + )?, )? .add_source( config::File::new( - config_path.as_deref().unwrap_or("config.toml"), + config_path.unwrap_or("config.toml"), config::FileFormat::Toml, ) .required(false), ) - .add_source( - config::Environment::with_prefix("RWF") - .prefix_separator("_") - .separator("__") - .try_parsing(true), - ); + .add_source(environment); let cfg: Self = builder.build()?.try_deserialize()?; cfg.validate()?; @@ -298,62 +330,22 @@ impl Config { #[cfg(test)] mod tests { - use std::sync::{Mutex, OnceLock}; - use super::*; - fn env_test_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - struct EnvVarGuard { - key: String, - original: Option, - } - - impl EnvVarGuard { - #[expect( - unsafe_code, - reason = "process environment mutation is serialized by env_test_lock" - )] - fn set(key: &str, value: &str) -> Self { - let original = std::env::var(key).ok(); - unsafe { std::env::set_var(key, value) }; - Self { - key: key.to_string(), - original, - } - } - } - - impl Drop for EnvVarGuard { - #[expect( - unsafe_code, - reason = "process environment mutation is serialized by env_test_lock" - )] - fn drop(&mut self) { - match self.original.as_deref() { - Some(value) => unsafe { std::env::set_var(&self.key, value) }, - None => unsafe { std::env::remove_var(&self.key) }, - } - } + fn empty_environment() -> config::Map { + config::Map::new() } - fn with_env_var(key: &str, value: &str, f: F) -> R - where - F: FnOnce() -> R, - { - let _guard = EnvVarGuard::set(key, value); - f() + fn environment(entries: &[(&str, &str)]) -> config::Map { + entries + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect() } #[test] fn defaults_match_documented_values() -> Result<(), ConfigError> { - let _guard = env_test_lock() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let cfg = Config::load()?; + let cfg = Config::load_from(None, Some(empty_environment()))?; assert_eq!(cfg.gateway.port, 3001); assert_eq!(cfg.live_search.port, 3000); assert_eq!(cfg.live_search.pool_max_connections, 20); @@ -367,19 +359,22 @@ mod tests { #[test] fn rwf_config_missing_path_errors() { - let _guard = env_test_lock() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let result = with_env_var("RWF_CONFIG", "/this/path/does/not/exist.toml", Config::load); + let result = Config::load_from( + Some("/this/path/does/not/exist.toml"), + Some(empty_environment()), + ); assert!(matches!(result, Err(ConfigError::ConfigPathNotFound(_)))); } #[test] fn invalid_zero_sse_buffer_is_rejected() { - let _guard = env_test_lock() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let result = with_env_var("RWF_LIVE_SEARCH__SSE_BROADCAST_BUFFER", "0", Config::load); + let result = Config::load_from( + None, + Some(environment(&[( + "RWF_LIVE_SEARCH__SSE_BROADCAST_BUFFER", + "0", + )])), + ); assert!(matches!(result, Err(ConfigError::Invalid(_)))); } diff --git a/e2e-tests/src/common/db.rs b/e2e-tests/src/common/db.rs index b4187cf..ba487ba 100644 --- a/e2e-tests/src/common/db.rs +++ b/e2e-tests/src/common/db.rs @@ -14,8 +14,7 @@ use testcontainers::{ContainerAsync, GenericImage, ImageExt}; pub struct TestEnv { pool: PgPool, connection_string: String, - #[allow(dead_code, reason = "Kept alive for Drop side-effect on TestEnv")] - container: Box>, + _container: Box>, } impl std::fmt::Debug for TestEnv { @@ -90,7 +89,7 @@ impl TestEnv { Ok(Self { pool, connection_string, - container: Box::new(container), + _container: Box::new(container), }) } diff --git a/e2e-tests/src/common/gateway_env.rs b/e2e-tests/src/common/gateway_env.rs index c64890b..97e713f 100644 --- a/e2e-tests/src/common/gateway_env.rs +++ b/e2e-tests/src/common/gateway_env.rs @@ -20,7 +20,6 @@ pub const TEST_ADMIN_USER_ID: &str = "00000000-0000-0000-0000-000000000001"; pub struct GatewayEnv { addr: SocketAddr, shutdown: CancellationToken, - #[allow(dead_code, reason = "Kept alive for Drop side-effect on GatewayEnv")] db: super::db::TestEnv, } diff --git a/e2e-tests/src/common/once.rs b/e2e-tests/src/common/once.rs index ba0526e..42c01fd 100644 --- a/e2e-tests/src/common/once.rs +++ b/e2e-tests/src/common/once.rs @@ -44,15 +44,17 @@ pub struct SharedServer { bg_init_once: Once, } +impl Default for SharedServer { + fn default() -> Self { + Self::new() + } +} + impl SharedServer { /// Create a new uninitialised server holder. /// /// This is `const`-callable so it can be used in a `static` initialiser. #[must_use] - #[expect( - clippy::new_without_default, - reason = "Default is not meaningful for a once-only initialiser; callers should explicitly opt in via `static SERVER: SharedServer = SharedServer::new()`" - )] pub const fn new() -> Self { Self { cell: OnceCell::const_new(), diff --git a/gateway/benches/auth_bench.rs b/gateway/benches/auth_bench.rs index 5a2e6ae..2d753e9 100644 --- a/gateway/benches/auth_bench.rs +++ b/gateway/benches/auth_bench.rs @@ -4,12 +4,7 @@ //! immediately validate it. This exercises PEM parsing, base64 encoding, //! base64 decoding, signature creation, and signature verification. //! -//! # Lints -//! -//! `expect_used` and `default_trait_access` are allowed here because -//! benchmark failure is a programming error. -#![allow(clippy::expect_used, clippy::default_trait_access)] - +use std::fmt::Debug; use std::hint::black_box; use criterion::{Criterion, criterion_group, criterion_main}; @@ -23,10 +18,22 @@ use uuid::Uuid; const BENCH_SEED: [u8; 32] = [0x42u8; 32]; /// Deterministic Ed25519 keypair PEM strings from [`BENCH_SEED`]. +fn or_exit(operation: &str, result: Result) -> T { + match result { + Ok(value) => value, + Err(error) => { + eprintln!("FATAL: {operation}: {error:?}"); + std::process::exit(1); + } + } +} + fn dev_keypair_pems() -> (String, String) { use aws_lc_rs::signature::{Ed25519KeyPair, KeyPair}; - let key_pair = - Ed25519KeyPair::from_seed_unchecked(&BENCH_SEED).expect("Ed25519 keypair from seed"); + let key_pair = or_exit( + "construct deterministic Ed25519 keypair", + Ed25519KeyPair::from_seed_unchecked(&BENCH_SEED), + ); let public_key = key_pair.public_key().as_ref().to_vec(); let private_pem = pem_encode("PRIVATE KEY", &ed25519_pkcs8_der(&BENCH_SEED)); let public_pem = pem_encode("PUBLIC KEY", &ed25519_spki_der(&public_key)); @@ -35,25 +42,34 @@ fn dev_keypair_pems() -> (String, String) { fn bench_sign_verify(c: &mut Criterion) { let (private_pem, public_pem) = dev_keypair_pems(); - let encoding_key = - EncodingKey::from_ed_pem(private_pem.as_bytes()).expect("valid Ed25519 private key PEM"); - let decoding_key = - DecodingKey::from_ed_pem(public_pem.as_bytes()).expect("valid Ed25519 public key PEM"); + let encoding_key = or_exit( + "parse benchmark Ed25519 private key", + EncodingKey::from_ed_pem(private_pem.as_bytes()), + ); + let decoding_key = or_exit( + "parse benchmark Ed25519 public key", + DecodingKey::from_ed_pem(public_pem.as_bytes()), + ); - let bench_user_id = - UserId::try_from(Uuid::from_u128(0xDEAD_BEEF_CAFE_BABE_0123_4567_89AB_CDEF)) - .expect("benchmark user id is non-nil"); + let bench_user_id = or_exit( + "construct non-zero benchmark user id", + UserId::try_from(Uuid::from_u128(0xDEAD_BEEF_CAFE_BABE_0123_4567_89AB_CDEF)), + ); c.bench_function("sign_verify", |b| { b.iter(|| { - let token = create_jwt( - black_box(&bench_user_id), - black_box(&encoding_key), - black_box(60 * 60 * 24), - ) - .expect("create_jwt should succeed"); - let claims = validate_jwt(black_box(&token), black_box(&decoding_key)) - .expect("validate_jwt should succeed"); + let token = or_exit( + "create benchmark JWT", + create_jwt( + black_box(&bench_user_id), + black_box(&encoding_key), + black_box(60 * 60 * 24), + ), + ); + let claims = or_exit( + "validate benchmark JWT", + validate_jwt(black_box(&token), black_box(&decoding_key)), + ); let _ = black_box(claims.sub); }); }); diff --git a/gateway/src/auth/refresh.rs b/gateway/src/auth/refresh.rs index b8c4ead..3e2f98d 100644 --- a/gateway/src/auth/refresh.rs +++ b/gateway/src/auth/refresh.rs @@ -30,15 +30,6 @@ pub enum RefreshError { InvalidTtl, } -#[derive(Debug, Clone)] -pub struct RefreshTokenRecord { - pub jti: Uuid, - pub subject: UserId, - pub created_at: DateTime, - pub expires_at: DateTime, - pub revoked_at: Option>, -} - /// Generate a fresh 256-bit opaque refresh token and its database JTI. /// /// # Errors @@ -159,47 +150,6 @@ pub async fn rotate( })) } -/// Lookup helper used by tests and admin tooling. -/// -/// # Errors -/// Returns a database error or an invalid stored user-id error. -#[allow(dead_code)] -pub async fn find_by_jti( - pool: &PgPool, - jti: Uuid, -) -> Result, RefreshError> { - let row = sqlx::query_as::< - _, - ( - Uuid, - Uuid, - DateTime, - DateTime, - Option>, - ), - >( - "SELECT jti, subject, created_at, expires_at, revoked_at \ - FROM refresh_tokens WHERE jti = $1", - ) - .bind(jti) - .fetch_optional(pool) - .await?; - - match row { - Some((jti, subject, created_at, expires_at, revoked_at)) => { - let subject = UserId::try_from(subject)?; - Ok(Some(RefreshTokenRecord { - jti, - subject, - created_at, - expires_at, - revoked_at, - })) - } - None => Ok(None), - } -} - #[cfg(test)] mod tests { use super::*; @@ -211,23 +161,14 @@ mod tests { } #[test] - #[expect( - clippy::panic, - reason = "RNG failure on a test host indicates a broken environment" - )] - fn generator_returns_unique_tokens_and_valid_jtis() { - let (a, ja) = match generate_raw_refresh_token() { - Ok(v) => v, - Err(e) => panic!("OS RNG unavailable on test host: {e}"), - }; - let (b, jb) = match generate_raw_refresh_token() { - Ok(v) => v, - Err(e) => panic!("OS RNG unavailable on test host: {e}"), - }; + fn generator_returns_unique_tokens_and_valid_jtis() -> Result<(), RefreshError> { + let (a, ja) = generate_raw_refresh_token()?; + let (b, jb) = generate_raw_refresh_token()?; assert_ne!(a, b); assert_ne!(ja, jb); assert_eq!(a.len(), 43); assert_eq!(ja.get_version_num(), 4); assert_eq!(jb.get_version_num(), 4); + Ok(()) } } diff --git a/gateway/src/cors.rs b/gateway/src/cors.rs index 78cdc81..1236d7b 100644 --- a/gateway/src/cors.rs +++ b/gateway/src/cors.rs @@ -49,10 +49,6 @@ pub fn cors_layer(allowed_origins: &str) -> CorsLayer { } /// Add a baseline CSP if the response has not already supplied one. -#[allow( - clippy::unused_async, - reason = "axum middleware handlers await next.run(request)" -)] pub async fn csp_middleware( request: axum::extract::Request, next: axum::middleware::Next, @@ -99,67 +95,55 @@ mod tests { frame-ancestors 'none'; \ form-action 'self'"; - #[expect( - clippy::expect_used, - clippy::unwrap_used, - reason = "synthetic test response/request construction" - )] #[tokio::test] async fn csp_middleware_inserts_default_header() { async fn handler() -> Response { - Response::builder() - .status(StatusCode::OK) - .body(Body::empty()) - .expect("static response build") + Response::new(Body::empty()) } let app = Router::new() .route("/", get(handler)) .layer(from_fn(csp_middleware)); - let response = app - .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) - .await - .unwrap(); + let response = match app.oneshot(Request::new(Body::empty())).await { + Ok(response) => response, + Err(error) => match error {}, + }; assert_eq!(response.status(), StatusCode::OK); - let header = response - .headers() - .get("content-security-policy") - .expect("CSP header missing"); - assert_eq!(header.to_str().unwrap(), POLICY); + assert_eq!( + response + .headers() + .get("content-security-policy") + .and_then(|header| header.to_str().ok()), + Some(POLICY), + ); } - #[expect( - clippy::expect_used, - clippy::unwrap_used, - reason = "synthetic test response/request construction" - )] #[tokio::test] async fn csp_middleware_does_not_override_existing_header() { async fn handler() -> Response { - Response::builder() - .status(StatusCode::OK) - .header("content-security-policy", "default-src 'none'") - .body(Body::empty()) - .expect("static response build") + let mut response = Response::new(Body::empty()); + response.headers_mut().insert( + HeaderName::from_static("content-security-policy"), + HeaderValue::from_static("default-src 'none'"), + ); + response } let app = Router::new() .route("/", get(handler)) .layer(from_fn(csp_middleware)); - let response = app - .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) - .await - .unwrap(); + let response = match app.oneshot(Request::new(Body::empty())).await { + Ok(response) => response, + Err(error) => match error {}, + }; assert_eq!( response .headers() .get("content-security-policy") - .unwrap() - .to_str() - .unwrap(), - "default-src 'none'", + .and_then(|header| header.to_str().ok()), + Some("default-src 'none'"), ); } } diff --git a/i18n-demo/build.rs b/i18n-demo/build.rs index f9cc93d..b968f2a 100644 --- a/i18n-demo/build.rs +++ b/i18n-demo/build.rs @@ -26,19 +26,19 @@ fn main() -> Result<(), Box> { translations_infos.rerun_if_locales_changed(); - // Suppress clippy lints in the generated module. The workspace denies both - // `clippy::pedantic` (group-level, priority -1) and individual lints such - // as `expect_used` (item-level, default priority). Our `#![allow]` must - // cover both levels. + // Suppress style-only Clippy noise in third-party generated source. The + // strict gate still forbids unwrap/expect/panic/unsafe-policy lints, so + // generated code cannot waive those non-negotiable categories. let top_level_attrs: proc_macro2::TokenStream = concat!( "#![allow(", + "clippy::allow_attributes, ", "clippy::pedantic, ", "clippy::module_inception, ", "clippy::use_self, ", - "clippy::expect_used, ", "clippy::missing_const_for_fn, ", "clippy::must_use_candidate, ", - "clippy::default_trait_access", + "clippy::default_trait_access, ", + "reason = \"third-party leptos_i18n generated source is not maintained in this crate\"", ")]" ) .parse() diff --git a/i18n-demo/src/app.rs b/i18n-demo/src/app.rs index 5741257..a42183a 100644 --- a/i18n-demo/src/app.rs +++ b/i18n-demo/src/app.rs @@ -48,10 +48,7 @@ fn set_document_lang(lang: &str) { #[cfg(not(target_arch = "wasm32"))] const fn set_document_lang(_lang: &str) {} -#[expect( - clippy::must_use_candidate, - reason = "Leptos component returns impl IntoView; must_use is implicit" -)] +#[must_use] #[component] pub fn App() -> impl IntoView { provide_meta_context(); @@ -72,10 +69,7 @@ pub fn App() -> impl IntoView { } } -#[expect( - clippy::must_use_candidate, - reason = "Leptos component returns impl IntoView; must_use is implicit" -)] +#[must_use] #[component] pub fn Home() -> impl IntoView { let i18n = use_i18n(); diff --git a/i18n-demo/src/ws_chat.rs b/i18n-demo/src/ws_chat.rs index 8e77e08..f6a4ca3 100644 --- a/i18n-demo/src/ws_chat.rs +++ b/i18n-demo/src/ws_chat.rs @@ -64,7 +64,7 @@ fn origin_matches_host(origin: &str, host: &str) -> bool { /// Browser-originated upgrades must be same-origin. The protocol layer rejects /// oversized frames/messages before application code receives them; the /// in-loop length check remains as defense in depth. -#[allow( +#[expect( clippy::unused_async, reason = "Axum handler entry point is intentionally async" )] @@ -185,22 +185,13 @@ mod tests { } #[test] - #[expect( - clippy::panic, - reason = "serde failure would make this test fixture invalid" - )] - fn event_round_trips_through_serde_json() { + fn event_round_trips_through_serde_json() -> serde_json::Result<()> { let event = ChatEvent::new(Uuid::new_v4(), "hello, room".to_string()); - let json = match serde_json::to_string(&event) { - Ok(value) => value, - Err(e) => panic!("serialize failed: {e}"), - }; - let back: ChatEvent = match serde_json::from_str(&json) { - Ok(value) => value, - Err(e) => panic!("deserialize failed: {e}"), - }; + let json = serde_json::to_string(&event)?; + let back: ChatEvent = serde_json::from_str(&json)?; assert_eq!(back.id, event.id); assert_eq!(back.from, event.from); assert_eq!(back.text, event.text); + Ok(()) } } diff --git a/live-search/benches/db_bench.rs b/live-search/benches/db_bench.rs index d365092..399c424 100644 --- a/live-search/benches/db_bench.rs +++ b/live-search/benches/db_bench.rs @@ -14,11 +14,6 @@ //! in the workspace `[workspace.dependencies]`). #![cfg(feature = "ssr")] -// Benchmarks intentionally use `expect`/`unwrap_or_default`/`unwrap` because: -// 1. They run against a real Postgres with a known seed dataset. -// 2. Failure should abort the bench loudly, not produce noise measurements. -// This is a documented exception to the workspace `unwrap_used`/`expect_used`/`panic` deny policy. -#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] use std::sync::Arc; use std::time::Duration; diff --git a/live-search/src/app.rs b/live-search/src/app.rs index 3ea4c64..2b75432 100644 --- a/live-search/src/app.rs +++ b/live-search/src/app.rs @@ -32,7 +32,7 @@ use crate::styles; /// # Errors /// Returns a server-function error for invalid input, unavailable application /// state, or a database failure. -#[allow( +#[expect( clippy::unused_async, reason = "The server body is async only in the SSR build generated by #[server]" )] @@ -119,10 +119,7 @@ pub fn shell(options: LeptosOptions) -> impl IntoView { } } -#[expect( - clippy::must_use_candidate, - reason = "Leptos #[component] converts this to a view fn; must_use is implicit" -)] +#[must_use] #[component] pub fn App() -> impl IntoView { provide_meta_context(); @@ -178,17 +175,16 @@ pub struct SearchResultRow { pub url: String, } -#[allow( +#[expect( clippy::missing_errors_doc, - reason = "Leptos component, not a fallible function" + reason = "Leptos component macro expansion looks fallible although the component API is not" )] #[component] -#[allow(unused_variables)] fn TitleLinkCellRenderer( class: String, value: Signal, row: RwSignal, - index: usize, + _index: usize, ) -> impl IntoView { let url = move || row.read().url.clone(); view! { @@ -213,10 +209,7 @@ fn result_row_renderer( } } -#[expect( - clippy::must_use_candidate, - reason = "Leptos #[component] converts this to a view fn; must_use is implicit" -)] +#[must_use] #[component] pub fn SearchPage() -> impl IntoView { let (query, set_query) = signal(String::new()); @@ -331,10 +324,7 @@ struct LiveResult { snippet: Arc, } -#[expect( - clippy::must_use_candidate, - reason = "Leptos #[component] converts this to a view fn; must_use is implicit" -)] +#[must_use] #[component] pub fn LiveFeedPage() -> impl IntoView { let results = RwSignal::new(VecDeque::::new()); diff --git a/live-search/src/cache.rs b/live-search/src/cache.rs index f285cc0..0f215e9 100644 --- a/live-search/src/cache.rs +++ b/live-search/src/cache.rs @@ -238,10 +238,6 @@ mod tests { /// only the version check makes `get` reject it. A subsequent `insert` /// at the same key, before the TTL expires, returns the *new* payload /// (verified via pointer inequality). - #[expect( - clippy::expect_used, - reason = "test assertion must hard-fail with a clear message if the preconditions are not met" - )] #[tokio::test] async fn cache_invalidate_then_reinsert_returns_fresh_payload() { let handle = CacheHandle::new(1000, Duration::from_secs(60)); @@ -249,11 +245,13 @@ mod tests { let first = Arc::new(sample_results()); handle.insert(key.clone(), first.clone()).await; - let pre_bump_first = handle - .get(&key) - .await - .expect("first insert must be visible"); - assert!(Arc::ptr_eq(&first, &pre_bump_first)); + let pre_bump_first = handle.get(&key).await; + assert!( + pre_bump_first + .as_ref() + .is_some_and(|cached| Arc::ptr_eq(&first, cached)), + "first insert must be visible and share the inserted Arc" + ); handle.invalidate_all(); assert!( @@ -263,13 +261,17 @@ mod tests { let second = Arc::new(sample_results()); handle.insert(key.clone(), second.clone()).await; - let post_bump_second = handle.get(&key).await.expect("reinsert must be visible"); + let post_bump_second = handle.get(&key).await; assert!( - Arc::ptr_eq(&second, &post_bump_second), - "reinsert should share the new Arc" + post_bump_second + .as_ref() + .is_some_and(|cached| Arc::ptr_eq(&second, cached)), + "reinsert must be visible and share the new Arc" ); assert!( - !Arc::ptr_eq(&first, &post_bump_second), + post_bump_second + .as_ref() + .is_some_and(|cached| !Arc::ptr_eq(&first, cached)), "must NOT return the stale payload after a reinsert" ); } diff --git a/live-search/src/shutdown.rs b/live-search/src/shutdown.rs index 8ef7180..eb0859d 100644 --- a/live-search/src/shutdown.rs +++ b/live-search/src/shutdown.rs @@ -17,46 +17,60 @@ use crate::db; /// Order matters: background tasks are drained before the pool is closed so the /// `PgListener` can observe cancellation and release its connection cleanly. /// -/// # Panics -/// Only panics if OS signal-handler installation fails. -/// /// # Errors -/// Returns the first background-task error/panic observed while draining, or an -/// error if tasks exceed the shutdown grace period. Cleanup still runs before -/// the error is returned. -#[expect( - clippy::expect_used, - reason = "signal handler installation failure is an unrecoverable runtime state" -)] +/// Returns an error if an OS signal handler cannot be installed, if a background +/// task fails or panics while draining, or if tasks exceed the shutdown grace +/// period. Cleanup still runs before any of these errors is returned. pub async fn wait( shutdown: CancellationToken, tasks: &mut JoinSet>, pool: &sqlx::PgPool, ) -> anyhow::Result<()> { - let signal_token = shutdown.clone(); - tokio::spawn(async move { - let ctrl_c = async { - signal::ctrl_c() - .await - .expect("failed to install Ctrl+C handler"); - }; - #[cfg(unix)] - let terminate = async { - let mut sig = signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("failed to install SIGTERM handler"); - sig.recv().await; - }; - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); + let mut signal_error: Option = None; + + #[cfg(unix)] + match signal::unix::signal(signal::unix::SignalKind::terminate()) { + Ok(mut terminate) => { + tokio::select! { + () = shutdown.cancelled() => { + tracing::info!("shutdown requested by application"); + } + result = signal::ctrl_c() => { + match result { + Ok(()) => tracing::info!("Ctrl+C received, initiating shutdown"), + Err(error) => { + tracing::error!(%error, "failed to install or await Ctrl+C handler"); + signal_error = Some(error.into()); + } + } + } + _ = terminate.recv() => { + tracing::info!("SIGTERM received, initiating shutdown"); + } + } + } + Err(error) => { + tracing::error!(%error, "failed to install SIGTERM handler"); + signal_error = Some(error.into()); + } + } - tokio::select! { - () = ctrl_c => tracing::info!("Ctrl+C received, initiating shutdown"), - () = terminate => tracing::info!("SIGTERM received, initiating shutdown"), + #[cfg(not(unix))] + tokio::select! { + () = shutdown.cancelled() => { + tracing::info!("shutdown requested by application"); } - signal_token.cancel(); - }); + result = signal::ctrl_c() => { + match result { + Ok(()) => tracing::info!("Ctrl+C received, initiating shutdown"), + Err(error) => { + tracing::error!(%error, "failed to install or await Ctrl+C handler"); + signal_error = Some(error.into()); + } + } + } + } - shutdown.cancelled().await; shutdown.cancel(); let drain = tokio::time::timeout(Duration::from_secs(10), async { @@ -86,7 +100,7 @@ pub async fn wait( }) .await; - let shutdown_error = match drain { + let drain_error = match drain { Ok(error) => error, Err(_elapsed) => { tracing::warn!("background tasks did not drain within 10s; aborting"); @@ -115,5 +129,5 @@ pub async fn wait( } } - shutdown_error.map_or_else(|| Ok(()), Err) + signal_error.or(drain_error).map_or_else(|| Ok(()), Err) } diff --git a/live-search/src/sse.rs b/live-search/src/sse.rs index 3bc02c7..bd73149 100644 --- a/live-search/src/sse.rs +++ b/live-search/src/sse.rs @@ -29,7 +29,7 @@ use crate::events::SseEvent; /// /// The caller must provide the broadcast sender (typically captured by a /// closure in the router setup). -#[allow( +#[expect( clippy::unused_async, reason = "Axum 0.8 requires async fn for Handler trait" )] @@ -91,16 +91,11 @@ mod tests { /// data string) and the public contract is "client subscribes via /// `addEventListener('event-name', …)` and parses the data as JSON". /// Verifying the JSON shape is the meaningful unit. - #[expect( - clippy::expect_used, - reason = "test fixtures: chrono::from_timestamp(0,0) is infallible on a sound system clock, and serde_json of the canonical enum cannot fail" - )] #[test] - fn sse_event_json_shape_per_variant() { - let epoch = - chrono::DateTime::from_timestamp(0, 0).expect("unix epoch is always representable"); + fn sse_event_json_shape_per_variant() -> serde_json::Result<()> { + let epoch = chrono::DateTime::::UNIX_EPOCH; let connected = SseEvent::Connected { server_time: epoch }; - let json = serde_json::to_string(&connected).expect("Connected serializes"); + let json = serde_json::to_string(&connected)?; assert!( json.contains("\"type\":\"Connected\""), "Connected must carry the `type` tag; got {json}" @@ -115,16 +110,17 @@ mod tests { url: "u".into(), snippet: "s".into(), }; - let json = serde_json::to_string(&result).expect("SearchResult serializes"); + let json = serde_json::to_string(&result)?; assert!(json.contains("\"type\":\"SearchResult\"")); assert!(json.contains("\"title\":\"t\"")); assert!(json.contains("\"url\":\"u\"")); assert!(json.contains("\"snippet\":\"s\"")); let lagged = SseEvent::StreamLagged { skipped: 42 }; - let json = serde_json::to_string(&lagged).expect("StreamLagged serializes"); + let json = serde_json::to_string(&lagged)?; assert!(json.contains("\"type\":\"StreamLagged\"")); assert!(json.contains("\"skipped\":42")); + Ok(()) } // (sse_event_variants_are_all_reachable removed in round 5: the From 760aea5b7094710b421efd5c8432895cb8760450 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Tue, 29 Sep 2026 05:45:47 +0200 Subject: [PATCH 3/7] Preserve TableRow index prop without lint suppression --- live-search/src/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/live-search/src/app.rs b/live-search/src/app.rs index 2b75432..fdbf091 100644 --- a/live-search/src/app.rs +++ b/live-search/src/app.rs @@ -184,8 +184,9 @@ fn TitleLinkCellRenderer( class: String, value: Signal, row: RwSignal, - _index: usize, + index: usize, ) -> impl IntoView { + let _ = index; let url = move || row.read().url.clone(); view! { From 418790b2d78516a3d8787af878bebbb67721bfe3 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Tue, 29 Sep 2026 05:50:52 +0200 Subject: [PATCH 4/7] Pin strict Rust gate acceptance revision --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2da4b52..cbbc6ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ env: jobs: rust-skills2-strict: name: rust-skills2 strict nightly gate - uses: codeandsolder/rust-skills2/.github/workflows/strict-rust.yml@ci/strict-rust-gate + uses: codeandsolder/rust-skills2/.github/workflows/strict-rust.yml@692143127e05e36af9e91b4befa2f75c446a01f2 lockfile: name: Lockfile consistency From b1954693cab377bdf6d115c16d563b5b357e8ba8 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Tue, 29 Sep 2026 06:08:06 +0200 Subject: [PATCH 5/7] Validate final strict Rust gate revision --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbbc6ec..9f00d2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ env: jobs: rust-skills2-strict: name: rust-skills2 strict nightly gate - uses: codeandsolder/rust-skills2/.github/workflows/strict-rust.yml@692143127e05e36af9e91b4befa2f75c446a01f2 + uses: codeandsolder/rust-skills2/.github/workflows/strict-rust.yml@910b471340481c4251496578dc446c320d21f2c5 lockfile: name: Lockfile consistency From f1669fae7fe2954ed200edbd190cc2847a6f182a Mon Sep 17 00:00:00 2001 From: codeandsolder <23157028+codeandsolder@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:27:29 +0200 Subject: [PATCH 6/7] ci: pin strict gate to merged rust-skills2 revision --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f00d2c..0280624 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ env: jobs: rust-skills2-strict: name: rust-skills2 strict nightly gate - uses: codeandsolder/rust-skills2/.github/workflows/strict-rust.yml@910b471340481c4251496578dc446c320d21f2c5 + uses: codeandsolder/rust-skills2/.github/workflows/strict-rust.yml@8a3424af099040eee4742f87609672db6759ec4c lockfile: name: Lockfile consistency From 6ef563f2ff603973e58bcd7780eddc04e545f937 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Tue, 29 Sep 2026 06:28:38 +0200 Subject: [PATCH 7/7] Track rust-skills2 strict gate from main --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0280624..909048f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ env: jobs: rust-skills2-strict: name: rust-skills2 strict nightly gate - uses: codeandsolder/rust-skills2/.github/workflows/strict-rust.yml@8a3424af099040eee4742f87609672db6759ec4c + uses: codeandsolder/rust-skills2/.github/workflows/strict-rust.yml@main lockfile: name: Lockfile consistency