use ggen_utils::error::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
mod scoring {
pub const EXACT_NAME_MATCH: f64 = 100.0;
pub const NAME_CONTAINS_QUERY: f64 = 50.0;
pub const FUZZY_NAME_MULTIPLIER: f64 = 30.0;
pub const DESCRIPTION_CONTAINS_QUERY: f64 = 20.0;
pub const TAG_KEYWORD_MATCH: f64 = 10.0;
pub const MAX_DOWNLOADS_BOOST: f64 = 10.0;
pub const MAX_STARS_BOOST: f64 = 5.0;
pub const DOWNLOADS_DIVISOR: f64 = 1000.0;
pub const STARS_DIVISOR: f64 = 10.0;
pub const FUZZY_SIMILARITY_THRESHOLD: f64 = 0.7;
}
mod defaults {
pub const DEFAULT_RESULT_LIMIT: usize = 10;
pub const DEFAULT_TIMEOUT_SECONDS: u64 = 30;
pub const MAX_RETRY_ATTEMPTS: u32 = 3;
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SearchInput {
pub query: String,
pub category: Option<String>,
pub keyword: Option<String>,
pub author: Option<String>,
#[serde(default)]
pub only_8020: bool,
#[serde(default)]
pub sector: Option<String>,
pub fuzzy: bool,
pub detailed: bool,
pub json: bool,
pub limit: usize,
}
impl SearchInput {
pub fn new(query: String) -> Self {
Self {
query,
limit: defaults::DEFAULT_RESULT_LIMIT,
..Default::default()
}
}
pub fn only_8020(mut self) -> Self {
self.only_8020 = true;
self
}
pub fn with_sector(mut self, sector: impl Into<String>) -> Self {
self.sector = Some(sector.into());
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SearchFilters {
pub category: Option<String>,
pub keyword: Option<String>,
pub author: Option<String>,
pub license: Option<String>,
pub min_stars: Option<u32>,
pub min_downloads: Option<u32>,
#[serde(default)]
pub only_8020: bool,
#[serde(default)]
pub sector: Option<String>,
pub sort: String,
pub order: String,
pub fuzzy: bool,
pub limit: usize,
}
impl SearchFilters {
pub fn new() -> Self {
Self {
sort: "relevance".to_string(),
order: "desc".to_string(),
limit: defaults::DEFAULT_RESULT_LIMIT,
..Default::default()
}
}
pub fn with_category(mut self, category: impl Into<String>) -> Self {
self.category = Some(category.into());
self
}
pub fn with_limit(mut self, limit: usize) -> Self {
self.limit = limit;
self
}
pub fn with_fuzzy(mut self, fuzzy: bool) -> Self {
self.fuzzy = fuzzy;
self
}
pub fn only_8020(mut self) -> Self {
self.only_8020 = true;
self
}
pub fn with_sector(mut self, sector: impl Into<String>) -> Self {
self.sector = Some(sector.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub author: Option<String>,
pub category: Option<String>,
pub tags: Vec<String>,
pub stars: u32,
pub downloads: u32,
#[serde(default)]
pub is_8020_certified: bool,
#[serde(default)]
pub sector: Option<String>,
#[serde(default)]
pub dark_matter_reduction_target: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RegistryIndex {
#[serde(rename = "updated_at")]
updated: String,
packages: Vec<PackageInfo>,
#[serde(default)]
search_index: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PackageInfo {
name: String,
version: String,
category: String,
description: String,
tags: Vec<String>,
#[serde(default)]
keywords: Vec<String>,
#[serde(default)]
author: Option<String>,
#[serde(default)]
license: Option<String>,
#[serde(default)]
downloads: u32,
#[serde(default)]
stars: u32,
#[serde(default)]
production_ready: bool,
#[serde(default)]
dependencies: Vec<String>,
#[serde(default)]
path: Option<String>, #[serde(default)]
download_url: Option<String>, #[serde(default)]
checksum: Option<String>, #[serde(default)]
is_8020_certified: bool,
#[serde(default)]
sector: Option<String>,
#[serde(default)]
dark_matter_reduction_target: Option<String>,
}
struct PackageMetadata {
id: String,
name: String,
description: String,
tags: Vec<String>,
keywords: Vec<String>,
category: String,
author: String,
latest_version: String,
downloads: u32,
stars: u32,
license: Option<String>,
is_8020_certified: bool,
sector: Option<String>,
dark_matter_reduction_target: Option<String>,
}
impl From<PackageInfo> for PackageMetadata {
fn from(info: PackageInfo) -> Self {
Self {
id: info.name.clone(),
name: info.name,
description: info.description,
tags: info.tags,
keywords: info.keywords,
category: info.category,
author: info.author.unwrap_or_else(|| "unknown".to_string()),
latest_version: info.version,
downloads: info.downloads,
stars: info.stars,
license: info.license,
is_8020_certified: info.is_8020_certified,
sector: info.sector,
dark_matter_reduction_target: info.dark_matter_reduction_target,
}
}
}
fn validate_package_for_search(pkg: &PackageInfo) -> Result<()> {
if pkg.name.is_empty() {
return Err(ggen_utils::error::Error::new(
"Package in search index has empty name",
));
}
if pkg.version.is_empty() {
return Err(ggen_utils::error::Error::new(&format!(
"Package {} has empty version in search index",
pkg.name
)));
}
if pkg.description.is_empty() {
return Err(ggen_utils::error::Error::new(&format!(
"Package {} has empty description in search index",
pkg.name
)));
}
if pkg.category.is_empty() {
return Err(ggen_utils::error::Error::new(&format!(
"Package {} has empty category in search index",
pkg.name
)));
}
if pkg.downloads > 1_000_000_000 {
tracing::warn!(
"Package {} has suspiciously high download count: {}",
pkg.name,
pkg.downloads
);
}
Ok(())
}
fn validate_registry_index(index: &RegistryIndex) -> Result<()> {
if index.packages.is_empty() {
return Err(ggen_utils::error::Error::new(
"❌ Registry index contains no packages. Run 'ggen marketplace sync' to download the registry."
));
}
let mut seen_names = std::collections::HashSet::new();
for pkg in &index.packages {
if !seen_names.insert(pkg.name.clone()) {
return Err(ggen_utils::error::Error::new(&format!(
"❌ Duplicate package name in search index: {} (registry is corrupted)",
pkg.name
)));
}
validate_package_for_search(pkg)?;
}
Ok(())
}
fn levenshtein_distance(s1: &str, s2: &str) -> usize {
let s1 = s1.to_lowercase();
let s2 = s2.to_lowercase();
let len1 = s1.len();
let len2 = s2.len();
if len1 == 0 {
return len2;
}
if len2 == 0 {
return len1;
}
let mut matrix = vec![vec![0; len2 + 1]; len1 + 1];
for (i, row) in matrix.iter_mut().enumerate().take(len1 + 1) {
row[0] = i;
}
for (j, col) in matrix[0].iter_mut().enumerate().take(len2 + 1) {
*col = j;
}
for (i, c1) in s1.chars().enumerate() {
for (j, c2) in s2.chars().enumerate() {
let cost = if c1 == c2 { 0 } else { 1 };
matrix[i + 1][j + 1] = std::cmp::min(
std::cmp::min(matrix[i][j + 1] + 1, matrix[i + 1][j] + 1),
matrix[i][j] + cost,
);
}
}
matrix[len1][len2]
}
fn calculate_relevance(pkg: &PackageMetadata, query: &str, fuzzy: bool) -> f64 {
let query_lower = query.to_lowercase();
let name_lower = pkg.name.to_lowercase();
let desc_lower = pkg.description.to_lowercase();
let mut score = 0.0;
if name_lower == query_lower {
score += scoring::EXACT_NAME_MATCH;
}
else if name_lower.contains(&query_lower) {
score += scoring::NAME_CONTAINS_QUERY;
}
else if fuzzy {
let distance = levenshtein_distance(&name_lower, &query_lower);
let max_len = std::cmp::max(name_lower.len(), query_lower.len());
if max_len > 0 {
let similarity = 1.0 - (distance as f64 / max_len as f64);
if similarity > scoring::FUZZY_SIMILARITY_THRESHOLD {
score += similarity * scoring::FUZZY_NAME_MULTIPLIER;
}
}
}
if desc_lower.contains(&query_lower) {
score += scoring::DESCRIPTION_CONTAINS_QUERY;
}
for word in query.split_whitespace() {
let word_lower = word.to_lowercase();
for tag in &pkg.tags {
if tag.to_lowercase().contains(&word_lower) {
score += scoring::TAG_KEYWORD_MATCH;
}
}
for keyword in &pkg.keywords {
if keyword.to_lowercase().contains(&word_lower) {
score += scoring::TAG_KEYWORD_MATCH;
}
}
}
let desc_quality_bonus = if pkg.description.len() > 200 {
1.0 } else if pkg.description.len() > 100 {
0.5 } else {
0.0 };
if score > 0.0 {
score += desc_quality_bonus;
}
if score > 0.0 {
score +=
(pkg.downloads as f64 / scoring::DOWNLOADS_DIVISOR).min(scoring::MAX_DOWNLOADS_BOOST);
score += (pkg.stars as f64 / scoring::STARS_DIVISOR).min(scoring::MAX_STARS_BOOST);
}
score
}
async fn load_registry_index() -> Result<RegistryIndex> {
let registry_url = std::env::var("GGEN_REGISTRY_URL").unwrap_or_else(|_| {
"https://seanchatmangpt.github.io/ggen/marketplace/registry/index.json".to_string()
});
if registry_url.starts_with("http://") || registry_url.starts_with("https://") {
match fetch_registry_from_url(®istry_url).await {
Ok(index) => {
if let Err(e) = cache_registry_index(&index).await {
tracing::warn!("Failed to cache registry index: {}", e);
}
return Ok(index);
}
Err(e) => {
tracing::debug!("Failed to fetch registry from {}: {}", registry_url, e);
}
}
}
let possible_paths = vec![
dirs::home_dir().map(|h| h.join(".ggen").join("registry").join("index.json")),
std::env::current_dir().ok().and_then(|cwd| {
let mut path = cwd.clone();
for _ in 0..5 {
let registry = path.join("marketplace").join("registry").join("index.json");
if registry.exists() {
return Some(registry);
}
path = match path.parent() {
Some(p) => p.to_path_buf(),
None => break,
};
}
None
}),
{
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest_dir
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("marketplace").join("registry").join("index.json"));
workspace_root
},
{
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest_dir
.parent()
.map(|p| p.join("registry").join("index.json"))
},
];
let registry_path = possible_paths.into_iter().flatten().find(|p| p.exists());
let registry_path = match registry_path {
Some(path) => path,
None => {
return Err(ggen_utils::error::Error::new(
"❌ Registry index not found. Run 'ggen marketplace sync' to download the registry."
));
}
};
let content = tokio::fs::read_to_string(®istry_path)
.await
.map_err(|e| {
ggen_utils::error::Error::new(&format!(
"Failed to read registry index {}: {}",
registry_path.display(),
e
))
})?;
let json_value: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
ggen_utils::error::Error::new(&format!(
"Invalid JSON in registry index {}: {}. Please check registry format.",
registry_path.display(),
e
))
})?;
if !json_value.is_object() {
return Err(ggen_utils::error::Error::new(&format!(
"Invalid registry format: expected object, got {}",
json_value
)));
}
let index: RegistryIndex = serde_json::from_value(json_value).map_err(|e| {
ggen_utils::error::Error::new(&format!(
"Invalid registry structure {}: {}. Expected 'packages' array and 'updated' timestamp.",
registry_path.display(),
e
))
})?;
validate_registry_index(&index)?;
Ok(index)
}
async fn fetch_registry_from_url(url: &str) -> Result<RegistryIndex> {
use reqwest::Client;
let client = Client::builder()
.timeout(std::time::Duration::from_secs(
defaults::DEFAULT_TIMEOUT_SECONDS,
))
.user_agent(format!("ggen/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to create HTTP client: {}", e))
})?;
const MAX_RETRIES: u32 = defaults::MAX_RETRY_ATTEMPTS;
let mut last_error = None;
for attempt in 1..=MAX_RETRIES {
match client.get(url).send().await {
Ok(response) => {
if !response.status().is_success() {
let status = response.status();
if status.is_client_error() {
return Err(ggen_utils::error::Error::new(&format!(
"Registry returned client error {} for URL: {}",
status, url
)));
}
last_error = Some(ggen_utils::error::Error::new(&format!(
"Registry returned status {} for URL: {}",
status, url
)));
} else {
match response.json::<RegistryIndex>().await {
Ok(index) => {
tracing::info!("Successfully fetched registry index from {}", url);
return Ok(index);
}
Err(e) => {
last_error = Some(ggen_utils::error::Error::new(&format!(
"Failed to parse registry index JSON: {}",
e
)));
}
}
}
}
Err(e) => {
last_error = Some(ggen_utils::error::Error::new(&format!(
"Network error fetching registry from {}: {}. Check your internet connection. Falling back to local cache if available.",
url, e
)));
}
}
if attempt < MAX_RETRIES {
let delay = std::time::Duration::from_secs(1 << (attempt - 1));
tokio::time::sleep(delay).await;
}
}
Err(last_error.unwrap_or_else(|| {
ggen_utils::error::Error::new(&format!(
"Failed to fetch registry after {} attempts",
MAX_RETRIES
))
}))
}
async fn cache_registry_index(index: &RegistryIndex) -> Result<()> {
let cache_dir = dirs::home_dir()
.ok_or_else(|| ggen_utils::error::Error::new("Could not determine home directory"))?
.join(".ggen")
.join("registry");
tokio::fs::create_dir_all(&cache_dir).await.map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to create cache directory: {}", e))
})?;
let cache_path = cache_dir.join("index.json");
let content = serde_json::to_string_pretty(index).map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to serialize registry index: {}", e))
})?;
tokio::fs::write(&cache_path, content).await.map_err(|e| {
ggen_utils::error::Error::new(&format!("Failed to write cache file: {}", e))
})?;
tracing::debug!("Cached registry index to {}", cache_path.display());
Ok(())
}
pub async fn search_packages(query: &str, filters: &SearchFilters) -> Result<Vec<SearchResult>> {
use crate::marketplace::NonEmptyQuery;
let validated_query = NonEmptyQuery::new(query)?;
let query_str = validated_query.as_str();
let index = load_registry_index().await?;
let mut scored_packages: Vec<(PackageMetadata, f64)> = index
.packages
.into_iter()
.filter_map(|info| {
if let Err(e) = validate_package_for_search(&info) {
tracing::warn!("Skipping invalid package in search: {}", e);
return None;
}
Some(PackageMetadata::from(info))
})
.filter_map(|pkg| {
if let Some(ref category) = filters.category {
if !pkg.category.eq_ignore_ascii_case(category) {
return None;
}
}
if let Some(ref author) = filters.author {
if !pkg.author.eq_ignore_ascii_case(author) {
return None;
}
}
if let Some(ref license) = filters.license {
if pkg
.license
.as_ref()
.map(|l| !l.eq_ignore_ascii_case(license))
.unwrap_or(true)
{
return None;
}
}
if let Some(min_stars) = filters.min_stars {
if pkg.stars < min_stars {
return None;
}
}
if let Some(min_downloads) = filters.min_downloads {
if pkg.downloads < min_downloads {
return None;
}
}
if filters.only_8020 {
if !pkg.is_8020_certified {
return None;
}
}
if let Some(ref sector) = filters.sector {
match &pkg.sector {
Some(pkg_sector) if pkg_sector.eq_ignore_ascii_case(sector) => {
}
_ => {
return None;
}
}
}
if let Some(ref keyword) = filters.keyword {
let keyword_lower = keyword.to_lowercase();
let has_keyword = pkg
.keywords
.iter()
.any(|k| k.to_lowercase().contains(&keyword_lower))
|| pkg
.tags
.iter()
.any(|t| t.to_lowercase().contains(&keyword_lower));
if !has_keyword {
return None;
}
}
let score = calculate_relevance(&pkg, query_str, filters.fuzzy);
if score > 0.0 {
Some((pkg, score))
} else {
None
}
})
.collect();
match filters.sort.as_str() {
"stars" => {
scored_packages.sort_by(|a, b| {
let cmp = b.0.stars.cmp(&a.0.stars);
if filters.order == "asc" {
cmp.reverse()
} else {
cmp
}
});
}
"downloads" => {
scored_packages.sort_by(|a, b| {
let cmp = b.0.downloads.cmp(&a.0.downloads);
if filters.order == "asc" {
cmp.reverse()
} else {
cmp
}
});
}
_ => {
scored_packages.sort_by(|a, b| {
let a_nan = a.1.is_nan();
let b_nan = b.1.is_nan();
let cmp = if a_nan || b_nan {
tracing::warn!(
"NaN detected in relevance score comparison: a={:?} b={:?}",
a.1,
b.1
);
std::cmp::Ordering::Equal
} else {
b.1.partial_cmp(&a.1).unwrap_or_else(|| {
tracing::error!(
"Unexpected partial_cmp failure (not NaN): a={:?} b={:?}",
a.1,
b.1
);
std::cmp::Ordering::Equal
})
};
if filters.order == "asc" {
cmp.reverse()
} else {
cmp
}
});
}
}
let results: Vec<SearchResult> = scored_packages
.into_iter()
.take(filters.limit)
.map(|(pkg, _score)| SearchResult {
id: pkg.id,
name: pkg.name,
version: pkg.latest_version,
description: pkg.description,
author: Some(pkg.author),
category: Some(pkg.category),
tags: pkg.tags,
stars: pkg.stars,
downloads: pkg.downloads,
is_8020_certified: pkg.is_8020_certified,
sector: pkg.sector.as_ref().cloned(),
dark_matter_reduction_target: pkg.dark_matter_reduction_target.as_ref().cloned(),
})
.collect();
Ok(results)
}
#[allow(clippy::too_many_arguments)] pub async fn search_and_display(
query: &str, category: Option<&str>, keyword: Option<&str>, author: Option<&str>, fuzzy: bool,
detailed: bool, json: bool, limit: usize,
) -> Result<()> {
let input = SearchInput {
query: query.to_string(),
category: category.map(|s| s.to_string()),
keyword: keyword.map(|s| s.to_string()),
author: author.map(|s| s.to_string()),
only_8020: false, sector: None, fuzzy,
detailed,
json,
limit,
};
search_and_display_with_input(&input).await
}
pub async fn search_and_display_with_input(input: &SearchInput) -> Result<()> {
let mut filters = SearchFilters::new()
.with_limit(input.limit)
.with_fuzzy(input.fuzzy);
if let Some(ref cat) = input.category {
filters = filters.with_category(cat);
}
if let Some(ref kw) = input.keyword {
filters.keyword = Some(kw.clone());
}
if let Some(ref auth) = input.author {
filters.author = Some(auth.clone());
}
if input.only_8020 {
filters = filters.only_8020();
}
if let Some(ref sector) = input.sector {
filters = filters.with_sector(sector);
}
let results = search_packages(&input.query, &filters).await?;
if input.json {
let json_output = serde_json::to_string_pretty(&results)?;
ggen_utils::alert_info!("{}", json_output);
} else if results.is_empty() {
ggen_utils::alert_info!("No packages found matching '{}'", input.query);
ggen_utils::alert_info!("\nTry:");
ggen_utils::alert_info!(" - Using broader search terms");
ggen_utils::alert_info!(" - Removing filters");
ggen_utils::alert_info!(" - Using --fuzzy for typo tolerance");
} else {
ggen_utils::alert_info!(
"Found {} package(s) matching '{}':\n",
results.len(),
input.query
);
for result in results {
ggen_utils::alert_info!("📦 {} v{}", result.name, result.version);
ggen_utils::alert_info!(" {}", result.description);
if input.detailed {
if let Some(author) = result.author {
ggen_utils::alert_info!(" Author: {}", author);
}
if let Some(category) = result.category {
ggen_utils::alert_info!(" Category: {}", category);
}
if !result.tags.is_empty() {
ggen_utils::alert_info!(" Tags: {}", result.tags.join(", "));
}
ggen_utils::alert_info!(
" ⭐ {} stars 📥 {} downloads",
result.stars,
result.downloads
);
}
}
}
Ok(())
}
pub async fn execute_search(input: SearchInput) -> Result<Vec<SearchResult>> {
let filters = SearchFilters {
category: input.category,
keyword: input.keyword,
author: input.author,
fuzzy: input.fuzzy,
limit: input.limit,
..Default::default()
};
search_packages(&input.query, &filters).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_search_filters_builder() {
let filters = SearchFilters::new()
.with_category("web")
.with_limit(5)
.with_fuzzy(true);
assert_eq!(filters.category, Some("web".to_string()));
assert_eq!(filters.limit, 5);
assert!(filters.fuzzy);
assert_eq!(filters.sort, "relevance");
}
#[test]
fn test_levenshtein_distance() {
assert_eq!(levenshtein_distance("", ""), 0);
assert_eq!(levenshtein_distance("hello", "hello"), 0);
assert_eq!(levenshtein_distance("hello", "helo"), 1);
assert_eq!(levenshtein_distance("rust", "rest"), 1);
assert_eq!(levenshtein_distance("python", "pyton"), 1);
assert_eq!(levenshtein_distance("javascript", "java"), 6);
}
#[test]
fn test_relevance_calculation_exact_match() {
let pkg = PackageMetadata {
id: "test".to_string(),
name: "Rust CLI".to_string(),
description: "A Rust CLI tool".to_string(),
tags: vec!["rust".to_string(), "cli".to_string()],
keywords: vec!["command-line".to_string()],
category: "rust".to_string(),
author: "test".to_string(),
latest_version: "1.0.0".to_string(),
downloads: 1000,
stars: 50,
license: Some("MIT".to_string()),
};
let score = calculate_relevance(&pkg, "rust cli", false);
assert!(
score >= scoring::EXACT_NAME_MATCH,
"Exact match should have high score: {}",
score
);
}
#[test]
fn test_package_metadata_conversion_preserves_8020_fields() {
let info = PackageInfo {
name: "test-package".to_string(),
version: "1.0.0".to_string(),
category: "rust".to_string(),
description: "Test package".to_string(),
tags: vec!["test".to_string()],
keywords: vec![],
author: Some("test-author".to_string()),
license: Some("MIT".to_string()),
downloads: 100,
stars: 10,
production_ready: true,
dependencies: vec![],
path: None,
download_url: None,
checksum: None,
is_8020_certified: true,
sector: Some("observability".to_string()),
dark_matter_reduction_target: Some("reduce-logs".to_string()),
};
let metadata = PackageMetadata::from(info.clone());
assert_eq!(metadata.is_8020_certified, info.is_8020_certified);
assert_eq!(metadata.sector, info.sector);
assert_eq!(
metadata.dark_matter_reduction_target,
info.dark_matter_reduction_target
);
let search_result = SearchResult {
id: metadata.id.clone(),
name: metadata.name.clone(),
version: metadata.latest_version.clone(),
description: metadata.description.clone(),
author: Some(metadata.author.clone()),
category: Some(metadata.category.clone()),
tags: metadata.tags.clone(),
stars: metadata.stars,
downloads: metadata.downloads,
is_8020_certified: metadata.is_8020_certified,
sector: metadata.sector.as_ref().cloned(),
dark_matter_reduction_target: metadata.dark_matter_reduction_target.as_ref().cloned(),
};
assert_eq!(search_result.is_8020_certified, info.is_8020_certified);
assert_eq!(search_result.sector, info.sector);
assert_eq!(
search_result.dark_matter_reduction_target,
info.dark_matter_reduction_target
);
assert_eq!(search_result.id, info.name);
assert_eq!(search_result.name, info.name);
}
#[test]
fn test_sector_filter_case_insensitive_ascii() {
let pkg1 = PackageMetadata {
id: "test1".to_string(),
name: "test1".to_string(),
description: "Test".to_string(),
tags: vec![],
keywords: vec![],
category: "test".to_string(),
author: "test".to_string(),
latest_version: "1.0.0".to_string(),
downloads: 0,
stars: 0,
license: None,
is_8020_certified: false,
sector: Some("Observability".to_string()), dark_matter_reduction_target: None,
};
let pkg2 = PackageMetadata {
id: "test2".to_string(),
name: "test2".to_string(),
description: "Test".to_string(),
tags: vec![],
keywords: vec![],
category: "test".to_string(),
author: "test".to_string(),
latest_version: "1.0.0".to_string(),
downloads: 0,
stars: 0,
license: None,
is_8020_certified: false,
sector: Some("observability".to_string()), dark_matter_reduction_target: None,
};
let filter = "observability";
assert!(pkg1.sector.as_ref().unwrap().eq_ignore_ascii_case(filter));
assert!(pkg2.sector.as_ref().unwrap().eq_ignore_ascii_case(filter));
}
#[test]
fn test_relevance_calculation_fuzzy_match() {
let pkg = PackageMetadata {
id: "test".to_string(),
name: "Rust Web".to_string(),
description: "A Rust web framework".to_string(),
tags: vec!["rust".to_string(), "web".to_string()],
keywords: vec!["http".to_string()],
category: "rust".to_string(),
author: "test".to_string(),
latest_version: "1.0.0".to_string(),
downloads: 500,
stars: 25,
license: Some("MIT".to_string()),
is_8020_certified: false,
sector: None,
dark_matter_reduction_target: None,
};
let score_fuzzy = calculate_relevance(&pkg, "rst web", true);
assert!(score_fuzzy > 0.0, "Fuzzy match should have some score");
let score_no_fuzzy = calculate_relevance(&pkg, "xyz", false);
assert!(
score_no_fuzzy < scoring::TAG_KEYWORD_MATCH,
"Unrelated query should have low score"
);
}
#[tokio::test]
async fn test_search_packages_real_index() {
let filters = SearchFilters::new();
let results = search_packages("rust", &filters).await;
match results {
Ok(_) => {
assert!(true);
}
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("Registry index not found")
|| msg.contains("registry is corrupted"),
"Error message should indicate missing or corrupted registry: {}",
msg
);
}
}
}
#[tokio::test]
async fn test_search_packages_with_limit() {
let filters = SearchFilters::new().with_limit(1);
let results = search_packages("cli", &filters).await;
if let Ok(results) = results {
assert!(results.len() <= 1, "Should respect limit of 1 result");
}
}
#[tokio::test]
async fn test_search_packages_with_category_filter() {
let filters = SearchFilters::new().with_category("rust");
let results = search_packages("web", &filters).await;
if let Ok(results) = results {
for result in results {
assert_eq!(
result.category,
Some("rust".to_string()),
"All results should be in rust category"
);
}
}
}
#[tokio::test]
async fn test_search_packages_with_fuzzy() {
let filters = SearchFilters::new().with_fuzzy(true);
let results = search_packages("pyton", &filters).await;
match results {
Ok(_results) => {
assert!(true);
}
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("Registry index not found")
|| msg.contains("registry is corrupted"),
"Error should indicate missing or corrupted registry: {}",
msg
);
}
}
}
}