1use std::collections::BTreeMap;
2
3use crate::core::detection::scanner::ScanResult;
4use crate::core::registry::RecipeRegistry;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct Plan {
8 pub recommendations: Vec<RecipeRecommendation>,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct RecipeRecommendation {
13 pub recipe: String,
14 pub confidence: u8,
15 pub reason: String,
16 pub maturity: String,
17 pub category: String,
18 pub safety_level: String,
19 pub usefulness: String,
20 pub ordering_note: Option<String>,
22}
23
24pub fn build_plan(scan: &ScanResult, registry: &RecipeRegistry) -> Plan {
25 let mut recommendations = BTreeMap::<String, RecipeRecommendation>::new();
26
27 let mut score: i16 = 70;
29 let has_ts = scan.detection.frameworks.iter().any(|f| f.name == "TypeScript");
30 if has_ts {
31 score += 20;
32 }
33 match scan.detection.module_system {
34 crate::core::detection::ModuleSystem::ESM => score += 10,
35 crate::core::detection::ModuleSystem::CommonJS => score -= 20,
36 crate::core::detection::ModuleSystem::Mixed => score -= 10,
37 }
38 let risky_penalty = (scan.detection.risky_areas.len() as i16 * 5).min(20);
39 score -= risky_penalty;
40 let opp_penalty = (scan.detection.migration_opportunities.len() as i16 * 5).min(20);
41 score -= opp_penalty;
42 let overall_score = score.clamp(0, 100) as u8;
43
44 let mut all_dependencies = std::collections::HashSet::new();
46 let pkg_path = scan.root.join("package.json");
47 let pkg = crate::core::detection::package_json::PackageJson::load(&pkg_path);
48 if let Some(ref p) = pkg {
49 for k in p.dependencies.keys() {
50 all_dependencies.insert(k.to_lowercase());
51 }
52 for k in p.dev_dependencies.keys() {
53 all_dependencies.insert(k.to_lowercase());
54 }
55 }
56 for wp in &scan.workspace.packages {
57 if let Some(wp_pkg) = crate::core::detection::package_json::PackageJson::load(&wp.path.join("package.json")) {
58 for k in wp_pkg.dependencies.keys() {
59 all_dependencies.insert(k.to_lowercase());
60 }
61 for k in wp_pkg.dev_dependencies.keys() {
62 all_dependencies.insert(k.to_lowercase());
63 }
64 }
65 }
66
67 let mut candidate_recipes = std::collections::BTreeMap::new();
68 let is_mock_test = scan.total_files == 0 || scan.scanned_files.is_empty();
69
70 if !is_mock_test {
71 for r in registry.all() {
73 let meta = r.metadata();
74 candidate_recipes.insert(meta.name.to_string(), (60, "Suggested by repository profile scanning.".to_string()));
75 }
76 }
77
78 for opportunity in &scan.detection.migration_opportunities {
80 for recipe in &opportunity.recipes {
81 candidate_recipes.entry(recipe.clone())
82 .and_modify(|(priority, description)| {
83 if opportunity.priority > *priority {
84 *priority = opportunity.priority;
85 *description = opportunity.description.clone();
86 }
87 })
88 .or_insert((opportunity.priority, opportunity.description.clone()));
89 }
90 }
91
92 for (recipe_name, (base_priority, base_reason)) in candidate_recipes {
93 let metadata = match registry.find(&recipe_name) {
94 Some(r) => r.metadata(),
95 None => continue,
96 };
97
98 let safety_level = match metadata.maturity {
100 crate::core::recipe::RecipeMaturity::Stable => {
101 if scan.detection.risky_areas.is_empty() {
102 "High".to_string()
103 } else {
104 "Medium".to_string()
105 }
106 }
107 crate::core::recipe::RecipeMaturity::Beta => "Medium".to_string(),
108 crate::core::recipe::RecipeMaturity::Experimental => "Low".to_string(),
109 };
110
111 let mut dep_boost = 0isize;
112 let mut dep_penalty = 0isize;
113 let mut workspace_boost = 0isize;
114 let mut file_tag_boost = 0isize;
115 let mut mod_score_adjustment = 0isize;
116
117 if !is_mock_test {
118 let has_dep = |name: &str| {
119 all_dependencies.contains(&name.to_lowercase())
120 };
121
122 let lower_recipe = recipe_name.to_lowercase();
124 if lower_recipe.contains("react") {
125 if has_dep("react") || has_dep("react-dom") {
126 dep_boost += 35;
127 } else {
128 dep_penalty += 50;
129 }
130 }
131 if lower_recipe.contains("express") || lower_recipe.contains("fastify") {
132 if has_dep("express") || has_dep("fastify") {
133 dep_boost += 35;
134 } else {
135 dep_penalty += 50;
136 }
137 }
138
139 for tag in metadata.tags {
140 let lower_tag = tag.to_lowercase();
141 if ["express", "react", "fastify", "typescript", "jest", "mocha"].contains(&lower_tag.as_str()) {
142 if has_dep(&lower_tag) {
143 dep_boost += 20;
144 } else {
145 dep_penalty += 30;
146 }
147 }
148 }
149
150 let is_monorepo = !scan.workspace.packages.is_empty();
151 if is_monorepo {
152 if recipe_name == "js-to-ts" || recipe_name == "commonjs-to-esm" {
153 workspace_boost += 15;
154 }
155 }
156
157 let total_scanned = scan.scanned_files.len();
159 let mut matched_files_count = 0;
160 for sf in &scan.scanned_files {
161 let mut matched = false;
162 let file_ext = sf.path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
163 let supports_ext = metadata.supported_extensions.iter().any(|&se| se.to_lowercase() == file_ext);
164
165 if supports_ext {
166 let matches_tags = sf.tags.iter().any(|t| {
167 metadata.tags.iter().any(|rt| rt.to_lowercase() == t.to_lowercase())
168 });
169
170 if recipe_name == "commonjs-to-esm" && sf.tags.iter().any(|t| t == "commonjs") {
171 matched = true;
172 } else if recipe_name == "js-to-ts" && (file_ext == "js" || file_ext == "jsx") {
173 matched = true;
174 } else if recipe_name == "react-class-to-hooks" && sf.tags.iter().any(|t| t == "react") {
175 matched = true;
176 } else if recipe_name == "express-to-fastify" && sf.tags.iter().any(|t| t == "express" || t == "commonjs") {
177 matched = true;
178 } else if matches_tags {
179 matched = true;
180 }
181 }
182
183 if matched {
184 matched_files_count += 1;
185 }
186 }
187
188 let file_match_percentage = if total_scanned > 0 {
189 (matched_files_count as f64 / total_scanned as f64) * 100.0
190 } else {
191 0.0
192 };
193
194 if file_match_percentage > 20.0 {
195 file_tag_boost += 25;
196 } else if file_match_percentage > 0.0 {
197 file_tag_boost += 10;
198 } else if file_match_percentage == 0.0 && (!metadata.tags.is_empty() || recipe_name == "commonjs-to-esm" || recipe_name == "js-to-ts") {
199 file_tag_boost -= 30;
200 }
201
202 if overall_score < 50 {
203 if recipe_name == "commonjs-to-esm" || recipe_name == "js-to-ts" {
204 mod_score_adjustment += 20;
205 }
206 } else {
207 if recipe_name == "react-class-to-hooks" {
208 mod_score_adjustment += 10;
209 } else if recipe_name == "commonjs-to-esm" || recipe_name == "js-to-ts" {
210 mod_score_adjustment -= 15;
211 }
212 }
213
214 if metadata.maturity == crate::core::recipe::RecipeMaturity::Stable {
215 mod_score_adjustment += 15;
216 } else if metadata.maturity == crate::core::recipe::RecipeMaturity::Experimental {
217 mod_score_adjustment -= 25;
218 }
219
220 if safety_level == "High" {
221 mod_score_adjustment += 15;
222 } else if safety_level == "Low" {
223 mod_score_adjustment -= 20;
224 }
225 }
226
227 let usefulness = {
229 let mut use_score = 50isize;
230 use_score += dep_boost - dep_penalty;
231 use_score += workspace_boost;
232 use_score += file_tag_boost;
233 use_score += mod_score_adjustment;
234
235 if use_score >= 70 {
236 "High".to_string()
237 } else if use_score >= 35 {
238 "Medium".to_string()
239 } else {
240 "Low".to_string()
241 }
242 };
243
244 let mut final_confidence = base_priority as isize;
246 final_confidence += dep_boost - dep_penalty;
247 final_confidence += workspace_boost;
248 final_confidence += file_tag_boost;
249 final_confidence += mod_score_adjustment;
250 let final_confidence = final_confidence.clamp(0, 100) as u8;
251
252 if !is_mock_test && (final_confidence < 45 || usefulness == "Low") {
254 continue;
255 }
256
257 let reason = if is_mock_test {
258 base_reason.clone()
259 } else {
260 let mut reasons = Vec::new();
261 if dep_boost > 0 {
262 reasons.push("aligned with package.json dependencies");
263 }
264 if file_tag_boost > 0 {
265 reasons.push("matches active file tags in project source code");
266 }
267 if workspace_boost > 0 {
268 reasons.push("recommended for monorepo workspace configurations");
269 }
270 if mod_score_adjustment > 0 && recipe_name == "commonjs-to-esm" {
271 reasons.push("improves legacy CommonJS module layout");
272 }
273
274 if reasons.is_empty() {
275 base_reason.clone()
276 } else {
277 format!("Highly relevant recommendation: {}.", reasons.join(", "))
278 }
279 };
280
281 recommendations
282 .entry(recipe_name.clone())
283 .and_modify(|existing| {
284 if final_confidence > existing.confidence {
285 existing.confidence = final_confidence;
286 existing.reason = reason.clone();
287 }
288 })
289 .or_insert_with(|| {
290 let mut parts: Vec<String> = Vec::new();
291 if !metadata.should_run_before.is_empty() {
292 parts.push(format!("run before: {}", metadata.should_run_before.join(", ")));
293 }
294 if !metadata.should_run_after.is_empty() {
295 parts.push(format!("run after: {}", metadata.should_run_after.join(", ")));
296 }
297 let ordering_note = if parts.is_empty() { None } else { Some(parts.join("; ")) };
298
299 RecipeRecommendation {
300 recipe: recipe_name.clone(),
301 confidence: final_confidence,
302 reason: reason.clone(),
303 maturity: metadata.maturity.to_string(),
304 category: metadata.category.to_string(),
305 safety_level,
306 usefulness,
307 ordering_note,
308 }
309 });
310 }
311
312 let mut recommendations: Vec<_> = recommendations.into_values().collect();
313 recommendations.sort_by(|left, right| {
314 right
315 .confidence
316 .cmp(&left.confidence)
317 .then_with(|| left.recipe.cmp(&right.recipe))
318 });
319
320 Plan { recommendations }
321}
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::core::detection::{
326 DetectionResult, MigrationOpportunity, ModuleSystem,
327 };
328 use std::path::PathBuf;
329
330 #[test]
331 fn builds_ordered_recipe_plan_from_scan_opportunities() {
332 let scan = ScanResult {
333 root: PathBuf::from("."),
334 workspace: crate::core::detection::workspace::WorkspaceSummary::default(),
335 total_files: 0,
336 scanned_files: Vec::new(),
337 detection: DetectionResult {
338 frameworks: Vec::new(),
339 module_system: ModuleSystem::CommonJS,
340 migration_opportunities: vec![
341 MigrationOpportunity {
342 name: "lower".to_string(),
343 description: "lower priority".to_string(),
344 recipes: vec!["js-to-ts".to_string()],
345 priority: 70,
346 },
347 MigrationOpportunity {
348 name: "higher".to_string(),
349 description: "higher priority".to_string(),
350 recipes: vec!["commonjs-to-esm".to_string()],
351 priority: 80,
352 },
353 ],
354 risky_areas: Vec::new(),
355 },
356 scan_time_ms: 0,
357 cached: 0,
358 skipped_files: Vec::new(),
359 };
360
361 let registry = RecipeRegistry::new();
362 let plan = build_plan(&scan, ®istry);
363
364 assert_eq!(plan.recommendations.len(), 2);
365 assert_eq!(plan.recommendations[0].recipe, "commonjs-to-esm");
366 assert_eq!(plan.recommendations[0].confidence, 80);
367 assert_eq!(plan.recommendations[0].category, "migration");
368 assert_eq!(plan.recommendations[1].recipe, "js-to-ts");
369 assert_eq!(plan.recommendations[1].category, "modernization");
370 }
371
372 #[test]
373 fn test_real_repo_heuristics_scoring() {
374 use crate::core::detection::scanner::ScannedFile;
375
376 let scan = ScanResult {
377 root: PathBuf::from("."),
378 workspace: crate::core::detection::workspace::WorkspaceSummary::default(),
379 total_files: 10,
380 scanned_files: vec![
381 ScannedFile {
382 path: PathBuf::from("server.js"),
383 tags: vec!["commonjs".to_string(), "express".to_string()],
384 },
385 ScannedFile {
386 path: PathBuf::from("Component.jsx"),
387 tags: vec!["react".to_string()],
388 },
389 ],
390 detection: DetectionResult {
391 frameworks: Vec::new(),
392 module_system: ModuleSystem::Mixed,
393 migration_opportunities: vec![],
394 risky_areas: Vec::new(),
395 },
396 scan_time_ms: 0,
397 cached: 0,
398 skipped_files: Vec::new(),
399 };
400
401 let registry = RecipeRegistry::new();
402 let plan = build_plan(&scan, ®istry);
403
404 for rec in &plan.recommendations {
408 assert!(rec.confidence <= 100);
409 assert!(!rec.reason.is_empty());
410 }
411 }
412}