xbp 10.28.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Init command module
//!
//! Guides the user through creating a project-local XBP configuration
//! under `.xbp/` by detecting framework, port, and sensible defaults.
//! Writes a canonical YAML config and optionally syncs legacy JSON when present.
//! Optionally commits/pushes the changes to git.

use crate::cli::auto_commit::{
    commit_paths, print_push_summary, print_skip, push_current_branch, AutoCommitRequest,
    AutoCommitResult,
};
use crate::strategies::deployment_config::XbpConfig;
use crate::strategies::project_detector::{
    infer_project_name as shared_infer_project_name, DeploymentRecommendations, PackageJsonInfo,
    ProjectDetector, ProjectType,
};
use crate::utils::{
    collapse_project_path, find_xbp_config_upwards, parse_env_file, to_env_references,
};
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;

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))?;

    if let Some(found) = find_xbp_config_upwards(&current_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(&current_dir)
        .await
        .unwrap_or(ProjectType::Unknown);
    debug!(?project_type, "Detected project type");

    let recommendations =
        ProjectDetector::get_deployment_recommendations(&current_dir, &project_type);
    let inferred_name = infer_project_name(&project_type, &current_dir, &recommendations);
    let app_type_guess = infer_app_type(&project_type);
    let port_guess = detect_port(&current_dir, &project_type, &recommendations);
    let env_vars = detect_environment_from_env_files(&current_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_project_path(&current_dir, &current_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,
        systemd: 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,
        database: None,
        target: Some(app_type),
        branch: current_git_branch().await,
        crate_name: None,
        npm_script: None,
        port_storybook: None,
        url: None,
        url_storybook: None,
        linear: None,
        github: None,
        publish: 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 written_paths = write_configs(&config, &yaml_path)?;

    let legacy_json_path = dot_xbp.join("xbp.json");
    if legacy_json_path.exists() {
        println!(
            "Created {} (synced legacy {})",
            yaml_path.display(),
            legacy_json_path.display()
        );
    } else {
        println!("Created {}", yaml_path.display());
    }

    match commit_paths(AutoCommitRequest {
        project_root: &current_dir,
        paths: written_paths,
        message: "chore(xbp): initialize project config".to_string(),
        action_label: "xbp init",
    })
    .await
    {
        Ok(AutoCommitResult::Committed(_)) => match push_current_branch(&current_dir).await {
            Ok(Some(outcome)) => print_push_summary(&outcome),
            Ok(None) => {}
            Err(e) => print_skip("xbp init", &format!("git push failed: {}", e)),
        },
        Ok(AutoCommitResult::Skipped(reason)) => print_skip("xbp init", &reason),
        Err(e) => print_skip("xbp init", &e),
    }

    Ok(())
}

fn infer_project_name(
    project_type: &ProjectType,
    current_dir: &Path,
    recommendations: &DeploymentRecommendations,
) -> String {
    shared_infer_project_name(current_dir, project_type, recommendations)
}

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::Vercel { .. } => Some("vercel".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(),
        "railway".to_string(),
        "vercel".to_string(),
        "docker-compose".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> {
    if let Ok(parsed) = parse_env_file(path) {
        if let Some(port) = parsed
            .get("PORT")
            .and_then(|value| value.parse::<u16>().ok())
        {
            return Some(port);
        }
    }

    let contents = fs::read_to_string(path).ok()?;
    for line in contents.lines() {
        if let Some(port) = extract_port_from_str(line.trim()) {
            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(parsed) = parse_env_file(&path) {
            for (key, value) in parsed {
                env_map.entry(key).or_insert(value);
            }
        }
    }
    to_env_references(&env_map)
}

fn write_configs(config: &XbpConfig, yaml_path: &Path) -> Result<Vec<PathBuf>, 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 mut written_paths = vec![yaml_path.to_path_buf()];

    let json_path = yaml_path
        .parent()
        .map(|parent| parent.join("xbp.json"))
        .ok_or_else(|| "Invalid YAML config path".to_string())?;
    if json_path.exists() {
        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))?;
        written_paths.push(json_path);
    }

    Ok(written_paths)
}

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())
}