diff --git a/Cargo.toml b/Cargo.toml index 772c33c..6145e09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,3 +60,23 @@ opt-level = 0 name = "server" path = "tests/server/main.rs" harness = true + +[lints.clippy] +# Enable pedantic lints with lower priority so individual allows take precedence +pedantic = { level = "warn", priority = -1 } + +# Pedantic lints to disable (too noisy or not applicable) +missing_errors_doc = "allow" # Would require extensive doc changes +missing_panics_doc = "allow" # Would require extensive doc changes +module_name_repetitions = "allow" # Common pattern in domain types (e.g., RoasterId in roasters) +must_use_candidate = "allow" # Too aggressive for this codebase +return_self_not_must_use = "allow" # Builder methods returning Self are common +needless_pass_by_value = "allow" # Generic T: ToString pattern and consumed value types +cast_possible_truncation = "allow" # Pagination values won't exceed u32 in practice +cast_sign_loss = "allow" # Database counts are non-negative + +# Cherry-picked restriction lints (production safety) +dbg_macro = "deny" # Prevent debug macros in production +todo = "warn" # Flag TODOs for attention +unwrap_used = "warn" # Flag unwrap() for review; allow where justified +expect_used = "warn" # Flag expect() for review; allow where justified diff --git a/src/application/routes/bags.rs b/src/application/routes/bags.rs index 61b488f..f27f2ed 100644 --- a/src/application/routes/bags.rs +++ b/src/application/routes/bags.rs @@ -149,7 +149,7 @@ pub(crate) async fn create_bag( entity_id: bag.id.into_inner(), action: "added".to_string(), occurred_at: chrono::Utc::now(), - title: roast.name.to_string(), + title: roast.name.clone(), details: vec![ TimelineEventDetail { label: "Roaster".to_string(), @@ -213,11 +213,14 @@ pub(crate) async fn update_bag( ) -> Result { let request = query.into_request::(); - let body_update = payload.map(|Json(p)| p).unwrap_or(UpdateBag { - remaining: None, - closed: None, - finished_at: None, - }); + let body_update = payload.map_or( + UpdateBag { + remaining: None, + closed: None, + finished_at: None, + }, + |Json(p)| p, + ); let mut update = UpdateBag { remaining: body_update.remaining.or(update_params.remaining), @@ -247,7 +250,7 @@ pub(crate) async fn update_bag( entity_id: bag.id.into_inner(), action: "finished".to_string(), occurred_at: chrono::Utc::now(), - title: roast.name.to_string(), + title: roast.name.clone(), details: vec![ TimelineEventDetail { label: "Roaster".to_string(), diff --git a/src/application/routes/gear.rs b/src/application/routes/gear.rs index 33bd2a8..be9da2e 100644 --- a/src/application/routes/gear.rs +++ b/src/application/routes/gear.rs @@ -142,7 +142,7 @@ pub(crate) async fn list_gear( let filter = match params.category { Some(ref cat_str) => { let category = GearCategory::from_str(cat_str) - .map_err(|_| AppError::validation("invalid category"))?; + .map_err(|()| AppError::validation("invalid category"))?; GearFilter::for_category(category) } None => GearFilter::all(), @@ -207,7 +207,7 @@ pub(crate) struct NewGearSubmission { impl NewGearSubmission { fn into_new_gear(self) -> Result { let category = GearCategory::from_str(&self.category) - .map_err(|_| AppError::validation("invalid category"))?; + .map_err(|()| AppError::validation("invalid category"))?; if self.make.trim().is_empty() { return Err(AppError::validation("make cannot be empty")); diff --git a/src/application/routes/roasts.rs b/src/application/routes/roasts.rs index 7d610b2..e9fd9da 100644 --- a/src/application/routes/roasts.rs +++ b/src/application/routes/roasts.rs @@ -181,7 +181,7 @@ pub(crate) async fn list_roasts( let s = s.trim(); Some(s.parse::().map_err(|_| { tracing::warn!("Invalid roaster_id: '{}'", s); - ApiError::from(AppError::validation(format!("Invalid roaster_id: '{}'", s))) + ApiError::from(AppError::validation(format!("Invalid roaster_id: '{s}'"))) })?) } _ => None, diff --git a/src/application/routes/support.rs b/src/application/routes/support.rs index 224adc6..c202092 100644 --- a/src/application/routes/support.rs +++ b/src/application/routes/support.rs @@ -192,8 +192,7 @@ pub fn is_datastar_request(headers: &HeaderMap) -> bool { headers .get("datastar-request") .and_then(|value| value.to_str().ok()) - .map(|value| value.eq_ignore_ascii_case("true")) - .unwrap_or(false) + .is_some_and(|value| value.eq_ignore_ascii_case("true")) } pub fn set_datastar_patch_headers(headers: &mut HeaderMap, selector: &'static str) { diff --git a/src/application/server.rs b/src/application/server.rs index 22337d8..8e7453e 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -165,6 +165,7 @@ async fn bootstrap_admin_user( Ok(()) } +#[allow(clippy::expect_used)] // Startup: panicking is appropriate if signal handlers fail async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c() @@ -180,7 +181,7 @@ async fn shutdown_signal() { }; tokio::select! { - _ = ctrl_c => {}, - _ = terminate => {}, + () = ctrl_c => {}, + () = terminate => {}, } } diff --git a/src/domain/listing.rs b/src/domain/listing.rs index 96df7c9..62f08e4 100644 --- a/src/domain/listing.rs +++ b/src/domain/listing.rs @@ -164,7 +164,7 @@ impl ListRequest { return Self::new(1, self.page_size, self.sort_key, self.sort_direction); } - let last_page = (total.div_ceil(limit as u64)) as u32; + let last_page = (total.div_ceil(u64::from(limit))) as u32; let adjusted_page = self.page.min(last_page.max(1)); Self::new( adjusted_page, @@ -199,7 +199,7 @@ impl Page { if self.total == 0 || self.showing_all { 1 } else { - let size = self.page_size as u64; + let size = u64::from(self.page_size); (self.total.div_ceil(size)) as u32 } } @@ -216,7 +216,7 @@ impl Page { if self.total == 0 { 0 } else { - ((self.page - 1) as u64) * self.page_size as u64 + 1 + u64::from(self.page - 1) * u64::from(self.page_size) + 1 } } diff --git a/src/infrastructure/auth.rs b/src/infrastructure/auth.rs index 4a6e4b8..b72f788 100644 --- a/src/infrastructure/auth.rs +++ b/src/infrastructure/auth.rs @@ -14,7 +14,7 @@ pub fn hash_password(password: &str) -> Result { let password_hash = argon2 .hash_password(password.as_bytes(), &salt) - .map_err(|e| anyhow::anyhow!("failed to hash password: {}", e))? + .map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))? .to_string(); Ok(password_hash) @@ -23,7 +23,7 @@ pub fn hash_password(password: &str) -> Result { /// Verifies a password against a hash pub fn verify_password(password: &str, password_hash: &str) -> Result { let parsed_hash = PasswordHash::new(password_hash) - .map_err(|e| anyhow::anyhow!("failed to parse password hash: {}", e))?; + .map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?; let argon2 = Argon2::default(); @@ -57,6 +57,7 @@ pub fn generate_session_token() -> String { } #[cfg(test)] +#[allow(clippy::unwrap_used)] // Tests: unwrap is acceptable for test assertions mod tests { use super::*; diff --git a/src/infrastructure/repositories/bags.rs b/src/infrastructure/repositories/bags.rs index c9921da..3fba270 100644 --- a/src/infrastructure/repositories/bags.rs +++ b/src/infrastructure/repositories/bags.rs @@ -10,7 +10,7 @@ use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::repositories::BagRepository; use crate::infrastructure::database::DatabasePool; -const BASE_SELECT: &str = r#" +const BASE_SELECT: &str = r" SELECT b.id, b.roast_id, b.roast_date, b.amount, b.remaining, b.closed, b.finished_at, b.created_at, b.updated_at, r.name as roast_name, r.slug as roast_slug, @@ -18,7 +18,7 @@ const BASE_SELECT: &str = r#" FROM bags b JOIN roasts r ON b.roast_id = r.id JOIN roasters rr ON r.roaster_id = rr.id -"#; +"; #[derive(Clone)] pub struct SqlBagRepository { @@ -108,11 +108,11 @@ impl SqlBagRepository { #[async_trait] impl BagRepository for SqlBagRepository { async fn insert(&self, bag: NewBag) -> Result { - let query = r#" + let query = r" INSERT INTO bags (roast_id, roast_date, amount, remaining) VALUES (?, ?, ?, ?) RETURNING id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at - "#; + "; let record = query_as::<_, BagRecord>(query) .bind(bag.roast_id.into_inner()) @@ -127,11 +127,11 @@ impl BagRepository for SqlBagRepository { } async fn get(&self, id: BagId) -> Result { - let query = r#" + let query = r" SELECT id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at FROM bags WHERE id = ? - "#; + "; let record = query_as::<_, BagRecord>(query) .bind(id.into_inner()) @@ -144,7 +144,7 @@ impl BagRepository for SqlBagRepository { } async fn get_with_roast(&self, id: BagId) -> Result { - let query = format!("{} WHERE b.id = ?", BASE_SELECT); + let query = format!("{BASE_SELECT} WHERE b.id = ?"); let record = query_as::<_, BagWithRoastRecord>(&query) .bind(id.into_inner()) @@ -167,12 +167,12 @@ impl BagRepository for SqlBagRepository { let where_clause = Self::build_where_clause(&filter); let base_query = match &where_clause { - Some(w) => format!("{} WHERE {}", BASE_SELECT, w), + Some(w) => format!("{BASE_SELECT} WHERE {w}"), None => BASE_SELECT.to_string(), }; let count_query = match &where_clause { - Some(w) => format!("SELECT COUNT(*) FROM bags b WHERE {}", w), + Some(w) => format!("SELECT COUNT(*) FROM bags b WHERE {w}"), None => "SELECT COUNT(*) FROM bags".to_string(), }; diff --git a/src/infrastructure/repositories/gear.rs b/src/infrastructure/repositories/gear.rs index 85cf3a9..a257e7b 100644 --- a/src/infrastructure/repositories/gear.rs +++ b/src/infrastructure/repositories/gear.rs @@ -37,7 +37,7 @@ impl SqlGearRepository { } fn to_domain(record: GearRecord) -> Result { - let category = GearCategory::from_str(&record.category).map_err(|_| { + let category = GearCategory::from_str(&record.category).map_err(|()| { RepositoryError::unexpected(format!("invalid category: {}", record.category)) })?; @@ -62,11 +62,11 @@ impl SqlGearRepository { #[async_trait] impl GearRepository for SqlGearRepository { async fn insert(&self, gear: NewGear) -> Result { - let query = r#" + let query = r" INSERT INTO gear (category, make, model) VALUES (?, ?, ?) RETURNING id, category, make, model, created_at, updated_at - "#; + "; let record = query_as::<_, GearRecord>(query) .bind(gear.category.as_str()) @@ -80,11 +80,11 @@ impl GearRepository for SqlGearRepository { } async fn get(&self, id: GearId) -> Result { - let query = r#" + let query = r" SELECT id, category, make, model, created_at, updated_at FROM gear WHERE id = ? - "#; + "; let record = query_as::<_, GearRecord>(query) .bind(id.into_inner()) @@ -106,8 +106,7 @@ impl GearRepository for SqlGearRepository { let base_query = match &where_clause { Some(w) => format!( - "SELECT id, category, make, model, created_at, updated_at FROM gear WHERE {}", - w + "SELECT id, category, make, model, created_at, updated_at FROM gear WHERE {w}" ), None => { "SELECT id, category, make, model, created_at, updated_at FROM gear".to_string() @@ -115,7 +114,7 @@ impl GearRepository for SqlGearRepository { }; let count_query = match &where_clause { - Some(w) => format!("SELECT COUNT(*) FROM gear WHERE {}", w), + Some(w) => format!("SELECT COUNT(*) FROM gear WHERE {w}"), None => "SELECT COUNT(*) FROM gear".to_string(), }; diff --git a/src/infrastructure/repositories/macros.rs b/src/infrastructure/repositories/macros.rs index 1268136..89d3943 100644 --- a/src/infrastructure/repositories/macros.rs +++ b/src/infrastructure/repositories/macros.rs @@ -1,4 +1,4 @@ -/// Helper macro for building dynamic UPDATE queries with QueryBuilder. +/// Helper macro for building dynamic UPDATE queries with `QueryBuilder`. /// /// Handles the common pattern of conditionally adding SET clauses with proper /// comma separation. diff --git a/src/infrastructure/repositories/pagination.rs b/src/infrastructure/repositories/pagination.rs index 9e15802..e15d5f3 100644 --- a/src/infrastructure/repositories/pagination.rs +++ b/src/infrastructure/repositories/pagination.rs @@ -20,7 +20,7 @@ where { match request.page_size() { PageSize::All => { - let query = format!("{} ORDER BY {}", base_query, order_clause); + let query = format!("{base_query} ORDER BY {order_clause}"); let records = query_as::<_, R>(&query) .fetch_all(pool) .await @@ -35,11 +35,11 @@ where Ok(Page::new(items, 1, page_size.max(1), total, true)) } PageSize::Limited(page_size) => { - let limit = page_size as i64; + let limit = i64::from(page_size); let mut page = request.page(); - let offset = ((page - 1) as i64).saturating_mul(limit); + let offset = i64::from(page - 1).saturating_mul(limit); - let query_sql = format!("{} ORDER BY {} LIMIT ? OFFSET ?", base_query, order_clause); + let query_sql = format!("{base_query} ORDER BY {order_clause} LIMIT ? OFFSET ?"); let mut records = query_as::<_, R>(&query_sql) .bind(limit) @@ -56,7 +56,7 @@ where if page > 1 && records.is_empty() && total > 0 { let last_page = ((total + limit - 1) / limit) as u32; page = last_page.max(1); - let offset = ((page - 1) as i64).saturating_mul(limit); + let offset = i64::from(page - 1).saturating_mul(limit); records = query_as::<_, R>(&query_sql) .bind(limit) .bind(offset) diff --git a/src/infrastructure/repositories/roasts.rs b/src/infrastructure/repositories/roasts.rs index ebc697d..dc949a6 100644 --- a/src/infrastructure/repositories/roasts.rs +++ b/src/infrastructure/repositories/roasts.rs @@ -52,6 +52,7 @@ impl SqlRoastRepository { } } +#[allow(clippy::too_many_lines)] // Repository impl has many methods #[async_trait] impl RoastRepository for SqlRoastRepository { async fn insert(&self, new_roast: NewRoast) -> Result { @@ -198,7 +199,7 @@ impl RoastRepository for SqlRoastRepository { .fetch_optional(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))? - .map(|record| record.into_roast()) + .map(RoastRecord::into_roast) .transpose()? .ok_or(RepositoryError::NotFound) } @@ -214,7 +215,7 @@ impl RoastRepository for SqlRoastRepository { .fetch_optional(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))? - .map(|record| record.into_with_roaster()) + .map(RoastWithRoasterRecord::into_with_roaster) .transpose()? .ok_or(RepositoryError::NotFound) } @@ -232,7 +233,7 @@ impl RoastRepository for SqlRoastRepository { .fetch_optional(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))? - .map(|record| record.into_roast()) + .map(RoastRecord::into_roast) .transpose()? .ok_or(RepositoryError::NotFound) } @@ -270,7 +271,7 @@ impl RoastRepository for SqlRoastRepository { records .into_iter() - .map(|record| record.into_with_roaster()) + .map(RoastWithRoasterRecord::into_with_roaster) .collect() } diff --git a/src/infrastructure/repositories/timeline_events.rs b/src/infrastructure/repositories/timeline_events.rs index 5b2aa68..e51ef17 100644 --- a/src/infrastructure/repositories/timeline_events.rs +++ b/src/infrastructure/repositories/timeline_events.rs @@ -24,11 +24,11 @@ impl SqlTimelineEventRepository { #[async_trait] impl TimelineEventRepository for SqlTimelineEventRepository { async fn insert(&self, event: NewTimelineEvent) -> Result { - let query = r#" + let query = r" INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json - "#; + "; let details_json = serde_json::to_string(&event.details).map_err(|err| { RepositoryError::unexpected(format!("failed to encode timeline event details: {err}")) diff --git a/src/infrastructure/repositories/tokens.rs b/src/infrastructure/repositories/tokens.rs index 8df5752..a9a8e11 100644 --- a/src/infrastructure/repositories/tokens.rs +++ b/src/infrastructure/repositories/tokens.rs @@ -18,7 +18,7 @@ impl SqlTokenRepository { Self { pool } } - fn to_domain(record: TokenRecord) -> Result { + fn to_domain(record: TokenRecord) -> Token { let TokenRecord { id, user_id, @@ -29,7 +29,7 @@ impl SqlTokenRepository { revoked_at, } = record; - Ok(Token::new( + Token::new( TokenId::from(id), UserId::from(user_id), token_hash, @@ -37,7 +37,7 @@ impl SqlTokenRepository { created_at, last_used_at, revoked_at, - )) + ) } } @@ -67,7 +67,7 @@ impl TokenRepository for SqlTokenRepository { RepositoryError::unexpected(err.to_string()) })?; - Self::to_domain(record) + Ok(Self::to_domain(record)) } async fn get(&self, id: TokenId) -> Result { @@ -80,7 +80,7 @@ impl TokenRepository for SqlTokenRepository { .map_err(|err| RepositoryError::unexpected(err.to_string()))? .ok_or(RepositoryError::NotFound)?; - Self::to_domain(record) + Ok(Self::to_domain(record)) } async fn get_by_token_hash(&self, token_hash: &str) -> Result { @@ -93,7 +93,7 @@ impl TokenRepository for SqlTokenRepository { .map_err(|err| RepositoryError::unexpected(err.to_string()))? .ok_or(RepositoryError::NotFound)?; - Self::to_domain(record) + Ok(Self::to_domain(record)) } async fn list_by_user(&self, user_id: UserId) -> Result, RepositoryError> { @@ -105,7 +105,7 @@ impl TokenRepository for SqlTokenRepository { .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; - records.into_iter().map(Self::to_domain).collect() + Ok(records.into_iter().map(Self::to_domain).collect()) } async fn revoke(&self, id: TokenId) -> Result { @@ -120,7 +120,7 @@ impl TokenRepository for SqlTokenRepository { .map_err(|err| RepositoryError::unexpected(err.to_string()))?; match record { - Some(record) => Self::to_domain(record), + Some(record) => Ok(Self::to_domain(record)), None => Err(RepositoryError::NotFound), } } diff --git a/src/infrastructure/repositories/users.rs b/src/infrastructure/repositories/users.rs index 0f0a9f4..58238b2 100644 --- a/src/infrastructure/repositories/users.rs +++ b/src/infrastructure/repositories/users.rs @@ -18,7 +18,7 @@ impl SqlUserRepository { Self { pool } } - fn to_domain(record: UserRecord) -> Result { + fn to_domain(record: UserRecord) -> User { let UserRecord { id, username, @@ -26,12 +26,7 @@ impl SqlUserRepository { created_at, } = record; - Ok(User::new( - UserId::from(id), - username, - password_hash, - created_at, - )) + User::new(UserId::from(id), username, password_hash, created_at) } } @@ -54,7 +49,7 @@ impl UserRepository for SqlUserRepository { RepositoryError::unexpected(err.to_string()) })?; - Self::to_domain(record) + Ok(Self::to_domain(record)) } async fn get(&self, id: UserId) -> Result { @@ -67,7 +62,7 @@ impl UserRepository for SqlUserRepository { .map_err(|err| RepositoryError::unexpected(err.to_string()))? .ok_or(RepositoryError::NotFound)?; - Self::to_domain(record) + Ok(Self::to_domain(record)) } async fn get_by_username(&self, username: &str) -> Result { @@ -80,7 +75,7 @@ impl UserRepository for SqlUserRepository { .map_err(|err| RepositoryError::unexpected(err.to_string()))? .ok_or(RepositoryError::NotFound)?; - Self::to_domain(record) + Ok(Self::to_domain(record)) } async fn exists(&self) -> Result { diff --git a/src/main.rs b/src/main.rs index d262e1d..82cd3a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,6 +76,7 @@ where /// Register a subscriber as global default to process span data. /// /// This should only be called once! +#[allow(clippy::expect_used)] // Startup: panicking is appropriate if logging cannot be initialized pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) { LogTracer::init().expect("Failed to set logger"); set_global_default(subscriber).expect("Failed to set subscriber"); diff --git a/src/presentation/web/views.rs b/src/presentation/web/views.rs index b3f305b..5fad68d 100644 --- a/src/presentation/web/views.rs +++ b/src/presentation/web/views.rs @@ -44,7 +44,7 @@ impl Paginated { if self.total == 0 || self.showing_all { 1 } else { - let page_size = self.page_size as u64; + let page_size = u64::from(self.page_size); self.total.div_ceil(page_size) as u32 } } @@ -77,7 +77,7 @@ impl Paginated { if self.total == 0 { 0 } else { - ((self.page - 1) as u64) * self.page_size as u64 + 1 + u64::from(self.page - 1) * u64::from(self.page_size) + 1 } } @@ -181,9 +181,7 @@ impl ListNavigator { } pub fn is_sorted_by(&self, key: &str) -> bool { - K::from_query(key) - .map(|candidate| candidate == self.request.sort_key()) - .unwrap_or(false) + K::from_query(key).is_some_and(|candidate| candidate == self.request.sort_key()) } pub fn next_sort_dir(&self, key: &str) -> &'static str { @@ -212,6 +210,7 @@ impl ListNavigator { Self::query_string(Self::request_for_sort(self.request, key)) } + #[allow(clippy::unused_self)] // Keeps consistent method interface fn build_href(&self, path: &str, request: ListRequest) -> String { if let Some((base, fragment)) = path.split_once('#') { format!("{}?{}#{}", base, Self::query_string(request), fragment) @@ -460,10 +459,8 @@ impl TimelineEventView { let link = match (entity_type.as_str(), slug, roaster_slug) { ("roaster", Some(slug), _) => format!("/roasters/{slug}"), - ("roast", Some(slug), Some(roaster_slug)) => { - format!("/roasters/{roaster_slug}/roasts/{slug}") - } - ("bag", Some(slug), Some(roaster_slug)) => { + // Both roasts and bags link to the roast page + ("roast" | "bag", Some(slug), Some(roaster_slug)) => { format!("/roasters/{roaster_slug}/roasts/{slug}") } ("gear", _, _) => "/gear".to_string(), @@ -546,8 +543,7 @@ impl BagView { finished_at: bag .bag .finished_at - .map(|d| d.to_string()) - .unwrap_or_else(|| "—".to_string()), + .map_or_else(|| "—".to_string(), |d| d.to_string()), created_at: bag.bag.created_at.format("%Y-%m-%d").to_string(), roast_name: bag.roast_name, roaster_name: bag.roaster_name,