xbp 10.28.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Project config generation and migration helpers.
//!
//! Generates or updates `.xbp/xbp.yaml` and can convert legacy JSON config
//! files into the canonical YAML location.

use crate::strategies::project_detector::{
    infer_project_name as shared_infer_project_name, infer_target as shared_infer_target,
    DeploymentRecommendations, ProjectDetector, ProjectType,
};
use crate::strategies::{
    normalize_config_paths_for_persistence, resolve_config_paths_for_runtime, XbpConfig,
};
use crate::utils::{
    collapse_project_path, default_project_yaml_config_path, find_xbp_config_upwards,
    maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal,
};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::process::Command;

#[derive(Debug, Clone)]
pub struct GenerateConfigArgs {
    pub force: bool,
    pub update: bool,
    pub from_json: Option<PathBuf>,
}

pub async fn run_generate_config(args: GenerateConfigArgs, _debug: bool) -> Result<(), String> {
    let current_dir =
        env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;

    let source = resolve_source_config(&current_dir, args.from_json.clone())?;
    let project_root = source
        .as_ref()
        .map(|source| source.project_root.clone())
        .unwrap_or_else(|| current_dir.clone());
    let yaml_path = default_project_yaml_config_path(&project_root);

    if yaml_path.exists() && !args.force && !args.update {
        return Err(format!(
            "Config already exists at {}. Use --update to refresh it or --force to overwrite.",
            yaml_path.display()
        ));
    }

    let mut config = if let Some(source) = &source {
        if source.kind == "json" {
            let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(
                &source.project_root,
                &source.config_path,
            );
        }
        load_xbp_config_from_path(&source.config_path, source.kind)?
    } else {
        build_detected_baseline(&project_root).await?
    };

    if args.update {
        apply_recommendation_defaults(&mut config, &project_root).await?;
    }

    normalize_config_for_persistence(&mut config, &project_root);

    if let Some(parent) = yaml_path.parent() {
        fs::create_dir_all(parent).map_err(|e| {
            format!(
                "Failed to create config directory {}: {}",
                parent.display(),
                e
            )
        })?;
    }

    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 config {}: {}", yaml_path.display(), e))?;

    if args.update {
        println!("Updated {}", yaml_path.display());
    } else {
        println!("Generated {}", yaml_path.display());
    }

    if let Some(source) = source {
        if source.kind == "json" {
            println!(
                "Converted legacy {} to {}",
                source.config_path.display(),
                yaml_path.display()
            );
        }
    }

    Ok(())
}

#[derive(Debug, Clone)]
struct SourceConfig {
    project_root: PathBuf,
    config_path: PathBuf,
    kind: &'static str,
}

fn resolve_source_config(
    current_dir: &Path,
    from_json: Option<PathBuf>,
) -> Result<Option<SourceConfig>, String> {
    if let Some(raw_path) = from_json {
        let config_path = if raw_path.is_absolute() {
            raw_path
        } else {
            current_dir.join(raw_path)
        };

        if !config_path.exists() {
            return Err(format!(
                "Legacy JSON config not found: {}",
                config_path.display()
            ));
        }

        if config_path.file_name() != Some(std::ffi::OsStr::new("xbp.json")) {
            return Err(format!(
                "--from-json expects an xbp.json file, got {}",
                config_path.display()
            ));
        }

        let project_root = resolve_project_root_from_config_path(&config_path)?;
        return Ok(Some(SourceConfig {
            project_root,
            config_path,
            kind: "json",
        }));
    }

    let found = find_xbp_config_upwards(current_dir);
    Ok(found.map(|found| SourceConfig {
        project_root: found.project_root,
        config_path: found.config_path,
        kind: found.kind,
    }))
}

fn resolve_project_root_from_config_path(path: &Path) -> Result<PathBuf, String> {
    let parent = path
        .parent()
        .ok_or_else(|| format!("Invalid config path: {}", path.display()))?;

    if parent.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
        parent
            .parent()
            .map(|root| root.to_path_buf())
            .ok_or_else(|| format!("Invalid .xbp directory path: {}", parent.display()))
    } else {
        Ok(parent.to_path_buf())
    }
}

fn load_xbp_config_from_path(path: &Path, kind_hint: &str) -> Result<XbpConfig, String> {
    let project_root = resolve_project_root_from_config_path(path)?;
    let content = fs::read_to_string(path)
        .map_err(|e| format!("Failed to read config {}: {}", path.display(), e))?;
    let kind = match kind_hint {
        "yaml" | "json" => kind_hint,
        _ => detect_kind(path),
    };

    let (mut config, healed_content): (XbpConfig, Option<String>) =
        parse_config_with_auto_heal(&content, kind).map_err(|e| {
            if kind == "yaml" {
                format!("Failed to parse YAML config {}: {}", path.display(), e)
            } else {
                format!("Failed to parse JSON config {}: {}", path.display(), e)
            }
        })?;

    if let Some(healed_content) = healed_content {
        let _ = fs::write(path, healed_content);
    }

    resolve_config_paths_for_runtime(&mut config, &project_root);

    Ok(config)
}

fn detect_kind(path: &Path) -> &'static str {
    if path
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
        .unwrap_or(false)
    {
        "yaml"
    } else {
        "json"
    }
}

async fn build_detected_baseline(project_root: &Path) -> Result<XbpConfig, String> {
    let detected = ProjectDetector::detect_project_type(project_root)
        .await
        .unwrap_or(ProjectType::Unknown);
    let recommendations = ProjectDetector::get_deployment_recommendations(project_root, &detected);
    Ok(build_baseline_config(
        project_root,
        &detected,
        &recommendations,
    ))
}

fn build_baseline_config(
    project_root: &Path,
    detected: &ProjectType,
    recommendations: &DeploymentRecommendations,
) -> XbpConfig {
    let inferred_name = infer_project_name(project_root, detected, recommendations);
    let target = infer_target(detected);
    XbpConfig {
        project_name: recommendations
            .process_name
            .clone()
            .unwrap_or(inferred_name),
        version: "0.1.0".to_string(),
        port: recommendations.default_port,
        build_dir: collapse_project_path(project_root, project_root.to_string_lossy().as_ref()),
        app_type: target.clone(),
        build_command: recommendations.build_command.clone(),
        start_command: recommendations.start_command.clone(),
        install_command: recommendations.install_command.clone(),
        environment: None,
        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: target.clone(),
        branch: Some("main".to_string()),
        crate_name: None,
        npm_script: None,
        port_storybook: None,
        url: None,
        url_storybook: None,
        linear: None,
        github: None,
        publish: None,
    }
}

async fn apply_recommendation_defaults(
    config: &mut XbpConfig,
    project_root: &Path,
) -> Result<(), String> {
    let detected = ProjectDetector::detect_project_type(project_root)
        .await
        .unwrap_or(ProjectType::Unknown);
    let recommendations = ProjectDetector::get_deployment_recommendations(project_root, &detected);

    if config.project_name.trim().is_empty() {
        config.project_name = infer_project_name(project_root, &detected, &recommendations);
    }

    if config.port == 0 {
        config.port = recommendations.default_port;
    }

    if config.build_dir.trim().is_empty() {
        config.build_dir =
            collapse_project_path(project_root, project_root.to_string_lossy().as_ref());
    } else {
        config.build_dir = collapse_project_path(project_root, &config.build_dir);
    }

    if config.app_type.is_none() {
        config.app_type = infer_target(&detected);
    }
    if config.target.is_none() {
        config.target = infer_target(&detected);
    }

    if config.build_command.is_none() {
        config.build_command = recommendations.build_command.clone();
    }
    if config.start_command.is_none() {
        config.start_command = recommendations.start_command.clone();
    }
    if config.install_command.is_none() {
        config.install_command = recommendations.install_command.clone();
    }

    if config.branch.is_none() {
        config.branch = current_git_branch().await;
    }
    if config.version.trim().is_empty() {
        config.version = "0.1.0".to_string();
    }

    Ok(())
}

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

fn infer_target(detected: &ProjectType) -> Option<String> {
    shared_infer_target(detected)
}

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(|branch| branch.trim().to_string())
        .filter(|branch| !branch.is_empty())
}

fn normalize_config_for_persistence(config: &mut XbpConfig, project_root: &Path) {
    normalize_config_paths_for_persistence(config, project_root);
}