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 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
31/// Default GitHub release branch name template.
32///
33/// Placeholders (see [`validate_github_release_branch_naming_template`]):
34/// `{VERSION}`, `{TAG}`, `{SERVICE_NAME}`, `${GITHUB_VERSION}`, `${GITHUB_TAG}`, `${SERVICE_NAME}`.
35pub const DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE: &str =
36    "releases/{SERVICE_NAME}-{VERSION}";
37
38/// Human-readable list of supported placeholders for release branch naming templates.
39pub const GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE_TOKENS: &str =
40    "{VERSION} {TAG} {SERVICE_NAME}  ${GITHUB_VERSION} ${GITHUB_TAG} ${SERVICE_NAME}";
41
42/// Validate a release-branch naming template before writing config or creating a branch.
43///
44/// Rules:
45/// - non-empty, no whitespace
46/// - every `{...}` / `${...}` token must be a known placeholder
47/// - must include a version token so each release gets a distinct branch
48pub 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/// One package under a multi-package monorepo publish provider.
144///
145/// Shared token / preflight / command settings live on the parent
146/// [`PublishTargetConfig`]; each entry only needs identity + paths.
147#[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    /// Extra monorepo packages that share this provider's token / command settings.
168    ///
169    /// When non-empty, each entry is published independently (npm packages and
170    /// rust crates from the same repo). Legacy single-package configs keep using
171    /// top-level `package_name` / `manifest_path` only.
172    #[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    /// Retry `cargo publish --no-verify` after local tarball verification fails (default true for crates).
189    #[serde(default)]
190    pub retry_no_verify: Option<bool>,
191    /// Package names that always receive a `--no-verify` retry on publish failure (e.g. `athena_rs`).
192    #[serde(default)]
193    pub no_verify_retry_packages: Vec<String>,
194    /// Seconds to wait for each published version to appear on the registry (default 180).
195    #[serde(default)]
196    pub visibility_timeout_seconds: Option<f64>,
197    /// Poll interval while waiting for registry visibility (default 5).
198    #[serde(default)]
199    pub visibility_poll_seconds: Option<f64>,
200}
201
202impl PublishTargetConfig {
203    /// Expand monorepo `packages[]` or fall back to the legacy single-package fields.
204    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        // Empty entry still produces one target (detect name from default manifest).
219        vec![PublishPackageEntry::default()]
220    }
221
222    /// Build a single-package clone with shared provider settings for one entry.
223    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    /// cargo-dist binary packaging for GitHub release assets / installers.
268    #[serde(default)]
269    pub dist: Option<PublishDistConfig>,
270}
271
272/// Project-level cargo-dist settings used by `xbp version release`.
273///
274/// When `enabled` is true (or a `dist-workspace.toml` is present), release
275/// builds host/global artifacts with the `dist` CLI and uploads them to the
276/// GitHub release. Multi-platform targets beyond the host are expected from
277/// generated GitHub Actions CI (`generate_ci`).
278#[derive(Debug, Clone, Serialize, Deserialize, Default)]
279pub struct PublishDistConfig {
280    #[serde(default)]
281    pub enabled: Option<bool>,
282    /// Package names to ship (defaults to crates publish package_name / dist metadata).
283    #[serde(default)]
284    pub packages: Vec<String>,
285    /// cargo-dist installers: shell, msi, powershell, npm, homebrew.
286    #[serde(default)]
287    pub installers: Vec<String>,
288    /// Rust target triples. Empty keeps host-mode defaults for local builds.
289    #[serde(default)]
290    pub targets: Vec<String>,
291    /// Artifact modes to run: host, local, global (default: host + global).
292    #[serde(default, alias = "artifacts")]
293    pub artifacts_modes: Vec<String>,
294    /// Pass `--allow-dirty` to dist (default true during release ledgers).
295    #[serde(default)]
296    pub allow_dirty: Option<bool>,
297    /// Run `dist generate --mode ci` when missing/out of date (default true).
298    #[serde(default)]
299    pub generate_ci: Option<bool>,
300    /// Override dist CLI binary name (`dist` or `cargo-dist`).
301    #[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    /// Additional named commands such as `test`, `lint`, `check`, or `publish`.
318    #[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    /// Force OpenNext deploy through WSL on Windows (`true`/`false`).
373    /// When unset, OpenNext apps auto-use WSL on Windows hosts.
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub use_wsl: Option<bool>,
376    /// Optional WSL distro name (`wsl -d <name>`).
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub wsl_distribution: Option<String>,
379    /// Package script for OpenNext build (default: first of `cf:build`, `build:worker`,
380    /// or `node scripts/opennext-cloudflare.mjs build` when present).
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub build_script: Option<String>,
383    /// Run `pnpm install --ignore-scripts` when node_modules is incomplete (default true for OpenNext).
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub install_ignore_scripts: Option<bool>,
386    /// Restore `@heroui-pro/react` dist from `~/.heroui/cache` when postinstall is a stub (default true for OpenNext).
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub restore_heroui_cache: Option<bool>,
389    /// Extra wrangler args after `deploy` (OpenNext default: `--keep-vars --autoconfig=false`).
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub wrangler_args: Option<Vec<String>>,
392    /// Env vars that must be present locally before install/build (hard fail).
393    /// Merged with auto-detected build requirements (e.g. HEROUI_AUTH_TOKEN for HeroUI Pro).
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub build_required_env: Vec<String>,
396    /// Package.json script to run as a fast preflight before install/build
397    /// (defaults: `predeploy`, `xbp:preflight`, `test:deploy` when present).
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub preflight_script: Option<String>,
400    /// When true, wrangler `secrets.required` missing locally hard-fail doctor/deploy
401    /// (default false: runtime secrets may already live on the Worker).
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub require_local_secrets: Option<bool>,
404    /// Prefer local Docker/OpenNext builds over remote-only paths (default true).
405    #[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    /// Wrangler Containers image reference. XBP's legacy `container` shape
440    /// remains supported; this field is used by the typed `containers` list.
441    #[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/// Project-level OCI registry defaults (`oci:` in xbp.yaml).
676///
677/// Image coordinates for GHCR-style registries:
678/// `{default_registry}/{owner}/{image_name}`.
679///
680/// `owner` / `repository` may differ from the app source repo (`github.repository`)
681/// when packages live under another GitHub org or a dedicated package repository.
682#[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    /// GitHub org/user (or registry namespace) used when composing image paths.
689    /// Example: `xylex-group` → `ghcr.io/xylex-group/<service>`.
690    #[serde(default, alias = "github_owner")]
691    pub owner: Option<String>,
692    /// Optional package-linked GitHub repository (`owner/repo`).
693    /// Used to derive [`Self::owner`] when `owner` is unset, and as documentation
694    /// for where OCI packages are published / permission-linked.
695    /// May differ from `github.repository` (source code repo).
696    #[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
712/// Extract `owner` from `owner/repo` (GitHub-style). Returns `None` for empty or single-segment.
713pub 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
725/// True when `image` already looks like a fully-qualified registry path
726/// (`ghcr.io/org/name`, `registry.example.com:5000/ns/img`, …).
727pub fn is_absolute_oci_image_ref(image: &str) -> bool {
728    let image = image.trim();
729    if image.is_empty() {
730        return false;
731    }
732    // Digest or tag suffix does not affect host detection.
733    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
743/// Resolve the OCI/GitHub namespace (owner) for image compose + auth hints.
744///
745/// Preference:
746/// 1. `oci.owner`
747/// 2. owner segment of `oci.repository`
748/// 3. `oci.registries.<default_registry>.username`
749/// 4. owner of `github.repository` (source repo)
750/// 5. `project_name` (last resort; may be scoped npm names — prefer explicit owner)
751pub 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        // Any registry username as weak fallback.
789        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
810/// Compose the image repository path (no tag) for a service.
811///
812/// - Absolute `service_image` → returned as-is (trimmed, tag/digest stripped only if
813///   callers pass a bare path; full refs with tags are preserved by using the path
814///   before the last `:` only when host-shaped — here we keep the string as given
815///   when absolute).
816/// - Relative / missing → `{registry}/{owner}/{name}` where `name` is the relative
817///   image or `service_name`.
818pub 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    // Relative image may be `ns/name` — still place under registry/owner only when
843    // single segment; multi-segment relative keeps as `{registry}/{relative}`.
844    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/// Per-service OCI image settings (`services[].oci`).
854#[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/// Project-level Kubernetes defaults (`kubernetes:` in xbp.yaml).
873#[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/// Project-level deploy defaults and groups (`deploy:` in xbp.yaml).
886///
887/// Primary deployable units remain `services[]`. This block only holds shared
888/// defaults and named groups for `xbp deploy <group>`.
889#[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/// Per-service deploy orchestration (`services[].deploy`).
912/// Distinct from `commands.deploy` (a local lifecycle script name).
913#[derive(Debug, Clone, Serialize, Deserialize, Default)]
914pub struct ServiceDeployConfig {
915    /// Default provider when `--to` is omitted and `destinations` is empty:
916    /// `kubernetes`, `kubernetes-operator`, `cloudflare-containers`, `worker`, `local`, …
917    ///
918    /// When `destinations` is non-empty, the default is **every enabled destination**
919    /// (multi-target: e.g. Cloudflare Worker+Container and Kubernetes together).
920    #[serde(default)]
921    pub provider: Option<String>,
922    /// Cloudflare worker app name (`workers[].name`). Defaults to service name.
923    #[serde(default)]
924    pub worker: Option<String>,
925    /// Cloudflare containers rollout (`immediate` | `gradual`).
926    #[serde(default)]
927    pub rollout: Option<String>,
928    /// Named destinations for multi-target deploy.
929    ///
930    /// ```toml
931    /// [services.deploy]
932    /// provider = "cloudflare-containers"
933    /// worker = "athena-auth"
934    ///
935    /// [services.deploy.destinations.kubernetes]
936    /// provider = "kubernetes"
937    ///
938    /// [services.deploy.destinations.cloudflare]
939    /// provider = "cloudflare-containers"
940    /// worker = "athena-auth"
941    /// rollout = "immediate"
942    /// ```
943    ///
944    /// Then: `xbp deploy athena-auth --run` ships both; narrow with
945    /// `--to cloudflare` / `--to kubernetes` / expand with `--to all`.
946    #[serde(default)]
947    pub destinations: HashMap<String, ServiceDeployDestinationConfig>,
948    #[serde(default)]
949    pub envs: HashMap<String, ServiceDeployEnvConfig>,
950}
951
952/// One entry under `services[].deploy.destinations.<name>`.
953#[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    /// Explicit env key/values for this deploy env (supports `${VAR}` placeholders).
976    /// Merged over `env_files` and `services[].environment`.
977    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
978    pub env: HashMap<String, String>,
979    /// Dotenv-style files relative to project or service root (e.g. `.env`, `.env.production`).
980    /// When empty, deploy auto-loads existing `.env`, `.env.{env}`, `.env.local` if present.
981    #[serde(default, skip_serializing_if = "Vec::is_empty")]
982    pub env_files: Vec<String>,
983    /// Files to mount into the container as a ConfigMap (e.g. `config.yaml` → `/app/config.yaml`).
984    /// When empty, auto-includes `config.yaml` / `config.yml` if present at project or service root.
985    #[serde(default, skip_serializing_if = "Vec::is_empty")]
986    pub config_files: Vec<String>,
987    /// Container listen port for bootstrap Service/Deployment (default 8080).
988    #[serde(default, skip_serializing_if = "Option::is_none")]
989    pub container_port: Option<u16>,
990    /// Keys that must be non-empty after compose (fail deploy or interactive prompt).
991    #[serde(default, skip_serializing_if = "Vec::is_empty")]
992    pub required_env: Vec<String>,
993    /// Public Service type, local port-forward, and optional DNS hosts.
994    #[serde(default, skip_serializing_if = "Option::is_none")]
995    pub expose: Option<ServiceDeployExposeConfig>,
996}
997
998/// `services[].deploy.envs.<env>.expose` — map Service → public/local access.
999#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1000pub struct ServiceDeployExposeConfig {
1001    /// `ClusterIP` | `NodePort` | `LoadBalancer` (bootstrap Service.spec.type).
1002    #[serde(default, skip_serializing_if = "Option::is_none")]
1003    pub service_type: Option<String>,
1004    /// Fixed NodePort when type is NodePort.
1005    #[serde(default, skip_serializing_if = "Option::is_none")]
1006    pub node_port: Option<u16>,
1007    /// Pod hostPort (docker-desktop convenience).
1008    #[serde(default, skip_serializing_if = "Option::is_none")]
1009    pub host_port: Option<u16>,
1010    /// localhost port for kubectl port-forward (defaults to container_port).
1011    #[serde(default, skip_serializing_if = "Option::is_none")]
1012    pub local_port: Option<u16>,
1013    /// Auto port-forward after rollout (default: true when local_port is set).
1014    #[serde(default, skip_serializing_if = "Option::is_none")]
1015    pub port_forward: Option<bool>,
1016    /// Hostnames written to the OS hosts file → 127.0.0.1 (with port-forward).
1017    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1018    pub dns_hosts: Vec<String>,
1019    /// `hosts` (default), `none`, or `cloudflare`.
1020    #[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    /// When `true`, discovery / generate-config must not overwrite `target`
1053    /// (e.g. Dockerfile detection flipping `nextjs` → `docker`). Default `false`.
1054    #[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    /// Optional deploy-time dependency edges for service graph ordering.
1084    #[serde(default, skip_serializing_if = "Option::is_none")]
1085    pub depends_on: Option<Vec<String>>,
1086    /// Path prefixes that, when dirty, mark this service as a version-bump candidate.
1087    /// Isolates multi-service dirty worktrees so each service versions independently.
1088    #[serde(default)]
1089    pub watch_paths: Option<Vec<String>>,
1090    /// When `false`, exclude this service from version bumps and release scopes.
1091    /// Defaults to enabled when omitted. Aliases: `versioning_enabled`, `enable_versioning`.
1092    #[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    /// When `false`, exclude this service from `xbp version release` (bumps may still run).
1100    /// Defaults to enabled when omitted. Requires `versioning` to remain enabled.
1101    /// Aliases: `release_enabled`, `enable_release`.
1102    #[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    /// Optional OCI image settings for deploy/OCI flows.
1116    #[serde(default, skip_serializing_if = "Option::is_none")]
1117    pub oci: Option<ServiceOciConfig>,
1118    /// Optional deploy orchestration (provider + envs). Not `commands.deploy`.
1119    #[serde(default, skip_serializing_if = "Option::is_none")]
1120    pub deploy: Option<ServiceDeployConfig>,
1121    /// Optional per-service Discord notification overrides.
1122    #[serde(default, skip_serializing_if = "Option::is_none")]
1123    pub discord: Option<ServiceDiscordConfig>,
1124    /// Per-service path/extension → bump-kind associations (override project `versioning`).
1125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1126    pub file_associations: Vec<VersionFileAssociation>,
1127}
1128
1129/// One glob pattern → suggested bump kind for `xbp version bump`.
1130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1131pub struct VersionFileAssociation {
1132    /// Glob against repo-relative paths (`**/*.md`, `docs/**`, `README.md`, `**/sql/**`).
1133    pub pattern: String,
1134    /// `patch`, `minor`, or `major`.
1135    pub bump: String,
1136}
1137
1138/// Project-level versioning defaults and file associations.
1139#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1140pub struct VersioningProjectConfig {
1141    /// Default bump when no association/history match (`patch` | `minor` | `major`).
1142    #[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    /// Whether this service participates in version bumps / dirty-tree versioning.
1154    pub fn is_versioning_enabled(&self) -> bool {
1155        self.versioning.unwrap_or(true)
1156    }
1157
1158    /// Whether this service may be released (`xbp version release`).
1159    /// Release is always off when versioning is disabled.
1160    pub fn is_release_enabled(&self) -> bool {
1161        self.is_versioning_enabled() && self.release.unwrap_or(true)
1162    }
1163
1164    /// When true, auto-discovery must not replace `target` from filesystem detection.
1165    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    /// Project-level OCI registry credentials / defaults.
1343    #[serde(default, skip_serializing_if = "Option::is_none")]
1344    pub oci: Option<OciProjectConfig>,
1345    /// Project-level Kubernetes defaults for deploy providers.
1346    #[serde(default, skip_serializing_if = "Option::is_none")]
1347    pub kubernetes: Option<KubernetesProjectConfig>,
1348    /// Project-level deploy defaults and named service groups.
1349    #[serde(default, skip_serializing_if = "Option::is_none")]
1350    pub deploy: Option<DeployProjectConfig>,
1351    // Legacy fields for backward compatibility
1352    #[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    /// Project-relative ignore patterns (gitignore-style subset).
1377    ///
1378    /// Also loaded from `.xbp/.xbpignore` and optional root `.xbpignore`.
1379    /// Used to skip service discovery roots and version-target discovery.
1380    /// Does **not** silence worktree-watch activity; use `watch_ignore_paths` for that.
1381    #[serde(default, alias = "ignored_paths", alias = "xbpignore")]
1382    pub ignore_paths: Vec<String>,
1383    /// Worktree-watch-only ignore patterns (gitignore-style subset).
1384    /// Does not affect service discovery. Aliases: `watch_ignore`.
1385    #[serde(default, alias = "watch_ignore")]
1386    pub watch_ignore_paths: Vec<String>,
1387    /// Service names or root directories (globs allowed) excluded from version bumps.
1388    /// Also applied on top of per-service `versioning: false`.
1389    #[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    /// Service names or root directories (globs allowed) excluded from releases.
1397    /// Also applied on top of per-service `release: false`.
1398    #[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    /// Project-level version bump defaults and path → kind associations.
1406    #[serde(default, skip_serializing_if = "Option::is_none")]
1407    pub versioning: Option<VersioningProjectConfig>,
1408    /// Project Discord webhook + per-event notification toggles.
1409    #[serde(default, skip_serializing_if = "Option::is_none")]
1410    pub discord: Option<DiscordConfig>,
1411    /// Static analysis / `xbp audit errors` configuration (language-agnostic).
1412    #[serde(default, skip_serializing_if = "Option::is_none", alias = "analysis")]
1413    pub audit: Option<AuditProjectConfig>,
1414}
1415
1416/// Project-level config for `xbp audit` (also parsed by `xbp-analysis` config adapter).
1417#[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
1609/// Match a service name or root directory against project-level disable patterns.
1610/// Patterns support `*` wildcards within a single segment and `**` across segments
1611/// via simple glob rules (`foo`, `apps/*`, `packages/**`).
1612pub 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
1640/// Whether a configured service should participate in version bumps / scopes.
1641pub 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
1646/// Whether a configured service should participate in releases.
1647pub 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
1653/// Match a repo-relative path against a versioning association pattern.
1654/// Supports `*`, `**`, `{a,b}` brace groups, and basename-only patterns like `README.md`.
1655pub 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    // Case-insensitive exact for bare names.
1700    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    // build_dir / service roots must point at real directories; remap stale
1986    // absolute paths from other machines (e.g. Linux /home/... on Windows).
1987    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    /// Create deployment config from CLI arguments and xbp config fallback
2115    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        // Try to load from xbp config if not all args provided
2122        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        // Get build/start commands from config if available
2145        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    /// Load xbp configuration from file (with service validation).
2166    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    /// Load xbp configuration without service validation.
2171    ///
2172    /// Use this for metadata-only reads (release tags, Linear settings, etc.) where
2173    /// deploy-time constraints such as unique ports must not block the command.
2174    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    /// Load xbp configuration from file.
2181    ///
2182    /// When `validate_services` is true, rejects configs with duplicate names/ports/URLs
2183    /// or empty service targets. Set false for non-deploy command paths.
2184    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        // Validate services if present (deploy-time constraints; skip for metadata-only loads)
2235        if validate_services_cfg {
2236            if let Some(services) = &config.services {
2237                validate_services(services)?;
2238            }
2239        }
2240
2241        Ok(config)
2242    }
2243
2244    /// Save updated configuration back to project config files (any supported format).
2245    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        // Keep a sibling xbp.json in sync only if it already exists and is not the primary file.
2308        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    /// Update port in the configuration
2327    pub fn update_port(&mut self, new_port: u16) {
2328        self.port = new_port;
2329    }
2330
2331    /// Merge with deployment recommendations from project detection
2332    pub fn merge_with_recommendations(
2333        &mut self,
2334        recommendations: &super::project_detector::DeploymentRecommendations,
2335    ) {
2336        // Use recommendations if not already set
2337        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        // Use recommended process name if app_name is generic
2350        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
2358/// Validate services configuration
2359pub 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        // Check for duplicate names
2366        if !names.insert(&service.name) {
2367            return Err(format!("Duplicate service name found: {}", service.name));
2368        }
2369
2370        // Check for duplicate ports
2371        if !ports.insert(service.port) {
2372            return Err(format!("Duplicate port found: {}", service.port));
2373        }
2374
2375        // Check for duplicate URLs
2376        if let Some(url) = &service.url {
2377            if !urls.insert(url) {
2378                return Err(format!("Duplicate URL found: {}", url));
2379            }
2380        }
2381
2382        // Validate target
2383        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
2436/// Find a service by name
2437pub 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
2457/// Get all configured Workers entries.
2458pub fn get_all_workers(config: &XbpConfig) -> Vec<WorkerConfig> {
2459    config.workers.clone().unwrap_or_default()
2460}
2461
2462/// Find a Worker entry by name.
2463pub 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
2485/// Get all services from config (or create a virtual one from legacy config)
2486pub fn get_all_services(config: &XbpConfig) -> Vec<ServiceConfig> {
2487    if let Some(services) = &config.services {
2488        services.clone()
2489    } else {
2490        // Backward compatibility: create a virtual service from top-level config
2491        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}