use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recommendation {
pub package_id: String,
pub confidence: f64,
pub reason: RecommendationReason,
pub quality_score: f64,
pub bundles: Vec<String>,
pub complements: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RecommendationReason {
SameBundleAffinity { bundle: String },
Complementary { package: String },
CategorySimilarity { category: String, avg_score: f64 },
PopularInDomain { domain: String },
Trending { improvement_rate: f64 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecommendationSet {
pub recommendations: Vec<Recommendation>,
pub installed: Vec<String>,
pub preferred_bundles: Vec<String>,
pub total_evaluated: usize,
pub quality_threshold: f64,
}
#[derive(Debug, Clone)]
pub struct RecommenderConfig {
pub min_quality_score: f64,
pub max_recommendations: usize,
pub production_ready_only: bool,
pub bundle_affinity_weight: f64,
pub complementarity_weight: f64,
pub quality_weight: f64,
pub popularity_weight: f64,
}
impl Default for RecommenderConfig {
fn default() -> Self {
Self {
min_quality_score: 75.0,
max_recommendations: 10,
production_ready_only: false,
bundle_affinity_weight: 30.0,
complementarity_weight: 25.0,
quality_weight: 30.0,
popularity_weight: 15.0,
}
}
}
#[derive(Debug, Clone)]
pub struct PackageInfo {
pub id: String,
pub score: f64,
pub production_ready: bool,
pub category: String,
pub bundles: Vec<String>,
pub related: Vec<String>,
pub downloads: usize,
pub tags: Vec<String>,
}
pub struct Recommender;
impl Recommender {
pub fn recommend(
config: &RecommenderConfig, installed: Vec<String>, preferred_bundles: Vec<String>,
all_packages: Vec<PackageInfo>,
) -> RecommendationSet {
let mut recommendations = Vec::new();
let candidates: Vec<_> = all_packages
.iter()
.filter(|pkg| {
!installed.contains(&pkg.id)
&& (!config.production_ready_only || pkg.production_ready)
&& pkg.score >= config.min_quality_score
})
.collect();
let mut scored: Vec<_> = candidates
.iter()
.map(|pkg| {
let confidence = Self::calculate_recommendation_score(
pkg,
&installed,
&preferred_bundles,
config,
);
(pkg, confidence)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
for (pkg, confidence) in scored.iter().take(config.max_recommendations) {
let reason = Self::determine_reason(pkg, &installed, &preferred_bundles);
recommendations.push(Recommendation {
package_id: pkg.id.clone(),
confidence: *confidence,
reason,
quality_score: pkg.score,
bundles: pkg.bundles.clone(),
complements: pkg
.related
.iter()
.filter(|r| installed.contains(r))
.cloned()
.collect(),
});
}
RecommendationSet {
recommendations,
installed: installed.clone(),
preferred_bundles,
total_evaluated: all_packages.len(),
quality_threshold: config.min_quality_score,
}
}
fn calculate_recommendation_score(
pkg: &PackageInfo, installed: &[String], preferred_bundles: &[String],
config: &RecommenderConfig,
) -> f64 {
let mut score = 0.0;
let bundle_matches = pkg
.bundles
.iter()
.filter(|b| preferred_bundles.contains(b))
.count();
if bundle_matches > 0 {
score += (bundle_matches as f64 / preferred_bundles.len().max(1) as f64)
* config.bundle_affinity_weight;
}
let complementary = pkg.related.iter().filter(|r| installed.contains(r)).count();
if complementary > 0 {
score += (complementary as f64 / pkg.related.len().max(1) as f64)
* config.complementarity_weight;
}
let quality_normalized = (pkg.score / 100.0).min(1.0);
score += quality_normalized * config.quality_weight;
let popularity_normalized = (pkg.downloads as f64 / 10000.0).min(1.0);
score += popularity_normalized * config.popularity_weight;
(score
/ (config.bundle_affinity_weight
+ config.complementarity_weight
+ config.quality_weight
+ config.popularity_weight))
* 100.0
}
fn determine_reason(
pkg: &PackageInfo, installed: &[String], preferred_bundles: &[String],
) -> RecommendationReason {
for bundle in &pkg.bundles {
if preferred_bundles.contains(bundle) {
return RecommendationReason::SameBundleAffinity {
bundle: bundle.clone(),
};
}
}
for related in &pkg.related {
if installed.contains(related) {
return RecommendationReason::Complementary {
package: related.clone(),
};
}
}
RecommendationReason::CategorySimilarity {
category: pkg.category.clone(),
avg_score: pkg.score,
}
}
pub fn get_complements(package_id: &str, all_packages: Vec<PackageInfo>) -> Vec<String> {
all_packages
.iter()
.find(|p| p.id == package_id)
.map(|p| p.related.clone())
.unwrap_or_default()
}
pub fn find_bundle_peers(package_id: &str, all_packages: Vec<PackageInfo>) -> Vec<String> {
let pkg = all_packages.iter().find(|p| p.id == package_id);
if let Some(pkg) = pkg {
all_packages
.iter()
.filter(|other| {
other.id != package_id && other.bundles.iter().any(|b| pkg.bundles.contains(b))
})
.map(|p| p.id.clone())
.collect()
} else {
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_recommendation_scoring() {
let config = RecommenderConfig::default();
let pkg = PackageInfo {
id: "test-pkg".to_string(),
score: 85.0,
production_ready: true,
category: "test".to_string(),
bundles: vec!["sector-enterprise-saas".to_string()],
related: vec!["crm-tool".to_string()],
downloads: 1000,
tags: vec!["enterprise".to_string()],
};
let installed = vec!["crm-tool".to_string()];
let bundles = vec!["sector-enterprise-saas".to_string()];
let score =
Recommender::calculate_recommendation_score(&pkg, &installed, &bundles, &config);
assert!(score > 0.0 && score <= 100.0);
}
#[test]
fn test_bundle_peers() {
let all_packages = vec![
PackageInfo {
id: "crm-system".to_string(),
score: 85.0,
production_ready: true,
category: "enterprise".to_string(),
bundles: vec!["sector-enterprise-saas".to_string()],
related: vec![],
downloads: 500,
tags: vec![],
},
PackageInfo {
id: "erp-system".to_string(),
score: 80.0,
production_ready: true,
category: "enterprise".to_string(),
bundles: vec!["sector-enterprise-saas".to_string()],
related: vec!["crm-system".to_string()],
downloads: 400,
tags: vec![],
},
];
let peers = Recommender::find_bundle_peers("crm-system", all_packages);
assert!(peers.contains(&"erp-system".to_string()));
}
}