add include parameter to override exclude patterns

exclude=^bigbufo_&include=bigbufo_0_0,bigbufo_2_1 now works as expected.
include acts as an allowlist - matching results bypass exclusion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+32 -8
src
+32 -8
src/search.rs
··· 61 61 /// family-friendly mode: filters out inappropriate content (default true) 62 62 #[serde(default = "default_family_friendly")] 63 63 pub family_friendly: bool, 64 - /// comma-separated glob patterns to exclude from results (e.g., "*party*,*sad*") 64 + /// comma-separated regex patterns to exclude from results (e.g., "excited,party") 65 65 #[serde(default)] 66 66 pub exclude: Option<String>, 67 + /// comma-separated regex patterns to include (overrides exclude) 68 + #[serde(default)] 69 + pub include: Option<String>, 67 70 } 68 71 69 72 fn default_top_k() -> usize { ··· 102 105 } 103 106 104 107 /// generate etag for caching based on query parameters 105 - fn generate_etag(query: &str, top_k: usize, alpha: f32, family_friendly: bool, exclude: &Option<String>) -> String { 108 + fn generate_etag(query: &str, top_k: usize, alpha: f32, family_friendly: bool, exclude: &Option<String>, include: &Option<String>) -> String { 106 109 let mut hasher = DefaultHasher::new(); 107 110 query.hash(&mut hasher); 108 111 top_k.hash(&mut hasher); ··· 110 113 alpha.to_bits().hash(&mut hasher); 111 114 family_friendly.hash(&mut hasher); 112 115 exclude.hash(&mut hasher); 116 + include.hash(&mut hasher); 113 117 format!("\"{}\"", hasher.finish()) 114 118 } 115 119 ··· 120 124 alpha: f32, 121 125 family_friendly: bool, 122 126 exclude: Option<String>, 127 + include: Option<String>, 123 128 config: &Config, 124 129 ) -> ActixResult<SearchResponse> { 125 130 // parse and compile exclusion regex patterns from comma-separated string ··· 130 135 .map(|p| p.trim()) 131 136 .filter(|p| !p.is_empty()) 132 137 .filter_map(|p| Regex::new(p).ok()) // silently skip invalid patterns 138 + .collect() 139 + }) 140 + .unwrap_or_default(); 141 + 142 + // parse and compile inclusion regex patterns (these override exclusions) 143 + let include_patterns: Vec<Regex> = include 144 + .as_ref() 145 + .map(|s| { 146 + s.split(',') 147 + .map(|p| p.trim()) 148 + .filter(|p| !p.is_empty()) 149 + .filter_map(|p| Regex::new(p).ok()) 133 150 .collect() 134 151 }) 135 152 .unwrap_or_default(); ··· 365 382 return false; 366 383 } 367 384 368 - // filter out results matching any exclude regex pattern 369 - for pattern in &exclude_patterns { 370 - if pattern.is_match(&result.name) { 371 - return false; 372 - } 385 + // check if result matches any include pattern (allowlist override) 386 + let matches_include = include_patterns.iter().any(|p| p.is_match(&result.name)); 387 + 388 + // check if result matches any exclude pattern 389 + let matches_exclude = exclude_patterns.iter().any(|p| p.is_match(&result.name)); 390 + 391 + // keep if: matches include OR doesn't match exclude 392 + // i.e., include overrides exclude 393 + if matches_exclude && !matches_include { 394 + return false; 373 395 } 374 396 375 397 true ··· 409 431 query.alpha, 410 432 query.family_friendly, 411 433 query.exclude.clone(), 434 + query.include.clone(), 412 435 &config 413 436 ).await?; 414 437 Ok(HttpResponse::Ok().json(response)) ··· 421 444 req: HttpRequest, 422 445 ) -> ActixResult<HttpResponse> { 423 446 // generate etag for caching 424 - let etag = generate_etag(&query.query, query.top_k, query.alpha, query.family_friendly, &query.exclude); 447 + let etag = generate_etag(&query.query, query.top_k, query.alpha, query.family_friendly, &query.exclude, &query.include); 425 448 426 449 // check if client has cached version 427 450 if let Some(if_none_match) = req.headers().get("if-none-match") { ··· 438 461 query.alpha, 439 462 query.family_friendly, 440 463 query.exclude.clone(), 464 + query.include.clone(), 441 465 &config 442 466 ).await?; 443 467