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}