1use 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(¤t_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}