1use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs;
11use std::path::PathBuf;
12
13use crate::config::LinearConfig;
14use crate::utils::{
15 collapse_project_path, find_xbp_config_upwards, maybe_auto_convert_legacy_xbp_json_to_yaml,
16 parse_config_with_auto_heal, resolve_env_placeholders, resolve_project_path,
17};
18
19fn default_xbp_version() -> String {
20 "0.1.0".to_string()
21}
22
23fn default_auto_push_on_commit() -> bool {
24 true
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ServiceCommands {
29 #[serde(default)]
30 pub pre: Option<String>,
31 #[serde(default)]
32 pub install: Option<String>,
33 #[serde(default)]
34 pub build: Option<String>,
35 #[serde(default)]
36 pub start: Option<String>,
37 #[serde(default)]
38 pub dev: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42pub struct SystemdConfig {
43 #[serde(default)]
44 pub environment_files: Vec<String>,
45 #[serde(default)]
46 pub config_paths: Vec<String>,
47 #[serde(default)]
48 pub read_write_paths: Vec<String>,
49 #[serde(default)]
50 pub runtime_directories: Vec<String>,
51 #[serde(default)]
52 pub state_directories: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct DatabaseConfig {
57 #[serde(default)]
58 pub enabled: Option<bool>,
59 #[serde(default)]
60 pub backend: Option<String>,
61 #[serde(default)]
62 pub url_env: Option<String>,
63 #[serde(default)]
64 pub key_env: Option<String>,
65 #[serde(default)]
66 pub schema: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ServiceConfig {
71 pub name: String,
72 pub target: String,
73 pub branch: String,
74 pub port: u16,
75 #[serde(default)]
76 pub root_directory: Option<String>,
77 #[serde(default)]
78 pub environment: Option<HashMap<String, String>>,
79 #[serde(default)]
80 pub url: Option<String>,
81 #[serde(default)]
82 pub healthcheck_path: Option<String>,
83 #[serde(default)]
84 pub restart_policy: Option<String>,
85 #[serde(default)]
86 pub restart_policy_max_failure_count: Option<u32>,
87 #[serde(default)]
88 pub start_wrapper: Option<String>,
89 #[serde(default)]
90 pub commands: Option<ServiceCommands>,
91 #[serde(default)]
92 pub force_run_from_root: Option<bool>,
93 #[serde(default)]
94 pub systemd_service_name: Option<String>,
95 #[serde(default)]
96 pub systemd: Option<SystemdConfig>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct XbpConfig {
101 pub project_name: String,
102 #[serde(default = "default_xbp_version")]
103 pub version: String,
104 pub port: u16,
105 pub build_dir: String,
106 #[serde(default)]
107 pub app_type: Option<String>,
108 #[serde(default)]
109 pub build_command: Option<String>,
110 #[serde(default)]
111 pub start_command: Option<String>,
112 #[serde(default)]
113 pub install_command: Option<String>,
114 #[serde(default)]
115 pub environment: Option<HashMap<String, String>>,
116 #[serde(default)]
117 pub services: Option<Vec<ServiceConfig>>,
118 #[serde(default)]
119 pub systemd_service_name: Option<String>,
120 #[serde(default)]
121 pub systemd: Option<SystemdConfig>,
122 #[serde(default)]
123 pub kafka_brokers: Option<String>,
124 #[serde(default)]
125 pub kafka_topic: Option<String>,
126 #[serde(default)]
127 pub kafka_public_url: Option<String>,
128 #[serde(default)]
129 pub log_files: Option<Vec<String>>,
130 #[serde(default)]
131 pub monitor_url: Option<String>,
132 #[serde(default)]
133 pub monitor_method: Option<String>,
134 #[serde(default)]
135 pub monitor_expected_code: Option<u16>,
136 #[serde(default)]
137 pub monitor_interval: Option<u64>,
138 #[serde(default)]
139 pub database: Option<DatabaseConfig>,
140 #[serde(default)]
142 pub target: Option<String>,
143 #[serde(default)]
144 pub branch: Option<String>,
145 #[serde(default)]
146 pub crate_name: Option<String>,
147 #[serde(default)]
148 pub npm_script: Option<String>,
149 #[serde(default)]
150 pub port_storybook: Option<u16>,
151 #[serde(default)]
152 pub url: Option<String>,
153 #[serde(default)]
154 pub url_storybook: Option<String>,
155 #[serde(default)]
156 pub linear: Option<LinearConfig>,
157 #[serde(default)]
158 pub github: Option<GitHubProjectConfig>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, Default)]
162pub struct GitHubProjectConfig {
163 #[serde(default = "default_auto_push_on_commit")]
164 pub auto_push_on_commit: bool,
165}
166
167#[derive(Debug, Clone)]
168pub struct DeploymentConfig {
169 pub app_name: String,
170 pub port: u16,
171 pub app_dir: PathBuf,
172 pub build_command: Option<String>,
173 pub start_command: Option<String>,
174 pub install_command: Option<String>,
175 pub environment: HashMap<String, String>,
176}
177
178pub fn normalize_config_paths_for_persistence(
179 config: &mut XbpConfig,
180 project_root: &std::path::Path,
181) {
182 config.build_dir = collapse_project_path(project_root, &config.build_dir);
183 if let Some(services) = &mut config.services {
184 for service in services {
185 if let Some(root_directory) = &service.root_directory {
186 service.root_directory = Some(collapse_project_path(project_root, root_directory));
187 }
188 }
189 }
190}
191
192pub fn resolve_config_paths_for_runtime(config: &mut XbpConfig, project_root: &std::path::Path) {
193 config.build_dir = resolve_project_path(project_root, &config.build_dir);
194 if let Some(services) = &mut config.services {
195 for service in services {
196 if let Some(root_directory) = &service.root_directory {
197 service.root_directory = Some(resolve_project_path(project_root, root_directory));
198 }
199 }
200 }
201}
202
203impl XbpConfig {
204 pub fn auto_push_on_commit_enabled(&self) -> bool {
205 self.github
206 .as_ref()
207 .map(|config| config.auto_push_on_commit)
208 .unwrap_or(true)
209 }
210}
211
212impl DeploymentConfig {
213 pub async fn from_args_or_config(
215 app_name: Option<String>,
216 port: Option<u16>,
217 app_dir: Option<PathBuf>,
218 config_path: Option<PathBuf>,
219 ) -> Result<Self, String> {
220 let xbp_config = if app_name.is_none() || port.is_none() || app_dir.is_none() {
222 Self::load_xbp_config(config_path).await.ok()
223 } else {
224 None
225 };
226
227 let app_name = app_name
228 .or_else(|| xbp_config.as_ref().map(|c| c.project_name.clone()))
229 .ok_or("Missing app name")?;
230
231 let port = port
232 .or_else(|| xbp_config.as_ref().map(|c| c.port))
233 .ok_or("Missing port")?;
234
235 let app_dir = app_dir
236 .or_else(|| xbp_config.as_ref().map(|c| PathBuf::from(&c.build_dir)))
237 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
238
239 let app_dir = app_dir
240 .canonicalize()
241 .map_err(|e| format!("Failed to resolve app directory: {}", e))?;
242
243 let build_command = xbp_config.as_ref().and_then(|c| c.build_command.clone());
245 let start_command = xbp_config.as_ref().and_then(|c| c.start_command.clone());
246 let install_command = xbp_config.as_ref().and_then(|c| c.install_command.clone());
247 let environment = xbp_config
248 .as_ref()
249 .and_then(|c| c.environment.clone())
250 .unwrap_or_default();
251 let environment = resolve_env_placeholders(&app_dir, &environment);
252
253 Ok(DeploymentConfig {
254 app_name,
255 port,
256 app_dir,
257 build_command,
258 start_command,
259 install_command,
260 environment,
261 })
262 }
263
264 pub async fn load_xbp_config(config_path: Option<PathBuf>) -> Result<XbpConfig, String> {
266 let cwd = std::env::current_dir().unwrap_or_default();
267
268 let (project_root, resolved_path, resolved_kind) = if let Some(p) = config_path.clone() {
269 let root = p
270 .parent()
271 .map(|pp| pp.to_path_buf())
272 .unwrap_or_else(|| cwd.clone());
273 (root, p, "auto")
274 } else {
275 let found = find_xbp_config_upwards(&cwd)
276 .ok_or_else(|| "Configuration file not found".to_string())?;
277 (found.project_root, found.config_path, found.kind)
278 };
279
280 let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &resolved_path);
281
282 let (config_path, kind) = (resolved_path, resolved_kind);
283
284 let content = fs::read_to_string(&config_path)
285 .map_err(|e| format!("Failed to read config: {}", e))?;
286
287 let effective_kind = match kind {
288 "yaml" | "json" => kind,
289 _ => {
290 if config_path
291 .extension()
292 .and_then(|s| s.to_str())
293 .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
294 .unwrap_or(false)
295 {
296 "yaml"
297 } else {
298 "json"
299 }
300 }
301 };
302
303 let (mut config, healed_content): (XbpConfig, Option<String>) =
304 parse_config_with_auto_heal(&content, effective_kind).map_err(|e| {
305 if effective_kind == "yaml" {
306 format!("Failed to parse YAML config: {}", e)
307 } else {
308 format!("Failed to parse JSON config: {}", e)
309 }
310 })?;
311
312 if let Some(healed_content) = healed_content {
313 let _ = fs::write(&config_path, healed_content);
314 }
315
316 resolve_config_paths_for_runtime(&mut config, &project_root);
317
318 if let Some(services) = &config.services {
320 validate_services(services)?;
321 }
322
323 Ok(config)
324 }
325
326 pub async fn save_xbp_config(&self, config_path: Option<PathBuf>) -> Result<(), String> {
328 let dir = self.app_dir.join(".xbp");
329 let json_path = dir.join("xbp.json");
330 let yaml_path = dir.join("xbp.yaml");
331
332 fs::create_dir_all(&dir)
334 .map_err(|e| format!("Failed to create config directory: {}", e))?;
335
336 let mut xbp_config = XbpConfig {
337 project_name: self.app_name.clone(),
338 version: default_xbp_version(),
339 port: self.port,
340 build_dir: self.app_dir.to_string_lossy().to_string(),
341 app_type: None,
342 build_command: self.build_command.clone(),
343 start_command: self.start_command.clone(),
344 install_command: self.install_command.clone(),
345 environment: if self.environment.is_empty() {
346 None
347 } else {
348 Some(self.environment.clone())
349 },
350 services: None,
351 systemd_service_name: None,
352 systemd: None,
353 kafka_brokers: None,
354 kafka_topic: None,
355 kafka_public_url: None,
356 log_files: None,
357 monitor_url: None,
358 monitor_method: None,
359 monitor_expected_code: None,
360 monitor_interval: None,
361 database: None,
362 target: None,
363 branch: None,
364 crate_name: None,
365 npm_script: None,
366 port_storybook: None,
367 url: None,
368 url_storybook: None,
369 linear: None,
370 github: None,
371 };
372
373 normalize_config_paths_for_persistence(&mut xbp_config, &self.app_dir);
374
375 let yaml = serde_yaml::to_string(&xbp_config)
376 .map_err(|e| format!("Failed to serialize config (yaml): {}", e))?;
377 let json = serde_json::to_string_pretty(&xbp_config)
378 .map_err(|e| format!("Failed to serialize config (json): {}", e))?;
379
380 let explicit_path = config_path;
381 let explicit_is_json = explicit_path
382 .as_ref()
383 .and_then(|path| path.extension().and_then(|ext| ext.to_str()))
384 .map(|ext| ext.eq_ignore_ascii_case("json"))
385 .unwrap_or(false);
386
387 fs::write(&yaml_path, &yaml)
388 .map_err(|e| format!("Failed to write yaml config {}: {}", yaml_path.display(), e))?;
389
390 if explicit_is_json {
391 let out_path = explicit_path.expect("explicit path should exist");
392 fs::write(&out_path, &json).map_err(|e| {
393 format!(
394 "Failed to write legacy JSON config {}: {}",
395 out_path.display(),
396 e
397 )
398 })?;
399 } else if json_path.exists() {
400 fs::write(&json_path, &json).map_err(|e| {
401 format!(
402 "Failed to sync legacy JSON config {}: {}",
403 json_path.display(),
404 e
405 )
406 })?;
407 }
408
409 Ok(())
410 }
411
412 pub fn update_port(&mut self, new_port: u16) {
414 self.port = new_port;
415 }
416
417 pub fn merge_with_recommendations(
419 &mut self,
420 recommendations: &super::project_detector::DeploymentRecommendations,
421 ) {
422 if self.build_command.is_none() {
424 self.build_command = recommendations.build_command.clone();
425 }
426
427 if self.start_command.is_none() {
428 self.start_command = recommendations.start_command.clone();
429 }
430
431 if self.install_command.is_none() {
432 self.install_command = recommendations.install_command.clone();
433 }
434
435 if let Some(recommended_name) = &recommendations.process_name {
437 if self.app_name == "app" || self.app_name == "unknown" {
438 self.app_name = recommended_name.clone();
439 }
440 }
441 }
442}
443
444pub fn validate_services(services: &[ServiceConfig]) -> Result<(), String> {
446 let mut names = std::collections::HashSet::new();
447 let mut ports = std::collections::HashSet::new();
448 let mut urls = std::collections::HashSet::new();
449
450 for service in services {
451 if !names.insert(&service.name) {
453 return Err(format!("Duplicate service name found: {}", service.name));
454 }
455
456 if !ports.insert(service.port) {
458 return Err(format!("Duplicate port found: {}", service.port));
459 }
460
461 if let Some(url) = &service.url {
463 if !urls.insert(url) {
464 return Err(format!("Duplicate URL found: {}", url));
465 }
466 }
467
468 let valid_targets = ["python", "expressjs", "nextjs", "rust"];
470 if !valid_targets.contains(&service.target.as_str()) {
471 return Err(format!(
472 "Invalid target '{}' for service '{}'. Must be one of: python, expressjs, nextjs, rust",
473 service.target, service.name
474 ));
475 }
476 }
477
478 Ok(())
479}
480
481pub fn get_service_by_name(config: &XbpConfig, name: &str) -> Result<ServiceConfig, String> {
483 if let Some(services) = &config.services {
484 services
485 .iter()
486 .find(|s| s.name == name)
487 .cloned()
488 .ok_or_else(|| format!("Service '{}' not found in configuration", name))
489 } else {
490 Err("No services configured. This project uses legacy single-service format.".to_string())
491 }
492}
493
494pub fn get_all_services(config: &XbpConfig) -> Vec<ServiceConfig> {
496 if let Some(services) = &config.services {
497 services.clone()
498 } else {
499 vec![ServiceConfig {
501 name: config.project_name.clone(),
502 target: config.target.clone().unwrap_or_else(|| "rust".to_string()),
503 branch: config.branch.clone().unwrap_or_else(|| "main".to_string()),
504 port: config.port,
505 root_directory: Some(config.build_dir.clone()),
506 environment: config.environment.clone(),
507 url: config.url.clone(),
508 healthcheck_path: None,
509 restart_policy: Some("on_failure".to_string()),
510 restart_policy_max_failure_count: Some(10),
511 start_wrapper: Some("pm2".to_string()),
512 commands: Some(ServiceCommands {
513 pre: None,
514 install: config.install_command.clone(),
515 build: config.build_command.clone(),
516 start: config.start_command.clone(),
517 dev: None,
518 }),
519 force_run_from_root: Some(false),
520 systemd_service_name: config.systemd_service_name.clone(),
521 systemd: config.systemd.clone(),
522 }]
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::XbpConfig;
529
530 #[test]
531 fn github_auto_push_defaults_to_true_when_missing() {
532 let config: XbpConfig = serde_yaml::from_str(
533 r#"
534project_name: demo
535version: 0.1.0
536port: 3000
537build_dir: ./
538"#,
539 )
540 .expect("parse config");
541
542 assert!(config.auto_push_on_commit_enabled());
543 }
544
545 #[test]
546 fn github_auto_push_can_be_disabled_per_project() {
547 let config: XbpConfig = serde_yaml::from_str(
548 r#"
549project_name: demo
550version: 0.1.0
551port: 3000
552build_dir: ./
553github:
554 auto_push_on_commit: false
555"#,
556 )
557 .expect("parse config");
558
559 assert!(!config.auto_push_on_commit_enabled());
560 }
561}