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(¤t_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);
}