//! Discover/search page with filterable, paginated items and projects. use crate::extractors::ValidatedExtraQuery; use axum::Json; use axum::extract::State; use axum::http::HeaderMap; use axum::response::IntoResponse; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use tower_sessions::Session; use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use crate::{ auth::MaybeUserUnverified, constants, db::{self, AiTierFilter, DiscoverSort, ItemType, discover::DiscoverFilters}, error::Result, helpers::get_csrf_token, templates::{DiscoverResultsTemplate, DiscoverTemplate, TagTreeTemplate}, types::{ DiscoverItem, DiscoverProject, FilterCategory, PriceBucket, SidebarView, TagBreadcrumb, TagChip, TagCrumb, TagDrillRow, TagTreeNode, }, }; /// Build the sidebar view model. /// /// Shared by the full page and the `/discover/results` partial. The partial /// needs it because the sidebar is swapped out-of-band on every filter change: /// its counts describe the current filter, so leaving them behind would show /// numbers that no longer match the results beside them. async fn build_sidebar( db: &PgPool, query: &DiscoverQuery, data: &DiscoverData, viewer_id: Option, ) -> Result { let f = query.filter_selection(); let search_filter = f.search; let tag_filter = f.tags; let item_type_filter = f.item_types; let has_source_code = f.has_source_code; // Build type and tag filters (items mode only) let category_filter = f.category; // Build category filters (projects mode only) let category_filters = if data.mode == "projects" { let cat_counts = db::categories::get_category_counts(db, search_filter).await?; let mut filters: Vec = vec![FilterCategory { name: "All".to_string(), value: String::new(), count: data.total_count, active: category_filter.is_none(), id: String::new(), following: false, }]; for cc in cat_counts { filters.push(FilterCategory { name: cc.name, value: cc.slug.to_string(), count: cc.count as u32, active: category_filter == Some(cc.slug.as_str()), id: String::new(), following: false, }); } filters } else { vec![] }; // The applied bounds, rendered back into the number inputs. Without this the // inputs come back blank, hx-include resends them empty, and the price filter // silently disappears on the next interaction. let (shown_min, shown_max) = sanitize_price_range(query.min_price, query.max_price); let current_min_price = shown_min.map(|v| v.to_string()).unwrap_or_default(); let current_max_price = shown_max.map(|v| v.to_string()).unwrap_or_default(); // Built before the template literal partially moves `query`. let browse_url_prefix = query.browse_base_url(); let browse_url_root = query.browse_root_url(); let mut tag_chips: Vec = Vec::new(); let mut tag_drill: Vec = Vec::new(); let mut tag_crumbs: Vec = Vec::new(); let (type_filters, tag_filters, ai_tier_filters, price_counts) = if data.mode == "items" { // The four facet counts are viewer-independent -> served from a short-TTL // memo (ultra-fuzz Run 12 Performance). Only `followed_tag_ids` is // per-viewer, so it is computed fresh (and only when logged in). let facet_filters = DiscoverFilters { search: search_filter, item_types: &item_type_filter, tags: &tag_filter, min_price: query.min_price.map(PriceDollars::cents), max_price: query.max_price.map(PriceDollars::cents), sort_by: None, ai_tier: f.ai_tier, }; let (type_counts, tag_counts, ai_counts, price_counts) = cached_facets(db, &facet_filters).await?; // The drill-down cursor. Not cached alongside the facets: it varies by // an axis the facet key does not carry, and it is one cheap indexed // query over a ~120-row table. let browse_cursor = query.browse.as_deref().filter(|s| !s.is_empty()); let drill_rows = db::tags::tag_children_with_counts(db, browse_cursor, &facet_filters).await?; // Follow state for every rung on screen. Still scoped to the rendered // rows rather than the viewer's whole followed set (fuzz 2026-07-06 // C5-1), and skipped entirely for anonymous viewers, but no longer // narrowed to assignable rows, since following is hierarchical and a // branch is now a legitimate follow target. let followed: std::collections::HashSet = match viewer_id { Some(uid) => { let ids: Vec<_> = drill_rows.iter().map(|r| r.tag_id).collect(); db::follows::following_subset(db, uid, &ids).await? } None => std::collections::HashSet::new(), }; tag_drill = drill_rows .into_iter() .map(|r| TagDrillRow { selected: tag_filter.iter().any(|t| t == &r.tag_slug), following: followed.contains(&r.tag_id), tag_id: r.tag_id.to_string(), slug: r.tag_slug, label: r.tag_name, count: r.count as u32, assignable: r.assignable, has_children: r.has_children, }) .collect(); // Breadcrumbs are derived from the cursor's own dot-path rather than // queried: tagtree::ancestors is exactly this, and every ancestor of a // valid slug is itself a valid slug. tag_crumbs = browse_cursor .map(|cursor| { tagtree::ancestors(cursor) .into_iter() .chain(std::iter::once(cursor)) .map(|slug| TagCrumb { slug: slug.to_string(), label: tagtree::leaf(slug).replace('-', " "), }) .collect() }) .unwrap_or_default(); // Chips carry a precomputed dismiss query so the template stays free of // list manipulation, and so removal is a plain link that works without JS. let chip_names: std::collections::HashMap = db::tags::tag_names_for_slugs(db, &tag_filter) .await? .into_iter() .collect(); tag_chips = tag_filter .iter() .map(|slug| { let remove_query = tag_filter .iter() .filter(|other| *other != slug) .map(|other| format!("tag={}", urlencoding::encode(other))) .collect::>() .join("&"); TagChip { label: chip_names .get(slug) .cloned() .unwrap_or_else(|| tagtree::leaf(slug).replace('-', " ")), context: tagtree::parent(slug).unwrap_or("").to_string(), slug: slug.clone(), remove_query, } }) .collect(); let mut type_filters: Vec = vec![FilterCategory { name: "All".to_string(), value: String::new(), count: data.total_count, active: item_type_filter.is_empty(), id: String::new(), following: false, }]; for tc in type_counts { let active = item_type_filter .iter() .any(|t| t.to_string() == tc.category); type_filters.push(FilterCategory { value: tc.category.clone(), name: tc.category, count: tc.count as u32, active, id: String::new(), following: false, }); } let mut tag_filters: Vec = vec![FilterCategory { name: "All".to_string(), value: String::new(), count: data.total_count, active: tag_filter.is_empty(), id: String::new(), following: false, }]; for tc in tag_counts.iter().take(10) { tag_filters.push(FilterCategory { name: tc.tag_name.clone(), value: tc.tag_slug.clone(), count: tc.count as u32, active: tag_filter.iter().any(|t| t == &tc.tag_slug), id: tc.tag_id.to_string(), following: false, }); } // Per `about/generative-ai.md` § "How Fans Use This", the three // filter options are "Everything" / "Human-led" (Handmade ∪ // Assisted) / "Handmade only". Aggregate the per-tier counts // into option-sized counts before passing to the template. let mut handmade_count: u32 = 0; let mut assisted_count: u32 = 0; for ac in &ai_counts { match ac.category.as_str() { "handmade" => handmade_count = ac.count as u32, "assisted" => assisted_count = ac.count as u32, _ => {} } } let ai_tier_filter_str = query.ai_tier.as_deref().filter(|s| !s.is_empty()); let ai_tier_filters: Vec = vec![ FilterCategory { name: "Everything".to_string(), value: String::new(), count: data.total_count, active: ai_tier_filter_str.is_none(), id: String::new(), following: false, }, FilterCategory { name: db::AiTierFilter::HumanLed.label().to_string(), value: db::AiTierFilter::HumanLed.to_string(), count: handmade_count + assisted_count, active: ai_tier_filter_str == Some(db::AiTierFilter::HumanLed.to_string().as_str()), id: String::new(), following: false, }, FilterCategory { name: db::AiTierFilter::HandmadeOnly.label().to_string(), value: db::AiTierFilter::HandmadeOnly.to_string(), count: handmade_count, active: ai_tier_filter_str == Some(db::AiTierFilter::HandmadeOnly.to_string().as_str()), id: String::new(), following: false, }, ]; (type_filters, tag_filters, ai_tier_filters, price_counts) } else { (vec![], vec![], vec![], Vec::::new()) }; // Both the counts and the clickable ranges come from db::discover::PRICE_BUCKETS, // so a bucket can never display a number its own link fails to reproduce. let price_base_params = query.params_without_price(); let (applied_min, applied_max) = sanitize_price_range(query.min_price, query.max_price); let price_buckets: Vec = db::discover::PRICE_BUCKETS .iter() .zip(price_counts.iter()) .map(|((label, min, max), count)| { let mut parts = price_base_params.clone(); parts.push(format!("min_price={}", PriceDollars::from_cents(*min))); if let Some(v) = *max { parts.push(format!("max_price={}", PriceDollars::from_cents(v))); } PriceBucket { label: label.to_string(), count: *count as u32, url: format!("/discover?{}", parts.join("&")), active: applied_min.map(PriceDollars::cents) == Some(*min) && applied_max.map(PriceDollars::cents) == *max, } }) .collect(); // The mobile filter badge counts *groups* with an active selection, not // individual values: picking three tags is still one filter in use. let current_types: Vec = dedup_nonempty(&query.item_type) .into_iter() .map(str::to_string) .collect(); let current_tags: Vec = dedup_nonempty(&query.tag) .into_iter() .map(str::to_string) .collect(); let current_category = query.category.clone().unwrap_or_default(); let current_ai_tier = query.ai_tier.clone().unwrap_or_default(); // A selection is only carried by a hidden input when no visible control // represents it. The drill-down shows one rung at a time, so a tag chosen // from elsewhere in the tree has no checkbox on screen; likewise a type // whose count dropped out of the facet list. Rendering a hidden input for a // value that also has a checked checkbox would submit it twice. let visible_tag_slugs: std::collections::HashSet<&str> = tag_drill.iter().map(|r| r.slug.as_str()).collect(); let hidden_tags: Vec = current_tags .iter() .filter(|t| !visible_tag_slugs.contains(t.as_str())) .cloned() .collect(); let visible_type_values: std::collections::HashSet<&str> = type_filters.iter().map(|t| t.value.as_str()).collect(); let hidden_types: Vec = current_types .iter() .filter(|t| !visible_type_values.contains(t.as_str())) .cloned() .collect(); let active_filter_count = [ !current_types.is_empty(), !current_tags.is_empty(), !current_category.is_empty(), !current_ai_tier.is_empty(), has_source_code, query.min_price.is_some(), query.max_price.is_some(), ] .iter() .filter(|&&v| v) .count() as u32; Ok(SidebarView { type_filters, tag_filters, category_filters, price_buckets, ai_tier_filters, tag_chips, tag_drill, tag_crumbs, hidden_tags, hidden_types, current_types, current_tags, current_min_price, current_max_price, current_category, current_ai_tier, browse_url_prefix, browse_url_root, has_source: has_source_code, active_filter_count, viewer_authenticated: viewer_id.is_some(), browse_cursor: query.browse.clone().unwrap_or_default(), }) } /// The four viewer-independent discover facet results (item-type, tag, ai-tier, and /// price-range counts). `followed_tag_ids` is deliberately excluded, it is /// per-viewer and always computed fresh. type FacetBundle = ( Vec, Vec, Vec, // Bucket counts, positional and in `db::discover::PRICE_BUCKETS` order. Vec, ); /// Cache key: the filter inputs every facet query keys off. Same filters -> /// identical viewer-independent counts, so they can be shared across viewers. /// /// `ai_tier` is part of the key. It was omitted while no facet query applied the /// AI-tier filter, which made it harmless; the moment the facets started /// cross-applying it, leaving it out would have served one tier's counts to /// another for up to `FACET_CACHE_TTL`. /// The leading `String` is the database name. The memo is a process-global, so /// without it two pools pointed at different databases would share counts. In /// production there is one database and this is a constant; under test every /// case gets its own template clone, and omitting it silently served one test's /// facet counts to another. type FacetKey = ( String, Option, Vec, Vec, Option, Option, Option, ); /// Short TTL for the facet memo. The discover facets are the hottest, most /// cacheable aggregate on the busiest public page; recomputing five full-catalog /// scans on every anonymous hit is the crate's largest DB amplifier (ultra-fuzz /// Run 12 Performance). Expiry-only invalidation (no bust on catalog change), a /// count that is up to a minute stale on the discover sidebar is harmless. const FACET_CACHE_TTL: Duration = Duration::from_mins(1); /// Bound the memo so a wide spread of filter combinations can't grow it without /// limit; on overflow, drop expired entries first, then clear if still full. const FACET_CACHE_MAX: usize = 512; static FACET_CACHE: OnceLock>> = OnceLock::new(); /// The four viewer-independent discover facets, memoized for [`FACET_CACHE_TTL`]. /// On a miss the four queries still run (concurrently); the memo makes the common /// case, repeated anonymous loads of the same filter view, a single map lookup /// instead of five full-catalog aggregate scans holding five pool connections. async fn cached_facets( db: &PgPool, filters: &db::discover::DiscoverFilters<'_>, ) -> Result { let key: FacetKey = ( db.connect_options() .get_database() .unwrap_or_default() .to_string(), filters.search.map(str::to_string), filters.item_types.to_vec(), filters.tags.to_vec(), filters.min_price, filters.max_price, filters.ai_tier, ); let cache = FACET_CACHE.get_or_init(|| Mutex::new(HashMap::new())); if let Ok(guard) = cache.lock() && let Some((at, bundle)) = guard.get(&key) && at.elapsed() < FACET_CACHE_TTL { return Ok(bundle.clone()); } let bundle: FacetBundle = tokio::try_join!( db::discover::get_item_type_counts(db, filters), db::tags::get_tag_counts(db, filters), db::discover::get_ai_tier_counts(db, filters), db::discover::get_price_range_counts(db, filters), )?; if let Ok(mut guard) = cache.lock() { if guard.len() >= FACET_CACHE_MAX { guard.retain(|_, (at, _)| at.elapsed() < FACET_CACHE_TTL); if guard.len() >= FACET_CACHE_MAX { guard.clear(); } } guard.insert(key, (Instant::now(), bundle.clone())); } Ok(bundle) } /// Timestamped index, so the TTL check and the shared handle travel together. type CachedTagIndex = Option<(Instant, Arc)>; /// Memoized [`tagtree::TagIndex`] over every tag slug, backing the sidebar's /// tag typeahead. /// /// The index is memory-only and has to be rebuilt from Postgres; `rebuild` /// exists for exactly this. It is refreshed wholesale on a TTL rather than /// mutated, which also sidesteps `TagIndex::remove` leaving orphaned segments /// behind (tagtree lib.rs:817) and degrading the segment-prefix gate over time. /// /// The taxonomy is ~120 tags and changes only by migration, so five minutes of /// staleness in an autocomplete is not worth invalidation machinery. static TAG_INDEX: OnceLock> = OnceLock::new(); const TAG_INDEX_TTL: Duration = Duration::from_mins(5); async fn cached_tag_index(db: &PgPool) -> Result> { let cell = TAG_INDEX.get_or_init(|| Mutex::new(None)); if let Ok(guard) = cell.lock() && let Some((at, index)) = guard.as_ref() && at.elapsed() < TAG_INDEX_TTL { return Ok(Arc::clone(index)); } let slugs = db::tags::all_tag_slugs(db).await?; let index = Arc::new(tagtree::TagIndex::new(slugs)); if let Ok(mut guard) = cell.lock() { *guard = Some((Instant::now(), Arc::clone(&index))); } Ok(index) } /// How many typeahead results to return. Enough to fill the dropdown without /// turning `suggest_fuzzy`'s full-corpus scan into a page-weight problem. const TAG_SUGGEST_LIMIT: usize = 8; /// The typed value and the filters the box sent along with it. /// /// Read off the raw query rather than through serde, because the two halves /// want different treatment: the typed value is one string under the described /// field's own name, and the rest is an opaque bag that travels straight back /// out on every candidate's pick. Deserializing the bag into [`DiscoverQuery`] /// and re-serializing it would be a round trip through eleven typed members for /// values this route never reads. /// /// Only the names the description says ride are kept /// ([`discover_typeahead::FILTERS`](crate::quasi::discover_typeahead::FILTERS)), /// so nothing else a caller appends reaches the markup. Blanks are dropped for /// [`DiscoverQuery::filter_params`]' reason: every filter request carries the /// whole control set, so echoing them would put `tag=&category=` in each pick. fn typeahead_query(raw: Option<&str>) -> (String, quasi_router::Params) { use crate::quasi::discover_typeahead; let mut typed = String::new(); let mut view = quasi_router::Params::new(); for (name, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) { if name == discover_typeahead::FIELD { typed = value.into_owned(); } else if discover_typeahead::FILTERS.contains(&name.as_ref()) && !value.trim().is_empty() { view.insert(name, value); } } (typed, view) } /// Tag typeahead for the discover sidebar. /// /// Prefix matching first (path-prefix, then segment-prefix), falling back to /// fuzzy only when prefix matching underfills. That ordering is tagtree's, and /// it is why typing "elec" finds `audio.genre.electronic` without the caller /// knowing which level it lives at. /// /// Answers the described suggestion list rather than JSON (N8, `1503db12`). /// What changed is who draws the dropdown: the markup is /// [`Outcome::Suggestions`](quasi_router::Outcome::Suggestions) rendered by /// quasi-webview, so the ~110 lines of `page-discover.js` that built the rows, /// tracked the highlight and added the facet by hand are gone. The route's own /// job — which tags, in which order — is unchanged. pub(super) async fn tag_suggestions_handler( State(db): State, axum::extract::RawQuery(raw): axum::extract::RawQuery, ) -> Result { let (raw_input, view) = typeahead_query(raw.as_deref()); let input = raw_input.trim(); if input.is_empty() { // An empty list rather than an empty document: the answer replaces the // list the field owns, so nothing to offer has to mean nothing there. return Ok(axum::response::Html(String::new())); } let index = cached_tag_index(&db).await?; let (hits, _exact) = index.suggest_with_status(input, TAG_SUGGEST_LIMIT); let mut slugs: Vec = hits.into_iter().map(str::to_string).collect(); if slugs.is_empty() { slugs = index .suggest_fuzzy(input, TAG_SUGGEST_LIMIT) .into_iter() .map(str::to_string) .collect(); } // Only depth-3+ tags can be assigned to an item, so only they can filter // anything; offering a category here would produce an empty result set. slugs.retain(|s| tagtree::depth(s) >= 3); let names: std::collections::HashMap = db::tags::tag_names_for_slugs(&db, &slugs) .await? .into_iter() .collect(); let hits: Vec = slugs .into_iter() .map(|slug| { let label = names .get(&slug) .cloned() .unwrap_or_else(|| tagtree::leaf(&slug).replace('-', " ")); // The parent path orients an otherwise ambiguous leaf: "Format" // appears under audio, software, writing, and video. let context = tagtree::parent(&slug).unwrap_or("").to_string(); crate::quasi::discover_typeahead::Hit { slug, label, context, } }) .collect(); Ok(axum::response::Html( crate::quasi::discover_typeahead::tag_suggestions(&hits, &view), )) } /// Deserialize an empty string as `None` instead of failing to parse. /// /// HTML form inputs send `field=` (empty string) when blank, which fails /// serde's default `Option` parsing. This treats `""` as `None`. fn empty_string_as_none<'de, D, T>(deserializer: D) -> std::result::Result, D::Error> where D: serde::Deserializer<'de>, T: std::str::FromStr, T::Err: std::fmt::Display, { let opt = Option::::deserialize(deserializer)?; match opt { None => Ok(None), Some(s) if s.is_empty() => Ok(None), Some(s) => s.parse::().map(Some).map_err(serde::de::Error::custom), } } /// A price bound from the query string: dollars on the wire, cents in the field. /// /// The wire format is dollars (`?max_price=24.99`) because a visitor types this /// filter by hand and reads it back out of the address bar; the catalog stores /// cents. Doing the conversion here, once, at the edge is what stops the two /// units meeting: typing 20 used to filter for items under twenty cents, with /// nothing on screen saying so (loose-wire g2-16). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct PriceDollars(i32); impl PriceDollars { /// The bound in cents, which is the only unit the queries speak. fn cents(self) -> i32 { self.0 } fn from_cents(cents: i32) -> Self { Self(cents) } } impl std::str::FromStr for PriceDollars { type Err = String; /// Delegates to the canonical dollars parser, so "$1,250" off a clipboard /// works here exactly as it does in the pricing forms, and a rejection /// carries that parser's wording. `ValidatedQuery` turns the rejection into /// the branded error page rather than a bare axum 400. fn from_str(s: &str) -> std::result::Result { crate::pricing::parse_dollars_to_cents("Price", Some(s)) .map(Self) .map_err(|e| e.user_message()) } } impl std::fmt::Display for PriceDollars { /// Whole dollars stay whole, so the common bucket links read `min_price=25` /// rather than `min_price=25.00`. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.0 % 100 == 0 { write!(f, "{}", self.0 / 100) } else { write!(f, "{}", crate::formatting::format_dollars_plain(self.0)) } } } /// Query parameters for the discover/search page. #[derive(Debug, Deserialize)] pub(super) struct DiscoverQuery { pub q: Option, /// Repeated `item_type=` params, OR'd together. A single value (what the /// pre-multi-select UI sends) parses as a one-element vec, so old links and /// bookmarks keep working. #[serde(default)] pub item_type: Vec, /// Repeated `tag=` params, OR'd together; each matches its own subtree. #[serde(default)] pub tag: Vec, pub category: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub min_price: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub max_price: Option, pub sort: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub page: Option, /// `"items"` or `"projects"`. Unset means projects, except when a tag is /// selected, which means items: see the handler for why. The comment here /// used to say items was the default and it never was. pub mode: Option, pub ai_tier: Option, pub has_source: Option, /// Drill-down cursor: which tag's children the sidebar is showing. Distinct /// from `tag`, which is the selection. Browsing into a category does not /// filter, and selecting a leaf does not move the cursor, so the two can be /// operated independently. pub browse: Option, } impl DiscoverQuery { /// Rebuild the canonical full-page URL for this filter selection, for the /// HTMX history headers. /// /// Blank values are dropped rather than echoed: every filter request carries /// the whole `.discover-filter` set via `hx-include`, so a verbatim /// round-trip would put `/discover?q=&tag=&category=&min_price=` in the /// address bar. Prices are sanitized so the URL states what was actually /// applied, and `page=1` is left implicit. fn to_page_url(&self) -> String { let mut parts = self.filter_params(); if let Some(b) = self .browse .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) { parts.push(format!("browse={}", urlencoding::encode(b))); } if let Some(p) = self.page.filter(|&p| p > 1) { parts.push(format!("page={p}")); } if parts.is_empty() { "/discover".to_string() } else { format!("/discover?{}", parts.join("&")) } } /// Base URL for moving the drill-down cursor, ending ready for a `browse=` /// value to be appended. /// /// Navigating the tree preserves the current selection and drops `page`, /// since a cursor move lands you on a different list and page 4 of the old /// one is meaningless. fn browse_base_url(&self) -> String { let parts = self.filter_params(); if parts.is_empty() { "/discover?browse=".to_string() } else { format!("/discover?{}&browse=", parts.join("&")) } } /// The URL that clears the cursor back to the tag roots, selection intact. fn browse_root_url(&self) -> String { let parts = self.filter_params(); if parts.is_empty() { "/discover".to_string() } else { format!("/discover?{}", parts.join("&")) } } /// Filter params with the price range omitted, for building the price /// bucket links: a bucket replaces the range rather than adding to it. fn params_without_price(&self) -> Vec { let (min, max) = sanitize_price_range(self.min_price, self.max_price); self.filter_params() .into_iter() .filter(|p| { !(min.is_some_and(|v| *p == format!("min_price={v}")) || max.is_some_and(|v| *p == format!("max_price={v}"))) }) .collect() } /// Every filter param except the cursor and the page, in canonical order. fn filter_params(&self) -> Vec { fn push_str(parts: &mut Vec, key: &str, value: Option<&String>) { if let Some(v) = value.map(|s| s.trim()).filter(|s| !s.is_empty()) { parts.push(format!("{key}={}", urlencoding::encode(v))); } } // Multi-valued facets emit one param per selection, deduped and blank-free, // so the address bar states exactly the filter that was applied. fn push_each(parts: &mut Vec, key: &str, values: &[String]) { for v in dedup_nonempty(values) { parts.push(format!("{key}={}", urlencoding::encode(v))); } } let mut parts = Vec::new(); push_str(&mut parts, "mode", self.mode.as_ref()); push_str(&mut parts, "q", self.q.as_ref()); push_each(&mut parts, "item_type", &self.item_type); push_each(&mut parts, "tag", &self.tag); push_str(&mut parts, "category", self.category.as_ref()); push_str(&mut parts, "ai_tier", self.ai_tier.as_ref()); push_str(&mut parts, "has_source", self.has_source.as_ref()); push_str(&mut parts, "sort", self.sort.as_ref()); let (min_price, max_price) = sanitize_price_range(self.min_price, self.max_price); if let Some(v) = min_price { parts.push(format!("min_price={v}")); } if let Some(v) = max_price { parts.push(format!("max_price={v}")); } parts } } /// Shared result data for both the full discover page and the HTMX partial. struct DiscoverData { items: Vec, projects: Vec, mode: String, total_count: u32, current_page: u32, total_pages: u32, pagination_range: Vec, showing_start: u32, showing_end: u32, /// A search term was applied, so the copy addresses a search rather than a /// filter. /// /// Taken from the same `filter_selection()` the query used, not from the raw /// `?q=`: a whitespace-only term is browsing, and reading it separately here /// would have the copy disagree with the list under it. is_search: bool, /// The rendered count line, e.g. "247 results" or "1 item". count_label: String, } /// The count line above the results. /// /// Says "results" for a search and names the thing for a browse, because those /// are different claims. Browsing, the number really is how many items match the /// filters. Searching, membership is any typed word (migration 179), so most of /// a large number can be partial matches; "247 items" would assert 247 things /// matched, which is not what was counted. "247 results" describes the list, /// and the tier heading and per-row counts describe its quality. /// /// Built here rather than in the templates because two of them render this same /// `#total-count` span, the page and the out-of-band partial. Any difference /// between them shows up as the text changing on the first HTMX swap, which is /// the failure mode that already bit this element once (it used to sit outside /// `#results-container` and go stale). One string, no way to disagree. fn results_count_label(total: u32, mode: &str, is_search: bool) -> String { let noun = match (is_search, mode, total) { (true, _, 1) => "result", (true, _, _) => "results", (false, "projects", 1) => "project", (false, "projects", _) => "projects", (false, _, 1) => "item", (false, _, _) => "items", }; format!("{total} {noun}") } /// Clamp the discover price filters to a sane range. A negative bound is /// meaningless (prices are non-negative cents) and is dropped; an inverted range /// (min > max) can only match nothing, so both bounds are dropped rather than /// issuing an empty-by-construction query. fn sanitize_price_range( min: Option, max: Option, ) -> (Option, Option) { let min = min.filter(|v| v.cents() >= 0); let max = max.filter(|v| v.cents() >= 0); if let (Some(lo), Some(hi)) = (min, max) && lo.cents() > hi.cents() { return (None, None); } (min, max) } #[cfg(test)] mod count_label_tests { use super::results_count_label; /// Browsing, the number is genuinely how many items match the filters. #[test] fn browsing_names_the_thing_being_counted() { assert_eq!(results_count_label(247, "items", false), "247 items"); assert_eq!(results_count_label(12, "projects", false), "12 projects"); } /// Searching, membership is any typed word, so most of a large number can be /// partial matches. "247 items" would assert 247 things matched; "247 /// results" only describes the list, which is all that was counted. #[test] fn searching_describes_the_list_rather_than_claiming_matches() { assert_eq!(results_count_label(247, "items", true), "247 results"); assert_eq!(results_count_label(247, "projects", true), "247 results"); } #[test] fn one_of_something_is_singular() { assert_eq!(results_count_label(1, "items", false), "1 item"); assert_eq!(results_count_label(1, "projects", false), "1 project"); assert_eq!(results_count_label(1, "items", true), "1 result"); } /// Zero renders through the empty state, but the label is still built, and /// "0 result" would read as a typo next to it. #[test] fn zero_is_plural() { assert_eq!(results_count_label(0, "items", false), "0 items"); assert_eq!(results_count_label(0, "items", true), "0 results"); } } #[cfg(test)] mod price_range_tests { use super::{PriceDollars, sanitize_price_range}; fn c(cents: i32) -> Option { Some(PriceDollars::from_cents(cents)) } #[test] fn drops_negatives_and_inverted_ranges() { assert_eq!(sanitize_price_range(c(100), c(500)), (c(100), c(500))); assert_eq!(sanitize_price_range(c(-1), c(500)), (None, c(500))); assert_eq!(sanitize_price_range(c(100), c(-5)), (c(100), None)); // inverted range can only match nothing -> drop both assert_eq!(sanitize_price_range(c(500), c(100)), (None, None)); assert_eq!(sanitize_price_range(None, None), (None, None)); } } #[cfg(test)] mod price_dollars_tests { use super::PriceDollars; /// The bug this type exists for: what the visitor types is dollars. #[test] fn parses_dollars_not_cents() { assert_eq!("20".parse::().unwrap().cents(), 2000); assert_eq!("24.99".parse::().unwrap().cents(), 2499); assert_eq!("0".parse::().unwrap().cents(), 0); } /// Pasted decoration is the pricing parser's job, and this inherits it. #[test] fn accepts_pasted_decoration() { assert_eq!("$1,250".parse::().unwrap().cents(), 125_000); } /// A rejection here is what `ValidatedQuery` turns into the branded page. #[test] fn rejects_junk_and_negatives() { assert!("abc".parse::().is_err()); assert!("-5".parse::().is_err()); } /// Round-trips through the URL: whole dollars stay whole, cents survive. #[test] fn displays_back_as_typed() { assert_eq!(PriceDollars::from_cents(2500).to_string(), "25"); assert_eq!(PriceDollars::from_cents(2499).to_string(), "24.99"); assert_eq!(PriceDollars::from_cents(0).to_string(), "0"); } } /// Every `DiscoverQuery` field, paired with whether the rendered page must /// carry a form control submitting under that name. /// /// This exists because a form's control names are a contract with its handler /// and nothing checks it: a rename pass once rewrote `name="has_source"` to /// `name="sidebar.has_source"` and the entire suite still passed, with the /// filter silently inert. /// /// The exhaustive destructure is the point. Adding a field to `DiscoverQuery` /// stops this compiling, which forces a decision about whether the new /// parameter needs a control rather than letting it be silently unreachable. #[cfg(test)] fn query_param_contract() -> Vec<(&'static str, bool)> { let DiscoverQuery { q: _, item_type: _, tag: _, category: _, min_price: _, max_price: _, sort: _, page: _, mode: _, ai_tier: _, has_source: _, browse: _, } = DiscoverQuery { q: None, item_type: Vec::new(), tag: Vec::new(), category: None, min_price: None, max_price: None, sort: None, page: None, mode: None, ai_tier: None, has_source: None, browse: None, }; vec![ ("q", true), ("item_type", true), ("tag", true), ("category", true), ("min_price", true), ("max_price", true), ("sort", true), // Pagination is rendered as links carrying hx-vals, not a control. ("page", false), ("mode", true), ("ai_tier", true), ("has_source", true), // The drill-down cursor moves by link, never by form submission. ("browse", false), ] } #[cfg(test)] mod query_contract_tests { use super::*; #[test] fn every_query_param_is_accounted_for() { let contract = query_param_contract(); assert_eq!( contract.len(), 12, "DiscoverQuery gained or lost a field; decide whether it needs a control" ); // Names must be unique, or a duplicate would mask a missing one. let mut names: Vec<_> = contract.iter().map(|(n, _)| *n).collect(); names.sort_unstable(); let before = names.len(); names.dedup(); assert_eq!(before, names.len(), "duplicate param name in the contract"); } } #[cfg(test)] mod page_url_tests { use super::{DiscoverQuery, PriceDollars}; fn query() -> DiscoverQuery { DiscoverQuery { q: None, item_type: Vec::new(), tag: Vec::new(), category: None, min_price: None, max_price: None, sort: None, page: None, mode: None, ai_tier: None, has_source: None, browse: None, } } #[test] fn bare_query_is_the_bare_page() { assert_eq!(query().to_page_url(), "/discover"); } #[test] fn blank_filters_are_dropped() { // hx-include ships every filter on every request, so most arrive blank. let q = DiscoverQuery { q: Some(String::new()), tag: vec![" ".to_string()], category: Some(String::new()), mode: Some("items".to_string()), item_type: vec!["preset".to_string()], ..query() }; assert_eq!(q.to_page_url(), "/discover?mode=items&item_type=preset"); } #[test] fn multi_select_facets_emit_one_param_per_value() { let q = DiscoverQuery { mode: Some("items".to_string()), tag: vec![ "audio.genre.electronic".to_string(), "audio.mood.dark".to_string(), ], item_type: vec!["audio".to_string(), "sample".to_string()], ..query() }; assert_eq!( q.to_page_url(), "/discover?mode=items&item_type=audio&item_type=sample\ &tag=audio.genre.electronic&tag=audio.mood.dark" ); } #[test] fn repeated_facet_values_are_deduped_in_the_url() { // A doubled selection must not inflate the URL or the SQL bind arrays. let q = DiscoverQuery { tag: vec!["a.b.c".to_string(), "a.b.c".to_string(), String::new()], ..query() }; assert_eq!(q.to_page_url(), "/discover?tag=a.b.c"); } #[test] fn values_are_percent_encoded() { let q = DiscoverQuery { q: Some("field recording & tape".to_string()), ..query() }; assert_eq!( q.to_page_url(), "/discover?q=field%20recording%20%26%20tape" ); } #[test] fn first_page_stays_implicit() { let q = DiscoverQuery { page: Some(1), ..query() }; assert_eq!(q.to_page_url(), "/discover"); let q = DiscoverQuery { page: Some(3), ..query() }; assert_eq!(q.to_page_url(), "/discover?page=3"); } #[test] fn url_states_the_prices_that_were_actually_applied() { // An inverted range is dropped from the query, so it must not linger in the URL. let q = DiscoverQuery { min_price: Some(PriceDollars::from_cents(500)), max_price: Some(PriceDollars::from_cents(100)), ..query() }; assert_eq!(q.to_page_url(), "/discover"); // And the bounds are written back in dollars, the unit they arrived in. let q = DiscoverQuery { min_price: Some(PriceDollars::from_cents(100)), max_price: Some(PriceDollars::from_cents(2499)), ..query() }; assert_eq!(q.to_page_url(), "/discover?min_price=1&max_price=24.99"); } } /// Fetch items or projects with pagination; shared by both handlers. /// The normalized filter selection parsed from a `DiscoverQuery`: empty strings /// collapse to `None` and enum-valued params are parsed. Both the data fetch and /// the filter-chip rendering need exactly this and derived it independently /// (audit Run 17 Architecture), the logic now lives in one place. struct DiscoverFilterSelection<'a> { item_types: Vec, tags: Vec, search: Option<&'a str>, category: Option<&'a str>, ai_tier: Option, has_source_code: bool, } impl DiscoverQuery { fn filter_selection(&self) -> DiscoverFilterSelection<'_> { DiscoverFilterSelection { // Blank entries are dropped: `hx-include` ships every filter input // on every request, so an unselected control arrives as `item_type=`. // Unparseable values are dropped rather than erroring, matching the // prior single-value behaviour. item_types: dedup_nonempty(&self.item_type) .into_iter() .filter_map(|s| s.parse().ok()) .collect(), tags: dedup_nonempty(&self.tag) .into_iter() .map(str::to_string) .collect(), search: self.q.as_deref().filter(|s| !s.trim().is_empty()), category: self.category.as_deref().filter(|s| !s.is_empty()), ai_tier: self .ai_tier .as_deref() .filter(|s| !s.is_empty()) .and_then(|s| s.parse().ok()), has_source_code: self.has_source.as_deref() == Some("1"), } } } /// Drop blank entries and duplicates while preserving order. /// /// Duplicates are dropped so a doubled `?tag=x&tag=x` cannot inflate the bind /// arrays; order is preserved so the pushed URL is stable across a round-trip /// and doesn't churn browser history. fn dedup_nonempty(values: &[String]) -> Vec<&str> { let mut seen = std::collections::HashSet::new(); values .iter() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .filter(|s| seen.insert(*s)) .collect() } async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result { // Clamp the upper bound too (UX MINOR, Run #23): an unbounded page yields a // giant OFFSET = one expensive deep scan per request. Matches the git/admin // list handlers. let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000); let limit = constants::DISCOVER_PAGE_SIZE as i64; let offset = ((page - 1) as i64) * limit; let f = query.filter_selection(); // A tag is an item-level facet and nothing else: `discover_projects` takes // no tag argument, and the sidebar only builds tag filters in items mode // (see `build_sidebar`). So a tag selected in projects mode is a filter the // URL claims and the query cannot apply, which renders as the full // unfiltered project list and reads as "this tag matches everything". // // That is the common path rather than a corner. Six templates link // `/discover?tag={slug}` with no mode (item, project, both players, and the // two reader views), so every tag click from a content page landed there. // Defaulting to items whenever a tag is selected fixes all of them at once, // and shared or hand-typed links with it. let mode = query.mode.as_deref().unwrap_or(if f.tags.is_empty() { "projects" } else { "items" }); let item_type_filter = f.item_types; let tag_filter = f.tags; let search_filter = f.search; let category_filter = f.category; let ai_tier_filter = f.ai_tier; let has_source_code = f.has_source_code; let (items, projects, total_count) = if mode == "projects" { let sort_filter: Option = query .sort .as_deref() .filter(|s| !s.is_empty()) .and_then(|s| s.parse().ok()); let db_projects = db::discover::discover_projects( pool, search_filter, category_filter, sort_filter, has_source_code, limit, offset, ) .await?; let total = db::discover::count_discover_projects( pool, search_filter, category_filter, has_source_code, ) .await?; let projects: Vec = crate::types::discover_projects_view(db_projects); (vec![], projects, total as u32) } else { let sort_filter: Option = query .sort .as_deref() .filter(|s| !s.is_empty()) .and_then(|s| s.parse().ok()); let (min_price, max_price) = sanitize_price_range(query.min_price, query.max_price); let filters = DiscoverFilters { search: search_filter, item_types: &item_type_filter, tags: &tag_filter, min_price: min_price.map(PriceDollars::cents), max_price: max_price.map(PriceDollars::cents), sort_by: sort_filter, ai_tier: ai_tier_filter, }; let db_items = db::discover::discover_items(pool, &filters, limit, offset).await?; let total = db::discover::count_discover_items(pool, &filters).await?; let items: Vec = crate::types::discover_items_view(db_items); (items, vec![], total as u32) }; let total_pages = ((total_count as f64) / (limit as f64)).ceil() as u32; let pagination_range = super::pagination::build_pagination_range(page, total_pages); let result_count = if mode == "projects" { projects.len() as u32 } else { items.len() as u32 }; // Reuse the i64 `offset` (computed overflow-safe above) for the "showing // X–Y" labels and saturate into u32, rather than recomputing // `(page - 1) * DISCOVER_PAGE_SIZE` in u32, which overflows for a large `?page=`. let showing_start = if result_count == 0 { 0 } else { offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32 }; // An out-of-range `?page=` returns nothing, and the raw offset would then render // as "Showing 0-24999950 of 2". With no rows on the page there is no range to // show, so both ends collapse to zero. let showing_end = if result_count == 0 { 0 } else { offset .saturating_add(result_count as i64) .clamp(0, u32::MAX as i64) as u32 }; Ok(DiscoverData { items, projects, mode: mode.to_string(), total_count, current_page: page, total_pages, pagination_range, showing_start, showing_end, is_search: search_filter.is_some(), count_label: results_count_label(total_count, mode, search_filter.is_some()), }) } /// Query parameters for the tag tree browser. #[derive(Debug, Deserialize)] pub(super) struct TagTreeQuery { pub parent: Option, } /// Browse the tag hierarchy with breadcrumb navigation. #[tracing::instrument(skip_all, name = "discover::tag_tree")] pub(super) async fn tag_tree( State(db): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, ValidatedExtraQuery(query): ValidatedExtraQuery, ) -> Result { let csrf_token = get_csrf_token(&session).await; // Resolve parent tag from ?parent=slug (dot-notation, e.g. "audio.genre") let parent_tag = if let Some(ref slug) = query.parent { db::tags::get_tag_by_slug(&db, slug).await? } else { None }; let parent_id = parent_tag.as_ref().map(|t| t.id); // Fetch children at this level let children = db::tags::get_child_tags(&db, parent_id).await?; // Item counts + child counts, both scoped to just this level's children // rather than aggregating over every tag in the catalog (fuzz 2026-07-06 C5-2). let child_ids: Vec<_> = children.iter().map(|c| c.id).collect(); let tag_counts = db::tags::item_counts_by_tag(&db, &child_ids).await?; let grandchild_counts = db::tags::count_children_by_parents(&db, &child_ids).await?; let categories: Vec = children .iter() .map(|child| TagTreeNode { name: child.name.clone(), slug: child.slug.clone(), item_count: *tag_counts.get(&child.id).unwrap_or(&0) as u32, child_count: *grandchild_counts.get(&child.id).unwrap_or(&0) as usize, }) .collect(); // Build breadcrumbs from ancestor chain let (breadcrumbs, current_tag) = if let Some(ref pt) = parent_tag { let ancestors = db::tags::get_tag_ancestors(&db, pt.id).await?; // ancestors includes the tag itself as the last element. // We want all ancestors except the current tag as breadcrumbs, // and the current tag as current_tag. let bc: Vec = ancestors .iter() .filter(|a| a.id != pt.id) .map(|a| TagBreadcrumb { name: a.name.clone(), slug: a.slug.clone(), }) .collect(); let ct = TagBreadcrumb { name: pt.name.clone(), slug: pt.slug.clone(), }; (bc, Some(ct)) } else { (vec![], None) }; Ok(TagTreeTemplate { csrf_token, session_user: maybe_user, categories, breadcrumbs, current_tag, }) } /// Render the discover page with filterable, paginated items or projects. #[tracing::instrument(skip_all, name = "discover::discover")] pub(super) async fn discover( State(db): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, ValidatedExtraQuery(query): ValidatedExtraQuery, ) -> Result { let csrf_token = get_csrf_token(&session).await; let data = fetch_discover_data(&db, &query).await?; let sidebar = build_sidebar(&db, &query, &data, maybe_user.as_ref().map(|u| u.id)).await?; let is_authenticated = maybe_user.is_some(); Ok(DiscoverTemplate { csrf_token, session_user: maybe_user, items: data.items, projects: data.projects, mode: data.mode, total_items: data.total_count, current_page: data.current_page, total_pages: data.total_pages, search_query: query.q.clone().unwrap_or_default(), is_search: data.is_search, count_label: data.count_label, sort_by: query.sort.clone().unwrap_or_default(), pagination_range: data.pagination_range, showing_start: data.showing_start, showing_end: data.showing_end, sidebar, is_authenticated, oob_sidebar: false, }) } /// Return discover results as an HTMX partial for filtering and pagination. #[tracing::instrument(skip_all, name = "discover::discover_results")] pub(super) async fn discover_results( State(db): State, MaybeUserUnverified(maybe_user): MaybeUserUnverified, headers: HeaderMap, ValidatedExtraQuery(query): ValidatedExtraQuery, ) -> Result { let data = fetch_discover_data(&db, &query).await?; // The sidebar rides along on every results request and is swapped // out-of-band, so its counts always describe the results beside them. let sidebar = build_sidebar(&db, &query, &data, maybe_user.as_ref().map(|u| u.id)).await?; // Mirror the filter state into the address bar so a filtered view can be // shared, bookmarked, and reloaded. A discrete filter click pushes a history // entry, so Back undoes one filter; the search box fires on a debounce while // typing, so it replaces instead of burying the page under one entry per // keystroke pause. HX-Source names the element that fired, as `tag#id`; // htmx 4 renamed the header from HX-Trigger and widened the value, which // used to be the bare id. let history_header = match headers.get("HX-Source").and_then(|v| v.to_str().ok()) { Some("input#search-input") => "HX-Replace-Url", _ => "HX-Push-Url", }; let page_url = query.to_page_url(); Ok(( [(history_header, page_url)], DiscoverResultsTemplate { items: data.items, projects: data.projects, mode: data.mode, total_items: data.total_count, current_page: data.current_page, total_pages: data.total_pages, pagination_range: data.pagination_range, showing_start: data.showing_start, showing_end: data.showing_end, current_category: query.category.clone().unwrap_or_default(), is_search: data.is_search, count_label: data.count_label, is_authenticated: maybe_user.is_some(), sidebar, oob_sidebar: true, }, )) } /// Query parameters for search suggestions. #[derive(Debug, Deserialize)] pub(super) struct SuggestionsQuery { pub q: Option, } /// JSON response for a search suggestion. #[derive(Debug, Serialize)] pub(super) struct SearchSuggestion { pub label: String, pub category: String, pub url: String, } /// Return search suggestions (tags, projects, creators) as JSON. #[tracing::instrument(skip_all, name = "discover::search_suggestions")] pub(super) async fn search_suggestions_handler( State(db): State, ValidatedExtraQuery(query): ValidatedExtraQuery, ) -> Result { let q = query.q.unwrap_or_default(); let rows = db::discover::search_suggestions(&db, &q).await?; let suggestions: Vec = rows .into_iter() .map(|r| SearchSuggestion { label: r.label, category: r.category, url: r.url, }) .collect(); Ok(Json(suggestions)) }