use std::collections::BTreeMap;
use crate::core::detection::scanner::ScanResult;
use crate::core::registry::RecipeRegistry;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Plan {
pub recommendations: Vec<RecipeRecommendation>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecipeRecommendation {
pub recipe: String,
pub confidence: u8,
pub reason: String,
pub maturity: String,
pub category: String,
pub safety_level: String,
pub usefulness: String,
pub ordering_note: Option<String>,
}
pub fn build_plan(scan: &ScanResult, registry: &RecipeRegistry) -> Plan {
let mut recommendations = BTreeMap::<String, RecipeRecommendation>::new();
let mut score: i16 = 70;
let has_ts = scan.detection.frameworks.iter().any(|f| f.name == "TypeScript");
if has_ts {
score += 20;
}
match scan.detection.module_system {
crate::core::detection::ModuleSystem::ESM => score += 10,
crate::core::detection::ModuleSystem::CommonJS => score -= 20,
crate::core::detection::ModuleSystem::Mixed => score -= 10,
}
let risky_penalty = (scan.detection.risky_areas.len() as i16 * 5).min(20);
score -= risky_penalty;
let opp_penalty = (scan.detection.migration_opportunities.len() as i16 * 5).min(20);
score -= opp_penalty;
let overall_score = score.clamp(0, 100) as u8;
let mut all_dependencies = std::collections::HashSet::new();
let pkg_path = scan.root.join("package.json");
let pkg = crate::core::detection::package_json::PackageJson::load(&pkg_path);
if let Some(ref p) = pkg {
for k in p.dependencies.keys() {
all_dependencies.insert(k.to_lowercase());
}
for k in p.dev_dependencies.keys() {
all_dependencies.insert(k.to_lowercase());
}
}
for wp in &scan.workspace.packages {
if let Some(wp_pkg) = crate::core::detection::package_json::PackageJson::load(&wp.path.join("package.json")) {
for k in wp_pkg.dependencies.keys() {
all_dependencies.insert(k.to_lowercase());
}
for k in wp_pkg.dev_dependencies.keys() {
all_dependencies.insert(k.to_lowercase());
}
}
}
let mut candidate_recipes = std::collections::BTreeMap::new();
let is_mock_test = scan.total_files == 0 || scan.scanned_files.is_empty();
if !is_mock_test {
for r in registry.all() {
let meta = r.metadata();
candidate_recipes.insert(meta.name.to_string(), (60, "Suggested by repository profile scanning.".to_string()));
}
}
for opportunity in &scan.detection.migration_opportunities {
for recipe in &opportunity.recipes {
candidate_recipes.entry(recipe.clone())
.and_modify(|(priority, description)| {
if opportunity.priority > *priority {
*priority = opportunity.priority;
*description = opportunity.description.clone();
}
})
.or_insert((opportunity.priority, opportunity.description.clone()));
}
}
for (recipe_name, (base_priority, base_reason)) in candidate_recipes {
let metadata = match registry.find(&recipe_name) {
Some(r) => r.metadata(),
None => continue,
};
let safety_level = match metadata.maturity {
crate::core::recipe::RecipeMaturity::Stable => {
if scan.detection.risky_areas.is_empty() {
"High".to_string()
} else {
"Medium".to_string()
}
}
crate::core::recipe::RecipeMaturity::Beta => "Medium".to_string(),
crate::core::recipe::RecipeMaturity::Experimental => "Low".to_string(),
};
let mut dep_boost = 0isize;
let mut dep_penalty = 0isize;
let mut workspace_boost = 0isize;
let mut file_tag_boost = 0isize;
let mut mod_score_adjustment = 0isize;
if !is_mock_test {
let has_dep = |name: &str| {
all_dependencies.contains(&name.to_lowercase())
};
let lower_recipe = recipe_name.to_lowercase();
if lower_recipe.contains("react") {
if has_dep("react") || has_dep("react-dom") {
dep_boost += 35;
} else {
dep_penalty += 50;
}
}
if lower_recipe.contains("express") || lower_recipe.contains("fastify") {
if has_dep("express") || has_dep("fastify") {
dep_boost += 35;
} else {
dep_penalty += 50;
}
}
for tag in metadata.tags {
let lower_tag = tag.to_lowercase();
if ["express", "react", "fastify", "typescript", "jest", "mocha"].contains(&lower_tag.as_str()) {
if has_dep(&lower_tag) {
dep_boost += 20;
} else {
dep_penalty += 30;
}
}
}
let is_monorepo = !scan.workspace.packages.is_empty();
if is_monorepo {
if recipe_name == "js-to-ts" || recipe_name == "commonjs-to-esm" {
workspace_boost += 15;
}
}
let total_scanned = scan.scanned_files.len();
let mut matched_files_count = 0;
for sf in &scan.scanned_files {
let mut matched = false;
let file_ext = sf.path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
let supports_ext = metadata.supported_extensions.iter().any(|&se| se.to_lowercase() == file_ext);
if supports_ext {
let matches_tags = sf.tags.iter().any(|t| {
metadata.tags.iter().any(|rt| rt.to_lowercase() == t.to_lowercase())
});
if recipe_name == "commonjs-to-esm" && sf.tags.iter().any(|t| t == "commonjs") {
matched = true;
} else if recipe_name == "js-to-ts" && (file_ext == "js" || file_ext == "jsx") {
matched = true;
} else if recipe_name == "react-class-to-hooks" && sf.tags.iter().any(|t| t == "react") {
matched = true;
} else if recipe_name == "express-to-fastify" && sf.tags.iter().any(|t| t == "express" || t == "commonjs") {
matched = true;
} else if matches_tags {
matched = true;
}
}
if matched {
matched_files_count += 1;
}
}
let file_match_percentage = if total_scanned > 0 {
(matched_files_count as f64 / total_scanned as f64) * 100.0
} else {
0.0
};
if file_match_percentage > 20.0 {
file_tag_boost += 25;
} else if file_match_percentage > 0.0 {
file_tag_boost += 10;
} else if file_match_percentage == 0.0 && (!metadata.tags.is_empty() || recipe_name == "commonjs-to-esm" || recipe_name == "js-to-ts") {
file_tag_boost -= 30;
}
if overall_score < 50 {
if recipe_name == "commonjs-to-esm" || recipe_name == "js-to-ts" {
mod_score_adjustment += 20;
}
} else {
if recipe_name == "react-class-to-hooks" {
mod_score_adjustment += 10;
} else if recipe_name == "commonjs-to-esm" || recipe_name == "js-to-ts" {
mod_score_adjustment -= 15;
}
}
if metadata.maturity == crate::core::recipe::RecipeMaturity::Stable {
mod_score_adjustment += 15;
} else if metadata.maturity == crate::core::recipe::RecipeMaturity::Experimental {
mod_score_adjustment -= 25;
}
if safety_level == "High" {
mod_score_adjustment += 15;
} else if safety_level == "Low" {
mod_score_adjustment -= 20;
}
}
let usefulness = {
let mut use_score = 50isize;
use_score += dep_boost - dep_penalty;
use_score += workspace_boost;
use_score += file_tag_boost;
use_score += mod_score_adjustment;
if use_score >= 70 {
"High".to_string()
} else if use_score >= 35 {
"Medium".to_string()
} else {
"Low".to_string()
}
};
let mut final_confidence = base_priority as isize;
final_confidence += dep_boost - dep_penalty;
final_confidence += workspace_boost;
final_confidence += file_tag_boost;
final_confidence += mod_score_adjustment;
let final_confidence = final_confidence.clamp(0, 100) as u8;
if !is_mock_test && (final_confidence < 45 || usefulness == "Low") {
continue;
}
let reason = if is_mock_test {
base_reason.clone()
} else {
let mut reasons = Vec::new();
if dep_boost > 0 {
reasons.push("aligned with package.json dependencies");
}
if file_tag_boost > 0 {
reasons.push("matches active file tags in project source code");
}
if workspace_boost > 0 {
reasons.push("recommended for monorepo workspace configurations");
}
if mod_score_adjustment > 0 && recipe_name == "commonjs-to-esm" {
reasons.push("improves legacy CommonJS module layout");
}
if reasons.is_empty() {
base_reason.clone()
} else {
format!("Highly relevant recommendation: {}.", reasons.join(", "))
}
};
recommendations
.entry(recipe_name.clone())
.and_modify(|existing| {
if final_confidence > existing.confidence {
existing.confidence = final_confidence;
existing.reason = reason.clone();
}
})
.or_insert_with(|| {
let mut parts: Vec<String> = Vec::new();
if !metadata.should_run_before.is_empty() {
parts.push(format!("run before: {}", metadata.should_run_before.join(", ")));
}
if !metadata.should_run_after.is_empty() {
parts.push(format!("run after: {}", metadata.should_run_after.join(", ")));
}
let ordering_note = if parts.is_empty() { None } else { Some(parts.join("; ")) };
RecipeRecommendation {
recipe: recipe_name.clone(),
confidence: final_confidence,
reason: reason.clone(),
maturity: metadata.maturity.to_string(),
category: metadata.category.to_string(),
safety_level,
usefulness,
ordering_note,
}
});
}
let mut recommendations: Vec<_> = recommendations.into_values().collect();
recommendations.sort_by(|left, right| {
right
.confidence
.cmp(&left.confidence)
.then_with(|| left.recipe.cmp(&right.recipe))
});
Plan { recommendations }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::detection::{
DetectionResult, MigrationOpportunity, ModuleSystem,
};
use std::path::PathBuf;
#[test]
fn builds_ordered_recipe_plan_from_scan_opportunities() {
let scan = ScanResult {
root: PathBuf::from("."),
workspace: crate::core::detection::workspace::WorkspaceSummary::default(),
total_files: 0,
scanned_files: Vec::new(),
detection: DetectionResult {
frameworks: Vec::new(),
module_system: ModuleSystem::CommonJS,
migration_opportunities: vec![
MigrationOpportunity {
name: "lower".to_string(),
description: "lower priority".to_string(),
recipes: vec!["js-to-ts".to_string()],
priority: 70,
},
MigrationOpportunity {
name: "higher".to_string(),
description: "higher priority".to_string(),
recipes: vec!["commonjs-to-esm".to_string()],
priority: 80,
},
],
risky_areas: Vec::new(),
},
scan_time_ms: 0,
cached: 0,
skipped_files: Vec::new(),
};
let registry = RecipeRegistry::new();
let plan = build_plan(&scan, ®istry);
assert_eq!(plan.recommendations.len(), 2);
assert_eq!(plan.recommendations[0].recipe, "commonjs-to-esm");
assert_eq!(plan.recommendations[0].confidence, 80);
assert_eq!(plan.recommendations[0].category, "migration");
assert_eq!(plan.recommendations[1].recipe, "js-to-ts");
assert_eq!(plan.recommendations[1].category, "modernization");
}
#[test]
fn test_real_repo_heuristics_scoring() {
use crate::core::detection::scanner::ScannedFile;
let scan = ScanResult {
root: PathBuf::from("."),
workspace: crate::core::detection::workspace::WorkspaceSummary::default(),
total_files: 10,
scanned_files: vec![
ScannedFile {
path: PathBuf::from("server.js"),
tags: vec!["commonjs".to_string(), "express".to_string()],
},
ScannedFile {
path: PathBuf::from("Component.jsx"),
tags: vec!["react".to_string()],
},
],
detection: DetectionResult {
frameworks: Vec::new(),
module_system: ModuleSystem::Mixed,
migration_opportunities: vec![],
risky_areas: Vec::new(),
},
scan_time_ms: 0,
cached: 0,
skipped_files: Vec::new(),
};
let registry = RecipeRegistry::new();
let plan = build_plan(&scan, ®istry);
for rec in &plan.recommendations {
assert!(rec.confidence <= 100);
assert!(!rec.reason.is_empty());
}
}
}