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                    allow_unchanged_container_image: false,
367                    prune_old_images: false,
368                    keep_image_tag_count: None,
369                }),
370            },
371            false,
372        )
373        .await?;
374    }
375    Ok(())
376}
377
378fn load_domain_config(root_override: Option<&Path>) -> Result<LoadedDomainConfig, String> {
379    let current_dir =
380        env::current_dir().map_err(|error| format!("Failed to read current directory: {error}"))?;
381    let start = root_override.unwrap_or(current_dir.as_path());
382    let found = find_xbp_config_upwards(start).ok_or_else(|| {
383        format!(
384            "Could not find .xbp/xbp.yaml from {}. Run `xbp init` first.",
385            start.display()
386        )
387    })?;
388    let content = fs::read_to_string(&found.config_path)
389        .map_err(|error| format!("Failed to read {}: {error}", found.config_path.display()))?;
390    let (mut config, healed_content) =
391        parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)
392            .map_err(|error| format!("Failed to parse {}: {error}", found.config_path.display()))?;
393    if let Some(healed_content) = healed_content {
394        fs::write(&found.config_path, healed_content)
395            .map_err(|error| format!("Failed to write {}: {error}", found.config_path.display()))?;
396    }
397    resolve_config_paths_for_runtime(&mut config, &found.project_root);
398    Ok(LoadedDomainConfig {
399        project_root: found.project_root,
400        config_path: found.config_path,
401        config,
402    })
403}
404
405fn find_domain<'a>(
406    config: &'a XbpConfig,
407    domain_name: &str,
408) -> Result<&'a VersionDomainConfig, String> {
409    config
410        .version_domains
411        .iter()
412        .find(|domain| domain.name == domain_name)
413        .ok_or_else(|| {
414            let available = config
415                .version_domains
416                .iter()
417                .map(|domain| domain.name.as_str())
418                .collect::<Vec<_>>()
419                .join(", ");
420            if available.is_empty() {
421                format!("Version domain `{domain_name}` was not found. Run `xbp version domain init --name {domain_name} --root <path> --write` first.")
422            } else {
423                format!("Version domain `{domain_name}` was not found. Available domains: {available}")
424            }
425        })
426}
427
428fn scaffold_domain(
429    project_root: &Path,
430    domain_root: &Path,
431    name: &str,
432    version: &str,
433) -> VersionDomainConfig {
434    let mut version_surfaces = Vec::new();
435    let cargo_toml = domain_root.join("Cargo.toml");
436    if cargo_toml.exists() {
437        version_surfaces.push(surface(
438            project_root,
439            "cargo_toml_package",
440            &cargo_toml,
441            None,
442        ));
443        version_surfaces.push(surface(
444            project_root,
445            "cargo_toml_workspace_package",
446            &cargo_toml,
447            None,
448        ));
449        version_surfaces.push(surface(
450            project_root,
451            "cargo_toml_workspace_dependencies",
452            &cargo_toml,
453            None,
454        ));
455    }
456    let cargo_lock = domain_root.join("Cargo.lock");
457    if cargo_lock.exists() {
458        version_surfaces.push(surface(
459            project_root,
460            "cargo_lock_packages",
461            &cargo_lock,
462            None,
463        ));
464    }
465    let package_json = domain_root.join("package.json");
466    if package_json.exists() {
467        version_surfaces.push(surface(project_root, "package_json", &package_json, None));
468    }
469    let wrangler = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]
470        .into_iter()
471        .map(|name| domain_root.join(name))
472        .find(|path| path.exists());
473    if let Some(wrangler) = wrangler {
474        version_surfaces.push(surface(
475            project_root,
476            "wrangler_var",
477            &wrangler,
478            Some("APP_VERSION"),
479        ));
480    }
481
482    VersionDomainConfig {
483        name: name.to_string(),
484        root: domain_root.to_string_lossy().to_string(),
485        version: version.to_string(),
486        owned_package_names: Vec::new(),
487        owned_package_prefixes: vec![name.to_string()],
488        version_surfaces,
489        allowed_bumps: vec!["patch".to_string(), "minor".to_string()],
490        validation_commands: Vec::new(),
491        image_version_probe: None,
492        live_health_urls: Vec::new(),
493        cloudflare_app: None,
494    }
495}
496
497fn surface(
498    project_root: &Path,
499    kind: &str,
500    path: &Path,
501    key: Option<&str>,
502) -> VersionDomainSurfaceConfig {
503    VersionDomainSurfaceConfig {
504        kind: kind.to_string(),
505        path: collapse_project_path(project_root, &path.to_string_lossy()),
506        key: key.map(ToOwned::to_owned),
507        marker: None,
508        packages: Vec::new(),
509    }
510}
511
512fn normalize_domain_for_persistence(project_root: &Path, domain: &mut VersionDomainConfig) {
513    domain.root = collapse_project_path(project_root, &domain.root);
514    for surface in &mut domain.version_surfaces {
515        surface.path = collapse_project_path(project_root, &surface.path);
516    }
517}
518
519fn upsert_domain(config: &mut XbpConfig, domain: VersionDomainConfig) {
520    if let Some(existing) = config
521        .version_domains
522        .iter_mut()
523        .find(|candidate| candidate.name == domain.name)
524    {
525        *existing = domain;
526    } else {
527        config.version_domains.push(domain);
528        config
529            .version_domains
530            .sort_by(|left, right| left.name.cmp(&right.name));
531    }
532}
533
534fn write_xbp_config(
535    project_root: &Path,
536    config_path: &Path,
537    config: &mut XbpConfig,
538) -> Result<(), String> {
539    normalize_config_paths_for_persistence(config, project_root);
540    let yaml = serialize_xbp_yaml(config)?;
541    fs::write(config_path, yaml)
542        .map_err(|error| format!("Failed to write {}: {error}", config_path.display()))?;
543    resolve_config_paths_for_runtime(config, project_root);
544    Ok(())
545}
546
547fn infer_domain_version(root: &Path) -> Option<String> {
548    let cargo = root.join("Cargo.toml");
549    if cargo.exists() {
550        if let Ok(content) = fs::read_to_string(&cargo) {
551            if let Ok(value) = content.parse::<TomlValue>() {
552                if let Some(version) = value
553                    .get("package")
554                    .and_then(|package| package.get("version"))
555                    .and_then(TomlValue::as_str)
556                {
557                    return Some(version.to_string());
558                }
559                if let Some(version) = value
560                    .get("workspace")
561                    .and_then(|workspace| workspace.get("package"))
562                    .and_then(|package| package.get("version"))
563                    .and_then(TomlValue::as_str)
564                {
565                    return Some(version.to_string());
566                }
567            }
568        }
569    }
570    let package_json = root.join("package.json");
571    if package_json.exists() {
572        if let Ok(content) = fs::read_to_string(&package_json) {
573            if let Ok(value) = serde_json::from_str::<JsonValue>(&content) {
574                if let Some(version) = value.get("version").and_then(JsonValue::as_str) {
575                    return Some(version.to_string());
576                }
577            }
578        }
579    }
580    None
581}
582
583fn build_domain_report(
584    project_root: &Path,
585    config: &XbpConfig,
586    domain: &VersionDomainConfig,
587) -> Result<VersionDomainReport, String> {
588    let mut report = VersionDomainReport {
589        domain: domain.name.clone(),
590        expected_version: domain.version.clone(),
591        ..VersionDomainReport::default()
592    };
593    if parse_semver(&domain.version).is_err() {
594        report.issues.push(format!(
595            "Domain version `{}` is not valid semver.",
596            domain.version
597        ));
598    }
599
600    for surface in &domain.version_surfaces {
601        match read_surface_observations(project_root, domain, surface) {
602            Ok(mut observations) => report.observations.append(&mut observations),
603            Err(error) => report.issues.push(error),
604        }
605    }
606
607    for observation in &report.observations {
608        if observation.version != domain.version {
609            report.issues.push(format!(
610                "{} {} reports {}, expected {}.",
611                observation.kind, observation.detail, observation.version, domain.version
612            ));
613        }
614    }
615
616    for issue in collect_cross_domain_ownership_issues(config) {
617        if issue.contains(&format!("`{}`", domain.name)) {
618            report.issues.push(issue);
619        }
620    }
621
622    if domain.version_surfaces.is_empty() {
623        report
624            .warnings
625            .push("No version_surfaces are configured.".to_string());
626    }
627    Ok(report)
628}
629
630fn read_surface_observations(
631    project_root: &Path,
632    domain: &VersionDomainConfig,
633    surface: &VersionDomainSurfaceConfig,
634) -> Result<Vec<VersionDomainObservation>, String> {
635    let path = PathBuf::from(&surface.path);
636    if !path.exists() {
637        return Err(format!("Configured surface not found: {}", path.display()));
638    }
639    let path_label = collapse_project_path(project_root, &path.to_string_lossy());
640    match surface.kind.as_str() {
641        "cargo_toml_package" => read_cargo_toml_package(&path, &path_label),
642        "cargo_toml_workspace_package" => read_cargo_toml_workspace_package(&path, &path_label),
643        "cargo_toml_workspace_dependencies" => {
644            read_cargo_toml_workspace_dependencies(&path, &path_label, domain, surface)
645        }
646        "cargo_lock_packages" => read_cargo_lock_packages(&path, &path_label, domain, surface),
647        "package_json" => read_package_json(&path, &path_label),
648        "wrangler_var" => read_wrangler_var(&path, &path_label, surface),
649        "generated_type_literal" => read_keyed_literal(&path, &path_label, surface),
650        "regex" | "readme_marker" => read_regex_surface(&path, &path_label, surface),
651        other => Err(format!(
652            "Unsupported version surface kind `{other}` for {}.",
653            path.display()
654        )),
655    }
656}
657
658fn read_toml(path: &Path) -> Result<TomlValue, String> {
659    let content = fs::read_to_string(path)
660        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
661    content
662        .parse::<TomlValue>()
663        .map_err(|error| format!("Failed to parse {} as TOML: {error}", path.display()))
664}
665
666fn read_cargo_toml_package(
667    path: &Path,
668    label: &str,
669) -> Result<Vec<VersionDomainObservation>, String> {
670    let value = read_toml(path)?;
671    let Some(version) = value
672        .get("package")
673        .and_then(|package| package.get("version"))
674        .and_then(TomlValue::as_str)
675    else {
676        return Ok(Vec::new());
677    };
678    Ok(vec![observation(
679        "cargo_toml_package",
680        label,
681        "[package].version",
682        version,
683    )])
684}
685
686fn read_cargo_toml_workspace_package(
687    path: &Path,
688    label: &str,
689) -> Result<Vec<VersionDomainObservation>, String> {
690    let value = read_toml(path)?;
691    let Some(version) = value
692        .get("workspace")
693        .and_then(|workspace| workspace.get("package"))
694        .and_then(|package| package.get("version"))
695        .and_then(TomlValue::as_str)
696    else {
697        return Ok(Vec::new());
698    };
699    Ok(vec![observation(
700        "cargo_toml_workspace_package",
701        label,
702        "[workspace.package].version",
703        version,
704    )])
705}
706
707fn read_cargo_toml_workspace_dependencies(
708    path: &Path,
709    label: &str,
710    domain: &VersionDomainConfig,
711    surface: &VersionDomainSurfaceConfig,
712) -> Result<Vec<VersionDomainObservation>, String> {
713    let value = read_toml(path)?;
714    let Some(dependencies) = value
715        .get("workspace")
716        .and_then(|workspace| workspace.get("dependencies"))
717        .and_then(TomlValue::as_table)
718    else {
719        return Ok(Vec::new());
720    };
721    let mut observations = Vec::new();
722    for (name, dependency) in dependencies {
723        if !surface_package_owned(domain, surface, name) {
724            continue;
725        }
726        let version = dependency
727            .as_str()
728            .or_else(|| dependency.get("version").and_then(TomlValue::as_str));
729        if let Some(version) = version {
730            observations.push(observation(
731                "cargo_toml_workspace_dependencies",
732                label,
733                &format!("[workspace.dependencies.{name}]"),
734                version,
735            ));
736        }
737    }
738    Ok(observations)
739}
740
741fn read_cargo_lock_packages(
742    path: &Path,
743    label: &str,
744    domain: &VersionDomainConfig,
745    surface: &VersionDomainSurfaceConfig,
746) -> Result<Vec<VersionDomainObservation>, String> {
747    let value = read_toml(path)?;
748    let Some(packages) = value.get("package").and_then(TomlValue::as_array) else {
749        return Ok(Vec::new());
750    };
751    let mut observations = Vec::new();
752    for package in packages {
753        let Some(name) = package.get("name").and_then(TomlValue::as_str) else {
754            continue;
755        };
756        if !surface_package_owned(domain, surface, name) {
757            continue;
758        }
759        if let Some(version) = package.get("version").and_then(TomlValue::as_str) {
760            observations.push(observation(
761                "cargo_lock_packages",
762                label,
763                &format!("package {name}"),
764                version,
765            ));
766        }
767    }
768    Ok(observations)
769}
770
771fn read_package_json(path: &Path, label: &str) -> Result<Vec<VersionDomainObservation>, String> {
772    let content = fs::read_to_string(path)
773        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
774    let value: JsonValue = serde_json::from_str(&content)
775        .map_err(|error| format!("Failed to parse {} as JSON: {error}", path.display()))?;
776    let Some(version) = value.get("version").and_then(JsonValue::as_str) else {
777        return Ok(Vec::new());
778    };
779    Ok(vec![observation("package_json", label, "version", version)])
780}
781
782fn read_wrangler_var(
783    path: &Path,
784    label: &str,
785    surface: &VersionDomainSurfaceConfig,
786) -> Result<Vec<VersionDomainObservation>, String> {
787    let key = required_surface_key(surface)?;
788    let content = fs::read_to_string(path)
789        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
790    let value = if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
791        let value: TomlValue = content
792            .parse()
793            .map_err(|error| format!("Failed to parse {} as TOML: {error}", path.display()))?;
794        serde_json::to_value(value).map_err(|error| error.to_string())?
795    } else {
796        serde_json::from_str::<JsonValue>(&strip_json_comments(&content))
797            .map_err(|error| format!("Failed to parse {} as JSONC: {error}", path.display()))?
798    };
799    let Some(version) = value
800        .get("vars")
801        .and_then(|vars| vars.get(&key))
802        .and_then(JsonValue::as_str)
803    else {
804        return Ok(Vec::new());
805    };
806    Ok(vec![observation(
807        "wrangler_var",
808        label,
809        &format!("vars.{key}"),
810        version,
811    )])
812}
813
814fn read_keyed_literal(
815    path: &Path,
816    label: &str,
817    surface: &VersionDomainSurfaceConfig,
818) -> Result<Vec<VersionDomainObservation>, String> {
819    let key = required_surface_key(surface)?;
820    let content = fs::read_to_string(path)
821        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
822    let regex = Regex::new(&format!(r#"{}\s*:\s*"([^"]+)""#, regex::escape(&key)))
823        .map_err(|error| error.to_string())?;
824    let Some(captures) = regex.captures(&content) else {
825        return Ok(Vec::new());
826    };
827    Ok(vec![observation(
828        "generated_type_literal",
829        label,
830        &key,
831        captures.get(1).map(|m| m.as_str()).unwrap_or_default(),
832    )])
833}
834
835fn read_regex_surface(
836    path: &Path,
837    label: &str,
838    surface: &VersionDomainSurfaceConfig,
839) -> Result<Vec<VersionDomainObservation>, String> {
840    let pattern = surface
841        .marker
842        .as_deref()
843        .ok_or_else(|| format!("Surface `{}` requires marker regex.", surface.path))?;
844    let content = fs::read_to_string(path)
845        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
846    let regex = Regex::new(pattern).map_err(|error| format!("Invalid marker regex: {error}"))?;
847    let Some(captures) = regex.captures(&content) else {
848        return Ok(Vec::new());
849    };
850    let Some(version) = captures.get(1).map(|m| m.as_str()) else {
851        return Err(format!(
852            "Marker regex for {} must expose the version in capture group 1.",
853            surface.path
854        ));
855    };
856    Ok(vec![observation("regex", label, pattern, version)])
857}
858
859fn observation(kind: &str, path: &str, detail: &str, version: &str) -> VersionDomainObservation {
860    VersionDomainObservation {
861        kind: kind.to_string(),
862        path: path.to_string(),
863        detail: detail.to_string(),
864        version: version.to_string(),
865    }
866}
867
868fn required_surface_key(surface: &VersionDomainSurfaceConfig) -> Result<String, String> {
869    surface
870        .key
871        .as_ref()
872        .map(|value| value.trim().to_string())
873        .filter(|value| !value.is_empty())
874        .ok_or_else(|| {
875            format!(
876                "Surface `{}` of kind `{}` requires `key`.",
877                surface.path, surface.kind
878            )
879        })
880}
881
882fn surface_package_owned(
883    domain: &VersionDomainConfig,
884    surface: &VersionDomainSurfaceConfig,
885    package: &str,
886) -> bool {
887    if !surface.packages.is_empty() {
888        return surface.packages.iter().any(|name| name == package);
889    }
890    package_owned_by_domain(domain, package)
891}
892
893fn package_owned_by_domain(domain: &VersionDomainConfig, package: &str) -> bool {
894    domain
895        .owned_package_names
896        .iter()
897        .any(|name| name == package)
898        || domain
899            .owned_package_prefixes
900            .iter()
901            .any(|prefix| package == prefix || package.starts_with(prefix))
902}
903
904fn collect_cross_domain_ownership_issues(config: &XbpConfig) -> Vec<String> {
905    let mut by_package = BTreeMap::<String, Vec<String>>::new();
906    for domain in &config.version_domains {
907        for name in &domain.owned_package_names {
908            by_package
909                .entry(name.clone())
910                .or_default()
911                .push(domain.name.clone());
912        }
913    }
914    by_package
915        .into_iter()
916        .filter(|(_, domains)| domains.len() > 1)
917        .map(|(package, domains)| {
918            format!(
919                "Package `{package}` is owned by multiple release domains: {}.",
920                domains
921                    .into_iter()
922                    .map(|domain| format!("`{domain}`"))
923                    .collect::<Vec<_>>()
924                    .join(", ")
925            )
926        })
927        .collect()
928}
929
930fn self_consistent_wrong_version(report: &VersionDomainReport) -> Option<String> {
931    if report.observations.is_empty() {
932        return None;
933    }
934    let versions = report
935        .observations
936        .iter()
937        .map(|observation| observation.version.clone())
938        .collect::<BTreeSet<_>>();
939    let only = versions.iter().next()?.clone();
940    if versions.len() == 1 && only != report.expected_version {
941        Some(only)
942    } else {
943        None
944    }
945}
946
947fn write_domain_surfaces(
948    project_root: &Path,
949    domain: &VersionDomainConfig,
950) -> Result<usize, String> {
951    let mut updated = 0;
952    for surface in &domain.version_surfaces {
953        if write_surface(project_root, domain, surface)? {
954            updated += 1;
955        }
956    }
957    Ok(updated)
958}
959
960fn write_surface(
961    project_root: &Path,
962    domain: &VersionDomainConfig,
963    surface: &VersionDomainSurfaceConfig,
964) -> Result<bool, String> {
965    let path = PathBuf::from(&surface.path);
966    if !path.exists() {
967        return Err(format!("Configured surface not found: {}", path.display()));
968    }
969    let content = fs::read_to_string(&path)
970        .map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
971    let updated = match surface.kind.as_str() {
972        "cargo_toml_package" => {
973            replace_toml_assignment(&content, r#"(?m)^version\s*=\s*"[^"]*""#, &domain.version)
974        }
975        "cargo_toml_workspace_package" => {
976            replace_scoped_toml_version(&content, "[workspace.package]", &domain.version)
977        }
978        "cargo_toml_workspace_dependencies" => {
979            replace_workspace_dependency_versions(&content, domain, surface, &domain.version)?
980        }
981        "cargo_lock_packages" => {
982            replace_cargo_lock_package_versions(&content, domain, surface, &domain.version)?
983        }
984        "package_json" => replace_json_string_key(&content, "version", &domain.version)?,
985        "wrangler_var" => {
986            replace_keyed_string(&content, &required_surface_key(surface)?, &domain.version)?
987        }
988        "generated_type_literal" => {
989            replace_type_literal(&content, &required_surface_key(surface)?, &domain.version)?
990        }
991        "regex" | "readme_marker" => replace_marker_regex(&content, surface, &domain.version)?,
992        other => {
993            return Err(format!(
994                "Unsupported version surface kind `{other}` for {}.",
995                surface.path
996            ));
997        }
998    };
999    if updated != content {
1000        fs::write(&path, updated).map_err(|error| {
1001            format!(
1002                "Failed to write {}: {error}",
1003                collapse_project_path(project_root, &path.to_string_lossy())
1004            )
1005        })?;
1006        return Ok(true);
1007    }
1008    Ok(false)
1009}
1010
1011fn replace_toml_assignment(content: &str, pattern: &str, version: &str) -> String {
1012    Regex::new(pattern)
1013        .expect("static regex")
1014        .replace(content, |_: &regex::Captures<'_>| {
1015            format!("version = \"{version}\"")
1016        })
1017        .to_string()
1018}
1019
1020fn replace_scoped_toml_version(content: &str, header: &str, version: &str) -> String {
1021    let mut output = String::with_capacity(content.len());
1022    let mut in_scope = false;
1023    for line in content.lines() {
1024        let trimmed = line.trim();
1025        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1026            in_scope = trimmed == header;
1027        }
1028        if in_scope && trimmed.starts_with("version") && trimmed.contains('=') {
1029            let indent = line
1030                .chars()
1031                .take_while(|ch| ch.is_whitespace())
1032                .collect::<String>();
1033            output.push_str(&format!("{indent}version = \"{version}\"\n"));
1034        } else {
1035            output.push_str(line);
1036            output.push('\n');
1037        }
1038    }
1039    output
1040}
1041
1042fn replace_workspace_dependency_versions(
1043    content: &str,
1044    domain: &VersionDomainConfig,
1045    surface: &VersionDomainSurfaceConfig,
1046    version: &str,
1047) -> Result<String, String> {
1048    let mut output = String::with_capacity(content.len());
1049    let mut in_scope = false;
1050    for line in content.lines() {
1051        let trimmed = line.trim();
1052        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1053            in_scope = trimmed == "[workspace.dependencies]";
1054        }
1055        if in_scope {
1056            let package = trimmed
1057                .split_once('=')
1058                .map(|(left, _)| left.trim().trim_matches('"').to_string());
1059            if let Some(package) = package {
1060                if surface_package_owned(domain, surface, &package) {
1061                    output.push_str(&replace_dependency_line(line, version)?);
1062                    output.push('\n');
1063                    continue;
1064                }
1065            }
1066        }
1067        output.push_str(line);
1068        output.push('\n');
1069    }
1070    Ok(output)
1071}
1072
1073fn replace_dependency_line(line: &str, version: &str) -> Result<String, String> {
1074    let string_dep = Regex::new(r#"=\s*"[^"]*""#).map_err(|error| error.to_string())?;
1075    if string_dep.is_match(line) {
1076        return Ok(string_dep
1077            .replace(line, |_: &regex::Captures<'_>| format!("= \"{version}\""))
1078            .to_string());
1079    }
1080    let object_dep = Regex::new(r#"version\s*=\s*"[^"]*""#).map_err(|error| error.to_string())?;
1081    Ok(object_dep
1082        .replace(line, |_: &regex::Captures<'_>| {
1083            format!("version = \"{version}\"")
1084        })
1085        .to_string())
1086}
1087
1088fn replace_cargo_lock_package_versions(
1089    content: &str,
1090    domain: &VersionDomainConfig,
1091    surface: &VersionDomainSurfaceConfig,
1092    version: &str,
1093) -> Result<String, String> {
1094    let mut output = String::with_capacity(content.len());
1095    let mut current_package: Option<String> = None;
1096    for line in content.lines() {
1097        let trimmed = line.trim();
1098        if trimmed == "[[package]]" {
1099            current_package = None;
1100        } else if let Some(name) = trimmed
1101            .strip_prefix("name = \"")
1102            .and_then(|rest| rest.strip_suffix('"'))
1103        {
1104            current_package = Some(name.to_string());
1105        }
1106        if trimmed.starts_with("version = \"")
1107            && current_package
1108                .as_deref()
1109                .is_some_and(|name| surface_package_owned(domain, surface, name))
1110        {
1111            let indent = line
1112                .chars()
1113                .take_while(|ch| ch.is_whitespace())
1114                .collect::<String>();
1115            output.push_str(&format!("{indent}version = \"{version}\"\n"));
1116        } else {
1117            output.push_str(line);
1118            output.push('\n');
1119        }
1120    }
1121    Ok(output)
1122}
1123
1124fn replace_json_string_key(content: &str, key: &str, version: &str) -> Result<String, String> {
1125    replace_keyed_string(content, key, version)
1126}
1127
1128fn replace_keyed_string(content: &str, key: &str, version: &str) -> Result<String, String> {
1129    let regex = Regex::new(&format!(r#""{}"\s*:\s*"[^"]*""#, regex::escape(key)))
1130        .map_err(|error| error.to_string())?;
1131    Ok(regex
1132        .replace(content, |_: &regex::Captures<'_>| {
1133            format!("\"{key}\": \"{version}\"")
1134        })
1135        .to_string())
1136}
1137
1138fn replace_type_literal(content: &str, key: &str, version: &str) -> Result<String, String> {
1139    let regex = Regex::new(&format!(r#"{}\s*:\s*"[^"]*""#, regex::escape(key)))
1140        .map_err(|error| error.to_string())?;
1141    Ok(regex
1142        .replace(content, |_: &regex::Captures<'_>| {
1143            format!("{key}: \"{version}\"")
1144        })
1145        .to_string())
1146}
1147
1148fn replace_marker_regex(
1149    content: &str,
1150    surface: &VersionDomainSurfaceConfig,
1151    version: &str,
1152) -> Result<String, String> {
1153    let pattern = surface
1154        .marker
1155        .as_deref()
1156        .ok_or_else(|| format!("Surface `{}` requires marker regex.", surface.path))?;
1157    let regex = Regex::new(pattern).map_err(|error| format!("Invalid marker regex: {error}"))?;
1158    Ok(regex
1159        .replace(content, |captures: &regex::Captures<'_>| {
1160            let full = captures.get(0).map(|m| m.as_str()).unwrap_or_default();
1161            let old = captures.get(1).map(|m| m.as_str()).unwrap_or_default();
1162            full.replacen(old, version, 1)
1163        })
1164        .to_string())
1165}
1166
1167fn parse_cargo_path_mismatch(log: &str) -> Option<CargoPathMismatchDiagnostic> {
1168    let requirement = Regex::new(
1169        r#"failed to select a version for the requirement `([^`=\s]+)\s*=\s*"\^?([^"]+)""#,
1170    )
1171    .ok()?
1172    .captures(log)?;
1173    let candidate = Regex::new(r#"candidate versions found which didn't match:\s*([^\r\n]+)"#)
1174        .ok()?
1175        .captures(log)?;
1176    let location = Regex::new(r#"location searched:\s*([^\r\n]+)"#)
1177        .ok()?
1178        .captures(log)?;
1179    let required_by = Regex::new(r#"required by package `([^`]+)`"#)
1180        .ok()?
1181        .captures(log)?;
1182    Some(CargoPathMismatchDiagnostic {
1183        dependency: requirement.get(1)?.as_str().to_string(),
1184        required_version: requirement.get(2)?.as_str().to_string(),
1185        candidate_version: candidate.get(1)?.as_str().trim().to_string(),
1186        searched_path: location.get(1)?.as_str().trim().to_string(),
1187        required_by: required_by.get(1)?.as_str().to_string(),
1188    })
1189}
1190
1191fn resolve_release_version(
1192    current: &Version,
1193    options: &VersionDomainReleaseOptions,
1194) -> Result<Version, String> {
1195    if let Some(version) = &options.version {
1196        return parse_semver(version);
1197    }
1198    if options.major {
1199        return Ok(Version::new(current.major + 1, 0, 0));
1200    }
1201    if options.minor {
1202        return Ok(Version::new(current.major, current.minor + 1, 0));
1203    }
1204    if options.patch {
1205        return Ok(Version::new(
1206            current.major,
1207            current.minor,
1208            current.patch + 1,
1209        ));
1210    }
1211    Err("Choose --patch, --minor, --major, or --version.".to_string())
1212}
1213
1214fn enforce_bump_policy(
1215    domain: &VersionDomainConfig,
1216    current: &Version,
1217    next: &Version,
1218    options: &VersionDomainReleaseOptions,
1219) -> Result<(), String> {
1220    let bump = classify_bump(current, next)?;
1221    if bump == "major" && !options.allow_major_jump {
1222        return Err(format!(
1223            "Refusing major version jump for domain `{}` from {} to {} without --allow-major-jump.",
1224            domain.name, current, next
1225        ));
1226    }
1227    if !domain.allowed_bumps.is_empty()
1228        && !domain.allowed_bumps.iter().any(|allowed| allowed == bump)
1229    {
1230        return Err(format!(
1231            "Refusing {bump} bump for domain `{}`; allowed_bumps is [{}].",
1232            domain.name,
1233            domain.allowed_bumps.join(", ")
1234        ));
1235    }
1236    Ok(())
1237}
1238
1239fn classify_bump(current: &Version, next: &Version) -> Result<&'static str, String> {
1240    if next <= current {
1241        return Err(format!(
1242            "Next version {next} must be greater than current version {current}."
1243        ));
1244    }
1245    if next.major != current.major {
1246        return Ok("major");
1247    }
1248    if next.minor != current.minor {
1249        return Ok("minor");
1250    }
1251    Ok("patch")
1252}
1253
1254fn enforce_cross_domain_policy(
1255    config: &XbpConfig,
1256    domain: &VersionDomainConfig,
1257    next: &Version,
1258    allowed_source: Option<&str>,
1259) -> Result<(), String> {
1260    for other in &config.version_domains {
1261        if other.name == domain.name || other.version != next.to_string() {
1262            continue;
1263        }
1264        if allowed_source == Some(other.name.as_str()) {
1265            return Ok(());
1266        }
1267        return Err(format!(
1268            "Refusing to align `{}` to {}: that version belongs to release domain `{}`. Pass --allow-cross-domain-version {} to record an explicit exception.",
1269            domain.name, next, other.name, other.name
1270        ));
1271    }
1272    Ok(())
1273}
1274
1275fn run_validation_commands(domain: &VersionDomainConfig) -> Result<(), String> {
1276    let root = PathBuf::from(&domain.root);
1277    for command in &domain.validation_commands {
1278        println!("validation: {command}");
1279        let status = if cfg!(windows) {
1280            Command::new("cmd")
1281                .args(["/C", command])
1282                .current_dir(&root)
1283                .stdout(Stdio::inherit())
1284                .stderr(Stdio::inherit())
1285                .status()
1286        } else {
1287            Command::new("sh")
1288                .args(["-lc", command])
1289                .current_dir(&root)
1290                .stdout(Stdio::inherit())
1291                .stderr(Stdio::inherit())
1292                .status()
1293        }
1294        .map_err(|error| format!("Failed to run validation command `{command}`: {error}"))?;
1295        if !status.success() {
1296            return Err(format!(
1297                "Validation command `{command}` failed with status {status}."
1298            ));
1299        }
1300    }
1301    Ok(())
1302}
1303
1304fn write_domain_release_ledger(
1305    project_root: &Path,
1306    domain: &VersionDomainConfig,
1307    previous: &Version,
1308    next: &Version,
1309    updated_surfaces: usize,
1310    report: &VersionDomainReport,
1311    cross_domain_exception: Option<&str>,
1312) -> Result<(), String> {
1313    let scope_slug = release_ledger_scope_slug("domain", &domain.name);
1314    let path = release_ledger_path(project_root, &scope_slug, &next.to_string());
1315    let now = Utc::now();
1316    let mut steps = BTreeMap::new();
1317    steps.insert(
1318        "domain_checks".to_string(),
1319        ReleaseLedgerStep {
1320            status: ReleaseLedgerStepStatus::Completed,
1321            started_at: Some(now),
1322            completed_at: Some(now),
1323            last_error: None,
1324            details: serde_json::json!({
1325                "domain": domain.name,
1326                "previous_version": previous.to_string(),
1327                "next_version": next.to_string(),
1328                "updated_surfaces": updated_surfaces,
1329                "cross_domain_exception": cross_domain_exception,
1330                "observations": report.observations,
1331            }),
1332        },
1333    );
1334    let ledger = ReleaseLedger {
1335        scope_kind: "domain".to_string(),
1336        scope_label: domain.name.clone(),
1337        scope_slug,
1338        version: next.to_string(),
1339        tag_name: format!("{}-{}", domain.name, next),
1340        branch_name: None,
1341        baseline: Default::default(),
1342        selection: Default::default(),
1343        domain: Some(ReleaseDomainLedgerRecord {
1344            name: domain.name.clone(),
1345            previous_version: previous.to_string(),
1346            next_version: next.to_string(),
1347            updated_surfaces,
1348            cross_domain_exception: cross_domain_exception.map(ToOwned::to_owned),
1349        }),
1350        cloudflare_evidence: Vec::new(),
1351        status: ReleaseLedgerStatus::Completed,
1352        steps,
1353        created_at: Some(now),
1354        updated_at: Some(now),
1355        completed_at: Some(now),
1356    };
1357    save_release_ledger(&path, &ledger)
1358}
1359
1360fn parse_semver(version: &str) -> Result<Version, String> {
1361    Version::parse(version).map_err(|error| format!("Invalid version `{version}`: {error}"))
1362}
1363
1364fn print_domain_report(report: &VersionDomainReport) {
1365    println!("Version domain: {}", report.domain);
1366    println!("Expected: {}", report.expected_version);
1367    if report.observations.is_empty() {
1368        println!("Observations: none");
1369    } else {
1370        println!("Observations:");
1371        for observation in &report.observations {
1372            println!(
1373                "- {} {} = {}",
1374                observation.kind, observation.detail, observation.version
1375            );
1376        }
1377    }
1378    for warning in &report.warnings {
1379        println!("warning: {warning}");
1380    }
1381}
1382
1383fn render_domain_failure(report: &VersionDomainReport) -> String {
1384    let mut lines = vec![format!(
1385        "Version-domain violation detected\n\nDomain: {}\nExpected: {}",
1386        report.domain, report.expected_version
1387    )];
1388    if !report.issues.is_empty() {
1389        lines.push("Evidence:".to_string());
1390        lines.extend(report.issues.iter().map(|issue| format!("- {issue}")));
1391    }
1392    lines.push("Blocked actions: refusing version sync, release, publish, or deploy until the domain is healthy.".to_string());
1393    lines.join("\n")
1394}
1395
1396fn strip_json_comments(input: &str) -> String {
1397    let mut output = String::with_capacity(input.len());
1398    let mut chars = input.chars().peekable();
1399    let mut in_string = false;
1400    let mut escaped = false;
1401    while let Some(ch) = chars.next() {
1402        if in_string {
1403            output.push(ch);
1404            if escaped {
1405                escaped = false;
1406            } else if ch == '\\' {
1407                escaped = true;
1408            } else if ch == '"' {
1409                in_string = false;
1410            }
1411            continue;
1412        }
1413        if ch == '"' {
1414            in_string = true;
1415            output.push(ch);
1416            continue;
1417        }
1418        if ch == '/' {
1419            match chars.peek().copied() {
1420                Some('/') => {
1421                    let _ = chars.next();
1422                    for next in chars.by_ref() {
1423                        if next == '\n' {
1424                            output.push('\n');
1425                            break;
1426                        }
1427                    }
1428                    continue;
1429                }
1430                Some('*') => {
1431                    let _ = chars.next();
1432                    let mut last = '\0';
1433                    for next in chars.by_ref() {
1434                        if last == '*' && next == '/' {
1435                            break;
1436                        }
1437                        last = next;
1438                    }
1439                    continue;
1440                }
1441                _ => {}
1442            }
1443        }
1444        output.push(ch);
1445    }
1446    output
1447}
1448
1449#[cfg(test)]
1450mod tests {
1451    use super::*;
1452    use std::time::{SystemTime, UNIX_EPOCH};
1453
1454    fn temp_dir(label: &str) -> PathBuf {
1455        let nanos = SystemTime::now()
1456            .duration_since(UNIX_EPOCH)
1457            .expect("clock")
1458            .as_nanos();
1459        let dir = env::temp_dir().join(format!("xbp-version-domain-{label}-{nanos}"));
1460        fs::create_dir_all(&dir).expect("temp dir");
1461        dir
1462    }
1463
1464    #[test]
1465    fn parses_cargo_path_dependency_mismatch_without_literal_incident_paths() {
1466        let log = r#"error: failed to select a version for the requirement `demo-core-macro = "^1.2.3"`
1467candidate versions found which didn't match: 9.8.7
1468location searched: /workspace/crates/macro
1469required by package `demo-core v9.8.7 (/workspace/crates/core)`"#;
1470        let parsed = parse_cargo_path_mismatch(log).expect("diagnostic");
1471        assert_eq!(parsed.dependency, "demo-core-macro");
1472        assert_eq!(parsed.required_version, "1.2.3");
1473        assert_eq!(parsed.candidate_version, "9.8.7");
1474        assert_eq!(parsed.searched_path, "/workspace/crates/macro");
1475    }
1476
1477    #[test]
1478    fn detects_self_consistent_wrong_state() {
1479        let report = VersionDomainReport {
1480            domain: "demo".to_string(),
1481            expected_version: "1.0.0".to_string(),
1482            observations: vec![
1483                observation("package_json", "package.json", "version", "2.0.0"),
1484                observation(
1485                    "wrangler_var",
1486                    "wrangler.jsonc",
1487                    "vars.APP_VERSION",
1488                    "2.0.0",
1489                ),
1490            ],
1491            issues: Vec::new(),
1492            warnings: Vec::new(),
1493        };
1494        assert_eq!(
1495            self_consistent_wrong_version(&report).as_deref(),
1496            Some("2.0.0")
1497        );
1498    }
1499
1500    #[test]
1501    fn domain_config_round_trips_and_normalizes_paths() {
1502        let root = temp_dir("config");
1503        let service = root.join("services").join("demo");
1504        fs::create_dir_all(&service).expect("service dir");
1505        let mut config = XbpConfig {
1506            project_name: "demo".to_string(),
1507            version: "0.1.0".to_string(),
1508            port: 3000,
1509            build_dir: root.to_string_lossy().to_string(),
1510            app_type: None,
1511            build_command: None,
1512            start_command: None,
1513            install_command: None,
1514            environment: None,
1515            services: None,
1516            openapi: None,
1517            workers: None,
1518            systemd_service_name: None,
1519            systemd: None,
1520            kafka_brokers: None,
1521            kafka_topic: None,
1522            kafka_public_url: None,
1523            log_files: None,
1524            monitor_url: None,
1525            monitor_method: None,
1526            monitor_expected_code: None,
1527            monitor_interval: None,
1528            database: None,
1529            oci: None,
1530            kubernetes: None,
1531            deploy: None,
1532            target: None,
1533            branch: None,
1534            crate_name: None,
1535            npm_script: None,
1536            port_storybook: None,
1537            url: None,
1538            url_storybook: None,
1539            linear: None,
1540            github: None,
1541            publish: None,
1542            version_targets: Vec::new(),
1543            version_domains: vec![VersionDomainConfig {
1544                name: "demo".to_string(),
1545                root: service.to_string_lossy().to_string(),
1546                version: "1.2.3".to_string(),
1547                owned_package_names: vec!["demo-core".to_string()],
1548                owned_package_prefixes: vec!["demo-".to_string()],
1549                version_surfaces: vec![VersionDomainSurfaceConfig {
1550                    kind: "package_json".to_string(),
1551                    path: service.join("package.json").to_string_lossy().to_string(),
1552                    key: None,
1553                    marker: None,
1554                    packages: Vec::new(),
1555                }],
1556                allowed_bumps: vec!["patch".to_string()],
1557                validation_commands: Vec::new(),
1558                image_version_probe: None,
1559                live_health_urls: Vec::new(),
1560                cloudflare_app: Some("demo-worker".to_string()),
1561            }],
1562            ignore_paths: Vec::new(),
1563            watch_ignore_paths: Vec::new(),
1564            versioning_disabled: Vec::new(),
1565            release_disabled: Vec::new(),
1566            versioning: None,
1567            discord: None,
1568        };
1569        normalize_config_paths_for_persistence(&mut config, &root);
1570        let yaml = serialize_xbp_yaml(&config).expect("yaml");
1571        assert!(yaml.contains("version_domains:"));
1572        assert!(yaml.contains("root: services/demo"));
1573        assert!(yaml.contains("path: services/demo/package.json"));
1574        let mut parsed: XbpConfig = serde_yaml::from_str(&yaml).expect("parse");
1575        resolve_config_paths_for_runtime(&mut parsed, &root);
1576        assert!(PathBuf::from(&parsed.version_domains[0].root).ends_with("services/demo"));
1577    }
1578
1579    #[test]
1580    fn blocks_major_jump_without_explicit_approval() {
1581        let domain = VersionDomainConfig {
1582            name: "demo".to_string(),
1583            version: "1.2.3".to_string(),
1584            allowed_bumps: vec![
1585                "major".to_string(),
1586                "minor".to_string(),
1587                "patch".to_string(),
1588            ],
1589            ..VersionDomainConfig::default()
1590        };
1591        let options = VersionDomainReleaseOptions {
1592            domain: "demo".to_string(),
1593            patch: false,
1594            minor: false,
1595            major: false,
1596            version: Some("2.0.0".to_string()),
1597            deploy: false,
1598            allow_major_jump: false,
1599            allow_cross_domain_version: None,
1600        };
1601        let error = enforce_bump_policy(
1602            &domain,
1603            &Version::parse("1.2.3").expect("current"),
1604            &Version::parse("2.0.0").expect("next"),
1605            &options,
1606        )
1607        .expect_err("blocked");
1608        assert!(error.contains("--allow-major-jump"));
1609    }
1610}