chore(lint): enable clippy pedantic and restriction lints

- Add [lints.clippy] section to Cargo.toml with pedantic group
- Cherry-pick restriction lints: dbg_macro (deny), todo, unwrap_used, expect_used (warn)
- Allow noisy pedantic lints (missing_errors_doc, module_name_repetitions, etc.)
- Fix unnecessary Result wrappers in token and user repository to_domain functions
- Merge duplicate match arms in TimelineEventViewModel
- Add justified #[allow] attributes for startup code and tests
This commit is contained in:
Jon Seager 2026-02-02 17:55:16 +00:00
parent 500389583f
commit 2bd008a8d7
No known key found for this signature in database
18 changed files with 93 additions and 77 deletions

View file

@ -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

View file

@ -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<Response, ApiError> {
let request = query.into_request::<BagSortKey>();
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(),

View file

@ -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<NewGear, AppError> {
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"));

View file

@ -181,7 +181,7 @@ pub(crate) async fn list_roasts(
let s = s.trim();
Some(s.parse::<RoasterId>().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,

View file

@ -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) {

View file

@ -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 => {},
}
}

View file

@ -164,7 +164,7 @@ impl<K: SortKey> ListRequest<K> {
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<T> Page<T> {
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<T> Page<T> {
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
}
}

View file

@ -14,7 +14,7 @@ pub fn hash_password(password: &str) -> Result<String> {
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<String> {
/// Verifies a password against a hash
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
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::*;

View file

@ -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<Bag, RepositoryError> {
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<Bag, RepositoryError> {
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<BagWithRoast, RepositoryError> {
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(),
};

View file

@ -37,7 +37,7 @@ impl SqlGearRepository {
}
fn to_domain(record: GearRecord) -> Result<Gear, RepositoryError> {
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<Gear, RepositoryError> {
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<Gear, RepositoryError> {
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(),
};

View file

@ -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.

View file

@ -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)

View file

@ -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<Roast, RepositoryError> {
@ -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()
}

View file

@ -24,11 +24,11 @@ impl SqlTimelineEventRepository {
#[async_trait]
impl TimelineEventRepository for SqlTimelineEventRepository {
async fn insert(&self, event: NewTimelineEvent) -> Result<TimelineEvent, RepositoryError> {
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}"))

View file

@ -18,7 +18,7 @@ impl SqlTokenRepository {
Self { pool }
}
fn to_domain(record: TokenRecord) -> Result<Token, RepositoryError> {
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<Token, RepositoryError> {
@ -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<Token, RepositoryError> {
@ -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<Vec<Token>, 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<Token, RepositoryError> {
@ -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),
}
}

View file

@ -18,7 +18,7 @@ impl SqlUserRepository {
Self { pool }
}
fn to_domain(record: UserRecord) -> Result<User, RepositoryError> {
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<User, RepositoryError> {
@ -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<User, RepositoryError> {
@ -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<bool, RepositoryError> {

View file

@ -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");

View file

@ -44,7 +44,7 @@ impl<T> Paginated<T> {
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<T> Paginated<T> {
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<K: SortKey> ListNavigator<K> {
}
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<K: SortKey> ListNavigator<K> {
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<K>) -> 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,