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" name = "server"
path = "tests/server/main.rs" path = "tests/server/main.rs"
harness = true 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(), entity_id: bag.id.into_inner(),
action: "added".to_string(), action: "added".to_string(),
occurred_at: chrono::Utc::now(), occurred_at: chrono::Utc::now(),
title: roast.name.to_string(), title: roast.name.clone(),
details: vec![ details: vec![
TimelineEventDetail { TimelineEventDetail {
label: "Roaster".to_string(), label: "Roaster".to_string(),
@ -213,11 +213,14 @@ pub(crate) async fn update_bag(
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
let request = query.into_request::<BagSortKey>(); let request = query.into_request::<BagSortKey>();
let body_update = payload.map(|Json(p)| p).unwrap_or(UpdateBag { let body_update = payload.map_or(
remaining: None, UpdateBag {
closed: None, remaining: None,
finished_at: None, closed: None,
}); finished_at: None,
},
|Json(p)| p,
);
let mut update = UpdateBag { let mut update = UpdateBag {
remaining: body_update.remaining.or(update_params.remaining), remaining: body_update.remaining.or(update_params.remaining),
@ -247,7 +250,7 @@ pub(crate) async fn update_bag(
entity_id: bag.id.into_inner(), entity_id: bag.id.into_inner(),
action: "finished".to_string(), action: "finished".to_string(),
occurred_at: chrono::Utc::now(), occurred_at: chrono::Utc::now(),
title: roast.name.to_string(), title: roast.name.clone(),
details: vec![ details: vec![
TimelineEventDetail { TimelineEventDetail {
label: "Roaster".to_string(), label: "Roaster".to_string(),

View file

@ -142,7 +142,7 @@ pub(crate) async fn list_gear(
let filter = match params.category { let filter = match params.category {
Some(ref cat_str) => { Some(ref cat_str) => {
let category = GearCategory::from_str(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) GearFilter::for_category(category)
} }
None => GearFilter::all(), None => GearFilter::all(),
@ -207,7 +207,7 @@ pub(crate) struct NewGearSubmission {
impl NewGearSubmission { impl NewGearSubmission {
fn into_new_gear(self) -> Result<NewGear, AppError> { fn into_new_gear(self) -> Result<NewGear, AppError> {
let category = GearCategory::from_str(&self.category) 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() { if self.make.trim().is_empty() {
return Err(AppError::validation("make cannot be 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(); let s = s.trim();
Some(s.parse::<RoasterId>().map_err(|_| { Some(s.parse::<RoasterId>().map_err(|_| {
tracing::warn!("Invalid roaster_id: '{}'", s); 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, _ => None,

View file

@ -192,8 +192,7 @@ pub fn is_datastar_request(headers: &HeaderMap) -> bool {
headers headers
.get("datastar-request") .get("datastar-request")
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.map(|value| value.eq_ignore_ascii_case("true")) .is_some_and(|value| value.eq_ignore_ascii_case("true"))
.unwrap_or(false)
} }
pub fn set_datastar_patch_headers(headers: &mut HeaderMap, selector: &'static str) { pub fn set_datastar_patch_headers(headers: &mut HeaderMap, selector: &'static str) {

View file

@ -165,6 +165,7 @@ async fn bootstrap_admin_user(
Ok(()) Ok(())
} }
#[allow(clippy::expect_used)] // Startup: panicking is appropriate if signal handlers fail
async fn shutdown_signal() { async fn shutdown_signal() {
let ctrl_c = async { let ctrl_c = async {
signal::ctrl_c() signal::ctrl_c()
@ -180,7 +181,7 @@ async fn shutdown_signal() {
}; };
tokio::select! { tokio::select! {
_ = ctrl_c => {}, () = ctrl_c => {},
_ = terminate => {}, () = 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); 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)); let adjusted_page = self.page.min(last_page.max(1));
Self::new( Self::new(
adjusted_page, adjusted_page,
@ -199,7 +199,7 @@ impl<T> Page<T> {
if self.total == 0 || self.showing_all { if self.total == 0 || self.showing_all {
1 1
} else { } else {
let size = self.page_size as u64; let size = u64::from(self.page_size);
(self.total.div_ceil(size)) as u32 (self.total.div_ceil(size)) as u32
} }
} }
@ -216,7 +216,7 @@ impl<T> Page<T> {
if self.total == 0 { if self.total == 0 {
0 0
} else { } 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 let password_hash = argon2
.hash_password(password.as_bytes(), &salt) .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(); .to_string();
Ok(password_hash) Ok(password_hash)
@ -23,7 +23,7 @@ pub fn hash_password(password: &str) -> Result<String> {
/// Verifies a password against a hash /// Verifies a password against a hash
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> { pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(password_hash) 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(); let argon2 = Argon2::default();
@ -57,6 +57,7 @@ pub fn generate_session_token() -> String {
} }
#[cfg(test)] #[cfg(test)]
#[allow(clippy::unwrap_used)] // Tests: unwrap is acceptable for test assertions
mod tests { mod tests {
use super::*; use super::*;

View file

@ -10,7 +10,7 @@ use crate::domain::listing::{ListRequest, Page, SortDirection};
use crate::domain::repositories::BagRepository; use crate::domain::repositories::BagRepository;
use crate::infrastructure::database::DatabasePool; use crate::infrastructure::database::DatabasePool;
const BASE_SELECT: &str = r#" const BASE_SELECT: &str = r"
SELECT SELECT
b.id, b.roast_id, b.roast_date, b.amount, b.remaining, b.closed, b.finished_at, b.created_at, b.updated_at, 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, r.name as roast_name, r.slug as roast_slug,
@ -18,7 +18,7 @@ const BASE_SELECT: &str = r#"
FROM bags b FROM bags b
JOIN roasts r ON b.roast_id = r.id JOIN roasts r ON b.roast_id = r.id
JOIN roasters rr ON r.roaster_id = rr.id JOIN roasters rr ON r.roaster_id = rr.id
"#; ";
#[derive(Clone)] #[derive(Clone)]
pub struct SqlBagRepository { pub struct SqlBagRepository {
@ -108,11 +108,11 @@ impl SqlBagRepository {
#[async_trait] #[async_trait]
impl BagRepository for SqlBagRepository { impl BagRepository for SqlBagRepository {
async fn insert(&self, bag: NewBag) -> Result<Bag, RepositoryError> { async fn insert(&self, bag: NewBag) -> Result<Bag, RepositoryError> {
let query = r#" let query = r"
INSERT INTO bags (roast_id, roast_date, amount, remaining) INSERT INTO bags (roast_id, roast_date, amount, remaining)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
RETURNING id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at RETURNING id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at
"#; ";
let record = query_as::<_, BagRecord>(query) let record = query_as::<_, BagRecord>(query)
.bind(bag.roast_id.into_inner()) .bind(bag.roast_id.into_inner())
@ -127,11 +127,11 @@ impl BagRepository for SqlBagRepository {
} }
async fn get(&self, id: BagId) -> Result<Bag, RepositoryError> { 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 SELECT id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at
FROM bags FROM bags
WHERE id = ? WHERE id = ?
"#; ";
let record = query_as::<_, BagRecord>(query) let record = query_as::<_, BagRecord>(query)
.bind(id.into_inner()) .bind(id.into_inner())
@ -144,7 +144,7 @@ impl BagRepository for SqlBagRepository {
} }
async fn get_with_roast(&self, id: BagId) -> Result<BagWithRoast, RepositoryError> { 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) let record = query_as::<_, BagWithRoastRecord>(&query)
.bind(id.into_inner()) .bind(id.into_inner())
@ -167,12 +167,12 @@ impl BagRepository for SqlBagRepository {
let where_clause = Self::build_where_clause(&filter); let where_clause = Self::build_where_clause(&filter);
let base_query = match &where_clause { 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(), None => BASE_SELECT.to_string(),
}; };
let count_query = match &where_clause { 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(), None => "SELECT COUNT(*) FROM bags".to_string(),
}; };

View file

@ -37,7 +37,7 @@ impl SqlGearRepository {
} }
fn to_domain(record: GearRecord) -> Result<Gear, RepositoryError> { 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)) RepositoryError::unexpected(format!("invalid category: {}", record.category))
})?; })?;
@ -62,11 +62,11 @@ impl SqlGearRepository {
#[async_trait] #[async_trait]
impl GearRepository for SqlGearRepository { impl GearRepository for SqlGearRepository {
async fn insert(&self, gear: NewGear) -> Result<Gear, RepositoryError> { async fn insert(&self, gear: NewGear) -> Result<Gear, RepositoryError> {
let query = r#" let query = r"
INSERT INTO gear (category, make, model) INSERT INTO gear (category, make, model)
VALUES (?, ?, ?) VALUES (?, ?, ?)
RETURNING id, category, make, model, created_at, updated_at RETURNING id, category, make, model, created_at, updated_at
"#; ";
let record = query_as::<_, GearRecord>(query) let record = query_as::<_, GearRecord>(query)
.bind(gear.category.as_str()) .bind(gear.category.as_str())
@ -80,11 +80,11 @@ impl GearRepository for SqlGearRepository {
} }
async fn get(&self, id: GearId) -> Result<Gear, RepositoryError> { async fn get(&self, id: GearId) -> Result<Gear, RepositoryError> {
let query = r#" let query = r"
SELECT id, category, make, model, created_at, updated_at SELECT id, category, make, model, created_at, updated_at
FROM gear FROM gear
WHERE id = ? WHERE id = ?
"#; ";
let record = query_as::<_, GearRecord>(query) let record = query_as::<_, GearRecord>(query)
.bind(id.into_inner()) .bind(id.into_inner())
@ -106,8 +106,7 @@ impl GearRepository for SqlGearRepository {
let base_query = match &where_clause { let base_query = match &where_clause {
Some(w) => format!( Some(w) => format!(
"SELECT id, category, make, model, created_at, updated_at FROM gear WHERE {}", "SELECT id, category, make, model, created_at, updated_at FROM gear WHERE {w}"
w
), ),
None => { None => {
"SELECT id, category, make, model, created_at, updated_at FROM gear".to_string() "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 { 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(), 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 /// Handles the common pattern of conditionally adding SET clauses with proper
/// comma separation. /// comma separation.

View file

@ -20,7 +20,7 @@ where
{ {
match request.page_size() { match request.page_size() {
PageSize::All => { 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) let records = query_as::<_, R>(&query)
.fetch_all(pool) .fetch_all(pool)
.await .await
@ -35,11 +35,11 @@ where
Ok(Page::new(items, 1, page_size.max(1), total, true)) Ok(Page::new(items, 1, page_size.max(1), total, true))
} }
PageSize::Limited(page_size) => { PageSize::Limited(page_size) => {
let limit = page_size as i64; let limit = i64::from(page_size);
let mut page = request.page(); 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) let mut records = query_as::<_, R>(&query_sql)
.bind(limit) .bind(limit)
@ -56,7 +56,7 @@ where
if page > 1 && records.is_empty() && total > 0 { if page > 1 && records.is_empty() && total > 0 {
let last_page = ((total + limit - 1) / limit) as u32; let last_page = ((total + limit - 1) / limit) as u32;
page = last_page.max(1); 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) records = query_as::<_, R>(&query_sql)
.bind(limit) .bind(limit)
.bind(offset) .bind(offset)

View file

@ -52,6 +52,7 @@ impl SqlRoastRepository {
} }
} }
#[allow(clippy::too_many_lines)] // Repository impl has many methods
#[async_trait] #[async_trait]
impl RoastRepository for SqlRoastRepository { impl RoastRepository for SqlRoastRepository {
async fn insert(&self, new_roast: NewRoast) -> Result<Roast, RepositoryError> { async fn insert(&self, new_roast: NewRoast) -> Result<Roast, RepositoryError> {
@ -198,7 +199,7 @@ impl RoastRepository for SqlRoastRepository {
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.map(|record| record.into_roast()) .map(RoastRecord::into_roast)
.transpose()? .transpose()?
.ok_or(RepositoryError::NotFound) .ok_or(RepositoryError::NotFound)
} }
@ -214,7 +215,7 @@ impl RoastRepository for SqlRoastRepository {
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.map(|record| record.into_with_roaster()) .map(RoastWithRoasterRecord::into_with_roaster)
.transpose()? .transpose()?
.ok_or(RepositoryError::NotFound) .ok_or(RepositoryError::NotFound)
} }
@ -232,7 +233,7 @@ impl RoastRepository for SqlRoastRepository {
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.map(|record| record.into_roast()) .map(RoastRecord::into_roast)
.transpose()? .transpose()?
.ok_or(RepositoryError::NotFound) .ok_or(RepositoryError::NotFound)
} }
@ -270,7 +271,7 @@ impl RoastRepository for SqlRoastRepository {
records records
.into_iter() .into_iter()
.map(|record| record.into_with_roaster()) .map(RoastWithRoasterRecord::into_with_roaster)
.collect() .collect()
} }

View file

@ -24,11 +24,11 @@ impl SqlTimelineEventRepository {
#[async_trait] #[async_trait]
impl TimelineEventRepository for SqlTimelineEventRepository { impl TimelineEventRepository for SqlTimelineEventRepository {
async fn insert(&self, event: NewTimelineEvent) -> Result<TimelineEvent, RepositoryError> { 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) INSERT INTO timeline_events (entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json 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| { let details_json = serde_json::to_string(&event.details).map_err(|err| {
RepositoryError::unexpected(format!("failed to encode timeline event details: {err}")) RepositoryError::unexpected(format!("failed to encode timeline event details: {err}"))

View file

@ -18,7 +18,7 @@ impl SqlTokenRepository {
Self { pool } Self { pool }
} }
fn to_domain(record: TokenRecord) -> Result<Token, RepositoryError> { fn to_domain(record: TokenRecord) -> Token {
let TokenRecord { let TokenRecord {
id, id,
user_id, user_id,
@ -29,7 +29,7 @@ impl SqlTokenRepository {
revoked_at, revoked_at,
} = record; } = record;
Ok(Token::new( Token::new(
TokenId::from(id), TokenId::from(id),
UserId::from(user_id), UserId::from(user_id),
token_hash, token_hash,
@ -37,7 +37,7 @@ impl SqlTokenRepository {
created_at, created_at,
last_used_at, last_used_at,
revoked_at, revoked_at,
)) )
} }
} }
@ -67,7 +67,7 @@ impl TokenRepository for SqlTokenRepository {
RepositoryError::unexpected(err.to_string()) RepositoryError::unexpected(err.to_string())
})?; })?;
Self::to_domain(record) Ok(Self::to_domain(record))
} }
async fn get(&self, id: TokenId) -> Result<Token, RepositoryError> { 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()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .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> { 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()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .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> { async fn list_by_user(&self, user_id: UserId) -> Result<Vec<Token>, RepositoryError> {
@ -105,7 +105,7 @@ impl TokenRepository for SqlTokenRepository {
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .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> { 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()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
match record { match record {
Some(record) => Self::to_domain(record), Some(record) => Ok(Self::to_domain(record)),
None => Err(RepositoryError::NotFound), None => Err(RepositoryError::NotFound),
} }
} }

View file

@ -18,7 +18,7 @@ impl SqlUserRepository {
Self { pool } Self { pool }
} }
fn to_domain(record: UserRecord) -> Result<User, RepositoryError> { fn to_domain(record: UserRecord) -> User {
let UserRecord { let UserRecord {
id, id,
username, username,
@ -26,12 +26,7 @@ impl SqlUserRepository {
created_at, created_at,
} = record; } = record;
Ok(User::new( User::new(UserId::from(id), username, password_hash, created_at)
UserId::from(id),
username,
password_hash,
created_at,
))
} }
} }
@ -54,7 +49,7 @@ impl UserRepository for SqlUserRepository {
RepositoryError::unexpected(err.to_string()) RepositoryError::unexpected(err.to_string())
})?; })?;
Self::to_domain(record) Ok(Self::to_domain(record))
} }
async fn get(&self, id: UserId) -> Result<User, RepositoryError> { 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()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Self::to_domain(record) Ok(Self::to_domain(record))
} }
async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError> { 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()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Self::to_domain(record) Ok(Self::to_domain(record))
} }
async fn exists(&self) -> Result<bool, RepositoryError> { async fn exists(&self) -> Result<bool, RepositoryError> {

View file

@ -76,6 +76,7 @@ where
/// Register a subscriber as global default to process span data. /// Register a subscriber as global default to process span data.
/// ///
/// This should only be called once! /// 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) { pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
LogTracer::init().expect("Failed to set logger"); LogTracer::init().expect("Failed to set logger");
set_global_default(subscriber).expect("Failed to set subscriber"); 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 { if self.total == 0 || self.showing_all {
1 1
} else { } 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 self.total.div_ceil(page_size) as u32
} }
} }
@ -77,7 +77,7 @@ impl<T> Paginated<T> {
if self.total == 0 { if self.total == 0 {
0 0
} else { } 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 { pub fn is_sorted_by(&self, key: &str) -> bool {
K::from_query(key) K::from_query(key).is_some_and(|candidate| candidate == self.request.sort_key())
.map(|candidate| candidate == self.request.sort_key())
.unwrap_or(false)
} }
pub fn next_sort_dir(&self, key: &str) -> &'static str { 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)) 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 { fn build_href(&self, path: &str, request: ListRequest<K>) -> String {
if let Some((base, fragment)) = path.split_once('#') { if let Some((base, fragment)) = path.split_once('#') {
format!("{}?{}#{}", base, Self::query_string(request), fragment) format!("{}?{}#{}", base, Self::query_string(request), fragment)
@ -460,10 +459,8 @@ impl TimelineEventView {
let link = match (entity_type.as_str(), slug, roaster_slug) { let link = match (entity_type.as_str(), slug, roaster_slug) {
("roaster", Some(slug), _) => format!("/roasters/{slug}"), ("roaster", Some(slug), _) => format!("/roasters/{slug}"),
("roast", Some(slug), Some(roaster_slug)) => { // Both roasts and bags link to the roast page
format!("/roasters/{roaster_slug}/roasts/{slug}") ("roast" | "bag", Some(slug), Some(roaster_slug)) => {
}
("bag", Some(slug), Some(roaster_slug)) => {
format!("/roasters/{roaster_slug}/roasts/{slug}") format!("/roasters/{roaster_slug}/roasts/{slug}")
} }
("gear", _, _) => "/gear".to_string(), ("gear", _, _) => "/gear".to_string(),
@ -546,8 +543,7 @@ impl BagView {
finished_at: bag finished_at: bag
.bag .bag
.finished_at .finished_at
.map(|d| d.to_string()) .map_or_else(|| "".to_string(), |d| d.to_string()),
.unwrap_or_else(|| "".to_string()),
created_at: bag.bag.created_at.format("%Y-%m-%d").to_string(), created_at: bag.bag.created_at.format("%Y-%m-%d").to_string(),
roast_name: bag.roast_name, roast_name: bag.roast_name,
roaster_name: bag.roaster_name, roaster_name: bag.roaster_name,