1use serde::{Deserialize, Deserializer, Serialize, Serializer};
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
27pub const DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE: &str = "releases/${GITHUB_VERSION}";
28
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub struct PublishTargetConfig {
31 #[serde(default)]
32 pub enabled: Option<bool>,
33 #[serde(default)]
34 pub package_name: Option<String>,
35 #[serde(default)]
36 pub working_directory: Option<String>,
37 #[serde(default)]
38 pub manifest_path: Option<String>,
39 #[serde(default)]
40 pub token: Option<String>,
41 #[serde(default)]
42 pub preflight_commands: Vec<String>,
43 #[serde(default)]
44 pub publish_command: Option<String>,
45 #[serde(default)]
46 pub use_wsl: Option<bool>,
47 #[serde(default)]
48 pub wsl_distribution: Option<String>,
49 #[serde(default)]
50 pub generate_npmrc: Option<bool>,
51 #[serde(default)]
52 pub access: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct PublishProjectConfig {
57 #[serde(default)]
58 pub npm: Option<PublishTargetConfig>,
59 #[serde(default)]
60 pub crates: Option<PublishTargetConfig>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ServiceCommands {
65 #[serde(default)]
66 pub pre: Option<String>,
67 #[serde(default)]
68 pub install: Option<String>,
69 #[serde(default)]
70 pub build: Option<String>,
71 #[serde(default)]
72 pub start: Option<String>,
73 #[serde(default)]
74 pub dev: Option<String>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct SystemdConfig {
79 #[serde(default)]
80 pub environment_files: Vec<String>,
81 #[serde(default)]
82 pub config_paths: Vec<String>,
83 #[serde(default)]
84 pub read_write_paths: Vec<String>,
85 #[serde(default)]
86 pub runtime_directories: Vec<String>,
87 #[serde(default)]
88 pub state_directories: Vec<String>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct DatabaseConfig {
93 #[serde(default)]
94 pub enabled: Option<bool>,
95 #[serde(default)]
96 pub backend: Option<String>,
97 #[serde(default)]
98 pub url_env: Option<String>,
99 #[serde(default)]
100 pub key_env: Option<String>,
101 #[serde(default)]
102 pub schema: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ServiceConfig {
107 pub name: String,
108 pub target: String,
109 pub branch: String,
110 pub port: u16,
111 #[serde(default)]
112 pub root_directory: Option<String>,
113 #[serde(default)]
114 pub environment: Option<HashMap<String, String>>,
115 #[serde(default)]
116 pub url: Option<String>,
117 #[serde(default)]
118 pub healthcheck_path: Option<String>,
119 #[serde(default)]
120 pub restart_policy: Option<String>,
121 #[serde(default)]
122 pub restart_policy_max_failure_count: Option<u32>,
123 #[serde(default)]
124 pub start_wrapper: Option<String>,
125 #[serde(default)]
126 pub commands: Option<ServiceCommands>,
127 #[serde(default)]
128 pub force_run_from_root: Option<bool>,
129 #[serde(default)]
130 pub version_targets: Option<Vec<String>>,
131 #[serde(default)]
132 pub systemd_service_name: Option<String>,
133 #[serde(default)]
134 pub systemd: Option<SystemdConfig>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct XbpConfig {
139 pub project_name: String,
140 #[serde(default = "default_xbp_version")]
141 pub version: String,
142 pub port: u16,
143 pub build_dir: String,
144 #[serde(default)]
145 pub app_type: Option<String>,
146 #[serde(default)]
147 pub build_command: Option<String>,
148 #[serde(default)]
149 pub start_command: Option<String>,
150 #[serde(default)]
151 pub install_command: Option<String>,
152 #[serde(default)]
153 pub environment: Option<HashMap<String, String>>,
154 #[serde(default)]
155 pub services: Option<Vec<ServiceConfig>>,
156 #[serde(default)]
157 pub systemd_service_name: Option<String>,
158 #[serde(default)]
159 pub systemd: Option<SystemdConfig>,
160 #[serde(default)]
161 pub kafka_brokers: Option<String>,
162 #[serde(default)]
163 pub kafka_topic: Option<String>,
164 #[serde(default)]
165 pub kafka_public_url: Option<String>,
166 #[serde(default)]
167 pub log_files: Option<Vec<String>>,
168 #[serde(default)]
169 pub monitor_url: Option<String>,
170 #[serde(default)]
171 pub monitor_method: Option<String>,
172 #[serde(default)]
173 pub monitor_expected_code: Option<u16>,
174 #[serde(default)]
175 pub monitor_interval: Option<u64>,
176 #[serde(default)]
177 pub database: Option<DatabaseConfig>,
178 #[serde(default)]
180 pub target: Option<String>,
181 #[serde(default)]
182 pub branch: Option<String>,
183 #[serde(default)]
184 pub crate_name: Option<String>,
185 #[serde(default)]
186 pub npm_script: Option<String>,
187 #[serde(default)]
188 pub port_storybook: Option<u16>,
189 #[serde(default)]
190 pub url: Option<String>,
191 #[serde(default)]
192 pub url_storybook: Option<String>,
193 #[serde(default)]
194 pub linear: Option<LinearConfig>,
195 #[serde(default)]
196 pub github: Option<GitHubProjectConfig>,
197 #[serde(default)]
198 pub publish: Option<PublishProjectConfig>,
199 #[serde(default)]
200 pub version_targets: Vec<String>,
201}
202
203#[derive(Debug, Clone)]
204pub struct GitHubProjectConfig {
205 pub repository: Option<String>,
206 pub auto_push_on_commit: bool,
207 pub release_branch: Option<GitHubReleaseBranchConfig>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211struct GitHubProjectConfigObject {
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 repository: Option<String>,
214 #[serde(
215 default = "default_auto_push_on_commit",
216 skip_serializing_if = "github_auto_push_on_commit_is_default"
217 )]
218 auto_push_on_commit: bool,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 release_branch: Option<GitHubReleaseBranchConfig>,
221}
222
223fn github_auto_push_on_commit_is_default(value: &bool) -> bool {
224 *value
225}
226
227impl Default for GitHubProjectConfig {
228 fn default() -> Self {
229 Self {
230 repository: None,
231 auto_push_on_commit: default_auto_push_on_commit(),
232 release_branch: None,
233 }
234 }
235}
236
237impl Serialize for GitHubProjectConfig {
238 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
239 where
240 S: Serializer,
241 {
242 if let Some(repository) = &self.repository {
243 if self.auto_push_on_commit && self.release_branch.is_none() {
244 return serializer.serialize_str(repository);
245 }
246 }
247
248 GitHubProjectConfigObject {
249 repository: self.repository.clone(),
250 auto_push_on_commit: self.auto_push_on_commit,
251 release_branch: self.release_branch.clone(),
252 }
253 .serialize(serializer)
254 }
255}
256
257impl<'de> Deserialize<'de> for GitHubProjectConfig {
258 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
259 where
260 D: Deserializer<'de>,
261 {
262 #[derive(Deserialize)]
263 #[serde(untagged)]
264 enum Repr {
265 Repository(String),
266 Config(GitHubProjectConfigObject),
267 }
268
269 match Repr::deserialize(deserializer)? {
270 Repr::Repository(repository) => Ok(Self {
271 repository: Some(repository),
272 ..Self::default()
273 }),
274 Repr::Config(config) => Ok(Self {
275 repository: config.repository,
276 auto_push_on_commit: config.auto_push_on_commit,
277 release_branch: config.release_branch,
278 }),
279 }
280 }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, Default)]
284pub struct GitHubReleaseBranchConfig {
285 #[serde(default)]
286 pub enabled: bool,
287 #[serde(default, alias = "template")]
288 pub naming_template: Option<String>,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub struct GitHubReleaseBranchSettings {
293 pub naming_template: String,
294}
295
296#[derive(Debug, Clone)]
297pub struct DeploymentConfig {
298 pub app_name: String,
299 pub port: u16,
300 pub app_dir: PathBuf,
301 pub build_command: Option<String>,
302 pub start_command: Option<String>,
303 pub install_command: Option<String>,
304 pub environment: HashMap<String, String>,
305}
306
307pub fn normalize_config_paths_for_persistence(
308 config: &mut XbpConfig,
309 project_root: &std::path::Path,
310) {
311 config.build_dir = collapse_project_path(project_root, &config.build_dir);
312 if let Some(publish) = &mut config.publish {
313 for target in [&mut publish.npm, &mut publish.crates] {
314 if let Some(target) = target {
315 if let Some(working_directory) = &target.working_directory {
316 target.working_directory =
317 Some(collapse_project_path(project_root, working_directory));
318 }
319 if let Some(manifest_path) = &target.manifest_path {
320 target.manifest_path = Some(collapse_project_path(project_root, manifest_path));
321 }
322 }
323 }
324 }
325 for target in &mut config.version_targets {
326 *target = collapse_project_path(project_root, target);
327 }
328 if let Some(services) = &mut config.services {
329 for service in services {
330 if let Some(root_directory) = &service.root_directory {
331 service.root_directory = Some(collapse_project_path(project_root, root_directory));
332 }
333 if let Some(version_targets) = &mut service.version_targets {
334 for target in version_targets {
335 *target = collapse_project_path(project_root, target);
336 }
337 }
338 }
339 }
340}
341
342pub fn resolve_config_paths_for_runtime(config: &mut XbpConfig, project_root: &std::path::Path) {
343 config.build_dir = resolve_project_path(project_root, &config.build_dir);
344 if let Some(publish) = &mut config.publish {
345 for target in [&mut publish.npm, &mut publish.crates] {
346 if let Some(target) = target {
347 if let Some(working_directory) = &target.working_directory {
348 target.working_directory =
349 Some(resolve_project_path(project_root, working_directory));
350 }
351 if let Some(manifest_path) = &target.manifest_path {
352 target.manifest_path = Some(resolve_project_path(project_root, manifest_path));
353 }
354 }
355 }
356 }
357 for target in &mut config.version_targets {
358 *target = resolve_project_path(project_root, target);
359 }
360 if let Some(services) = &mut config.services {
361 for service in services {
362 if let Some(root_directory) = &service.root_directory {
363 service.root_directory = Some(resolve_project_path(project_root, root_directory));
364 }
365 if let Some(version_targets) = &mut service.version_targets {
366 for target in version_targets {
367 *target = resolve_project_path(project_root, target);
368 }
369 }
370 }
371 }
372}
373
374impl XbpConfig {
375 pub fn auto_push_on_commit_enabled(&self) -> bool {
376 self.github
377 .as_ref()
378 .map(|config| config.auto_push_on_commit)
379 .unwrap_or(true)
380 }
381
382 pub fn github_release_branch_settings(&self) -> Option<GitHubReleaseBranchSettings> {
383 let release_branch = self
384 .github
385 .as_ref()
386 .and_then(|config| config.release_branch.as_ref())?;
387 if !release_branch.enabled {
388 return None;
389 }
390
391 let naming_template = release_branch
392 .naming_template
393 .as_deref()
394 .map(str::trim)
395 .filter(|value| !value.is_empty())
396 .unwrap_or(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
397 .to_string();
398
399 Some(GitHubReleaseBranchSettings { naming_template })
400 }
401}
402
403impl DeploymentConfig {
404 pub async fn from_args_or_config(
406 app_name: Option<String>,
407 port: Option<u16>,
408 app_dir: Option<PathBuf>,
409 config_path: Option<PathBuf>,
410 ) -> Result<Self, String> {
411 let xbp_config = if app_name.is_none() || port.is_none() || app_dir.is_none() {
413 Self::load_xbp_config(config_path).await.ok()
414 } else {
415 None
416 };
417
418 let app_name = app_name
419 .or_else(|| xbp_config.as_ref().map(|c| c.project_name.clone()))
420 .ok_or("Missing app name")?;
421
422 let port = port
423 .or_else(|| xbp_config.as_ref().map(|c| c.port))
424 .ok_or("Missing port")?;
425
426 let app_dir = app_dir
427 .or_else(|| xbp_config.as_ref().map(|c| PathBuf::from(&c.build_dir)))
428 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
429
430 let app_dir = app_dir
431 .canonicalize()
432 .map_err(|e| format!("Failed to resolve app directory: {}", e))?;
433
434 let build_command = xbp_config.as_ref().and_then(|c| c.build_command.clone());
436 let start_command = xbp_config.as_ref().and_then(|c| c.start_command.clone());
437 let install_command = xbp_config.as_ref().and_then(|c| c.install_command.clone());
438 let environment = xbp_config
439 .as_ref()
440 .and_then(|c| c.environment.clone())
441 .unwrap_or_default();
442 let environment = resolve_env_placeholders(&app_dir, &environment);
443
444 Ok(DeploymentConfig {
445 app_name,
446 port,
447 app_dir,
448 build_command,
449 start_command,
450 install_command,
451 environment,
452 })
453 }
454
455 pub async fn load_xbp_config(config_path: Option<PathBuf>) -> Result<XbpConfig, String> {
457 let cwd = std::env::current_dir().unwrap_or_default();
458
459 let (project_root, resolved_path, resolved_kind) = if let Some(p) = config_path.clone() {
460 let root = p
461 .parent()
462 .map(|parent| {
463 if parent.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
464 parent
465 .parent()
466 .map(std::path::Path::to_path_buf)
467 .unwrap_or_else(|| parent.to_path_buf())
468 } else {
469 parent.to_path_buf()
470 }
471 })
472 .unwrap_or_else(|| cwd.clone());
473 (root, p, "auto")
474 } else {
475 let found = find_xbp_config_upwards(&cwd)
476 .ok_or_else(|| "Configuration file not found".to_string())?;
477 (found.project_root, found.config_path, found.kind)
478 };
479
480 let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &resolved_path);
481
482 let (config_path, kind) = (resolved_path, resolved_kind);
483
484 let content = fs::read_to_string(&config_path)
485 .map_err(|e| format!("Failed to read config: {}", e))?;
486
487 let effective_kind = match kind {
488 "yaml" | "json" => kind,
489 _ => {
490 if config_path
491 .extension()
492 .and_then(|s| s.to_str())
493 .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
494 .unwrap_or(false)
495 {
496 "yaml"
497 } else {
498 "json"
499 }
500 }
501 };
502
503 let (mut config, healed_content): (XbpConfig, Option<String>) =
504 parse_config_with_auto_heal(&content, effective_kind).map_err(|e| {
505 if effective_kind == "yaml" {
506 format!("Failed to parse YAML config: {}", e)
507 } else {
508 format!("Failed to parse JSON config: {}", e)
509 }
510 })?;
511
512 if let Some(healed_content) = healed_content {
513 let _ = fs::write(&config_path, healed_content);
514 }
515
516 resolve_config_paths_for_runtime(&mut config, &project_root);
517
518 if let Some(services) = &config.services {
520 validate_services(services)?;
521 }
522
523 Ok(config)
524 }
525
526 pub async fn save_xbp_config(&self, config_path: Option<PathBuf>) -> Result<(), String> {
528 let dir = self.app_dir.join(".xbp");
529 let json_path = dir.join("xbp.json");
530 let yaml_path = dir.join("xbp.yaml");
531
532 fs::create_dir_all(&dir)
534 .map_err(|e| format!("Failed to create config directory: {}", e))?;
535
536 let mut xbp_config = XbpConfig {
537 project_name: self.app_name.clone(),
538 version: default_xbp_version(),
539 port: self.port,
540 build_dir: self.app_dir.to_string_lossy().to_string(),
541 app_type: None,
542 build_command: self.build_command.clone(),
543 start_command: self.start_command.clone(),
544 install_command: self.install_command.clone(),
545 environment: if self.environment.is_empty() {
546 None
547 } else {
548 Some(self.environment.clone())
549 },
550 services: None,
551 systemd_service_name: None,
552 systemd: None,
553 kafka_brokers: None,
554 kafka_topic: None,
555 kafka_public_url: None,
556 log_files: None,
557 monitor_url: None,
558 monitor_method: None,
559 monitor_expected_code: None,
560 monitor_interval: None,
561 database: None,
562 target: None,
563 branch: None,
564 crate_name: None,
565 npm_script: None,
566 port_storybook: None,
567 url: None,
568 url_storybook: None,
569 linear: None,
570 github: None,
571 publish: None,
572 version_targets: Vec::new(),
573 };
574
575 normalize_config_paths_for_persistence(&mut xbp_config, &self.app_dir);
576
577 let yaml = serde_yaml::to_string(&xbp_config)
578 .map_err(|e| format!("Failed to serialize config (yaml): {}", e))?;
579 let json = serde_json::to_string_pretty(&xbp_config)
580 .map_err(|e| format!("Failed to serialize config (json): {}", e))?;
581
582 let explicit_path = config_path;
583 let explicit_is_json = explicit_path
584 .as_ref()
585 .and_then(|path| path.extension().and_then(|ext| ext.to_str()))
586 .map(|ext| ext.eq_ignore_ascii_case("json"))
587 .unwrap_or(false);
588
589 fs::write(&yaml_path, &yaml)
590 .map_err(|e| format!("Failed to write yaml config {}: {}", yaml_path.display(), e))?;
591
592 if explicit_is_json {
593 let out_path = explicit_path.expect("explicit path should exist");
594 fs::write(&out_path, &json).map_err(|e| {
595 format!(
596 "Failed to write legacy JSON config {}: {}",
597 out_path.display(),
598 e
599 )
600 })?;
601 } else if json_path.exists() {
602 fs::write(&json_path, &json).map_err(|e| {
603 format!(
604 "Failed to sync legacy JSON config {}: {}",
605 json_path.display(),
606 e
607 )
608 })?;
609 }
610
611 Ok(())
612 }
613
614 pub fn update_port(&mut self, new_port: u16) {
616 self.port = new_port;
617 }
618
619 pub fn merge_with_recommendations(
621 &mut self,
622 recommendations: &super::project_detector::DeploymentRecommendations,
623 ) {
624 if self.build_command.is_none() {
626 self.build_command = recommendations.build_command.clone();
627 }
628
629 if self.start_command.is_none() {
630 self.start_command = recommendations.start_command.clone();
631 }
632
633 if self.install_command.is_none() {
634 self.install_command = recommendations.install_command.clone();
635 }
636
637 if let Some(recommended_name) = &recommendations.process_name {
639 if self.app_name == "app" || self.app_name == "unknown" {
640 self.app_name = recommended_name.clone();
641 }
642 }
643 }
644}
645
646pub fn validate_services(services: &[ServiceConfig]) -> Result<(), String> {
648 let mut names = std::collections::HashSet::new();
649 let mut ports = std::collections::HashSet::new();
650 let mut urls = std::collections::HashSet::new();
651
652 for service in services {
653 if !names.insert(&service.name) {
655 return Err(format!("Duplicate service name found: {}", service.name));
656 }
657
658 if !ports.insert(service.port) {
660 return Err(format!("Duplicate port found: {}", service.port));
661 }
662
663 if let Some(url) = &service.url {
665 if !urls.insert(url) {
666 return Err(format!("Duplicate URL found: {}", url));
667 }
668 }
669
670 if service.target.trim().is_empty() {
672 return Err(format!(
673 "Service '{}' is missing a target. Set it to something like rust, nextjs, nodejs, python, docker, or a custom runtime label.",
674 service.name
675 ));
676 }
677 }
678
679 Ok(())
680}
681
682pub fn legacy_service_from_config(config: &XbpConfig) -> ServiceConfig {
683 ServiceConfig {
684 name: config.project_name.clone(),
685 target: config.target.clone().unwrap_or_else(|| "rust".to_string()),
686 branch: config.branch.clone().unwrap_or_else(|| "main".to_string()),
687 port: config.port,
688 root_directory: Some(config.build_dir.clone()),
689 environment: config.environment.clone(),
690 url: config.url.clone(),
691 healthcheck_path: None,
692 restart_policy: Some("on_failure".to_string()),
693 restart_policy_max_failure_count: Some(10),
694 start_wrapper: Some("pm2".to_string()),
695 commands: Some(ServiceCommands {
696 pre: None,
697 install: config.install_command.clone(),
698 build: config.build_command.clone(),
699 start: config.start_command.clone(),
700 dev: None,
701 }),
702 force_run_from_root: Some(false),
703 version_targets: if config.version_targets.is_empty() {
704 None
705 } else {
706 Some(config.version_targets.clone())
707 },
708 systemd_service_name: config.systemd_service_name.clone(),
709 systemd: config.systemd.clone(),
710 }
711}
712
713pub fn get_service_by_name(config: &XbpConfig, name: &str) -> Result<ServiceConfig, String> {
715 if let Some(services) = &config.services {
716 services
717 .iter()
718 .find(|s| s.name == name)
719 .cloned()
720 .ok_or_else(|| format!("Service '{}' not found in configuration", name))
721 } else {
722 Err("No services configured. This project uses legacy single-service format.".to_string())
723 }
724}
725
726pub fn get_all_services(config: &XbpConfig) -> Vec<ServiceConfig> {
728 if let Some(services) = &config.services {
729 services.clone()
730 } else {
731 vec![legacy_service_from_config(config)]
733 }
734}
735
736#[cfg(test)]
737mod tests {
738 use super::{
739 normalize_config_paths_for_persistence, resolve_config_paths_for_runtime, XbpConfig,
740 DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE,
741 };
742
743 #[test]
744 fn github_auto_push_defaults_to_true_when_missing() {
745 let config: XbpConfig = serde_yaml::from_str(
746 r#"
747project_name: demo
748version: 0.1.0
749port: 3000
750build_dir: ./
751"#,
752 )
753 .expect("parse config");
754
755 assert!(config.auto_push_on_commit_enabled());
756 }
757
758 #[test]
759 fn github_auto_push_can_be_disabled_per_project() {
760 let config: XbpConfig = serde_yaml::from_str(
761 r#"
762project_name: demo
763version: 0.1.0
764port: 3000
765build_dir: ./
766github:
767 auto_push_on_commit: false
768"#,
769 )
770 .expect("parse config");
771
772 assert!(!config.auto_push_on_commit_enabled());
773 }
774
775 #[test]
776 fn github_scalar_repository_shorthand_parses_with_defaults() {
777 let config: XbpConfig = serde_yaml::from_str(
778 r#"
779project_name: demo
780version: 0.1.0
781port: 3000
782build_dir: ./
783github: xylex-group/statbot-js
784"#,
785 )
786 .expect("parse config");
787
788 let github = config.github.expect("github config");
789 assert_eq!(github.repository.as_deref(), Some("xylex-group/statbot-js"));
790 assert!(github.auto_push_on_commit);
791 assert!(github.release_branch.is_none());
792 }
793
794 #[test]
795 fn github_scalar_repository_shorthand_round_trips_to_scalar_yaml() {
796 let config: XbpConfig = serde_yaml::from_str(
797 r#"
798project_name: demo
799version: 0.1.0
800port: 3000
801build_dir: ./
802github: xylex-group/statbot-js
803"#,
804 )
805 .expect("parse config");
806
807 let yaml = serde_yaml::to_string(&config).expect("serialize config");
808
809 assert!(yaml.contains("github: xylex-group/statbot-js"));
810 }
811
812 #[test]
813 fn github_release_branch_is_disabled_by_default() {
814 let config: XbpConfig = serde_yaml::from_str(
815 r#"
816project_name: demo
817version: 0.1.0
818port: 3000
819build_dir: ./
820"#,
821 )
822 .expect("parse config");
823
824 assert!(config.github_release_branch_settings().is_none());
825 }
826
827 #[test]
828 fn github_release_branch_uses_default_template_when_enabled_without_one() {
829 let config: XbpConfig = serde_yaml::from_str(
830 r#"
831project_name: demo
832version: 0.1.0
833port: 3000
834build_dir: ./
835github:
836 release_branch:
837 enabled: true
838"#,
839 )
840 .expect("parse config");
841
842 let settings = config
843 .github_release_branch_settings()
844 .expect("release branch settings");
845 assert_eq!(
846 settings.naming_template,
847 DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE
848 );
849 }
850
851 #[test]
852 fn github_release_branch_uses_custom_template_when_configured() {
853 let config: XbpConfig = serde_yaml::from_str(
854 r#"
855project_name: demo
856version: 0.1.0
857port: 3000
858build_dir: ./
859github:
860 release_branch:
861 enabled: true
862 naming_template: rel/${GITHUB_TAG}
863"#,
864 )
865 .expect("parse config");
866
867 let settings = config
868 .github_release_branch_settings()
869 .expect("release branch settings");
870 assert_eq!(settings.naming_template, "rel/${GITHUB_TAG}");
871 }
872
873 #[test]
874 fn config_version_targets_resolve_for_runtime_and_persist_relatively() {
875 let project_root = std::env::temp_dir().join("xbp-version-target-config");
876 let mut config: XbpConfig = serde_yaml::from_str(
877 r#"
878project_name: demo
879version: 0.1.0
880port: 3000
881build_dir: ./
882version_targets:
883 - crates/cli/Cargo.toml
884 - apps/web/package.json
885"#,
886 )
887 .expect("parse config");
888
889 resolve_config_paths_for_runtime(&mut config, &project_root);
890 assert!(std::path::Path::new(&config.version_targets[0]).ends_with("crates/cli/Cargo.toml"));
891 assert!(std::path::Path::new(&config.version_targets[1]).ends_with("apps/web/package.json"));
892
893 normalize_config_paths_for_persistence(&mut config, &project_root);
894 assert_eq!(
895 config.version_targets,
896 vec![
897 "crates/cli/Cargo.toml".to_string(),
898 "apps/web/package.json".to_string()
899 ]
900 );
901 }
902
903 #[test]
904 fn service_version_targets_resolve_for_runtime_and_persist_relatively() {
905 let project_root = std::env::temp_dir().join("xbp-service-version-target-config");
906 let mut config: XbpConfig = serde_yaml::from_str(
907 r#"
908project_name: demo
909version: 0.1.0
910port: 3000
911build_dir: ./
912services:
913 - name: web
914 target: nextjs
915 branch: main
916 port: 3001
917 root_directory: apps/web
918 version_targets:
919 - apps/web/package.json
920"#,
921 )
922 .expect("parse config");
923
924 resolve_config_paths_for_runtime(&mut config, &project_root);
925 let services = config.services.as_ref().expect("services");
926 assert!(std::path::Path::new(
927 services[0]
928 .version_targets
929 .as_ref()
930 .expect("service targets")
931 .first()
932 .expect("first target")
933 )
934 .ends_with("apps/web/package.json"));
935
936 normalize_config_paths_for_persistence(&mut config, &project_root);
937 assert_eq!(
938 config.services.unwrap()[0].version_targets,
939 Some(vec!["apps/web/package.json".to_string()])
940 );
941 }
942}