Skip to main content

xbp_cli/commands/version/
domain.rs

1use super::release_ledger::{
2    release_ledger_path, release_ledger_scope_slug, save_release_ledger, ReleaseDomainLedgerRecord,
3    ReleaseLedger, ReleaseLedgerStatus, ReleaseLedgerStep, ReleaseLedgerStepStatus,
4};
5use crate::cli::commands::{
6    CloudflareCmd, CloudflareReleaseCmd, CloudflareRollout, CloudflareSubCommand,
7};
8use crate::commands::cloudflare::run_cloudflare;
9use crate::strategies::{
10    normalize_config_paths_for_persistence, resolve_config_paths_for_runtime, VersionDomainConfig,
11    VersionDomainSurfaceConfig, XbpConfig,
12};
13use crate::utils::{
14    collapse_project_path, find_xbp_config_upwards, parse_config_with_auto_heal, serialize_xbp_yaml,
15};
16use chrono::Utc;
17use regex::Regex;
18use semver::Version;
19use serde::{Deserialize, Serialize};
20use serde_json::Value as JsonValue;
21use std::collections::{BTreeMap, BTreeSet};
22use std::env;
23use std::fs;
24use std::path::{Path, PathBuf};
25use std::process::{Command, Stdio};
26use toml::Value as TomlValue;
27
28#[derive(Debug, Clone)]
29pub struct VersionDomainCommandOptions {
30    pub command: VersionDomainCommand,
31}
32
33#[derive(Debug, Clone)]
34pub enum VersionDomainCommand {
35    Init(VersionDomainInitOptions),
36    Doctor(VersionDomainDoctorOptions),
37    Sync(VersionDomainSyncOptions),
38    Diagnose(VersionDomainDiagnoseOptions),
39    Release(VersionDomainReleaseOptions),
40}
41
42#[derive(Debug, Clone)]
43pub struct VersionDomainInitOptions {
44    pub name: String,
45    pub root: PathBuf,
46    pub write: bool,
47}
48
49#[derive(Debug, Clone)]
50pub struct VersionDomainDoctorOptions {
51    pub domain: String,
52}
53
54#[derive(Debug, Clone)]
55pub struct VersionDomainSyncOptions {
56    pub domain: String,
57    pub write: bool,
58}
59
60#[derive(Debug, Clone)]
61pub struct VersionDomainDiagnoseOptions {
62    pub domain: String,
63    pub log: Option<PathBuf>,
64}
65
66#[derive(Debug, Clone)]
67pub struct VersionDomainReleaseOptions {
68    pub domain: String,
69    pub patch: bool,
70    pub minor: bool,
71    pub major: bool,
72    pub version: Option<String>,
73    pub deploy: bool,
74    pub allow_major_jump: bool,
75    pub allow_cross_domain_version: Option<String>,
76}
77
78#[derive(Debug, Clone)]
79struct LoadedDomainConfig {
80    project_root: PathBuf,
81    config_path: PathBuf,
82    config: XbpConfig,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86struct VersionDomainObservation {
87    kind: String,
88    path: String,
89    detail: String,
90    version: String,
91}
92
93#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94struct VersionDomainReport {
95    domain: String,
96    expected_version: String,
97    observations: Vec<VersionDomainObservation>,
98    issues: Vec<String>,
99    warnings: Vec<String>,
100}
101
102impl VersionDomainReport {
103    fn is_healthy(&self) -> bool {
104        self.issues.is_empty()
105    }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109struct CargoPathMismatchDiagnostic {
110    dependency: String,
111    required_version: String,
112    candidate_version: String,
113    searched_path: String,
114    required_by: String,
115}
116
117pub async fn run_version_domain_command(
118    options: VersionDomainCommandOptions,
119) -> Result<(), String> {
120    match options.command {
121        VersionDomainCommand::Init(init) => run_domain_init(init),
122        VersionDomainCommand::Doctor(doctor) => run_domain_doctor(&doctor.domain),
123        VersionDomainCommand::Sync(sync) => run_domain_sync(&sync.domain, sync.write),
124        VersionDomainCommand::Diagnose(diagnose) => run_domain_diagnose(diagnose),
125        VersionDomainCommand::Release(release) => run_domain_release(release).await,
126    }
127}
128
129pub fn check_domain_for_cloudflare_release(
130    root_override: Option<&Path>,
131    domain_name: &str,
132    version: &str,
133    cloudflare_app: &str,
134) -> Result<(), String> {
135    let loaded = load_domain_config(root_override)?;
136    let domain = find_domain(&loaded.config, domain_name)?;
137    if domain.version != version {
138        return Err(format!(
139            "Cloudflare release version `{version}` does not match release domain `{}`={}.",
140            domain.name, domain.version
141        ));
142    }
143    if let Some(expected_app) = domain.cloudflare_app.as_deref() {
144        if expected_app != cloudflare_app {
145            return Err(format!(
146                "Release domain `{}` is bound to Cloudflare app `{expected_app}`, not `{cloudflare_app}`.",
147                domain.name
148            ));
149        }
150    }
151    let report = build_domain_report(&loaded.project_root, &loaded.config, domain)?;
152    if !report.is_healthy() {
153        return Err(render_domain_failure(&report));
154    }
155    Ok(())
156}
157
158fn run_domain_init(options: VersionDomainInitOptions) -> Result<(), String> {
159    let mut loaded = load_domain_config(None)?;
160    let domain_root = if options.root.is_absolute() {
161        options.root
162    } else {
163        loaded.project_root.join(options.root)
164    };
165    let version =
166        infer_domain_version(&domain_root).unwrap_or_else(|| loaded.config.version.clone());
167    let domain = scaffold_domain(&loaded.project_root, &domain_root, &options.name, &version);
168
169    if !options.write {
170        let mut preview = domain.clone();
171        normalize_domain_for_persistence(&loaded.project_root, &mut preview);
172        println!(
173            "{}",
174            serde_yaml::to_string(&preview)
175                .map_err(|error| format!("Failed to render version domain: {error}"))?
176        );
177        println!("Run again with --write to persist this version domain.");
178        return Ok(());
179    }
180
181    upsert_domain(&mut loaded.config, domain);
182    write_xbp_config(
183        &loaded.project_root,
184        &loaded.config_path,
185        &mut loaded.config,
186    )?;
187    println!(
188        "Wrote version domain `{}` to {}",
189        options.name,
190        loaded.config_path.display()
191    );
192    Ok(())
193}
194
195fn run_domain_doctor(domain_name: &str) -> Result<(), String> {
196    let loaded = load_domain_config(None)?;
197    let domain = find_domain(&loaded.config, domain_name)?;
198    let report = build_domain_report(&loaded.project_root, &loaded.config, domain)?;
199    print_domain_report(&report);
200    if report.is_healthy() {
201        println!("Version domain `{domain_name}`: ok");
202        Ok(())
203    } else {
204        Err(render_domain_failure(&report))
205    }
206}
207
208fn run_domain_sync(domain_name: &str, write: bool) -> Result<(), String> {
209    let mut loaded = load_domain_config(None)?;
210    let domain = find_domain(&loaded.config, domain_name)?.clone();
211    let report = build_domain_report(&loaded.project_root, &loaded.config, &domain)?;
212    print_domain_report(&report);
213    if report.observations.is_empty() {
214        return Err(format!(
215            "Version domain `{}` has no configured version_surfaces.",
216            domain.name
217        ));
218    }
219    if let Some(version) = self_consistent_wrong_version(&report) {
220        return Err(format!(
221            "All checked surfaces for `{}` agree on {}, but the configured domain version is {}. Consistency is not correctness; refusing to sync.",
222            domain.name, version, domain.version
223        ));
224    }
225    if !write {
226        if report.is_healthy() {
227            println!("Version domain `{domain_name}` sync check: ok");
228            return Ok(());
229        }
230        return Err(render_domain_failure(&report));
231    }
232
233    let updated = write_domain_surfaces(&loaded.project_root, &domain)?;
234    if let Some(existing) = loaded
235        .config
236        .version_domains
237        .iter_mut()
238        .find(|candidate| candidate.name == domain.name)
239    {
240        existing.version = domain.version.clone();
241    }
242    write_xbp_config(
243        &loaded.project_root,
244        &loaded.config_path,
245        &mut loaded.config,
246    )?;
247    println!(
248        "Updated {} surface(s) for version domain `{}` to {}.",
249        updated, domain.name, domain.version
250    );
251    Ok(())
252}
253
254fn run_domain_diagnose(options: VersionDomainDiagnoseOptions) -> Result<(), String> {
255    let loaded = load_domain_config(None)?;
256    let domain = find_domain(&loaded.config, &options.domain)?;
257    let log = match options.log {
258        Some(path) => fs::read_to_string(&path)
259            .map_err(|error| format!("Failed to read {}: {error}", path.display()))?,
260        None => String::new(),
261    };
262    let diagnostic = parse_cargo_path_mismatch(&log);
263    if let Some(diagnostic) = diagnostic {
264        println!("Path dependency mismatch detected");
265        println!("Domain: {}", domain.name);
266        println!("Expected: {}", domain.version);
267        println!("Dependency: {}", diagnostic.dependency);
268        println!("Required version: {}", diagnostic.required_version);
269        println!("Candidate version: {}", diagnostic.candidate_version);
270        println!("Location searched: {}", diagnostic.searched_path);
271        println!("Required by: {}", diagnostic.required_by);
272        if package_owned_by_domain(domain, &diagnostic.dependency) {
273            println!(
274                "Diagnosis: local package-version drift inside `{}`. Do not align this domain to {}.",
275                domain.name, diagnostic.candidate_version
276            );
277        } else {
278            println!(
279                "Diagnosis: dependency `{}` is not owned by `{}`; inspect cross-domain ownership before writing versions.",
280                diagnostic.dependency, domain.name
281            );
282        }
283        return Ok(());
284    }
285    println!("No known version-domain incident pattern detected.");
286    Ok(())
287}
288
289async fn run_domain_release(options: VersionDomainReleaseOptions) -> Result<(), String> {
290    let mut loaded = load_domain_config(None)?;
291    let domain_index = loaded
292        .config
293        .version_domains
294        .iter()
295        .position(|domain| domain.name == options.domain)
296        .ok_or_else(|| format!("Version domain `{}` was not found.", options.domain))?;
297    let current_domain = loaded.config.version_domains[domain_index].clone();
298    let current_version = parse_semver(&current_domain.version)?;
299    let next_version = resolve_release_version(&current_version, &options)?;
300    enforce_bump_policy(&current_domain, &current_version, &next_version, &options)?;
301    enforce_cross_domain_policy(
302        &loaded.config,
303        &current_domain,
304        &next_version,
305        options.allow_cross_domain_version.as_deref(),
306    )?;
307
308    let current_report =
309        build_domain_report(&loaded.project_root, &loaded.config, &current_domain)?;
310    if !current_report.is_healthy() {
311        return Err(format!(
312            "Version domain `{}` is not healthy before release.\n{}",
313            current_domain.name,
314            render_domain_failure(&current_report)
315        ));
316    }
317
318    let mut next_domain = current_domain.clone();
319    next_domain.version = next_version.to_string();
320    let updated = write_domain_surfaces(&loaded.project_root, &next_domain)?;
321    loaded.config.version_domains[domain_index].version = next_version.to_string();
322    write_xbp_config(
323        &loaded.project_root,
324        &loaded.config_path,
325        &mut loaded.config,
326    )?;
327    run_validation_commands(&next_domain)?;
328
329    let final_report = build_domain_report(&loaded.project_root, &loaded.config, &next_domain)?;
330    if !final_report.is_healthy() {
331        return Err(render_domain_failure(&final_report));
332    }
333    write_domain_release_ledger(
334        &loaded.project_root,
335        &next_domain,
336        &current_version,
337        &next_version,
338        updated,
339        &final_report,
340        options.allow_cross_domain_version.as_deref(),
341    )?;
342
343    println!(
344        "Released version domain `{}` from {} to {}.",
345        next_domain.name, current_version, next_version
346    );
347
348    if options.deploy {
349        let app = next_domain.cloudflare_app.clone().ok_or_else(|| {
350            format!(
351                "Version domain `{}` does not configure cloudflare_app; cannot deploy.",
352                next_domain.name
353            )
354        })?;
355        run_cloudflare(
356            CloudflareCmd {
357                root: Some(loaded.project_root.clone()),
358                app: Some(app),
359                token: None,
360                account_id: None,
361                command: CloudflareSubCommand::Release(CloudflareReleaseCmd {
362                    version: next_version.to_string(),
363                    domain: Some(next_domain.name.clone()),
364                    rollout: CloudflareRollout::Immediate,
365                    skip_deploy: false,
366                    local_build: false,
367                    local_build_only: false,
368                    allow_unchanged_container_image: true,
369                    require_new_container_image: false,
370                    prune_old_images: false,
371                    keep_image_tag_count: None,
372                }),
373            },
374            false,
375        )
376        .await?;
377    }
378    Ok(())
379}
380
381fn load_domain_config(root_override: Option<&Path>) -> Result<LoadedDomainConfig, String> {
382    let current_dir =
383        env::current_dir().map_err(|error| format!("Failed to read current directory: {error}"))?;
384    let start = root_override.unwrap_or(current_dir.as_path());
385    let found = find_xbp_config_upwards(start).ok_or_else(|| {
386        format!(
387            "Could not find .xbp/xbp.yaml from {}. Run `xbp init` first.",
388            start.display()
389        )
390    })?;
391    let content = fs::read_to_string(&found.config_path)
392        .map_err(|error| format!("Failed to read {}: {error}", found.config_path.display()))?;
393    let (mut config, healed_content) =
394        parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)
395            .map_err(|error| format!("Failed to parse {}: {error}", found.config_path.display()))?;
396    if let Some(healed_content) = healed_content {
397        fs::write(&found.config_path, healed_content)
398            .map_err(|error| format!("Failed to write {}: {error}", found.config_path.display()))?;
399    }
400    resolve_config_paths_for_runtime(&mut config, &found.project_root);
401    Ok(LoadedDomainConfig {
402        project_root: found.project_root,
403        config_path: found.config_path,
404        config,
405    })
406}
407
408fn find_domain<'a>(
409    config: &'a XbpConfig,
410    domain_name: &str,
411) -> Result<&'a VersionDomainConfig, String> {
412    config
413        .version_domains
414        .iter()
415        .find(|domain| domain.name == domain_name)
416        .ok_or_else(|| {
417            let available = config
418                .version_domains
419                .iter()
420                .map(|domain| domain.name.as_str())
421                .collect::<Vec<_>>()
422                .join(", ");
423            if available.is_empty() {
424                format!("Version domain `{domain_name}` was not found. Run `xbp version domain init --name {domain_name} --root <path> --write` first.")
425            } else {
426                format!("Version domain `{domain_name}` was not found. Available domains: {available}")
427            }
428        })
429}
430
431fn scaffold_domain(
432    project_root: &Path,
433    domain_root: &Path,
434    name: &str,
435    version: &str,
436) -> VersionDomainConfig {
437    let mut version_surfaces = Vec::new();
438    let cargo_toml = domain_root.join("Cargo.toml");
439    if cargo_toml.exists() {
440        version_surfaces.push(surface(
441            project_root,
442            "cargo_toml_package",
443            &cargo_toml,
444            None,
445        ));
446        version_surfaces.push(surface(
447            project_root,
448            "cargo_toml_workspace_package",
449            &cargo_toml,
450            None,
451        ));
452        version_surfaces.push(surface(
453            project_root,
454            "cargo_toml_workspace_dependencies",
455            &cargo_toml,
456            None,
457        ));
458    }
459    let cargo_lock = domain_root.join("Cargo.lock");
460    if cargo_lock.exists() {
461        version_surfaces.push(surface(
462            project_root,
463            "cargo_lock_packages",
464            &cargo_lock,
465            None,
466        ));
467    }
468    let package_json = domain_root.join("package.json");
469    if package_json.exists() {
470        version_surfaces.push(surface(project_root, "package_json", &package_json, None));
471    }
472    let wrangler = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]
473        .into_iter()
474        .map(|name| domain_root.join(name))
475        .find(|path| path.exists());
476    if let Some(wrangler) = wrangler {
477        version_surfaces.push(surface(
478            project_root,
479            "wrangler_var",
480            &wrangler,
481            Some("APP_VERSION"),
482        ));
483    }
484
485    VersionDomainConfig {
486        name: name.to_string(),
487        root: domain_root.to_string_lossy().to_string(),
488        version: version.to_string(),
489        owned_package_names: Vec::new(),
490        owned_package_prefixes: vec![name.to_string()],
491        version_surfaces,
492        allowed_bumps: vec!["patch".to_string(), "minor".to_string()],
493        validation_commands: Vec::new(),
494        image_version_probe: None,
495        live_health_urls: Vec::new(),
496        cloudflare_app: None,
497    }
498}
499
500fn surface(
501    project_root: &Path,
502    kind: &str,
503    path: &Path,
504    key: Option<&str>,
505) -> VersionDomainSurfaceConfig {
506    VersionDomainSurfaceConfig {
507        kind: kind.to_string(),
508        path: collapse_project_path(project_root, &path.to_string_lossy()),
509        key: key.map(ToOwned::to_owned),
510        marker: None,
511        packages: Vec::new(),
512    }
513}
514
515fn normalize_domain_for_persistence(project_root: &Path, domain: &mut VersionDomainConfig) {
516    domain.root = collapse_project_path(project_root, &domain.root);
517    for surface in &mut domain.version_surfaces {
518        surface.path = collapse_project_path(project_root, &surface.path);
519    }
520}
521
522fn upsert_domain(config: &mut XbpConfig, domain: VersionDomainConfig) {
523    if let Some(existing) = config
524        .version_domains
525        .iter_mut()
526        .find(|candidate| candidate.name == domain.name)
527    {
528        *existing = domain;
529    } else {
530        config.version_domains.push(domain);
531        config
532            .version_domains
533            .sort_by(|left, right| left.name.cmp(&right.name));
534    }
535}
536
537fn write_xbp_config(
538    project_root: &Path,
539    config_path: &Path,
540    config: &mut XbpConfig,
541) -> Result<(), String> {
542    normalize_config_paths_for_persistence(config, project_root);
543    let yaml = serialize_xbp_yaml(config)?;
544    fs::write(config_path, yaml)
545        .map_err(|error| format!("Failed to write {}: {error}", config_path.display()))?;
546    resolve_config_paths_for_runtime(config, project_root);
547    Ok(())
548}
549
550fn infer_domain_version(root: &Path) -> Option<String> {
551    let cargo = root.join("Cargo.toml");
552    if cargo.exists() {
553        if let Ok(content) = fs::read_to_string(&cargo) {
554            if let Ok(value) = content.parse::<TomlValue>() {
555                if let Some(version) = value
556                    .get("package")
557                    .and_then(|package| package.get("version"))
558                    .and_then(TomlValue::as_str)
559                {
560                    return Some(version.to_string());
561                }
562                if let Some(version) = value
563                    .get("workspace")
564                    .and_then(|workspace| workspace.get("package"))
565                    .and_then(|package| package.get("version"))
566                    .and_then(TomlValue::as_str)
567                {
568                    return Some(version.to_string());
569                }
570            }
571        }
572    }
573    let package_json = root.join("package.json");
574    if package_json.exists() {
575        if let Ok(content) = fs::read_to_string(&package_json) {
576            if let Ok(value) = serde_json::from_str::<JsonValue>(&content) {
577                if let Some(version) = value.get("version").and_then(JsonValue::as_str) {
578                    return Some(version.to_string());
579                }
580            }
581        }
582    }
583    None
584}
585
586fn build_domain_report(
587    project_root: &Path,
588    config: &XbpConfig,
589    domain: &VersionDomainConfig,
590) -> Result<VersionDomainReport, String> {
591    let mut report = VersionDomainReport {
592        domain: domain.name.clone(),
593        expected_version: domain.version.clone(),
594        ..VersionDomainReport::default()
595    };
596    if parse_semver(&domain.version).is_err() {
597        report.issues.push(format!(
598            "Domain version `{}` is not valid semver.",
599            domain.version
600        ));
601    }
602
603    for surface in &domain.version_surfaces {
604        match read_surface_observations(project_root, domain, surface) {
605            Ok(mut observations) => report.observations.append(&mut observations),
606            Err(error) => report.issues.push(error),
607        }
608    }
609
610    for observation in &report.observations {
611        if observation.version != domain.version {
612            report.issues.push(format!(
613                "{} {} reports {}, expected {}.",
614                observation.kind, observation.detail, observation.version, domain.version
615            ));
616        }
617    }
618
619    for issue in collect_cross_domain_ownership_issues(config) {
620        if issue.contains(&format!("`{}`", domain.name)) {
621            report.issues.push(issue);
622        }
623    }
624
625    if domain.version_surfaces.is_empty() {
626        report
627            .warnings
628            .push("No version_surfaces are configured.".to_string());
629    }
630    Ok(report)
631}
632
633fn read_surface_observations(
634    project_root: &Path,
635    domain: &VersionDomainConfig,
636    surface: &VersionDomainSurfaceConfig,
637) -> Result<Vec<VersionDomainObservation>, String> {
638    let path = PathBuf::from(&surface.path);
639    if !path.exists() {
640        return Err(format!("Configured surface not found: {}", path.display()));
641    }
642    let path_label = collapse_project_path(project_root, &path.to_string_lossy());
643    match surface.kind.as_str() {
644        "cargo_toml_package" => read_cargo_toml_package(&path, &path_label),
645        "cargo_toml_workspace_package" => read_cargo_toml_workspace_package(&path, &path_label),
646        "cargo_toml_workspace_dependencies" => {
647            read_cargo_toml_workspace_dependencies(&path, &path_label, domain, surface)
648        }
649        "cargo_lock_packages" => read_cargo_lock_packages(&path, &path_label, domain, surface),
650        "package_json" => read_package_json(&path, &path_label),
651        "wrangler_var" => read_wrangler_var(&path, &path_label, surface),
652        "generated_type_literal" => read_keyed_literal(&path, &path_label, surface),
653        "regex" | "readme_marker" => read_regex_surface(&path, &path_label, surface),
654        other => Err(format!(
655            "Unsupported version surface kind `{other}` for {}.",
656            path.display()
657        )),
658    }
659}
660
661fn read_toml(path: &Path) -> Result<TomlValue, String> {
662    let content = fs::read_to_string(path)
663        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
664    content
665        .parse::<TomlValue>()
666        .map_err(|error| format!("Failed to parse {} as TOML: {error}", path.display()))
667}
668
669fn read_cargo_toml_package(
670    path: &Path,
671    label: &str,
672) -> Result<Vec<VersionDomainObservation>, String> {
673    let value = read_toml(path)?;
674    let Some(version) = value
675        .get("package")
676        .and_then(|package| package.get("version"))
677        .and_then(TomlValue::as_str)
678    else {
679        return Ok(Vec::new());
680    };
681    Ok(vec![observation(
682        "cargo_toml_package",
683        label,
684        "[package].version",
685        version,
686    )])
687}
688
689fn read_cargo_toml_workspace_package(
690    path: &Path,
691    label: &str,
692) -> Result<Vec<VersionDomainObservation>, String> {
693    let value = read_toml(path)?;
694    let Some(version) = value
695        .get("workspace")
696        .and_then(|workspace| workspace.get("package"))
697        .and_then(|package| package.get("version"))
698        .and_then(TomlValue::as_str)
699    else {
700        return Ok(Vec::new());
701    };
702    Ok(vec![observation(
703        "cargo_toml_workspace_package",
704        label,
705        "[workspace.package].version",
706        version,
707    )])
708}
709
710fn read_cargo_toml_workspace_dependencies(
711    path: &Path,
712    label: &str,
713    domain: &VersionDomainConfig,
714    surface: &VersionDomainSurfaceConfig,
715) -> Result<Vec<VersionDomainObservation>, String> {
716    let value = read_toml(path)?;
717    let Some(dependencies) = value
718        .get("workspace")
719        .and_then(|workspace| workspace.get("dependencies"))
720        .and_then(TomlValue::as_table)
721    else {
722        return Ok(Vec::new());
723    };
724    let mut observations = Vec::new();
725    for (name, dependency) in dependencies {
726        if !surface_package_owned(domain, surface, name) {
727            continue;
728        }
729        let version = dependency
730            .as_str()
731            .or_else(|| dependency.get("version").and_then(TomlValue::as_str));
732        if let Some(version) = version {
733            observations.push(observation(
734                "cargo_toml_workspace_dependencies",
735                label,
736                &format!("[workspace.dependencies.{name}]"),
737                version,
738            ));
739        }
740    }
741    Ok(observations)
742}
743
744fn read_cargo_lock_packages(
745    path: &Path,
746    label: &str,
747    domain: &VersionDomainConfig,
748    surface: &VersionDomainSurfaceConfig,
749) -> Result<Vec<VersionDomainObservation>, String> {
750    let value = read_toml(path)?;
751    let Some(packages) = value.get("package").and_then(TomlValue::as_array) else {
752        return Ok(Vec::new());
753    };
754    let mut observations = Vec::new();
755    for package in packages {
756        let Some(name) = package.get("name").and_then(TomlValue::as_str) else {
757            continue;
758        };
759        if !surface_package_owned(domain, surface, name) {
760            continue;
761        }
762        if let Some(version) = package.get("version").and_then(TomlValue::as_str) {
763            observations.push(observation(
764                "cargo_lock_packages",
765                label,
766                &format!("package {name}"),
767                version,
768            ));
769        }
770    }
771    Ok(observations)
772}
773
774fn read_package_json(path: &Path, label: &str) -> Result<Vec<VersionDomainObservation>, String> {
775    let content = fs::read_to_string(path)
776        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
777    let value: JsonValue = serde_json::from_str(&content)
778        .map_err(|error| format!("Failed to parse {} as JSON: {error}", path.display()))?;
779    let Some(version) = value.get("version").and_then(JsonValue::as_str) else {
780        return Ok(Vec::new());
781    };
782    Ok(vec![observation("package_json", label, "version", version)])
783}
784
785fn read_wrangler_var(
786    path: &Path,
787    label: &str,
788    surface: &VersionDomainSurfaceConfig,
789) -> Result<Vec<VersionDomainObservation>, String> {
790    let key = required_surface_key(surface)?;
791    let content = fs::read_to_string(path)
792        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
793    let value = if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
794        let value: TomlValue = content
795            .parse()
796            .map_err(|error| format!("Failed to parse {} as TOML: {error}", path.display()))?;
797        serde_json::to_value(value).map_err(|error| error.to_string())?
798    } else {
799        serde_json::from_str::<JsonValue>(&strip_json_comments(&content))
800            .map_err(|error| format!("Failed to parse {} as JSONC: {error}", path.display()))?
801    };
802    let Some(version) = value
803        .get("vars")
804        .and_then(|vars| vars.get(&key))
805        .and_then(JsonValue::as_str)
806    else {
807        return Ok(Vec::new());
808    };
809    Ok(vec![observation(
810        "wrangler_var",
811        label,
812        &format!("vars.{key}"),
813        version,
814    )])
815}
816
817fn read_keyed_literal(
818    path: &Path,
819    label: &str,
820    surface: &VersionDomainSurfaceConfig,
821) -> Result<Vec<VersionDomainObservation>, String> {
822    let key = required_surface_key(surface)?;
823    let content = fs::read_to_string(path)
824        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
825    let regex = Regex::new(&format!(r#"{}\s*:\s*"([^"]+)""#, regex::escape(&key)))
826        .map_err(|error| error.to_string())?;
827    let Some(captures) = regex.captures(&content) else {
828        return Ok(Vec::new());
829    };
830    Ok(vec![observation(
831        "generated_type_literal",
832        label,
833        &key,
834        captures.get(1).map(|m| m.as_str()).unwrap_or_default(),
835    )])
836}
837
838fn read_regex_surface(
839    path: &Path,
840    label: &str,
841    surface: &VersionDomainSurfaceConfig,
842) -> Result<Vec<VersionDomainObservation>, String> {
843    let pattern = surface
844        .marker
845        .as_deref()
846        .ok_or_else(|| format!("Surface `{}` requires marker regex.", surface.path))?;
847    let content = fs::read_to_string(path)
848        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
849    let regex = Regex::new(pattern).map_err(|error| format!("Invalid marker regex: {error}"))?;
850    let Some(captures) = regex.captures(&content) else {
851        return Ok(Vec::new());
852    };
853    let Some(version) = captures.get(1).map(|m| m.as_str()) else {
854        return Err(format!(
855            "Marker regex for {} must expose the version in capture group 1.",
856            surface.path
857        ));
858    };
859    Ok(vec![observation("regex", label, pattern, version)])
860}
861
862fn observation(kind: &str, path: &str, detail: &str, version: &str) -> VersionDomainObservation {
863    VersionDomainObservation {
864        kind: kind.to_string(),
865        path: path.to_string(),
866        detail: detail.to_string(),
867        version: version.to_string(),
868    }
869}
870
871fn required_surface_key(surface: &VersionDomainSurfaceConfig) -> Result<String, String> {
872    surface
873        .key
874        .as_ref()
875        .map(|value| value.trim().to_string())
876        .filter(|value| !value.is_empty())
877        .ok_or_else(|| {
878            format!(
879                "Surface `{}` of kind `{}` requires `key`.",
880                surface.path, surface.kind
881            )
882        })
883}
884
885fn surface_package_owned(
886    domain: &VersionDomainConfig,
887    surface: &VersionDomainSurfaceConfig,
888    package: &str,
889) -> bool {
890    if !surface.packages.is_empty() {
891        return surface.packages.iter().any(|name| name == package);
892    }
893    package_owned_by_domain(domain, package)
894}
895
896fn package_owned_by_domain(domain: &VersionDomainConfig, package: &str) -> bool {
897    domain
898        .owned_package_names
899        .iter()
900        .any(|name| name == package)
901        || domain
902            .owned_package_prefixes
903            .iter()
904            .any(|prefix| package == prefix || package.starts_with(prefix))
905}
906
907fn collect_cross_domain_ownership_issues(config: &XbpConfig) -> Vec<String> {
908    let mut by_package = BTreeMap::<String, Vec<String>>::new();
909    for domain in &config.version_domains {
910        for name in &domain.owned_package_names {
911            by_package
912                .entry(name.clone())
913                .or_default()
914                .push(domain.name.clone());
915        }
916    }
917    by_package
918        .into_iter()
919        .filter(|(_, domains)| domains.len() > 1)
920        .map(|(package, domains)| {
921            format!(
922                "Package `{package}` is owned by multiple release domains: {}.",
923                domains
924                    .into_iter()
925                    .map(|domain| format!("`{domain}`"))
926                    .collect::<Vec<_>>()
927                    .join(", ")
928            )
929        })
930        .collect()
931}
932
933fn self_consistent_wrong_version(report: &VersionDomainReport) -> Option<String> {
934    if report.observations.is_empty() {
935        return None;
936    }
937    let versions = report
938        .observations
939        .iter()
940        .map(|observation| observation.version.clone())
941        .collect::<BTreeSet<_>>();
942    let only = versions.iter().next()?.clone();
943    if versions.len() == 1 && only != report.expected_version {
944        Some(only)
945    } else {
946        None
947    }
948}
949
950fn write_domain_surfaces(
951    project_root: &Path,
952    domain: &VersionDomainConfig,
953) -> Result<usize, String> {
954    let mut updated = 0;
955    for surface in &domain.version_surfaces {
956        if write_surface(project_root, domain, surface)? {
957            updated += 1;
958        }
959    }
960    Ok(updated)
961}
962
963fn write_surface(
964    project_root: &Path,
965    domain: &VersionDomainConfig,
966    surface: &VersionDomainSurfaceConfig,
967) -> Result<bool, String> {
968    let path = PathBuf::from(&surface.path);
969    if !path.exists() {
970        return Err(format!("Configured surface not found: {}", path.display()));
971    }
972    let content = fs::read_to_string(&path)
973        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
974    let updated = match surface.kind.as_str() {
975        "cargo_toml_package" => {
976            replace_toml_assignment(&content, r#"(?m)^version\s*=\s*"[^"]*""#, &domain.version)
977        }
978        "cargo_toml_workspace_package" => {
979            replace_scoped_toml_version(&content, "[workspace.package]", &domain.version)
980        }
981        "cargo_toml_workspace_dependencies" => {
982            replace_workspace_dependency_versions(&content, domain, surface, &domain.version)?
983        }
984        "cargo_lock_packages" => {
985            replace_cargo_lock_package_versions(&content, domain, surface, &domain.version)?
986        }
987        "package_json" => replace_json_string_key(&content, "version", &domain.version)?,
988        "wrangler_var" => {
989            replace_keyed_string(&content, &required_surface_key(surface)?, &domain.version)?
990        }
991        "generated_type_literal" => {
992            replace_type_literal(&content, &required_surface_key(surface)?, &domain.version)?
993        }
994        "regex" | "readme_marker" => replace_marker_regex(&content, surface, &domain.version)?,
995        other => {
996            return Err(format!(
997                "Unsupported version surface kind `{other}` for {}.",
998                surface.path
999            ));
1000        }
1001    };
1002    if updated != content {
1003        fs::write(&path, updated).map_err(|error| {
1004            format!(
1005                "Failed to write {}: {error}",
1006                collapse_project_path(project_root, &path.to_string_lossy())
1007            )
1008        })?;
1009        return Ok(true);
1010    }
1011    Ok(false)
1012}
1013
1014fn replace_toml_assignment(content: &str, pattern: &str, version: &str) -> String {
1015    Regex::new(pattern)
1016        .expect("static regex")
1017        .replace(content, |_: &regex::Captures<'_>| {
1018            format!("version = \"{version}\"")
1019        })
1020        .to_string()
1021}
1022
1023fn replace_scoped_toml_version(content: &str, header: &str, version: &str) -> String {
1024    let mut output = String::with_capacity(content.len());
1025    let mut in_scope = false;
1026    for line in content.lines() {
1027        let trimmed = line.trim();
1028        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1029            in_scope = trimmed == header;
1030        }
1031        if in_scope && trimmed.starts_with("version") && trimmed.contains('=') {
1032            let indent = line
1033                .chars()
1034                .take_while(|ch| ch.is_whitespace())
1035                .collect::<String>();
1036            output.push_str(&format!("{indent}version = \"{version}\"\n"));
1037        } else {
1038            output.push_str(line);
1039            output.push('\n');
1040        }
1041    }
1042    output
1043}
1044
1045fn replace_workspace_dependency_versions(
1046    content: &str,
1047    domain: &VersionDomainConfig,
1048    surface: &VersionDomainSurfaceConfig,
1049    version: &str,
1050) -> Result<String, String> {
1051    let mut output = String::with_capacity(content.len());
1052    let mut in_scope = false;
1053    for line in content.lines() {
1054        let trimmed = line.trim();
1055        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1056            in_scope = trimmed == "[workspace.dependencies]";
1057        }
1058        if in_scope {
1059            let package = trimmed
1060                .split_once('=')
1061                .map(|(left, _)| left.trim().trim_matches('"').to_string());
1062            if let Some(package) = package {
1063                if surface_package_owned(domain, surface, &package) {
1064                    output.push_str(&replace_dependency_line(line, version)?);
1065                    output.push('\n');
1066                    continue;
1067                }
1068            }
1069        }
1070        output.push_str(line);
1071        output.push('\n');
1072    }
1073    Ok(output)
1074}
1075
1076fn replace_dependency_line(line: &str, version: &str) -> Result<String, String> {
1077    let string_dep = Regex::new(r#"=\s*"[^"]*""#).map_err(|error| error.to_string())?;
1078    if string_dep.is_match(line) {
1079        return Ok(string_dep
1080            .replace(line, |_: &regex::Captures<'_>| format!("= \"{version}\""))
1081            .to_string());
1082    }
1083    let object_dep = Regex::new(r#"version\s*=\s*"[^"]*""#).map_err(|error| error.to_string())?;
1084    Ok(object_dep
1085        .replace(line, |_: &regex::Captures<'_>| {
1086            format!("version = \"{version}\"")
1087        })
1088        .to_string())
1089}
1090
1091fn replace_cargo_lock_package_versions(
1092    content: &str,
1093    domain: &VersionDomainConfig,
1094    surface: &VersionDomainSurfaceConfig,
1095    version: &str,
1096) -> Result<String, String> {
1097    let mut output = String::with_capacity(content.len());
1098    let mut current_package: Option<String> = None;
1099    for line in content.lines() {
1100        let trimmed = line.trim();
1101        if trimmed == "[[package]]" {
1102            current_package = None;
1103        } else if let Some(name) = trimmed
1104            .strip_prefix("name = \"")
1105            .and_then(|rest| rest.strip_suffix('"'))
1106        {
1107            current_package = Some(name.to_string());
1108        }
1109        if trimmed.starts_with("version = \"")
1110            && current_package
1111                .as_deref()
1112                .is_some_and(|name| surface_package_owned(domain, surface, name))
1113        {
1114            let indent = line
1115                .chars()
1116                .take_while(|ch| ch.is_whitespace())
1117                .collect::<String>();
1118            output.push_str(&format!("{indent}version = \"{version}\"\n"));
1119        } else {
1120            output.push_str(line);
1121            output.push('\n');
1122        }
1123    }
1124    Ok(output)
1125}
1126
1127fn replace_json_string_key(content: &str, key: &str, version: &str) -> Result<String, String> {
1128    replace_keyed_string(content, key, version)
1129}
1130
1131fn replace_keyed_string(content: &str, key: &str, version: &str) -> Result<String, String> {
1132    let regex = Regex::new(&format!(r#""{}"\s*:\s*"[^"]*""#, regex::escape(key)))
1133        .map_err(|error| error.to_string())?;
1134    Ok(regex
1135        .replace(content, |_: &regex::Captures<'_>| {
1136            format!("\"{key}\": \"{version}\"")
1137        })
1138        .to_string())
1139}
1140
1141fn replace_type_literal(content: &str, key: &str, version: &str) -> Result<String, String> {
1142    let regex = Regex::new(&format!(r#"{}\s*:\s*"[^"]*""#, regex::escape(key)))
1143        .map_err(|error| error.to_string())?;
1144    Ok(regex
1145        .replace(content, |_: &regex::Captures<'_>| {
1146            format!("{key}: \"{version}\"")
1147        })
1148        .to_string())
1149}
1150
1151fn replace_marker_regex(
1152    content: &str,
1153    surface: &VersionDomainSurfaceConfig,
1154    version: &str,
1155) -> Result<String, String> {
1156    let pattern = surface
1157        .marker
1158        .as_deref()
1159        .ok_or_else(|| format!("Surface `{}` requires marker regex.", surface.path))?;
1160    let regex = Regex::new(pattern).map_err(|error| format!("Invalid marker regex: {error}"))?;
1161    Ok(regex
1162        .replace(content, |captures: &regex::Captures<'_>| {
1163            let full = captures.get(0).map(|m| m.as_str()).unwrap_or_default();
1164            let old = captures.get(1).map(|m| m.as_str()).unwrap_or_default();
1165            full.replacen(old, version, 1)
1166        })
1167        .to_string())
1168}
1169
1170fn parse_cargo_path_mismatch(log: &str) -> Option<CargoPathMismatchDiagnostic> {
1171    let requirement = Regex::new(
1172        r#"failed to select a version for the requirement `([^`=\s]+)\s*=\s*"\^?([^"]+)""#,
1173    )
1174    .ok()?
1175    .captures(log)?;
1176    let candidate = Regex::new(r#"candidate versions found which didn't match:\s*([^\r\n]+)"#)
1177        .ok()?
1178        .captures(log)?;
1179    let location = Regex::new(r#"location searched:\s*([^\r\n]+)"#)
1180        .ok()?
1181        .captures(log)?;
1182    let required_by = Regex::new(r#"required by package `([^`]+)`"#)
1183        .ok()?
1184        .captures(log)?;
1185    Some(CargoPathMismatchDiagnostic {
1186        dependency: requirement.get(1)?.as_str().to_string(),
1187        required_version: requirement.get(2)?.as_str().to_string(),
1188        candidate_version: candidate.get(1)?.as_str().trim().to_string(),
1189        searched_path: location.get(1)?.as_str().trim().to_string(),
1190        required_by: required_by.get(1)?.as_str().to_string(),
1191    })
1192}
1193
1194fn resolve_release_version(
1195    current: &Version,
1196    options: &VersionDomainReleaseOptions,
1197) -> Result<Version, String> {
1198    if let Some(version) = &options.version {
1199        return parse_semver(version);
1200    }
1201    if options.major {
1202        return Ok(Version::new(current.major + 1, 0, 0));
1203    }
1204    if options.minor {
1205        return Ok(Version::new(current.major, current.minor + 1, 0));
1206    }
1207    if options.patch {
1208        return Ok(Version::new(
1209            current.major,
1210            current.minor,
1211            current.patch + 1,
1212        ));
1213    }
1214    Err("Choose --patch, --minor, --major, or --version.".to_string())
1215}
1216
1217fn enforce_bump_policy(
1218    domain: &VersionDomainConfig,
1219    current: &Version,
1220    next: &Version,
1221    options: &VersionDomainReleaseOptions,
1222) -> Result<(), String> {
1223    let bump = classify_bump(current, next)?;
1224    if bump == "major" && !options.allow_major_jump {
1225        return Err(format!(
1226            "Refusing major version jump for domain `{}` from {} to {} without --allow-major-jump.",
1227            domain.name, current, next
1228        ));
1229    }
1230    if !domain.allowed_bumps.is_empty()
1231        && !domain.allowed_bumps.iter().any(|allowed| allowed == bump)
1232    {
1233        return Err(format!(
1234            "Refusing {bump} bump for domain `{}`; allowed_bumps is [{}].",
1235            domain.name,
1236            domain.allowed_bumps.join(", ")
1237        ));
1238    }
1239    Ok(())
1240}
1241
1242fn classify_bump(current: &Version, next: &Version) -> Result<&'static str, String> {
1243    if next <= current {
1244        return Err(format!(
1245            "Next version {next} must be greater than current version {current}."
1246        ));
1247    }
1248    if next.major != current.major {
1249        return Ok("major");
1250    }
1251    if next.minor != current.minor {
1252        return Ok("minor");
1253    }
1254    Ok("patch")
1255}
1256
1257fn enforce_cross_domain_policy(
1258    config: &XbpConfig,
1259    domain: &VersionDomainConfig,
1260    next: &Version,
1261    allowed_source: Option<&str>,
1262) -> Result<(), String> {
1263    for other in &config.version_domains {
1264        if other.name == domain.name || other.version != next.to_string() {
1265            continue;
1266        }
1267        if allowed_source == Some(other.name.as_str()) {
1268            return Ok(());
1269        }
1270        return Err(format!(
1271            "Refusing to align `{}` to {}: that version belongs to release domain `{}`. Pass --allow-cross-domain-version {} to record an explicit exception.",
1272            domain.name, next, other.name, other.name
1273        ));
1274    }
1275    Ok(())
1276}
1277
1278fn run_validation_commands(domain: &VersionDomainConfig) -> Result<(), String> {
1279    let root = PathBuf::from(&domain.root);
1280    for command in &domain.validation_commands {
1281        println!("validation: {command}");
1282        let status = if cfg!(windows) {
1283            Command::new("cmd")
1284                .args(["/C", command])
1285                .current_dir(&root)
1286                .stdout(Stdio::inherit())
1287                .stderr(Stdio::inherit())
1288                .status()
1289        } else {
1290            Command::new("sh")
1291                .args(["-lc", command])
1292                .current_dir(&root)
1293                .stdout(Stdio::inherit())
1294                .stderr(Stdio::inherit())
1295                .status()
1296        }
1297        .map_err(|error| format!("Failed to run validation command `{command}`: {error}"))?;
1298        if !status.success() {
1299            return Err(format!(
1300                "Validation command `{command}` failed with status {status}."
1301            ));
1302        }
1303    }
1304    Ok(())
1305}
1306
1307fn write_domain_release_ledger(
1308    project_root: &Path,
1309    domain: &VersionDomainConfig,
1310    previous: &Version,
1311    next: &Version,
1312    updated_surfaces: usize,
1313    report: &VersionDomainReport,
1314    cross_domain_exception: Option<&str>,
1315) -> Result<(), String> {
1316    let scope_slug = release_ledger_scope_slug("domain", &domain.name);
1317    let path = release_ledger_path(project_root, &scope_slug, &next.to_string());
1318    let now = Utc::now();
1319    let mut steps = BTreeMap::new();
1320    steps.insert(
1321        "domain_checks".to_string(),
1322        ReleaseLedgerStep {
1323            status: ReleaseLedgerStepStatus::Completed,
1324            started_at: Some(now),
1325            completed_at: Some(now),
1326            last_error: None,
1327            details: serde_json::json!({
1328                "domain": domain.name,
1329                "previous_version": previous.to_string(),
1330                "next_version": next.to_string(),
1331                "updated_surfaces": updated_surfaces,
1332                "cross_domain_exception": cross_domain_exception,
1333                "observations": report.observations,
1334            }),
1335        },
1336    );
1337    let ledger = ReleaseLedger {
1338        scope_kind: "domain".to_string(),
1339        scope_label: domain.name.clone(),
1340        scope_slug,
1341        version: next.to_string(),
1342        tag_name: format!("{}-{}", domain.name, next),
1343        xbp_version: super::release_ledger::current_xbp_cli_version(),
1344        branch_name: None,
1345        baseline: Default::default(),
1346        selection: Default::default(),
1347        domain: Some(ReleaseDomainLedgerRecord {
1348            name: domain.name.clone(),
1349            previous_version: previous.to_string(),
1350            next_version: next.to_string(),
1351            updated_surfaces,
1352            cross_domain_exception: cross_domain_exception.map(ToOwned::to_owned),
1353        }),
1354        cloudflare_evidence: Vec::new(),
1355        status: ReleaseLedgerStatus::Completed,
1356        steps,
1357        created_at: Some(now),
1358        updated_at: Some(now),
1359        completed_at: Some(now),
1360    };
1361    save_release_ledger(&path, &ledger)
1362}
1363
1364fn parse_semver(version: &str) -> Result<Version, String> {
1365    Version::parse(version).map_err(|error| format!("Invalid version `{version}`: {error}"))
1366}
1367
1368fn print_domain_report(report: &VersionDomainReport) {
1369    println!("Version domain: {}", report.domain);
1370    println!("Expected: {}", report.expected_version);
1371    if report.observations.is_empty() {
1372        println!("Observations: none");
1373    } else {
1374        println!("Observations:");
1375        for observation in &report.observations {
1376            println!(
1377                "- {} {} = {}",
1378                observation.kind, observation.detail, observation.version
1379            );
1380        }
1381    }
1382    for warning in &report.warnings {
1383        println!("warning: {warning}");
1384    }
1385}
1386
1387fn render_domain_failure(report: &VersionDomainReport) -> String {
1388    let mut lines = vec![format!(
1389        "Version-domain violation detected\n\nDomain: {}\nExpected: {}",
1390        report.domain, report.expected_version
1391    )];
1392    if !report.issues.is_empty() {
1393        lines.push("Evidence:".to_string());
1394        lines.extend(report.issues.iter().map(|issue| format!("- {issue}")));
1395    }
1396    lines.push("Blocked actions: refusing version sync, release, publish, or deploy until the domain is healthy.".to_string());
1397    lines.join("\n")
1398}
1399
1400fn strip_json_comments(input: &str) -> String {
1401    let mut output = String::with_capacity(input.len());
1402    let mut chars = input.chars().peekable();
1403    let mut in_string = false;
1404    let mut escaped = false;
1405    while let Some(ch) = chars.next() {
1406        if in_string {
1407            output.push(ch);
1408            if escaped {
1409                escaped = false;
1410            } else if ch == '\\' {
1411                escaped = true;
1412            } else if ch == '"' {
1413                in_string = false;
1414            }
1415            continue;
1416        }
1417        if ch == '"' {
1418            in_string = true;
1419            output.push(ch);
1420            continue;
1421        }
1422        if ch == '/' {
1423            match chars.peek().copied() {
1424                Some('/') => {
1425                    let _ = chars.next();
1426                    for next in chars.by_ref() {
1427                        if next == '\n' {
1428                            output.push('\n');
1429                            break;
1430                        }
1431                    }
1432                    continue;
1433                }
1434                Some('*') => {
1435                    let _ = chars.next();
1436                    let mut last = '\0';
1437                    for next in chars.by_ref() {
1438                        if last == '*' && next == '/' {
1439                            break;
1440                        }
1441                        last = next;
1442                    }
1443                    continue;
1444                }
1445                _ => {}
1446            }
1447        }
1448        output.push(ch);
1449    }
1450    output
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455    use super::*;
1456    use std::time::{SystemTime, UNIX_EPOCH};
1457
1458    fn temp_dir(label: &str) -> PathBuf {
1459        let nanos = SystemTime::now()
1460            .duration_since(UNIX_EPOCH)
1461            .expect("clock")
1462            .as_nanos();
1463        let dir = env::temp_dir().join(format!("xbp-version-domain-{label}-{nanos}"));
1464        fs::create_dir_all(&dir).expect("temp dir");
1465        dir
1466    }
1467
1468    #[test]
1469    fn parses_cargo_path_dependency_mismatch_without_literal_incident_paths() {
1470        let log = r#"error: failed to select a version for the requirement `demo-core-macro = "^1.2.3"`
1471candidate versions found which didn't match: 9.8.7
1472location searched: /workspace/crates/macro
1473required by package `demo-core v9.8.7 (/workspace/crates/core)`"#;
1474        let parsed = parse_cargo_path_mismatch(log).expect("diagnostic");
1475        assert_eq!(parsed.dependency, "demo-core-macro");
1476        assert_eq!(parsed.required_version, "1.2.3");
1477        assert_eq!(parsed.candidate_version, "9.8.7");
1478        assert_eq!(parsed.searched_path, "/workspace/crates/macro");
1479    }
1480
1481    #[test]
1482    fn detects_self_consistent_wrong_state() {
1483        let report = VersionDomainReport {
1484            domain: "demo".to_string(),
1485            expected_version: "1.0.0".to_string(),
1486            observations: vec![
1487                observation("package_json", "package.json", "version", "2.0.0"),
1488                observation(
1489                    "wrangler_var",
1490                    "wrangler.jsonc",
1491                    "vars.APP_VERSION",
1492                    "2.0.0",
1493                ),
1494            ],
1495            issues: Vec::new(),
1496            warnings: Vec::new(),
1497        };
1498        assert_eq!(
1499            self_consistent_wrong_version(&report).as_deref(),
1500            Some("2.0.0")
1501        );
1502    }
1503
1504    #[test]
1505    fn domain_config_round_trips_and_normalizes_paths() {
1506        let root = temp_dir("config");
1507        let service = root.join("services").join("demo");
1508        fs::create_dir_all(&service).expect("service dir");
1509        let mut config = XbpConfig {
1510            project_name: "demo".to_string(),
1511            version: "0.1.0".to_string(),
1512            port: 3000,
1513            build_dir: root.to_string_lossy().to_string(),
1514            app_type: None,
1515            build_command: None,
1516            start_command: None,
1517            install_command: None,
1518            environment: None,
1519            services: None,
1520            openapi: None,
1521            workers: None,
1522            systemd_service_name: None,
1523            systemd: None,
1524            kafka_brokers: None,
1525            kafka_topic: None,
1526            kafka_public_url: None,
1527            log_files: None,
1528            monitor_url: None,
1529            monitor_method: None,
1530            monitor_expected_code: None,
1531            monitor_interval: None,
1532            database: None,
1533            oci: None,
1534            kubernetes: None,
1535            deploy: None,
1536            target: None,
1537            branch: None,
1538            crate_name: None,
1539            npm_script: None,
1540            port_storybook: None,
1541            url: None,
1542            url_storybook: None,
1543            linear: None,
1544            github: None,
1545            publish: None,
1546            version_targets: Vec::new(),
1547            version_domains: vec![VersionDomainConfig {
1548                name: "demo".to_string(),
1549                root: service.to_string_lossy().to_string(),
1550                version: "1.2.3".to_string(),
1551                owned_package_names: vec!["demo-core".to_string()],
1552                owned_package_prefixes: vec!["demo-".to_string()],
1553                version_surfaces: vec![VersionDomainSurfaceConfig {
1554                    kind: "package_json".to_string(),
1555                    path: service.join("package.json").to_string_lossy().to_string(),
1556                    key: None,
1557                    marker: None,
1558                    packages: Vec::new(),
1559                }],
1560                allowed_bumps: vec!["patch".to_string()],
1561                validation_commands: Vec::new(),
1562                image_version_probe: None,
1563                live_health_urls: Vec::new(),
1564                cloudflare_app: Some("demo-worker".to_string()),
1565            }],
1566            ignore_paths: Vec::new(),
1567            watch_ignore_paths: Vec::new(),
1568            versioning_disabled: Vec::new(),
1569            release_disabled: Vec::new(),
1570            versioning: None,
1571            discord: None,
1572        audit: None,
1573        };
1574        normalize_config_paths_for_persistence(&mut config, &root);
1575        let yaml = serialize_xbp_yaml(&config).expect("yaml");
1576        assert!(yaml.contains("version_domains:"));
1577        assert!(yaml.contains("root: services/demo"));
1578        assert!(yaml.contains("path: services/demo/package.json"));
1579        let mut parsed: XbpConfig = serde_yaml::from_str(&yaml).expect("parse");
1580        resolve_config_paths_for_runtime(&mut parsed, &root);
1581        assert!(PathBuf::from(&parsed.version_domains[0].root).ends_with("services/demo"));
1582    }
1583
1584    #[test]
1585    fn blocks_major_jump_without_explicit_approval() {
1586        let domain = VersionDomainConfig {
1587            name: "demo".to_string(),
1588            version: "1.2.3".to_string(),
1589            allowed_bumps: vec![
1590                "major".to_string(),
1591                "minor".to_string(),
1592                "patch".to_string(),
1593            ],
1594            ..VersionDomainConfig::default()
1595        };
1596        let options = VersionDomainReleaseOptions {
1597            domain: "demo".to_string(),
1598            patch: false,
1599            minor: false,
1600            major: false,
1601            version: Some("2.0.0".to_string()),
1602            deploy: false,
1603            allow_major_jump: false,
1604            allow_cross_domain_version: None,
1605        };
1606        let error = enforce_bump_policy(
1607            &domain,
1608            &Version::parse("1.2.3").expect("current"),
1609            &Version::parse("2.0.0").expect("next"),
1610            &options,
1611        )
1612        .expect_err("blocked");
1613        assert!(error.contains("--allow-major-jump"));
1614    }
1615}