1use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use serde_json::Value as JsonValue;
10use std::collections::{BTreeMap, HashMap};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use crate::config::{DiscordConfig, LinearConfig, ServiceDiscordConfig};
15use crate::utils::{
16 collapse_project_path, config_kind_from_path, find_xbp_config_upwards,
17 maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal,
18 resolve_env_placeholders, resolve_project_config_write_path, resolve_project_dir,
19 resolve_project_path,
20};
21use xbp_core::RunnerExecutionRuntime;
22
23fn default_xbp_version() -> String {
24 "0.1.0".to_string()
25}
26
27fn default_auto_push_on_commit() -> bool {
28 true
29}
30
31pub const DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE: &str =
36 "releases/{SERVICE_NAME}-{VERSION}";
37
38pub const GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE_TOKENS: &str =
40 "{VERSION} {TAG} {SERVICE_NAME} ${GITHUB_VERSION} ${GITHUB_TAG} ${SERVICE_NAME}";
41
42pub fn validate_github_release_branch_naming_template(template: &str) -> Result<(), String> {
49 let template = template.trim();
50 if template.is_empty() {
51 return Err("Release branch naming template must not be empty".to_string());
52 }
53 if template.chars().any(char::is_whitespace) {
54 return Err("Release branch naming template must not contain whitespace".to_string());
55 }
56
57 let mut brace_tokens: Vec<String> = Vec::new();
58 let mut dollar_tokens: Vec<String> = Vec::new();
59 let bytes = template.as_bytes();
60 let mut i = 0;
61 while i < bytes.len() {
62 if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
63 let rest = &template[i + 2..];
64 let Some(end) = rest.find('}') else {
65 return Err(
66 "Unclosed ${...} placeholder in release branch naming template".to_string(),
67 );
68 };
69 let name = &rest[..end];
70 if name.is_empty() {
71 return Err("Empty ${} placeholder in release branch naming template".to_string());
72 }
73 if !name
74 .chars()
75 .all(|c| c.is_ascii_alphanumeric() || c == '_')
76 {
77 return Err(format!(
78 "Invalid placeholder name ${{{name}}} in release branch naming template"
79 ));
80 }
81 dollar_tokens.push(name.to_string());
82 i += 2 + end + 1;
83 continue;
84 }
85 if bytes[i] == b'{' {
86 let rest = &template[i + 1..];
87 let Some(end) = rest.find('}') else {
88 return Err(
89 "Unclosed {...} placeholder in release branch naming template".to_string(),
90 );
91 };
92 let name = &rest[..end];
93 if name.is_empty() {
94 return Err("Empty {} placeholder in release branch naming template".to_string());
95 }
96 if !name
97 .chars()
98 .all(|c| c.is_ascii_alphanumeric() || c == '_')
99 {
100 return Err(format!(
101 "Invalid placeholder name {{{name}}} in release branch naming template"
102 ));
103 }
104 brace_tokens.push(name.to_string());
105 i += 1 + end + 1;
106 continue;
107 }
108 i += 1;
109 }
110
111 const BRACE_OK: &[&str] = &["VERSION", "TAG", "SERVICE_NAME"];
112 const DOLLAR_OK: &[&str] = &["GITHUB_VERSION", "GITHUB_TAG", "SERVICE_NAME"];
113
114 for token in &brace_tokens {
115 if !BRACE_OK.contains(&token.as_str()) {
116 return Err(format!(
117 "Unknown template token {{{token}}}. Supported: {GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE_TOKENS}"
118 ));
119 }
120 }
121 for token in &dollar_tokens {
122 if !DOLLAR_OK.contains(&token.as_str()) {
123 return Err(format!(
124 "Unknown template token ${{{token}}}. Supported: {GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE_TOKENS}"
125 ));
126 }
127 }
128
129 let has_version = brace_tokens.iter().any(|t| t == "VERSION" || t == "TAG")
130 || dollar_tokens
131 .iter()
132 .any(|t| t == "GITHUB_VERSION" || t == "GITHUB_TAG");
133 if !has_version {
134 return Err(
135 "Release branch naming template must include a version token ({VERSION}, {TAG}, ${GITHUB_VERSION}, or ${GITHUB_TAG})"
136 .to_string(),
137 );
138 }
139
140 Ok(())
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
148pub struct PublishPackageEntry {
149 #[serde(default)]
150 pub package_name: Option<String>,
151 #[serde(default)]
152 pub working_directory: Option<String>,
153 #[serde(default)]
154 pub manifest_path: Option<String>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158pub struct PublishTargetConfig {
159 #[serde(default)]
160 pub enabled: Option<bool>,
161 #[serde(default)]
162 pub package_name: Option<String>,
163 #[serde(default)]
164 pub working_directory: Option<String>,
165 #[serde(default)]
166 pub manifest_path: Option<String>,
167 #[serde(default)]
173 pub packages: Vec<PublishPackageEntry>,
174 #[serde(default)]
175 pub token: Option<String>,
176 #[serde(default)]
177 pub preflight_commands: Vec<String>,
178 #[serde(default)]
179 pub publish_command: Option<String>,
180 #[serde(default)]
181 pub use_wsl: Option<bool>,
182 #[serde(default)]
183 pub wsl_distribution: Option<String>,
184 #[serde(default)]
185 pub generate_npmrc: Option<bool>,
186 #[serde(default)]
187 pub access: Option<String>,
188 #[serde(default)]
190 pub retry_no_verify: Option<bool>,
191 #[serde(default)]
193 pub no_verify_retry_packages: Vec<String>,
194 #[serde(default)]
196 pub visibility_timeout_seconds: Option<f64>,
197 #[serde(default)]
199 pub visibility_poll_seconds: Option<f64>,
200}
201
202impl PublishTargetConfig {
203 pub fn package_entries(&self) -> Vec<PublishPackageEntry> {
205 if !self.packages.is_empty() {
206 return self.packages.clone();
207 }
208 if self.package_name.is_some()
209 || self.manifest_path.is_some()
210 || self.working_directory.is_some()
211 {
212 return vec![PublishPackageEntry {
213 package_name: self.package_name.clone(),
214 working_directory: self.working_directory.clone(),
215 manifest_path: self.manifest_path.clone(),
216 }];
217 }
218 vec![PublishPackageEntry::default()]
220 }
221
222 pub fn for_package_entry(&self, entry: &PublishPackageEntry) -> Self {
224 let mut clone = self.clone();
225 clone.packages = Vec::new();
226 if let Some(name) = entry
227 .package_name
228 .as_ref()
229 .map(|s| s.trim())
230 .filter(|s| !s.is_empty())
231 {
232 clone.package_name = Some(name.to_string());
233 }
234 if let Some(path) = entry
235 .manifest_path
236 .as_ref()
237 .map(|s| s.trim())
238 .filter(|s| !s.is_empty())
239 {
240 clone.manifest_path = Some(path.to_string());
241 }
242 if let Some(dir) = entry
243 .working_directory
244 .as_ref()
245 .map(|s| s.trim())
246 .filter(|s| !s.is_empty())
247 {
248 clone.working_directory = Some(dir.to_string());
249 } else if let Some(manifest) = clone.manifest_path.as_deref() {
250 let parent = std::path::Path::new(manifest)
251 .parent()
252 .map(|p| p.to_string_lossy().replace('\\', "/"))
253 .filter(|p| !p.is_empty())
254 .unwrap_or_else(|| ".".to_string());
255 clone.working_directory = Some(parent);
256 }
257 clone
258 }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, Default)]
262pub struct PublishProjectConfig {
263 #[serde(default)]
264 pub npm: Option<PublishTargetConfig>,
265 #[serde(default)]
266 pub crates: Option<PublishTargetConfig>,
267 #[serde(default)]
269 pub dist: Option<PublishDistConfig>,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, Default)]
279pub struct PublishDistConfig {
280 #[serde(default)]
281 pub enabled: Option<bool>,
282 #[serde(default)]
284 pub packages: Vec<String>,
285 #[serde(default)]
287 pub installers: Vec<String>,
288 #[serde(default)]
290 pub targets: Vec<String>,
291 #[serde(default, alias = "artifacts")]
293 pub artifacts_modes: Vec<String>,
294 #[serde(default)]
296 pub allow_dirty: Option<bool>,
297 #[serde(default)]
299 pub generate_ci: Option<bool>,
300 #[serde(default)]
302 pub tool: Option<String>,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, Default)]
306pub struct ServiceCommands {
307 #[serde(default)]
308 pub pre: Option<String>,
309 #[serde(default)]
310 pub install: Option<String>,
311 #[serde(default)]
312 pub build: Option<String>,
313 #[serde(default)]
314 pub start: Option<String>,
315 #[serde(default)]
316 pub dev: Option<String>,
317 #[serde(default, flatten)]
319 pub custom: BTreeMap<String, String>,
320}
321
322impl ServiceCommands {
323 pub fn get(&self, name: &str) -> Option<&String> {
324 match name {
325 "pre" => self.pre.as_ref(),
326 "install" => self.install.as_ref(),
327 "build" => self.build.as_ref(),
328 "start" => self.start.as_ref(),
329 "dev" => self.dev.as_ref(),
330 other => self.custom.get(other),
331 }
332 }
333
334 pub fn available_names(&self) -> Vec<String> {
335 let mut names = ["pre", "install", "build", "start", "dev"]
336 .into_iter()
337 .filter(|name| self.get(name).is_some_and(|value| !value.trim().is_empty()))
338 .map(str::to_string)
339 .collect::<Vec<_>>();
340 names.extend(
341 self.custom
342 .iter()
343 .filter(|(_, value)| !value.trim().is_empty())
344 .map(|(name, _)| name.clone()),
345 );
346 names
347 }
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize, Default)]
351pub struct SystemdConfig {
352 #[serde(default)]
353 pub environment_files: Vec<String>,
354 #[serde(default)]
355 pub config_paths: Vec<String>,
356 #[serde(default)]
357 pub read_write_paths: Vec<String>,
358 #[serde(default)]
359 pub runtime_directories: Vec<String>,
360 #[serde(default)]
361 pub state_directories: Vec<String>,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, Default)]
365pub struct WorkerDeployConfig {
366 #[serde(default)]
367 pub command: Option<String>,
368 #[serde(default)]
369 pub ci_script: Option<String>,
370 #[serde(default)]
371 pub select_script: Option<String>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub use_wsl: Option<bool>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub wsl_distribution: Option<String>,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub build_script: Option<String>,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub install_ignore_scripts: Option<bool>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub restore_heroui_cache: Option<bool>,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub wrangler_args: Option<Vec<String>>,
392 #[serde(default, skip_serializing_if = "Vec::is_empty")]
395 pub build_required_env: Vec<String>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub preflight_script: Option<String>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub require_local_secrets: Option<bool>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub prefer_local_build: Option<bool>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, Default)]
410pub struct WorkerContainerConfig {
411 #[serde(default)]
412 pub class_name: Option<String>,
413 #[serde(default)]
414 pub binding: Option<String>,
415 #[serde(default)]
416 pub dockerfile: Option<String>,
417 #[serde(default)]
418 pub port: Option<u16>,
419 #[serde(default)]
420 pub application_id: Option<String>,
421 #[serde(default)]
422 pub healthcheck_path: Option<String>,
423 #[serde(default)]
424 pub health_urls: Vec<String>,
425 #[serde(default)]
426 pub expected_image_name: Option<String>,
427 #[serde(default)]
428 pub image_tag_keep_count: Option<usize>,
429 #[serde(default)]
430 pub allow_unchanged_container_image: bool,
431 #[serde(default)]
432 pub required_secrets: Vec<String>,
433 #[serde(default)]
434 pub version_var: Option<String>,
435 #[serde(default)]
436 pub instance_name_template: Option<String>,
437 #[serde(default)]
438 pub default_rollout: Option<String>,
439 #[serde(default)]
442 pub image: Option<String>,
443 #[serde(default)]
444 pub build_context: Option<String>,
445 #[serde(default)]
446 pub instance_type: Option<String>,
447 #[serde(default)]
448 pub max_instances: Option<u32>,
449 #[serde(default)]
450 pub rollout_active_grace_period: Option<u32>,
451 #[serde(default)]
452 pub rollout_step_percentage: Vec<u8>,
453 #[serde(default)]
454 pub regions: Vec<String>,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize, Default)]
458pub struct WorkerDurableObjectBinding {
459 pub name: String,
460 pub class_name: String,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub script_name: Option<String>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
464 pub environment: Option<String>,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, Default)]
468pub struct WorkerDurableObjectMigration {
469 pub tag: String,
470 #[serde(default, skip_serializing_if = "Vec::is_empty")]
471 pub new_classes: Vec<String>,
472 #[serde(default, skip_serializing_if = "Vec::is_empty")]
473 pub new_sqlite_classes: Vec<String>,
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize, Default)]
477pub struct WorkerDurableObjectNamespace {
478 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub id: Option<String>,
480 pub name: String,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub script: Option<String>,
483 #[serde(rename = "class", alias = "class_name")]
484 pub class_name: String,
485 #[serde(default)]
486 pub use_sqlite: bool,
487 #[serde(default)]
488 pub use_containers: bool,
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize, Default)]
492pub struct WorkerDurableObjectsConfig {
493 #[serde(default)]
494 pub bindings: Vec<WorkerDurableObjectBinding>,
495 #[serde(default)]
496 pub migrations: Vec<WorkerDurableObjectMigration>,
497 #[serde(default)]
498 pub namespaces: Vec<WorkerDurableObjectNamespace>,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct WorkerConfig {
503 pub name: String,
504 pub root: String,
505 #[serde(default)]
506 pub script_name: Option<String>,
507 #[serde(default)]
508 pub service: Option<String>,
509 #[serde(default)]
510 pub deploy: Option<WorkerDeployConfig>,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub container: Option<WorkerContainerConfig>,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub containers: Option<Vec<WorkerContainerConfig>>,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub durable_objects: Option<WorkerDurableObjectsConfig>,
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize, Default)]
520pub struct VersionDomainSurfaceConfig {
521 pub kind: String,
522 pub path: String,
523 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub key: Option<String>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub marker: Option<String>,
527 #[serde(default)]
528 pub packages: Vec<String>,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize, Default)]
532pub struct VersionDomainConfig {
533 pub name: String,
534 pub root: String,
535 pub version: String,
536 #[serde(default)]
537 pub owned_package_names: Vec<String>,
538 #[serde(default)]
539 pub owned_package_prefixes: Vec<String>,
540 #[serde(default)]
541 pub version_surfaces: Vec<VersionDomainSurfaceConfig>,
542 #[serde(default)]
543 pub allowed_bumps: Vec<String>,
544 #[serde(default)]
545 pub validation_commands: Vec<String>,
546 #[serde(default, skip_serializing_if = "Option::is_none")]
547 pub image_version_probe: Option<String>,
548 #[serde(default)]
549 pub live_health_urls: Vec<String>,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub cloudflare_app: Option<String>,
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct DatabaseConfig {
556 #[serde(default)]
557 pub enabled: Option<bool>,
558 #[serde(default)]
559 pub backend: Option<String>,
560 #[serde(default)]
561 pub url_env: Option<String>,
562 #[serde(default)]
563 pub key_env: Option<String>,
564 #[serde(default)]
565 pub schema: Option<String>,
566}
567
568#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
569pub struct OpenApiOutputsConfig {
570 #[serde(default)]
571 pub yaml: Option<String>,
572 #[serde(default)]
573 pub json: Option<String>,
574}
575
576#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
577pub struct OpenApiServiceDefaultsConfig {
578 #[serde(default)]
579 pub spec_version: Option<String>,
580 #[serde(default)]
581 pub strict: Option<bool>,
582 #[serde(default)]
583 pub source_roots: Option<Vec<String>>,
584 #[serde(default)]
585 pub outputs: Option<OpenApiOutputsConfig>,
586 #[serde(default)]
587 pub unknown_schema: Option<String>,
588}
589
590#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
591pub struct OpenApiAggregateConfig {
592 #[serde(default)]
593 pub enabled: Option<bool>,
594 #[serde(default)]
595 pub outputs: Option<OpenApiOutputsConfig>,
596 #[serde(default)]
597 pub overlay: Option<String>,
598}
599
600#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
601pub struct ProjectOpenApiConfig {
602 #[serde(default)]
603 pub enabled: Option<bool>,
604 #[serde(default)]
605 pub auto_detect_services: Option<bool>,
606 #[serde(default)]
607 pub generate_on_release: Option<bool>,
608 #[serde(default)]
609 pub service_defaults: Option<OpenApiServiceDefaultsConfig>,
610 #[serde(default)]
611 pub aggregate: Option<OpenApiAggregateConfig>,
612}
613
614#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
615pub struct OpenApiLocalServerConfig {
616 #[serde(default)]
617 pub enabled: Option<bool>,
618 #[serde(default)]
619 pub scheme: Option<String>,
620 #[serde(default)]
621 pub host: Option<String>,
622 #[serde(default)]
623 pub description: Option<String>,
624}
625
626#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
627pub struct OpenApiServerDerivationConfig {
628 #[serde(default)]
629 pub from_service_url: Option<bool>,
630 #[serde(default)]
631 pub service_url_description: Option<String>,
632 #[serde(default)]
633 pub local: Option<OpenApiLocalServerConfig>,
634}
635
636#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
637pub struct OpenApiWebSocketRouteConfig {
638 pub path: String,
639 #[serde(default)]
640 pub client_message_schema: Option<String>,
641 #[serde(default)]
642 pub server_message_schema: Option<String>,
643 #[serde(default)]
644 pub message_format: Option<String>,
645 #[serde(default)]
646 pub server_sends_first: Option<bool>,
647}
648
649#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
650pub struct ServiceOpenApiConfig {
651 #[serde(default)]
652 pub enabled: Option<bool>,
653 #[serde(default)]
654 pub framework: Option<String>,
655 #[serde(default)]
656 pub spec_version: Option<String>,
657 #[serde(default)]
658 pub strict: Option<bool>,
659 #[serde(default)]
660 pub source_roots: Option<Vec<String>>,
661 #[serde(default)]
662 pub outputs: Option<OpenApiOutputsConfig>,
663 #[serde(default)]
664 pub unknown_schema: Option<String>,
665 #[serde(default)]
666 pub overlay: Option<String>,
667 #[serde(default)]
668 pub aggregate_path_prefix: Option<String>,
669 #[serde(default)]
670 pub server_derivation: Option<OpenApiServerDerivationConfig>,
671 #[serde(default)]
672 pub websocket_routes: Option<Vec<OpenApiWebSocketRouteConfig>>,
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize, Default)]
683pub struct OciProjectConfig {
684 #[serde(default)]
685 pub registries: HashMap<String, OciRegistryConfig>,
686 #[serde(default)]
687 pub default_registry: Option<String>,
688 #[serde(default, alias = "github_owner")]
691 pub owner: Option<String>,
692 #[serde(default, alias = "github_repository", alias = "package_repository")]
697 pub repository: Option<String>,
698}
699
700#[derive(Debug, Clone, Serialize, Deserialize, Default)]
701pub struct OciRegistryConfig {
702 #[serde(default)]
703 pub provider: Option<String>,
704 #[serde(default)]
705 pub username: Option<String>,
706 #[serde(default)]
707 pub token_source: Option<String>,
708 #[serde(default)]
709 pub url: Option<String>,
710}
711
712pub fn github_owner_from_repository_ref(repository: &str) -> Option<String> {
714 let repo = repository.trim().trim_matches('/');
715 if repo.is_empty() || !repo.contains('/') {
716 return None;
717 }
718 let owner = repo.split('/').next()?.trim();
719 if owner.is_empty() || owner.contains(':') {
720 return None;
721 }
722 Some(owner.to_string())
723}
724
725pub fn is_absolute_oci_image_ref(image: &str) -> bool {
728 let image = image.trim();
729 if image.is_empty() {
730 return false;
731 }
732 let path = image.split('@').next().unwrap_or(image);
734 let path = path.split(':').next().unwrap_or(path);
735 let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
736 if parts.len() < 2 {
737 return false;
738 }
739 let host = parts[0];
740 host.contains('.') || host.contains(':') || host == "localhost"
741}
742
743pub fn resolve_oci_image_owner(
752 oci: Option<&OciProjectConfig>,
753 github_repository: Option<&str>,
754 project_name: &str,
755) -> Option<String> {
756 if let Some(oci) = oci {
757 if let Some(owner) = oci
758 .owner
759 .as_deref()
760 .map(str::trim)
761 .filter(|s| !s.is_empty())
762 {
763 return Some(owner.to_string());
764 }
765 if let Some(owner) = oci
766 .repository
767 .as_deref()
768 .and_then(github_owner_from_repository_ref)
769 {
770 return Some(owner);
771 }
772 if let Some(registry) = oci
773 .default_registry
774 .as_deref()
775 .map(str::trim)
776 .filter(|s| !s.is_empty())
777 {
778 if let Some(user) = oci
779 .registries
780 .get(registry)
781 .and_then(|r| r.username.as_deref())
782 .map(str::trim)
783 .filter(|s| !s.is_empty())
784 {
785 return Some(user.to_string());
786 }
787 }
788 if let Some(user) = oci.registries.values().find_map(|r| {
790 r.username
791 .as_deref()
792 .map(str::trim)
793 .filter(|s| !s.is_empty())
794 }) {
795 return Some(user.to_string());
796 }
797 }
798 if let Some(owner) = github_repository.and_then(github_owner_from_repository_ref) {
799 return Some(owner);
800 }
801 let pn = project_name.trim().trim_start_matches('@');
802 let pn = pn.split('/').next().unwrap_or(pn).trim();
803 if pn.is_empty() {
804 None
805 } else {
806 Some(pn.to_string())
807 }
808}
809
810pub fn compose_service_oci_image(
819 oci: Option<&OciProjectConfig>,
820 github_repository: Option<&str>,
821 project_name: &str,
822 service_name: &str,
823 service_image: Option<&str>,
824) -> Option<String> {
825 if let Some(img) = service_image.map(str::trim).filter(|s| !s.is_empty()) {
826 if is_absolute_oci_image_ref(img) {
827 return Some(img.to_string());
828 }
829 }
830
831 let registry = oci
832 .and_then(|o| o.default_registry.as_deref())
833 .map(str::trim)
834 .filter(|s| !s.is_empty())?;
835 let owner = resolve_oci_image_owner(oci, github_repository, project_name)?;
836 let name = service_image
837 .map(str::trim)
838 .filter(|s| !s.is_empty())
839 .unwrap_or(service_name)
840 .trim()
841 .trim_matches('/');
842 if name.contains('/') {
845 return Some(format!("{registry}/{name}"));
846 }
847 if name.is_empty() {
848 return None;
849 }
850 Some(format!("{registry}/{owner}/{name}"))
851}
852
853#[derive(Debug, Clone, Serialize, Deserialize, Default)]
855pub struct ServiceOciConfig {
856 #[serde(default)]
857 pub image: Option<String>,
858 #[serde(default)]
859 pub tag: Option<String>,
860 #[serde(default)]
861 pub tag_from: Option<String>,
862 #[serde(default)]
863 pub dockerfile: Option<String>,
864 #[serde(default)]
865 pub context: Option<String>,
866 #[serde(default)]
867 pub platforms: Vec<String>,
868 #[serde(default)]
869 pub build_args: HashMap<String, String>,
870}
871
872#[derive(Debug, Clone, Serialize, Deserialize, Default)]
874pub struct KubernetesProjectConfig {
875 #[serde(default)]
876 pub default_context: Option<String>,
877 #[serde(default)]
878 pub default_namespace: Option<String>,
879 #[serde(default)]
880 pub require_context_confirmation_for_apply: Option<bool>,
881 #[serde(default)]
882 pub kubeconfig: Option<String>,
883}
884
885#[derive(Debug, Clone, Serialize, Deserialize, Default)]
890pub struct DeployProjectConfig {
891 #[serde(default)]
892 pub default_env: Option<String>,
893 #[serde(default)]
894 pub history_dir: Option<String>,
895 #[serde(default)]
896 pub lock_file: Option<String>,
897 #[serde(default)]
898 pub groups: HashMap<String, DeployGroupConfig>,
899}
900
901#[derive(Debug, Clone, Serialize, Deserialize, Default)]
902pub struct DeployGroupConfig {
903 #[serde(default)]
904 pub services: Vec<String>,
905 #[serde(default)]
906 pub order: Vec<String>,
907 #[serde(default)]
908 pub description: Option<String>,
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize, Default)]
914pub struct ServiceDeployConfig {
915 #[serde(default)]
921 pub provider: Option<String>,
922 #[serde(default)]
924 pub worker: Option<String>,
925 #[serde(default)]
927 pub rollout: Option<String>,
928 #[serde(default)]
947 pub destinations: HashMap<String, ServiceDeployDestinationConfig>,
948 #[serde(default)]
949 pub envs: HashMap<String, ServiceDeployEnvConfig>,
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize, Default)]
954pub struct ServiceDeployDestinationConfig {
955 #[serde(default)]
956 pub provider: Option<String>,
957 #[serde(default)]
958 pub worker: Option<String>,
959 #[serde(default)]
960 pub rollout: Option<String>,
961 #[serde(default)]
962 pub enabled: Option<bool>,
963}
964
965#[derive(Debug, Clone, Serialize, Deserialize, Default)]
966pub struct ServiceDeployEnvConfig {
967 #[serde(default)]
968 pub namespace: Option<String>,
969 #[serde(default)]
970 pub replicas: Option<u32>,
971 #[serde(default)]
972 pub health: Vec<String>,
973 #[serde(default)]
974 pub kubernetes: Option<ServiceDeployKubernetesConfig>,
975 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
978 pub env: HashMap<String, String>,
979 #[serde(default, skip_serializing_if = "Vec::is_empty")]
982 pub env_files: Vec<String>,
983 #[serde(default, skip_serializing_if = "Vec::is_empty")]
986 pub config_files: Vec<String>,
987 #[serde(default, skip_serializing_if = "Option::is_none")]
989 pub container_port: Option<u16>,
990 #[serde(default, skip_serializing_if = "Vec::is_empty")]
992 pub required_env: Vec<String>,
993 #[serde(default, skip_serializing_if = "Option::is_none")]
995 pub expose: Option<ServiceDeployExposeConfig>,
996}
997
998#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1000pub struct ServiceDeployExposeConfig {
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1003 pub service_type: Option<String>,
1004 #[serde(default, skip_serializing_if = "Option::is_none")]
1006 pub node_port: Option<u16>,
1007 #[serde(default, skip_serializing_if = "Option::is_none")]
1009 pub host_port: Option<u16>,
1010 #[serde(default, skip_serializing_if = "Option::is_none")]
1012 pub local_port: Option<u16>,
1013 #[serde(default, skip_serializing_if = "Option::is_none")]
1015 pub port_forward: Option<bool>,
1016 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1018 pub dns_hosts: Vec<String>,
1019 #[serde(default, skip_serializing_if = "Option::is_none")]
1021 pub dns_mode: Option<String>,
1022}
1023
1024#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1025pub struct ServiceDeployKubernetesConfig {
1026 #[serde(default)]
1027 pub manifests: Option<ServiceDeployManifestsConfig>,
1028 #[serde(default)]
1029 pub workload: Option<String>,
1030 #[serde(default)]
1031 pub service: Option<String>,
1032 #[serde(default)]
1033 pub crds_path: Option<String>,
1034 #[serde(default)]
1035 pub install_path: Option<String>,
1036 #[serde(default)]
1037 pub selector: Option<String>,
1038}
1039
1040#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1041pub struct ServiceDeployManifestsConfig {
1042 #[serde(default)]
1043 pub base: Option<String>,
1044 #[serde(default)]
1045 pub overlay: Option<String>,
1046}
1047
1048#[derive(Debug, Clone, Serialize)]
1049pub struct ServiceConfig {
1050 pub name: String,
1051 pub target: String,
1052 #[serde(
1055 default,
1056 skip_serializing_if = "is_false_or_none",
1057 alias = "freeze_target",
1058 alias = "target_frozen"
1059 )]
1060 pub target_freeze: Option<bool>,
1061 pub branch: String,
1062 pub port: u16,
1063 #[serde(default)]
1064 pub root_directory: Option<String>,
1065 #[serde(default)]
1066 pub environment: Option<HashMap<String, String>>,
1067 #[serde(default)]
1068 pub url: Option<String>,
1069 #[serde(default)]
1070 pub healthcheck_path: Option<String>,
1071 #[serde(default)]
1072 pub restart_policy: Option<String>,
1073 #[serde(default)]
1074 pub restart_policy_max_failure_count: Option<u32>,
1075 #[serde(default)]
1076 pub start_wrapper: Option<String>,
1077 #[serde(default)]
1078 pub commands: Option<ServiceCommands>,
1079 #[serde(default)]
1080 pub force_run_from_root: Option<bool>,
1081 #[serde(default)]
1082 pub version_targets: Option<Vec<String>>,
1083 #[serde(default, skip_serializing_if = "Option::is_none")]
1085 pub depends_on: Option<Vec<String>>,
1086 #[serde(default)]
1089 pub watch_paths: Option<Vec<String>>,
1090 #[serde(
1093 default,
1094 skip_serializing_if = "Option::is_none",
1095 alias = "versioning_enabled",
1096 alias = "enable_versioning"
1097 )]
1098 pub versioning: Option<bool>,
1099 #[serde(
1103 default,
1104 skip_serializing_if = "Option::is_none",
1105 alias = "release_enabled",
1106 alias = "enable_release"
1107 )]
1108 pub release: Option<bool>,
1109 #[serde(default)]
1110 pub systemd_service_name: Option<String>,
1111 #[serde(default)]
1112 pub systemd: Option<SystemdConfig>,
1113 #[serde(default)]
1114 pub openapi: Option<ServiceOpenApiConfig>,
1115 #[serde(default, skip_serializing_if = "Option::is_none")]
1117 pub oci: Option<ServiceOciConfig>,
1118 #[serde(default, skip_serializing_if = "Option::is_none")]
1120 pub deploy: Option<ServiceDeployConfig>,
1121 #[serde(default, skip_serializing_if = "Option::is_none")]
1123 pub discord: Option<ServiceDiscordConfig>,
1124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1126 pub file_associations: Vec<VersionFileAssociation>,
1127}
1128
1129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1131pub struct VersionFileAssociation {
1132 pub pattern: String,
1134 pub bump: String,
1136}
1137
1138#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1140pub struct VersioningProjectConfig {
1141 #[serde(default, skip_serializing_if = "Option::is_none")]
1143 pub default_bump: Option<String>,
1144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1145 pub file_associations: Vec<VersionFileAssociation>,
1146}
1147
1148fn is_false_or_none(value: &Option<bool>) -> bool {
1149 !value.unwrap_or(false)
1150}
1151
1152impl ServiceConfig {
1153 pub fn is_versioning_enabled(&self) -> bool {
1155 self.versioning.unwrap_or(true)
1156 }
1157
1158 pub fn is_release_enabled(&self) -> bool {
1161 self.is_versioning_enabled() && self.release.unwrap_or(true)
1162 }
1163
1164 pub fn is_target_frozen(&self) -> bool {
1166 self.target_freeze.unwrap_or(false)
1167 }
1168}
1169
1170fn resolve_systemd_fields(
1171 systemd_service_name: Option<String>,
1172 systemd: Option<JsonValue>,
1173) -> Result<(Option<String>, Option<SystemdConfig>), String> {
1174 match systemd {
1175 None | Some(JsonValue::Null) => Ok((systemd_service_name, None)),
1176 Some(JsonValue::String(name)) => {
1177 let resolved_name = if systemd_service_name
1178 .as_ref()
1179 .is_some_and(|value| !value.trim().is_empty())
1180 {
1181 systemd_service_name
1182 } else if name.is_empty() {
1183 None
1184 } else {
1185 Some(name)
1186 };
1187 Ok((resolved_name, None))
1188 }
1189 Some(value) => {
1190 let config: SystemdConfig = serde_json::from_value(value)
1191 .map_err(|error| format!("invalid systemd config: {error}"))?;
1192 Ok((systemd_service_name, Some(config)))
1193 }
1194 }
1195}
1196
1197fn deserialize_service_config<'de, D>(deserializer: D) -> Result<ServiceConfig, D::Error>
1198where
1199 D: Deserializer<'de>,
1200{
1201 #[derive(Deserialize)]
1202 struct ServiceConfigRaw {
1203 name: String,
1204 target: String,
1205 #[serde(default, alias = "freeze_target", alias = "target_frozen")]
1206 target_freeze: Option<bool>,
1207 branch: String,
1208 port: u16,
1209 #[serde(default)]
1210 root_directory: Option<String>,
1211 #[serde(default)]
1212 environment: Option<HashMap<String, String>>,
1213 #[serde(default)]
1214 url: Option<String>,
1215 #[serde(default)]
1216 healthcheck_path: Option<String>,
1217 #[serde(default)]
1218 restart_policy: Option<String>,
1219 #[serde(default)]
1220 restart_policy_max_failure_count: Option<u32>,
1221 #[serde(default)]
1222 start_wrapper: Option<String>,
1223 #[serde(default)]
1224 commands: Option<ServiceCommands>,
1225 #[serde(default)]
1226 force_run_from_root: Option<bool>,
1227 #[serde(default)]
1228 version_targets: Option<Vec<String>>,
1229 #[serde(default)]
1230 depends_on: Option<Vec<String>>,
1231 #[serde(default)]
1232 watch_paths: Option<Vec<String>>,
1233 #[serde(default, alias = "versioning_enabled", alias = "enable_versioning")]
1234 versioning: Option<bool>,
1235 #[serde(default, alias = "release_enabled", alias = "enable_release")]
1236 release: Option<bool>,
1237 #[serde(default)]
1238 systemd_service_name: Option<String>,
1239 #[serde(default)]
1240 systemd: Option<JsonValue>,
1241 #[serde(default)]
1242 openapi: Option<ServiceOpenApiConfig>,
1243 #[serde(default)]
1244 oci: Option<ServiceOciConfig>,
1245 #[serde(default)]
1246 deploy: Option<ServiceDeployConfig>,
1247 #[serde(default)]
1248 discord: Option<ServiceDiscordConfig>,
1249 #[serde(default)]
1250 file_associations: Vec<VersionFileAssociation>,
1251 }
1252
1253 let raw = ServiceConfigRaw::deserialize(deserializer)?;
1254 let (systemd_service_name, systemd) =
1255 resolve_systemd_fields(raw.systemd_service_name, raw.systemd)
1256 .map_err(serde::de::Error::custom)?;
1257
1258 Ok(ServiceConfig {
1259 name: raw.name,
1260 target: raw.target,
1261 target_freeze: raw.target_freeze,
1262 branch: raw.branch,
1263 port: raw.port,
1264 root_directory: raw.root_directory,
1265 environment: raw.environment,
1266 url: raw.url,
1267 healthcheck_path: raw.healthcheck_path,
1268 restart_policy: raw.restart_policy,
1269 restart_policy_max_failure_count: raw.restart_policy_max_failure_count,
1270 start_wrapper: raw.start_wrapper,
1271 commands: raw.commands,
1272 force_run_from_root: raw.force_run_from_root,
1273 version_targets: raw.version_targets,
1274 depends_on: raw.depends_on,
1275 watch_paths: raw.watch_paths,
1276 versioning: raw.versioning,
1277 release: raw.release,
1278 systemd_service_name,
1279 systemd,
1280 openapi: raw.openapi,
1281 oci: raw.oci,
1282 deploy: raw.deploy,
1283 discord: raw.discord,
1284 file_associations: raw.file_associations,
1285 })
1286}
1287
1288impl<'de> Deserialize<'de> for ServiceConfig {
1289 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1290 where
1291 D: Deserializer<'de>,
1292 {
1293 deserialize_service_config(deserializer)
1294 }
1295}
1296
1297#[derive(Debug, Clone, Serialize)]
1298pub struct XbpConfig {
1299 pub project_name: String,
1300 #[serde(default = "default_xbp_version")]
1301 pub version: String,
1302 pub port: u16,
1303 pub build_dir: String,
1304 #[serde(default)]
1305 pub app_type: Option<String>,
1306 #[serde(default)]
1307 pub build_command: Option<String>,
1308 #[serde(default)]
1309 pub start_command: Option<String>,
1310 #[serde(default)]
1311 pub install_command: Option<String>,
1312 #[serde(default)]
1313 pub environment: Option<HashMap<String, String>>,
1314 #[serde(default)]
1315 pub services: Option<Vec<ServiceConfig>>,
1316 #[serde(default)]
1317 pub openapi: Option<ProjectOpenApiConfig>,
1318 #[serde(default)]
1319 pub workers: Option<Vec<WorkerConfig>>,
1320 #[serde(default)]
1321 pub systemd_service_name: Option<String>,
1322 #[serde(default)]
1323 pub systemd: Option<SystemdConfig>,
1324 #[serde(default)]
1325 pub kafka_brokers: Option<String>,
1326 #[serde(default)]
1327 pub kafka_topic: Option<String>,
1328 #[serde(default)]
1329 pub kafka_public_url: Option<String>,
1330 #[serde(default)]
1331 pub log_files: Option<Vec<String>>,
1332 #[serde(default)]
1333 pub monitor_url: Option<String>,
1334 #[serde(default)]
1335 pub monitor_method: Option<String>,
1336 #[serde(default)]
1337 pub monitor_expected_code: Option<u16>,
1338 #[serde(default)]
1339 pub monitor_interval: Option<u64>,
1340 #[serde(default)]
1341 pub database: Option<DatabaseConfig>,
1342 #[serde(default, skip_serializing_if = "Option::is_none")]
1344 pub oci: Option<OciProjectConfig>,
1345 #[serde(default, skip_serializing_if = "Option::is_none")]
1347 pub kubernetes: Option<KubernetesProjectConfig>,
1348 #[serde(default, skip_serializing_if = "Option::is_none")]
1350 pub deploy: Option<DeployProjectConfig>,
1351 #[serde(default)]
1353 pub target: Option<String>,
1354 #[serde(default)]
1355 pub branch: Option<String>,
1356 #[serde(default)]
1357 pub crate_name: Option<String>,
1358 #[serde(default)]
1359 pub npm_script: Option<String>,
1360 #[serde(default)]
1361 pub port_storybook: Option<u16>,
1362 #[serde(default)]
1363 pub url: Option<String>,
1364 #[serde(default)]
1365 pub url_storybook: Option<String>,
1366 #[serde(default)]
1367 pub linear: Option<LinearConfig>,
1368 #[serde(default)]
1369 pub github: Option<GitHubProjectConfig>,
1370 #[serde(default)]
1371 pub publish: Option<PublishProjectConfig>,
1372 #[serde(default)]
1373 pub version_targets: Vec<String>,
1374 #[serde(default)]
1375 pub version_domains: Vec<VersionDomainConfig>,
1376 #[serde(default, alias = "ignored_paths", alias = "xbpignore")]
1382 pub ignore_paths: Vec<String>,
1383 #[serde(default, alias = "watch_ignore")]
1386 pub watch_ignore_paths: Vec<String>,
1387 #[serde(
1390 default,
1391 alias = "disabled_versioning_services",
1392 alias = "no_version_services",
1393 alias = "versioning_disabled_services"
1394 )]
1395 pub versioning_disabled: Vec<String>,
1396 #[serde(
1399 default,
1400 alias = "disabled_release_services",
1401 alias = "no_release_services",
1402 alias = "release_disabled_services"
1403 )]
1404 pub release_disabled: Vec<String>,
1405 #[serde(default, skip_serializing_if = "Option::is_none")]
1407 pub versioning: Option<VersioningProjectConfig>,
1408 #[serde(default, skip_serializing_if = "Option::is_none")]
1410 pub discord: Option<DiscordConfig>,
1411 #[serde(default, skip_serializing_if = "Option::is_none", alias = "analysis")]
1413 pub audit: Option<AuditProjectConfig>,
1414}
1415
1416#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1418pub struct AuditProjectConfig {
1419 #[serde(default, skip_serializing_if = "Option::is_none")]
1420 pub enabled: Option<bool>,
1421 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1422 pub languages: Vec<String>,
1423 #[serde(default, skip_serializing_if = "Option::is_none")]
1424 pub rules: Option<AuditRulesConfig>,
1425 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1426 pub path_exclusions: Vec<String>,
1427 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1428 pub generated_exclusions: Vec<String>,
1429 #[serde(default, skip_serializing_if = "Option::is_none")]
1430 pub min_severity: Option<String>,
1431 #[serde(default, skip_serializing_if = "Option::is_none")]
1432 pub adapters: Option<JsonValue>,
1433}
1434
1435#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1436pub struct AuditRulesConfig {
1437 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1438 pub enabled: Vec<String>,
1439 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1440 pub disabled: Vec<String>,
1441 #[serde(default, skip_serializing_if = "Option::is_none")]
1442 pub min_severity: Option<String>,
1443}
1444
1445fn deserialize_xbp_config<'de, D>(deserializer: D) -> Result<XbpConfig, D::Error>
1446where
1447 D: Deserializer<'de>,
1448{
1449 let mut value = JsonValue::deserialize(deserializer)?;
1450 let _ = crate::utils::normalize_xbp_config_value(&mut value);
1451 XbpConfigBody::deserialize(value)
1452 .map(Into::into)
1453 .map_err(serde::de::Error::custom)
1454}
1455
1456#[derive(Deserialize)]
1457struct XbpConfigBody {
1458 project_name: String,
1459 #[serde(default = "default_xbp_version")]
1460 version: String,
1461 port: u16,
1462 build_dir: String,
1463 #[serde(default)]
1464 app_type: Option<String>,
1465 #[serde(default)]
1466 build_command: Option<String>,
1467 #[serde(default)]
1468 start_command: Option<String>,
1469 #[serde(default)]
1470 install_command: Option<String>,
1471 #[serde(default)]
1472 environment: Option<HashMap<String, String>>,
1473 #[serde(default)]
1474 services: Option<Vec<ServiceConfig>>,
1475 #[serde(default)]
1476 openapi: Option<ProjectOpenApiConfig>,
1477 #[serde(default)]
1478 workers: Option<Vec<WorkerConfig>>,
1479 #[serde(default)]
1480 systemd_service_name: Option<String>,
1481 #[serde(default)]
1482 systemd: Option<SystemdConfig>,
1483 #[serde(default)]
1484 kafka_brokers: Option<String>,
1485 #[serde(default)]
1486 kafka_topic: Option<String>,
1487 #[serde(default)]
1488 kafka_public_url: Option<String>,
1489 #[serde(default)]
1490 log_files: Option<Vec<String>>,
1491 #[serde(default)]
1492 monitor_url: Option<String>,
1493 #[serde(default)]
1494 monitor_method: Option<String>,
1495 #[serde(default)]
1496 monitor_expected_code: Option<u16>,
1497 #[serde(default)]
1498 monitor_interval: Option<u64>,
1499 #[serde(default)]
1500 database: Option<DatabaseConfig>,
1501 #[serde(default)]
1502 oci: Option<OciProjectConfig>,
1503 #[serde(default)]
1504 kubernetes: Option<KubernetesProjectConfig>,
1505 #[serde(default)]
1506 deploy: Option<DeployProjectConfig>,
1507 #[serde(default)]
1508 target: Option<String>,
1509 #[serde(default)]
1510 branch: Option<String>,
1511 #[serde(default)]
1512 crate_name: Option<String>,
1513 #[serde(default)]
1514 npm_script: Option<String>,
1515 #[serde(default)]
1516 port_storybook: Option<u16>,
1517 #[serde(default)]
1518 url: Option<String>,
1519 #[serde(default)]
1520 url_storybook: Option<String>,
1521 #[serde(default)]
1522 linear: Option<LinearConfig>,
1523 #[serde(default)]
1524 github: Option<GitHubProjectConfig>,
1525 #[serde(default)]
1526 publish: Option<PublishProjectConfig>,
1527 #[serde(default)]
1528 version_targets: Vec<String>,
1529 #[serde(default)]
1530 version_domains: Vec<VersionDomainConfig>,
1531 #[serde(default, alias = "ignored_paths", alias = "xbpignore")]
1532 ignore_paths: Vec<String>,
1533 #[serde(default, alias = "watch_ignore")]
1534 watch_ignore_paths: Vec<String>,
1535 #[serde(
1536 default,
1537 alias = "disabled_versioning_services",
1538 alias = "no_version_services",
1539 alias = "versioning_disabled_services"
1540 )]
1541 versioning_disabled: Vec<String>,
1542 #[serde(
1543 default,
1544 alias = "disabled_release_services",
1545 alias = "no_release_services",
1546 alias = "release_disabled_services"
1547 )]
1548 release_disabled: Vec<String>,
1549 #[serde(default)]
1550 versioning: Option<VersioningProjectConfig>,
1551 #[serde(default)]
1552 discord: Option<DiscordConfig>,
1553 #[serde(default, alias = "analysis")]
1554 audit: Option<AuditProjectConfig>,
1555}
1556
1557impl From<XbpConfigBody> for XbpConfig {
1558 fn from(body: XbpConfigBody) -> Self {
1559 Self {
1560 project_name: body.project_name,
1561 version: body.version,
1562 port: body.port,
1563 build_dir: body.build_dir,
1564 app_type: body.app_type,
1565 build_command: body.build_command,
1566 start_command: body.start_command,
1567 install_command: body.install_command,
1568 environment: body.environment,
1569 services: body.services,
1570 openapi: body.openapi,
1571 workers: body.workers,
1572 systemd_service_name: body.systemd_service_name,
1573 systemd: body.systemd,
1574 kafka_brokers: body.kafka_brokers,
1575 kafka_topic: body.kafka_topic,
1576 kafka_public_url: body.kafka_public_url,
1577 log_files: body.log_files,
1578 monitor_url: body.monitor_url,
1579 monitor_method: body.monitor_method,
1580 monitor_expected_code: body.monitor_expected_code,
1581 monitor_interval: body.monitor_interval,
1582 database: body.database,
1583 oci: body.oci,
1584 kubernetes: body.kubernetes,
1585 deploy: body.deploy,
1586 target: body.target,
1587 branch: body.branch,
1588 crate_name: body.crate_name,
1589 npm_script: body.npm_script,
1590 port_storybook: body.port_storybook,
1591 url: body.url,
1592 url_storybook: body.url_storybook,
1593 linear: body.linear,
1594 github: body.github,
1595 publish: body.publish,
1596 version_targets: body.version_targets,
1597 version_domains: body.version_domains,
1598 ignore_paths: body.ignore_paths,
1599 watch_ignore_paths: body.watch_ignore_paths,
1600 versioning_disabled: body.versioning_disabled,
1601 release_disabled: body.release_disabled,
1602 versioning: body.versioning,
1603 discord: body.discord,
1604 audit: body.audit,
1605 }
1606 }
1607}
1608
1609pub fn service_matches_disable_pattern(service: &ServiceConfig, pattern: &str) -> bool {
1613 let pattern = pattern.trim().replace('\\', "/");
1614 if pattern.is_empty() {
1615 return false;
1616 }
1617 let name = service.name.trim();
1618 if glob_simple_match(&pattern, name) {
1619 return true;
1620 }
1621 if let Some(root) = service.root_directory.as_deref() {
1622 let normalized = root
1623 .replace('\\', "/")
1624 .trim_start_matches("./")
1625 .trim_matches('/')
1626 .to_string();
1627 if !normalized.is_empty() && glob_simple_match(&pattern, &normalized) {
1628 return true;
1629 }
1630 }
1631 false
1632}
1633
1634pub fn service_matches_any_disable_pattern(service: &ServiceConfig, patterns: &[String]) -> bool {
1635 patterns
1636 .iter()
1637 .any(|pattern| service_matches_disable_pattern(service, pattern))
1638}
1639
1640pub fn service_versioning_allowed(config: &XbpConfig, service: &ServiceConfig) -> bool {
1642 service.is_versioning_enabled()
1643 && !service_matches_any_disable_pattern(service, &config.versioning_disabled)
1644}
1645
1646pub fn service_release_allowed(config: &XbpConfig, service: &ServiceConfig) -> bool {
1648 service.is_release_enabled()
1649 && service_versioning_allowed(config, service)
1650 && !service_matches_any_disable_pattern(service, &config.release_disabled)
1651}
1652
1653pub fn path_matches_association_pattern(pattern: &str, path: &str) -> bool {
1656 let pattern = pattern.trim().replace('\\', "/");
1657 let path = path.replace('\\', "/");
1658 let path = path.trim_start_matches("./").trim_matches('/');
1659 if pattern.is_empty() {
1660 return false;
1661 }
1662 if let Some((prefix, rest)) = pattern.split_once('{') {
1663 if let Some((choices, suffix)) = rest.split_once('}') {
1664 return choices.split(',').any(|choice| {
1665 let expanded = format!("{prefix}{}{suffix}", choice.trim());
1666 path_matches_association_pattern(&expanded, path)
1667 });
1668 }
1669 }
1670 if glob_simple_match(&pattern, path) {
1671 return true;
1672 }
1673 if !pattern.contains('/') {
1674 if let Some(name) = path.rsplit('/').next() {
1675 return glob_simple_match(&pattern, name);
1676 }
1677 }
1678 false
1679}
1680
1681pub fn parse_bump_kind_label(value: &str) -> Option<&'static str> {
1682 match value.trim().to_ascii_lowercase().as_str() {
1683 "patch" => Some("patch"),
1684 "minor" => Some("minor"),
1685 "major" => Some("major"),
1686 _ => None,
1687 }
1688}
1689
1690fn glob_simple_match(pattern: &str, value: &str) -> bool {
1691 let pattern = pattern.trim_matches('/');
1692 let value = value.trim_matches('/');
1693 if pattern == value {
1694 return true;
1695 }
1696 if pattern == "*" || pattern == "**" {
1697 return true;
1698 }
1699 if !pattern.contains('*') && !pattern.contains('/') {
1701 return pattern.eq_ignore_ascii_case(value)
1702 || value
1703 .split('/')
1704 .any(|segment| segment.eq_ignore_ascii_case(pattern));
1705 }
1706
1707 let pattern_parts: Vec<&str> = pattern.split('/').filter(|p| !p.is_empty()).collect();
1708 let value_parts: Vec<&str> = value.split('/').filter(|p| !p.is_empty()).collect();
1709 glob_parts_match(&pattern_parts, &value_parts)
1710}
1711
1712fn glob_parts_match(pattern: &[&str], value: &[&str]) -> bool {
1713 match (pattern.first().copied(), value.first().copied()) {
1714 (None, None) => true,
1715 (Some("**"), _) => {
1716 if glob_parts_match(&pattern[1..], value) {
1717 return true;
1718 }
1719 if value.is_empty() {
1720 return false;
1721 }
1722 glob_parts_match(pattern, &value[1..])
1723 }
1724 (Some(pat), Some(seg)) => {
1725 if segment_star_match(pat, seg) {
1726 glob_parts_match(&pattern[1..], &value[1..])
1727 } else {
1728 false
1729 }
1730 }
1731 _ => false,
1732 }
1733}
1734
1735fn segment_star_match(pattern: &str, segment: &str) -> bool {
1736 if pattern == "*" {
1737 return true;
1738 }
1739 if !pattern.contains('*') {
1740 return pattern.eq_ignore_ascii_case(segment);
1741 }
1742 let parts: Vec<&str> = pattern.split('*').collect();
1743 let lower = segment.to_ascii_lowercase();
1744 let mut rest = lower.as_str();
1745 for (index, part) in parts.iter().enumerate() {
1746 let part_l = part.to_ascii_lowercase();
1747 if part_l.is_empty() {
1748 continue;
1749 }
1750 if index == 0 {
1751 if !rest.starts_with(&part_l) {
1752 return false;
1753 }
1754 rest = &rest[part_l.len()..];
1755 continue;
1756 }
1757 if index == parts.len() - 1 {
1758 return rest.ends_with(&part_l);
1759 }
1760 if let Some(pos) = rest.find(&part_l) {
1761 rest = &rest[pos + part_l.len()..];
1762 } else {
1763 return false;
1764 }
1765 }
1766 true
1767}
1768
1769impl<'de> Deserialize<'de> for XbpConfig {
1770 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1771 where
1772 D: Deserializer<'de>,
1773 {
1774 deserialize_xbp_config(deserializer)
1775 }
1776}
1777
1778#[derive(Debug, Clone)]
1779pub struct GitHubProjectConfig {
1780 pub repository: Option<String>,
1781 pub auto_push_on_commit: bool,
1782 pub release_branch: Option<GitHubReleaseBranchConfig>,
1783 pub runners: Option<GitHubRunnersProjectConfig>,
1784}
1785
1786#[derive(Debug, Clone, Serialize, Deserialize)]
1787struct GitHubProjectConfigObject {
1788 #[serde(default, skip_serializing_if = "Option::is_none")]
1789 repository: Option<String>,
1790 #[serde(
1791 default = "default_auto_push_on_commit",
1792 skip_serializing_if = "github_auto_push_on_commit_is_default"
1793 )]
1794 auto_push_on_commit: bool,
1795 #[serde(default, skip_serializing_if = "Option::is_none")]
1796 release_branch: Option<GitHubReleaseBranchConfig>,
1797 #[serde(default, skip_serializing_if = "Option::is_none")]
1798 runners: Option<GitHubRunnersProjectConfig>,
1799}
1800
1801fn github_auto_push_on_commit_is_default(value: &bool) -> bool {
1802 *value
1803}
1804
1805impl Default for GitHubProjectConfig {
1806 fn default() -> Self {
1807 Self {
1808 repository: None,
1809 auto_push_on_commit: default_auto_push_on_commit(),
1810 release_branch: None,
1811 runners: None,
1812 }
1813 }
1814}
1815
1816impl Serialize for GitHubProjectConfig {
1817 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1818 where
1819 S: Serializer,
1820 {
1821 if let Some(repository) = &self.repository {
1822 if self.auto_push_on_commit && self.release_branch.is_none() && self.runners.is_none() {
1823 return serializer.serialize_str(repository);
1824 }
1825 }
1826
1827 GitHubProjectConfigObject {
1828 repository: self.repository.clone(),
1829 auto_push_on_commit: self.auto_push_on_commit,
1830 release_branch: self.release_branch.clone(),
1831 runners: self.runners.clone(),
1832 }
1833 .serialize(serializer)
1834 }
1835}
1836
1837impl<'de> Deserialize<'de> for GitHubProjectConfig {
1838 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1839 where
1840 D: Deserializer<'de>,
1841 {
1842 #[derive(Deserialize)]
1843 #[serde(untagged)]
1844 enum Repr {
1845 Repository(String),
1846 Config(GitHubProjectConfigObject),
1847 }
1848
1849 match Repr::deserialize(deserializer)? {
1850 Repr::Repository(repository) => Ok(Self {
1851 repository: Some(repository),
1852 ..Self::default()
1853 }),
1854 Repr::Config(config) => Ok(Self {
1855 repository: config.repository,
1856 auto_push_on_commit: config.auto_push_on_commit,
1857 release_branch: config.release_branch,
1858 runners: config.runners,
1859 }),
1860 }
1861 }
1862}
1863
1864#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1865pub struct GitHubRunnersProjectConfig {
1866 #[serde(default)]
1867 pub default_organization: Option<String>,
1868 #[serde(default)]
1869 pub default_runner_group: Option<String>,
1870 #[serde(default)]
1871 pub visibility: Option<String>,
1872 #[serde(default)]
1873 pub default_labels: Vec<String>,
1874 #[serde(default)]
1875 pub preferred_runtime: Option<RunnerExecutionRuntime>,
1876 #[serde(default)]
1877 pub work_directory: Option<String>,
1878 #[serde(default)]
1879 pub templates: Vec<GitHubRunnerTemplateConfig>,
1880}
1881
1882#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1883pub struct GitHubRunnerTemplateConfig {
1884 #[serde(default)]
1885 pub name: Option<String>,
1886 #[serde(default)]
1887 pub runner_group: Option<String>,
1888 #[serde(default)]
1889 pub labels: Vec<String>,
1890 #[serde(default)]
1891 pub runtime: Option<RunnerExecutionRuntime>,
1892}
1893
1894#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1895pub struct GitHubReleaseBranchConfig {
1896 #[serde(default)]
1897 pub enabled: bool,
1898 #[serde(default, alias = "template")]
1899 pub naming_template: Option<String>,
1900}
1901
1902#[derive(Debug, Clone, PartialEq, Eq)]
1903pub struct GitHubReleaseBranchSettings {
1904 pub naming_template: String,
1905}
1906
1907#[derive(Debug, Clone)]
1908pub struct DeploymentConfig {
1909 pub app_name: String,
1910 pub port: u16,
1911 pub app_dir: PathBuf,
1912 pub build_command: Option<String>,
1913 pub start_command: Option<String>,
1914 pub install_command: Option<String>,
1915 pub environment: HashMap<String, String>,
1916}
1917
1918pub fn normalize_config_paths_for_persistence(
1919 config: &mut XbpConfig,
1920 project_root: &std::path::Path,
1921) {
1922 config.build_dir = collapse_project_path(project_root, &config.build_dir);
1923 if let Some(publish) = &mut config.publish {
1924 for target in [&mut publish.npm, &mut publish.crates]
1925 .into_iter()
1926 .flatten()
1927 {
1928 if let Some(working_directory) = &target.working_directory {
1929 target.working_directory =
1930 Some(collapse_project_path(project_root, working_directory));
1931 }
1932 if let Some(manifest_path) = &target.manifest_path {
1933 target.manifest_path = Some(collapse_project_path(project_root, manifest_path));
1934 }
1935 for entry in &mut target.packages {
1936 if let Some(working_directory) = &entry.working_directory {
1937 entry.working_directory =
1938 Some(collapse_project_path(project_root, working_directory));
1939 }
1940 if let Some(manifest_path) = &entry.manifest_path {
1941 entry.manifest_path =
1942 Some(collapse_project_path(project_root, manifest_path));
1943 }
1944 }
1945 }
1946 }
1947 for target in &mut config.version_targets {
1948 *target = collapse_project_path(project_root, target);
1949 }
1950 for domain in &mut config.version_domains {
1951 domain.root = collapse_project_path(project_root, &domain.root);
1952 for surface in &mut domain.version_surfaces {
1953 surface.path = collapse_project_path(project_root, &surface.path);
1954 }
1955 }
1956 if let Some(services) = &mut config.services {
1957 for service in services {
1958 if let Some(root_directory) = &service.root_directory {
1959 service.root_directory = Some(collapse_project_path(project_root, root_directory));
1960 }
1961 if let Some(version_targets) = &mut service.version_targets {
1962 for target in version_targets {
1963 *target = collapse_project_path(project_root, target);
1964 }
1965 }
1966 if let Some(watch_paths) = &mut service.watch_paths {
1967 for path in watch_paths {
1968 *path = collapse_project_path(project_root, path);
1969 }
1970 }
1971 }
1972 }
1973 if let Some(workers) = &mut config.workers {
1974 for worker in workers {
1975 let original_worker_root = PathBuf::from(&worker.root);
1976 if let Some(container) = &mut worker.container {
1977 normalize_worker_container_paths(project_root, &original_worker_root, container);
1978 }
1979 worker.root = collapse_project_path(project_root, &worker.root);
1980 }
1981 }
1982}
1983
1984pub fn resolve_config_paths_for_runtime(config: &mut XbpConfig, project_root: &std::path::Path) {
1985 config.build_dir = resolve_project_dir(project_root, &config.build_dir);
1988 if let Some(publish) = &mut config.publish {
1989 for target in [&mut publish.npm, &mut publish.crates]
1990 .into_iter()
1991 .flatten()
1992 {
1993 if let Some(working_directory) = &target.working_directory {
1994 target.working_directory =
1995 Some(resolve_project_path(project_root, working_directory));
1996 }
1997 if let Some(manifest_path) = &target.manifest_path {
1998 target.manifest_path = Some(resolve_project_path(project_root, manifest_path));
1999 }
2000 for entry in &mut target.packages {
2001 if let Some(working_directory) = &entry.working_directory {
2002 entry.working_directory =
2003 Some(resolve_project_path(project_root, working_directory));
2004 }
2005 if let Some(manifest_path) = &entry.manifest_path {
2006 entry.manifest_path =
2007 Some(resolve_project_path(project_root, manifest_path));
2008 }
2009 }
2010 }
2011 }
2012 for target in &mut config.version_targets {
2013 *target = resolve_project_path(project_root, target);
2014 }
2015 for domain in &mut config.version_domains {
2016 domain.root = resolve_project_path(project_root, &domain.root);
2017 for surface in &mut domain.version_surfaces {
2018 surface.path = resolve_project_path(project_root, &surface.path);
2019 }
2020 }
2021 if let Some(services) = &mut config.services {
2022 for service in services {
2023 if let Some(root_directory) = &service.root_directory {
2024 service.root_directory = Some(resolve_project_dir(project_root, root_directory));
2025 }
2026 if let Some(version_targets) = &mut service.version_targets {
2027 for target in version_targets {
2028 *target = resolve_project_path(project_root, target);
2029 }
2030 }
2031 if let Some(watch_paths) = &mut service.watch_paths {
2032 for path in watch_paths {
2033 *path = resolve_project_path(project_root, path);
2034 }
2035 }
2036 }
2037 }
2038 if let Some(workers) = &mut config.workers {
2039 for worker in workers {
2040 worker.root = resolve_project_path(project_root, &worker.root);
2041 if let Some(container) = &mut worker.container {
2042 resolve_worker_container_paths(Path::new(&worker.root), container);
2043 }
2044 }
2045 }
2046}
2047
2048fn normalize_worker_container_paths(
2049 project_root: &Path,
2050 worker_root: &Path,
2051 container: &mut WorkerContainerConfig,
2052) {
2053 let Some(dockerfile) = container.dockerfile.as_ref() else {
2054 return;
2055 };
2056 let dockerfile_path = PathBuf::from(dockerfile);
2057 if !dockerfile_path.is_absolute() {
2058 return;
2059 }
2060
2061 if let Ok(relative) = dockerfile_path.strip_prefix(worker_root) {
2062 let rendered = relative.to_string_lossy().replace('\\', "/");
2063 container.dockerfile = Some(if rendered.is_empty() {
2064 "Dockerfile".to_string()
2065 } else {
2066 rendered
2067 });
2068 return;
2069 }
2070
2071 container.dockerfile = Some(collapse_project_path(project_root, dockerfile));
2072}
2073
2074fn resolve_worker_container_paths(worker_root: &Path, container: &mut WorkerContainerConfig) {
2075 let Some(dockerfile) = container.dockerfile.as_ref() else {
2076 return;
2077 };
2078 if PathBuf::from(dockerfile).is_absolute() {
2079 return;
2080 }
2081 container.dockerfile = Some(worker_root.join(dockerfile).to_string_lossy().to_string());
2082}
2083
2084impl XbpConfig {
2085 pub fn auto_push_on_commit_enabled(&self) -> bool {
2086 self.github
2087 .as_ref()
2088 .map(|config| config.auto_push_on_commit)
2089 .unwrap_or(true)
2090 }
2091
2092 pub fn github_release_branch_settings(&self) -> Option<GitHubReleaseBranchSettings> {
2093 let release_branch = self
2094 .github
2095 .as_ref()
2096 .and_then(|config| config.release_branch.as_ref())?;
2097 if !release_branch.enabled {
2098 return None;
2099 }
2100
2101 let naming_template = release_branch
2102 .naming_template
2103 .as_deref()
2104 .map(str::trim)
2105 .filter(|value| !value.is_empty())
2106 .unwrap_or(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
2107 .to_string();
2108
2109 Some(GitHubReleaseBranchSettings { naming_template })
2110 }
2111}
2112
2113impl DeploymentConfig {
2114 pub async fn from_args_or_config(
2116 app_name: Option<String>,
2117 port: Option<u16>,
2118 app_dir: Option<PathBuf>,
2119 config_path: Option<PathBuf>,
2120 ) -> Result<Self, String> {
2121 let xbp_config = if app_name.is_none() || port.is_none() || app_dir.is_none() {
2123 Self::load_xbp_config(config_path).await.ok()
2124 } else {
2125 None
2126 };
2127
2128 let app_name = app_name
2129 .or_else(|| xbp_config.as_ref().map(|c| c.project_name.clone()))
2130 .ok_or("Missing app name")?;
2131
2132 let port = port
2133 .or_else(|| xbp_config.as_ref().map(|c| c.port))
2134 .ok_or("Missing port")?;
2135
2136 let app_dir = app_dir
2137 .or_else(|| xbp_config.as_ref().map(|c| PathBuf::from(&c.build_dir)))
2138 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
2139
2140 let app_dir = app_dir
2141 .canonicalize()
2142 .map_err(|e| format!("Failed to resolve app directory: {}", e))?;
2143
2144 let build_command = xbp_config.as_ref().and_then(|c| c.build_command.clone());
2146 let start_command = xbp_config.as_ref().and_then(|c| c.start_command.clone());
2147 let install_command = xbp_config.as_ref().and_then(|c| c.install_command.clone());
2148 let environment = xbp_config
2149 .as_ref()
2150 .and_then(|c| c.environment.clone())
2151 .unwrap_or_default();
2152 let environment = resolve_env_placeholders(&app_dir, &environment);
2153
2154 Ok(DeploymentConfig {
2155 app_name,
2156 port,
2157 app_dir,
2158 build_command,
2159 start_command,
2160 install_command,
2161 environment,
2162 })
2163 }
2164
2165 pub async fn load_xbp_config(config_path: Option<PathBuf>) -> Result<XbpConfig, String> {
2167 Self::load_xbp_config_with_options(config_path, true).await
2168 }
2169
2170 pub async fn load_xbp_config_lenient(
2175 config_path: Option<PathBuf>,
2176 ) -> Result<XbpConfig, String> {
2177 Self::load_xbp_config_with_options(config_path, false).await
2178 }
2179
2180 pub async fn load_xbp_config_with_options(
2185 config_path: Option<PathBuf>,
2186 validate_services_cfg: bool,
2187 ) -> Result<XbpConfig, String> {
2188 let cwd = std::env::current_dir().unwrap_or_default();
2189
2190 let (project_root, resolved_path, resolved_kind) = if let Some(p) = config_path.clone() {
2191 let root = p
2192 .parent()
2193 .map(|parent| {
2194 if parent.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
2195 parent
2196 .parent()
2197 .map(std::path::Path::to_path_buf)
2198 .unwrap_or_else(|| parent.to_path_buf())
2199 } else {
2200 parent.to_path_buf()
2201 }
2202 })
2203 .unwrap_or_else(|| cwd.clone());
2204 (root, p, "auto")
2205 } else {
2206 let found = find_xbp_config_upwards(&cwd)
2207 .ok_or_else(|| "Configuration file not found".to_string())?;
2208 (found.project_root, found.config_path, found.kind)
2209 };
2210
2211 let _ = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &resolved_path);
2212
2213 let (config_path, kind) = (resolved_path, resolved_kind);
2214
2215 let content = fs::read_to_string(&config_path)
2216 .map_err(|e| format!("Failed to read config: {}", e))?;
2217
2218 let effective_kind = match kind {
2219 "yaml" | "json" | "jsonc" | "toml" => kind,
2220 _ => config_kind_from_path(&config_path)?,
2221 };
2222
2223 let (mut config, healed_content): (XbpConfig, Option<String>) =
2224 parse_config_with_auto_heal(&content, effective_kind).map_err(|e| {
2225 format!("Failed to parse {effective_kind} config: {e}")
2226 })?;
2227
2228 if let Some(healed_content) = healed_content {
2229 let _ = fs::write(&config_path, healed_content);
2230 }
2231
2232 resolve_config_paths_for_runtime(&mut config, &project_root);
2233
2234 if validate_services_cfg {
2236 if let Some(services) = &config.services {
2237 validate_services(services)?;
2238 }
2239 }
2240
2241 Ok(config)
2242 }
2243
2244 pub async fn save_xbp_config(&self, config_path: Option<PathBuf>) -> Result<(), String> {
2246 let found = find_xbp_config_upwards(&self.app_dir);
2247 let primary_path = config_path.unwrap_or_else(|| {
2248 resolve_project_config_write_path(&self.app_dir, found.as_ref())
2249 });
2250
2251 let mut xbp_config = XbpConfig {
2252 project_name: self.app_name.clone(),
2253 version: default_xbp_version(),
2254 port: self.port,
2255 build_dir: self.app_dir.to_string_lossy().to_string(),
2256 app_type: None,
2257 build_command: self.build_command.clone(),
2258 start_command: self.start_command.clone(),
2259 install_command: self.install_command.clone(),
2260 environment: if self.environment.is_empty() {
2261 None
2262 } else {
2263 Some(self.environment.clone())
2264 },
2265 services: None,
2266 openapi: None,
2267 workers: None,
2268 systemd_service_name: None,
2269 systemd: None,
2270 kafka_brokers: None,
2271 kafka_topic: None,
2272 kafka_public_url: None,
2273 log_files: None,
2274 monitor_url: None,
2275 monitor_method: None,
2276 monitor_expected_code: None,
2277 monitor_interval: None,
2278 database: None,
2279 oci: None,
2280 kubernetes: None,
2281 deploy: None,
2282 target: None,
2283 branch: None,
2284 crate_name: None,
2285 npm_script: None,
2286 port_storybook: None,
2287 url: None,
2288 url_storybook: None,
2289 linear: None,
2290 github: None,
2291 publish: None,
2292 version_targets: Vec::new(),
2293 version_domains: Vec::new(),
2294 ignore_paths: Vec::new(),
2295 watch_ignore_paths: Vec::new(),
2296 versioning_disabled: Vec::new(),
2297 release_disabled: Vec::new(),
2298 versioning: None,
2299 discord: None,
2300 audit: None,
2301 };
2302
2303 normalize_config_paths_for_persistence(&mut xbp_config, &self.app_dir);
2304
2305 crate::utils::write_xbp_project_config_at_path(&primary_path, &xbp_config)?;
2306
2307 let sibling_json = primary_path
2309 .parent()
2310 .map(|p| p.join("xbp.json"))
2311 .unwrap_or_else(|| self.app_dir.join(".xbp").join("xbp.json"));
2312 if sibling_json.exists() && sibling_json != primary_path {
2313 crate::utils::write_xbp_project_config_at_path(&sibling_json, &xbp_config).map_err(
2314 |e| {
2315 format!(
2316 "Failed to sync sibling JSON config {}: {e}",
2317 sibling_json.display()
2318 )
2319 },
2320 )?;
2321 }
2322
2323 Ok(())
2324 }
2325
2326 pub fn update_port(&mut self, new_port: u16) {
2328 self.port = new_port;
2329 }
2330
2331 pub fn merge_with_recommendations(
2333 &mut self,
2334 recommendations: &super::project_detector::DeploymentRecommendations,
2335 ) {
2336 if self.build_command.is_none() {
2338 self.build_command = recommendations.build_command.clone();
2339 }
2340
2341 if self.start_command.is_none() {
2342 self.start_command = recommendations.start_command.clone();
2343 }
2344
2345 if self.install_command.is_none() {
2346 self.install_command = recommendations.install_command.clone();
2347 }
2348
2349 if let Some(recommended_name) = &recommendations.process_name {
2351 if self.app_name == "app" || self.app_name == "unknown" {
2352 self.app_name = recommended_name.clone();
2353 }
2354 }
2355 }
2356}
2357
2358pub fn validate_services(services: &[ServiceConfig]) -> Result<(), String> {
2360 let mut names = std::collections::HashSet::new();
2361 let mut ports = std::collections::HashSet::new();
2362 let mut urls = std::collections::HashSet::new();
2363
2364 for service in services {
2365 if !names.insert(&service.name) {
2367 return Err(format!("Duplicate service name found: {}", service.name));
2368 }
2369
2370 if !ports.insert(service.port) {
2372 return Err(format!("Duplicate port found: {}", service.port));
2373 }
2374
2375 if let Some(url) = &service.url {
2377 if !urls.insert(url) {
2378 return Err(format!("Duplicate URL found: {}", url));
2379 }
2380 }
2381
2382 if service.target.trim().is_empty() {
2384 return Err(format!(
2385 "Service '{}' is missing a target. Set it to something like rust, nextjs, nodejs, python, docker, or a custom runtime label.",
2386 service.name
2387 ));
2388 }
2389 }
2390
2391 Ok(())
2392}
2393
2394pub fn legacy_service_from_config(config: &XbpConfig) -> ServiceConfig {
2395 ServiceConfig {
2396 name: config.project_name.clone(),
2397 target: config.target.clone().unwrap_or_else(|| "rust".to_string()),
2398 target_freeze: None,
2399 branch: config.branch.clone().unwrap_or_else(|| "main".to_string()),
2400 port: config.port,
2401 root_directory: Some(config.build_dir.clone()),
2402 environment: config.environment.clone(),
2403 url: config.url.clone(),
2404 healthcheck_path: None,
2405 restart_policy: Some("on_failure".to_string()),
2406 restart_policy_max_failure_count: Some(10),
2407 start_wrapper: Some("pm2".to_string()),
2408 commands: Some(ServiceCommands {
2409 pre: None,
2410 install: config.install_command.clone(),
2411 build: config.build_command.clone(),
2412 start: config.start_command.clone(),
2413 dev: None,
2414 custom: Default::default(),
2415 }),
2416 force_run_from_root: Some(false),
2417 version_targets: if config.version_targets.is_empty() {
2418 None
2419 } else {
2420 Some(config.version_targets.clone())
2421 },
2422 depends_on: None,
2423 watch_paths: None,
2424 versioning: None,
2425 release: None,
2426 systemd_service_name: config.systemd_service_name.clone(),
2427 systemd: config.systemd.clone(),
2428 openapi: None,
2429 oci: None,
2430 deploy: None,
2431 discord: None,
2432 file_associations: Vec::new(),
2433 }
2434}
2435
2436pub fn get_service_by_name(config: &XbpConfig, name: &str) -> Result<ServiceConfig, String> {
2438 if let Some(services) = &config.services {
2439 services
2440 .iter()
2441 .find(|s| s.name == name)
2442 .cloned()
2443 .ok_or_else(|| format!("Service '{}' not found in configuration", name))
2444 } else {
2445 let legacy = legacy_service_from_config(config);
2446 if legacy.name == name {
2447 Ok(legacy)
2448 } else {
2449 Err(format!(
2450 "Service '{}' not found. Legacy single-service projects expose only '{}'.",
2451 name, legacy.name
2452 ))
2453 }
2454 }
2455}
2456
2457pub fn get_all_workers(config: &XbpConfig) -> Vec<WorkerConfig> {
2459 config.workers.clone().unwrap_or_default()
2460}
2461
2462pub fn get_worker_by_name(config: &XbpConfig, name: &str) -> Result<WorkerConfig, String> {
2464 get_all_workers(config)
2465 .into_iter()
2466 .find(|worker| worker.name == name)
2467 .ok_or_else(|| {
2468 let available = get_all_workers(config)
2469 .iter()
2470 .map(|worker| worker.name.as_str())
2471 .collect::<Vec<_>>()
2472 .join(", ");
2473 if available.is_empty() {
2474 format!(
2475 "Worker app '{name}' was not found. Add a `workers:` section to project config (`.xbp/xbp.toml` by default) or run `xbp workers deploy configure --write-config`."
2476 )
2477 } else {
2478 format!(
2479 "Worker app '{name}' was not found. Available workers: {available}"
2480 )
2481 }
2482 })
2483}
2484
2485pub fn get_all_services(config: &XbpConfig) -> Vec<ServiceConfig> {
2487 if let Some(services) = &config.services {
2488 services.clone()
2489 } else {
2490 vec![legacy_service_from_config(config)]
2492 }
2493}
2494
2495#[cfg(test)]
2496mod tests {
2497 use super::{
2498 normalize_config_paths_for_persistence, resolve_config_paths_for_runtime,
2499 validate_github_release_branch_naming_template, validate_services, ServiceConfig,
2500 XbpConfig, DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE,
2501 };
2502
2503 fn sample_service(name: &str, port: u16) -> ServiceConfig {
2504 ServiceConfig {
2505 name: name.to_string(),
2506 target: "rust".to_string(),
2507 target_freeze: None,
2508 branch: "main".to_string(),
2509 port,
2510 root_directory: None,
2511 environment: None,
2512 url: None,
2513 healthcheck_path: None,
2514 restart_policy: None,
2515 restart_policy_max_failure_count: None,
2516 start_wrapper: None,
2517 commands: None,
2518 force_run_from_root: None,
2519 version_targets: None,
2520 depends_on: None,
2521 watch_paths: None,
2522 versioning: None,
2523 release: None,
2524 systemd_service_name: None,
2525 systemd: None,
2526 openapi: None,
2527 oci: None,
2528 deploy: None,
2529 discord: None,
2530 file_associations: Vec::new(),
2531 }
2532 }
2533
2534 #[test]
2535 fn validate_services_rejects_duplicate_ports() {
2536 let services = vec![sample_service("a", 3004), sample_service("b", 3004)];
2537 let err = validate_services(&services).expect_err("duplicate ports");
2538 assert!(err.contains("Duplicate port found: 3004"));
2539 }
2540
2541 #[test]
2542 fn validate_services_allows_unique_ports() {
2543 let services = vec![sample_service("a", 3004), sample_service("b", 3005)];
2544 validate_services(&services).expect("unique ports should pass");
2545 }
2546
2547 #[test]
2548 fn service_versioning_and_release_flags_default_enabled() {
2549 let service = sample_service("api", 3000);
2550 assert!(service.is_versioning_enabled());
2551 assert!(service.is_release_enabled());
2552
2553 let disabled: ServiceConfig = serde_yaml::from_str(
2554 r#"
2555name: docs
2556target: nextjs
2557branch: main
2558port: 3001
2559versioning: false
2560release: false
2561"#,
2562 )
2563 .expect("parse");
2564 assert!(!disabled.is_versioning_enabled());
2565 assert!(!disabled.is_release_enabled());
2566
2567 let version_only: ServiceConfig = serde_yaml::from_str(
2568 r#"
2569name: internal
2570target: rust
2571branch: main
2572port: 3002
2573release: false
2574"#,
2575 )
2576 .expect("parse");
2577 assert!(version_only.is_versioning_enabled());
2578 assert!(!version_only.is_release_enabled());
2579 }
2580
2581 #[test]
2582 fn project_disable_lists_match_name_and_root_globs() {
2583 let mut service = sample_service("web", 3000);
2584 service.root_directory = Some("apps/web".to_string());
2585 let config = XbpConfig {
2586 project_name: "demo".to_string(),
2587 version: "0.1.0".to_string(),
2588 port: 3000,
2589 build_dir: "./".to_string(),
2590 app_type: None,
2591 build_command: None,
2592 start_command: None,
2593 install_command: None,
2594 environment: None,
2595 services: None,
2596 openapi: None,
2597 workers: None,
2598 systemd_service_name: None,
2599 systemd: None,
2600 kafka_brokers: None,
2601 kafka_topic: None,
2602 kafka_public_url: None,
2603 log_files: None,
2604 monitor_url: None,
2605 monitor_method: None,
2606 monitor_expected_code: None,
2607 monitor_interval: None,
2608 database: None,
2609 oci: None,
2610 kubernetes: None,
2611 deploy: None,
2612 target: None,
2613 branch: None,
2614 crate_name: None,
2615 npm_script: None,
2616 port_storybook: None,
2617 url: None,
2618 url_storybook: None,
2619 linear: None,
2620 github: None,
2621 publish: None,
2622 version_targets: Vec::new(),
2623 version_domains: Vec::new(),
2624 ignore_paths: Vec::new(),
2625 watch_ignore_paths: Vec::new(),
2626 versioning_disabled: vec!["docs".to_string(), "apps/*".to_string()],
2627 release_disabled: vec!["web".to_string()],
2628 versioning: None,
2629 discord: None,
2630 audit: None,
2631 };
2632
2633 assert!(!super::service_versioning_allowed(&config, &service));
2634 assert!(!super::service_release_allowed(&config, &service));
2635
2636 let mut docs = sample_service("docs", 3001);
2637 docs.root_directory = Some("apps/docs".to_string());
2638 assert!(!super::service_versioning_allowed(&config, &docs));
2639
2640 let mut api = sample_service("api", 3002);
2641 api.root_directory = Some("crates/api".to_string());
2642 assert!(super::service_versioning_allowed(&config, &api));
2643 assert!(super::service_release_allowed(&config, &api));
2644 }
2645
2646 #[test]
2647 fn service_commands_accept_arbitrary_named_commands() {
2648 let commands: super::ServiceCommands =
2649 serde_yaml::from_str("build: cargo build\ntest: cargo test\nlint: cargo clippy\n")
2650 .expect("parse service commands");
2651
2652 assert_eq!(
2653 commands.get("build").map(String::as_str),
2654 Some("cargo build")
2655 );
2656 assert_eq!(commands.get("test").map(String::as_str), Some("cargo test"));
2657 assert_eq!(
2658 commands.available_names(),
2659 vec!["build".to_string(), "lint".to_string(), "test".to_string()]
2660 );
2661 }
2662
2663 #[test]
2664 fn parses_systemd_string_shorthand_without_struct_error() {
2665 let config: XbpConfig = serde_yaml::from_str(
2666 r#"
2667project_name: athena
2668version: 4.5.0
2669port: 4052
2670build_dir: ./
2671services:
2672 - name: athena-gateway
2673 target: rust
2674 branch: main
2675 port: 4052
2676 systemd: athena.service
2677 systemd_service_name: athena
2678"#,
2679 )
2680 .expect("parse config");
2681
2682 let service = &config.services.expect("services")[0];
2683 assert_eq!(service.systemd_service_name.as_deref(), Some("athena"));
2684 assert!(service.systemd.is_none());
2685 }
2686
2687 #[test]
2688 fn service_config_accepts_systemd_string_shorthand_directly() {
2689 let service: ServiceConfig = serde_yaml::from_str(
2690 r#"
2691name: athena-gateway
2692target: rust
2693branch: main
2694port: 4052
2695systemd: athena.service
2696"#,
2697 )
2698 .expect("parse service");
2699
2700 assert_eq!(
2701 service.systemd_service_name.as_deref(),
2702 Some("athena.service")
2703 );
2704 assert!(service.systemd.is_none());
2705 }
2706
2707 #[test]
2708 fn parses_discord_webhook_and_service_overrides() {
2709 let config: XbpConfig = serde_yaml::from_str(
2710 r#"
2711project_name: demo
2712version: 1.2.3
2713port: 3000
2714build_dir: ./
2715discord:
2716 enabled: true
2717 webhook_url: ${DISCORD_WEBHOOK_URL}
2718 username: XBP
2719 events:
2720 version_release: true
2721 crates_io: true
2722 npm: false
2723 linear: true
2724 cargo_dist: true
2725 oci: true
2726services:
2727 - name: api
2728 target: rust
2729 branch: main
2730 port: 3000
2731 discord:
2732 enabled: true
2733 events:
2734 oci: false
2735"#,
2736 )
2737 .expect("parse discord schema");
2738 let discord = config.discord.expect("project discord");
2739 assert_eq!(discord.enabled, Some(true));
2740 assert_eq!(
2741 discord.webhook_url.as_deref(),
2742 Some("${DISCORD_WEBHOOK_URL}")
2743 );
2744 let events = discord.events.expect("events");
2745 assert_eq!(events.npm, Some(false));
2746 assert_eq!(events.crates_io, Some(true));
2747 let service = config
2748 .services
2749 .as_ref()
2750 .and_then(|s| s.first())
2751 .expect("service");
2752 let service_discord = service.discord.as_ref().expect("service discord");
2753 assert_eq!(
2754 service_discord
2755 .events
2756 .as_ref()
2757 .and_then(|e| e.oci),
2758 Some(false)
2759 );
2760 }
2761
2762 #[test]
2763 fn parses_project_and_service_deploy_oci_blocks() {
2764 let config: XbpConfig = serde_yaml::from_str(
2765 r#"
2766project_name: demo
2767version: 1.2.3
2768port: 3000
2769build_dir: ./
2770oci:
2771 default_registry: ghcr.io
2772 owner: xylex-group
2773 repository: xylex-group/container-images
2774 registries:
2775 ghcr.io:
2776 provider: github
2777 username: xylex-group
2778 token_source: github
2779kubernetes:
2780 default_namespace: demo-system
2781 require_context_confirmation_for_apply: true
2782deploy:
2783 default_env: production
2784 history_dir: .xbp/deployments
2785 groups:
2786 core:
2787 services: [api]
2788 order: [api]
2789services:
2790 - name: api
2791 target: rust
2792 branch: main
2793 port: 3000
2794 oci:
2795 image: ghcr.io/xylex-group/demo-api
2796 tag_from: project_version
2797 dockerfile: Dockerfile
2798 platforms: [linux/amd64]
2799 deploy:
2800 provider: kubernetes
2801 envs:
2802 production:
2803 namespace: demo-system
2804 replicas: 2
2805 health:
2806 - https://api.example.com/health
2807 kubernetes:
2808 workload: deployment/api
2809 service: api
2810"#,
2811 )
2812 .expect("parse deploy/oci schema");
2813
2814 let oci = config.oci.expect("project oci");
2815 assert!(oci.registries.contains_key("ghcr.io"));
2816 assert_eq!(oci.owner.as_deref(), Some("xylex-group"));
2817 assert_eq!(
2818 oci.repository.as_deref(),
2819 Some("xylex-group/container-images")
2820 );
2821 assert_eq!(
2822 super::compose_service_oci_image(Some(&oci), None, "demo", "api", None).as_deref(),
2823 Some("ghcr.io/xylex-group/api")
2824 );
2825 assert_eq!(
2826 super::compose_service_oci_image(
2827 Some(&oci),
2828 None,
2829 "demo",
2830 "api",
2831 Some("ghcr.io/other-org/custom")
2832 )
2833 .as_deref(),
2834 Some("ghcr.io/other-org/custom")
2835 );
2836 assert_eq!(
2837 config.kubernetes.as_ref().and_then(|k| k.default_namespace.as_deref()),
2838 Some("demo-system")
2839 );
2840 let group = config
2841 .deploy
2842 .as_ref()
2843 .and_then(|d| d.groups.get("core"))
2844 .expect("group");
2845 assert_eq!(group.services, vec!["api".to_string()]);
2846 let service = config.services.as_ref().unwrap().first().unwrap();
2847 assert_eq!(
2848 service.oci.as_ref().and_then(|o| o.image.as_deref()),
2849 Some("ghcr.io/xylex-group/demo-api")
2850 );
2851 assert_eq!(
2852 service.deploy.as_ref().and_then(|d| d.provider.as_deref()),
2853 Some("kubernetes")
2854 );
2855 let env = service
2856 .deploy
2857 .as_ref()
2858 .and_then(|d| d.envs.get("production"))
2859 .expect("production env");
2860 assert_eq!(env.replicas, Some(2));
2861 assert_eq!(
2862 env.kubernetes
2863 .as_ref()
2864 .and_then(|k| k.workload.as_deref()),
2865 Some("deployment/api")
2866 );
2867 }
2868
2869 #[test]
2870 fn github_auto_push_defaults_to_true_when_missing() {
2871 let config: XbpConfig = serde_yaml::from_str(
2872 r#"
2873project_name: demo
2874version: 0.1.0
2875port: 3000
2876build_dir: ./
2877"#,
2878 )
2879 .expect("parse config");
2880
2881 assert!(config.auto_push_on_commit_enabled());
2882 }
2883
2884 #[test]
2885 fn github_auto_push_can_be_disabled_per_project() {
2886 let config: XbpConfig = serde_yaml::from_str(
2887 r#"
2888project_name: demo
2889version: 0.1.0
2890port: 3000
2891build_dir: ./
2892github:
2893 auto_push_on_commit: false
2894"#,
2895 )
2896 .expect("parse config");
2897
2898 assert!(!config.auto_push_on_commit_enabled());
2899 }
2900
2901 #[test]
2902 fn github_scalar_repository_shorthand_parses_with_defaults() {
2903 let config: XbpConfig = serde_yaml::from_str(
2904 r#"
2905project_name: demo
2906version: 0.1.0
2907port: 3000
2908build_dir: ./
2909github: xylex-group/statbot-js
2910"#,
2911 )
2912 .expect("parse config");
2913
2914 let github = config.github.expect("github config");
2915 assert_eq!(github.repository.as_deref(), Some("xylex-group/statbot-js"));
2916 assert!(github.auto_push_on_commit);
2917 assert!(github.release_branch.is_none());
2918 }
2919
2920 #[test]
2921 fn github_scalar_repository_shorthand_round_trips_to_scalar_yaml() {
2922 let config: XbpConfig = serde_yaml::from_str(
2923 r#"
2924project_name: demo
2925version: 0.1.0
2926port: 3000
2927build_dir: ./
2928github: xylex-group/statbot-js
2929"#,
2930 )
2931 .expect("parse config");
2932
2933 let yaml = serde_yaml::to_string(&config).expect("serialize config");
2934
2935 assert!(yaml.contains("github: xylex-group/statbot-js"));
2936 }
2937
2938 #[test]
2939 fn default_release_branch_template_is_service_then_version() {
2940 assert_eq!(
2941 DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE,
2942 "releases/{SERVICE_NAME}-{VERSION}"
2943 );
2944 assert!(
2945 validate_github_release_branch_naming_template(
2946 DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE
2947 )
2948 .is_ok()
2949 );
2950 }
2951
2952 #[test]
2953 fn release_branch_template_rejects_unknown_and_missing_version_tokens() {
2954 assert!(validate_github_release_branch_naming_template(
2955 "releases/{PROJECT_NAME}-{VERSION}"
2956 )
2957 .is_err());
2958 assert!(validate_github_release_branch_naming_template("releases/{SERVICE_NAME}").is_err());
2959 assert!(validate_github_release_branch_naming_template("releases/{FLAG}-{VERSION}").is_err());
2960 assert!(validate_github_release_branch_naming_template("").is_err());
2961 assert!(validate_github_release_branch_naming_template("release / {VERSION}").is_err());
2962 assert!(validate_github_release_branch_naming_template("rel/${GITHUB_TAG}").is_ok());
2963 assert!(validate_github_release_branch_naming_template(
2964 "releases/{SERVICE_NAME}-{VERSION}"
2965 )
2966 .is_ok());
2967 }
2968
2969 #[test]
2970 fn github_release_branch_is_disabled_by_default() {
2971 let config: XbpConfig = serde_yaml::from_str(
2972 r#"
2973project_name: demo
2974version: 0.1.0
2975port: 3000
2976build_dir: ./
2977"#,
2978 )
2979 .expect("parse config");
2980
2981 assert!(config.github_release_branch_settings().is_none());
2982 }
2983
2984 #[test]
2985 fn github_release_branch_uses_default_template_when_enabled_without_one() {
2986 let config: XbpConfig = serde_yaml::from_str(
2987 r#"
2988project_name: demo
2989version: 0.1.0
2990port: 3000
2991build_dir: ./
2992github:
2993 release_branch:
2994 enabled: true
2995"#,
2996 )
2997 .expect("parse config");
2998
2999 let settings = config
3000 .github_release_branch_settings()
3001 .expect("release branch settings");
3002 assert_eq!(
3003 settings.naming_template,
3004 DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE
3005 );
3006 }
3007
3008 #[test]
3009 fn github_release_branch_uses_custom_template_when_configured() {
3010 let config: XbpConfig = serde_yaml::from_str(
3011 r#"
3012project_name: demo
3013version: 0.1.0
3014port: 3000
3015build_dir: ./
3016github:
3017 release_branch:
3018 enabled: true
3019 naming_template: rel/${GITHUB_TAG}
3020"#,
3021 )
3022 .expect("parse config");
3023
3024 let settings = config
3025 .github_release_branch_settings()
3026 .expect("release branch settings");
3027 assert_eq!(settings.naming_template, "rel/${GITHUB_TAG}");
3028 }
3029
3030 #[test]
3031 fn config_version_targets_resolve_for_runtime_and_persist_relatively() {
3032 let project_root = std::env::temp_dir().join("xbp-version-target-config");
3033 let mut config: XbpConfig = serde_yaml::from_str(
3034 r#"
3035project_name: demo
3036version: 0.1.0
3037port: 3000
3038build_dir: ./
3039version_targets:
3040 - crates/cli/Cargo.toml
3041 - apps/web/package.json
3042"#,
3043 )
3044 .expect("parse config");
3045
3046 resolve_config_paths_for_runtime(&mut config, &project_root);
3047 assert!(std::path::Path::new(&config.version_targets[0]).ends_with("crates/cli/Cargo.toml"));
3048 assert!(std::path::Path::new(&config.version_targets[1]).ends_with("apps/web/package.json"));
3049
3050 normalize_config_paths_for_persistence(&mut config, &project_root);
3051 assert_eq!(
3052 config.version_targets,
3053 vec![
3054 "crates/cli/Cargo.toml".to_string(),
3055 "apps/web/package.json".to_string()
3056 ]
3057 );
3058 }
3059
3060 #[test]
3061 fn worker_container_config_round_trips_and_normalizes_dockerfile() {
3062 let project_root = std::env::temp_dir().join("xbp-worker-container-config");
3063 let mut config: XbpConfig = serde_yaml::from_str(
3064 r#"
3065project_name: demo
3066version: 0.1.0
3067port: 3000
3068build_dir: ./
3069workers:
3070 - name: auth
3071 root: apps/auth-worker
3072 script_name: demo-auth
3073 container:
3074 class_name: AuthContainer
3075 binding: AUTH_CONTAINER
3076 dockerfile: Dockerfile
3077 port: 8787
3078 application_id: app_123
3079 healthcheck_path: /health
3080 health_urls:
3081 - https://auth.example.com/health
3082 expected_image_name: demo-auth-authcontainer
3083 image_tag_keep_count: 10
3084 allow_unchanged_container_image: false
3085 required_secrets:
3086 - AUTH_SECRET
3087 version_var: AUTH_VERSION
3088 instance_name_template: auth-${VERSION}
3089 default_rollout: gradual
3090"#,
3091 )
3092 .expect("parse config");
3093
3094 let worker = config
3095 .workers
3096 .as_ref()
3097 .and_then(|workers| workers.first())
3098 .expect("worker");
3099 let container = worker.container.as_ref().expect("container");
3100 assert_eq!(container.class_name.as_deref(), Some("AuthContainer"));
3101 assert_eq!(container.port, Some(8787));
3102 assert_eq!(container.required_secrets, vec!["AUTH_SECRET"]);
3103 assert_eq!(
3104 container.health_urls,
3105 vec!["https://auth.example.com/health"]
3106 );
3107 assert_eq!(
3108 container.expected_image_name.as_deref(),
3109 Some("demo-auth-authcontainer")
3110 );
3111 assert_eq!(container.image_tag_keep_count, Some(10));
3112 assert!(!container.allow_unchanged_container_image);
3113
3114 resolve_config_paths_for_runtime(&mut config, &project_root);
3115 let worker = config
3116 .workers
3117 .as_ref()
3118 .and_then(|workers| workers.first())
3119 .expect("worker");
3120 assert!(std::path::Path::new(&worker.root).ends_with("apps/auth-worker"));
3121 assert!(std::path::Path::new(
3122 worker
3123 .container
3124 .as_ref()
3125 .and_then(|container| container.dockerfile.as_deref())
3126 .expect("dockerfile")
3127 )
3128 .ends_with("apps/auth-worker/Dockerfile"));
3129
3130 normalize_config_paths_for_persistence(&mut config, &project_root);
3131 let yaml = serde_yaml::to_string(&config).expect("serialize config");
3132 assert!(yaml.contains("container:"));
3133 assert!(yaml.contains("class_name: AuthContainer"));
3134 assert!(yaml.contains("dockerfile: Dockerfile"));
3135 }
3136
3137 #[test]
3138 fn legacy_get_service_by_name_resolves_virtual_project_service() {
3139 use super::{get_all_services, get_service_by_name};
3140
3141 let config: XbpConfig = serde_yaml::from_str(
3142 r#"
3143project_name: "@suitsfinance/formations"
3144version: 0.7.0
3145port: 3000
3146build_dir: ./
3147target: nextjs
3148branch: main
3149build_command: pnpm run build
3150start_command: pnpm run start
3151install_command: pnpm install
3152"#,
3153 )
3154 .expect("parse config");
3155
3156 let listed = get_all_services(&config);
3157 assert_eq!(listed.len(), 1);
3158 assert_eq!(listed[0].name, "@suitsfinance/formations");
3159
3160 let resolved =
3161 get_service_by_name(&config, "@suitsfinance/formations").expect("resolve legacy");
3162 assert_eq!(resolved.name, "@suitsfinance/formations");
3163 assert_eq!(
3164 resolved.commands.as_ref().and_then(|c| c.build.as_deref()),
3165 Some("pnpm run build")
3166 );
3167
3168 let err = get_service_by_name(&config, "missing").expect_err("unknown legacy name");
3169 assert!(err.contains("not found"));
3170 assert!(err.contains("@suitsfinance/formations"));
3171 }
3172
3173 #[test]
3174 fn service_version_targets_resolve_for_runtime_and_persist_relatively() {
3175 let project_root = std::env::temp_dir().join("xbp-service-version-target-config");
3176 let mut config: XbpConfig = serde_yaml::from_str(
3177 r#"
3178project_name: demo
3179version: 0.1.0
3180port: 3000
3181build_dir: ./
3182services:
3183 - name: web
3184 target: nextjs
3185 branch: main
3186 port: 3001
3187 root_directory: apps/web
3188 version_targets:
3189 - apps/web/package.json
3190"#,
3191 )
3192 .expect("parse config");
3193
3194 resolve_config_paths_for_runtime(&mut config, &project_root);
3195 let services = config.services.as_ref().expect("services");
3196 assert!(std::path::Path::new(
3197 services[0]
3198 .version_targets
3199 .as_ref()
3200 .expect("service targets")
3201 .first()
3202 .expect("first target")
3203 )
3204 .ends_with("apps/web/package.json"));
3205
3206 normalize_config_paths_for_persistence(&mut config, &project_root);
3207 assert_eq!(
3208 config.services.unwrap()[0].version_targets,
3209 Some(vec!["apps/web/package.json".to_string()])
3210 );
3211 }
3212}