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)
28    pub machine_type: String,
29    /// Why this machine type was recommended
30    pub machine_reasoning: String,
31
32    /// Recommended region
33    pub region: String,
34    /// Why this region was recommended
35    pub region_reasoning: String,
36
37    /// Detected port to expose
38    pub port: u16,
39    /// Where the port was detected from
40    pub port_source: String,
41
42    /// Recommended health check path (if detected)
43    pub health_check_path: Option<String>,
44
45    /// Overall confidence in recommendation (0.0-1.0)
46    pub confidence: f32,
47
48    /// Alternative recommendations if user wants to customize
49    pub alternatives: RecommendationAlternatives,
50}
51
52/// Alternative options for customization
53#[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/// Provider option with availability info
61#[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/// Machine type option with specs
69#[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/// Region option with display name
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct RegionOption {
80    pub region: String,
81    pub display_name: String,
82}
83
84/// Input for generating recommendations
85#[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
93/// Generate deployment recommendation based on project analysis
94pub fn recommend_deployment(input: RecommendationInput) -> DeploymentRecommendation {
95    // 1. Select provider
96    let (provider, provider_reasoning) = select_provider(&input);
97
98    // 2. Select target (K8s vs Cloud Runner)
99    let (target, target_reasoning) = select_target(&input);
100
101    // 3. Select machine type based on detected framework
102    let (machine_type, machine_reasoning) = select_machine_type(&input.analysis, &provider);
103
104    // 4. Select region
105    let (region, region_reasoning) = select_region(&provider, input.user_region_hint.as_deref());
106
107    // 5. Select port
108    let (port, port_source) = select_port(&input.analysis);
109
110    // 6. Select health check path
111    let health_check_path = select_health_endpoint(&input.analysis);
112
113    // 7. Calculate confidence
114    let confidence = calculate_confidence(&input.analysis, &port_source, health_check_path.is_some());
115
116    // 8. Build alternatives
117    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
136/// Select the best provider based on available options and project characteristics
137fn select_provider(input: &RecommendationInput) -> (CloudProvider, String) {
138    // Check if infrastructure suggests a specific provider
139    if let Some(ref infra) = input.analysis.infrastructure {
140        // If they have existing K8s clusters, prefer the provider they're already using
141        if infra.has_kubernetes || input.has_existing_k8s {
142            // For now, default to Hetzner for K8s unless GCP clusters detected
143            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    // Check which providers are available
153    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        // Both available - prefer Hetzner for cost-effectiveness
158        (
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        // Fallback - shouldn't happen in practice
174        (
175            CloudProvider::Hetzner,
176            "Hetzner selected: Default provider".to_string(),
177        )
178    }
179}
180
181/// Select deployment target based on existing infrastructure
182fn select_target(input: &RecommendationInput) -> (DeploymentTarget, String) {
183    // Check for existing Kubernetes infrastructure
184    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    // Default to Cloud Runner for simplicity
194    (
195        DeploymentTarget::CloudRunner,
196        "Cloud Runner recommended: Simpler deployment, no cluster management required".to_string(),
197    )
198}
199
200/// Select machine type based on detected framework characteristics
201fn select_machine_type(analysis: &ProjectAnalysis, provider: &CloudProvider) -> (String, String) {
202    // Detect framework type to determine resource needs
203    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            // Fallback for unsupported providers
240            (
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/// Memory requirement categories
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252enum MemoryRequirement {
253    Low,    // Node.js, Go, Rust - efficient runtimes
254    Medium, // Python, Ruby - moderate memory
255    High,   // Java/JVM, ML frameworks - memory intensive
256}
257
258/// Framework resource hint for machine selection
259struct FrameworkResourceHint {
260    name: String,
261    memory_requirement: MemoryRequirement,
262}
263
264/// Analyze project to determine framework resource requirements
265fn get_framework_resource_hint(analysis: &ProjectAnalysis) -> FrameworkResourceHint {
266    // Check for JVM-based frameworks (high memory)
267    for tech in &analysis.technologies {
268        if matches!(tech.category, TechnologyCategory::BackendFramework) {
269            let name_lower = tech.name.to_lowercase();
270
271            // JVM frameworks - high memory
272            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            // Go, Rust frameworks - low memory
281            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            // Node.js frameworks - low memory
292            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            // Python frameworks - medium memory
302            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    // Check languages if no framework detected
313    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    // Default fallback
346    FrameworkResourceHint {
347        name: "Unknown".to_string(),
348        memory_requirement: MemoryRequirement::Medium,
349    }
350}
351
352/// Select region based on user hint or defaults
353fn select_region(provider: &CloudProvider, user_hint: Option<&str>) -> (String, String) {
354    if let Some(hint) = user_hint {
355        // Validate hint is a valid region for this provider
356        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
375/// Select the best port from analysis results
376fn select_port(analysis: &ProjectAnalysis) -> (u16, String) {
377    // Priority: SourceCode > PackageJson > ConfigFile > FrameworkDefault > Dockerfile > DockerCompose > EnvVar
378    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    // Find the highest priority port
392    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                // Try to get framework name
402                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    // Fallback to 8080
417    (8080, "Default port 8080: No port detected in project".to_string())
418}
419
420/// Select the best health endpoint from analysis
421fn select_health_endpoint(analysis: &ProjectAnalysis) -> Option<String> {
422    // Find highest confidence health endpoint
423    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
428/// Calculate overall confidence in the recommendation
429fn calculate_confidence(analysis: &ProjectAnalysis, port_source: &str, has_health_endpoint: bool) -> f32 {
430    let mut confidence: f32 = 0.5; // Base confidence
431
432    // Boost for detected port from reliable source
433    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    // Boost for detected framework
440    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    // Boost for health endpoint
447    if has_health_endpoint {
448        confidence += 0.1;
449    }
450
451    // Penalty if using fallback port
452    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
459/// Build alternative options for user customization
460fn build_alternatives(selected_provider: &CloudProvider, available_providers: &[CloudProvider]) -> RecommendationAlternatives {
461    // Build provider options
462    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    // Build machine type options for selected provider
478    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    // Build region options for selected provider
489    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        // Express should get a small machine
581        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        // Spring Boot should get a larger machine (JVM needs memory)
625        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 has K8s clusters
648            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        // Go services should get small machine
766        assert_eq!(rec.machine_type, "cx23");
767        assert!(rec.machine_reasoning.contains("memory-efficient") || rec.machine_reasoning.contains("Gin"));
768    }
769}