1use crate::analyzer::{PortSource, ProjectAnalysis, TechnologyCategory};
7use crate::platform::api::types::{CloudProvider, DeploymentTarget};
8use crate::wizard::cloud_provider_data::{
9 get_default_machine_type, get_default_region, get_machine_types_for_provider,
10 get_regions_for_provider,
11};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DeploymentRecommendation {
17 pub provider: CloudProvider,
19 pub provider_reasoning: String,
21
22 pub target: DeploymentTarget,
24 pub target_reasoning: String,
26
27 pub machine_type: String,
29 pub machine_reasoning: String,
31
32 pub region: String,
34 pub region_reasoning: String,
36
37 pub port: u16,
39 pub port_source: String,
41
42 pub health_check_path: Option<String>,
44
45 pub confidence: f32,
47
48 pub alternatives: RecommendationAlternatives,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct RecommendationAlternatives {
55 pub providers: Vec<ProviderOption>,
56 pub machine_types: Vec<MachineOption>,
57 pub regions: Vec<RegionOption>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ProviderOption {
63 pub provider: CloudProvider,
64 pub available: bool,
65 pub reason_if_unavailable: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct MachineOption {
71 pub machine_type: String,
72 pub vcpu: String,
73 pub memory_gb: String,
74 pub description: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct RegionOption {
80 pub region: String,
81 pub display_name: String,
82}
83
84#[derive(Debug, Clone)]
86pub struct RecommendationInput {
87 pub analysis: ProjectAnalysis,
88 pub available_providers: Vec<CloudProvider>,
89 pub has_existing_k8s: bool,
90 pub user_region_hint: Option<String>,
91}
92
93pub fn recommend_deployment(input: RecommendationInput) -> DeploymentRecommendation {
95 let (provider, provider_reasoning) = select_provider(&input);
97
98 let (target, target_reasoning) = select_target(&input);
100
101 let (machine_type, machine_reasoning) = select_machine_type(&input.analysis, &provider);
103
104 let (region, region_reasoning) = select_region(&provider, input.user_region_hint.as_deref());
106
107 let (port, port_source) = select_port(&input.analysis);
109
110 let health_check_path = select_health_endpoint(&input.analysis);
112
113 let confidence = calculate_confidence(&input.analysis, &port_source, health_check_path.is_some());
115
116 let alternatives = build_alternatives(&provider, &input.available_providers);
118
119 DeploymentRecommendation {
120 provider,
121 provider_reasoning,
122 target,
123 target_reasoning,
124 machine_type,
125 machine_reasoning,
126 region,
127 region_reasoning,
128 port,
129 port_source,
130 health_check_path,
131 confidence,
132 alternatives,
133 }
134}
135
136fn select_provider(input: &RecommendationInput) -> (CloudProvider, String) {
138 if let Some(ref infra) = input.analysis.infrastructure {
140 if infra.has_kubernetes || input.has_existing_k8s {
142 if input.available_providers.contains(&CloudProvider::Gcp) {
144 return (
145 CloudProvider::Gcp,
146 "GCP recommended: Existing Kubernetes infrastructure detected".to_string(),
147 );
148 }
149 }
150 }
151
152 let has_hetzner = input.available_providers.contains(&CloudProvider::Hetzner);
154 let has_gcp = input.available_providers.contains(&CloudProvider::Gcp);
155
156 if has_hetzner && has_gcp {
157 (
159 CloudProvider::Hetzner,
160 "Hetzner recommended: Cost-effective for web services, European data centers".to_string(),
161 )
162 } else if has_hetzner {
163 (
164 CloudProvider::Hetzner,
165 "Hetzner selected: Only available connected provider".to_string(),
166 )
167 } else if has_gcp {
168 (
169 CloudProvider::Gcp,
170 "GCP selected: Only available connected provider".to_string(),
171 )
172 } else {
173 (
175 CloudProvider::Hetzner,
176 "Hetzner selected: Default provider".to_string(),
177 )
178 }
179}
180
181fn select_target(input: &RecommendationInput) -> (DeploymentTarget, String) {
183 if let Some(ref infra) = input.analysis.infrastructure {
185 if infra.has_kubernetes && input.has_existing_k8s {
186 return (
187 DeploymentTarget::Kubernetes,
188 "Kubernetes recommended: Existing K8s manifests detected and clusters available".to_string(),
189 );
190 }
191 }
192
193 (
195 DeploymentTarget::CloudRunner,
196 "Cloud Runner recommended: Simpler deployment, no cluster management required".to_string(),
197 )
198}
199
200fn select_machine_type(analysis: &ProjectAnalysis, provider: &CloudProvider) -> (String, String) {
202 let framework_info = get_framework_resource_hint(analysis);
204
205 let (machine_type, reasoning) = match provider {
206 CloudProvider::Hetzner => {
207 match framework_info.memory_requirement {
208 MemoryRequirement::Low => (
209 "cx23".to_string(),
210 format!("cx23 (2 vCPU, 4GB) recommended: {} services are memory-efficient", framework_info.name),
211 ),
212 MemoryRequirement::Medium => (
213 "cx33".to_string(),
214 format!("cx33 (4 vCPU, 8GB) recommended: {} may benefit from more resources", framework_info.name),
215 ),
216 MemoryRequirement::High => (
217 "cx43".to_string(),
218 format!("cx43 (8 vCPU, 16GB) recommended: {} requires significant memory (JVM, ML, etc.)", framework_info.name),
219 ),
220 }
221 }
222 CloudProvider::Gcp => {
223 match framework_info.memory_requirement {
224 MemoryRequirement::Low => (
225 "e2-small".to_string(),
226 format!("e2-small (0.5 vCPU, 2GB) recommended: {} services are lightweight", framework_info.name),
227 ),
228 MemoryRequirement::Medium => (
229 "e2-medium".to_string(),
230 format!("e2-medium (1 vCPU, 4GB) recommended: {} may need moderate resources", framework_info.name),
231 ),
232 MemoryRequirement::High => (
233 "e2-standard-2".to_string(),
234 format!("e2-standard-2 (2 vCPU, 8GB) recommended: {} requires significant memory", framework_info.name),
235 ),
236 }
237 }
238 _ => {
239 (
241 get_default_machine_type(provider).to_string(),
242 "Default machine type selected".to_string(),
243 )
244 }
245 };
246
247 (machine_type, reasoning)
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252enum MemoryRequirement {
253 Low, Medium, High, }
257
258struct FrameworkResourceHint {
260 name: String,
261 memory_requirement: MemoryRequirement,
262}
263
264fn get_framework_resource_hint(analysis: &ProjectAnalysis) -> FrameworkResourceHint {
266 for tech in &analysis.technologies {
268 if matches!(tech.category, TechnologyCategory::BackendFramework) {
269 let name_lower = tech.name.to_lowercase();
270
271 if name_lower.contains("spring") || name_lower.contains("quarkus")
273 || name_lower.contains("micronaut") || name_lower.contains("ktor") {
274 return FrameworkResourceHint {
275 name: tech.name.clone(),
276 memory_requirement: MemoryRequirement::High,
277 };
278 }
279
280 if name_lower.contains("gin") || name_lower.contains("echo")
282 || name_lower.contains("fiber") || name_lower.contains("chi")
283 || name_lower.contains("actix") || name_lower.contains("axum")
284 || name_lower.contains("rocket") {
285 return FrameworkResourceHint {
286 name: tech.name.clone(),
287 memory_requirement: MemoryRequirement::Low,
288 };
289 }
290
291 if name_lower.contains("express") || name_lower.contains("fastify")
293 || name_lower.contains("koa") || name_lower.contains("hono")
294 || name_lower.contains("elysia") || name_lower.contains("nest") {
295 return FrameworkResourceHint {
296 name: tech.name.clone(),
297 memory_requirement: MemoryRequirement::Low,
298 };
299 }
300
301 if name_lower.contains("fastapi") || name_lower.contains("flask")
303 || name_lower.contains("django") {
304 return FrameworkResourceHint {
305 name: tech.name.clone(),
306 memory_requirement: MemoryRequirement::Medium,
307 };
308 }
309 }
310 }
311
312 for lang in &analysis.languages {
314 let name_lower = lang.name.to_lowercase();
315
316 if name_lower.contains("java") || name_lower.contains("kotlin") || name_lower.contains("scala") {
317 return FrameworkResourceHint {
318 name: lang.name.clone(),
319 memory_requirement: MemoryRequirement::High,
320 };
321 }
322
323 if name_lower.contains("go") || name_lower.contains("rust") {
324 return FrameworkResourceHint {
325 name: lang.name.clone(),
326 memory_requirement: MemoryRequirement::Low,
327 };
328 }
329
330 if name_lower.contains("javascript") || name_lower.contains("typescript") {
331 return FrameworkResourceHint {
332 name: lang.name.clone(),
333 memory_requirement: MemoryRequirement::Low,
334 };
335 }
336
337 if name_lower.contains("python") {
338 return FrameworkResourceHint {
339 name: lang.name.clone(),
340 memory_requirement: MemoryRequirement::Medium,
341 };
342 }
343 }
344
345 FrameworkResourceHint {
347 name: "Unknown".to_string(),
348 memory_requirement: MemoryRequirement::Medium,
349 }
350}
351
352fn select_region(provider: &CloudProvider, user_hint: Option<&str>) -> (String, String) {
354 if let Some(hint) = user_hint {
355 let regions = get_regions_for_provider(provider);
357 if regions.iter().any(|r| r.id == hint) {
358 return (
359 hint.to_string(),
360 format!("{} selected: User preference", hint),
361 );
362 }
363 }
364
365 let default_region = get_default_region(provider);
366 let reasoning = match provider {
367 CloudProvider::Hetzner => format!("{} (Nuremberg) selected: Default EU region, low latency for European users", default_region),
368 CloudProvider::Gcp => format!("{} (Iowa) selected: Default US region, good general-purpose choice", default_region),
369 _ => format!("{} selected: Default region for provider", default_region),
370 };
371
372 (default_region.to_string(), reasoning)
373}
374
375fn select_port(analysis: &ProjectAnalysis) -> (u16, String) {
377 let port_priority = |source: &Option<PortSource>| -> u8 {
379 match source {
380 Some(PortSource::SourceCode) => 7,
381 Some(PortSource::PackageJson) => 6,
382 Some(PortSource::ConfigFile) => 5,
383 Some(PortSource::FrameworkDefault) => 4,
384 Some(PortSource::Dockerfile) => 3,
385 Some(PortSource::DockerCompose) => 2,
386 Some(PortSource::EnvVar) => 1,
387 None => 0,
388 }
389 };
390
391 let best_port = analysis.ports.iter()
393 .max_by_key(|p| port_priority(&p.source));
394
395 if let Some(port) = best_port {
396 let source_desc = match &port.source {
397 Some(PortSource::SourceCode) => "Detected from source code analysis",
398 Some(PortSource::PackageJson) => "Detected from package.json scripts",
399 Some(PortSource::ConfigFile) => "Detected from configuration file",
400 Some(PortSource::FrameworkDefault) => {
401 let framework_name = analysis.technologies.iter()
403 .find(|t| matches!(t.category, TechnologyCategory::BackendFramework | TechnologyCategory::MetaFramework))
404 .map(|t| t.name.as_str())
405 .unwrap_or("framework");
406 return (port.number, format!("Framework default ({}: {})", framework_name, port.number));
407 }
408 Some(PortSource::Dockerfile) => "Detected from Dockerfile EXPOSE",
409 Some(PortSource::DockerCompose) => "Detected from docker-compose.yml",
410 Some(PortSource::EnvVar) => "Detected from environment variable reference",
411 None => "Detected from project analysis",
412 };
413 return (port.number, source_desc.to_string());
414 }
415
416 (8080, "Default port 8080: No port detected in project".to_string())
418}
419
420fn select_health_endpoint(analysis: &ProjectAnalysis) -> Option<String> {
422 analysis.health_endpoints.iter()
424 .max_by(|a, b| a.confidence.partial_cmp(&b.confidence).unwrap_or(std::cmp::Ordering::Equal))
425 .map(|e| e.path.clone())
426}
427
428fn calculate_confidence(analysis: &ProjectAnalysis, port_source: &str, has_health_endpoint: bool) -> f32 {
430 let mut confidence: f32 = 0.5; if port_source.contains("source code") || port_source.contains("package.json") {
434 confidence += 0.2;
435 } else if port_source.contains("Dockerfile") || port_source.contains("framework") {
436 confidence += 0.1;
437 }
438
439 let has_framework = analysis.technologies.iter()
441 .any(|t| matches!(t.category, TechnologyCategory::BackendFramework | TechnologyCategory::MetaFramework));
442 if has_framework {
443 confidence += 0.15;
444 }
445
446 if has_health_endpoint {
448 confidence += 0.1;
449 }
450
451 if port_source.contains("No port detected") || port_source.contains("Default port") {
453 confidence -= 0.2;
454 }
455
456 confidence.clamp(0.0, 1.0)
457}
458
459fn build_alternatives(selected_provider: &CloudProvider, available_providers: &[CloudProvider]) -> RecommendationAlternatives {
461 let providers: Vec<ProviderOption> = CloudProvider::all()
463 .iter()
464 .map(|p| ProviderOption {
465 provider: p.clone(),
466 available: available_providers.contains(p) && p.is_available(),
467 reason_if_unavailable: if !p.is_available() {
468 Some(format!("{} coming soon", p.display_name()))
469 } else if !available_providers.contains(p) {
470 Some("Not connected".to_string())
471 } else {
472 None
473 },
474 })
475 .collect();
476
477 let machine_types: Vec<MachineOption> = get_machine_types_for_provider(selected_provider)
479 .iter()
480 .map(|m| MachineOption {
481 machine_type: m.id.to_string(),
482 vcpu: m.cpu.to_string(),
483 memory_gb: m.memory.to_string(),
484 description: m.description.map(String::from).unwrap_or_default(),
485 })
486 .collect();
487
488 let regions: Vec<RegionOption> = get_regions_for_provider(selected_provider)
490 .iter()
491 .map(|r| RegionOption {
492 region: r.id.to_string(),
493 display_name: format!("{} ({})", r.name, r.location),
494 })
495 .collect();
496
497 RecommendationAlternatives {
498 providers,
499 machine_types,
500 regions,
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::analyzer::{
508 AnalysisMetadata, ArchitectureType, DetectedLanguage, DetectedTechnology,
509 HealthEndpoint, InfrastructurePresence, Port, ProjectType, TechnologyCategory,
510 };
511 use std::collections::HashMap;
512 use std::path::PathBuf;
513
514 fn create_minimal_analysis() -> ProjectAnalysis {
515 #[allow(deprecated)]
516 ProjectAnalysis {
517 project_root: PathBuf::from("/test"),
518 languages: vec![],
519 technologies: vec![],
520 frameworks: vec![],
521 dependencies: HashMap::new(),
522 entry_points: vec![],
523 ports: vec![],
524 health_endpoints: vec![],
525 environment_variables: vec![],
526 project_type: ProjectType::WebApplication,
527 build_scripts: vec![],
528 services: vec![],
529 architecture_type: ArchitectureType::Monolithic,
530 docker_analysis: None,
531 infrastructure: None,
532 analysis_metadata: AnalysisMetadata {
533 timestamp: "2024-01-01T00:00:00Z".to_string(),
534 analyzer_version: "0.1.0".to_string(),
535 analysis_duration_ms: 100,
536 files_analyzed: 10,
537 confidence_score: 0.8,
538 },
539 }
540 }
541
542 #[test]
543 fn test_nodejs_express_recommendation() {
544 let mut analysis = create_minimal_analysis();
545 analysis.languages.push(DetectedLanguage {
546 name: "JavaScript".to_string(),
547 version: Some("18".to_string()),
548 confidence: 0.9,
549 files: vec![],
550 main_dependencies: vec!["express".to_string()],
551 dev_dependencies: vec![],
552 package_manager: Some("npm".to_string()),
553 });
554 analysis.technologies.push(DetectedTechnology {
555 name: "Express".to_string(),
556 version: Some("4.18".to_string()),
557 category: TechnologyCategory::BackendFramework,
558 confidence: 0.9,
559 requires: vec![],
560 conflicts_with: vec![],
561 is_primary: true,
562 file_indicators: vec![],
563 });
564 analysis.ports.push(Port {
565 number: 3000,
566 protocol: crate::analyzer::Protocol::Http,
567 description: Some("Express default".to_string()),
568 source: Some(PortSource::PackageJson),
569 });
570
571 let input = RecommendationInput {
572 analysis,
573 available_providers: vec![CloudProvider::Hetzner, CloudProvider::Gcp],
574 has_existing_k8s: false,
575 user_region_hint: None,
576 };
577
578 let rec = recommend_deployment(input);
579
580 assert!(rec.machine_type == "cx23" || rec.machine_type == "e2-small");
582 assert_eq!(rec.port, 3000);
583 assert!(rec.machine_reasoning.contains("Express"));
584 }
585
586 #[test]
587 fn test_java_spring_recommendation() {
588 let mut analysis = create_minimal_analysis();
589 analysis.languages.push(DetectedLanguage {
590 name: "Java".to_string(),
591 version: Some("17".to_string()),
592 confidence: 0.9,
593 files: vec![],
594 main_dependencies: vec!["spring-boot".to_string()],
595 dev_dependencies: vec![],
596 package_manager: Some("maven".to_string()),
597 });
598 analysis.technologies.push(DetectedTechnology {
599 name: "Spring Boot".to_string(),
600 version: Some("3.0".to_string()),
601 category: TechnologyCategory::BackendFramework,
602 confidence: 0.9,
603 requires: vec![],
604 conflicts_with: vec![],
605 is_primary: true,
606 file_indicators: vec![],
607 });
608 analysis.ports.push(Port {
609 number: 8080,
610 protocol: crate::analyzer::Protocol::Http,
611 description: Some("Spring Boot default".to_string()),
612 source: Some(PortSource::FrameworkDefault),
613 });
614
615 let input = RecommendationInput {
616 analysis,
617 available_providers: vec![CloudProvider::Hetzner],
618 has_existing_k8s: false,
619 user_region_hint: None,
620 };
621
622 let rec = recommend_deployment(input);
623
624 assert!(rec.machine_type == "cx43" || rec.machine_reasoning.contains("memory"));
626 assert_eq!(rec.port, 8080);
627 }
628
629 #[test]
630 fn test_existing_k8s_suggests_kubernetes_target() {
631 let mut analysis = create_minimal_analysis();
632 analysis.infrastructure = Some(InfrastructurePresence {
633 has_kubernetes: true,
634 kubernetes_paths: vec![PathBuf::from("k8s/")],
635 has_helm: false,
636 helm_chart_paths: vec![],
637 has_docker_compose: false,
638 has_terraform: false,
639 terraform_paths: vec![],
640 has_deployment_config: false,
641 summary: Some("Kubernetes manifests detected".to_string()),
642 });
643
644 let input = RecommendationInput {
645 analysis,
646 available_providers: vec![CloudProvider::Gcp],
647 has_existing_k8s: true, user_region_hint: None,
649 };
650
651 let rec = recommend_deployment(input);
652 assert_eq!(rec.target, DeploymentTarget::Kubernetes);
653 assert!(rec.target_reasoning.contains("Kubernetes"));
654 }
655
656 #[test]
657 fn test_no_k8s_defaults_to_cloud_runner() {
658 let analysis = create_minimal_analysis();
659
660 let input = RecommendationInput {
661 analysis,
662 available_providers: vec![CloudProvider::Hetzner],
663 has_existing_k8s: false,
664 user_region_hint: None,
665 };
666
667 let rec = recommend_deployment(input);
668 assert_eq!(rec.target, DeploymentTarget::CloudRunner);
669 assert!(rec.target_reasoning.contains("Cloud Runner"));
670 }
671
672 #[test]
673 fn test_port_fallback_to_8080() {
674 let analysis = create_minimal_analysis();
675
676 let input = RecommendationInput {
677 analysis,
678 available_providers: vec![CloudProvider::Hetzner],
679 has_existing_k8s: false,
680 user_region_hint: None,
681 };
682
683 let rec = recommend_deployment(input);
684 assert_eq!(rec.port, 8080);
685 assert!(rec.port_source.contains("No port detected") || rec.port_source.contains("Default"));
686 }
687
688 #[test]
689 fn test_health_endpoint_included_when_detected() {
690 let mut analysis = create_minimal_analysis();
691 analysis.health_endpoints.push(HealthEndpoint {
692 path: "/health".to_string(),
693 confidence: 0.9,
694 source: crate::analyzer::HealthEndpointSource::CodePattern,
695 description: Some("Found in source code".to_string()),
696 });
697
698 let input = RecommendationInput {
699 analysis,
700 available_providers: vec![CloudProvider::Hetzner],
701 has_existing_k8s: false,
702 user_region_hint: None,
703 };
704
705 let rec = recommend_deployment(input);
706 assert_eq!(rec.health_check_path, Some("/health".to_string()));
707 }
708
709 #[test]
710 fn test_alternatives_populated() {
711 let analysis = create_minimal_analysis();
712
713 let input = RecommendationInput {
714 analysis,
715 available_providers: vec![CloudProvider::Hetzner, CloudProvider::Gcp],
716 has_existing_k8s: false,
717 user_region_hint: None,
718 };
719
720 let rec = recommend_deployment(input);
721
722 assert!(!rec.alternatives.providers.is_empty());
723 assert!(!rec.alternatives.machine_types.is_empty());
724 assert!(!rec.alternatives.regions.is_empty());
725 }
726
727 #[test]
728 fn test_user_region_hint_respected() {
729 let analysis = create_minimal_analysis();
730
731 let input = RecommendationInput {
732 analysis,
733 available_providers: vec![CloudProvider::Hetzner],
734 has_existing_k8s: false,
735 user_region_hint: Some("fsn1".to_string()),
736 };
737
738 let rec = recommend_deployment(input);
739 assert_eq!(rec.region, "fsn1");
740 assert!(rec.region_reasoning.contains("User preference"));
741 }
742
743 #[test]
744 fn test_go_service_gets_small_machine() {
745 let mut analysis = create_minimal_analysis();
746 analysis.technologies.push(DetectedTechnology {
747 name: "Gin".to_string(),
748 version: Some("1.9".to_string()),
749 category: TechnologyCategory::BackendFramework,
750 confidence: 0.9,
751 requires: vec![],
752 conflicts_with: vec![],
753 is_primary: true,
754 file_indicators: vec![],
755 });
756
757 let input = RecommendationInput {
758 analysis,
759 available_providers: vec![CloudProvider::Hetzner],
760 has_existing_k8s: false,
761 user_region_hint: None,
762 };
763
764 let rec = recommend_deployment(input);
765 assert_eq!(rec.machine_type, "cx23");
767 assert!(rec.machine_reasoning.contains("memory-efficient") || rec.machine_reasoning.contains("Gin"));
768 }
769}