#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectRuntime {
Container,
NativeNode,
NativeRust,
Static,
}
impl ProjectRuntime {
pub const fn as_str(self) -> &'static str {
match self {
Self::Container => "container",
Self::NativeNode => "native_node",
Self::NativeRust => "native_rust",
Self::Static => "static",
}
}
pub const fn is_native(self) -> bool {
matches!(self, Self::NativeNode | Self::NativeRust)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ProjectType {
Docker {
has_dockerfile: bool,
detected_ports: Vec<u16>,
},
DockerCompose {
compose_files: Vec<String>,
detected_ports: Vec<u16>,
},
Railway {
has_railway_json: bool,
has_railway_toml: bool,
},
Vercel {
has_vercel_json: bool,
has_project_link: bool,
},
Python {
has_requirements_txt: bool,
has_pyproject_toml: bool,
},
OpenApi {
spec_files: Vec<String>,
},
Terraform {
tf_file_count: usize,
},
NextJs {
package_json: PackageJsonInfo,
has_next_config: bool,
},
NodeJs {
package_json: PackageJsonInfo,
},
Rust {
cargo_toml: CargoTomlInfo,
},
Unknown,
}
impl ProjectType {
pub const fn kind_slug(&self) -> &'static str {
match self {
Self::Docker { .. } => "docker",
Self::DockerCompose { .. } => "docker-compose",
Self::Railway { .. } => "railway",
Self::Vercel { .. } => "vercel",
Self::Python { .. } => "python",
Self::OpenApi { .. } => "openapi",
Self::Terraform { .. } => "terraform",
Self::NextJs { .. } => "nextjs",
Self::NodeJs { .. } => "nodejs",
Self::Rust { .. } => "rust",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectHint {
Docker,
DockerCompose,
Python,
NodeJs,
Rust,
Railway,
Vercel,
Go,
}
impl ProjectHint {
pub const fn as_str(self) -> &'static str {
match self {
Self::Docker => "docker",
Self::DockerCompose => "docker_compose",
Self::Python => "python",
Self::NodeJs => "nodejs",
Self::Rust => "rust",
Self::Railway => "railway",
Self::Vercel => "vercel",
Self::Go => "go",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PackageJsonInfo {
pub name: String,
pub version: String,
pub scripts: HashMap<String, String>,
pub dependencies: HashMap<String, String>,
pub dev_dependencies: HashMap<String, String>,
pub main: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CargoTomlInfo {
pub name: String,
pub version: String,
pub description: Option<String>,
pub authors: Vec<String>,
pub edition: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeploymentRecommendations {
pub build_command: Option<String>,
pub start_command: Option<String>,
pub install_command: Option<String>,
pub default_port: u16,
pub process_name: Option<String>,
pub requires_build: bool,
}
pub struct ProjectDetector;
impl ProjectDetector {
pub async fn detect_project_type(project_path: &Path) -> Result<ProjectType, String> {
let project_path = project_path
.canonicalize()
.map_err(|e| format!("Failed to resolve project path: {}", e))?;
if let Ok(project_type) = Self::detect_docker_compose(&project_path).await {
return Ok(project_type);
}
if let Ok(project_type) = Self::detect_docker(&project_path).await {
return Ok(project_type);
}
if let Ok(project_type) = Self::detect_railway(&project_path).await {
return Ok(project_type);
}
if let Ok(project_type) = Self::detect_vercel(&project_path).await {
return Ok(project_type);
}
if let Ok(project_type) = Self::detect_python(&project_path).await {
return Ok(project_type);
}
if let Ok(project_type) = Self::detect_nextjs(&project_path).await {
return Ok(project_type);
}
if let Ok(project_type) = Self::detect_nodejs(&project_path).await {
return Ok(project_type);
}
if let Ok(project_type) = Self::detect_rust(&project_path).await {
return Ok(project_type);
}
if let Ok(project_type) = Self::detect_openapi(&project_path).await {
return Ok(project_type);
}
if let Ok(project_type) = Self::detect_terraform(&project_path).await {
return Ok(project_type);
}
Ok(ProjectType::Unknown)
}
pub fn detect_project_hints(project_path: &Path) -> Result<Vec<ProjectHint>, String> {
let project_path = project_path
.canonicalize()
.map_err(|e| format!("Failed to resolve project path: {}", e))?;
let mut hints = Vec::new();
if project_path.join("Dockerfile").exists() {
push_hint(&mut hints, ProjectHint::Docker);
}
if project_path.join("docker-compose.yml").exists()
|| project_path.join("docker-compose.yaml").exists()
|| project_path.join("compose.yml").exists()
|| project_path.join("compose.yaml").exists()
{
push_hint(&mut hints, ProjectHint::DockerCompose);
}
if project_path.join("requirements.txt").exists()
|| project_path.join("pyproject.toml").exists()
|| project_path.join("setup.py").exists()
{
push_hint(&mut hints, ProjectHint::Python);
}
if project_path.join("package.json").exists() {
push_hint(&mut hints, ProjectHint::NodeJs);
}
if project_path.join("Cargo.toml").exists() {
push_hint(&mut hints, ProjectHint::Rust);
}
if project_path.join("railway.json").exists() || project_path.join("railway.toml").exists()
{
push_hint(&mut hints, ProjectHint::Railway);
}
if project_path.join("vercel.json").exists()
|| project_path.join(".vercel").join("project.json").exists()
{
push_hint(&mut hints, ProjectHint::Vercel);
}
if project_path.join("go.mod").exists() {
push_hint(&mut hints, ProjectHint::Go);
}
Ok(hints)
}
pub fn detect_provider_manifests(project_path: &Path) -> Vec<String> {
let mut detections = Vec::new();
if project_path.join("Dockerfile").exists() {
detections.push("Dockerfile".to_string());
}
for name in [
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
] {
if project_path.join(name).exists() {
detections.push(name.to_string());
}
}
if project_path.join("railway.json").exists() {
detections.push("railway.json".to_string());
}
if project_path.join("railway.toml").exists() {
detections.push("railway.toml".to_string());
}
if project_path.join("vercel.json").exists() {
detections.push("vercel.json".to_string());
}
if project_path.join(".vercel").join("project.json").exists() {
detections.push(".vercel/project.json".to_string());
}
if project_path.join("requirements.txt").exists() {
detections.push("requirements.txt".to_string());
}
if project_path.join("pyproject.toml").exists() {
detections.push("pyproject.toml".to_string());
}
if project_path.join("setup.py").exists() {
detections.push("setup.py".to_string());
}
if project_path.join("go.mod").exists() {
detections.push("go.mod".to_string());
}
for name in [
"openapi.yaml",
"openapi.yml",
"swagger.yaml",
"swagger.yml",
"swagger.json",
] {
if project_path.join(name).exists() {
detections.push(name.to_string());
}
}
let tf_count = match fs::read_dir(project_path) {
Ok(entries) => entries
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.and_then(|s| s.to_str())
.map(|ext| ext.eq_ignore_ascii_case("tf"))
.unwrap_or(false)
})
.count(),
Err(_) => 0,
};
if tf_count > 0 {
detections.push(format!("Terraform (.tf) x{}", tf_count));
}
detections
}
pub fn get_deployment_recommendations(
project_path: &Path,
project_type: &ProjectType,
) -> DeploymentRecommendations {
match project_type {
ProjectType::DockerCompose { detected_ports, .. } => DeploymentRecommendations {
build_command: Some("docker compose build".to_string()),
start_command: Some("docker compose up -d".to_string()),
install_command: None,
default_port: detected_ports.first().copied().unwrap_or(80),
process_name: None,
requires_build: true,
},
ProjectType::Docker { detected_ports, .. } => {
let default_port = detected_ports.first().copied().unwrap_or(80);
let image_tag = local_docker_image_tag(project_path, project_type);
let container_name = local_docker_container_name(project_path, project_type);
DeploymentRecommendations {
build_command: Some(format!("docker build -t {image_tag} .")),
start_command: Some(format!(
"docker run -d --rm --name {container_name} -p {default_port}:{default_port} -e PORT {image_tag}"
)),
install_command: None,
default_port,
process_name: None,
requires_build: true,
}
}
ProjectType::Railway { .. } => DeploymentRecommendations {
build_command: None,
start_command: None,
install_command: None,
default_port: 8080,
process_name: None,
requires_build: false,
},
ProjectType::Vercel { .. } => DeploymentRecommendations {
build_command: None,
start_command: None,
install_command: None,
default_port: 3000,
process_name: None,
requires_build: false,
},
ProjectType::Python {
has_requirements_txt,
has_pyproject_toml,
} => {
let pip = preferred_pip_command();
let install_command = if *has_requirements_txt {
Some(format!("{pip} install -r requirements.txt"))
} else if *has_pyproject_toml {
Some(format!("{pip} install -e ."))
} else {
None
};
DeploymentRecommendations {
build_command: None,
start_command: None,
install_command,
default_port: 8000,
process_name: None,
requires_build: false,
}
}
ProjectType::OpenApi { .. } => DeploymentRecommendations {
build_command: None,
start_command: None,
install_command: None,
default_port: 8080,
process_name: None,
requires_build: false,
},
ProjectType::Terraform { .. } => DeploymentRecommendations {
build_command: None,
start_command: None,
install_command: None,
default_port: 8080,
process_name: None,
requires_build: false,
},
ProjectType::NextJs { package_json, .. } => DeploymentRecommendations {
build_command: Some("pnpm run build".to_string()),
start_command: Some("pnpm run start".to_string()),
install_command: Some("pnpm install".to_string()),
default_port: 3000,
process_name: Some(package_json.name.clone()),
requires_build: true,
},
ProjectType::NodeJs { package_json } => {
let start_cmd = package_json
.scripts
.get("start")
.map(|script| {
format!(
"pnpm run {}",
script.split_whitespace().next().unwrap_or("start")
)
})
.or_else(|| {
package_json
.main
.as_ref()
.map(|main| format!("node {main}"))
})
.unwrap_or_else(|| "pnpm run start".to_string());
DeploymentRecommendations {
build_command: package_json
.scripts
.get("build")
.map(|_| "pnpm run build".to_string()),
start_command: Some(start_cmd),
install_command: Some("pnpm install".to_string()),
default_port: 3000,
process_name: Some(package_json.name.clone()),
requires_build: package_json.scripts.contains_key("build"),
}
}
ProjectType::Rust { cargo_toml } => DeploymentRecommendations {
build_command: Some("cargo build --release".to_string()),
start_command: Some(format!("./target/release/{}", cargo_toml.name)),
install_command: None,
default_port: 8080,
process_name: Some(cargo_toml.name.clone()),
requires_build: true,
},
ProjectType::Unknown => DeploymentRecommendations {
build_command: None,
start_command: None,
install_command: None,
default_port: 8080,
process_name: None,
requires_build: false,
},
}
}
async fn detect_docker_compose(project_path: &Path) -> Result<ProjectType, String> {
let compose_names = [
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
];
let mut compose_files = Vec::new();
for name in compose_names {
if project_path.join(name).exists() {
compose_files.push(name.to_string());
}
}
if compose_files.is_empty() {
return Err("No compose file found".to_string());
}
let mut detected_ports = Vec::new();
for file in &compose_files {
let path = project_path.join(file);
if let Ok(contents) = fs::read_to_string(&path) {
if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(&contents) {
if let Some(services) = value.get("services").and_then(|v| v.as_mapping()) {
for (_service_name, service_cfg) in services {
let ports = service_cfg
.get("ports")
.and_then(|v| v.as_sequence())
.cloned()
.unwrap_or_default();
for port_value in ports {
if let Some(port_text) = port_value.as_str() {
if let Some(host) = port_text.split(':').next() {
if let Ok(port) = host.parse::<u16>() {
detected_ports.push(port);
}
}
} else if let Some(port_number) = port_value.as_i64() {
if port_number >= 1 && port_number <= u16::MAX as i64 {
detected_ports.push(port_number as u16);
}
}
}
}
}
}
}
}
detected_ports.sort_unstable();
detected_ports.dedup();
Ok(ProjectType::DockerCompose {
compose_files,
detected_ports,
})
}
async fn detect_docker(project_path: &Path) -> Result<ProjectType, String> {
let dockerfile_path = project_path.join("Dockerfile");
if !dockerfile_path.exists() {
return Err("No Dockerfile found".to_string());
}
let detected_ports = detect_dockerfile_ports(&dockerfile_path);
Ok(ProjectType::Docker {
has_dockerfile: true,
detected_ports,
})
}
async fn detect_railway(project_path: &Path) -> Result<ProjectType, String> {
let has_railway_json = project_path.join("railway.json").exists();
let has_railway_toml = project_path.join("railway.toml").exists();
if !has_railway_json && !has_railway_toml {
return Err("No railway manifest found".to_string());
}
Ok(ProjectType::Railway {
has_railway_json,
has_railway_toml,
})
}
async fn detect_vercel(project_path: &Path) -> Result<ProjectType, String> {
let has_vercel_json = project_path.join("vercel.json").exists();
let has_project_link = project_path.join(".vercel").join("project.json").exists();
if !has_vercel_json && !has_project_link {
return Err("No vercel manifest found".to_string());
}
Ok(ProjectType::Vercel {
has_vercel_json,
has_project_link,
})
}
async fn detect_python(project_path: &Path) -> Result<ProjectType, String> {
let has_requirements_txt = project_path.join("requirements.txt").exists();
let has_pyproject_toml = project_path.join("pyproject.toml").exists();
if !has_requirements_txt && !has_pyproject_toml {
return Err("No python manifest found".to_string());
}
Ok(ProjectType::Python {
has_requirements_txt,
has_pyproject_toml,
})
}
async fn detect_openapi(project_path: &Path) -> Result<ProjectType, String> {
let names = [
"openapi.yaml",
"openapi.yml",
"swagger.yaml",
"swagger.yml",
"swagger.json",
];
let mut spec_files = Vec::new();
for name in names {
if project_path.join(name).exists() {
spec_files.push(name.to_string());
}
}
if spec_files.is_empty() {
return Err("No OpenAPI spec found".to_string());
}
Ok(ProjectType::OpenApi { spec_files })
}
async fn detect_terraform(project_path: &Path) -> Result<ProjectType, String> {
let tf_file_count = fs::read_dir(project_path)
.map_err(|_| "Failed to read directory".to_string())?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.and_then(|s| s.to_str())
.map(|ext| ext.eq_ignore_ascii_case("tf"))
.unwrap_or(false)
})
.count();
if tf_file_count == 0 {
return Err("No terraform files found".to_string());
}
Ok(ProjectType::Terraform { tf_file_count })
}
async fn detect_nextjs(project_path: &Path) -> Result<ProjectType, String> {
let package_json_path = project_path.join("package.json");
let next_config_path = project_path.join("next.config.js");
let next_config_mjs_path = project_path.join("next.config.mjs");
let next_dir = project_path.join(".next");
if !package_json_path.exists() {
return Err("No package.json found".to_string());
}
let package_json = Self::parse_package_json(&package_json_path)?;
let has_next = package_json.dependencies.contains_key("next")
|| package_json.dev_dependencies.contains_key("next");
if !has_next {
return Err("Next.js not found in dependencies".to_string());
}
let has_next_config =
next_config_path.exists() || next_config_mjs_path.exists() || next_dir.exists();
Ok(ProjectType::NextJs {
package_json,
has_next_config,
})
}
async fn detect_nodejs(project_path: &Path) -> Result<ProjectType, String> {
let package_json_path = project_path.join("package.json");
if !package_json_path.exists() {
return Err("No package.json found".to_string());
}
Ok(ProjectType::NodeJs {
package_json: Self::parse_package_json(&package_json_path)?,
})
}
async fn detect_rust(project_path: &Path) -> Result<ProjectType, String> {
let cargo_toml_path = project_path.join("Cargo.toml");
if !cargo_toml_path.exists() {
return Err("No Cargo.toml found".to_string());
}
Ok(ProjectType::Rust {
cargo_toml: Self::parse_cargo_toml(&cargo_toml_path)?,
})
}
fn parse_package_json(path: &Path) -> Result<PackageJsonInfo, String> {
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read package.json: {e}"))?;
let json: Value = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse package.json: {e}"))?;
Ok(PackageJsonInfo {
name: json["name"].as_str().unwrap_or("unknown").to_string(),
version: json["version"].as_str().unwrap_or("0.0.0").to_string(),
scripts: json["scripts"]
.as_object()
.map(|obj| {
obj.iter()
.filter_map(|(key, value)| {
value.as_str().map(|text| (key.clone(), text.to_string()))
})
.collect()
})
.unwrap_or_default(),
dependencies: json["dependencies"]
.as_object()
.map(|obj| {
obj.iter()
.filter_map(|(key, value)| {
value.as_str().map(|text| (key.clone(), text.to_string()))
})
.collect()
})
.unwrap_or_default(),
dev_dependencies: json["devDependencies"]
.as_object()
.map(|obj| {
obj.iter()
.filter_map(|(key, value)| {
value.as_str().map(|text| (key.clone(), text.to_string()))
})
.collect()
})
.unwrap_or_default(),
main: json["main"].as_str().map(|value| value.to_string()),
})
}
fn parse_cargo_toml(path: &Path) -> Result<CargoTomlInfo, String> {
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read Cargo.toml: {e}"))?;
let toml_value: toml::Value =
toml::from_str(&content).map_err(|e| format!("Failed to parse Cargo.toml: {e}"))?;
let package = toml_value
.get("package")
.ok_or("No [package] section found in Cargo.toml")?;
Ok(CargoTomlInfo {
name: package
.get("name")
.and_then(|value| value.as_str())
.ok_or("No name found in [package] section")?
.to_string(),
version: package
.get("version")
.and_then(|value| value.as_str())
.unwrap_or("0.0.0")
.to_string(),
description: package
.get("description")
.and_then(|value| value.as_str())
.map(|value| value.to_string()),
authors: package
.get("authors")
.and_then(|value| value.as_array())
.map(|values| {
values
.iter()
.filter_map(|value| value.as_str())
.map(|value| value.to_string())
.collect()
})
.unwrap_or_default(),
edition: package
.get("edition")
.and_then(|value| value.as_str())
.map(|value| value.to_string()),
})
}
}
fn push_hint(hints: &mut Vec<ProjectHint>, hint: ProjectHint) {
if !hints.contains(&hint) {
hints.push(hint);
}
}
pub fn infer_project_name(
project_root: &Path,
project_type: &ProjectType,
recommendations: &DeploymentRecommendations,
) -> String {
if let Some(name) = recommendations.process_name.clone() {
return name;
}
match project_type {
ProjectType::Rust { cargo_toml } => cargo_toml.name.clone(),
ProjectType::NextJs { package_json, .. } | ProjectType::NodeJs { package_json } => {
package_json.name.clone()
}
_ => project_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("app")
.to_string(),
}
}
pub fn infer_target(project_type: &ProjectType) -> Option<String> {
Some(
match project_type {
ProjectType::NextJs { .. } => "nextjs",
ProjectType::NodeJs { .. } => "expressjs",
ProjectType::Rust { .. } => "rust",
ProjectType::Python { .. } => "python",
ProjectType::DockerCompose { .. } => "docker-compose",
ProjectType::Docker { .. } => "docker",
ProjectType::Railway { .. } => "railway",
ProjectType::Vercel { .. } => "vercel",
ProjectType::OpenApi { .. } => "openapi",
ProjectType::Terraform { .. } => "terraform",
ProjectType::Unknown => "unknown",
}
.to_string(),
)
}
fn detect_dockerfile_ports(dockerfile_path: &Path) -> Vec<u16> {
let Ok(contents) = fs::read_to_string(dockerfile_path) else {
return Vec::new();
};
let mut detected_ports = Vec::new();
for line in contents.lines() {
let line = line.split('#').next().unwrap_or_default().trim();
if line.len() < 6 || !line[..6].eq_ignore_ascii_case("expose") {
continue;
}
let Some(rest) = line.get(6..) else {
continue;
};
for token in rest.split_whitespace() {
let port_token = token.split('/').next().unwrap_or_default().trim();
if let Ok(port) = port_token.parse::<u16>() {
detected_ports.push(port);
}
}
}
detected_ports.sort_unstable();
detected_ports.dedup();
detected_ports
}
fn local_docker_image_tag(project_path: &Path, project_type: &ProjectType) -> String {
format!(
"xbp-{}:latest",
sanitize_docker_name(&infer_project_name(
project_path,
project_type,
&DeploymentRecommendations {
build_command: None,
start_command: None,
install_command: None,
default_port: 80,
process_name: None,
requires_build: true,
},
))
)
}
fn local_docker_container_name(project_path: &Path, project_type: &ProjectType) -> String {
format!(
"xbp-{}",
sanitize_docker_name(&infer_project_name(
project_path,
project_type,
&DeploymentRecommendations {
build_command: None,
start_command: None,
install_command: None,
default_port: 80,
process_name: None,
requires_build: true,
},
))
)
}
fn sanitize_docker_name(value: &str) -> String {
let mut sanitized = String::with_capacity(value.len());
let mut previous_was_separator = false;
for character in value.chars() {
let normalized = if character.is_ascii_alphanumeric() {
previous_was_separator = false;
character.to_ascii_lowercase()
} else if !previous_was_separator {
previous_was_separator = true;
'-'
} else {
continue;
};
sanitized.push(normalized);
}
let sanitized = sanitized.trim_matches('-').to_string();
if sanitized.is_empty() {
"app".to_string()
} else {
sanitized
}
}
pub fn recommended_runtime(project_type: &ProjectType) -> Option<ProjectRuntime> {
match project_type {
ProjectType::Docker { .. } | ProjectType::DockerCompose { .. } => {
Some(ProjectRuntime::Container)
}
ProjectType::NextJs { .. } | ProjectType::NodeJs { .. } => Some(ProjectRuntime::NativeNode),
ProjectType::Rust { .. } => Some(ProjectRuntime::NativeRust),
ProjectType::Railway { .. }
| ProjectType::Vercel { .. }
| ProjectType::Python { .. }
| ProjectType::OpenApi { .. }
| ProjectType::Terraform { .. }
| ProjectType::Unknown => None,
}
}
fn first_available_command(candidates: &[&str]) -> Option<String> {
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
for candidate in candidates {
let plain = dir.join(candidate);
if plain.is_file() {
return Some((*candidate).to_string());
}
#[cfg(windows)]
for ext in ["exe", "cmd", "bat"] {
let with_ext = dir.join(format!("{candidate}.{ext}"));
if with_ext.is_file() {
return Some((*candidate).to_string());
}
}
}
}
None
}
fn preferred_pip_command() -> String {
first_available_command(&["pip3", "pip"]).unwrap_or_else(|| {
if cfg!(target_os = "windows") {
"pip".to_string()
} else {
"pip3".to_string()
}
})
}
#[cfg(test)]
mod tests {
use super::{
infer_project_name, infer_target, recommended_runtime, DeploymentRecommendations,
PackageJsonInfo, ProjectDetector, ProjectHint, ProjectRuntime, ProjectType,
};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
#[test]
fn infer_name_prefers_process_name_then_manifest_name() {
let project_type = ProjectType::NodeJs {
package_json: PackageJsonInfo {
name: "demo-node".to_string(),
version: "1.0.0".to_string(),
scripts: HashMap::new(),
dependencies: HashMap::new(),
dev_dependencies: HashMap::new(),
main: Some("server.js".to_string()),
},
};
let recommendations = DeploymentRecommendations {
build_command: None,
start_command: None,
install_command: None,
default_port: 3000,
process_name: Some("preferred-name".to_string()),
requires_build: false,
};
assert_eq!(
infer_project_name(Path::new("C:/work/demo"), &project_type, &recommendations),
"preferred-name"
);
}
#[test]
fn target_and_runtime_mappings_match_control_plane_contracts() {
let rust = ProjectType::Rust {
cargo_toml: super::CargoTomlInfo {
name: "demo".to_string(),
version: "0.1.0".to_string(),
description: None,
authors: vec![],
edition: Some("2021".to_string()),
},
};
let docker = ProjectType::Docker {
has_dockerfile: true,
detected_ports: vec![8080],
};
let python = ProjectType::Python {
has_requirements_txt: true,
has_pyproject_toml: false,
};
let vercel = ProjectType::Vercel {
has_vercel_json: true,
has_project_link: false,
};
assert_eq!(infer_target(&rust).as_deref(), Some("rust"));
assert_eq!(recommended_runtime(&rust), Some(ProjectRuntime::NativeRust));
assert_eq!(
recommended_runtime(&docker),
Some(ProjectRuntime::Container)
);
assert_eq!(recommended_runtime(&python), None);
assert_eq!(infer_target(&vercel).as_deref(), Some("vercel"));
assert_eq!(recommended_runtime(&vercel), None);
}
#[tokio::test]
async fn dockerfile_detection_reads_exposed_ports() {
let project_root = temp_dir("xbp-build-docker-ports");
fs::create_dir_all(&project_root).expect("create temp dir");
fs::write(
project_root.join("Dockerfile"),
"FROM alpine\nEXPOSE 8080 3000/tcp\nEXPOSE 8080\n",
)
.expect("write dockerfile");
let detected = ProjectDetector::detect_project_type(&project_root)
.await
.expect("detect docker project");
assert_eq!(
detected,
ProjectType::Docker {
has_dockerfile: true,
detected_ports: vec![3000, 8080],
}
);
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn docker_recommendations_are_concrete_and_path_aware() {
let project_root = Path::new("C:/work/My Demo_App");
let detected = ProjectType::Docker {
has_dockerfile: true,
detected_ports: vec![8080],
};
let recommendations =
ProjectDetector::get_deployment_recommendations(project_root, &detected);
assert_eq!(
recommendations.build_command.as_deref(),
Some("docker build -t xbp-my-demo-app:latest .")
);
assert_eq!(
recommendations.start_command.as_deref(),
Some(
"docker run -d --rm --name xbp-my-demo-app -p 8080:8080 -e PORT xbp-my-demo-app:latest"
)
);
assert_eq!(recommendations.default_port, 8080);
assert!(recommendations.requires_build);
}
#[test]
fn detect_project_hints_tracks_multi_manifest_projects_without_duplicates() {
let project_root = temp_dir("xbp-build-hints");
fs::create_dir_all(&project_root).expect("create temp dir");
fs::write(project_root.join("Dockerfile"), "FROM alpine\n").expect("write dockerfile");
fs::write(project_root.join("docker-compose.yml"), "services: {}\n")
.expect("write compose");
fs::write(project_root.join("requirements.txt"), "flask\n").expect("write requirements");
fs::write(project_root.join("go.mod"), "module demo\n").expect("write go mod");
fs::write(project_root.join("Cargo.toml"), "[package]\nname='demo'\n")
.expect("write cargo toml");
let hints = ProjectDetector::detect_project_hints(&project_root).expect("project hints");
assert!(hints.contains(&ProjectHint::Docker));
assert!(hints.contains(&ProjectHint::DockerCompose));
assert!(hints.contains(&ProjectHint::Python));
assert!(hints.contains(&ProjectHint::Go));
assert!(hints.contains(&ProjectHint::Rust));
assert_eq!(
hints
.iter()
.filter(|hint| matches!(hint, ProjectHint::DockerCompose))
.count(),
1
);
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn detect_provider_manifests_includes_setup_py_and_go_mod() {
let project_root = temp_dir("xbp-build-manifests");
fs::create_dir_all(&project_root).expect("create temp dir");
fs::write(
project_root.join("setup.py"),
"from setuptools import setup\n",
)
.expect("write setup.py");
fs::write(project_root.join("go.mod"), "module demo\n").expect("write go mod");
let manifests = ProjectDetector::detect_provider_manifests(&project_root);
assert!(manifests.contains(&"setup.py".to_string()));
assert!(manifests.contains(&"go.mod".to_string()));
let _ = fs::remove_dir_all(project_root);
}
#[tokio::test]
async fn vercel_detection_prefers_vercel_manifests_before_generic_nodejs() {
let project_root = temp_dir("xbp-build-vercel-detection");
fs::create_dir_all(project_root.join(".vercel")).expect("create .vercel");
fs::write(
project_root.join("package.json"),
r#"{"name":"demo","version":"1.0.0"}"#,
)
.expect("write package json");
fs::write(project_root.join("vercel.json"), "{\n}\n").expect("write vercel json");
fs::write(
project_root.join(".vercel").join("project.json"),
r#"{"projectId":"prj_demo","orgId":"team_demo"}"#,
)
.expect("write vercel project link");
let detected = ProjectDetector::detect_project_type(&project_root)
.await
.expect("detect vercel project");
assert_eq!(
detected,
ProjectType::Vercel {
has_vercel_json: true,
has_project_link: true,
}
);
let hints = ProjectDetector::detect_project_hints(&project_root).expect("project hints");
assert!(hints.contains(&ProjectHint::Vercel));
let manifests = ProjectDetector::detect_provider_manifests(&project_root);
assert!(manifests.contains(&"vercel.json".to_string()));
assert!(manifests.contains(&".vercel/project.json".to_string()));
let _ = fs::remove_dir_all(project_root);
}
fn temp_dir(prefix: &str) -> std::path::PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("{prefix}-{unique}"))
}
}