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
775fn 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 "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 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}