diff --git a/app/src/app/packages/category/[categoryName]/page.tsx b/app/src/app/packages/category/[categoryName]/page.tsx
new file mode 100644
index 0000000..e43aaef
--- /dev/null
+++ b/app/src/app/packages/category/[categoryName]/page.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import React from "react";
+import { useParams } from "next/navigation";
+import CategoryPackages from "../../../../pages/CategoryPackages";
+
+export default function CategoryPage() {
+ const params = useParams();
+ const categoryName = decodeURIComponent(params.categoryName as string);
+
+ return ;
+}
\ No newline at end of file
diff --git a/app/src/features/detail/components/PackageSidebar.tsx b/app/src/features/detail/components/PackageSidebar.tsx
index ec0d421..130bcb0 100644
--- a/app/src/features/detail/components/PackageSidebar.tsx
+++ b/app/src/features/detail/components/PackageSidebar.tsx
@@ -11,6 +11,7 @@ import {
ListItem,
Link,
Typography,
+ Chip,
} from "@mui/material";
import { FullPackage } from "../hooks/usePackageDetail";
import "./PackageSidebar.css";
@@ -20,6 +21,8 @@ import GitHubIcon from "@mui/icons-material/GitHub";
import DescriptionIcon from "@mui/icons-material/Description";
import HomeIcon from "@mui/icons-material/Home";
import LinkIcon from "@mui/icons-material/Link";
+import LocalOfferIcon from "@mui/icons-material/LocalOffer";
+import LabelIcon from "@mui/icons-material/Label";
interface PackageSidebarProps {
data: FullPackage | null;
@@ -79,6 +82,77 @@ const PackageSidebar = ({ data, loading, error }: PackageSidebarProps) => {
+ {/* Categories Section */}
+ {data.categories && data.categories.length > 0 && (
+
+
+
+
+ Categories
+
+
+
+ {data.categories.map((category) => (
+ {
+ window.location.href = `/packages/category/${encodeURIComponent(category)}`;
+ }}
+ />
+ ))}
+
+
+ )}
+
+ {/* Keywords Section */}
+ {data.keywords && data.keywords.length > 0 && (
+
+
+
+
+ Keywords
+
+
+
+ {data.keywords.map((keyword) => (
+ {
+ const newParams = new URLSearchParams();
+ newParams.set("query", keyword);
+ newParams.set("page", "1");
+ window.location.href = `/?${newParams.toString()}`;
+ }}
+ />
+ ))}
+
+
+ )}
+
{data.repository && (
diff --git a/app/src/features/detail/hooks/usePackageDetail.ts b/app/src/features/detail/hooks/usePackageDetail.ts
index 00358ec..5b411f7 100644
--- a/app/src/features/detail/hooks/usePackageDetail.ts
+++ b/app/src/features/detail/hooks/usePackageDetail.ts
@@ -20,6 +20,8 @@ export interface FullPackage {
urls: string[];
readme: string | null;
license: string | null;
+ categories: string[];
+ keywords: string[];
}
const usePackageDetail = (packageName: string, version?: string) => {
diff --git a/app/src/pages/CategoryPackages.tsx b/app/src/pages/CategoryPackages.tsx
new file mode 100644
index 0000000..df3c569
--- /dev/null
+++ b/app/src/pages/CategoryPackages.tsx
@@ -0,0 +1,322 @@
+"use client";
+
+import React, { Suspense, useEffect, useState, useRef } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import {
+ Box,
+ Card,
+ CardContent,
+ Typography,
+ CircularProgress,
+ Pagination,
+ Chip,
+} from "@mui/material";
+import { PackagePreview } from "../utils/http";
+import { formatDate } from "../utils/date";
+import { SERVER_URI } from "../constants";
+import NextLink from "next/link";
+
+const PER_PAGE = 10;
+
+interface CategoryPackagesProps {
+ categoryName: string;
+}
+
+function CategoryPackages({ categoryName }: CategoryPackagesProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [totalPages, setTotalPages] = useState(1);
+ const [totalCount, setTotalCount] = useState(0);
+ const abortControllerRef = useRef();
+
+ const currentPage = parseInt(searchParams.get("page") || "1", 10);
+
+ useEffect(() => {
+ if (!categoryName) {
+ setResults([]);
+ setTotalPages(1);
+ setTotalCount(0);
+ setError(null);
+ setLoading(false);
+ abortControllerRef.current?.abort();
+ return;
+ }
+
+ // Cancel previous request
+ abortControllerRef.current?.abort();
+ abortControllerRef.current = new AbortController();
+
+ setLoading(true);
+ setError(null);
+
+ fetch(`${SERVER_URI}/packages/category/${encodeURIComponent(categoryName)}?page=${currentPage}&per_page=${PER_PAGE}`, {
+ signal: abortControllerRef.current?.signal,
+ credentials: 'include',
+ })
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error("Failed to fetch packages");
+ }
+ return response.json();
+ })
+ .then((data) => {
+ setResults(data.data);
+ setTotalPages(data.totalPages);
+ setTotalCount(data.totalCount);
+ })
+ .catch((err) => {
+ if (err.name !== "AbortError") {
+ setError("Failed to fetch packages for this category");
+ console.error("Category packages error:", err);
+ }
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+
+ return () => {
+ abortControllerRef.current?.abort();
+ };
+ }, [categoryName, currentPage]);
+
+ const handlePageChange = (_: React.ChangeEvent, page: number) => {
+ const newParams = new URLSearchParams(searchParams);
+ newParams.set("page", page.toString());
+ router.replace(`/packages/category/${encodeURIComponent(categoryName)}?${newParams.toString()}`);
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ };
+
+ const containerStyles = {
+ maxWidth: "1200px",
+ mx: "auto",
+ px: { xs: 1, sm: 2, md: 3 },
+ width: "100%",
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+ Loading packages for category "{categoryName}"...
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ Error Loading Packages
+
+ {error}
+
+ Please try again or contact support if the problem persists.
+
+
+ );
+ }
+
+ if (results.length === 0) {
+ return (
+
+
+ No packages found
+
+
+ No packages found for category "{categoryName}".
+
+
+ );
+ }
+
+ return (
+
+
+
+ Packages in Category: {categoryName}
+
+
+ Found {totalCount} package{totalCount === 1 ? "" : "s"} in category "{categoryName}"
+
+
+
+
+ {results.map((result) => (
+
+
+
+
+
+
+ {result.name}
+
+
+ v{result.version}
+
+
+
+
+
+ {result.description || "No description available"}
+
+
+ {/* Categories and Keywords Tags */}
+ {(result.categories?.length > 0 || result.keywords?.length > 0) && (
+
+ {result.categories?.map((category) => (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ // Navigate to category filter
+ window.location.href = `/packages/category/${encodeURIComponent(category)}`;
+ }}
+ />
+ ))}
+ {result.keywords?.map((keyword) => (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ // Navigate to keyword search
+ const newParams = new URLSearchParams();
+ newParams.set("query", keyword);
+ newParams.set("page", "1");
+ window.location.href = `/?${newParams.toString()}`;
+ }}
+ />
+ ))}
+
+ )}
+
+
+
+ Updated {formatDate(result.updatedAt)}
+
+
+
+
+
+ ))}
+
+
+ {totalPages > 1 && (
+
+
+
+ )}
+
+ );
+}
+
+export default function CategoryPackagesWrapper({ categoryName }: CategoryPackagesProps) {
+ return (
+ Loading category packages... }>
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/src/pages/SearchResults.tsx b/app/src/pages/SearchResults.tsx
index 07fcb1c..4536294 100644
--- a/app/src/pages/SearchResults.tsx
+++ b/app/src/pages/SearchResults.tsx
@@ -9,6 +9,7 @@ import {
Typography,
CircularProgress,
Pagination,
+ Chip,
} from "@mui/material";
import HTTP, { PackagePreview } from "../utils/http";
import { formatDate } from "../utils/date";
@@ -214,12 +215,74 @@ function SearchResults() {
-
- {result.description || "No description available"}
-
+
+
+ {result.description || "No description available"}
+
+
+ {/* Categories and Keywords Tags */}
+ {(result.categories?.length > 0 || result.keywords?.length > 0) && (
+
+ {result.categories?.map((category) => (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ // Navigate to category filter
+ const newParams = new URLSearchParams();
+ newParams.set("category", category);
+ newParams.set("page", "1");
+ window.location.href = `/packages/category/${encodeURIComponent(category)}`;
+ }}
+ />
+ ))}
+ {result.keywords?.map((keyword) => (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ // Navigate to keyword search
+ const newParams = new URLSearchParams();
+ newParams.set("query", keyword);
+ newParams.set("page", "1");
+ window.location.href = `/?${newParams.toString()}`;
+ }}
+ />
+ ))}
+
+ )}
+
,
pub readme: Option,
pub license: Option,
+ pub categories: Vec,
+ pub keywords: Vec,
}
-impl From for FullPackage {
- fn from(full_package: crate::models::FullPackage) -> Self {
+impl From for FullPackage {
+ fn from(full_package_with_categories: crate::models::FullPackageWithCategories) -> Self {
+ let full_package = full_package_with_categories.package;
fn string_to_url(s: String) -> Option {
Url::parse(&s).ok()
}
@@ -66,6 +69,8 @@ impl From for FullPackage {
.collect(),
license: full_package.license,
readme: full_package.readme,
+ categories: full_package_with_categories.categories,
+ keywords: full_package_with_categories.keywords,
}
}
}
diff --git a/src/db/package_category_keyword.rs b/src/db/package_category_keyword.rs
index 830d144..7dc7a1b 100644
--- a/src/db/package_category_keyword.rs
+++ b/src/db/package_category_keyword.rs
@@ -1,6 +1,6 @@
use super::error::DatabaseError;
use super::{schema, DbConn};
-use crate::models::{NewPackageCategory, NewPackageKeyword};
+use crate::models::{NewPackageCategory, NewPackageKeyword, PackageCategory, PackageKeyword};
use diesel::prelude::*;
use uuid::Uuid;
@@ -42,4 +42,56 @@ impl DbConn<'_> {
.execute(self.inner())
.map_err(DatabaseError::InsertPackageKeywordsFailed)
}
+
+ /// Retrieve all categories for a package by package ID.
+ pub fn get_categories_for_package(
+ &mut self,
+ package_id: Uuid,
+ ) -> Result, DatabaseError> {
+ schema::package_categories::table
+ .filter(schema::package_categories::package_id.eq(package_id))
+ .order_by(schema::package_categories::category.asc())
+ .select(PackageCategory::as_select())
+ .load(self.inner())
+ .map_err(|err| DatabaseError::QueryFailed("get categories for package".to_string(), err))
+ }
+
+ /// Retrieve all keywords for a package by package ID.
+ pub fn get_keywords_for_package(
+ &mut self,
+ package_id: Uuid,
+ ) -> Result, DatabaseError> {
+ schema::package_keywords::table
+ .filter(schema::package_keywords::package_id.eq(package_id))
+ .order_by(schema::package_keywords::keyword.asc())
+ .select(PackageKeyword::as_select())
+ .load(self.inner())
+ .map_err(|err| DatabaseError::QueryFailed("get keywords for package".to_string(), err))
+ }
+
+ /// Retrieve categories for multiple packages by package IDs.
+ pub fn get_categories_for_packages(
+ &mut self,
+ package_ids: &[Uuid],
+ ) -> Result, DatabaseError> {
+ schema::package_categories::table
+ .filter(schema::package_categories::package_id.eq_any(package_ids))
+ .order_by((schema::package_categories::package_id.asc(), schema::package_categories::category.asc()))
+ .select(PackageCategory::as_select())
+ .load(self.inner())
+ .map_err(|err| DatabaseError::QueryFailed("get categories for packages".to_string(), err))
+ }
+
+ /// Retrieve keywords for multiple packages by package IDs.
+ pub fn get_keywords_for_packages(
+ &mut self,
+ package_ids: &[Uuid],
+ ) -> Result, DatabaseError> {
+ schema::package_keywords::table
+ .filter(schema::package_keywords::package_id.eq_any(package_ids))
+ .order_by((schema::package_keywords::package_id.asc(), schema::package_keywords::keyword.asc()))
+ .select(PackageKeyword::as_select())
+ .load(self.inner())
+ .map_err(|err| DatabaseError::QueryFailed("get keywords for packages".to_string(), err))
+ }
}
diff --git a/src/db/package_version.rs b/src/db/package_version.rs
index 4eaa6a6..ca7c244 100644
--- a/src/db/package_version.rs
+++ b/src/db/package_version.rs
@@ -3,7 +3,7 @@ use super::{models, schema, DbConn};
use crate::api::pagination::{PaginatedResponse, Pagination};
use crate::handlers::publish::PublishInfo;
use crate::models::{
- ApiToken, AuthorInfo, CountResult, FullPackage, PackagePreview, PackageVersionInfo,
+ ApiToken, AuthorInfo, CountResult, FullPackage, FullPackageWithCategories, PackagePreview, PackagePreviewWithCategories, PackageVersionInfo,
};
use chrono::{DateTime, Utc};
use diesel::prelude::*;
@@ -368,7 +368,7 @@ impl DbConn<'_> {
.collect())
}
- /// Search for packages by name or description using fuzzy search.
+ /// Search for packages by name, description, categories, or keywords using fuzzy search.
pub fn search_packages(
&mut self,
query: String,
@@ -386,21 +386,44 @@ impl DbConn<'_> {
p.created_at AS created_at,
pv.created_at AS updated_at,
ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pv.created_at DESC) AS rank,
- -- Combined relevance scoring
+ -- Combined relevance scoring including categories and keywords
GREATEST(
similarity($1, LOWER(p.package_name)),
CASE
WHEN LOWER(p.package_name) ILIKE '%' || $1 || '%' THEN 0.7
ELSE 0.0
END,
- similarity($1, LOWER(COALESCE(pv.package_description, ''))) * 0.3
+ similarity($1, LOWER(COALESCE(pv.package_description, ''))) * 0.3,
+ -- Category matches get high relevance
+ COALESCE(MAX(
+ CASE
+ WHEN LOWER(pc.category) ILIKE '%' || $1 || '%' THEN 0.8
+ WHEN similarity($1, LOWER(pc.category)) > 0.3 THEN 0.6
+ ELSE 0.0
+ END
+ ), 0.0),
+ -- Keyword matches get medium relevance
+ COALESCE(MAX(
+ CASE
+ WHEN LOWER(pk.keyword) ILIKE '%' || $1 || '%' THEN 0.7
+ WHEN similarity($1, LOWER(pk.keyword)) > 0.3 THEN 0.5
+ ELSE 0.0
+ END
+ ), 0.0)
) AS relevance_score
FROM package_versions pv
JOIN packages p ON pv.package_id = p.id
+ LEFT JOIN package_categories pc ON p.id = pc.package_id
+ LEFT JOIN package_keywords pk ON p.id = pk.package_id
WHERE
LOWER(p.package_name) ILIKE '%' || $1 || '%' OR
similarity($1, LOWER(p.package_name)) > 0.2 OR
- similarity($1, LOWER(COALESCE(pv.package_description, ''))) > 0.1
+ similarity($1, LOWER(COALESCE(pv.package_description, ''))) > 0.1 OR
+ LOWER(pc.category) ILIKE '%' || $1 || '%' OR
+ similarity($1, LOWER(pc.category)) > 0.3 OR
+ LOWER(pk.keyword) ILIKE '%' || $1 || '%' OR
+ similarity($1, LOWER(pk.keyword)) > 0.3
+ GROUP BY p.id, p.package_name, pv.id, pv.num, pv.package_description, p.created_at, pv.created_at
)
SELECT
name,
@@ -423,15 +446,21 @@ impl DbConn<'_> {
DatabaseError::QueryFailed("search packages".to_string(), err)
})?;
- // Count total matches
+ // Count total matches including categories and keywords
let total = diesel::sql_query(
r#"SELECT COUNT(DISTINCT p.id) AS count
FROM packages p
JOIN package_versions pv ON pv.package_id = p.id
+ LEFT JOIN package_categories pc ON p.id = pc.package_id
+ LEFT JOIN package_keywords pk ON p.id = pk.package_id
WHERE
LOWER(p.package_name) ILIKE '%' || $1 || '%' OR
similarity($1, LOWER(p.package_name)) > 0.2 OR
- similarity($1, LOWER(COALESCE(pv.package_description, ''))) > 0.1
+ similarity($1, LOWER(COALESCE(pv.package_description, ''))) > 0.1 OR
+ LOWER(pc.category) ILIKE '%' || $1 || '%' OR
+ similarity($1, LOWER(pc.category)) > 0.3 OR
+ LOWER(pk.keyword) ILIKE '%' || $1 || '%' OR
+ similarity($1, LOWER(pk.keyword)) > 0.3
"#,
)
.bind::(query_lower)
@@ -447,4 +476,218 @@ impl DbConn<'_> {
per_page: pagination.limit(),
})
}
+
+ /// Search for packages with categories and keywords included in the response.
+ pub fn search_packages_with_categories(
+ &mut self,
+ query: String,
+ pagination: Pagination,
+ ) -> Result, DatabaseError> {
+ // First get the basic search results
+ let basic_results = self.search_packages(query, pagination)?;
+
+ // Extract package names to get package IDs for categories/keywords lookup
+ let package_names: Vec = basic_results.data.iter().map(|p| p.name.clone()).collect();
+
+ // Get package IDs for these packages
+ let package_ids: Vec = schema::packages::table
+ .filter(schema::packages::package_name.eq_any(&package_names))
+ .select(schema::packages::id)
+ .load::(self.inner())
+ .map_err(|err| DatabaseError::QueryFailed("get package ids".to_string(), err))?;
+
+ // Get categories and keywords for these packages
+ let categories = self.get_categories_for_packages(&package_ids)?;
+ let keywords = self.get_keywords_for_packages(&package_ids)?;
+
+ // Create a map of package_id -> package_name for quick lookup
+ let package_id_to_name: std::collections::HashMap = schema::packages::table
+ .filter(schema::packages::package_name.eq_any(&package_names))
+ .select((schema::packages::id, schema::packages::package_name))
+ .load::<(Uuid, String)>(self.inner())
+ .map_err(|err| DatabaseError::QueryFailed("get package id to name mapping".to_string(), err))?
+ .into_iter()
+ .collect();
+
+ // Create maps for quick lookup
+ let mut package_categories: std::collections::HashMap> = std::collections::HashMap::new();
+ let mut package_keywords: std::collections::HashMap> = std::collections::HashMap::new();
+
+ for category in categories {
+ if let Some(package_name) = package_id_to_name.get(&category.package_id) {
+ package_categories.entry(package_name.clone()).or_default().push(category.category);
+ }
+ }
+
+ for keyword in keywords {
+ if let Some(package_name) = package_id_to_name.get(&keyword.package_id) {
+ package_keywords.entry(package_name.clone()).or_default().push(keyword.keyword);
+ }
+ }
+
+ // Combine results
+ let enhanced_results: Vec = basic_results
+ .data
+ .into_iter()
+ .map(|package| PackagePreviewWithCategories {
+ categories: package_categories.get(&package.name).cloned().unwrap_or_default(),
+ keywords: package_keywords.get(&package.name).cloned().unwrap_or_default(),
+ package,
+ })
+ .collect();
+
+ Ok(PaginatedResponse {
+ data: enhanced_results,
+ total_count: basic_results.total_count,
+ total_pages: basic_results.total_pages,
+ current_page: basic_results.current_page,
+ per_page: basic_results.per_page,
+ })
+ }
+
+ /// Filter packages by category.
+ pub fn filter_packages_by_category(
+ &mut self,
+ category: String,
+ pagination: Pagination,
+ ) -> Result, DatabaseError> {
+ let category_lower = category.to_lowercase();
+
+ let packages = diesel::sql_query(
+ r#"WITH ranked_versions AS (
+ SELECT
+ p.id AS package_id,
+ p.package_name AS name,
+ pv.num AS version,
+ pv.package_description AS description,
+ p.created_at AS created_at,
+ pv.created_at AS updated_at,
+ ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pv.created_at DESC) AS rank
+ FROM package_versions pv
+ JOIN packages p ON pv.package_id = p.id
+ JOIN package_categories pc ON p.id = pc.package_id
+ WHERE
+ LOWER(pc.category) = $1 OR
+ LOWER(pc.category) ILIKE '%' || $1 || '%'
+ )
+ SELECT
+ name,
+ version,
+ description,
+ created_at,
+ updated_at
+ FROM ranked_versions
+ WHERE rank = 1
+ ORDER BY created_at DESC
+ OFFSET $2
+ LIMIT $3;
+ "#,
+ )
+ .bind::(category_lower.clone())
+ .bind::(pagination.offset())
+ .bind::(pagination.limit())
+ .load::(self.inner())
+ .map_err(|err: diesel::result::Error| {
+ DatabaseError::QueryFailed("filter packages by category".to_string(), err)
+ })?;
+
+ // Count total matches
+ let total = diesel::sql_query(
+ r#"SELECT COUNT(DISTINCT p.id) AS count
+ FROM packages p
+ JOIN package_categories pc ON p.id = pc.package_id
+ WHERE
+ LOWER(pc.category) = $1 OR
+ LOWER(pc.category) ILIKE '%' || $1 || '%'
+ "#,
+ )
+ .bind::(category_lower)
+ .get_result::(self.inner())
+ .map_err(|err| DatabaseError::QueryFailed("count category filter".to_string(), err))?
+ .count;
+
+ // Extract package names to get package IDs for categories/keywords lookup
+ let package_names: Vec = packages.iter().map(|p| p.name.clone()).collect();
+
+ // Get package IDs for these packages
+ let package_ids: Vec = schema::packages::table
+ .filter(schema::packages::package_name.eq_any(&package_names))
+ .select(schema::packages::id)
+ .load::(self.inner())
+ .map_err(|err| DatabaseError::QueryFailed("get package ids for category filter".to_string(), err))?;
+
+ // Get categories and keywords for these packages
+ let categories = self.get_categories_for_packages(&package_ids)?;
+ let keywords = self.get_keywords_for_packages(&package_ids)?;
+
+ // Create a map of package_id -> package_name for quick lookup
+ let package_id_to_name: std::collections::HashMap = schema::packages::table
+ .filter(schema::packages::package_name.eq_any(&package_names))
+ .select((schema::packages::id, schema::packages::package_name))
+ .load::<(Uuid, String)>(self.inner())
+ .map_err(|err| DatabaseError::QueryFailed("get package id to name mapping for category filter".to_string(), err))?
+ .into_iter()
+ .collect();
+
+ // Create maps for quick lookup
+ let mut package_categories: std::collections::HashMap> = std::collections::HashMap::new();
+ let mut package_keywords: std::collections::HashMap> = std::collections::HashMap::new();
+
+ for category in categories {
+ if let Some(package_name) = package_id_to_name.get(&category.package_id) {
+ package_categories.entry(package_name.clone()).or_default().push(category.category);
+ }
+ }
+
+ for keyword in keywords {
+ if let Some(package_name) = package_id_to_name.get(&keyword.package_id) {
+ package_keywords.entry(package_name.clone()).or_default().push(keyword.keyword);
+ }
+ }
+
+ // Combine results
+ let enhanced_results: Vec = packages
+ .into_iter()
+ .map(|package| PackagePreviewWithCategories {
+ categories: package_categories.get(&package.name).cloned().unwrap_or_default(),
+ keywords: package_keywords.get(&package.name).cloned().unwrap_or_default(),
+ package,
+ })
+ .collect();
+
+ Ok(PaginatedResponse {
+ data: enhanced_results,
+ total_count: total,
+ total_pages: ((total as f64) / (pagination.limit() as f64)).ceil() as i64,
+ current_page: pagination.page(),
+ per_page: pagination.limit(),
+ })
+ }
+
+ /// Get a full package with categories and keywords by name and version.
+ pub fn get_full_package_with_categories(
+ &mut self,
+ pkg_name: String,
+ version: String,
+ ) -> Result {
+ // First get the basic package info
+ let package = self.get_full_package_version(pkg_name, version)?;
+
+ // Get package ID for this package
+ let package_id: Uuid = schema::packages::table
+ .filter(schema::packages::package_name.eq(&package.name))
+ .select(schema::packages::id)
+ .first(self.inner())
+ .map_err(|err| DatabaseError::QueryFailed("get package id for full package".to_string(), err))?;
+
+ // Get categories and keywords for this package
+ let categories = self.get_categories_for_package(package_id)?;
+ let keywords = self.get_keywords_for_package(package_id)?;
+
+ Ok(FullPackageWithCategories {
+ package,
+ categories: categories.into_iter().map(|c| c.category).collect(),
+ keywords: keywords.into_iter().map(|k| k.keyword).collect(),
+ })
+ }
}
diff --git a/src/main.rs b/src/main.rs
index 09f2af5..99455db 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -26,7 +26,7 @@ use forc_pub::handlers::upload::{handle_project_upload, install_forc_at_path, Up
use forc_pub::middleware::cors::Cors;
use forc_pub::middleware::session_auth::{SessionAuth, SESSION_COOKIE_NAME};
use forc_pub::middleware::token_auth::TokenAuth;
-use forc_pub::models::{PackagePreview, PackageVersionInfo};
+use forc_pub::models::{FullPackageWithCategories, PackagePreviewWithCategories, PackageVersionInfo};
use forc_pub::util::{load_env, validate_or_format_semver};
use rocket::http::Status;
use rocket::tokio::task;
@@ -277,7 +277,17 @@ fn packages(
let updated_after = updated_after.and_then(|date_str| DateTime::::from_str(date_str).ok());
let db_data =
db.transaction(|conn| conn.get_full_packages(updated_after, pagination.clone()))?;
- let data = db_data.data.into_iter().map(FullPackage::from).collect();
+
+ // For now, convert to FullPackageWithCategories with empty categories/keywords
+ // This endpoint could be enhanced later to include categories if needed
+ let data = db_data.data.into_iter().map(|pkg| {
+ let full_package_with_categories = FullPackageWithCategories {
+ package: pkg,
+ categories: vec![], // Empty for now
+ keywords: vec![], // Empty for now
+ };
+ FullPackage::from(full_package_with_categories)
+ }).collect();
Ok(Json(PaginatedResponse {
data,
@@ -291,7 +301,7 @@ fn packages(
#[get("/package?&")]
fn package(db: &State, name: String, version: Option) -> ApiResult {
let db_data =
- db.transaction(|conn| conn.get_full_package_version(name, version.unwrap_or_default()))?;
+ db.transaction(|conn| conn.get_full_package_with_categories(name, version.unwrap_or_default()))?;
Ok(Json(FullPackage::from(db_data)))
}
@@ -345,7 +355,7 @@ fn search(
db: &State,
query: String,
pagination: Pagination,
-) -> ApiResult> {
+) -> ApiResult> {
if query.trim().is_empty() || query.len() > 100 {
return Err(ApiError::Generic(
"Invalid query parameter".into(),
@@ -353,7 +363,24 @@ fn search(
));
}
- let result = db.transaction(|conn| conn.search_packages(query, pagination))?;
+ let result = db.transaction(|conn| conn.search_packages_with_categories(query, pagination))?;
+ Ok(Json(result))
+}
+
+#[get("/packages/category/?")]
+fn packages_by_category(
+ db: &State,
+ category: String,
+ pagination: Pagination,
+) -> ApiResult> {
+ if category.trim().is_empty() || category.len() > 50 {
+ return Err(ApiError::Generic(
+ "Invalid category parameter".into(),
+ Status::BadRequest,
+ ));
+ }
+
+ let result = db.transaction(|conn| conn.filter_packages_by_category(category, pagination))?;
Ok(Json(result))
}
@@ -394,6 +421,7 @@ async fn rocket() -> _ {
package_versions,
recent_packages,
search,
+ packages_by_category,
all_options,
health
],
diff --git a/src/models.rs b/src/models.rs
index ccbee20..23a98a0 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -201,7 +201,7 @@ pub struct NewPackageKeyword {
pub keyword: String,
}
-#[derive(QueryableByName, Serialize, Debug)]
+#[derive(QueryableByName, Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PackagePreview {
#[diesel(sql_type = Text)]
@@ -216,6 +216,15 @@ pub struct PackagePreview {
pub updated_at: DateTime,
}
+#[derive(Serialize, Debug, Clone)]
+#[serde(rename_all = "camelCase")]
+pub struct PackagePreviewWithCategories {
+ #[serde(flatten)]
+ pub package: PackagePreview,
+ pub categories: Vec,
+ pub keywords: Vec,
+}
+
#[derive(QueryableByName, Serialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct FullPackage {
@@ -258,6 +267,15 @@ pub struct FullPackage {
pub license: Option,
}
+#[derive(Serialize, Debug, Clone)]
+#[serde(rename_all = "camelCase")]
+pub struct FullPackageWithCategories {
+ #[serde(flatten)]
+ pub package: FullPackage,
+ pub categories: Vec,
+ pub keywords: Vec,
+}
+
#[derive(QueryableByName)]
pub struct CountResult {
#[diesel(sql_type = BigInt)]
diff --git a/tests/db_integration.rs b/tests/db_integration.rs
index 6c978e5..e0f60b6 100644
--- a/tests/db_integration.rs
+++ b/tests/db_integration.rs
@@ -45,6 +45,8 @@ fn setup_db() -> Database {
fn clear_tables(db: &mut Database) {
db.transaction(|conn| {
+ diesel::delete(forc_pub::schema::package_categories::table).execute(conn.inner())?;
+ diesel::delete(forc_pub::schema::package_keywords::table).execute(conn.inner())?;
diesel::delete(forc_pub::schema::package_versions::table).execute(conn.inner())?;
diesel::delete(forc_pub::schema::packages::table).execute(conn.inner())?;
diesel::delete(forc_pub::schema::api_tokens::table).execute(conn.inner())?;
@@ -421,3 +423,520 @@ fn test_package_categories_keywords() {
Ok::<(), diesel::result::Error>(())
});
}
+
+#[test]
+#[serial]
+fn test_search_with_categories() {
+ let db = &mut setup_db();
+ let _ = db.transaction(|conn| {
+ // Set up session, user, token, and upload.
+ let session = conn
+ .new_user_session(&mock_user_1(), 1000)
+ .expect("session is ok");
+ let user = conn.get_user_for_session(session.id).expect("user is ok");
+ let (token, _) = conn
+ .new_token(user.id, "test token".to_string())
+ .expect("token is ok");
+ let upload = conn
+ .new_upload(&NewUpload {
+ id: uuid::Uuid::new_v4(),
+ forc_version: TEST_VERSION_1.into(),
+ source_code_ipfs_hash: "test-ipfs-hash".into(),
+ abi_ipfs_hash: None,
+ bytecode_identifier: None,
+ readme: None,
+ forc_manifest: TEST_MANIFEST.into(),
+ })
+ .expect("upload is ok");
+
+ // Create a package with categories and keywords
+ let request = PublishInfo {
+ package_name: "web3-utils".into(),
+ upload_id: upload.id,
+ num: Version::parse(TEST_VERSION_1).unwrap(),
+ package_description: Some("Web3 utilities for blockchain development".into()),
+ repository: None,
+ documentation: None,
+ homepage: None,
+ urls: vec![],
+ readme: None,
+ license: None,
+ };
+
+ let version_result = conn
+ .new_package_version(&token, &request)
+ .expect("version result is ok");
+
+ // Insert categories
+ let categories = vec!["blockchain".to_string(), "web3".to_string()];
+ conn.insert_categories(version_result.package_id, &categories)
+ .expect("insert categories is ok");
+
+ // Insert keywords
+ let keywords = vec!["ethereum".to_string(), "smart-contracts".to_string()];
+ conn.insert_keywords(version_result.package_id, &keywords)
+ .expect("insert keywords is ok");
+
+ // Test search by category
+ let search_result = conn
+ .search_packages_with_categories(
+ "blockchain".to_string(),
+ Pagination {
+ page: Some(1),
+ per_page: Some(10),
+ },
+ )
+ .expect("search by category is ok");
+
+ assert_eq!(search_result.data.len(), 1);
+ assert_eq!(search_result.data[0].package.name, "web3-utils");
+ assert_eq!(search_result.data[0].categories.len(), 2);
+ assert!(search_result.data[0].categories.contains(&"blockchain".to_string()));
+ assert!(search_result.data[0].categories.contains(&"web3".to_string()));
+
+ // Test search by keyword
+ let search_result = conn
+ .search_packages_with_categories(
+ "ethereum".to_string(),
+ Pagination {
+ page: Some(1),
+ per_page: Some(10),
+ },
+ )
+ .expect("search by keyword is ok");
+
+ assert_eq!(search_result.data.len(), 1);
+ assert_eq!(search_result.data[0].package.name, "web3-utils");
+ assert_eq!(search_result.data[0].keywords.len(), 2);
+ assert!(search_result.data[0].keywords.contains(&"ethereum".to_string()));
+ assert!(search_result.data[0].keywords.contains(&"smart-contracts".to_string()));
+
+ // Test search by package name (should still work)
+ let search_result = conn
+ .search_packages_with_categories(
+ "web3".to_string(),
+ Pagination {
+ page: Some(1),
+ per_page: Some(10),
+ },
+ )
+ .expect("search by name is ok");
+
+ assert_eq!(search_result.data.len(), 1);
+ assert_eq!(search_result.data[0].package.name, "web3-utils");
+
+ Ok::<(), diesel::result::Error>(())
+ });
+}
+
+#[test]
+#[serial]
+fn test_filter_by_category() {
+ let db = &mut setup_db();
+ let _ = db.transaction(|conn| {
+ // Set up session, user, token, and upload.
+ let session = conn
+ .new_user_session(&mock_user_1(), 1000)
+ .expect("session is ok");
+ let user = conn.get_user_for_session(session.id).expect("user is ok");
+ let (token, _) = conn
+ .new_token(user.id, "test token".to_string())
+ .expect("token is ok");
+ let upload = conn
+ .new_upload(&NewUpload {
+ id: uuid::Uuid::new_v4(),
+ forc_version: TEST_VERSION_1.into(),
+ source_code_ipfs_hash: "test-ipfs-hash".into(),
+ abi_ipfs_hash: None,
+ bytecode_identifier: None,
+ readme: None,
+ forc_manifest: TEST_MANIFEST.into(),
+ })
+ .expect("upload is ok");
+
+ // Create first package with "defi" category
+ let request1 = PublishInfo {
+ package_name: "defi-lib".into(),
+ upload_id: upload.id,
+ num: Version::parse(TEST_VERSION_1).unwrap(),
+ package_description: Some("DeFi utilities".into()),
+ repository: None,
+ documentation: None,
+ homepage: None,
+ urls: vec![],
+ readme: None,
+ license: None,
+ };
+
+ let version_result1 = conn
+ .new_package_version(&token, &request1)
+ .expect("version result is ok");
+
+ conn.insert_categories(version_result1.package_id, &vec!["defi".to_string()])
+ .expect("insert categories is ok");
+
+ // Create second package with "gaming" category
+ let request2 = PublishInfo {
+ package_name: "game-engine".into(),
+ upload_id: upload.id,
+ num: Version::parse(TEST_VERSION_1).unwrap(),
+ package_description: Some("Gaming utilities".into()),
+ repository: None,
+ documentation: None,
+ homepage: None,
+ urls: vec![],
+ readme: None,
+ license: None,
+ };
+
+ let version_result2 = conn
+ .new_package_version(&token, &request2)
+ .expect("version result is ok");
+
+ conn.insert_categories(version_result2.package_id, &vec!["gaming".to_string()])
+ .expect("insert categories is ok");
+
+ // Test filter by "defi" category
+ let filter_result = conn
+ .filter_packages_by_category(
+ "defi".to_string(),
+ Pagination {
+ page: Some(1),
+ per_page: Some(10),
+ },
+ )
+ .expect("filter by defi category is ok");
+
+ assert_eq!(filter_result.data.len(), 1);
+ assert_eq!(filter_result.data[0].package.name, "defi-lib");
+ assert!(filter_result.data[0].categories.contains(&"defi".to_string()));
+
+ // Test filter by "gaming" category
+ let filter_result = conn
+ .filter_packages_by_category(
+ "gaming".to_string(),
+ Pagination {
+ page: Some(1),
+ per_page: Some(10),
+ },
+ )
+ .expect("filter by gaming category is ok");
+
+ assert_eq!(filter_result.data.len(), 1);
+ assert_eq!(filter_result.data[0].package.name, "game-engine");
+ assert!(filter_result.data[0].categories.contains(&"gaming".to_string()));
+
+ // Test filter by non-existent category
+ let filter_result = conn
+ .filter_packages_by_category(
+ "nonexistent".to_string(),
+ Pagination {
+ page: Some(1),
+ per_page: Some(10),
+ },
+ )
+ .expect("filter by nonexistent category is ok");
+
+ assert_eq!(filter_result.data.len(), 0);
+
+ Ok::<(), diesel::result::Error>(())
+ });
+}
+
+#[test]
+#[serial]
+fn test_get_full_package_with_categories() {
+ let db = &mut setup_db();
+ let _ = db.transaction(|conn| {
+ // Set up session, user, token, and upload.
+ let session = conn
+ .new_user_session(&mock_user_1(), 1000)
+ .expect("session is ok");
+ let user = conn.get_user_for_session(session.id).expect("user is ok");
+ let (token, _) = conn
+ .new_token(user.id, "test token".to_string())
+ .expect("token is ok");
+ let upload = conn
+ .new_upload(&NewUpload {
+ id: uuid::Uuid::new_v4(),
+ forc_version: TEST_VERSION_1.into(),
+ source_code_ipfs_hash: "test-ipfs-hash".into(),
+ abi_ipfs_hash: None,
+ bytecode_identifier: None,
+ readme: Some("Test README".into()),
+ forc_manifest: TEST_MANIFEST.into(),
+ })
+ .expect("upload is ok");
+
+ let request = PublishInfo {
+ package_name: "full-test-package".into(),
+ upload_id: upload.id,
+ num: Version::parse(TEST_VERSION_1).unwrap(),
+ package_description: Some("Full test package description".into()),
+ repository: Url::parse(TEST_URL_REPO).ok(),
+ documentation: Url::parse(TEST_URL_DOC).ok(),
+ homepage: Url::parse(TEST_URL_HOME).ok(),
+ urls: vec![Url::parse(TEST_URL_OTHER).expect("other url")],
+ readme: Some("Test README".into()),
+ license: Some("MIT".into()),
+ };
+
+ let version_result = conn
+ .new_package_version(&token, &request)
+ .expect("version result is ok");
+
+ // Insert categories and keywords
+ let categories = vec!["utilities".to_string(), "testing".to_string()];
+ let keywords = vec!["mock".to_string(), "test".to_string(), "unit".to_string()];
+
+ conn.insert_categories(version_result.package_id, &categories)
+ .expect("insert categories is ok");
+ conn.insert_keywords(version_result.package_id, &keywords)
+ .expect("insert keywords is ok");
+
+ // Test get_full_package_with_categories
+ let full_package = conn
+ .get_full_package_with_categories("full-test-package".to_string(), TEST_VERSION_1.to_string())
+ .expect("get full package with categories is ok");
+
+ assert_eq!(full_package.package.name, "full-test-package");
+ assert_eq!(full_package.package.version, TEST_VERSION_1);
+ assert_eq!(full_package.package.description, Some("Full test package description".into()));
+ assert_eq!(full_package.package.license, Some("MIT".into()));
+
+ // Check categories
+ assert_eq!(full_package.categories.len(), 2);
+ assert!(full_package.categories.contains(&"utilities".to_string()));
+ assert!(full_package.categories.contains(&"testing".to_string()));
+
+ // Check keywords
+ assert_eq!(full_package.keywords.len(), 3);
+ assert!(full_package.keywords.contains(&"mock".to_string()));
+ assert!(full_package.keywords.contains(&"test".to_string()));
+ assert!(full_package.keywords.contains(&"unit".to_string()));
+
+ Ok::<(), diesel::result::Error>(())
+ });
+}
+
+#[test]
+#[serial]
+fn test_get_categories_and_keywords_for_packages() {
+ let db = &mut setup_db();
+ let _ = db.transaction(|conn| {
+ // Set up session, user, token, and upload.
+ let session = conn
+ .new_user_session(&mock_user_1(), 1000)
+ .expect("session is ok");
+ let user = conn.get_user_for_session(session.id).expect("user is ok");
+ let (token, _) = conn
+ .new_token(user.id, "test token".to_string())
+ .expect("token is ok");
+ let upload = conn
+ .new_upload(&NewUpload {
+ id: uuid::Uuid::new_v4(),
+ forc_version: TEST_VERSION_1.into(),
+ source_code_ipfs_hash: "test-ipfs-hash".into(),
+ abi_ipfs_hash: None,
+ bytecode_identifier: None,
+ readme: None,
+ forc_manifest: TEST_MANIFEST.into(),
+ })
+ .expect("upload is ok");
+
+ // Create two packages
+ let request1 = PublishInfo {
+ package_name: "package-one".into(),
+ upload_id: upload.id,
+ num: Version::parse(TEST_VERSION_1).unwrap(),
+ package_description: Some("First package".into()),
+ repository: None,
+ documentation: None,
+ homepage: None,
+ urls: vec![],
+ readme: None,
+ license: None,
+ };
+
+ let version_result1 = conn
+ .new_package_version(&token, &request1)
+ .expect("version result is ok");
+
+ let request2 = PublishInfo {
+ package_name: "package-two".into(),
+ upload_id: upload.id,
+ num: Version::parse(TEST_VERSION_1).unwrap(),
+ package_description: Some("Second package".into()),
+ repository: None,
+ documentation: None,
+ homepage: None,
+ urls: vec![],
+ readme: None,
+ license: None,
+ };
+
+ let version_result2 = conn
+ .new_package_version(&token, &request2)
+ .expect("version result is ok");
+
+ // Insert categories and keywords for both packages
+ conn.insert_categories(version_result1.package_id, &vec!["cat1".to_string(), "common".to_string()])
+ .expect("insert categories is ok");
+ conn.insert_keywords(version_result1.package_id, &vec!["key1".to_string(), "shared".to_string()])
+ .expect("insert keywords is ok");
+
+ conn.insert_categories(version_result2.package_id, &vec!["cat2".to_string(), "common".to_string()])
+ .expect("insert categories is ok");
+ conn.insert_keywords(version_result2.package_id, &vec!["key2".to_string(), "shared".to_string()])
+ .expect("insert keywords is ok");
+
+ // Test get_categories_for_packages
+ let categories = conn
+ .get_categories_for_packages(&vec![version_result1.package_id, version_result2.package_id])
+ .expect("get categories for packages is ok");
+
+ assert_eq!(categories.len(), 4); // 2 categories per package
+ let category_names: Vec = categories.iter().map(|c| c.category.clone()).collect();
+ assert!(category_names.contains(&"cat1".to_string()));
+ assert!(category_names.contains(&"cat2".to_string()));
+ assert!(category_names.contains(&"common".to_string()));
+
+ // Test get_keywords_for_packages
+ let keywords = conn
+ .get_keywords_for_packages(&vec![version_result1.package_id, version_result2.package_id])
+ .expect("get keywords for packages is ok");
+
+ assert_eq!(keywords.len(), 4); // 2 keywords per package
+ let keyword_names: Vec = keywords.iter().map(|k| k.keyword.clone()).collect();
+ assert!(keyword_names.contains(&"key1".to_string()));
+ assert!(keyword_names.contains(&"key2".to_string()));
+ assert!(keyword_names.contains(&"shared".to_string()));
+
+ Ok::<(), diesel::result::Error>(())
+ });
+}
+
+#[test]
+#[serial]
+fn test_keywords_specific_functionality() {
+ let db = &mut setup_db();
+ let _ = db.transaction(|conn| {
+ // Set up session, user, token, and upload.
+ let session = conn
+ .new_user_session(&mock_user_1(), 1000)
+ .expect("session is ok");
+ let user = conn.get_user_for_session(session.id).expect("user is ok");
+ let (token, _) = conn
+ .new_token(user.id, "test token".to_string())
+ .expect("token is ok");
+ let upload = conn
+ .new_upload(&NewUpload {
+ id: uuid::Uuid::new_v4(),
+ forc_version: TEST_VERSION_1.into(),
+ source_code_ipfs_hash: "test-ipfs-hash".into(),
+ abi_ipfs_hash: None,
+ bytecode_identifier: None,
+ readme: None,
+ forc_manifest: TEST_MANIFEST.into(),
+ })
+ .expect("upload is ok");
+
+ // Create package with ONLY keywords (no categories)
+ let request = PublishInfo {
+ package_name: "keyword-only-package".into(),
+ upload_id: upload.id,
+ num: Version::parse(TEST_VERSION_1).unwrap(),
+ package_description: Some("A package with only keywords for testing".into()),
+ repository: None,
+ documentation: None,
+ homepage: None,
+ urls: vec![],
+ readme: None,
+ license: None,
+ };
+
+ let version_result = conn
+ .new_package_version(&token, &request)
+ .expect("version result is ok");
+
+ // Insert ONLY keywords (no categories)
+ let keywords = vec![
+ "rust".to_string(),
+ "blockchain".to_string(),
+ "smart-contracts".to_string(),
+ "defi".to_string(),
+ "testing".to_string(),
+ ];
+ conn.insert_keywords(version_result.package_id, &keywords)
+ .expect("insert keywords is ok");
+
+ // Test 1: Search by specific keyword
+ let search_result = conn
+ .search_packages_with_categories(
+ "rust".to_string(),
+ Pagination {
+ page: Some(1),
+ per_page: Some(10),
+ },
+ )
+ .expect("search by rust keyword is ok");
+
+ assert_eq!(search_result.data.len(), 1);
+ assert_eq!(search_result.data[0].package.name, "keyword-only-package");
+ assert_eq!(search_result.data[0].categories.len(), 0); // No categories
+ assert_eq!(search_result.data[0].keywords.len(), 5); // All keywords
+ assert!(search_result.data[0].keywords.contains(&"rust".to_string()));
+ assert!(search_result.data[0].keywords.contains(&"blockchain".to_string()));
+ assert!(search_result.data[0].keywords.contains(&"smart-contracts".to_string()));
+
+ // Test 2: Search by compound keyword
+ let search_result = conn
+ .search_packages_with_categories(
+ "smart-contracts".to_string(),
+ Pagination {
+ page: Some(1),
+ per_page: Some(10),
+ },
+ )
+ .expect("search by smart-contracts keyword is ok");
+
+ assert_eq!(search_result.data.len(), 1);
+ assert_eq!(search_result.data[0].package.name, "keyword-only-package");
+ assert!(search_result.data[0].keywords.contains(&"smart-contracts".to_string()));
+
+ // Test 3: Search by partial keyword match
+ let search_result = conn
+ .search_packages_with_categories(
+ "contract".to_string(),
+ Pagination {
+ page: Some(1),
+ per_page: Some(10),
+ },
+ )
+ .expect("search by partial keyword is ok");
+
+ assert_eq!(search_result.data.len(), 1);
+ assert_eq!(search_result.data[0].package.name, "keyword-only-package");
+
+ // Test 4: Test get_keywords_for_package
+ let package_keywords = conn
+ .get_keywords_for_package(version_result.package_id)
+ .expect("get keywords for package is ok");
+
+ assert_eq!(package_keywords.len(), 5);
+ let keyword_names: Vec = package_keywords.iter().map(|k| k.keyword.clone()).collect();
+ assert!(keyword_names.contains(&"rust".to_string()));
+ assert!(keyword_names.contains(&"blockchain".to_string()));
+ assert!(keyword_names.contains(&"smart-contracts".to_string()));
+ assert!(keyword_names.contains(&"defi".to_string()));
+ assert!(keyword_names.contains(&"testing".to_string()));
+
+ // Test 5: Verify keywords are sorted alphabetically
+ let sorted_keywords: Vec = package_keywords.iter().map(|k| k.keyword.clone()).collect();
+ let mut expected_sorted = keywords.clone();
+ expected_sorted.sort();
+ assert_eq!(sorted_keywords, expected_sorted);
+
+ Ok::<(), diesel::result::Error>(())
+ });
+}