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(¤t_domain.version)?;
299 let next_version = resolve_release_version(¤t_version, &options)?;
300 enforce_bump_policy(¤t_domain, ¤t_version, &next_version, &options)?;
301 enforce_cross_domain_policy(
302 &loaded.config,
303 ¤t_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, ¤t_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(¤t_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 ¤t_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, |_: ®ex::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, |_: ®ex::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, |_: ®ex::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, |_: ®ex::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, |_: ®ex::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: ®ex::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}