Skip to main content

syncable_cli/wizard/
recommendations.rs

1//! Deployment recommendation engine
2//!
3//! Generates intelligent deployment recommendations based on project analysis.
4//! Takes analyzer output and produces actionable suggestions with reasoning.
5
6use 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/// A deployment recommendation with reasoning
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DeploymentRecommendation {
17    /// Recommended cloud provider
18    pub provider: CloudProvider,
19    /// Why this provider was recommended
20    pub provider_reasoning: String,
21
22    /// Recommended deployment target
23    pub target: DeploymentTarget,
24    /// Why this target was recommended
25    pub target_reasoning: String,
26
27    /// Recommended machine type (provider-specific, used for Hetzner)
28    pub machine_type: String,
29    /// Why this machine type was recommended
30    pub machine_reasoning: String,
31
32    /// Recommended CPU allocation (for GCP Cloud Run / Azure ACA)
33    pub cpu: Option<String>,
34    /// Recommended memory allocation (for GCP Cloud Run / Azure ACA)
35    pub memory: Option<String>,
36
37    /// Recommended region
38    pub region: String,
39    /// Why this region was recommended
40    pub region_reasoning: String,
41
42    /// Detected port to expose
43    pub port: u16,
44    /// Where the port was detected from
45    pub port_source: String,
46
47    /// Recommended health check path (if detected)
48    pub health_check_path: Option<String>,
49
50    /// Overall confidence in recommendation (0.0-1.0)
51    pub confidence: f32,
52
53    /// Alternative recommendations if user wants to customize
54    pub alternatives: RecommendationAlternatives,
55}
56
57/// Alternative options for customization
58#[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/// Provider option with availability info
66#[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/// Machine type option with specs
74#[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/// Region option with display name
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct RegionOption {
85    pub region: String,
86    pub display_name: String,
87}
88
89/// Input for generating recommendations
90#[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
98/// Generate deployment recommendation based on project analysis
99pub fn recommend_deployment(input: RecommendationInput) -> DeploymentRecommendation {
100    // 1. Select provider
101    let (provider, provider_reasoning) = select_provider(&input);
102
103    // 2. Select target (K8s vs Cloud Runner)
104    let (target, target_reasoning) = select_target(&input);
105
106    // 3. Select machine type based on detected framework
107    let machine_result = select_machine_type(&input.analysis, &provider);
108
109    // 4. Select region
110    let (region, region_reasoning) = select_region(&provider, input.user_region_hint.as_deref());
111
112    // 5. Select port
113    let (port, port_source) = select_port(&input.analysis);
114
115    // 6. Select health check path
116    let health_check_path = select_health_endpoint(&input.analysis);
117
118    // 7. Calculate confidence
119    let confidence =
120        calculate_confidence(&input.analysis, &port_source, health_check_path.is_some());
121
122    // 8. Build alternatives
123    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
144/// Select the best provider based on available options and project characteristics
145fn select_provider(input: &RecommendationInput) -> (CloudProvider, String) {
146    // Check if infrastructure suggests a specific provider
147    if let Some(ref infra) = input.analysis.infrastructure {
148        // If they have existing K8s clusters, prefer the provider they're already using
149        if infra.has_kubernetes || input.has_existing_k8s {
150            // For now, default to Hetzner for K8s unless GCP clusters detected
151            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    // Check which providers are available
161    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    // Build list of connected provider names for reasoning
166    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        // Fallback - shouldn't happen in practice
212        (
213            CloudProvider::Hetzner,
214            "Hetzner selected: Default provider".to_string(),
215        )
216    }
217}
218
219/// Select deployment target based on existing infrastructure
220fn select_target(input: &RecommendationInput) -> (DeploymentTarget, String) {
221    // Check for existing Kubernetes infrastructure
222    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    // Default to Cloud Runner for simplicity
233    (
234        DeploymentTarget::CloudRunner,
235        "Cloud Runner recommended: Simpler deployment, no cluster management required".to_string(),
236    )
237}
238
239/// Machine type selection result with optional CPU/memory for Cloud Run / ACA
240struct MachineTypeResult {
241    machine_type: String,
242    reasoning: String,
243    cpu: Option<String>,
244    memory: Option<String>,
245}
246
247/// Select machine type based on detected framework characteristics
248fn select_machine_type(analysis: &ProjectAnalysis, provider: &CloudProvider) -> MachineTypeResult {
249    // Detect framework type to determine resource needs
250    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            // Use Cloud Run CPU/memory instead of Compute Engine machine types
286            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            // Use Azure Container Apps resource pairs
321            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            // Fallback for unsupported providers
356            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/// Memory requirement categories
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368enum MemoryRequirement {
369    Low,    // Node.js, Go, Rust - efficient runtimes
370    Medium, // Python, Ruby - moderate memory
371    High,   // Java/JVM, ML frameworks - memory intensive
372}
373
374/// Framework resource hint for machine selection
375struct FrameworkResourceHint {
376    name: String,
377    memory_requirement: MemoryRequirement,
378}
379
380/// Analyze project to determine framework resource requirements
381fn get_framework_resource_hint(analysis: &ProjectAnalysis) -> FrameworkResourceHint {
382    // Check for JVM-based frameworks (high memory)
383    for tech in &analysis.technologies {
384        if matches!(tech.category, TechnologyCategory::BackendFramework) {
385            let name_lower = tech.name.to_lowercase();
386
387            // JVM frameworks - high memory
388            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            // Go, Rust frameworks - low memory
400            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            // Node.js frameworks - low memory
415            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            // Python frameworks - medium memory
429            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    // Check languages if no framework detected
442    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    // Default fallback
478    FrameworkResourceHint {
479        name: "Unknown".to_string(),
480        memory_requirement: MemoryRequirement::Medium,
481    }
482}
483
484/// Select region based on user hint or defaults
485fn 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        // For providers with dynamic regions (empty static list), accept the hint as-is.
489        // For providers with static region lists, validate against the list.
490        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
518/// Select the best port from analysis results
519fn select_port(analysis: &ProjectAnalysis) -> (u16, String) {
520    // Priority: SourceCode > PackageJson > ConfigFile > FrameworkDefault > Dockerfile > DockerCompose > EnvVar
521    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    // Find the highest priority port
535    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                // Try to get framework name
547                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    // Fallback to 8080
573    (
574        8080,
575        "Default port 8080: No port detected in project".to_string(),
576    )
577}
578
579/// Select the best health endpoint from analysis
580fn select_health_endpoint(analysis: &ProjectAnalysis) -> Option<String> {
581    // Find highest confidence health endpoint
582    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
593/// Calculate overall confidence in the recommendation
594fn calculate_confidence(
595    analysis: &ProjectAnalysis,
596    port_source: &str,
597    has_health_endpoint: bool,
598) -> f32 {
599    let mut confidence: f32 = 0.5; // Base confidence
600
601    // Boost for detected port from reliable source
602    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    // Boost for detected framework
609    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    // Boost for health endpoint
620    if has_health_endpoint {
621        confidence += 0.1;
622    }
623
624    // Penalty if using fallback port
625    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
632/// Build alternative options for user customization
633fn build_alternatives(
634    selected_provider: &CloudProvider,
635    available_providers: &[CloudProvider],
636) -> RecommendationAlternatives {
637    // Build provider options
638    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    // Build machine type options for selected provider
654    // For Hetzner, returns empty - agent must use list_hetzner_availability tool
655    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    // Build region options for selected provider
666    // For Hetzner, returns empty - agent must use list_hetzner_availability tool
667    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        // Express should get a small machine
759        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        // Spring Boot should get a larger machine (JVM needs memory)
807        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 has K8s clusters
830            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        // Use GCP-only so static machine types and regions are populated
898        // (Hetzner uses dynamic types/regions via API, so its alternatives are empty)
899        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        // Go services should get small machine
952        assert_eq!(rec.machine_type, "cx23");
953        assert!(
954            rec.machine_reasoning.contains("memory-efficient")
955                || rec.machine_reasoning.contains("Gin")
956        );
957    }
958}