use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use dialoguer::{Confirm, Input, Select};
use regex::Regex;
use tokio::process::Command;
use tracing::{debug, info, warn};
use crate::strategies::deployment_config::XbpConfig;
use crate::strategies::project_detector::{
DeploymentRecommendations, PackageJsonInfo, ProjectDetector, ProjectType,
};
use crate::utils::{collapse_home_to_env, find_xbp_config_upwards};
pub async fn run_init(debug: bool) -> Result<(), String> {
let current_dir: PathBuf = env::current_dir()
.map_err(|e| format!("Failed to read current directory: {}", e))?
.canonicalize()
.map_err(|e| format!("Failed to resolve current directory: {}", e))?;
if let Some(found) = find_xbp_config_upwards(¤t_dir) {
let proceed = Confirm::new()
.with_prompt(format!(
"An XBP config already exists at {}. Overwrite?",
found.location
))
.default(false)
.interact()
.map_err(|e| format!("Prompt failed: {}", e))?;
if !proceed {
return Ok(());
}
}
let project_type: ProjectType = ProjectDetector::detect_project_type(¤t_dir)
.await
.unwrap_or(ProjectType::Unknown);
debug!(?project_type, "Detected project type");
let recommendations = ProjectDetector::get_deployment_recommendations(&project_type);
let inferred_name = infer_project_name(&project_type, ¤t_dir, &recommendations);
let app_type_guess = infer_app_type(&project_type);
let port_guess = detect_port(¤t_dir, &project_type, &recommendations);
let env_vars = detect_environment_from_env_files(¤t_dir);
let project_name: String = Input::new()
.with_prompt("Project name")
.with_initial_text(inferred_name)
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))?;
let app_type: String =
select_app_type(app_type_guess.clone()).map_err(|e| format!("Prompt failed: {}", e))?;
let port: u16 = Input::new()
.with_prompt("Primary port")
.default(port_guess)
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))?;
let build_dir = collapse_home_to_env(¤t_dir.to_string_lossy());
let config = XbpConfig {
project_name,
version: "0.1.0".to_string(),
port,
build_dir,
app_type: Some(app_type.clone()),
build_command: recommendations.build_command.clone(),
start_command: recommendations.start_command.clone(),
install_command: recommendations.install_command.clone(),
environment: if env_vars.is_empty() {
None
} else {
Some(env_vars)
},
services: None,
systemd_service_name: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
target: Some(app_type),
branch: current_git_branch().await,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
};
let dot_xbp = current_dir.join(".xbp");
fs::create_dir_all(&dot_xbp)
.map_err(|e| format!("Failed to create {}: {}", dot_xbp.display(), e))?;
let yaml_path = dot_xbp.join("xbp.yaml");
let json_path = dot_xbp.join("xbp.json");
write_configs(&config, &yaml_path, &json_path)?;
println!(
"Created {} and {}",
yaml_path.display(),
json_path.display()
);
if let Err(e) = git_commit_and_push(debug).await {
warn!("Git commit/push skipped: {}", e);
}
Ok(())
}
fn infer_project_name(
project_type: &ProjectType,
current_dir: &Path,
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()
}
_ => current_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("app")
.to_string(),
}
}
fn infer_app_type(project_type: &ProjectType) -> Option<String> {
match project_type {
ProjectType::NextJs { .. } => Some("nextjs".to_string()),
ProjectType::NodeJs { package_json } => {
if has_express_dependency(package_json) {
Some("expressjs".to_string())
} else {
Some("nodejs".to_string())
}
}
ProjectType::Rust { .. } => Some("rust".to_string()),
ProjectType::DockerCompose { .. } => Some("docker-compose".to_string()),
ProjectType::Docker { .. } => Some("docker".to_string()),
ProjectType::Railway { .. } => Some("railway".to_string()),
ProjectType::Python { .. } => Some("python".to_string()),
_ => None,
}
}
fn has_express_dependency(package_json: &PackageJsonInfo) -> bool {
package_json
.dependencies
.keys()
.any(|k| k.eq_ignore_ascii_case("express"))
|| package_json
.dev_dependencies
.keys()
.any(|k| k.eq_ignore_ascii_case("express"))
}
fn select_app_type(detected: Option<String>) -> Result<String, String> {
let mut options: Vec<String> = vec![
"nextjs".to_string(),
"expressjs".to_string(),
"rust".to_string(),
"nodejs".to_string(),
"python".to_string(),
"docker".to_string(),
"custom...".to_string(),
];
let default_index = if let Some(ref guess) = detected {
if let Some(pos) = options.iter().position(|o| o == guess) {
pos
} else {
options.insert(0, format!("{} (detected)", guess));
0
}
} else {
0
};
let selection = Select::new()
.with_prompt("App type")
.items(&options)
.default(default_index)
.interact()
.map_err(|e| format!("Prompt failed: {}", e))?;
let choice = options
.get(selection)
.cloned()
.unwrap_or_else(|| "nextjs".to_string());
if choice == "custom..." {
Input::<String>::new()
.with_prompt("Enter app type")
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))
} else if let Some(stripped) = choice.strip_suffix(" (detected)") {
Ok(stripped.to_string())
} else {
Ok(choice)
}
}
fn detect_port(
project_root: &Path,
project_type: &ProjectType,
recommendations: &DeploymentRecommendations,
) -> u16 {
if let Ok(port_env) = env::var("PORT") {
if let Ok(port) = port_env.parse::<u16>() {
return port;
}
}
for name in [".env", ".env.local", ".env.development", ".env.production"] {
if let Some(port) = parse_port_from_env_file(&project_root.join(name)) {
return port;
}
}
if let Some(port) = detect_port_from_package_json(project_root) {
return port;
}
if let ProjectType::DockerCompose { detected_ports, .. } = project_type {
if let Some(port) = detected_ports.first() {
return *port;
}
}
recommendations.default_port
}
fn parse_port_from_env_file(path: &Path) -> Option<u16> {
let contents = fs::read_to_string(path).ok()?;
for line in contents.lines() {
let mut trimmed = line.trim();
if trimmed.starts_with("export ") {
trimmed = trimmed.trim_start_matches("export ").trim();
}
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((key, value)) = trimmed.split_once('=') {
if key.trim() == "PORT" {
if let Ok(port) = value.trim().parse::<u16>() {
return Some(port);
}
}
}
if let Some(port) = extract_port_from_str(trimmed) {
return Some(port);
}
}
None
}
fn detect_port_from_package_json(project_root: &Path) -> Option<u16> {
let pkg_path = project_root.join("package.json");
let content = fs::read_to_string(&pkg_path).ok()?;
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
if let Some(port) = value.get("port").and_then(|v| v.as_u64()) {
return Some(port as u16);
}
if let Some(scripts) = value.get("scripts").and_then(|v| v.as_object()) {
for script in scripts.values() {
if let Some(text) = script.as_str() {
if let Some(port) = extract_port_from_str(text) {
return Some(port);
}
}
}
}
None
}
fn extract_port_from_str(text: &str) -> Option<u16> {
let patterns = [
r"PORT\s*[:=]\s*(\d{2,5})",
r"port\s*[:=]\s*(\d{2,5})",
r"--port\s+(\d{2,5})",
r"-p\s+(\d{2,5})",
];
for pat in patterns {
if let Ok(re) = Regex::new(pat) {
if let Some(caps) = re.captures(text) {
if let Some(m) = caps.get(1) {
if let Ok(port) = m.as_str().parse::<u16>() {
return Some(port);
}
}
}
}
}
None
}
fn detect_environment_from_env_files(project_root: &Path) -> HashMap<String, String> {
let mut env_map = HashMap::new();
for name in [".env", ".env.local", ".env.development", ".env.production"] {
let path = project_root.join(name);
if !path.exists() {
continue;
}
if let Ok(contents) = fs::read_to_string(&path) {
for line in contents.lines() {
let mut trimmed = line.trim();
if trimmed.starts_with("export ") {
trimmed = trimmed.trim_start_matches("export ").trim();
}
if trimmed.is_empty() || trimmed.starts_with('#') || !trimmed.contains('=') {
continue;
}
let mut parts = trimmed.splitn(2, '=');
let key = parts.next().unwrap_or("").trim();
let value = parts.next().unwrap_or("").trim();
if key.is_empty() {
continue;
}
env_map
.entry(key.to_string())
.or_insert_with(|| value.to_string());
}
}
}
env_map
}
fn write_configs(config: &XbpConfig, yaml_path: &Path, json_path: &Path) -> Result<(), String> {
let yaml = serde_yaml::to_string(config)
.map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
fs::write(yaml_path, yaml)
.map_err(|e| format!("Failed to write {}: {}", yaml_path.display(), e))?;
let json = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
fs::write(json_path, json)
.map_err(|e| format!("Failed to write {}: {}", json_path.display(), e))?;
Ok(())
}
async fn current_git_branch() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()
.map(|s| s.trim().to_string())
}
async fn git_commit_and_push(debug: bool) -> Result<(), String> {
let status = Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.await
.map_err(|e| format!("Failed to check git status: {}", e))?;
if !status.status.success() {
return Err("Not a git repository".to_string());
}
run_git_command(&["add", "."], debug).await?;
let commit_status = Command::new("git")
.args(["commit", "-m", "feat!: initialized project with XBP[bot]"])
.output()
.await
.map_err(|e| format!("Failed to run git commit: {}", e))?;
if !commit_status.status.success() {
let stderr = String::from_utf8_lossy(&commit_status.stderr);
if stderr.contains("nothing to commit") {
return Err("No changes to commit".to_string());
}
return Err(format!("git commit failed: {}", stderr.trim()));
}
run_git_command(&["push"], debug).await?;
Ok(())
}
async fn run_git_command(args: &[&str], debug: bool) -> Result<(), String> {
if debug {
debug!("Running git {:?}", args);
}
let output = Command::new("git")
.args(args)
.output()
.await
.map_err(|e| format!("Failed to run git {:?}: {}", args, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git {:?} failed: {}", args, stderr.trim()));
}
info!("git {:?} succeeded", args);
Ok(())
}