Skip to main content

xbp_cli/strategies/
deployment_config.rs

1//! deployment configuration management module
2//!
3//! handles reading and writing deployment configurations
4//! including xbp.yaml/xbp.json files and cli argument parsing
5//! supports both legacy single service and new multi service formats
6//! validates service configurations for duplicates and invalid targets
7
8use 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    // Legacy fields for backward compatibility
179    #[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    /// Create deployment config from CLI arguments and xbp config fallback
405    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        // Try to load from xbp config if not all args provided
412        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        // Get build/start commands from config if available
435        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    /// Load xbp configuration from file
456    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        // Validate services if present
519        if let Some(services) = &config.services {
520            validate_services(services)?;
521        }
522
523        Ok(config)
524    }
525
526    /// Save updated configuration back to project config files
527    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        // Create .xbp directory if it doesn't exist
533        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    /// Update port in the configuration
615    pub fn update_port(&mut self, new_port: u16) {
616        self.port = new_port;
617    }
618
619    /// Merge with deployment recommendations from project detection
620    pub fn merge_with_recommendations(
621        &mut self,
622        recommendations: &super::project_detector::DeploymentRecommendations,
623    ) {
624        // Use recommendations if not already set
625        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        // Use recommended process name if app_name is generic
638        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
646/// Validate services configuration
647pub 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        // Check for duplicate names
654        if !names.insert(&service.name) {
655            return Err(format!("Duplicate service name found: {}", service.name));
656        }
657
658        // Check for duplicate ports
659        if !ports.insert(service.port) {
660            return Err(format!("Duplicate port found: {}", service.port));
661        }
662
663        // Check for duplicate URLs
664        if let Some(url) = &service.url {
665            if !urls.insert(url) {
666                return Err(format!("Duplicate URL found: {}", url));
667            }
668        }
669
670        // Validate target
671        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
713/// Find a service by name
714pub 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
726/// Get all services from config (or create a virtual one from legacy config)
727pub fn get_all_services(config: &XbpConfig) -> Vec<ServiceConfig> {
728    if let Some(services) = &config.services {
729        services.clone()
730    } else {
731        // Backward compatibility: create a virtual service from top-level config
732        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}