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 cpu: Option<String>,
34 pub memory: Option<String>,
36
37 pub region: String,
39 pub region_reasoning: String,
41
42 pub port: u16,
44 pub port_source: String,
46
47 pub health_check_path: Option<String>,
49
50 pub confidence: f32,
52
53 pub alternatives: RecommendationAlternatives,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct RecommendationAlternatives {
60 pub providers: Vec<ProviderOption>,
61 pub machine_types: Vec<MachineOption>,
62 pub regions: Vec<RegionOption>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ProviderOption {
68 pub provider: CloudProvider,
69 pub available: bool,
70 pub reason_if_unavailable: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct MachineOption {
76 pub machine_type: String,
77 pub vcpu: String,
78 pub memory_gb: String,
79 pub description: String,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct RegionOption {
85 pub region: String,
86 pub display_name: String,
87}
88
89#[derive(Debug, Clone)]
91pub struct RecommendationInput {
92 pub analysis: ProjectAnalysis,
93 pub available_providers: Vec<CloudProvider>,
94 pub has_existing_k8s: bool,
95 pub user_region_hint: Option<String>,
96}
97
98pub fn recommend_deployment(input: RecommendationInput) -> DeploymentRecommendation {
100 let (provider, provider_reasoning) = select_provider(&input);
102
103 let (target, target_reasoning) = select_target(&input);
105
106 let machine_result = select_machine_type(&input.analysis, &provider);
108
109 let (region, region_reasoning) = select_region(&provider, input.user_region_hint.as_deref());
111
112 let (port, port_source) = select_port(&input.analysis);
114
115 let health_check_path = select_health_endpoint(&input.analysis);
117
118 let confidence =
120 calculate_confidence(&input.analysis, &port_source, health_check_path.is_some());
121
122 let alternatives = build_alternatives(&provider, &input.available_providers);
124
125 DeploymentRecommendation {
126 provider,
127 provider_reasoning,
128 target,
129 target_reasoning,
130 machine_type: machine_result.machine_type,
131 machine_reasoning: machine_result.reasoning,
132 cpu: machine_result.cpu,
133 memory: machine_result.memory,
134 region,
135 region_reasoning,
136 port,
137 port_source,
138 health_check_path,
139 confidence,
140 alternatives,
141 }
142}
143
144fn select_provider(input: &RecommendationInput) -> (CloudProvider, String) {
146 if let Some(ref infra) = input.analysis.infrastructure {
148 if infra.has_kubernetes || input.has_existing_k8s {
150 if input.available_providers.contains(&CloudProvider::Gcp) {
152 return (
153 CloudProvider::Gcp,
154 "GCP recommended: Existing Kubernetes infrastructure detected".to_string(),
155 );
156 }
157 }
158 }
159
160 let has_hetzner = input.available_providers.contains(&CloudProvider::Hetzner);
162 let has_gcp = input.available_providers.contains(&CloudProvider::Gcp);
163 let has_azure = input.available_providers.contains(&CloudProvider::Azure);
164
165 let connected: Vec<&str> = input
167 .available_providers
168 .iter()
169 .filter(|p| p.is_available())
170 .map(|p| p.display_name())
171 .collect();
172 let also_available = if connected.len() > 1 {
173 format!(". Also connected: {}", connected.to_vec().join(", "))
174 } else {
175 String::new()
176 };
177
178 if has_hetzner && has_gcp {
179 (
180 CloudProvider::Hetzner,
181 format!(
182 "Hetzner recommended: Cost-effective for web services, European data centers{}",
183 also_available
184 ),
185 )
186 } else if has_hetzner {
187 (
188 CloudProvider::Hetzner,
189 format!(
190 "Hetzner recommended: Cost-effective dedicated servers with predictable pricing{}",
191 also_available
192 ),
193 )
194 } else if has_gcp {
195 (
196 CloudProvider::Gcp,
197 format!(
198 "GCP recommended: Scalable serverless options with Cloud Run{}",
199 also_available
200 ),
201 )
202 } else if has_azure {
203 (
204 CloudProvider::Azure,
205 format!(
206 "Azure recommended: Container Apps with auto-scaling and scale-to-zero{}",
207 also_available
208 ),
209 )
210 } else {
211 (
213 CloudProvider::Hetzner,
214 "Hetzner selected: Default provider".to_string(),
215 )
216 }
217}
218
219fn select_target(input: &RecommendationInput) -> (DeploymentTarget, String) {
221 if let Some(ref infra) = input.analysis.infrastructure {
223 if infra.has_kubernetes && input.has_existing_k8s {
224 return (
225 DeploymentTarget::Kubernetes,
226 "Kubernetes recommended: Existing K8s manifests detected and clusters available"
227 .to_string(),
228 );
229 }
230 }
231
232 (
234 DeploymentTarget::CloudRunner,
235 "Cloud Runner recommended: Simpler deployment, no cluster management required".to_string(),
236 )
237}
238
239struct MachineTypeResult {
241 machine_type: String,
242 reasoning: String,
243 cpu: Option<String>,
244 memory: Option<String>,
245}
246
247fn select_machine_type(analysis: &ProjectAnalysis, provider: &CloudProvider) -> MachineTypeResult {
249 let framework_info = get_framework_resource_hint(analysis);
251
252 match provider {
253 CloudProvider::Hetzner => {
254 let (machine_type, reasoning) = match framework_info.memory_requirement {
255 MemoryRequirement::Low => (
256 "cx23".to_string(),
257 format!(
258 "cx23 (2 vCPU, 4GB) recommended: {} services are memory-efficient",
259 framework_info.name
260 ),
261 ),
262 MemoryRequirement::Medium => (
263 "cx33".to_string(),
264 format!(
265 "cx33 (4 vCPU, 8GB) recommended: {} may benefit from more resources",
266 framework_info.name
267 ),
268 ),
269 MemoryRequirement::High => (
270 "cx43".to_string(),
271 format!(
272 "cx43 (8 vCPU, 16GB) recommended: {} requires significant memory (JVM, ML, etc.)",
273 framework_info.name
274 ),
275 ),
276 };
277 MachineTypeResult {
278 machine_type,
279 reasoning,
280 cpu: None,
281 memory: None,
282 }
283 }
284 CloudProvider::Gcp => {
285 let (cpu, mem, reasoning) = match framework_info.memory_requirement {
287 MemoryRequirement::Low => (
288 "1",
289 "512Mi",
290 format!(
291 "Cloud Run 1 vCPU / 512Mi recommended: {} services are lightweight",
292 framework_info.name
293 ),
294 ),
295 MemoryRequirement::Medium => (
296 "2",
297 "2Gi",
298 format!(
299 "Cloud Run 2 vCPU / 2Gi recommended: {} may need moderate resources",
300 framework_info.name
301 ),
302 ),
303 MemoryRequirement::High => (
304 "4",
305 "8Gi",
306 format!(
307 "Cloud Run 4 vCPU / 8Gi recommended: {} requires significant memory",
308 framework_info.name
309 ),
310 ),
311 };
312 MachineTypeResult {
313 machine_type: format!("{}-cpu-{}mem", cpu, mem),
314 reasoning,
315 cpu: Some(cpu.to_string()),
316 memory: Some(mem.to_string()),
317 }
318 }
319 CloudProvider::Azure => {
320 let (cpu, mem, reasoning) = match framework_info.memory_requirement {
322 MemoryRequirement::Low => (
323 "0.5",
324 "1.0Gi",
325 format!(
326 "ACA 0.5 vCPU / 1 GB recommended: {} services are lightweight",
327 framework_info.name
328 ),
329 ),
330 MemoryRequirement::Medium => (
331 "1.0",
332 "2.0Gi",
333 format!(
334 "ACA 1 vCPU / 2 GB recommended: {} may need moderate resources",
335 framework_info.name
336 ),
337 ),
338 MemoryRequirement::High => (
339 "2.0",
340 "4.0Gi",
341 format!(
342 "ACA 2 vCPU / 4 GB recommended: {} requires significant memory",
343 framework_info.name
344 ),
345 ),
346 };
347 MachineTypeResult {
348 machine_type: format!("{}-cpu-{}mem", cpu, mem),
349 reasoning,
350 cpu: Some(cpu.to_string()),
351 memory: Some(mem.to_string()),
352 }
353 }
354 _ => {
355 MachineTypeResult {
357 machine_type: get_default_machine_type(provider).to_string(),
358 reasoning: "Default machine type selected".to_string(),
359 cpu: None,
360 memory: None,
361 }
362 }
363 }
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368enum MemoryRequirement {
369 Low, Medium, High, }
373
374struct FrameworkResourceHint {
376 name: String,
377 memory_requirement: MemoryRequirement,
378}
379
380fn get_framework_resource_hint(analysis: &ProjectAnalysis) -> FrameworkResourceHint {
382 for tech in &analysis.technologies {
384 if matches!(tech.category, TechnologyCategory::BackendFramework) {
385 let name_lower = tech.name.to_lowercase();
386
387 if name_lower.contains("spring")
389 || name_lower.contains("quarkus")
390 || name_lower.contains("micronaut")
391 || name_lower.contains("ktor")
392 {
393 return FrameworkResourceHint {
394 name: tech.name.clone(),
395 memory_requirement: MemoryRequirement::High,
396 };
397 }
398
399 if name_lower.contains("gin")
401 || name_lower.contains("echo")
402 || name_lower.contains("fiber")
403 || name_lower.contains("chi")
404 || name_lower.contains("actix")
405 || name_lower.contains("axum")
406 || name_lower.contains("rocket")
407 {
408 return FrameworkResourceHint {
409 name: tech.name.clone(),
410 memory_requirement: MemoryRequirement::Low,
411 };
412 }
413
414 if name_lower.contains("express")
416 || name_lower.contains("fastify")
417 || name_lower.contains("koa")
418 || name_lower.contains("hono")
419 || name_lower.contains("elysia")
420 || name_lower.contains("nest")
421 {
422 return FrameworkResourceHint {
423 name: tech.name.clone(),
424 memory_requirement: MemoryRequirement::Low,
425 };
426 }
427
428 if name_lower.contains("fastapi")
430 || name_lower.contains("flask")
431 || name_lower.contains("django")
432 {
433 return FrameworkResourceHint {
434 name: tech.name.clone(),
435 memory_requirement: MemoryRequirement::Medium,
436 };
437 }
438 }
439 }
440
441 for lang in &analysis.languages {
443 let name_lower = lang.name.to_lowercase();
444
445 if name_lower.contains("java")
446 || name_lower.contains("kotlin")
447 || name_lower.contains("scala")
448 {
449 return FrameworkResourceHint {
450 name: lang.name.clone(),
451 memory_requirement: MemoryRequirement::High,
452 };
453 }
454
455 if name_lower.contains("go") || name_lower.contains("rust") {
456 return FrameworkResourceHint {
457 name: lang.name.clone(),
458 memory_requirement: MemoryRequirement::Low,
459 };
460 }
461
462 if name_lower.contains("javascript") || name_lower.contains("typescript") {
463 return FrameworkResourceHint {
464 name: lang.name.clone(),
465 memory_requirement: MemoryRequirement::Low,
466 };
467 }
468
469 if name_lower.contains("python") {
470 return FrameworkResourceHint {
471 name: lang.name.clone(),
472 memory_requirement: MemoryRequirement::Medium,
473 };
474 }
475 }
476
477 FrameworkResourceHint {
479 name: "Unknown".to_string(),
480 memory_requirement: MemoryRequirement::Medium,
481 }
482}
483
484fn select_region(provider: &CloudProvider, user_hint: Option<&str>) -> (String, String) {
486 if let Some(hint) = user_hint {
487 let regions = get_regions_for_provider(provider);
488 if regions.is_empty() || regions.iter().any(|r| r.id == hint) {
491 return (
492 hint.to_string(),
493 format!("{} selected: User preference", hint),
494 );
495 }
496 }
497
498 let default_region = get_default_region(provider);
499 let reasoning = match provider {
500 CloudProvider::Hetzner => format!(
501 "{} (Nuremberg) selected: Default EU region, low latency for European users",
502 default_region
503 ),
504 CloudProvider::Gcp => format!(
505 "{} (Iowa) selected: Default US region, good general-purpose choice",
506 default_region
507 ),
508 CloudProvider::Azure => format!(
509 "{} (Virginia) selected: Default US region, broad service availability",
510 default_region
511 ),
512 _ => format!("{} selected: Default region for provider", default_region),
513 };
514
515 (default_region.to_string(), reasoning)
516}
517
518fn select_port(analysis: &ProjectAnalysis) -> (u16, String) {
520 let port_priority = |source: &Option<PortSource>| -> u8 {
522 match source {
523 Some(PortSource::SourceCode) => 7,
524 Some(PortSource::PackageJson) => 6,
525 Some(PortSource::ConfigFile) => 5,
526 Some(PortSource::FrameworkDefault) => 4,
527 Some(PortSource::Dockerfile) => 3,
528 Some(PortSource::DockerCompose) => 2,
529 Some(PortSource::EnvVar) => 1,
530 None => 0,
531 }
532 };
533
534 let best_port = analysis
536 .ports
537 .iter()
538 .max_by_key(|p| port_priority(&p.source));
539
540 if let Some(port) = best_port {
541 let source_desc = match &port.source {
542 Some(PortSource::SourceCode) => "Detected from source code analysis",
543 Some(PortSource::PackageJson) => "Detected from package.json scripts",
544 Some(PortSource::ConfigFile) => "Detected from configuration file",
545 Some(PortSource::FrameworkDefault) => {
546 let framework_name = analysis
548 .technologies
549 .iter()
550 .find(|t| {
551 matches!(
552 t.category,
553 TechnologyCategory::BackendFramework
554 | TechnologyCategory::MetaFramework
555 )
556 })
557 .map(|t| t.name.as_str())
558 .unwrap_or("framework");
559 return (
560 port.number,
561 format!("Framework default ({}: {})", framework_name, port.number),
562 );
563 }
564 Some(PortSource::Dockerfile) => "Detected from Dockerfile EXPOSE",
565 Some(PortSource::DockerCompose) => "Detected from docker-compose.yml",
566 Some(PortSource::EnvVar) => "Detected from environment variable reference",
567 None => "Detected from project analysis",
568 };
569 return (port.number, source_desc.to_string());
570 }
571
572 (
574 8080,
575 "Default port 8080: No port detected in project".to_string(),
576 )
577}
578
579fn select_health_endpoint(analysis: &ProjectAnalysis) -> Option<String> {
581 analysis
583 .health_endpoints
584 .iter()
585 .max_by(|a, b| {
586 a.confidence
587 .partial_cmp(&b.confidence)
588 .unwrap_or(std::cmp::Ordering::Equal)
589 })
590 .map(|e| e.path.clone())
591}
592
593fn calculate_confidence(
595 analysis: &ProjectAnalysis,
596 port_source: &str,
597 has_health_endpoint: bool,
598) -> f32 {
599 let mut confidence: f32 = 0.5; if port_source.contains("source code") || port_source.contains("package.json") {
603 confidence += 0.2;
604 } else if port_source.contains("Dockerfile") || port_source.contains("framework") {
605 confidence += 0.1;
606 }
607
608 let has_framework = analysis.technologies.iter().any(|t| {
610 matches!(
611 t.category,
612 TechnologyCategory::BackendFramework | TechnologyCategory::MetaFramework
613 )
614 });
615 if has_framework {
616 confidence += 0.15;
617 }
618
619 if has_health_endpoint {
621 confidence += 0.1;
622 }
623
624 if port_source.contains("No port detected") || port_source.contains("Default port") {
626 confidence -= 0.2;
627 }
628
629 confidence.clamp(0.0, 1.0)
630}
631
632fn build_alternatives(
634 selected_provider: &CloudProvider,
635 available_providers: &[CloudProvider],
636) -> RecommendationAlternatives {
637 let providers: Vec<ProviderOption> = CloudProvider::all()
639 .iter()
640 .map(|p| ProviderOption {
641 provider: p.clone(),
642 available: available_providers.contains(p) && p.is_available(),
643 reason_if_unavailable: if !p.is_available() {
644 Some(format!("{} coming soon", p.display_name()))
645 } else if !available_providers.contains(p) {
646 Some("Not connected".to_string())
647 } else {
648 None
649 },
650 })
651 .collect();
652
653 let machine_types: Vec<MachineOption> = get_machine_types_for_provider(selected_provider)
656 .iter()
657 .map(|m| MachineOption {
658 machine_type: m.id.to_string(),
659 vcpu: m.cpu.to_string(),
660 memory_gb: m.memory.to_string(),
661 description: m.description.map(String::from).unwrap_or_default(),
662 })
663 .collect();
664
665 let regions: Vec<RegionOption> = get_regions_for_provider(selected_provider)
668 .iter()
669 .map(|r| RegionOption {
670 region: r.id.to_string(),
671 display_name: format!("{} ({})", r.name, r.location),
672 })
673 .collect();
674
675 RecommendationAlternatives {
676 providers,
677 machine_types,
678 regions,
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685 use crate::analyzer::{
686 AnalysisMetadata, ArchitectureType, DetectedLanguage, DetectedTechnology, HealthEndpoint,
687 InfrastructurePresence, Port, ProjectType, TechnologyCategory,
688 };
689 use std::collections::HashMap;
690 use std::path::PathBuf;
691
692 fn create_minimal_analysis() -> ProjectAnalysis {
693 #[allow(deprecated)]
694 ProjectAnalysis {
695 project_root: PathBuf::from("/test"),
696 languages: vec![],
697 technologies: vec![],
698 frameworks: vec![],
699 dependencies: HashMap::new(),
700 entry_points: vec![],
701 ports: vec![],
702 health_endpoints: vec![],
703 environment_variables: vec![],
704 project_type: ProjectType::WebApplication,
705 build_scripts: vec![],
706 services: vec![],
707 architecture_type: ArchitectureType::Monolithic,
708 docker_analysis: None,
709 infrastructure: None,
710 analysis_metadata: AnalysisMetadata {
711 timestamp: "2024-01-01T00:00:00Z".to_string(),
712 analyzer_version: "0.1.0".to_string(),
713 analysis_duration_ms: 100,
714 files_analyzed: 10,
715 confidence_score: 0.8,
716 },
717 }
718 }
719
720 #[test]
721 fn test_nodejs_express_recommendation() {
722 let mut analysis = create_minimal_analysis();
723 analysis.languages.push(DetectedLanguage {
724 name: "JavaScript".to_string(),
725 version: Some("18".to_string()),
726 confidence: 0.9,
727 files: vec![],
728 main_dependencies: vec!["express".to_string()],
729 dev_dependencies: vec![],
730 package_manager: Some("npm".to_string()),
731 });
732 analysis.technologies.push(DetectedTechnology {
733 name: "Express".to_string(),
734 version: Some("4.18".to_string()),
735 category: TechnologyCategory::BackendFramework,
736 confidence: 0.9,
737 requires: vec![],
738 conflicts_with: vec![],
739 is_primary: true,
740 file_indicators: vec![],
741 });
742 analysis.ports.push(Port {
743 number: 3000,
744 protocol: crate::analyzer::Protocol::Http,
745 description: Some("Express default".to_string()),
746 source: Some(PortSource::PackageJson),
747 });
748
749 let input = RecommendationInput {
750 analysis,
751 available_providers: vec![CloudProvider::Hetzner, CloudProvider::Gcp],
752 has_existing_k8s: false,
753 user_region_hint: None,
754 };
755
756 let rec = recommend_deployment(input);
757
758 assert!(
760 rec.machine_type == "cx23"
761 || rec.machine_type.contains("1-cpu")
762 || rec.machine_type == "e2-small"
763 );
764 assert_eq!(rec.port, 3000);
765 assert!(rec.machine_reasoning.contains("Express"));
766 }
767
768 #[test]
769 fn test_java_spring_recommendation() {
770 let mut analysis = create_minimal_analysis();
771 analysis.languages.push(DetectedLanguage {
772 name: "Java".to_string(),
773 version: Some("17".to_string()),
774 confidence: 0.9,
775 files: vec![],
776 main_dependencies: vec!["spring-boot".to_string()],
777 dev_dependencies: vec![],
778 package_manager: Some("maven".to_string()),
779 });
780 analysis.technologies.push(DetectedTechnology {
781 name: "Spring Boot".to_string(),
782 version: Some("3.0".to_string()),
783 category: TechnologyCategory::BackendFramework,
784 confidence: 0.9,
785 requires: vec![],
786 conflicts_with: vec![],
787 is_primary: true,
788 file_indicators: vec![],
789 });
790 analysis.ports.push(Port {
791 number: 8080,
792 protocol: crate::analyzer::Protocol::Http,
793 description: Some("Spring Boot default".to_string()),
794 source: Some(PortSource::FrameworkDefault),
795 });
796
797 let input = RecommendationInput {
798 analysis,
799 available_providers: vec![CloudProvider::Hetzner],
800 has_existing_k8s: false,
801 user_region_hint: None,
802 };
803
804 let rec = recommend_deployment(input);
805
806 assert!(rec.machine_type == "cx43" || rec.machine_reasoning.contains("memory"));
808 assert_eq!(rec.port, 8080);
809 }
810
811 #[test]
812 fn test_existing_k8s_suggests_kubernetes_target() {
813 let mut analysis = create_minimal_analysis();
814 analysis.infrastructure = Some(InfrastructurePresence {
815 has_kubernetes: true,
816 kubernetes_paths: vec![PathBuf::from("k8s/")],
817 has_helm: false,
818 helm_chart_paths: vec![],
819 has_docker_compose: false,
820 has_terraform: false,
821 terraform_paths: vec![],
822 has_deployment_config: false,
823 summary: Some("Kubernetes manifests detected".to_string()),
824 });
825
826 let input = RecommendationInput {
827 analysis,
828 available_providers: vec![CloudProvider::Gcp],
829 has_existing_k8s: true, user_region_hint: None,
831 };
832
833 let rec = recommend_deployment(input);
834 assert_eq!(rec.target, DeploymentTarget::Kubernetes);
835 assert!(rec.target_reasoning.contains("Kubernetes"));
836 }
837
838 #[test]
839 fn test_no_k8s_defaults_to_cloud_runner() {
840 let analysis = create_minimal_analysis();
841
842 let input = RecommendationInput {
843 analysis,
844 available_providers: vec![CloudProvider::Hetzner],
845 has_existing_k8s: false,
846 user_region_hint: None,
847 };
848
849 let rec = recommend_deployment(input);
850 assert_eq!(rec.target, DeploymentTarget::CloudRunner);
851 assert!(rec.target_reasoning.contains("Cloud Runner"));
852 }
853
854 #[test]
855 fn test_port_fallback_to_8080() {
856 let analysis = create_minimal_analysis();
857
858 let input = RecommendationInput {
859 analysis,
860 available_providers: vec![CloudProvider::Hetzner],
861 has_existing_k8s: false,
862 user_region_hint: None,
863 };
864
865 let rec = recommend_deployment(input);
866 assert_eq!(rec.port, 8080);
867 assert!(
868 rec.port_source.contains("No port detected") || rec.port_source.contains("Default")
869 );
870 }
871
872 #[test]
873 fn test_health_endpoint_included_when_detected() {
874 let mut analysis = create_minimal_analysis();
875 analysis.health_endpoints.push(HealthEndpoint {
876 path: "/health".to_string(),
877 confidence: 0.9,
878 source: crate::analyzer::HealthEndpointSource::CodePattern,
879 description: Some("Found in source code".to_string()),
880 });
881
882 let input = RecommendationInput {
883 analysis,
884 available_providers: vec![CloudProvider::Hetzner],
885 has_existing_k8s: false,
886 user_region_hint: None,
887 };
888
889 let rec = recommend_deployment(input);
890 assert_eq!(rec.health_check_path, Some("/health".to_string()));
891 }
892
893 #[test]
894 fn test_alternatives_populated() {
895 let analysis = create_minimal_analysis();
896
897 let input = RecommendationInput {
900 analysis,
901 available_providers: vec![CloudProvider::Gcp],
902 has_existing_k8s: false,
903 user_region_hint: None,
904 };
905
906 let rec = recommend_deployment(input);
907
908 assert!(!rec.alternatives.providers.is_empty());
909 assert!(!rec.alternatives.machine_types.is_empty());
910 assert!(!rec.alternatives.regions.is_empty());
911 }
912
913 #[test]
914 fn test_user_region_hint_respected() {
915 let analysis = create_minimal_analysis();
916
917 let input = RecommendationInput {
918 analysis,
919 available_providers: vec![CloudProvider::Hetzner],
920 has_existing_k8s: false,
921 user_region_hint: Some("fsn1".to_string()),
922 };
923
924 let rec = recommend_deployment(input);
925 assert_eq!(rec.region, "fsn1");
926 assert!(rec.region_reasoning.contains("User preference"));
927 }
928
929 #[test]
930 fn test_go_service_gets_small_machine() {
931 let mut analysis = create_minimal_analysis();
932 analysis.technologies.push(DetectedTechnology {
933 name: "Gin".to_string(),
934 version: Some("1.9".to_string()),
935 category: TechnologyCategory::BackendFramework,
936 confidence: 0.9,
937 requires: vec![],
938 conflicts_with: vec![],
939 is_primary: true,
940 file_indicators: vec![],
941 });
942
943 let input = RecommendationInput {
944 analysis,
945 available_providers: vec![CloudProvider::Hetzner],
946 has_existing_k8s: false,
947 user_region_hint: None,
948 };
949
950 let rec = recommend_deployment(input);
951 assert_eq!(rec.machine_type, "cx23");
953 assert!(
954 rec.machine_reasoning.contains("memory-efficient")
955 || rec.machine_reasoning.contains("Gin")
956 );
957 }
958}