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 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, |_: ®ex::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, |_: ®ex::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, |_: ®ex::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, |_: ®ex::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, |_: ®ex::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: ®ex::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}