Skip to main content

xbp_cli/strategies/
project_detector.rs

1#![forbid(unsafe_code)]
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::HashMap;
6use std::fs;
7use std::path::Path;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ProjectRuntime {
12    Container,
13    NativeNode,
14    NativeRust,
15    Static,
16}
17
18impl ProjectRuntime {
19    pub const fn as_str(self) -> &'static str {
20        match self {
21            Self::Container => "container",
22            Self::NativeNode => "native_node",
23            Self::NativeRust => "native_rust",
24            Self::Static => "static",
25        }
26    }
27
28    pub const fn is_native(self) -> bool {
29        matches!(self, Self::NativeNode | Self::NativeRust)
30    }
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub enum ProjectType {
35    Docker {
36        has_dockerfile: bool,
37        detected_ports: Vec<u16>,
38    },
39    DockerCompose {
40        compose_files: Vec<String>,
41        detected_ports: Vec<u16>,
42    },
43    Railway {
44        has_railway_json: bool,
45        has_railway_toml: bool,
46    },
47    Vercel {
48        has_vercel_json: bool,
49        has_project_link: bool,
50    },
51    Python {
52        has_requirements_txt: bool,
53        has_pyproject_toml: bool,
54    },
55    OpenApi {
56        spec_files: Vec<String>,
57    },
58    Terraform {
59        tf_file_count: usize,
60    },
61    NextJs {
62        package_json: PackageJsonInfo,
63        has_next_config: bool,
64    },
65    NodeJs {
66        package_json: PackageJsonInfo,
67    },
68    Rust {
69        cargo_toml: CargoTomlInfo,
70    },
71    Unknown,
72}
73
74impl ProjectType {
75    pub const fn kind_slug(&self) -> &'static str {
76        match self {
77            Self::Docker { .. } => "docker",
78            Self::DockerCompose { .. } => "docker-compose",
79            Self::Railway { .. } => "railway",
80            Self::Vercel { .. } => "vercel",
81            Self::Python { .. } => "python",
82            Self::OpenApi { .. } => "openapi",
83            Self::Terraform { .. } => "terraform",
84            Self::NextJs { .. } => "nextjs",
85            Self::NodeJs { .. } => "nodejs",
86            Self::Rust { .. } => "rust",
87            Self::Unknown => "unknown",
88        }
89    }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum ProjectHint {
95    Docker,
96    DockerCompose,
97    Python,
98    NodeJs,
99    Rust,
100    Railway,
101    Vercel,
102    Go,
103}
104
105impl ProjectHint {
106    pub const fn as_str(self) -> &'static str {
107        match self {
108            Self::Docker => "docker",
109            Self::DockerCompose => "docker_compose",
110            Self::Python => "python",
111            Self::NodeJs => "nodejs",
112            Self::Rust => "rust",
113            Self::Railway => "railway",
114            Self::Vercel => "vercel",
115            Self::Go => "go",
116        }
117    }
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct PackageJsonInfo {
122    pub name: String,
123    pub version: String,
124    pub scripts: HashMap<String, String>,
125    pub dependencies: HashMap<String, String>,
126    pub dev_dependencies: HashMap<String, String>,
127    pub main: Option<String>,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct CargoTomlInfo {
132    pub name: String,
133    pub version: String,
134    pub description: Option<String>,
135    pub authors: Vec<String>,
136    pub edition: Option<String>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct DeploymentRecommendations {
141    pub build_command: Option<String>,
142    pub start_command: Option<String>,
143    pub install_command: Option<String>,
144    pub default_port: u16,
145    pub process_name: Option<String>,
146    pub requires_build: bool,
147}
148
149pub struct ProjectDetector;
150
151impl ProjectDetector {
152    pub async fn detect_project_type(project_path: &Path) -> Result<ProjectType, String> {
153        let project_path = project_path
154            .canonicalize()
155            .map_err(|e| format!("Failed to resolve project path: {}", e))?;
156
157        if let Ok(project_type) = Self::detect_docker_compose(&project_path).await {
158            return Ok(project_type);
159        }
160        if let Ok(project_type) = Self::detect_docker(&project_path).await {
161            return Ok(project_type);
162        }
163        if let Ok(project_type) = Self::detect_railway(&project_path).await {
164            return Ok(project_type);
165        }
166        if let Ok(project_type) = Self::detect_vercel(&project_path).await {
167            return Ok(project_type);
168        }
169        if let Ok(project_type) = Self::detect_python(&project_path).await {
170            return Ok(project_type);
171        }
172        if let Ok(project_type) = Self::detect_nextjs(&project_path).await {
173            return Ok(project_type);
174        }
175        if let Ok(project_type) = Self::detect_nodejs(&project_path).await {
176            return Ok(project_type);
177        }
178        if let Ok(project_type) = Self::detect_rust(&project_path).await {
179            return Ok(project_type);
180        }
181        if let Ok(project_type) = Self::detect_openapi(&project_path).await {
182            return Ok(project_type);
183        }
184        if let Ok(project_type) = Self::detect_terraform(&project_path).await {
185            return Ok(project_type);
186        }
187
188        Ok(ProjectType::Unknown)
189    }
190
191    pub fn detect_project_hints(project_path: &Path) -> Result<Vec<ProjectHint>, String> {
192        let project_path = project_path
193            .canonicalize()
194            .map_err(|e| format!("Failed to resolve project path: {}", e))?;
195        let mut hints = Vec::new();
196
197        if project_path.join("Dockerfile").exists() {
198            push_hint(&mut hints, ProjectHint::Docker);
199        }
200
201        if project_path.join("docker-compose.yml").exists()
202            || project_path.join("docker-compose.yaml").exists()
203            || project_path.join("compose.yml").exists()
204            || project_path.join("compose.yaml").exists()
205        {
206            push_hint(&mut hints, ProjectHint::DockerCompose);
207        }
208
209        if project_path.join("requirements.txt").exists()
210            || project_path.join("pyproject.toml").exists()
211            || project_path.join("setup.py").exists()
212        {
213            push_hint(&mut hints, ProjectHint::Python);
214        }
215
216        if project_path.join("package.json").exists() {
217            push_hint(&mut hints, ProjectHint::NodeJs);
218        }
219
220        if project_path.join("Cargo.toml").exists() {
221            push_hint(&mut hints, ProjectHint::Rust);
222        }
223
224        if project_path.join("railway.json").exists() || project_path.join("railway.toml").exists()
225        {
226            push_hint(&mut hints, ProjectHint::Railway);
227        }
228
229        if project_path.join("vercel.json").exists()
230            || project_path.join(".vercel").join("project.json").exists()
231        {
232            push_hint(&mut hints, ProjectHint::Vercel);
233        }
234
235        if project_path.join("go.mod").exists() {
236            push_hint(&mut hints, ProjectHint::Go);
237        }
238
239        Ok(hints)
240    }
241
242    pub fn detect_provider_manifests(project_path: &Path) -> Vec<String> {
243        let mut detections = Vec::new();
244
245        if project_path.join("Dockerfile").exists() {
246            detections.push("Dockerfile".to_string());
247        }
248
249        for name in [
250            "docker-compose.yml",
251            "docker-compose.yaml",
252            "compose.yml",
253            "compose.yaml",
254        ] {
255            if project_path.join(name).exists() {
256                detections.push(name.to_string());
257            }
258        }
259
260        if project_path.join("railway.json").exists() {
261            detections.push("railway.json".to_string());
262        }
263        if project_path.join("railway.toml").exists() {
264            detections.push("railway.toml".to_string());
265        }
266        if project_path.join("vercel.json").exists() {
267            detections.push("vercel.json".to_string());
268        }
269        if project_path.join(".vercel").join("project.json").exists() {
270            detections.push(".vercel/project.json".to_string());
271        }
272        if project_path.join("requirements.txt").exists() {
273            detections.push("requirements.txt".to_string());
274        }
275        if project_path.join("pyproject.toml").exists() {
276            detections.push("pyproject.toml".to_string());
277        }
278        if project_path.join("setup.py").exists() {
279            detections.push("setup.py".to_string());
280        }
281        if project_path.join("go.mod").exists() {
282            detections.push("go.mod".to_string());
283        }
284
285        for name in [
286            "openapi.yaml",
287            "openapi.yml",
288            "swagger.yaml",
289            "swagger.yml",
290            "swagger.json",
291        ] {
292            if project_path.join(name).exists() {
293                detections.push(name.to_string());
294            }
295        }
296
297        let tf_count = match fs::read_dir(project_path) {
298            Ok(entries) => entries
299                .filter_map(|entry| entry.ok())
300                .filter(|entry| {
301                    entry
302                        .path()
303                        .extension()
304                        .and_then(|s| s.to_str())
305                        .map(|ext| ext.eq_ignore_ascii_case("tf"))
306                        .unwrap_or(false)
307                })
308                .count(),
309            Err(_) => 0,
310        };
311        if tf_count > 0 {
312            detections.push(format!("Terraform (.tf) x{}", tf_count));
313        }
314
315        detections
316    }
317
318    pub fn get_deployment_recommendations(
319        project_path: &Path,
320        project_type: &ProjectType,
321    ) -> DeploymentRecommendations {
322        match project_type {
323            ProjectType::DockerCompose { detected_ports, .. } => DeploymentRecommendations {
324                build_command: Some("docker compose build".to_string()),
325                start_command: Some("docker compose up -d".to_string()),
326                install_command: None,
327                default_port: detected_ports.first().copied().unwrap_or(80),
328                process_name: None,
329                requires_build: true,
330            },
331            ProjectType::Docker { detected_ports, .. } => {
332                let default_port = detected_ports.first().copied().unwrap_or(80);
333                let image_tag = local_docker_image_tag(project_path, project_type);
334                let container_name = local_docker_container_name(project_path, project_type);
335
336                DeploymentRecommendations {
337                    build_command: Some(format!("docker build -t {image_tag} .")),
338                    start_command: Some(format!(
339                        "docker run -d --rm --name {container_name} -p {default_port}:{default_port} -e PORT {image_tag}"
340                    )),
341                    install_command: None,
342                    default_port,
343                    process_name: None,
344                    requires_build: true,
345                }
346            }
347            ProjectType::Railway { .. } => DeploymentRecommendations {
348                build_command: None,
349                start_command: None,
350                install_command: None,
351                default_port: 8080,
352                process_name: None,
353                requires_build: false,
354            },
355            ProjectType::Vercel { .. } => DeploymentRecommendations {
356                build_command: None,
357                start_command: None,
358                install_command: None,
359                default_port: 3000,
360                process_name: None,
361                requires_build: false,
362            },
363            ProjectType::Python {
364                has_requirements_txt,
365                has_pyproject_toml,
366            } => {
367                let pip = preferred_pip_command();
368                let install_command = if *has_requirements_txt {
369                    Some(format!("{pip} install -r requirements.txt"))
370                } else if *has_pyproject_toml {
371                    Some(format!("{pip} install -e ."))
372                } else {
373                    None
374                };
375
376                DeploymentRecommendations {
377                    build_command: None,
378                    start_command: None,
379                    install_command,
380                    default_port: 8000,
381                    process_name: None,
382                    requires_build: false,
383                }
384            }
385            ProjectType::OpenApi { .. } => DeploymentRecommendations {
386                build_command: None,
387                start_command: None,
388                install_command: None,
389                default_port: 8080,
390                process_name: None,
391                requires_build: false,
392            },
393            ProjectType::Terraform { .. } => DeploymentRecommendations {
394                build_command: None,
395                start_command: None,
396                install_command: None,
397                default_port: 8080,
398                process_name: None,
399                requires_build: false,
400            },
401            ProjectType::NextJs { package_json, .. } => DeploymentRecommendations {
402                build_command: Some("pnpm run build".to_string()),
403                start_command: Some("pnpm run start".to_string()),
404                install_command: Some("pnpm install".to_string()),
405                default_port: 3000,
406                process_name: Some(package_json.name.clone()),
407                requires_build: true,
408            },
409            ProjectType::NodeJs { package_json } => {
410                let start_cmd = package_json
411                    .scripts
412                    .get("start")
413                    .map(|script| {
414                        format!(
415                            "pnpm run {}",
416                            script.split_whitespace().next().unwrap_or("start")
417                        )
418                    })
419                    .or_else(|| {
420                        package_json
421                            .main
422                            .as_ref()
423                            .map(|main| format!("node {main}"))
424                    })
425                    .unwrap_or_else(|| "pnpm run start".to_string());
426
427                DeploymentRecommendations {
428                    build_command: package_json
429                        .scripts
430                        .get("build")
431                        .map(|_| "pnpm run build".to_string()),
432                    start_command: Some(start_cmd),
433                    install_command: Some("pnpm install".to_string()),
434                    default_port: 3000,
435                    process_name: Some(package_json.name.clone()),
436                    requires_build: package_json.scripts.contains_key("build"),
437                }
438            }
439            ProjectType::Rust { cargo_toml } => DeploymentRecommendations {
440                build_command: Some("cargo build --release".to_string()),
441                start_command: Some(format!("./target/release/{}", cargo_toml.name)),
442                install_command: None,
443                default_port: 8080,
444                process_name: Some(cargo_toml.name.clone()),
445                requires_build: true,
446            },
447            ProjectType::Unknown => DeploymentRecommendations {
448                build_command: None,
449                start_command: None,
450                install_command: None,
451                default_port: 8080,
452                process_name: None,
453                requires_build: false,
454            },
455        }
456    }
457
458    async fn detect_docker_compose(project_path: &Path) -> Result<ProjectType, String> {
459        let compose_names = [
460            "docker-compose.yml",
461            "docker-compose.yaml",
462            "compose.yml",
463            "compose.yaml",
464        ];
465
466        let mut compose_files = Vec::new();
467        for name in compose_names {
468            if project_path.join(name).exists() {
469                compose_files.push(name.to_string());
470            }
471        }
472
473        if compose_files.is_empty() {
474            return Err("No compose file found".to_string());
475        }
476
477        let mut detected_ports = Vec::new();
478        for file in &compose_files {
479            let path = project_path.join(file);
480            if let Ok(contents) = fs::read_to_string(&path) {
481                if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(&contents) {
482                    if let Some(services) = value.get("services").and_then(|v| v.as_mapping()) {
483                        for (_service_name, service_cfg) in services {
484                            let ports = service_cfg
485                                .get("ports")
486                                .and_then(|v| v.as_sequence())
487                                .cloned()
488                                .unwrap_or_default();
489
490                            for port_value in ports {
491                                if let Some(port_text) = port_value.as_str() {
492                                    if let Some(host) = port_text.split(':').next() {
493                                        if let Ok(port) = host.parse::<u16>() {
494                                            detected_ports.push(port);
495                                        }
496                                    }
497                                } else if let Some(port_number) = port_value.as_i64() {
498                                    if port_number >= 1 && port_number <= u16::MAX as i64 {
499                                        detected_ports.push(port_number as u16);
500                                    }
501                                }
502                            }
503                        }
504                    }
505                }
506            }
507        }
508
509        detected_ports.sort_unstable();
510        detected_ports.dedup();
511
512        Ok(ProjectType::DockerCompose {
513            compose_files,
514            detected_ports,
515        })
516    }
517
518    async fn detect_docker(project_path: &Path) -> Result<ProjectType, String> {
519        let dockerfile_path = project_path.join("Dockerfile");
520        if !dockerfile_path.exists() {
521            return Err("No Dockerfile found".to_string());
522        }
523        let detected_ports = detect_dockerfile_ports(&dockerfile_path);
524        Ok(ProjectType::Docker {
525            has_dockerfile: true,
526            detected_ports,
527        })
528    }
529
530    async fn detect_railway(project_path: &Path) -> Result<ProjectType, String> {
531        let has_railway_json = project_path.join("railway.json").exists();
532        let has_railway_toml = project_path.join("railway.toml").exists();
533        if !has_railway_json && !has_railway_toml {
534            return Err("No railway manifest found".to_string());
535        }
536
537        Ok(ProjectType::Railway {
538            has_railway_json,
539            has_railway_toml,
540        })
541    }
542
543    async fn detect_vercel(project_path: &Path) -> Result<ProjectType, String> {
544        let has_vercel_json = project_path.join("vercel.json").exists();
545        let has_project_link = project_path.join(".vercel").join("project.json").exists();
546        if !has_vercel_json && !has_project_link {
547            return Err("No vercel manifest found".to_string());
548        }
549
550        Ok(ProjectType::Vercel {
551            has_vercel_json,
552            has_project_link,
553        })
554    }
555
556    async fn detect_python(project_path: &Path) -> Result<ProjectType, String> {
557        let has_requirements_txt = project_path.join("requirements.txt").exists();
558        let has_pyproject_toml = project_path.join("pyproject.toml").exists();
559        if !has_requirements_txt && !has_pyproject_toml {
560            return Err("No python manifest found".to_string());
561        }
562
563        Ok(ProjectType::Python {
564            has_requirements_txt,
565            has_pyproject_toml,
566        })
567    }
568
569    async fn detect_openapi(project_path: &Path) -> Result<ProjectType, String> {
570        let names = [
571            "openapi.yaml",
572            "openapi.yml",
573            "swagger.yaml",
574            "swagger.yml",
575            "swagger.json",
576        ];
577        let mut spec_files = Vec::new();
578        for name in names {
579            if project_path.join(name).exists() {
580                spec_files.push(name.to_string());
581            }
582        }
583
584        if spec_files.is_empty() {
585            return Err("No OpenAPI spec found".to_string());
586        }
587        Ok(ProjectType::OpenApi { spec_files })
588    }
589
590    async fn detect_terraform(project_path: &Path) -> Result<ProjectType, String> {
591        let tf_file_count = fs::read_dir(project_path)
592            .map_err(|_| "Failed to read directory".to_string())?
593            .filter_map(|entry| entry.ok())
594            .filter(|entry| {
595                entry
596                    .path()
597                    .extension()
598                    .and_then(|s| s.to_str())
599                    .map(|ext| ext.eq_ignore_ascii_case("tf"))
600                    .unwrap_or(false)
601            })
602            .count();
603
604        if tf_file_count == 0 {
605            return Err("No terraform files found".to_string());
606        }
607        Ok(ProjectType::Terraform { tf_file_count })
608    }
609
610    async fn detect_nextjs(project_path: &Path) -> Result<ProjectType, String> {
611        let package_json_path = project_path.join("package.json");
612        let next_config_path = project_path.join("next.config.js");
613        let next_config_mjs_path = project_path.join("next.config.mjs");
614        let next_dir = project_path.join(".next");
615
616        if !package_json_path.exists() {
617            return Err("No package.json found".to_string());
618        }
619
620        let package_json = Self::parse_package_json(&package_json_path)?;
621        let has_next = package_json.dependencies.contains_key("next")
622            || package_json.dev_dependencies.contains_key("next");
623        if !has_next {
624            return Err("Next.js not found in dependencies".to_string());
625        }
626
627        let has_next_config =
628            next_config_path.exists() || next_config_mjs_path.exists() || next_dir.exists();
629
630        Ok(ProjectType::NextJs {
631            package_json,
632            has_next_config,
633        })
634    }
635
636    async fn detect_nodejs(project_path: &Path) -> Result<ProjectType, String> {
637        let package_json_path = project_path.join("package.json");
638        if !package_json_path.exists() {
639            return Err("No package.json found".to_string());
640        }
641
642        Ok(ProjectType::NodeJs {
643            package_json: Self::parse_package_json(&package_json_path)?,
644        })
645    }
646
647    async fn detect_rust(project_path: &Path) -> Result<ProjectType, String> {
648        let cargo_toml_path = project_path.join("Cargo.toml");
649        if !cargo_toml_path.exists() {
650            return Err("No Cargo.toml found".to_string());
651        }
652
653        Ok(ProjectType::Rust {
654            cargo_toml: Self::parse_cargo_toml(&cargo_toml_path)?,
655        })
656    }
657
658    fn parse_package_json(path: &Path) -> Result<PackageJsonInfo, String> {
659        let content =
660            fs::read_to_string(path).map_err(|e| format!("Failed to read package.json: {e}"))?;
661        let json: Value = serde_json::from_str(&content)
662            .map_err(|e| format!("Failed to parse package.json: {e}"))?;
663
664        Ok(PackageJsonInfo {
665            name: json["name"].as_str().unwrap_or("unknown").to_string(),
666            version: json["version"].as_str().unwrap_or("0.0.0").to_string(),
667            scripts: json["scripts"]
668                .as_object()
669                .map(|obj| {
670                    obj.iter()
671                        .filter_map(|(key, value)| {
672                            value.as_str().map(|text| (key.clone(), text.to_string()))
673                        })
674                        .collect()
675                })
676                .unwrap_or_default(),
677            dependencies: json["dependencies"]
678                .as_object()
679                .map(|obj| {
680                    obj.iter()
681                        .filter_map(|(key, value)| {
682                            value.as_str().map(|text| (key.clone(), text.to_string()))
683                        })
684                        .collect()
685                })
686                .unwrap_or_default(),
687            dev_dependencies: json["devDependencies"]
688                .as_object()
689                .map(|obj| {
690                    obj.iter()
691                        .filter_map(|(key, value)| {
692                            value.as_str().map(|text| (key.clone(), text.to_string()))
693                        })
694                        .collect()
695                })
696                .unwrap_or_default(),
697            main: json["main"].as_str().map(|value| value.to_string()),
698        })
699    }
700
701    fn parse_cargo_toml(path: &Path) -> Result<CargoTomlInfo, String> {
702        let content =
703            fs::read_to_string(path).map_err(|e| format!("Failed to read Cargo.toml: {e}"))?;
704        let toml_value: toml::Value =
705            toml::from_str(&content).map_err(|e| format!("Failed to parse Cargo.toml: {e}"))?;
706        let package = toml_value
707            .get("package")
708            .ok_or("No [package] section found in Cargo.toml")?;
709
710        Ok(CargoTomlInfo {
711            name: package
712                .get("name")
713                .and_then(|value| value.as_str())
714                .ok_or("No name found in [package] section")?
715                .to_string(),
716            version: package
717                .get("version")
718                .and_then(|value| value.as_str())
719                .unwrap_or("0.0.0")
720                .to_string(),
721            description: package
722                .get("description")
723                .and_then(|value| value.as_str())
724                .map(|value| value.to_string()),
725            authors: package
726                .get("authors")
727                .and_then(|value| value.as_array())
728                .map(|values| {
729                    values
730                        .iter()
731                        .filter_map(|value| value.as_str())
732                        .map(|value| value.to_string())
733                        .collect()
734                })
735                .unwrap_or_default(),
736            edition: package
737                .get("edition")
738                .and_then(|value| value.as_str())
739                .map(|value| value.to_string()),
740        })
741    }
742}
743
744fn push_hint(hints: &mut Vec<ProjectHint>, hint: ProjectHint) {
745    if !hints.contains(&hint) {
746        hints.push(hint);
747    }
748}
749
750pub fn infer_project_name(
751    project_root: &Path,
752    project_type: &ProjectType,
753    recommendations: &DeploymentRecommendations,
754) -> String {
755    if let Some(name) = recommendations.process_name.clone() {
756        return name;
757    }
758
759    match project_type {
760        ProjectType::Rust { cargo_toml } => cargo_toml.name.clone(),
761        ProjectType::NextJs { package_json, .. } | ProjectType::NodeJs { package_json } => {
762            package_json.name.clone()
763        }
764        _ => project_root
765            .file_name()
766            .and_then(|value| value.to_str())
767            .unwrap_or("app")
768            .to_string(),
769    }
770}
771
772pub fn infer_target(project_type: &ProjectType) -> Option<String> {
773    Some(
774        match project_type {
775            ProjectType::NextJs { .. } => "nextjs",
776            ProjectType::NodeJs { .. } => "expressjs",
777            ProjectType::Rust { .. } => "rust",
778            ProjectType::Python { .. } => "python",
779            ProjectType::DockerCompose { .. } => "docker-compose",
780            ProjectType::Docker { .. } => "docker",
781            ProjectType::Railway { .. } => "railway",
782            ProjectType::Vercel { .. } => "vercel",
783            ProjectType::OpenApi { .. } => "openapi",
784            ProjectType::Terraform { .. } => "terraform",
785            ProjectType::Unknown => "unknown",
786        }
787        .to_string(),
788    )
789}
790
791fn detect_dockerfile_ports(dockerfile_path: &Path) -> Vec<u16> {
792    let Ok(contents) = fs::read_to_string(dockerfile_path) else {
793        return Vec::new();
794    };
795
796    let mut detected_ports = Vec::new();
797    for line in contents.lines() {
798        let line = line.split('#').next().unwrap_or_default().trim();
799        if line.len() < 6 || !line[..6].eq_ignore_ascii_case("expose") {
800            continue;
801        }
802
803        let Some(rest) = line.get(6..) else {
804            continue;
805        };
806
807        for token in rest.split_whitespace() {
808            let port_token = token.split('/').next().unwrap_or_default().trim();
809            if let Ok(port) = port_token.parse::<u16>() {
810                detected_ports.push(port);
811            }
812        }
813    }
814
815    detected_ports.sort_unstable();
816    detected_ports.dedup();
817    detected_ports
818}
819
820fn local_docker_image_tag(project_path: &Path, project_type: &ProjectType) -> String {
821    format!(
822        "xbp-{}:latest",
823        sanitize_docker_name(&infer_project_name(
824            project_path,
825            project_type,
826            &DeploymentRecommendations {
827                build_command: None,
828                start_command: None,
829                install_command: None,
830                default_port: 80,
831                process_name: None,
832                requires_build: true,
833            },
834        ))
835    )
836}
837
838fn local_docker_container_name(project_path: &Path, project_type: &ProjectType) -> String {
839    format!(
840        "xbp-{}",
841        sanitize_docker_name(&infer_project_name(
842            project_path,
843            project_type,
844            &DeploymentRecommendations {
845                build_command: None,
846                start_command: None,
847                install_command: None,
848                default_port: 80,
849                process_name: None,
850                requires_build: true,
851            },
852        ))
853    )
854}
855
856fn sanitize_docker_name(value: &str) -> String {
857    let mut sanitized = String::with_capacity(value.len());
858    let mut previous_was_separator = false;
859
860    for character in value.chars() {
861        let normalized = if character.is_ascii_alphanumeric() {
862            previous_was_separator = false;
863            character.to_ascii_lowercase()
864        } else if !previous_was_separator {
865            previous_was_separator = true;
866            '-'
867        } else {
868            continue;
869        };
870        sanitized.push(normalized);
871    }
872
873    let sanitized = sanitized.trim_matches('-').to_string();
874    if sanitized.is_empty() {
875        "app".to_string()
876    } else {
877        sanitized
878    }
879}
880
881pub fn recommended_runtime(project_type: &ProjectType) -> Option<ProjectRuntime> {
882    match project_type {
883        ProjectType::Docker { .. } | ProjectType::DockerCompose { .. } => {
884            Some(ProjectRuntime::Container)
885        }
886        ProjectType::NextJs { .. } | ProjectType::NodeJs { .. } => Some(ProjectRuntime::NativeNode),
887        ProjectType::Rust { .. } => Some(ProjectRuntime::NativeRust),
888        ProjectType::Railway { .. }
889        | ProjectType::Vercel { .. }
890        | ProjectType::Python { .. }
891        | ProjectType::OpenApi { .. }
892        | ProjectType::Terraform { .. }
893        | ProjectType::Unknown => None,
894    }
895}
896
897fn first_available_command(candidates: &[&str]) -> Option<String> {
898    let path_var = std::env::var_os("PATH")?;
899
900    for dir in std::env::split_paths(&path_var) {
901        for candidate in candidates {
902            let plain = dir.join(candidate);
903            if plain.is_file() {
904                return Some((*candidate).to_string());
905            }
906
907            #[cfg(windows)]
908            for ext in ["exe", "cmd", "bat"] {
909                let with_ext = dir.join(format!("{candidate}.{ext}"));
910                if with_ext.is_file() {
911                    return Some((*candidate).to_string());
912                }
913            }
914        }
915    }
916
917    None
918}
919
920fn preferred_pip_command() -> String {
921    first_available_command(&["pip3", "pip"]).unwrap_or_else(|| {
922        if cfg!(target_os = "windows") {
923            "pip".to_string()
924        } else {
925            "pip3".to_string()
926        }
927    })
928}
929
930#[cfg(test)]
931mod tests {
932    use super::{
933        infer_project_name, infer_target, recommended_runtime, DeploymentRecommendations,
934        PackageJsonInfo, ProjectDetector, ProjectHint, ProjectRuntime, ProjectType,
935    };
936    use std::collections::HashMap;
937    use std::fs;
938    use std::path::Path;
939    use std::sync::atomic::{AtomicU64, Ordering};
940
941    #[test]
942    fn infer_name_prefers_process_name_then_manifest_name() {
943        let project_type = ProjectType::NodeJs {
944            package_json: PackageJsonInfo {
945                name: "demo-node".to_string(),
946                version: "1.0.0".to_string(),
947                scripts: HashMap::new(),
948                dependencies: HashMap::new(),
949                dev_dependencies: HashMap::new(),
950                main: Some("server.js".to_string()),
951            },
952        };
953        let recommendations = DeploymentRecommendations {
954            build_command: None,
955            start_command: None,
956            install_command: None,
957            default_port: 3000,
958            process_name: Some("preferred-name".to_string()),
959            requires_build: false,
960        };
961
962        assert_eq!(
963            infer_project_name(Path::new("C:/work/demo"), &project_type, &recommendations),
964            "preferred-name"
965        );
966    }
967
968    #[test]
969    fn target_and_runtime_mappings_match_control_plane_contracts() {
970        let rust = ProjectType::Rust {
971            cargo_toml: super::CargoTomlInfo {
972                name: "demo".to_string(),
973                version: "0.1.0".to_string(),
974                description: None,
975                authors: vec![],
976                edition: Some("2021".to_string()),
977            },
978        };
979        let docker = ProjectType::Docker {
980            has_dockerfile: true,
981            detected_ports: vec![8080],
982        };
983        let python = ProjectType::Python {
984            has_requirements_txt: true,
985            has_pyproject_toml: false,
986        };
987        let vercel = ProjectType::Vercel {
988            has_vercel_json: true,
989            has_project_link: false,
990        };
991
992        assert_eq!(infer_target(&rust).as_deref(), Some("rust"));
993        assert_eq!(recommended_runtime(&rust), Some(ProjectRuntime::NativeRust));
994        assert_eq!(
995            recommended_runtime(&docker),
996            Some(ProjectRuntime::Container)
997        );
998        assert_eq!(recommended_runtime(&python), None);
999        assert_eq!(infer_target(&vercel).as_deref(), Some("vercel"));
1000        assert_eq!(recommended_runtime(&vercel), None);
1001    }
1002
1003    #[tokio::test]
1004    async fn dockerfile_detection_reads_exposed_ports() {
1005        let project_root = temp_dir("xbp-build-docker-ports");
1006        fs::create_dir_all(&project_root).expect("create temp dir");
1007        fs::write(
1008            project_root.join("Dockerfile"),
1009            "FROM alpine\nEXPOSE 8080 3000/tcp\nEXPOSE 8080\n",
1010        )
1011        .expect("write dockerfile");
1012
1013        let detected = ProjectDetector::detect_project_type(&project_root)
1014            .await
1015            .expect("detect docker project");
1016
1017        assert_eq!(
1018            detected,
1019            ProjectType::Docker {
1020                has_dockerfile: true,
1021                detected_ports: vec![3000, 8080],
1022            }
1023        );
1024
1025        let _ = fs::remove_dir_all(project_root);
1026    }
1027
1028    #[test]
1029    fn docker_recommendations_are_concrete_and_path_aware() {
1030        let project_root = Path::new("C:/work/My Demo_App");
1031        let detected = ProjectType::Docker {
1032            has_dockerfile: true,
1033            detected_ports: vec![8080],
1034        };
1035
1036        let recommendations =
1037            ProjectDetector::get_deployment_recommendations(project_root, &detected);
1038
1039        assert_eq!(
1040            recommendations.build_command.as_deref(),
1041            Some("docker build -t xbp-my-demo-app:latest .")
1042        );
1043        assert_eq!(
1044            recommendations.start_command.as_deref(),
1045            Some(
1046                "docker run -d --rm --name xbp-my-demo-app -p 8080:8080 -e PORT xbp-my-demo-app:latest"
1047            )
1048        );
1049        assert_eq!(recommendations.default_port, 8080);
1050        assert!(recommendations.requires_build);
1051    }
1052
1053    #[test]
1054    fn detect_project_hints_tracks_multi_manifest_projects_without_duplicates() {
1055        let project_root = temp_dir("xbp-build-hints");
1056        fs::create_dir_all(&project_root).expect("create temp dir");
1057        fs::write(project_root.join("Dockerfile"), "FROM alpine\n").expect("write dockerfile");
1058        fs::write(project_root.join("docker-compose.yml"), "services: {}\n")
1059            .expect("write compose");
1060        fs::write(project_root.join("requirements.txt"), "flask\n").expect("write requirements");
1061        fs::write(project_root.join("go.mod"), "module demo\n").expect("write go mod");
1062        fs::write(project_root.join("Cargo.toml"), "[package]\nname='demo'\n")
1063            .expect("write cargo toml");
1064
1065        let hints = ProjectDetector::detect_project_hints(&project_root).expect("project hints");
1066
1067        assert!(hints.contains(&ProjectHint::Docker));
1068        assert!(hints.contains(&ProjectHint::DockerCompose));
1069        assert!(hints.contains(&ProjectHint::Python));
1070        assert!(hints.contains(&ProjectHint::Go));
1071        assert!(hints.contains(&ProjectHint::Rust));
1072        assert_eq!(
1073            hints
1074                .iter()
1075                .filter(|hint| matches!(hint, ProjectHint::DockerCompose))
1076                .count(),
1077            1
1078        );
1079
1080        let _ = fs::remove_dir_all(project_root);
1081    }
1082
1083    #[test]
1084    fn detect_provider_manifests_includes_setup_py_and_go_mod() {
1085        let project_root = temp_dir("xbp-build-manifests");
1086        fs::create_dir_all(&project_root).expect("create temp dir");
1087        fs::write(
1088            project_root.join("setup.py"),
1089            "from setuptools import setup\n",
1090        )
1091        .expect("write setup.py");
1092        fs::write(project_root.join("go.mod"), "module demo\n").expect("write go mod");
1093
1094        let manifests = ProjectDetector::detect_provider_manifests(&project_root);
1095
1096        assert!(manifests.contains(&"setup.py".to_string()));
1097        assert!(manifests.contains(&"go.mod".to_string()));
1098
1099        let _ = fs::remove_dir_all(project_root);
1100    }
1101
1102    #[tokio::test]
1103    async fn vercel_detection_prefers_vercel_manifests_before_generic_nodejs() {
1104        let project_root = temp_dir("xbp-build-vercel-detection");
1105        fs::create_dir_all(project_root.join(".vercel")).expect("create .vercel");
1106        fs::write(
1107            project_root.join("package.json"),
1108            r#"{"name":"demo","version":"1.0.0"}"#,
1109        )
1110        .expect("write package json");
1111        fs::write(project_root.join("vercel.json"), "{\n}\n").expect("write vercel json");
1112        fs::write(
1113            project_root.join(".vercel").join("project.json"),
1114            r#"{"projectId":"prj_demo","orgId":"team_demo"}"#,
1115        )
1116        .expect("write vercel project link");
1117
1118        let detected = ProjectDetector::detect_project_type(&project_root)
1119            .await
1120            .expect("detect vercel project");
1121
1122        assert_eq!(
1123            detected,
1124            ProjectType::Vercel {
1125                has_vercel_json: true,
1126                has_project_link: true,
1127            }
1128        );
1129
1130        let hints = ProjectDetector::detect_project_hints(&project_root).expect("project hints");
1131        assert!(hints.contains(&ProjectHint::Vercel));
1132
1133        let manifests = ProjectDetector::detect_provider_manifests(&project_root);
1134        assert!(manifests.contains(&"vercel.json".to_string()));
1135        assert!(manifests.contains(&".vercel/project.json".to_string()));
1136
1137        let _ = fs::remove_dir_all(project_root);
1138    }
1139
1140    fn temp_dir(prefix: &str) -> std::path::PathBuf {
1141        static COUNTER: AtomicU64 = AtomicU64::new(0);
1142        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
1143        std::env::temp_dir().join(format!("{prefix}-{unique}"))
1144    }
1145}