1use super::{
2 git_worktree_state, parse_version, read_cargo_lock_version_for_package,
3 read_regex_version_from_content, read_version_from_path, resolve_project_root,
4 write_cargo_lock_version_for_package, write_version_to_path, GitWorktreeState,
5};
6use crate::utils::{cargo_command_exists, cargo_program, resolve_cargo_package_version};
7use semver::Version;
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, BTreeSet};
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::thread;
14use std::time::Duration as StdDuration;
15use tokio::time::{sleep, Duration, Instant};
16use toml::Value as TomlValue;
17use toml_edit::{value, DocumentMut, Item, Table, Value};
18
19const DEFAULT_METADATA_FILES: &[&str] = &[
20 "README.md",
21 "openapi.yaml",
22 "openapi.yml",
23 "openapi.json",
24 "swagger.yaml",
25 "swagger.yml",
26 "swagger.json",
27];
28const CONFIG_CANDIDATES: &[&str] = &[".xbp/workspace-release.yaml", ".xbp/workspace-release.yml"];
29
30#[derive(Debug, Clone)]
31pub struct WorkspaceVersionCommandOptions {
32 pub repo: Option<PathBuf>,
33 pub json: bool,
34 pub command: WorkspaceVersionCommand,
35}
36
37#[derive(Debug, Clone)]
38pub enum WorkspaceVersionCommand {
39 Check(WorkspaceVersionCheckOptions),
40 Sync(WorkspaceVersionSyncOptions),
41 Validate(WorkspaceVersionValidateOptions),
42 PublishPlan(WorkspacePublishPlanOptions),
43 PublishRun(WorkspacePublishRunOptions),
44}
45
46#[derive(Debug, Clone)]
47pub struct WorkspaceVersionCheckOptions {
48 pub version: Option<String>,
49}
50
51#[derive(Debug, Clone)]
52pub struct WorkspaceVersionSyncOptions {
53 pub version: Option<String>,
54 pub write: bool,
55}
56
57#[derive(Debug, Clone)]
58pub struct WorkspaceVersionValidateOptions {
59 pub package: Option<String>,
60 pub cargo_check: bool,
61 pub package_dry_run: bool,
62}
63
64#[derive(Debug, Clone)]
65pub struct WorkspacePublishPlanOptions {
66 pub only: Option<String>,
67 pub include_prereqs: bool,
68}
69
70#[derive(Debug, Clone)]
71pub struct WorkspacePublishRunOptions {
72 pub dry_run: bool,
73 pub from: Option<String>,
74 pub only: Option<String>,
75 pub include_prereqs: bool,
76 pub continue_on_error: bool,
77 pub allow_dirty: bool,
78 pub timeout_seconds: f64,
79 pub poll_interval_seconds: f64,
80}
81
82#[derive(Debug, Default, Clone, Deserialize)]
83struct WorkspaceReleaseConfig {
84 #[serde(default)]
85 version_coupled_manifests: Vec<String>,
86 #[serde(default)]
87 metadata_files: Vec<String>,
88 #[serde(default)]
89 publish: WorkspaceReleasePublishConfig,
90}
91
92#[derive(Debug, Default, Clone, Deserialize)]
93struct WorkspaceReleasePublishConfig {
94 #[serde(default)]
95 exclude: Vec<String>,
96 #[serde(default)]
97 order: Vec<String>,
98}
99
100#[derive(Debug, Clone)]
101struct ReleaseSurface {
102 repo_root: PathBuf,
103 config_path: Option<PathBuf>,
104 config: WorkspaceReleaseConfig,
105 packages: Vec<ReleasePackage>,
106 metadata_files: Vec<MetadataVersionFile>,
107 cargo_lock: Option<PathBuf>,
108 root_package_name: Option<String>,
109}
110
111#[derive(Debug, Clone)]
112struct ReleasePackage {
113 name: String,
114 manifest_path: PathBuf,
115 manifest_relative: String,
116 version: Version,
117 publishable: bool,
118 publish_excluded: bool,
119 dependency_pins: Vec<LocalDependencyPin>,
120 publish_internal_dependencies: Vec<String>,
121 publish_missing_version_pins: Vec<String>,
122}
123
124#[derive(Debug, Clone)]
125struct LocalDependencyPin {
126 field: String,
127 version: Option<String>,
128}
129
130#[derive(Debug, Clone)]
131struct MetadataVersionFile {
132 path: PathBuf,
133 relative: String,
134}
135
136#[derive(Debug, Clone, Serialize)]
137struct DriftEntry {
138 path: String,
139 field: String,
140 actual: Option<String>,
141 expected: String,
142}
143
144#[derive(Debug, Clone, Serialize)]
145struct SyncEdit {
146 path: String,
147 field: String,
148 before: Option<String>,
149 after: String,
150}
151
152#[derive(Debug, Clone, Serialize)]
153pub(crate) struct PublishPlanItem {
154 pub package: String,
155 pub manifest: String,
156 pub version: String,
157 pub publishable: bool,
158 pub crates_io_visible: Option<bool>,
159 pub publish_needed: bool,
160 pub blocked_by: Vec<String>,
161 pub reason: String,
162}
163
164#[derive(Debug, Clone, Serialize)]
165struct ValidationCommandResult {
166 command: String,
167 success: bool,
168 exit_code: Option<i32>,
169 stderr: String,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 warning: Option<String>,
172}
173
174#[derive(Debug, Clone, Serialize)]
175struct WorkspaceCheckReport {
176 repo_root: String,
177 expected_version: String,
178 aligned: bool,
179 drift: Vec<DriftEntry>,
180}
181
182#[derive(Debug, Clone, Serialize)]
183struct WorkspaceSyncReport {
184 repo_root: String,
185 expected_version: String,
186 write: bool,
187 changed: bool,
188 files_changed: Vec<String>,
189 edits: Vec<SyncEdit>,
190}
191
192#[derive(Debug, Clone, Serialize)]
193struct WorkspacePublishPlanReport {
194 repo_root: String,
195 requested_package: Option<String>,
196 included_prereqs: Vec<String>,
197 required_closure: Vec<String>,
198 packages: Vec<PublishPlanItem>,
199 publish_order: Vec<String>,
200}
201
202#[derive(Debug, Clone, Serialize)]
203struct WorkspaceValidateReport {
204 repo_root: String,
205 ok: bool,
206 issues: Vec<DriftEntry>,
207 commands: Vec<ValidationCommandResult>,
208}
209
210#[derive(Debug, Clone, Serialize)]
211struct WorkspacePublishRunReport {
212 repo_root: String,
213 dry_run: bool,
214 requested_package: Option<String>,
215 included_prereqs: Vec<String>,
216 required_closure: Vec<String>,
217 published: Vec<String>,
218 skipped: Vec<String>,
219 failed: Vec<String>,
220}
221
222#[derive(Debug, Clone, Serialize)]
223pub(crate) struct WorkspacePublishCommandTarget {
224 pub package: String,
225 pub version: String,
226 pub manifest_path: PathBuf,
227 pub manifest_relative: String,
228}
229
230#[derive(Debug, Clone, Serialize)]
231pub(crate) struct ManifestWorkspacePublishResolution {
232 pub workspace_root: PathBuf,
233 pub requested_package: String,
234 pub included_prereqs: Vec<String>,
235 pub required_closure: Vec<String>,
236 pub packages: Vec<PublishPlanItem>,
237 pub publish_order: Vec<WorkspacePublishCommandTarget>,
238}
239
240#[derive(Debug, Deserialize)]
241struct CargoMetadata {
242 packages: Vec<CargoMetadataPackage>,
243 workspace_members: Vec<String>,
244 workspace_root: String,
245}
246
247#[derive(Debug, Deserialize)]
248struct CargoMetadataPackage {
249 id: String,
250 manifest_path: String,
251}
252
253pub async fn run_version_workspace_command(
254 options: WorkspaceVersionCommandOptions,
255) -> Result<(), String> {
256 let repo_root = match options.repo {
257 Some(path) => path,
258 None => resolve_project_root(),
259 };
260 let surface = discover_release_surface(&repo_root)?;
261
262 match options.command {
263 WorkspaceVersionCommand::Check(check) => {
264 let expected = resolve_expected_version(&surface, check.version.as_deref())?;
265 let drift = collect_drift(&surface, &expected)?;
266 let report = WorkspaceCheckReport {
267 repo_root: display_path(&surface.repo_root),
268 expected_version: expected.to_string(),
269 aligned: drift.is_empty(),
270 drift,
271 };
272 if options.json {
273 println!(
274 "{}",
275 serde_json::to_string_pretty(&report)
276 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
277 );
278 } else {
279 print_check_report(&surface, &report);
280 }
281 if report.aligned {
282 Ok(())
283 } else {
284 Err("Workspace release drift detected.".to_string())
285 }
286 }
287 WorkspaceVersionCommand::Sync(sync) => {
288 let expected = resolve_expected_version(&surface, sync.version.as_deref())?;
289 let (edits, changed_files) = apply_sync(&surface, &expected, sync.write)?;
290 let report = WorkspaceSyncReport {
291 repo_root: display_path(&surface.repo_root),
292 expected_version: expected.to_string(),
293 write: sync.write,
294 changed: !edits.is_empty(),
295 files_changed: changed_files,
296 edits,
297 };
298 if options.json {
299 println!(
300 "{}",
301 serde_json::to_string_pretty(&report)
302 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
303 );
304 } else {
305 print_sync_report(&surface, &report);
306 }
307 Ok(())
308 }
309 WorkspaceVersionCommand::Validate(validate) => {
310 let report = run_validation(&surface, &validate)?;
311 if options.json {
312 println!(
313 "{}",
314 serde_json::to_string_pretty(&report)
315 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
316 );
317 } else {
318 print_validation_report(&surface, &report);
319 }
320 if report.ok {
321 Ok(())
322 } else {
323 Err("Workspace validation failed.".to_string())
324 }
325 }
326 WorkspaceVersionCommand::PublishPlan(plan) => {
327 let report = build_publish_plan_report(&surface, &plan).await?;
328 if options.json {
329 println!(
330 "{}",
331 serde_json::to_string_pretty(&report)
332 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
333 );
334 } else {
335 print_publish_plan_report(&surface, &report);
336 }
337 Ok(())
338 }
339 WorkspaceVersionCommand::PublishRun(run) => {
340 let report = run_publish(&surface, &run).await?;
341 if options.json {
342 println!(
343 "{}",
344 serde_json::to_string_pretty(&report)
345 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
346 );
347 } else {
348 print_publish_run_report(&surface, &report);
349 }
350 if report.failed.is_empty() {
351 Ok(())
352 } else {
353 Err("Workspace publish run failed.".to_string())
354 }
355 }
356 }
357}
358
359fn discover_release_surface(repo_root: &Path) -> Result<ReleaseSurface, String> {
360 let metadata = load_cargo_metadata(repo_root)?;
361 discover_release_surface_from_metadata(metadata)
362}
363
364fn discover_release_surface_from_manifest(manifest_path: &Path) -> Result<ReleaseSurface, String> {
365 let metadata = load_cargo_metadata_for_manifest(manifest_path)?;
366 discover_release_surface_from_metadata(metadata)
367}
368
369fn discover_release_surface_from_metadata(
370 metadata: CargoMetadata,
371) -> Result<ReleaseSurface, String> {
372 let repo_root = PathBuf::from(&metadata.workspace_root);
373 let config_path = CONFIG_CANDIDATES
374 .iter()
375 .map(|candidate| repo_root.join(candidate))
376 .find(|path| path.exists());
377 let config = load_workspace_release_config(config_path.as_deref())?;
378
379 let workspace_member_ids: BTreeSet<String> = metadata.workspace_members.into_iter().collect();
380 let mut candidate_manifests = Vec::new();
381 for package in metadata.packages {
382 if !workspace_member_ids.contains(&package.id) {
383 continue;
384 }
385 candidate_manifests.push(PathBuf::from(package.manifest_path));
386 }
387 for relative in &config.version_coupled_manifests {
388 let manifest = repo_root.join(relative);
389 if !candidate_manifests
390 .iter()
391 .any(|existing| existing == &manifest)
392 {
393 candidate_manifests.push(manifest);
394 }
395 }
396
397 let mut basic_packages = Vec::new();
398 for manifest_path in candidate_manifests {
399 basic_packages.push(read_basic_manifest_info(&manifest_path, &config)?);
400 }
401 basic_packages.sort_by(|a, b| a.name.cmp(&b.name));
402
403 let release_names: BTreeSet<String> = basic_packages
404 .iter()
405 .map(|package| package.name.clone())
406 .collect();
407 let mut packages = Vec::new();
408 for basic in basic_packages {
409 packages.push(read_release_package(&repo_root, basic, &release_names)?);
410 }
411
412 let root_manifest = repo_root.join("Cargo.toml");
413 let root_package_name = packages
414 .iter()
415 .find(|package| package.manifest_path == root_manifest)
416 .map(|package| package.name.clone());
417
418 let mut seen = BTreeSet::new();
419 let mut metadata_files = Vec::new();
420 for relative in DEFAULT_METADATA_FILES
421 .iter()
422 .copied()
423 .chain(config.metadata_files.iter().map(String::as_str))
424 {
425 if !seen.insert(relative.to_string()) {
426 continue;
427 }
428 let path = repo_root.join(relative);
429 if !path.exists() {
430 continue;
431 }
432 if read_metadata_version(&path)?.is_some() {
433 metadata_files.push(MetadataVersionFile {
434 relative: normalize_relative(&repo_root, &path),
435 path,
436 });
437 }
438 }
439
440 let cargo_lock = repo_root.join("Cargo.lock");
441 Ok(ReleaseSurface {
442 repo_root,
443 config_path,
444 config,
445 packages,
446 metadata_files,
447 cargo_lock: cargo_lock.exists().then_some(cargo_lock),
448 root_package_name,
449 })
450}
451
452#[derive(Debug, Clone)]
453struct BasicManifestInfo {
454 name: String,
455 manifest_path: PathBuf,
456 publishable: bool,
457 publish_excluded: bool,
458}
459
460fn read_basic_manifest_info(
461 manifest_path: &Path,
462 config: &WorkspaceReleaseConfig,
463) -> Result<BasicManifestInfo, String> {
464 let content = fs::read_to_string(manifest_path)
465 .map_err(|e| format!("Failed to read {}: {}", manifest_path.display(), e))?;
466 let value: TomlValue =
467 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
468 let package = value
469 .get("package")
470 .and_then(TomlValue::as_table)
471 .ok_or_else(|| format!("Expected [package] in {}", manifest_path.display()))?;
472 let name = package
473 .get("name")
474 .and_then(TomlValue::as_str)
475 .ok_or_else(|| format!("Missing package.name in {}", manifest_path.display()))?
476 .to_string();
477 let publishable = match package.get("publish") {
478 Some(TomlValue::Boolean(false)) => false,
479 Some(TomlValue::Array(values)) if values.is_empty() => false,
480 _ => true,
481 };
482 Ok(BasicManifestInfo {
483 name: name.clone(),
484 manifest_path: manifest_path.to_path_buf(),
485 publishable,
486 publish_excluded: config.publish.exclude.iter().any(|value| value == &name),
487 })
488}
489
490fn read_release_package(
491 repo_root: &Path,
492 basic: BasicManifestInfo,
493 release_names: &BTreeSet<String>,
494) -> Result<ReleasePackage, String> {
495 let content = fs::read_to_string(&basic.manifest_path)
496 .map_err(|e| format!("Failed to read {}: {}", basic.manifest_path.display(), e))?;
497 let value: TomlValue =
498 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
499 let version = resolve_cargo_package_version(&basic.manifest_path)?
500 .ok_or_else(|| {
501 format!(
502 "Missing package.version in {}",
503 basic.manifest_path.display()
504 )
505 })
506 .and_then(|value| parse_version(&value))?;
507 let dependency_analysis = analyze_local_dependencies_from_toml(&value, release_names);
508
509 Ok(ReleasePackage {
510 name: basic.name,
511 manifest_path: basic.manifest_path.clone(),
512 manifest_relative: normalize_relative(repo_root, &basic.manifest_path),
513 version,
514 publishable: basic.publishable,
515 publish_excluded: basic.publish_excluded,
516 dependency_pins: dependency_analysis.dependency_pins,
517 publish_internal_dependencies: dependency_analysis.publish_internal_dependencies,
518 publish_missing_version_pins: dependency_analysis.publish_missing_version_pins,
519 })
520}
521
522#[derive(Debug, Default)]
523struct DependencyAnalysis {
524 dependency_pins: Vec<LocalDependencyPin>,
525 publish_internal_dependencies: Vec<String>,
526 publish_missing_version_pins: Vec<String>,
527}
528
529fn analyze_local_dependencies_from_toml(
530 value: &TomlValue,
531 release_names: &BTreeSet<String>,
532) -> DependencyAnalysis {
533 let mut pins = Vec::new();
534 let mut publish_internal_dependencies = BTreeSet::new();
535 let mut publish_missing_version_pins = Vec::new();
536 collect_dependency_pins_from_table(
537 value,
538 "",
539 release_names,
540 &mut pins,
541 &mut publish_internal_dependencies,
542 &mut publish_missing_version_pins,
543 );
544 pins.sort_by(|a, b| a.field.cmp(&b.field));
545 publish_missing_version_pins.sort();
546 DependencyAnalysis {
547 dependency_pins: pins,
548 publish_internal_dependencies: publish_internal_dependencies.into_iter().collect(),
549 publish_missing_version_pins,
550 }
551}
552
553fn collect_dependency_pins_from_table(
554 value: &TomlValue,
555 prefix: &str,
556 release_names: &BTreeSet<String>,
557 pins: &mut Vec<LocalDependencyPin>,
558 publish_internal_dependencies: &mut BTreeSet<String>,
559 publish_missing_version_pins: &mut Vec<String>,
560) {
561 let Some(table) = value.as_table() else {
562 return;
563 };
564
565 for (key, entry) in table {
566 let path = if prefix.is_empty() {
567 key.to_string()
568 } else {
569 format!("{}.{}", prefix, key)
570 };
571 if matches!(
572 key.as_str(),
573 "dependencies" | "dev-dependencies" | "build-dependencies"
574 ) {
575 let publish_relevant = !matches!(key.as_str(), "dev-dependencies");
576 collect_dependency_section(
577 path.as_str(),
578 entry,
579 release_names,
580 publish_relevant,
581 pins,
582 publish_internal_dependencies,
583 publish_missing_version_pins,
584 );
585 continue;
586 }
587 collect_dependency_pins_from_table(
588 entry,
589 &path,
590 release_names,
591 pins,
592 publish_internal_dependencies,
593 publish_missing_version_pins,
594 );
595 }
596}
597
598fn collect_dependency_section(
599 section_name: &str,
600 value: &TomlValue,
601 release_names: &BTreeSet<String>,
602 publish_relevant: bool,
603 pins: &mut Vec<LocalDependencyPin>,
604 publish_internal_dependencies: &mut BTreeSet<String>,
605 publish_missing_version_pins: &mut Vec<String>,
606) {
607 let Some(table) = value.as_table() else {
608 return;
609 };
610 for (dependency_name, dependency_value) in table {
611 let Some(release_dependency_name) = resolve_release_dependency_name(
612 dependency_name,
613 dependency_value.as_table(),
614 release_names,
615 ) else {
616 continue;
617 };
618 let Some(detail) = dependency_value.as_table() else {
619 continue;
620 };
621 let uses_workspace = detail
622 .get("workspace")
623 .and_then(TomlValue::as_bool)
624 .unwrap_or(false);
625 let uses_path = detail.contains_key("path");
626 if !uses_path && !uses_workspace {
627 continue;
628 }
629 if publish_relevant {
630 publish_internal_dependencies.insert(release_dependency_name);
631 }
632 if !uses_path {
633 continue;
634 }
635 let field = format!("{}.{}.version", section_name, dependency_name);
636 let version = detail
637 .get("version")
638 .and_then(TomlValue::as_str)
639 .map(|value| value.to_string());
640 if publish_relevant && version.is_none() {
641 publish_missing_version_pins.push(field.clone());
642 }
643 pins.push(LocalDependencyPin { field, version });
644 }
645}
646
647fn resolve_release_dependency_name(
648 dependency_name: &str,
649 detail: Option<&toml::map::Map<String, TomlValue>>,
650 release_names: &BTreeSet<String>,
651) -> Option<String> {
652 if release_names.contains(dependency_name) {
653 return Some(dependency_name.to_string());
654 }
655
656 let aliased_package_name = detail
657 .and_then(|detail| detail.get("package"))
658 .and_then(TomlValue::as_str)?;
659 if release_names.contains(aliased_package_name) {
660 return Some(aliased_package_name.to_string());
661 }
662
663 None
664}
665
666fn collect_drift(surface: &ReleaseSurface, expected: &Version) -> Result<Vec<DriftEntry>, String> {
667 let expected_text = expected.to_string();
668 let mut drift = Vec::new();
669
670 for package in &surface.packages {
671 if package.version != *expected {
672 drift.push(DriftEntry {
673 path: package.manifest_relative.clone(),
674 field: "package.version".to_string(),
675 actual: Some(package.version.to_string()),
676 expected: expected_text.clone(),
677 });
678 }
679 for pin in &package.dependency_pins {
680 if pin.version.as_deref() != Some(expected_text.as_str()) {
681 drift.push(DriftEntry {
682 path: package.manifest_relative.clone(),
683 field: pin.field.clone(),
684 actual: pin.version.clone(),
685 expected: expected_text.clone(),
686 });
687 }
688 }
689 }
690
691 for metadata in &surface.metadata_files {
692 let actual = read_metadata_version(&metadata.path)?;
693 if actual.as_deref() != Some(expected_text.as_str()) {
694 drift.push(DriftEntry {
695 path: metadata.relative.clone(),
696 field: "version".to_string(),
697 actual,
698 expected: expected_text.clone(),
699 });
700 }
701 }
702
703 if let Some(cargo_lock) = surface.cargo_lock.as_ref() {
704 for package in &surface.packages {
705 let actual = read_cargo_lock_version_for_package(cargo_lock, &package.name)?;
706 if let Some(actual_version) = actual {
707 if actual_version != expected_text {
708 drift.push(DriftEntry {
709 path: "Cargo.lock".to_string(),
710 field: format!("package.{}.version", package.name),
711 actual: Some(actual_version),
712 expected: expected_text.clone(),
713 });
714 }
715 }
716 }
717 }
718
719 drift.sort_by(|a, b| a.path.cmp(&b.path).then(a.field.cmp(&b.field)));
720 Ok(drift)
721}
722
723fn apply_sync(
724 surface: &ReleaseSurface,
725 expected: &Version,
726 write: bool,
727) -> Result<(Vec<SyncEdit>, Vec<String>), String> {
728 let release_names: BTreeSet<String> = surface
729 .packages
730 .iter()
731 .map(|package| package.name.clone())
732 .collect();
733 let mut edits = Vec::new();
734 let mut changed_files = BTreeSet::new();
735 let expected_text = expected.to_string();
736
737 for package in &surface.packages {
738 let file_edits = sync_manifest_versions(package, &release_names, &expected_text, write)?;
739 if !file_edits.is_empty() {
740 changed_files.insert(package.manifest_relative.clone());
741 edits.extend(file_edits);
742 }
743 }
744
745 for metadata in &surface.metadata_files {
746 let actual = read_metadata_version(&metadata.path)?;
747 if actual.as_deref() == Some(expected_text.as_str()) {
748 continue;
749 }
750 if write {
751 write_metadata_version(&metadata.path, expected)?;
752 }
753 changed_files.insert(metadata.relative.clone());
754 edits.push(SyncEdit {
755 path: metadata.relative.clone(),
756 field: "version".to_string(),
757 before: actual,
758 after: expected_text.clone(),
759 });
760 }
761
762 if let Some(cargo_lock) = surface.cargo_lock.as_ref() {
763 for package in &surface.packages {
764 let actual = read_cargo_lock_version_for_package(cargo_lock, &package.name)?;
765 if actual.as_deref() == Some(expected_text.as_str()) {
766 continue;
767 }
768 if actual.is_none() {
769 continue;
770 }
771 if write {
772 write_cargo_lock_version_for_package(cargo_lock, Some(&package.name), expected)?;
773 }
774 changed_files.insert("Cargo.lock".to_string());
775 edits.push(SyncEdit {
776 path: "Cargo.lock".to_string(),
777 field: format!("package.{}.version", package.name),
778 before: actual,
779 after: expected_text.clone(),
780 });
781 }
782 }
783
784 let changed_files = changed_files.into_iter().collect::<Vec<_>>();
785 Ok((edits, changed_files))
786}
787
788fn sync_manifest_versions(
789 package: &ReleasePackage,
790 release_names: &BTreeSet<String>,
791 expected: &str,
792 write: bool,
793) -> Result<Vec<SyncEdit>, String> {
794 let content = fs::read_to_string(&package.manifest_path)
795 .map_err(|e| format!("Failed to read {}: {}", package.manifest_path.display(), e))?;
796 let mut doc = content
797 .parse::<DocumentMut>()
798 .map_err(|e| format!("Failed to parse {}: {}", package.manifest_path.display(), e))?;
799 let mut edits = Vec::new();
800
801 let current_package_version = doc["package"]["version"].as_str().map(str::to_string);
802 if current_package_version.as_deref() != Some(expected) {
803 edits.push(SyncEdit {
804 path: package.manifest_relative.clone(),
805 field: "package.version".to_string(),
806 before: current_package_version,
807 after: expected.to_string(),
808 });
809 if write {
810 doc["package"]["version"] = value(expected);
811 }
812 }
813
814 sync_dependency_tables_in_item(
815 doc.as_item_mut(),
816 "",
817 release_names,
818 expected,
819 &package.manifest_relative,
820 &mut edits,
821 );
822
823 if write && !edits.is_empty() {
824 fs::write(&package.manifest_path, doc.to_string())
825 .map_err(|e| format!("Failed to write {}: {}", package.manifest_path.display(), e))?;
826 }
827
828 Ok(edits)
829}
830
831fn sync_dependency_tables_in_item(
832 item: &mut Item,
833 prefix: &str,
834 release_names: &BTreeSet<String>,
835 expected: &str,
836 manifest_relative: &str,
837 edits: &mut Vec<SyncEdit>,
838) {
839 let Some(table) = item.as_table_mut() else {
840 return;
841 };
842
843 let keys = table
844 .iter()
845 .map(|(key, _)| key.to_string())
846 .collect::<Vec<_>>();
847 for key in keys {
848 let next_prefix = if prefix.is_empty() {
849 key.clone()
850 } else {
851 format!("{}.{}", prefix, key)
852 };
853 let Some(child) = table.get_mut(&key) else {
854 continue;
855 };
856 if matches!(
857 key.as_str(),
858 "dependencies" | "dev-dependencies" | "build-dependencies"
859 ) {
860 if let Some(dep_table) = child.as_table_mut() {
861 sync_dependency_entries(
862 dep_table,
863 &next_prefix,
864 release_names,
865 expected,
866 manifest_relative,
867 edits,
868 );
869 }
870 continue;
871 }
872 sync_dependency_tables_in_item(
873 child,
874 &next_prefix,
875 release_names,
876 expected,
877 manifest_relative,
878 edits,
879 );
880 }
881}
882
883fn sync_dependency_entries(
884 table: &mut Table,
885 section_name: &str,
886 release_names: &BTreeSet<String>,
887 expected: &str,
888 manifest_relative: &str,
889 edits: &mut Vec<SyncEdit>,
890) {
891 let keys = table
892 .iter()
893 .map(|(key, _)| key.to_string())
894 .collect::<Vec<_>>();
895 for dependency_name in keys {
896 let Some(item) = table.get_mut(&dependency_name) else {
897 continue;
898 };
899 if !item_matches_release_dependency(&dependency_name, item, release_names) {
900 continue;
901 }
902 match item {
903 Item::Value(value_item) => {
904 let Some(inline) = value_item.as_inline_table_mut() else {
905 continue;
906 };
907 if inline.get("path").is_none() {
908 continue;
909 }
910 let before = inline
911 .get("version")
912 .and_then(Value::as_str)
913 .map(|value| value.to_string());
914 if before.as_deref() == Some(expected) {
915 continue;
916 }
917 inline.insert("version", Value::from(expected));
918 edits.push(SyncEdit {
919 path: manifest_relative.to_string(),
920 field: format!("{}.{}.version", section_name, dependency_name),
921 before,
922 after: expected.to_string(),
923 });
924 }
925 Item::Table(dep_table) => {
926 if dep_table.get("path").is_none() {
927 continue;
928 }
929 let before = dep_table
930 .get("version")
931 .and_then(Item::as_str)
932 .map(|value| value.to_string());
933 if before.as_deref() == Some(expected) {
934 continue;
935 }
936 dep_table["version"] = value(expected);
937 edits.push(SyncEdit {
938 path: manifest_relative.to_string(),
939 field: format!("{}.{}.version", section_name, dependency_name),
940 before,
941 after: expected.to_string(),
942 });
943 }
944 _ => {}
945 }
946 }
947}
948
949fn item_matches_release_dependency(
950 dependency_name: &str,
951 item: &Item,
952 release_names: &BTreeSet<String>,
953) -> bool {
954 if release_names.contains(dependency_name) {
955 return true;
956 }
957
958 let aliased_package_name = match item {
959 Item::Value(value_item) => value_item
960 .as_inline_table()
961 .and_then(|table| table.get("package"))
962 .and_then(Value::as_str),
963 Item::Table(table) => table.get("package").and_then(Item::as_str),
964 _ => None,
965 };
966
967 aliased_package_name
968 .map(|package_name| release_names.contains(package_name))
969 .unwrap_or(false)
970}
971
972fn run_validation(
973 surface: &ReleaseSurface,
974 options: &WorkspaceVersionValidateOptions,
975) -> Result<WorkspaceValidateReport, String> {
976 let mut issues = match resolve_expected_version(surface, None) {
977 Ok(expected) => collect_drift(surface, &expected)?,
978 Err(error) if error.contains("Workspace root has no package.version") => Vec::new(),
979 Err(error) => return Err(error),
980 };
981 if let Some(package_name) = options.package.as_deref() {
982 issues.retain(|issue| {
983 issue.path == "Cargo.lock"
984 || issue.path.ends_with("README.md")
985 || issue.path.ends_with("openapi.yaml")
986 || issue.path.ends_with("openapi.yml")
987 || issue.path.ends_with("openapi.json")
988 || issue.field.contains(package_name)
989 || issue.path.contains(package_name)
990 });
991 }
992
993 let mut commands = Vec::new();
994 if options.cargo_check {
995 let mut command = Command::new(cargo_program());
996 command
997 .current_dir(&surface.repo_root)
998 .arg("check")
999 .arg("-q");
1000 if let Some(package) = options.package.as_deref() {
1001 command.arg("-p").arg(package);
1002 }
1003 commands.push(run_command_capture(command, "cargo check -q")?);
1004 }
1005
1006 if options.package_dry_run {
1007 commands.extend(run_package_dry_run_validation(
1008 surface,
1009 options.package.as_deref(),
1010 )?);
1011 }
1012
1013 let ok = issues.is_empty() && commands.iter().all(|result| result.success);
1014 Ok(WorkspaceValidateReport {
1015 repo_root: display_path(&surface.repo_root),
1016 ok,
1017 issues,
1018 commands,
1019 })
1020}
1021
1022fn select_packages_for_validation<'a>(
1023 surface: &'a ReleaseSurface,
1024 package_name: Option<&str>,
1025) -> Result<Vec<&'a ReleasePackage>, String> {
1026 if let Some(package_name) = package_name {
1027 let package = surface
1028 .packages
1029 .iter()
1030 .find(|package| package.name == package_name)
1031 .ok_or_else(|| format!("Unknown workspace package `{}`.", package_name))?;
1032 return Ok(vec![package]);
1033 }
1034 Ok(surface
1035 .packages
1036 .iter()
1037 .filter(|package| package.publishable && !package.publish_excluded)
1038 .collect())
1039}
1040
1041fn select_ordered_packages_for_validation<'a>(
1042 surface: &'a ReleaseSurface,
1043 package_name: Option<&str>,
1044) -> Result<Vec<&'a ReleasePackage>, String> {
1045 let selected = select_packages_for_validation(surface, package_name)?;
1046 let selected_names: BTreeSet<String> = selected
1047 .iter()
1048 .map(|package| package.name.clone())
1049 .collect();
1050 let ordered_names = topological_package_order(surface)?;
1051 Ok(ordered_names
1052 .into_iter()
1053 .filter(|name| selected_names.contains(name))
1054 .filter_map(|name| {
1055 selected
1056 .iter()
1057 .find(|package| package.name == name)
1058 .copied()
1059 })
1060 .collect())
1061}
1062
1063fn run_package_dry_run_validation(
1064 surface: &ReleaseSurface,
1065 package_name: Option<&str>,
1066) -> Result<Vec<ValidationCommandResult>, String> {
1067 let packages = select_ordered_packages_for_validation(surface, package_name)?;
1068 let mut results = Vec::new();
1069
1070 for package in packages {
1071 let label = format!(
1072 "cargo publish --dry-run --locked --no-verify --manifest-path {}",
1073 package.manifest_relative
1074 );
1075 let repo_root = surface.repo_root.clone();
1076 let manifest_path = package.manifest_path.clone();
1077 let mut result = run_publish_dry_run_capture(&repo_root, &manifest_path, &label)?;
1078 if !result.success {
1079 if is_unpublished_workspace_dependency_error(&result.stderr) {
1080 let fallback_label = format!(
1081 "cargo package --allow-dirty --no-verify --manifest-path {}",
1082 package.manifest_relative
1083 );
1084 let fallback_result =
1085 run_package_capture(&repo_root, &manifest_path, &fallback_label)?;
1086 if fallback_result.success {
1087 result = ValidationCommandResult {
1088 command: fallback_label,
1089 success: true,
1090 exit_code: fallback_result.exit_code,
1091 stderr: fallback_result.stderr,
1092 warning: Some(format!(
1093 "Registry dry-run skipped for `{}`: workspace dependencies are not on crates.io yet. Local packaging succeeded.",
1094 package.name
1095 )),
1096 };
1097 } else {
1098 result.stderr = append_validation_hint(&result.stderr);
1099 }
1100 } else {
1101 result.stderr = append_validation_hint(&result.stderr);
1102 }
1103 }
1104 results.push(result);
1105 }
1106
1107 Ok(results)
1108}
1109
1110fn is_unpublished_workspace_dependency_error(stderr: &str) -> bool {
1111 stderr.contains("no matching package named")
1112 || stderr.contains("failed to select a version for the requirement")
1113}
1114
1115fn is_transient_registry_error(stderr: &str) -> bool {
1116 stderr.contains("Could not resolve host")
1117 || stderr.contains("Could not resolve hostname")
1118 || stderr.contains("failed to update registry")
1119 || stderr.contains("download of config.json failed")
1120}
1121
1122fn append_validation_hint(stderr: &str) -> String {
1123 let mut message = stderr.to_string();
1124 if stderr.contains("Access is denied") || stderr.contains("os error 5") {
1125 message.push_str(
1126 "\nHint: close any running `xbp` processes before package dry-run validation on Windows.",
1127 );
1128 }
1129 if is_transient_registry_error(stderr) {
1130 message
1131 .push_str("\nHint: crates.io was unreachable. Check network connectivity and retry.");
1132 }
1133 if is_unpublished_workspace_dependency_error(stderr) {
1134 message.push_str(
1135 "\nHint: publish workspace dependencies first, or rely on the local `cargo package` fallback.",
1136 );
1137 }
1138 message
1139}
1140
1141fn run_publish_dry_run_capture(
1142 repo_root: &Path,
1143 manifest_path: &Path,
1144 label: &str,
1145) -> Result<ValidationCommandResult, String> {
1146 let build = || {
1147 let mut command = Command::new(cargo_program());
1148 command
1149 .current_dir(repo_root)
1150 .arg("publish")
1151 .arg("--dry-run")
1152 .arg("--locked")
1153 .arg("--no-verify")
1154 .arg("--manifest-path")
1155 .arg(manifest_path);
1156 command
1157 };
1158 run_command_capture_with_retry(build, label)
1159}
1160
1161fn run_package_capture(
1162 repo_root: &Path,
1163 manifest_path: &Path,
1164 label: &str,
1165) -> Result<ValidationCommandResult, String> {
1166 let build = || {
1167 let mut command = Command::new(cargo_program());
1168 command
1169 .current_dir(repo_root)
1170 .arg("package")
1171 .arg("--allow-dirty")
1172 .arg("--no-verify")
1173 .arg("--manifest-path")
1174 .arg(manifest_path);
1175 command
1176 };
1177 run_command_capture_with_retry(build, label)
1178}
1179
1180fn run_command_capture_with_retry(
1181 build_command: impl Fn() -> Command,
1182 label: &str,
1183) -> Result<ValidationCommandResult, String> {
1184 let first = run_command_capture(build_command(), label)?;
1185 if first.success || !is_transient_registry_error(&first.stderr) {
1186 return Ok(first);
1187 }
1188
1189 thread::sleep(StdDuration::from_secs(2));
1190 let mut retry = run_command_capture(build_command(), format!("{label} (retry)"))?;
1191 if !retry.success {
1192 retry.stderr = append_validation_hint(&retry.stderr);
1193 }
1194 Ok(retry)
1195}
1196
1197async fn build_publish_plan_report(
1198 surface: &ReleaseSurface,
1199 options: &WorkspacePublishPlanOptions,
1200) -> Result<WorkspacePublishPlanReport, String> {
1201 let visibility = collect_crates_io_visibility(surface).await?;
1202 let plan = build_publish_plan(
1203 surface,
1204 &visibility,
1205 Some(&PublishSelection {
1206 from: None,
1207 only: options.only.clone(),
1208 include_prereqs: options.include_prereqs,
1209 }),
1210 )?;
1211 Ok(WorkspacePublishPlanReport {
1212 repo_root: display_path(&surface.repo_root),
1213 requested_package: plan.requested_package,
1214 included_prereqs: plan.included_prereqs,
1215 required_closure: plan.required_closure,
1216 packages: plan.items,
1217 publish_order: plan.publish_order,
1218 })
1219}
1220
1221#[derive(Debug, Clone)]
1222pub(crate) struct WorkspaceVersionDriftSummary {
1223 pub expected_version: Version,
1224 pub drift_count: usize,
1225 pub preview: Vec<String>,
1226}
1227
1228pub(crate) fn inspect_workspace_version_drift(
1229 repo_root: &Path,
1230 expected_version: &Version,
1231) -> Result<Option<WorkspaceVersionDriftSummary>, String> {
1232 let surface = match discover_release_surface(repo_root) {
1233 Ok(surface) => surface,
1234 Err(_) => return Ok(None),
1235 };
1236 if surface.repo_root != repo_root {
1237 return Ok(None);
1238 }
1239 if surface.packages.len() <= 1 && surface.config_path.is_none() {
1240 return Ok(None);
1241 }
1242
1243 let drift = collect_drift(&surface, expected_version)?;
1244 if drift.is_empty() {
1245 return Ok(None);
1246 }
1247
1248 let preview = drift
1249 .iter()
1250 .take(6)
1251 .map(|entry| {
1252 format!(
1253 "{} {}: {} -> {}",
1254 entry.path,
1255 entry.field,
1256 entry.actual.as_deref().unwrap_or("<missing>"),
1257 entry.expected
1258 )
1259 })
1260 .collect();
1261
1262 Ok(Some(WorkspaceVersionDriftSummary {
1263 expected_version: expected_version.clone(),
1264 drift_count: drift.len(),
1265 preview,
1266 }))
1267}
1268
1269pub(crate) fn sync_workspace_to_version(
1270 repo_root: &Path,
1271 expected_version: &Version,
1272) -> Result<Vec<String>, String> {
1273 let surface = discover_release_surface(repo_root)?;
1274 let (_edits, changed_files) = apply_sync(&surface, expected_version, true)?;
1275 Ok(changed_files)
1276}
1277
1278pub(crate) async fn resolve_manifest_workspace_publish(
1279 manifest_path: &Path,
1280 include_prereqs: bool,
1281) -> Result<ManifestWorkspacePublishResolution, String> {
1282 let surface = discover_release_surface_from_manifest(manifest_path)?;
1283 let package = surface
1284 .packages
1285 .iter()
1286 .find(|package| same_path(&package.manifest_path, manifest_path))
1287 .ok_or_else(|| {
1288 format!(
1289 "Manifest {} is not a publishable package in the resolved Cargo workspace.",
1290 manifest_path.display()
1291 )
1292 })?;
1293 let visibility = collect_crates_io_visibility(&surface).await?;
1294 let plan = build_publish_plan(
1295 &surface,
1296 &visibility,
1297 Some(&PublishSelection {
1298 from: None,
1299 only: Some(package.name.clone()),
1300 include_prereqs,
1301 }),
1302 )?;
1303 Ok(ManifestWorkspacePublishResolution {
1304 workspace_root: surface.repo_root.clone(),
1305 requested_package: package.name.clone(),
1306 included_prereqs: plan.included_prereqs,
1307 required_closure: plan.required_closure,
1308 packages: plan.items,
1309 publish_order: build_publish_command_targets(&surface, &plan.publish_order)?,
1310 })
1311}
1312
1313async fn run_publish(
1314 surface: &ReleaseSurface,
1315 options: &WorkspacePublishRunOptions,
1316) -> Result<WorkspacePublishRunReport, String> {
1317 if options.only.is_some() && options.from.is_some() {
1318 return Err("`--only` cannot be combined with `--from`.".to_string());
1319 }
1320 if !options.allow_dirty {
1321 enforce_clean_worktree(&surface.repo_root)?;
1322 }
1323
1324 let validation = run_validation(
1325 surface,
1326 &WorkspaceVersionValidateOptions {
1327 package: options.only.clone(),
1328 cargo_check: false,
1329 package_dry_run: false,
1330 },
1331 )?;
1332 if !validation.ok {
1333 return Err("Workspace has structural publish blockers. Run `xbp version workspace validate` for details.".to_string());
1334 }
1335
1336 let visibility = collect_crates_io_visibility(surface).await?;
1337 let selection = PublishSelection {
1338 from: options.from.clone(),
1339 only: options.only.clone(),
1340 include_prereqs: options.include_prereqs,
1341 };
1342 let plan = build_publish_plan(surface, &visibility, Some(&selection))?;
1343 let blockers = collect_publish_blockers(&plan.items);
1344 if !blockers.is_empty() {
1345 return Err(render_workspace_publish_blockers(
1346 surface,
1347 &plan,
1348 Some(options),
1349 &blockers,
1350 ));
1351 }
1352
1353 let mut item_by_name = BTreeMap::new();
1354 for item in &plan.items {
1355 item_by_name.insert(item.package.clone(), item.clone());
1356 }
1357
1358 let mut published = Vec::new();
1359 let mut skipped = Vec::new();
1360 let mut failed = Vec::new();
1361 let publish_order = plan.publish_order.clone();
1362 let total_in_order = publish_order.len();
1363 for (index, package_name) in publish_order.iter().enumerate() {
1364 let Some(item) = item_by_name.get(package_name) else {
1365 continue;
1366 };
1367 if !item.publish_needed {
1368 skipped.push(format!("{} {}", item.package, item.reason));
1369 continue;
1370 }
1371 if !item.blocked_by.is_empty() {
1372 let message = format!("{} blocked by {}", item.package, item.blocked_by.join(", "));
1373 failed.push(message.clone());
1374 if !options.continue_on_error {
1375 break;
1376 }
1377 continue;
1378 }
1379
1380 let package = surface
1381 .packages
1382 .iter()
1383 .find(|package| package.name == item.package)
1384 .ok_or_else(|| format!("Missing package `{}` in release surface.", item.package))?;
1385 let cargo_publish = format!(
1386 "cargo publish --locked --manifest-path {}{}",
1387 package.manifest_relative,
1388 if options.allow_dirty {
1389 " --allow-dirty"
1390 } else {
1391 ""
1392 }
1393 );
1394 println!("{}", cargo_publish);
1395 if options.dry_run {
1396 published.push(format!("{} (dry-run)", item.package));
1397 continue;
1398 }
1399
1400 let status = Command::new(cargo_program())
1401 .current_dir(&surface.repo_root)
1402 .arg("publish")
1403 .arg("--locked")
1404 .arg("--manifest-path")
1405 .arg(&package.manifest_path)
1406 .args(options.allow_dirty.then_some("--allow-dirty"))
1407 .status()
1408 .map_err(|e| {
1409 format!(
1410 "Failed to execute cargo publish for {}: {}",
1411 item.package, e
1412 )
1413 })?;
1414 if !status.success() {
1415 let message = format!(
1416 "{} publish failed with exit code {:?}",
1417 item.package,
1418 status.code()
1419 );
1420 failed.push(message.clone());
1421 if !options.continue_on_error {
1422 break;
1423 }
1424 continue;
1425 }
1426
1427 let is_last = index + 1 == total_in_order;
1430 wait_for_crates_io_visibility(
1431 &item.package,
1432 &item.version,
1433 options.timeout_seconds,
1434 options.poll_interval_seconds,
1435 is_last,
1436 )
1437 .await?;
1438 published.push(item.package.clone());
1439 }
1440
1441 Ok(WorkspacePublishRunReport {
1442 repo_root: display_path(&surface.repo_root),
1443 dry_run: options.dry_run,
1444 requested_package: plan.requested_package,
1445 included_prereqs: plan.included_prereqs,
1446 required_closure: plan.required_closure,
1447 published,
1448 skipped,
1449 failed,
1450 })
1451}
1452
1453fn build_publish_command_targets(
1454 surface: &ReleaseSurface,
1455 publish_order: &[String],
1456) -> Result<Vec<WorkspacePublishCommandTarget>, String> {
1457 let mut targets = Vec::new();
1458 for package_name in publish_order {
1459 let package = surface
1460 .packages
1461 .iter()
1462 .find(|package| &package.name == package_name)
1463 .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1464 targets.push(WorkspacePublishCommandTarget {
1465 package: package.name.clone(),
1466 version: package.version.to_string(),
1467 manifest_path: package.manifest_path.clone(),
1468 manifest_relative: package.manifest_relative.clone(),
1469 });
1470 }
1471 Ok(targets)
1472}
1473
1474#[derive(Debug, Clone)]
1475struct PublishSelection {
1476 from: Option<String>,
1477 only: Option<String>,
1478 include_prereqs: bool,
1479}
1480
1481#[derive(Debug, Clone)]
1482struct ResolvedPublishSelection {
1483 requested_package: Option<String>,
1484 selected_packages: Vec<String>,
1485 included_prereqs: Vec<String>,
1486 required_closure: Vec<String>,
1487}
1488
1489#[derive(Debug, Clone)]
1490struct BuiltPublishPlan {
1491 requested_package: Option<String>,
1492 included_prereqs: Vec<String>,
1493 required_closure: Vec<String>,
1494 items: Vec<PublishPlanItem>,
1495 publish_order: Vec<String>,
1496}
1497
1498fn build_publish_plan(
1499 surface: &ReleaseSurface,
1500 visibility: &BTreeMap<String, bool>,
1501 selection: Option<&PublishSelection>,
1502) -> Result<BuiltPublishPlan, String> {
1503 let ordered_packages = topological_package_order(surface)?;
1504 let selected = resolve_selected_packages(surface, &ordered_packages, selection)?;
1505 let selected_set: BTreeSet<String> = selected.selected_packages.iter().cloned().collect();
1506 let mut available = visibility.clone();
1507 let mut items = Vec::new();
1508 let mut publish_order = Vec::new();
1509
1510 for package_name in ordered_packages {
1511 if !selected_set.contains(&package_name) {
1512 continue;
1513 }
1514 let package = surface
1515 .packages
1516 .iter()
1517 .find(|package| package.name == package_name)
1518 .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1519 let visible = visibility.get(&package.name).copied();
1520 let mut publish_needed = false;
1521 let mut blocked_by = Vec::new();
1522 let reason = if visible == Some(true) {
1523 "already visible on crates.io".to_string()
1524 } else if !package.publishable {
1525 "publish disabled in package metadata".to_string()
1526 } else if package.publish_excluded {
1527 "publish excluded by workspace release config".to_string()
1528 } else if !package.publish_missing_version_pins.is_empty() {
1529 format!(
1530 "missing version pins for {}",
1531 package.publish_missing_version_pins.join(", ")
1532 )
1533 } else {
1534 for dependency in &package.publish_internal_dependencies {
1535 if available.get(dependency).copied().unwrap_or(false) {
1536 continue;
1537 }
1538 blocked_by.push(dependency.clone());
1539 }
1540 if blocked_by.is_empty() {
1541 publish_needed = true;
1542 publish_order.push(package.name.clone());
1543 available.insert(package.name.clone(), true);
1544 "publish required".to_string()
1545 } else {
1546 format!("waiting for {}", blocked_by.join(", "))
1547 }
1548 };
1549
1550 items.push(PublishPlanItem {
1551 package: package.name.clone(),
1552 manifest: package.manifest_relative.clone(),
1553 version: package.version.to_string(),
1554 publishable: package.publishable
1555 && !package.publish_excluded
1556 && package.publish_missing_version_pins.is_empty(),
1557 crates_io_visible: visible,
1558 publish_needed,
1559 blocked_by,
1560 reason,
1561 });
1562 }
1563
1564 Ok(BuiltPublishPlan {
1565 requested_package: selected.requested_package,
1566 included_prereqs: selected.included_prereqs,
1567 required_closure: selected.required_closure,
1568 items,
1569 publish_order,
1570 })
1571}
1572
1573fn resolve_selected_packages(
1574 surface: &ReleaseSurface,
1575 ordered_packages: &[String],
1576 selection: Option<&PublishSelection>,
1577) -> Result<ResolvedPublishSelection, String> {
1578 let Some(selection) = selection else {
1579 return Ok(ResolvedPublishSelection {
1580 requested_package: None,
1581 selected_packages: ordered_packages.to_vec(),
1582 included_prereqs: Vec::new(),
1583 required_closure: ordered_packages.to_vec(),
1584 });
1585 };
1586 if let Some(only) = selection.only.as_deref() {
1587 if !ordered_packages.iter().any(|package| package == only) {
1588 return Err(format!("Unknown package `{}` for `--only`.", only));
1589 }
1590 let required_closure = collect_publish_closure(surface, only, ordered_packages)?;
1591 let selected_packages = if selection.include_prereqs {
1592 required_closure.clone()
1593 } else {
1594 vec![only.to_string()]
1595 };
1596 let included_prereqs = required_closure
1597 .iter()
1598 .filter(|package| package.as_str() != only)
1599 .cloned()
1600 .collect::<Vec<_>>();
1601 return Ok(ResolvedPublishSelection {
1602 requested_package: Some(only.to_string()),
1603 selected_packages,
1604 included_prereqs: if selection.include_prereqs {
1605 included_prereqs
1606 } else {
1607 Vec::new()
1608 },
1609 required_closure,
1610 });
1611 }
1612 if let Some(from) = selection.from.as_deref() {
1613 let start = ordered_packages
1614 .iter()
1615 .position(|package| package == from)
1616 .ok_or_else(|| format!("Unknown package `{}` for `--from`.", from))?;
1617 return Ok(ResolvedPublishSelection {
1618 requested_package: None,
1619 selected_packages: ordered_packages[start..].to_vec(),
1620 included_prereqs: Vec::new(),
1621 required_closure: ordered_packages[start..].to_vec(),
1622 });
1623 }
1624 Ok(ResolvedPublishSelection {
1625 requested_package: None,
1626 selected_packages: ordered_packages.to_vec(),
1627 included_prereqs: Vec::new(),
1628 required_closure: ordered_packages.to_vec(),
1629 })
1630}
1631
1632fn collect_publish_closure(
1633 surface: &ReleaseSurface,
1634 root_package: &str,
1635 ordered_packages: &[String],
1636) -> Result<Vec<String>, String> {
1637 let mut by_name = BTreeMap::new();
1638 for package in &surface.packages {
1639 by_name.insert(package.name.as_str(), package);
1640 }
1641 let mut visited = BTreeSet::new();
1642 collect_publish_closure_visit(root_package, &by_name, &mut visited)?;
1643 Ok(ordered_packages
1644 .iter()
1645 .filter(|package| visited.contains(package.as_str()))
1646 .cloned()
1647 .collect())
1648}
1649
1650fn collect_publish_closure_visit<'a>(
1651 package_name: &'a str,
1652 by_name: &BTreeMap<&'a str, &'a ReleasePackage>,
1653 visited: &mut BTreeSet<&'a str>,
1654) -> Result<(), String> {
1655 if !visited.insert(package_name) {
1656 return Ok(());
1657 }
1658 let package = by_name
1659 .get(package_name)
1660 .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1661 for dependency in &package.publish_internal_dependencies {
1662 collect_publish_closure_visit(dependency, by_name, visited)?;
1663 }
1664 Ok(())
1665}
1666
1667fn collect_publish_blockers(items: &[PublishPlanItem]) -> Vec<String> {
1668 items
1669 .iter()
1670 .filter(|item| {
1671 item.crates_io_visible != Some(true)
1672 && (!item.blocked_by.is_empty() || !item.publishable)
1673 })
1674 .map(|item| {
1675 if !item.blocked_by.is_empty() {
1676 format!("{} blocked by {}", item.package, item.blocked_by.join(", "))
1677 } else {
1678 format!("{} {}", item.package, item.reason)
1679 }
1680 })
1681 .collect()
1682}
1683
1684fn render_workspace_publish_blockers(
1685 surface: &ReleaseSurface,
1686 plan: &BuiltPublishPlan,
1687 run_options: Option<&WorkspacePublishRunOptions>,
1688 blockers: &[String],
1689) -> String {
1690 let mut message = String::new();
1691 message.push_str("Workspace publish is blocked.\n");
1692 message.push_str(&format!("Repo: {}\n", surface.repo_root.display()));
1693 if let Some(requested_package) = plan.requested_package.as_deref() {
1694 message.push_str(&format!("Requested package: {}\n", requested_package));
1695 }
1696 if !plan.included_prereqs.is_empty() {
1697 message.push_str(&format!(
1698 "Auto-included prerequisites: {}\n",
1699 plan.included_prereqs.join(", ")
1700 ));
1701 }
1702 if !plan.required_closure.is_empty() {
1703 message.push_str(&format!(
1704 "Required publish order: {}\n",
1705 plan.required_closure.join(" -> ")
1706 ));
1707 }
1708 message.push_str("Blockers:\n");
1709 for blocker in blockers {
1710 message.push_str(&format!("- {}\n", blocker));
1711 }
1712 if let (Some(requested_package), Some(run_options)) =
1713 (plan.requested_package.as_deref(), run_options)
1714 {
1715 if !run_options.include_prereqs {
1716 message.push_str(&format!(
1717 "Rerun with prerequisites: xbp version workspace publish run --repo {} --only {} --include-prereqs{}\n",
1718 quote_argument(&surface.repo_root),
1719 requested_package,
1720 if run_options.allow_dirty {
1721 " --allow-dirty"
1722 } else {
1723 ""
1724 }
1725 ));
1726 }
1727 }
1728 message.trim_end().to_string()
1729}
1730
1731fn topological_package_order(surface: &ReleaseSurface) -> Result<Vec<String>, String> {
1732 let package_names = surface
1733 .packages
1734 .iter()
1735 .map(|package| package.name.clone())
1736 .collect::<BTreeSet<_>>();
1737 let order_overrides = surface
1738 .config
1739 .publish
1740 .order
1741 .iter()
1742 .enumerate()
1743 .map(|(index, name)| (name.clone(), index))
1744 .collect::<BTreeMap<_, _>>();
1745 let mut indegree = BTreeMap::new();
1746 let mut reverse = BTreeMap::<String, Vec<String>>::new();
1747 for package in &surface.packages {
1748 let deps = package
1749 .publish_internal_dependencies
1750 .iter()
1751 .filter(|name| package_names.contains(*name))
1752 .cloned()
1753 .collect::<Vec<_>>();
1754 indegree.insert(package.name.clone(), deps.len());
1755 for dep in deps {
1756 reverse.entry(dep).or_default().push(package.name.clone());
1757 }
1758 }
1759
1760 let mut queue = indegree
1761 .iter()
1762 .filter_map(|(name, degree)| (*degree == 0).then_some(name.clone()))
1763 .collect::<Vec<_>>();
1764 sort_package_names(&mut queue, &order_overrides);
1765
1766 let mut ordered = Vec::new();
1767 while let Some(name) = queue.first().cloned() {
1768 queue.remove(0);
1769 ordered.push(name.clone());
1770 if let Some(children) = reverse.get(&name) {
1771 for child in children {
1772 if let Some(entry) = indegree.get_mut(child) {
1773 *entry -= 1;
1774 if *entry == 0 {
1775 queue.push(child.clone());
1776 }
1777 }
1778 }
1779 sort_package_names(&mut queue, &order_overrides);
1780 }
1781 }
1782
1783 if ordered.len() != surface.packages.len() {
1784 return Err(
1785 "Workspace package graph contains a publish-relevant dependency cycle.".to_string(),
1786 );
1787 }
1788 Ok(ordered)
1789}
1790
1791fn sort_package_names(names: &mut [String], overrides: &BTreeMap<String, usize>) {
1792 names.sort_by(|a, b| {
1793 overrides
1794 .get(a)
1795 .copied()
1796 .unwrap_or(usize::MAX)
1797 .cmp(&overrides.get(b).copied().unwrap_or(usize::MAX))
1798 .then(a.cmp(b))
1799 });
1800}
1801
1802async fn collect_crates_io_visibility(
1803 surface: &ReleaseSurface,
1804) -> Result<BTreeMap<String, bool>, String> {
1805 let client = crates_io_client()?;
1806 let mut visibility = BTreeMap::new();
1807 for package in &surface.packages {
1808 if !package.publishable || package.publish_excluded {
1809 visibility.insert(package.name.clone(), false);
1810 continue;
1811 }
1812 visibility.insert(
1813 package.name.clone(),
1814 crates_io_has_exact_version(&client, &package.name, &package.version.to_string())
1815 .await?,
1816 );
1817 }
1818 Ok(visibility)
1819}
1820
1821async fn wait_for_crates_io_visibility(
1822 package: &str,
1823 version: &str,
1824 timeout_seconds: f64,
1825 poll_interval_seconds: f64,
1826 soft_on_timeout: bool,
1827) -> Result<(), String> {
1828 let timeout = Duration::from_secs_f64(timeout_seconds.max(1.0));
1829 let poll = Duration::from_secs_f64(poll_interval_seconds.max(0.5));
1830 let deadline = Instant::now() + timeout;
1831 let client = crates_io_client()?;
1832 loop {
1833 match crates_io_version_lookup(&client, package, version).await? {
1834 CratesIoLookup::Found => return Ok(()),
1835 CratesIoLookup::NotFound | CratesIoLookup::Retryable => {}
1836 }
1837 if Instant::now() >= deadline {
1838 let message = format!(
1839 "{} {} was published, but did not become visible on crates.io within {:.0}s",
1840 package, version, timeout_seconds
1841 );
1842 if soft_on_timeout {
1843 eprintln!(
1844 "Warning: registry visibility still pending for {}@{} — continuing because cargo already reported a successful publish. Check https://crates.io/crates/{}",
1845 package, version, package
1846 );
1847 return Ok(());
1848 }
1849 return Err(message);
1850 }
1851 sleep(poll).await;
1852 }
1853}
1854
1855#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1856enum CratesIoLookup {
1857 Found,
1858 NotFound,
1859 Retryable,
1860}
1861
1862async fn crates_io_version_lookup(
1863 client: &reqwest::Client,
1864 package: &str,
1865 version: &str,
1866) -> Result<CratesIoLookup, String> {
1867 let url = format!("https://crates.io/api/v1/crates/{}/{}", package, version);
1868 let response = client
1869 .get(url)
1870 .send()
1871 .await
1872 .map_err(|e| format!("Failed crates.io lookup for {} {}: {}", package, version, e))?;
1873 let status = response.status();
1874 if status.is_success() {
1875 return Ok(CratesIoLookup::Found);
1876 }
1877 if status == reqwest::StatusCode::NOT_FOUND {
1878 return Ok(CratesIoLookup::NotFound);
1879 }
1880 if status == reqwest::StatusCode::FORBIDDEN {
1881 return Err(format!(
1882 "crates.io rejected the visibility lookup for {} {} with HTTP 403 \
1883(descriptive User-Agent required). If cargo already printed Published, the crate is live.",
1884 package, version
1885 ));
1886 }
1887 if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
1888 return Ok(CratesIoLookup::Retryable);
1889 }
1890 Ok(CratesIoLookup::NotFound)
1891}
1892
1893async fn crates_io_has_exact_version(
1894 client: &reqwest::Client,
1895 package: &str,
1896 version: &str,
1897) -> Result<bool, String> {
1898 Ok(matches!(
1899 crates_io_version_lookup(client, package, version).await?,
1900 CratesIoLookup::Found
1901 ))
1902}
1903
1904fn crates_io_client() -> Result<reqwest::Client, String> {
1905 reqwest::Client::builder()
1906 .user_agent(format!(
1907 "xbp/{} (https://github.com/xylex-group/xbp)",
1908 env!("CARGO_PKG_VERSION")
1909 ))
1910 .build()
1911 .map_err(|e| format!("Failed to build crates.io HTTP client: {}", e))
1912}
1913
1914fn enforce_clean_worktree(project_root: &Path) -> Result<(), String> {
1915 let Some(GitWorktreeState { is_dirty, .. }) = git_worktree_state(project_root)? else {
1916 return Ok(());
1917 };
1918 if is_dirty {
1919 return Err(
1920 "Workspace repo has uncommitted changes. Re-run with `--allow-dirty` to override."
1921 .to_string(),
1922 );
1923 }
1924 Ok(())
1925}
1926
1927fn resolve_expected_version(
1928 surface: &ReleaseSurface,
1929 explicit: Option<&str>,
1930) -> Result<Version, String> {
1931 if let Some(explicit) = explicit {
1932 return parse_version(explicit);
1933 }
1934 if let Some(root_package_name) = surface.root_package_name.as_ref() {
1935 return surface
1936 .packages
1937 .iter()
1938 .find(|package| &package.name == root_package_name)
1939 .map(|package| package.version.clone())
1940 .ok_or_else(|| "Could not resolve the root package version.".to_string());
1941 }
1942
1943 let root_manifest = surface.repo_root.join("Cargo.toml");
1944 resolve_cargo_package_version(&root_manifest)?
1945 .ok_or_else(|| {
1946 "Workspace root has no package.version or workspace.package.version; pass `--version` explicitly.".to_string()
1947 })
1948 .and_then(|version| parse_version(&version))
1949}
1950
1951fn load_cargo_metadata(repo_root: &Path) -> Result<CargoMetadata, String> {
1952 let mut command = Command::new(cargo_program());
1953 command
1954 .current_dir(repo_root)
1955 .args(["metadata", "--format-version", "1", "--no-deps"]);
1956 load_cargo_metadata_command(&mut command)
1957}
1958
1959fn load_cargo_metadata_for_manifest(manifest_path: &Path) -> Result<CargoMetadata, String> {
1960 let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
1961 let mut command = Command::new(cargo_program());
1962 command
1963 .current_dir(manifest_dir)
1964 .arg("metadata")
1965 .arg("--format-version")
1966 .arg("1")
1967 .arg("--no-deps")
1968 .arg("--manifest-path")
1969 .arg(manifest_path);
1970 load_cargo_metadata_command(&mut command)
1971}
1972
1973fn load_cargo_metadata_command(command: &mut Command) -> Result<CargoMetadata, String> {
1974 if !cargo_command_exists() {
1975 return Err("`cargo` is required to inspect workspace metadata.".to_string());
1976 }
1977 let output = command
1978 .output()
1979 .map_err(|e| format!("Failed to run `cargo metadata`: {}", e))?;
1980 if !output.status.success() {
1981 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1982 return Err(format!("`cargo metadata` failed: {}", stderr));
1983 }
1984 serde_json::from_slice::<CargoMetadata>(&output.stdout)
1985 .map_err(|e| format!("Failed to parse cargo metadata JSON: {}", e))
1986}
1987
1988fn load_workspace_release_config(path: Option<&Path>) -> Result<WorkspaceReleaseConfig, String> {
1989 let Some(path) = path else {
1990 return Ok(WorkspaceReleaseConfig::default());
1991 };
1992 let content = fs::read_to_string(path)
1993 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1994 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
1995}
1996
1997fn run_command_capture(
1998 mut command: Command,
1999 label: impl Into<String>,
2000) -> Result<ValidationCommandResult, String> {
2001 let label = label.into();
2002 let output = command
2003 .output()
2004 .map_err(|e| format!("Failed to run `{}`: {}", label, e))?;
2005 Ok(ValidationCommandResult {
2006 command: label,
2007 success: output.status.success(),
2008 exit_code: output.status.code(),
2009 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
2010 warning: None,
2011 })
2012}
2013
2014fn read_metadata_version(path: &Path) -> Result<Option<String>, String> {
2015 let file_name = path
2016 .file_name()
2017 .and_then(|value| value.to_str())
2018 .unwrap_or_default();
2019 match file_name {
2020 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
2021 let content = fs::read_to_string(path)
2022 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2023 read_regex_version_from_content(&content, r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
2024 }
2025 _ => read_version_from_path(path),
2026 }
2027}
2028
2029fn write_metadata_version(path: &Path, version: &Version) -> Result<(), String> {
2030 let file_name = path
2031 .file_name()
2032 .and_then(|value| value.to_str())
2033 .unwrap_or_default();
2034 match file_name {
2035 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
2036 let content = fs::read_to_string(path)
2037 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2038 let regex = regex::Regex::new(r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
2039 .map_err(|e| format!("Failed to build OpenAPI regex: {}", e))?;
2040 let updated = regex
2041 .replace(&content, format!(" version: {}", version))
2042 .to_string();
2043 fs::write(path, updated)
2044 .map_err(|e| format!("Failed to write {}: {}", path.display(), e))
2045 }
2046 _ => write_version_to_path(path, version).map(|_| ()),
2047 }
2048}
2049
2050fn normalize_relative(repo_root: &Path, path: &Path) -> String {
2051 path.strip_prefix(repo_root)
2052 .unwrap_or(path)
2053 .to_string_lossy()
2054 .replace('\\', "/")
2055}
2056
2057fn display_path(path: &Path) -> String {
2058 path.to_string_lossy().to_string()
2059}
2060
2061fn same_path(left: &Path, right: &Path) -> bool {
2062 fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf())
2063 == fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
2064}
2065
2066fn quote_argument(path: &Path) -> String {
2067 let value = path.to_string_lossy();
2068 if value.contains(' ') {
2069 format!("\"{}\"", value)
2070 } else {
2071 value.to_string()
2072 }
2073}
2074
2075fn print_check_report(surface: &ReleaseSurface, report: &WorkspaceCheckReport) {
2076 println!("Workspace version check");
2077 println!("Repo: {}", surface.repo_root.display());
2078 println!("Expected version: {}", report.expected_version);
2079 if let Some(config_path) = &surface.config_path {
2080 println!(
2081 "Config: {}",
2082 normalize_relative(&surface.repo_root, config_path)
2083 );
2084 }
2085 println!(
2086 "Status: {}",
2087 if report.aligned {
2088 "aligned"
2089 } else {
2090 "drift detected"
2091 }
2092 );
2093 for entry in &report.drift {
2094 println!(
2095 "{} {} actual={} expected={}",
2096 entry.path,
2097 entry.field,
2098 entry.actual.as_deref().unwrap_or("<missing>"),
2099 entry.expected
2100 );
2101 }
2102}
2103
2104fn print_sync_report(surface: &ReleaseSurface, report: &WorkspaceSyncReport) {
2105 println!(
2106 "Workspace version {}",
2107 if report.write { "sync" } else { "sync preview" }
2108 );
2109 println!("Repo: {}", surface.repo_root.display());
2110 println!("Expected version: {}", report.expected_version);
2111 if report.edits.is_empty() {
2112 println!("No changes needed.");
2113 return;
2114 }
2115 println!("Files: {}", report.files_changed.join(", "));
2116 for edit in &report.edits {
2117 println!(
2118 "{} {} {} -> {}",
2119 edit.path,
2120 edit.field,
2121 edit.before.as_deref().unwrap_or("<missing>"),
2122 edit.after
2123 );
2124 }
2125}
2126
2127fn print_validation_report(surface: &ReleaseSurface, report: &WorkspaceValidateReport) {
2128 println!("Workspace validation");
2129 println!("Repo: {}", surface.repo_root.display());
2130 println!("Status: {}", if report.ok { "ok" } else { "failed" });
2131 for issue in &report.issues {
2132 println!(
2133 "{} {} actual={} expected={}",
2134 issue.path,
2135 issue.field,
2136 issue.actual.as_deref().unwrap_or("<missing>"),
2137 issue.expected
2138 );
2139 }
2140 for command in &report.commands {
2141 let status = if command.success {
2142 if command.warning.is_some() {
2143 "ok (with warning)"
2144 } else {
2145 "ok"
2146 }
2147 } else {
2148 "failed"
2149 };
2150 println!("{} [{}]", command.command, status);
2151 if let Some(warning) = command.warning.as_deref() {
2152 println!("warning: {}", warning);
2153 }
2154 if !command.stderr.is_empty() {
2155 println!("{}", command.stderr);
2156 }
2157 }
2158}
2159
2160fn print_publish_plan_report(surface: &ReleaseSurface, report: &WorkspacePublishPlanReport) {
2161 println!("Workspace publish plan");
2162 println!("Repo: {}", surface.repo_root.display());
2163 if let Some(requested_package) = report.requested_package.as_deref() {
2164 println!("Requested package: {}", requested_package);
2165 }
2166 if !report.included_prereqs.is_empty() {
2167 println!(
2168 "Auto-included prerequisites: {}",
2169 report.included_prereqs.join(", ")
2170 );
2171 }
2172 if !report.required_closure.is_empty() {
2173 println!("Required closure: {}", report.required_closure.join(" -> "));
2174 }
2175 println!(
2176 "Publish order: {}",
2177 if report.publish_order.is_empty() {
2178 "<none>".to_string()
2179 } else {
2180 report.publish_order.join(", ")
2181 }
2182 );
2183 for item in &report.packages {
2184 println!(
2185 "{} {} visible={} needed={} reason={}",
2186 item.package,
2187 item.version,
2188 item.crates_io_visible
2189 .map(|value| value.to_string())
2190 .unwrap_or_else(|| "n/a".to_string()),
2191 item.publish_needed,
2192 item.reason
2193 );
2194 if !item.blocked_by.is_empty() {
2195 println!(" blocked by {}", item.blocked_by.join(", "));
2196 }
2197 }
2198}
2199
2200fn print_publish_run_report(surface: &ReleaseSurface, report: &WorkspacePublishRunReport) {
2201 println!("Workspace publish run");
2202 println!("Repo: {}", surface.repo_root.display());
2203 println!("Dry run: {}", report.dry_run);
2204 if let Some(requested_package) = report.requested_package.as_deref() {
2205 println!("Requested package: {}", requested_package);
2206 }
2207 if !report.included_prereqs.is_empty() {
2208 println!(
2209 "Auto-included prerequisites: {}",
2210 report.included_prereqs.join(", ")
2211 );
2212 }
2213 if !report.required_closure.is_empty() {
2214 println!("Required closure: {}", report.required_closure.join(" -> "));
2215 }
2216 if !report.published.is_empty() {
2217 println!("Published: {}", report.published.join(", "));
2218 }
2219 if !report.skipped.is_empty() {
2220 println!("Skipped: {}", report.skipped.join("; "));
2221 }
2222 if !report.failed.is_empty() {
2223 println!("Failed: {}", report.failed.join("; "));
2224 }
2225}
2226
2227#[cfg(test)]
2228mod tests {
2229 use super::{
2230 apply_sync, build_publish_plan, collect_drift, discover_release_surface,
2231 inspect_workspace_version_drift, resolve_expected_version, PublishSelection,
2232 };
2233 use semver::Version;
2234 use std::collections::BTreeMap;
2235 use std::fs;
2236 use std::path::PathBuf;
2237 use std::time::{SystemTime, UNIX_EPOCH};
2238
2239 fn temp_dir(name: &str) -> PathBuf {
2240 let nanos = SystemTime::now()
2241 .duration_since(UNIX_EPOCH)
2242 .expect("time")
2243 .as_nanos();
2244 let dir = std::env::temp_dir().join(format!("xbp-workspace-release-{}-{}", name, nanos));
2245 fs::create_dir_all(&dir).expect("create temp dir");
2246 dir
2247 }
2248
2249 fn write_file(path: &PathBuf, content: &str) {
2250 if let Some(parent) = path.parent() {
2251 fs::create_dir_all(parent).expect("create parent");
2252 }
2253 fs::write(path, content).expect("write file");
2254 }
2255
2256 fn create_demo_workspace() -> PathBuf {
2257 let root = temp_dir("demo");
2258 write_file(
2259 &root.join("Cargo.toml"),
2260 r#"[package]
2261name = "athena_rs"
2262version = "3.16.4"
2263
2264[dependencies.alpha]
2265path = "crates/alpha"
2266version = "3.16.4"
2267
2268[dependencies.beta]
2269path = "crates/beta"
2270version = "3.16.4"
2271
2272[dependencies.athena-s3]
2273path = "crates/athena-s3"
2274version = "3.16.4"
2275
2276[workspace]
2277members = ["crates/alpha", "crates/beta", "crates/athena-s3"]
2278resolver = "2"
2279"#,
2280 );
2281 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
2282 write_file(
2283 &root.join("README.md"),
2284 "# Athena\n\ncurrent version: `3.16.4`\n",
2285 );
2286 write_file(
2287 &root.join("openapi.yaml"),
2288 "openapi: 3.1.0\ninfo:\n title: Athena\n version: 3.16.4\n",
2289 );
2290 write_file(
2291 &root.join("Cargo.lock"),
2292 r#"version = 4
2293
2294[[package]]
2295name = "athena_rs"
2296version = "3.16.4"
2297
2298[[package]]
2299name = "alpha"
2300version = "3.16.4"
2301
2302[[package]]
2303name = "beta"
2304version = "3.16.4"
2305
2306[[package]]
2307name = "athena-s3"
2308version = "3.16.4"
2309"#,
2310 );
2311 write_file(
2312 &root.join("crates/alpha/Cargo.toml"),
2313 r#"[package]
2314name = "alpha"
2315version = "3.16.4"
2316
2317[dependencies]
2318beta = { path = "../beta", version = "3.16.4" }
2319athena-s3 = { path = "../athena-s3", version = "3.16.4" }
2320"#,
2321 );
2322 write_file(&root.join("crates/alpha/src/lib.rs"), "pub fn alpha() {}\n");
2323 write_file(
2324 &root.join("crates/beta/Cargo.toml"),
2325 r#"[package]
2326name = "beta"
2327version = "3.16.4"
2328"#,
2329 );
2330 write_file(&root.join("crates/beta/src/lib.rs"), "pub fn beta() {}\n");
2331 write_file(
2332 &root.join("crates/athena-s3/Cargo.toml"),
2333 r#"[package]
2334name = "athena-s3"
2335version = "3.16.4"
2336"#,
2337 );
2338 write_file(
2339 &root.join("crates/athena-s3/src/lib.rs"),
2340 "pub fn athena_s3() {}\n",
2341 );
2342 write_file(
2343 &root.join("crates/athena-backups/Cargo.toml"),
2344 r#"[package]
2345name = "athena-backups"
2346version = "3.16.0"
2347
2348[dependencies]
2349beta = { path = "../beta", version = "3.16.0" }
2350"#,
2351 );
2352 write_file(
2353 &root.join("crates/athena-backups/src/lib.rs"),
2354 "pub fn athena_backups() {}\n",
2355 );
2356 root
2357 }
2358
2359 #[test]
2360 fn inspect_workspace_version_drift_ignores_parent_workspace_for_nested_project_root() {
2361 let root = create_demo_workspace();
2362 let nested_project_root = root.join("packages").join("athena-js");
2363 fs::create_dir_all(&nested_project_root).expect("create nested project root");
2364
2365 let drift = inspect_workspace_version_drift(&nested_project_root, &Version::new(2, 13, 0))
2366 .expect("inspect drift");
2367
2368 assert!(drift.is_none());
2369 }
2370
2371 fn create_workspace_dependency_demo_workspace() -> PathBuf {
2372 let root = temp_dir("workspace-deps");
2373 write_file(
2374 &root.join("Cargo.toml"),
2375 r#"[package]
2376name = "xbp"
2377version = "10.27.0"
2378
2379[dependencies]
2380xbp-providers.workspace = true
2381
2382[workspace]
2383members = ["crates/http", "crates/providers"]
2384resolver = "2"
2385
2386[workspace.dependencies]
2387xbp-http = { path = "crates/http", version = "0.1.0" }
2388xbp-providers = { path = "crates/providers", version = "0.1.0" }
2389"#,
2390 );
2391 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
2392 write_file(
2393 &root.join("crates/http/Cargo.toml"),
2394 r#"[package]
2395name = "xbp-http"
2396version = "0.1.0"
2397"#,
2398 );
2399 write_file(&root.join("crates/http/src/lib.rs"), "pub fn http() {}\n");
2400 write_file(
2401 &root.join("crates/providers/Cargo.toml"),
2402 r#"[package]
2403name = "xbp-providers"
2404version = "0.1.0"
2405
2406[dependencies]
2407xbp-http.workspace = true
2408"#,
2409 );
2410 write_file(
2411 &root.join("crates/providers/src/lib.rs"),
2412 "pub fn providers() {}\n",
2413 );
2414 root
2415 }
2416
2417 fn create_alias_dependency_demo_workspace() -> PathBuf {
2418 let root = temp_dir("workspace-alias-deps");
2419 write_file(
2420 &root.join("Cargo.toml"),
2421 r#"[package]
2422name = "xbp"
2423version = "10.27.0"
2424
2425[dependencies.xbp_monitor]
2426package = "xbp-monitoring"
2427path = "crates/monitor"
2428version = "10.27.0"
2429
2430[workspace]
2431members = ["crates/monitor"]
2432resolver = "2"
2433"#,
2434 );
2435 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
2436 write_file(
2437 &root.join("crates/monitor/Cargo.toml"),
2438 r#"[package]
2439name = "xbp-monitoring"
2440version = "10.27.0"
2441"#,
2442 );
2443 write_file(
2444 &root.join("crates/monitor/src/lib.rs"),
2445 "pub fn monitor() {}\n",
2446 );
2447 root
2448 }
2449
2450 fn create_virtual_workspace_package_demo_workspace() -> PathBuf {
2451 let root = temp_dir("virtual-workspace-package");
2452 write_file(
2453 &root.join("Cargo.toml"),
2454 r#"[workspace]
2455members = ["crates/cli"]
2456resolver = "2"
2457
2458[workspace.package]
2459version = "10.35.0"
2460"#,
2461 );
2462 write_file(
2463 &root.join("crates/cli/Cargo.toml"),
2464 r#"[package]
2465name = "xbp"
2466version.workspace = true
2467"#,
2468 );
2469 write_file(&root.join("crates/cli/src/lib.rs"), "pub fn cli() {}\n");
2470 root
2471 }
2472
2473 #[test]
2474 fn discovery_uses_workspace_members_and_excludes_non_member_crates() {
2475 let root = create_demo_workspace();
2476 let surface = discover_release_surface(&root).expect("discover");
2477 let names = surface
2478 .packages
2479 .iter()
2480 .map(|package| package.name.clone())
2481 .collect::<Vec<_>>();
2482 assert!(names.contains(&"athena-s3".to_string()));
2483 assert!(!names.contains(&"athena-backups".to_string()));
2484 }
2485
2486 #[test]
2487 fn expected_version_reads_virtual_workspace_package_version() {
2488 let root = create_virtual_workspace_package_demo_workspace();
2489 let surface = discover_release_surface(&root).expect("discover");
2490
2491 assert_eq!(
2492 resolve_expected_version(&surface, None).expect("expected version"),
2493 Version::new(10, 35, 0)
2494 );
2495
2496 let _ = fs::remove_dir_all(root);
2497 }
2498
2499 #[test]
2500 fn drift_reports_package_dependency_metadata_and_lock_mismatches() {
2501 let root = create_demo_workspace();
2502 write_file(
2503 &root.join("crates/alpha/Cargo.toml"),
2504 r#"[package]
2505name = "alpha"
2506version = "3.16.5"
2507
2508[dependencies]
2509beta = { path = "../beta", version = "3.16.4" }
2510athena-s3 = { path = "../athena-s3" }
2511"#,
2512 );
2513 let surface = discover_release_surface(&root).expect("discover");
2514 let expected = Version::new(3, 16, 4);
2515 let drift = collect_drift(&surface, &expected).expect("drift");
2516 assert!(drift.iter().any(
2517 |entry| entry.path == "crates/alpha/Cargo.toml" && entry.field == "package.version"
2518 ));
2519 assert!(
2520 drift
2521 .iter()
2522 .any(|entry| entry.field == "dependencies.athena-s3.version"
2523 && entry.actual.is_none())
2524 );
2525 }
2526
2527 #[test]
2528 fn sync_preview_and_write_updates_workspace_surface() {
2529 let root = create_demo_workspace();
2530 let surface = discover_release_surface(&root).expect("discover");
2531 let expected = Version::new(3, 16, 5);
2532 let (preview, _) = apply_sync(&surface, &expected, false).expect("preview");
2533 assert!(!preview.is_empty());
2534
2535 let (written, files) = apply_sync(&surface, &expected, true).expect("write");
2536 assert!(!written.is_empty());
2537 assert!(files.contains(&"Cargo.toml".to_string()));
2538 let updated = fs::read_to_string(root.join("crates/alpha/Cargo.toml")).expect("read");
2539 assert!(updated.contains("version = \"3.16.5\""));
2540 assert!(updated.contains("athena-s3 = { path = \"../athena-s3\", version = \"3.16.5\" }"));
2541 }
2542
2543 #[test]
2544 fn drift_and_sync_follow_aliased_workspace_dependencies() {
2545 let root = create_alias_dependency_demo_workspace();
2546 write_file(
2547 &root.join("Cargo.toml"),
2548 r#"[package]
2549name = "xbp"
2550version = "10.27.1"
2551
2552[dependencies.xbp_monitor]
2553package = "xbp-monitoring"
2554path = "crates/monitor"
2555version = "10.27.0"
2556
2557[workspace]
2558members = ["crates/monitor"]
2559resolver = "2"
2560"#,
2561 );
2562
2563 let surface = discover_release_surface(&root).expect("discover");
2564 let expected = Version::new(10, 27, 1);
2565 let drift = collect_drift(&surface, &expected).expect("drift");
2566 assert!(drift
2567 .iter()
2568 .any(|entry| entry.path == "Cargo.toml"
2569 && entry.field == "dependencies.xbp_monitor.version"));
2570
2571 let (written, files) = apply_sync(&surface, &expected, true).expect("write");
2572 assert!(!written.is_empty());
2573 assert!(files.contains(&"Cargo.toml".to_string()));
2574 let updated = fs::read_to_string(root.join("Cargo.toml")).expect("read root manifest");
2575 assert!(updated.contains("version = \"10.27.1\""));
2576 assert!(updated.contains("package = \"xbp-monitoring\""));
2577 assert!(updated.contains("version = \"10.27.1\""));
2578 }
2579
2580 #[test]
2581 fn publish_plan_orders_dependencies_before_dependents() {
2582 let root = create_demo_workspace();
2583 let surface = discover_release_surface(&root).expect("discover");
2584 let mut visibility = BTreeMap::new();
2585 visibility.insert("athena_rs".to_string(), false);
2586 visibility.insert("alpha".to_string(), false);
2587 visibility.insert("beta".to_string(), true);
2588 visibility.insert("athena-s3".to_string(), false);
2589 let plan = build_publish_plan(
2590 &surface,
2591 &visibility,
2592 Some(&PublishSelection {
2593 from: None,
2594 only: None,
2595 include_prereqs: false,
2596 }),
2597 )
2598 .expect("plan");
2599 let alpha_pos = plan
2600 .publish_order
2601 .iter()
2602 .position(|name| name == "alpha")
2603 .expect("alpha");
2604 let s3_pos = plan
2605 .publish_order
2606 .iter()
2607 .position(|name| name == "athena-s3")
2608 .expect("s3");
2609 assert!(s3_pos < alpha_pos);
2610 assert!(plan
2611 .items
2612 .iter()
2613 .any(|item| item.package == "athena_rs" && item.publish_needed));
2614 }
2615
2616 #[test]
2617 fn publish_plan_orders_workspace_dependencies_before_dependents() {
2618 let root = create_workspace_dependency_demo_workspace();
2619 let surface = discover_release_surface(&root).expect("discover");
2620 let mut visibility = BTreeMap::new();
2621 visibility.insert("xbp".to_string(), false);
2622 visibility.insert("xbp-http".to_string(), false);
2623 visibility.insert("xbp-providers".to_string(), false);
2624
2625 let plan = build_publish_plan(
2626 &surface,
2627 &visibility,
2628 Some(&PublishSelection {
2629 from: None,
2630 only: None,
2631 include_prereqs: false,
2632 }),
2633 )
2634 .expect("plan");
2635
2636 let xbp_pos = plan
2637 .publish_order
2638 .iter()
2639 .position(|name| name == "xbp")
2640 .expect("xbp");
2641 let providers_pos = plan
2642 .publish_order
2643 .iter()
2644 .position(|name| name == "xbp-providers")
2645 .expect("providers");
2646 let http_pos = plan
2647 .publish_order
2648 .iter()
2649 .position(|name| name == "xbp-http")
2650 .expect("http");
2651
2652 assert!(http_pos < providers_pos);
2653 assert!(providers_pos < xbp_pos);
2654 }
2655
2656 #[test]
2657 fn publish_plan_orders_aliased_workspace_dependencies_before_dependents() {
2658 let root = create_alias_dependency_demo_workspace();
2659 let surface = discover_release_surface(&root).expect("discover");
2660 let mut visibility = BTreeMap::new();
2661 visibility.insert("xbp".to_string(), false);
2662 visibility.insert("xbp-monitoring".to_string(), false);
2663
2664 let plan = build_publish_plan(
2665 &surface,
2666 &visibility,
2667 Some(&PublishSelection {
2668 from: None,
2669 only: None,
2670 include_prereqs: false,
2671 }),
2672 )
2673 .expect("plan");
2674
2675 let xbp_pos = plan
2676 .publish_order
2677 .iter()
2678 .position(|name| name == "xbp")
2679 .expect("xbp");
2680 let monitor_pos = plan
2681 .publish_order
2682 .iter()
2683 .position(|name| name == "xbp-monitoring")
2684 .expect("xbp-monitoring");
2685
2686 assert!(monitor_pos < xbp_pos);
2687 }
2688
2689 #[test]
2690 fn publish_plan_only_with_prereqs_limits_to_minimal_closure() {
2691 let root = create_demo_workspace();
2692 let surface = discover_release_surface(&root).expect("discover");
2693 let mut visibility = BTreeMap::new();
2694 visibility.insert("athena_rs".to_string(), false);
2695 visibility.insert("alpha".to_string(), false);
2696 visibility.insert("beta".to_string(), false);
2697 visibility.insert("athena-s3".to_string(), false);
2698
2699 let plan = build_publish_plan(
2700 &surface,
2701 &visibility,
2702 Some(&PublishSelection {
2703 from: None,
2704 only: Some("alpha".to_string()),
2705 include_prereqs: true,
2706 }),
2707 )
2708 .expect("plan");
2709
2710 let package_names = plan
2711 .items
2712 .iter()
2713 .map(|item| item.package.as_str())
2714 .collect::<Vec<_>>();
2715 assert_eq!(package_names.len(), 3);
2716 assert!(!package_names.contains(&"athena_rs"));
2717 assert_eq!(plan.required_closure.len(), 3);
2718 assert!(plan.required_closure.contains(&"alpha".to_string()));
2719 assert!(plan.required_closure.contains(&"beta".to_string()));
2720 assert!(plan.required_closure.contains(&"athena-s3".to_string()));
2721 assert_eq!(plan.included_prereqs.len(), 2);
2722 assert!(plan.included_prereqs.contains(&"beta".to_string()));
2723 assert!(plan.included_prereqs.contains(&"athena-s3".to_string()));
2724 assert_eq!(plan.publish_order.len(), 3);
2725
2726 let alpha_pos = plan
2727 .publish_order
2728 .iter()
2729 .position(|package| package == "alpha")
2730 .expect("alpha in publish order");
2731 let beta_pos = plan
2732 .publish_order
2733 .iter()
2734 .position(|package| package == "beta")
2735 .expect("beta in publish order");
2736 let s3_pos = plan
2737 .publish_order
2738 .iter()
2739 .position(|package| package == "athena-s3")
2740 .expect("athena-s3 in publish order");
2741 assert!(beta_pos < alpha_pos);
2742 assert!(s3_pos < alpha_pos);
2743 }
2744
2745 #[test]
2746 fn publish_plan_only_without_prereqs_reports_blocked_package() {
2747 let root = create_demo_workspace();
2748 let surface = discover_release_surface(&root).expect("discover");
2749 let mut visibility = BTreeMap::new();
2750 visibility.insert("athena_rs".to_string(), false);
2751 visibility.insert("alpha".to_string(), false);
2752 visibility.insert("beta".to_string(), false);
2753 visibility.insert("athena-s3".to_string(), false);
2754
2755 let plan = build_publish_plan(
2756 &surface,
2757 &visibility,
2758 Some(&PublishSelection {
2759 from: None,
2760 only: Some("alpha".to_string()),
2761 include_prereqs: false,
2762 }),
2763 )
2764 .expect("plan");
2765
2766 assert_eq!(plan.required_closure.len(), 3);
2767 assert!(plan.required_closure.contains(&"alpha".to_string()));
2768 assert!(plan.required_closure.contains(&"beta".to_string()));
2769 assert!(plan.required_closure.contains(&"athena-s3".to_string()));
2770 assert!(plan.included_prereqs.is_empty());
2771 assert!(plan.publish_order.is_empty());
2772 assert_eq!(plan.items.len(), 1);
2773 assert_eq!(plan.items[0].package, "alpha");
2774 assert_eq!(plan.items[0].blocked_by.len(), 2);
2775 assert!(plan.items[0].blocked_by.contains(&"beta".to_string()));
2776 assert!(plan.items[0].blocked_by.contains(&"athena-s3".to_string()));
2777 }
2778
2779 #[test]
2780 fn publish_plan_ignores_dev_dependencies_for_publish_blockers() {
2781 let root = temp_dir("publish-dev-deps");
2782 write_file(
2783 &root.join("Cargo.toml"),
2784 r#"[package]
2785name = "demo-root"
2786version = "1.0.0"
2787
2788[workspace]
2789members = ["crates/app", "crates/dev-helper"]
2790resolver = "2"
2791"#,
2792 );
2793 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
2794 write_file(
2795 &root.join("crates/app/Cargo.toml"),
2796 r#"[package]
2797name = "demo-app"
2798version = "1.0.0"
2799
2800[dev-dependencies]
2801dev-helper = { path = "../dev-helper", version = "1.0.0" }
2802"#,
2803 );
2804 write_file(&root.join("crates/app/src/lib.rs"), "pub fn app() {}\n");
2805 write_file(
2806 &root.join("crates/dev-helper/Cargo.toml"),
2807 r#"[package]
2808name = "dev-helper"
2809version = "1.0.0"
2810"#,
2811 );
2812 write_file(
2813 &root.join("crates/dev-helper/src/lib.rs"),
2814 "pub fn helper() {}\n",
2815 );
2816
2817 let surface = discover_release_surface(&root).expect("discover");
2818 let mut visibility = BTreeMap::new();
2819 visibility.insert("demo-root".to_string(), false);
2820 visibility.insert("demo-app".to_string(), false);
2821 visibility.insert("dev-helper".to_string(), false);
2822
2823 let plan = build_publish_plan(
2824 &surface,
2825 &visibility,
2826 Some(&PublishSelection {
2827 from: None,
2828 only: Some("demo-app".to_string()),
2829 include_prereqs: false,
2830 }),
2831 )
2832 .expect("plan");
2833
2834 assert_eq!(plan.required_closure, vec!["demo-app"]);
2835 assert_eq!(plan.publish_order, vec!["demo-app"]);
2836 assert!(plan.items[0].blocked_by.is_empty());
2837 }
2838}