Skip to main content

xbp_cli/commands/
generate_config.rs

1//! Project config generation and migration helpers.
2//!
3//! Generates or updates `.xbp/xbp.yaml` and can convert legacy JSON config
4//! files into the canonical YAML location.
5
6use crate::commands::project_services::auto_populate_services;
7use crate::strategies::project_detector::{
8    infer_project_name as shared_infer_project_name, infer_target as shared_infer_target,
9    DeploymentRecommendations, ProjectDetector, ProjectType,
10};
11use crate::strategies::{
12    normalize_config_paths_for_persistence, resolve_config_paths_for_runtime, XbpConfig,
13};
14use crate::utils::{
15    collapse_project_path, default_project_yaml_config_path, find_xbp_config_upwards,
16    maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal,
17};
18use std::env;
19use std::fs;
20use std::path::{Path, PathBuf};
21use tokio::process::Command;
22
23#[derive(Debug, Clone)]
24pub struct GenerateConfigArgs {
25    pub force: bool,
26    pub update: bool,
27    pub from_json: Option<PathBuf>,
28}
29
30pub async fn run_generate_config(args: GenerateConfigArgs, _debug: bool) -> Result<(), String> {
31    let current_dir =
32        env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
33
34    let source = resolve_source_config(&current_dir, args.from_json.clone())?;
35    let project_root = source
36        .as_ref()
37        .map(|source| source.project_root.clone())
38        .unwrap_or_else(|| current_dir.clone());
39    let yaml_path = default_project_yaml_config_path(&project_root);
40
41    if yaml_path.exists() && !args.force && !args.update {
42        return Err(format!(
43            "Config already exists at {}. Use --update to refresh it or --force to overwrite.",
44            yaml_path.display()
45        ));
46    }
47
48    let mut config = if let Some(source) = &source {
49        if source.kind == "json" {
50            let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(
51                &source.project_root,
52                &source.config_path,
53            );
54        }
55        load_xbp_config_from_path(&source.config_path, source.kind)?
56    } else {
57        build_detected_baseline(&project_root).await?
58    };
59
60    if args.update {
61        apply_recommendation_defaults(&mut config, &project_root).await?;
62    }
63
64    normalize_config_for_persistence(&mut config, &project_root);
65
66    if let Some(parent) = yaml_path.parent() {
67        fs::create_dir_all(parent).map_err(|e| {
68            format!(
69                "Failed to create config directory {}: {}",
70                parent.display(),
71                e
72            )
73        })?;
74    }
75
76    let yaml = serde_yaml::to_string(&config)
77        .map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
78    fs::write(&yaml_path, yaml)
79        .map_err(|e| format!("Failed to write YAML config {}: {}", yaml_path.display(), e))?;
80
81    if args.update {
82        println!("Updated {}", yaml_path.display());
83    } else {
84        println!("Generated {}", yaml_path.display());
85    }
86
87    if let Some(source) = source {
88        if source.kind == "json" {
89            println!(
90                "Converted legacy {} to {}",
91                source.config_path.display(),
92                yaml_path.display()
93            );
94        }
95    }
96
97    Ok(())
98}
99
100#[derive(Debug, Clone)]
101struct SourceConfig {
102    project_root: PathBuf,
103    config_path: PathBuf,
104    kind: &'static str,
105}
106
107fn resolve_source_config(
108    current_dir: &Path,
109    from_json: Option<PathBuf>,
110) -> Result<Option<SourceConfig>, String> {
111    if let Some(raw_path) = from_json {
112        let config_path = if raw_path.is_absolute() {
113            raw_path
114        } else {
115            current_dir.join(raw_path)
116        };
117
118        if !config_path.exists() {
119            return Err(format!(
120                "Legacy JSON config not found: {}",
121                config_path.display()
122            ));
123        }
124
125        if config_path.file_name() != Some(std::ffi::OsStr::new("xbp.json")) {
126            return Err(format!(
127                "--from-json expects an xbp.json file, got {}",
128                config_path.display()
129            ));
130        }
131
132        let project_root = resolve_project_root_from_config_path(&config_path)?;
133        return Ok(Some(SourceConfig {
134            project_root,
135            config_path,
136            kind: "json",
137        }));
138    }
139
140    let found = find_xbp_config_upwards(current_dir);
141    Ok(found.map(|found| SourceConfig {
142        project_root: found.project_root,
143        config_path: found.config_path,
144        kind: found.kind,
145    }))
146}
147
148fn resolve_project_root_from_config_path(path: &Path) -> Result<PathBuf, String> {
149    let parent = path
150        .parent()
151        .ok_or_else(|| format!("Invalid config path: {}", path.display()))?;
152
153    if parent.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
154        parent
155            .parent()
156            .map(|root| root.to_path_buf())
157            .ok_or_else(|| format!("Invalid .xbp directory path: {}", parent.display()))
158    } else {
159        Ok(parent.to_path_buf())
160    }
161}
162
163fn load_xbp_config_from_path(path: &Path, kind_hint: &str) -> Result<XbpConfig, String> {
164    let project_root = resolve_project_root_from_config_path(path)?;
165    let content = fs::read_to_string(path)
166        .map_err(|e| format!("Failed to read config {}: {}", path.display(), e))?;
167    let kind = match kind_hint {
168        "yaml" | "json" => kind_hint,
169        _ => detect_kind(path),
170    };
171
172    let (mut config, healed_content): (XbpConfig, Option<String>) =
173        parse_config_with_auto_heal(&content, kind).map_err(|e| {
174            if kind == "yaml" {
175                format!("Failed to parse YAML config {}: {}", path.display(), e)
176            } else {
177                format!("Failed to parse JSON config {}: {}", path.display(), e)
178            }
179        })?;
180
181    if let Some(healed_content) = healed_content {
182        let _ = fs::write(path, healed_content);
183    }
184
185    resolve_config_paths_for_runtime(&mut config, &project_root);
186
187    Ok(config)
188}
189
190fn detect_kind(path: &Path) -> &'static str {
191    if path
192        .extension()
193        .and_then(|ext| ext.to_str())
194        .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
195        .unwrap_or(false)
196    {
197        "yaml"
198    } else {
199        "json"
200    }
201}
202
203async fn build_detected_baseline(project_root: &Path) -> Result<XbpConfig, String> {
204    let detected = ProjectDetector::detect_project_type(project_root)
205        .await
206        .unwrap_or(ProjectType::Unknown);
207    let recommendations = ProjectDetector::get_deployment_recommendations(project_root, &detected);
208    let mut config = build_baseline_config(project_root, &detected, &recommendations);
209    auto_populate_services(&mut config, project_root, &detected).await?;
210    Ok(config)
211}
212
213fn build_baseline_config(
214    project_root: &Path,
215    detected: &ProjectType,
216    recommendations: &DeploymentRecommendations,
217) -> XbpConfig {
218    let inferred_name = infer_project_name(project_root, detected, recommendations);
219    let target = infer_target(detected);
220    XbpConfig {
221        project_name: recommendations
222            .process_name
223            .clone()
224            .unwrap_or(inferred_name),
225        version: "0.1.0".to_string(),
226        port: recommendations.default_port,
227        build_dir: collapse_project_path(project_root, project_root.to_string_lossy().as_ref()),
228        app_type: target.clone(),
229        build_command: recommendations.build_command.clone(),
230        start_command: recommendations.start_command.clone(),
231        install_command: recommendations.install_command.clone(),
232        environment: None,
233        services: None,
234        systemd_service_name: None,
235        systemd: None,
236        kafka_brokers: None,
237        kafka_topic: None,
238        kafka_public_url: None,
239        log_files: None,
240        monitor_url: None,
241        monitor_method: None,
242        monitor_expected_code: None,
243        monitor_interval: None,
244        database: None,
245        target: target.clone(),
246        branch: Some("main".to_string()),
247        crate_name: None,
248        npm_script: None,
249        port_storybook: None,
250        url: None,
251        url_storybook: None,
252        linear: None,
253        github: None,
254        publish: None,
255        version_targets: Vec::new(),
256    }
257}
258
259async fn apply_recommendation_defaults(
260    config: &mut XbpConfig,
261    project_root: &Path,
262) -> Result<(), String> {
263    let detected = ProjectDetector::detect_project_type(project_root)
264        .await
265        .unwrap_or(ProjectType::Unknown);
266    let recommendations = ProjectDetector::get_deployment_recommendations(project_root, &detected);
267
268    if config.project_name.trim().is_empty() {
269        config.project_name = infer_project_name(project_root, &detected, &recommendations);
270    }
271
272    if config.port == 0 {
273        config.port = recommendations.default_port;
274    }
275
276    if config.build_dir.trim().is_empty() {
277        config.build_dir =
278            collapse_project_path(project_root, project_root.to_string_lossy().as_ref());
279    } else {
280        config.build_dir = collapse_project_path(project_root, &config.build_dir);
281    }
282
283    if config.app_type.is_none() {
284        config.app_type = infer_target(&detected);
285    }
286    if config.target.is_none() {
287        config.target = infer_target(&detected);
288    }
289
290    if config.build_command.is_none() {
291        config.build_command = recommendations.build_command.clone();
292    }
293    if config.start_command.is_none() {
294        config.start_command = recommendations.start_command.clone();
295    }
296    if config.install_command.is_none() {
297        config.install_command = recommendations.install_command.clone();
298    }
299
300    if config.branch.is_none() {
301        config.branch = current_git_branch().await;
302    }
303    if config.version.trim().is_empty() {
304        config.version = "0.1.0".to_string();
305    }
306
307    if config.services.as_ref().is_none_or(Vec::is_empty) {
308        auto_populate_services(config, project_root, &detected).await?;
309    }
310
311    Ok(())
312}
313
314fn infer_project_name(
315    project_root: &Path,
316    detected: &ProjectType,
317    recommendations: &DeploymentRecommendations,
318) -> String {
319    shared_infer_project_name(project_root, detected, recommendations)
320}
321
322fn infer_target(detected: &ProjectType) -> Option<String> {
323    shared_infer_target(detected)
324}
325
326async fn current_git_branch() -> Option<String> {
327    let output = Command::new("git")
328        .args(["rev-parse", "--abbrev-ref", "HEAD"])
329        .output()
330        .await
331        .ok()?;
332
333    if !output.status.success() {
334        return None;
335    }
336
337    String::from_utf8(output.stdout)
338        .ok()
339        .map(|branch| branch.trim().to_string())
340        .filter(|branch| !branch.is_empty())
341}
342
343fn normalize_config_for_persistence(config: &mut XbpConfig, project_root: &Path) {
344    normalize_config_paths_for_persistence(config, project_root);
345}