Skip to main content

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