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 PublishHeal(WorkspacePublishHealOptions),
44 PublishRun(WorkspacePublishRunOptions),
45}
46
47#[derive(Debug, Clone)]
48pub struct WorkspaceVersionCheckOptions {
49 pub version: Option<String>,
50}
51
52#[derive(Debug, Clone)]
53pub struct WorkspaceVersionSyncOptions {
54 pub version: Option<String>,
55 pub write: bool,
56}
57
58#[derive(Debug, Clone)]
59pub struct WorkspaceVersionValidateOptions {
60 pub package: Option<String>,
61 pub cargo_check: bool,
62 pub package_dry_run: bool,
63}
64
65#[derive(Debug, Clone)]
66pub struct WorkspacePublishPlanOptions {
67 pub only: Option<String>,
68 pub include_prereqs: bool,
69}
70
71#[derive(Debug, Clone)]
72pub struct WorkspacePublishHealOptions {
73 pub only: Option<String>,
74 pub include_prereqs: bool,
75 pub write: bool,
76 pub version: Option<String>,
77}
78
79#[derive(Debug, Clone)]
80pub struct WorkspacePublishRunOptions {
81 pub dry_run: bool,
82 pub from: Option<String>,
83 pub only: Option<String>,
84 pub include_prereqs: bool,
85 pub continue_on_error: bool,
86 pub allow_dirty: bool,
87 pub auto_fix: bool,
88 pub timeout_seconds: f64,
89 pub poll_interval_seconds: f64,
90}
91
92#[derive(Debug, Default, Clone, Deserialize)]
93struct WorkspaceReleaseConfig {
94 #[serde(default)]
95 version_coupled_manifests: Vec<String>,
96 #[serde(default)]
97 metadata_files: Vec<String>,
98 #[serde(default)]
99 publish: WorkspaceReleasePublishConfig,
100}
101
102#[derive(Debug, Default, Clone, Deserialize)]
103struct WorkspaceReleasePublishConfig {
104 #[serde(default)]
105 exclude: Vec<String>,
106 #[serde(default)]
107 order: Vec<String>,
108}
109
110#[derive(Debug, Clone)]
111struct ReleaseSurface {
112 repo_root: PathBuf,
113 config_path: Option<PathBuf>,
114 config: WorkspaceReleaseConfig,
115 packages: Vec<ReleasePackage>,
116 metadata_files: Vec<MetadataVersionFile>,
117 cargo_lock: Option<PathBuf>,
118 root_package_name: Option<String>,
119}
120
121#[derive(Debug, Clone)]
122struct ReleasePackage {
123 name: String,
124 manifest_path: PathBuf,
125 manifest_relative: String,
126 version: Version,
127 publishable: bool,
128 publish_excluded: bool,
129 dependency_pins: Vec<LocalDependencyPin>,
130 publish_internal_dependencies: Vec<String>,
131 publish_missing_version_pins: Vec<String>,
132}
133
134#[derive(Debug, Clone)]
135struct LocalDependencyPin {
136 field: String,
137 version: Option<String>,
138}
139
140#[derive(Debug, Clone)]
141struct MetadataVersionFile {
142 path: PathBuf,
143 relative: String,
144}
145
146#[derive(Debug, Clone, Serialize)]
147struct DriftEntry {
148 path: String,
149 field: String,
150 actual: Option<String>,
151 expected: String,
152}
153
154#[derive(Debug, Clone, Serialize)]
155struct SyncEdit {
156 path: String,
157 field: String,
158 before: Option<String>,
159 after: String,
160}
161
162#[derive(Debug, Clone, Serialize)]
163pub(crate) struct PublishPlanItem {
164 pub package: String,
165 pub manifest: String,
166 pub version: String,
167 pub publishable: bool,
168 pub crates_io_visible: Option<bool>,
169 pub publish_needed: bool,
170 pub blocked_by: Vec<String>,
171 pub reason: String,
172}
173
174#[derive(Debug, Clone, Serialize)]
175struct ValidationCommandResult {
176 command: String,
177 success: bool,
178 exit_code: Option<i32>,
179 stderr: String,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 warning: Option<String>,
182}
183
184#[derive(Debug, Clone, Serialize)]
185struct WorkspaceCheckReport {
186 repo_root: String,
187 expected_version: String,
188 aligned: bool,
189 drift: Vec<DriftEntry>,
190}
191
192#[derive(Debug, Clone, Serialize)]
193struct WorkspaceSyncReport {
194 repo_root: String,
195 expected_version: String,
196 write: bool,
197 changed: bool,
198 files_changed: Vec<String>,
199 edits: Vec<SyncEdit>,
200}
201
202#[derive(Debug, Clone, Serialize)]
203struct WorkspacePublishPlanReport {
204 repo_root: String,
205 requested_package: Option<String>,
206 included_prereqs: Vec<String>,
207 required_closure: Vec<String>,
208 packages: Vec<PublishPlanItem>,
209 publish_order: Vec<String>,
210 #[serde(default, skip_serializing_if = "Vec::is_empty")]
211 surface_issues: Vec<PublishSurfaceIssue>,
212}
213
214#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
215#[serde(rename_all = "snake_case")]
216pub(crate) enum PublishSurfaceIssueKind {
217 PublishDisabled,
218 PublishExcluded,
219 MissingVersionPin,
220 WorkspaceDepVersionDrift,
221 MissingPackageMetadata,
222}
223
224#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
225pub(crate) struct PublishSurfaceIssue {
226 pub package: String,
227 pub manifest: String,
228 pub kind: PublishSurfaceIssueKind,
229 pub detail: String,
230 pub auto_fixable: bool,
231}
232
233#[derive(Debug, Clone, Serialize)]
234struct WorkspacePublishHealReport {
235 repo_root: String,
236 write: bool,
237 expected_version: String,
238 issues: Vec<PublishSurfaceIssue>,
239 edits: Vec<SyncEdit>,
240 files_changed: Vec<String>,
241}
242
243#[derive(Debug, Clone)]
244pub(crate) struct PublishSurfaceHealSummary {
245 pub expected_version: Version,
246 pub issue_count: usize,
247 pub auto_fixable_count: usize,
248 pub preview: Vec<String>,
249}
250
251#[derive(Debug, Clone, Serialize)]
252struct WorkspaceValidateReport {
253 repo_root: String,
254 ok: bool,
255 issues: Vec<DriftEntry>,
256 commands: Vec<ValidationCommandResult>,
257}
258
259#[derive(Debug, Clone, Serialize)]
260struct WorkspacePublishRunReport {
261 repo_root: String,
262 dry_run: bool,
263 requested_package: Option<String>,
264 included_prereqs: Vec<String>,
265 required_closure: Vec<String>,
266 published: Vec<String>,
267 skipped: Vec<String>,
268 failed: Vec<String>,
269}
270
271#[derive(Debug, Clone, Serialize)]
272pub(crate) struct WorkspacePublishCommandTarget {
273 pub package: String,
274 pub version: String,
275 pub manifest_path: PathBuf,
276 pub manifest_relative: String,
277}
278
279#[derive(Debug, Clone, Serialize)]
280pub(crate) struct ManifestWorkspacePublishResolution {
281 pub workspace_root: PathBuf,
282 pub requested_package: String,
283 pub included_prereqs: Vec<String>,
284 pub required_closure: Vec<String>,
285 pub packages: Vec<PublishPlanItem>,
286 pub publish_order: Vec<WorkspacePublishCommandTarget>,
287}
288
289#[derive(Debug, Deserialize)]
290struct CargoMetadata {
291 packages: Vec<CargoMetadataPackage>,
292 workspace_members: Vec<String>,
293 workspace_root: String,
294}
295
296#[derive(Debug, Deserialize)]
297struct CargoMetadataPackage {
298 id: String,
299 manifest_path: String,
300}
301
302pub async fn run_version_workspace_command(
303 options: WorkspaceVersionCommandOptions,
304) -> Result<(), String> {
305 let repo_root = match options.repo {
306 Some(path) => path,
307 None => resolve_project_root(),
308 };
309 let surface = discover_release_surface(&repo_root)?;
310
311 match options.command {
312 WorkspaceVersionCommand::Check(check) => {
313 let expected = resolve_expected_version(&surface, check.version.as_deref())?;
314 let drift = collect_drift(&surface, &expected)?;
315 let report = WorkspaceCheckReport {
316 repo_root: display_path(&surface.repo_root),
317 expected_version: expected.to_string(),
318 aligned: drift.is_empty(),
319 drift,
320 };
321 if options.json {
322 println!(
323 "{}",
324 serde_json::to_string_pretty(&report)
325 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
326 );
327 } else {
328 print_check_report(&surface, &report);
329 }
330 if report.aligned {
331 Ok(())
332 } else {
333 Err("Workspace release drift detected.".to_string())
334 }
335 }
336 WorkspaceVersionCommand::Sync(sync) => {
337 let expected = resolve_expected_version(&surface, sync.version.as_deref())?;
338 let (edits, changed_files) = apply_sync(&surface, &expected, sync.write)?;
339 let report = WorkspaceSyncReport {
340 repo_root: display_path(&surface.repo_root),
341 expected_version: expected.to_string(),
342 write: sync.write,
343 changed: !edits.is_empty(),
344 files_changed: changed_files,
345 edits,
346 };
347 if options.json {
348 println!(
349 "{}",
350 serde_json::to_string_pretty(&report)
351 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
352 );
353 } else {
354 print_sync_report(&surface, &report);
355 }
356 Ok(())
357 }
358 WorkspaceVersionCommand::Validate(validate) => {
359 let report = run_validation(&surface, &validate)?;
360 if options.json {
361 println!(
362 "{}",
363 serde_json::to_string_pretty(&report)
364 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
365 );
366 } else {
367 print_validation_report(&surface, &report);
368 }
369 if report.ok {
370 Ok(())
371 } else {
372 Err("Workspace validation failed.".to_string())
373 }
374 }
375 WorkspaceVersionCommand::PublishPlan(plan) => {
376 let report = build_publish_plan_report(&surface, &plan).await?;
377 if options.json {
378 println!(
379 "{}",
380 serde_json::to_string_pretty(&report)
381 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
382 );
383 } else {
384 print_publish_plan_report(&surface, &report);
385 }
386 Ok(())
387 }
388 WorkspaceVersionCommand::PublishHeal(heal) => {
389 let report = run_publish_heal(&surface, &heal)?;
390 if options.json {
391 println!(
392 "{}",
393 serde_json::to_string_pretty(&report)
394 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
395 );
396 } else {
397 print_publish_heal_report(&surface, &report);
398 }
399 Ok(())
400 }
401 WorkspaceVersionCommand::PublishRun(run) => {
402 let surface = if run.auto_fix {
403 let selection = PublishSelection {
404 from: run.from.clone(),
405 only: run.only.clone(),
406 include_prereqs: run.include_prereqs,
407 };
408 let expected = resolve_expected_version(&surface, None)?;
409 let issues = collect_publish_surface_issues(&surface, Some(&selection), &expected)?;
410 let auto_fixable = issues.iter().any(|issue| issue.auto_fixable);
411 if auto_fixable {
412 let (_edits, files) =
413 apply_publish_surface_heals(&surface, Some(&selection), &expected, true)?;
414 if !files.is_empty() {
415 println!(
416 "Auto-fixed publish surface ({} file(s)): {}",
417 files.len(),
418 files.join(", ")
419 );
420 }
421 discover_release_surface(&repo_root)?
422 } else {
423 surface
424 }
425 } else {
426 surface
427 };
428 let report = run_publish(&surface, &run).await?;
429 if options.json {
430 println!(
431 "{}",
432 serde_json::to_string_pretty(&report)
433 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
434 );
435 } else {
436 print_publish_run_report(&surface, &report);
437 }
438 if report.failed.is_empty() {
439 Ok(())
440 } else {
441 Err("Workspace publish run failed.".to_string())
442 }
443 }
444 }
445}
446
447fn discover_release_surface(repo_root: &Path) -> Result<ReleaseSurface, String> {
448 let metadata = load_cargo_metadata(repo_root)?;
449 discover_release_surface_from_metadata(metadata)
450}
451
452fn discover_release_surface_from_manifest(manifest_path: &Path) -> Result<ReleaseSurface, String> {
453 let metadata = load_cargo_metadata_for_manifest(manifest_path)?;
454 discover_release_surface_from_metadata(metadata)
455}
456
457fn discover_release_surface_from_metadata(
458 metadata: CargoMetadata,
459) -> Result<ReleaseSurface, String> {
460 let repo_root = PathBuf::from(&metadata.workspace_root);
461 let config_path = CONFIG_CANDIDATES
462 .iter()
463 .map(|candidate| repo_root.join(candidate))
464 .find(|path| path.exists());
465 let config = load_workspace_release_config(config_path.as_deref())?;
466
467 let workspace_member_ids: BTreeSet<String> = metadata.workspace_members.into_iter().collect();
468 let mut candidate_manifests = Vec::new();
469 for package in metadata.packages {
470 if !workspace_member_ids.contains(&package.id) {
471 continue;
472 }
473 candidate_manifests.push(PathBuf::from(package.manifest_path));
474 }
475 for relative in &config.version_coupled_manifests {
476 let manifest = repo_root.join(relative);
477 if !candidate_manifests
478 .iter()
479 .any(|existing| existing == &manifest)
480 {
481 candidate_manifests.push(manifest);
482 }
483 }
484
485 let mut basic_packages = Vec::new();
486 for manifest_path in candidate_manifests {
487 basic_packages.push(read_basic_manifest_info(&manifest_path, &config)?);
488 }
489 basic_packages.sort_by(|a, b| a.name.cmp(&b.name));
490
491 let release_names: BTreeSet<String> = basic_packages
492 .iter()
493 .map(|package| package.name.clone())
494 .collect();
495 let mut packages = Vec::new();
496 for basic in basic_packages {
497 packages.push(read_release_package(&repo_root, basic, &release_names)?);
498 }
499
500 let root_manifest = repo_root.join("Cargo.toml");
501 let root_package_name = packages
502 .iter()
503 .find(|package| package.manifest_path == root_manifest)
504 .map(|package| package.name.clone());
505
506 let mut seen = BTreeSet::new();
507 let mut metadata_files = Vec::new();
508 for relative in DEFAULT_METADATA_FILES
509 .iter()
510 .copied()
511 .chain(config.metadata_files.iter().map(String::as_str))
512 {
513 if !seen.insert(relative.to_string()) {
514 continue;
515 }
516 let path = repo_root.join(relative);
517 if !path.exists() {
518 continue;
519 }
520 if read_metadata_version(&path)?.is_some() {
521 metadata_files.push(MetadataVersionFile {
522 relative: normalize_relative(&repo_root, &path),
523 path,
524 });
525 }
526 }
527
528 let cargo_lock = repo_root.join("Cargo.lock");
529 Ok(ReleaseSurface {
530 repo_root,
531 config_path,
532 config,
533 packages,
534 metadata_files,
535 cargo_lock: cargo_lock.exists().then_some(cargo_lock),
536 root_package_name,
537 })
538}
539
540#[derive(Debug, Clone)]
541struct BasicManifestInfo {
542 name: String,
543 manifest_path: PathBuf,
544 publishable: bool,
545 publish_excluded: bool,
546}
547
548fn read_basic_manifest_info(
549 manifest_path: &Path,
550 config: &WorkspaceReleaseConfig,
551) -> Result<BasicManifestInfo, String> {
552 let content = fs::read_to_string(manifest_path)
553 .map_err(|e| format!("Failed to read {}: {}", manifest_path.display(), e))?;
554 let value: TomlValue =
555 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
556 let package = value
557 .get("package")
558 .and_then(TomlValue::as_table)
559 .ok_or_else(|| format!("Expected [package] in {}", manifest_path.display()))?;
560 let name = package
561 .get("name")
562 .and_then(TomlValue::as_str)
563 .ok_or_else(|| format!("Missing package.name in {}", manifest_path.display()))?
564 .to_string();
565 let publishable = match package.get("publish") {
566 Some(TomlValue::Boolean(false)) => false,
567 Some(TomlValue::Array(values)) if values.is_empty() => false,
568 _ => true,
569 };
570 Ok(BasicManifestInfo {
571 name: name.clone(),
572 manifest_path: manifest_path.to_path_buf(),
573 publishable,
574 publish_excluded: config.publish.exclude.iter().any(|value| value == &name),
575 })
576}
577
578fn read_release_package(
579 repo_root: &Path,
580 basic: BasicManifestInfo,
581 release_names: &BTreeSet<String>,
582) -> Result<ReleasePackage, String> {
583 let content = fs::read_to_string(&basic.manifest_path)
584 .map_err(|e| format!("Failed to read {}: {}", basic.manifest_path.display(), e))?;
585 let value: TomlValue =
586 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
587 let version = resolve_cargo_package_version(&basic.manifest_path)?
588 .ok_or_else(|| {
589 format!(
590 "Missing package.version in {}",
591 basic.manifest_path.display()
592 )
593 })
594 .and_then(|value| parse_version(&value))?;
595 let dependency_analysis = analyze_local_dependencies_from_toml(&value, release_names);
596
597 Ok(ReleasePackage {
598 name: basic.name,
599 manifest_path: basic.manifest_path.clone(),
600 manifest_relative: normalize_relative(repo_root, &basic.manifest_path),
601 version,
602 publishable: basic.publishable,
603 publish_excluded: basic.publish_excluded,
604 dependency_pins: dependency_analysis.dependency_pins,
605 publish_internal_dependencies: dependency_analysis.publish_internal_dependencies,
606 publish_missing_version_pins: dependency_analysis.publish_missing_version_pins,
607 })
608}
609
610#[derive(Debug, Default)]
611struct DependencyAnalysis {
612 dependency_pins: Vec<LocalDependencyPin>,
613 publish_internal_dependencies: Vec<String>,
614 publish_missing_version_pins: Vec<String>,
615}
616
617fn analyze_local_dependencies_from_toml(
618 value: &TomlValue,
619 release_names: &BTreeSet<String>,
620) -> DependencyAnalysis {
621 let mut pins = Vec::new();
622 let mut publish_internal_dependencies = BTreeSet::new();
623 let mut publish_missing_version_pins = Vec::new();
624 collect_dependency_pins_from_table(
625 value,
626 "",
627 release_names,
628 &mut pins,
629 &mut publish_internal_dependencies,
630 &mut publish_missing_version_pins,
631 );
632 pins.sort_by(|a, b| a.field.cmp(&b.field));
633 publish_missing_version_pins.sort();
634 DependencyAnalysis {
635 dependency_pins: pins,
636 publish_internal_dependencies: publish_internal_dependencies.into_iter().collect(),
637 publish_missing_version_pins,
638 }
639}
640
641fn collect_dependency_pins_from_table(
642 value: &TomlValue,
643 prefix: &str,
644 release_names: &BTreeSet<String>,
645 pins: &mut Vec<LocalDependencyPin>,
646 publish_internal_dependencies: &mut BTreeSet<String>,
647 publish_missing_version_pins: &mut Vec<String>,
648) {
649 let Some(table) = value.as_table() else {
650 return;
651 };
652
653 for (key, entry) in table {
654 let path = if prefix.is_empty() {
655 key.to_string()
656 } else {
657 format!("{}.{}", prefix, key)
658 };
659 if matches!(
660 key.as_str(),
661 "dependencies" | "dev-dependencies" | "build-dependencies"
662 ) {
663 let publish_relevant = !matches!(key.as_str(), "dev-dependencies");
664 collect_dependency_section(
665 path.as_str(),
666 entry,
667 release_names,
668 publish_relevant,
669 pins,
670 publish_internal_dependencies,
671 publish_missing_version_pins,
672 );
673 continue;
674 }
675 collect_dependency_pins_from_table(
676 entry,
677 &path,
678 release_names,
679 pins,
680 publish_internal_dependencies,
681 publish_missing_version_pins,
682 );
683 }
684}
685
686fn collect_dependency_section(
687 section_name: &str,
688 value: &TomlValue,
689 release_names: &BTreeSet<String>,
690 publish_relevant: bool,
691 pins: &mut Vec<LocalDependencyPin>,
692 publish_internal_dependencies: &mut BTreeSet<String>,
693 publish_missing_version_pins: &mut Vec<String>,
694) {
695 let Some(table) = value.as_table() else {
696 return;
697 };
698 for (dependency_name, dependency_value) in table {
699 let Some(release_dependency_name) = resolve_release_dependency_name(
700 dependency_name,
701 dependency_value.as_table(),
702 release_names,
703 ) else {
704 continue;
705 };
706 let Some(detail) = dependency_value.as_table() else {
707 continue;
708 };
709 let uses_workspace = detail
710 .get("workspace")
711 .and_then(TomlValue::as_bool)
712 .unwrap_or(false);
713 let uses_path = detail.contains_key("path");
714 if !uses_path && !uses_workspace {
715 continue;
716 }
717 if publish_relevant {
718 publish_internal_dependencies.insert(release_dependency_name);
719 }
720 if !uses_path {
721 continue;
722 }
723 let field = format!("{}.{}.version", section_name, dependency_name);
724 let version = detail
725 .get("version")
726 .and_then(TomlValue::as_str)
727 .map(|value| value.to_string());
728 if publish_relevant && version.is_none() {
729 publish_missing_version_pins.push(field.clone());
730 }
731 pins.push(LocalDependencyPin { field, version });
732 }
733}
734
735fn resolve_release_dependency_name(
736 dependency_name: &str,
737 detail: Option<&toml::map::Map<String, TomlValue>>,
738 release_names: &BTreeSet<String>,
739) -> Option<String> {
740 if release_names.contains(dependency_name) {
741 return Some(dependency_name.to_string());
742 }
743
744 let aliased_package_name = detail
745 .and_then(|detail| detail.get("package"))
746 .and_then(TomlValue::as_str)?;
747 if release_names.contains(aliased_package_name) {
748 return Some(aliased_package_name.to_string());
749 }
750
751 None
752}
753
754fn collect_drift(surface: &ReleaseSurface, expected: &Version) -> Result<Vec<DriftEntry>, String> {
755 let expected_text = expected.to_string();
756 let mut drift = Vec::new();
757
758 for package in &surface.packages {
759 if package.version != *expected {
760 drift.push(DriftEntry {
761 path: package.manifest_relative.clone(),
762 field: "package.version".to_string(),
763 actual: Some(package.version.to_string()),
764 expected: expected_text.clone(),
765 });
766 }
767 for pin in &package.dependency_pins {
768 if pin.version.as_deref() != Some(expected_text.as_str()) {
769 drift.push(DriftEntry {
770 path: package.manifest_relative.clone(),
771 field: pin.field.clone(),
772 actual: pin.version.clone(),
773 expected: expected_text.clone(),
774 });
775 }
776 }
777 }
778
779 for metadata in &surface.metadata_files {
780 let actual = read_metadata_version(&metadata.path)?;
781 if actual.as_deref() != Some(expected_text.as_str()) {
782 drift.push(DriftEntry {
783 path: metadata.relative.clone(),
784 field: "version".to_string(),
785 actual,
786 expected: expected_text.clone(),
787 });
788 }
789 }
790
791 if let Some(cargo_lock) = surface.cargo_lock.as_ref() {
792 for package in &surface.packages {
793 let actual = read_cargo_lock_version_for_package(cargo_lock, &package.name)?;
794 if let Some(actual_version) = actual {
795 if actual_version != expected_text {
796 drift.push(DriftEntry {
797 path: "Cargo.lock".to_string(),
798 field: format!("package.{}.version", package.name),
799 actual: Some(actual_version),
800 expected: expected_text.clone(),
801 });
802 }
803 }
804 }
805 }
806
807 drift.sort_by(|a, b| a.path.cmp(&b.path).then(a.field.cmp(&b.field)));
808 Ok(drift)
809}
810
811fn apply_sync(
812 surface: &ReleaseSurface,
813 expected: &Version,
814 write: bool,
815) -> Result<(Vec<SyncEdit>, Vec<String>), String> {
816 let release_names: BTreeSet<String> = surface
817 .packages
818 .iter()
819 .map(|package| package.name.clone())
820 .collect();
821 let mut edits = Vec::new();
822 let mut changed_files = BTreeSet::new();
823 let expected_text = expected.to_string();
824
825 for package in &surface.packages {
826 let file_edits = sync_manifest_versions(package, &release_names, &expected_text, write)?;
827 if !file_edits.is_empty() {
828 changed_files.insert(package.manifest_relative.clone());
829 edits.extend(file_edits);
830 }
831 }
832
833 for metadata in &surface.metadata_files {
834 let actual = read_metadata_version(&metadata.path)?;
835 if actual.as_deref() == Some(expected_text.as_str()) {
836 continue;
837 }
838 if write {
839 write_metadata_version(&metadata.path, expected)?;
840 }
841 changed_files.insert(metadata.relative.clone());
842 edits.push(SyncEdit {
843 path: metadata.relative.clone(),
844 field: "version".to_string(),
845 before: actual,
846 after: expected_text.clone(),
847 });
848 }
849
850 if let Some(cargo_lock) = surface.cargo_lock.as_ref() {
851 for package in &surface.packages {
852 let actual = read_cargo_lock_version_for_package(cargo_lock, &package.name)?;
853 if actual.as_deref() == Some(expected_text.as_str()) {
854 continue;
855 }
856 if actual.is_none() {
857 continue;
858 }
859 if write {
860 write_cargo_lock_version_for_package(cargo_lock, Some(&package.name), expected)?;
861 }
862 changed_files.insert("Cargo.lock".to_string());
863 edits.push(SyncEdit {
864 path: "Cargo.lock".to_string(),
865 field: format!("package.{}.version", package.name),
866 before: actual,
867 after: expected_text.clone(),
868 });
869 }
870 }
871
872 let changed_files = changed_files.into_iter().collect::<Vec<_>>();
873 Ok((edits, changed_files))
874}
875
876fn sync_manifest_versions(
877 package: &ReleasePackage,
878 release_names: &BTreeSet<String>,
879 expected: &str,
880 write: bool,
881) -> Result<Vec<SyncEdit>, String> {
882 let content = fs::read_to_string(&package.manifest_path)
883 .map_err(|e| format!("Failed to read {}: {}", package.manifest_path.display(), e))?;
884 let mut doc = content
885 .parse::<DocumentMut>()
886 .map_err(|e| format!("Failed to parse {}: {}", package.manifest_path.display(), e))?;
887 let mut edits = Vec::new();
888
889 let current_package_version = doc["package"]["version"].as_str().map(str::to_string);
890 if current_package_version.as_deref() != Some(expected) {
891 edits.push(SyncEdit {
892 path: package.manifest_relative.clone(),
893 field: "package.version".to_string(),
894 before: current_package_version,
895 after: expected.to_string(),
896 });
897 if write {
898 doc["package"]["version"] = value(expected);
899 }
900 }
901
902 sync_dependency_tables_in_item(
903 doc.as_item_mut(),
904 "",
905 release_names,
906 expected,
907 &package.manifest_relative,
908 &mut edits,
909 );
910
911 if write && !edits.is_empty() {
912 fs::write(&package.manifest_path, doc.to_string())
913 .map_err(|e| format!("Failed to write {}: {}", package.manifest_path.display(), e))?;
914 }
915
916 Ok(edits)
917}
918
919fn sync_dependency_tables_in_item(
920 item: &mut Item,
921 prefix: &str,
922 release_names: &BTreeSet<String>,
923 expected: &str,
924 manifest_relative: &str,
925 edits: &mut Vec<SyncEdit>,
926) {
927 let Some(table) = item.as_table_mut() else {
928 return;
929 };
930
931 let keys = table
932 .iter()
933 .map(|(key, _)| key.to_string())
934 .collect::<Vec<_>>();
935 for key in keys {
936 let next_prefix = if prefix.is_empty() {
937 key.clone()
938 } else {
939 format!("{}.{}", prefix, key)
940 };
941 let Some(child) = table.get_mut(&key) else {
942 continue;
943 };
944 if matches!(
945 key.as_str(),
946 "dependencies" | "dev-dependencies" | "build-dependencies"
947 ) {
948 if let Some(dep_table) = child.as_table_mut() {
949 sync_dependency_entries(
950 dep_table,
951 &next_prefix,
952 release_names,
953 expected,
954 manifest_relative,
955 edits,
956 );
957 }
958 continue;
959 }
960 sync_dependency_tables_in_item(
961 child,
962 &next_prefix,
963 release_names,
964 expected,
965 manifest_relative,
966 edits,
967 );
968 }
969}
970
971fn sync_dependency_entries(
972 table: &mut Table,
973 section_name: &str,
974 release_names: &BTreeSet<String>,
975 expected: &str,
976 manifest_relative: &str,
977 edits: &mut Vec<SyncEdit>,
978) {
979 let keys = table
980 .iter()
981 .map(|(key, _)| key.to_string())
982 .collect::<Vec<_>>();
983 for dependency_name in keys {
984 let Some(item) = table.get_mut(&dependency_name) else {
985 continue;
986 };
987 if !item_matches_release_dependency(&dependency_name, item, release_names) {
988 continue;
989 }
990 match item {
991 Item::Value(value_item) => {
992 let Some(inline) = value_item.as_inline_table_mut() else {
993 continue;
994 };
995 if inline.get("path").is_none() {
996 continue;
997 }
998 let before = inline
999 .get("version")
1000 .and_then(Value::as_str)
1001 .map(|value| value.to_string());
1002 if before.as_deref() == Some(expected) {
1003 continue;
1004 }
1005 inline.insert("version", Value::from(expected));
1006 edits.push(SyncEdit {
1007 path: manifest_relative.to_string(),
1008 field: format!("{}.{}.version", section_name, dependency_name),
1009 before,
1010 after: expected.to_string(),
1011 });
1012 }
1013 Item::Table(dep_table) => {
1014 if dep_table.get("path").is_none() {
1015 continue;
1016 }
1017 let before = dep_table
1018 .get("version")
1019 .and_then(Item::as_str)
1020 .map(|value| value.to_string());
1021 if before.as_deref() == Some(expected) {
1022 continue;
1023 }
1024 dep_table["version"] = value(expected);
1025 edits.push(SyncEdit {
1026 path: manifest_relative.to_string(),
1027 field: format!("{}.{}.version", section_name, dependency_name),
1028 before,
1029 after: expected.to_string(),
1030 });
1031 }
1032 _ => {}
1033 }
1034 }
1035}
1036
1037fn item_matches_release_dependency(
1038 dependency_name: &str,
1039 item: &Item,
1040 release_names: &BTreeSet<String>,
1041) -> bool {
1042 if release_names.contains(dependency_name) {
1043 return true;
1044 }
1045
1046 let aliased_package_name = match item {
1047 Item::Value(value_item) => value_item
1048 .as_inline_table()
1049 .and_then(|table| table.get("package"))
1050 .and_then(Value::as_str),
1051 Item::Table(table) => table.get("package").and_then(Item::as_str),
1052 _ => None,
1053 };
1054
1055 aliased_package_name
1056 .map(|package_name| release_names.contains(package_name))
1057 .unwrap_or(false)
1058}
1059
1060fn run_validation(
1061 surface: &ReleaseSurface,
1062 options: &WorkspaceVersionValidateOptions,
1063) -> Result<WorkspaceValidateReport, String> {
1064 let mut issues = match resolve_expected_version(surface, None) {
1065 Ok(expected) => collect_drift(surface, &expected)?,
1066 Err(error) if error.contains("Workspace root has no package.version") => Vec::new(),
1067 Err(error) => return Err(error),
1068 };
1069 if let Some(package_name) = options.package.as_deref() {
1070 issues.retain(|issue| {
1071 issue.path == "Cargo.lock"
1072 || issue.path.ends_with("README.md")
1073 || issue.path.ends_with("openapi.yaml")
1074 || issue.path.ends_with("openapi.yml")
1075 || issue.path.ends_with("openapi.json")
1076 || issue.field.contains(package_name)
1077 || issue.path.contains(package_name)
1078 });
1079 }
1080
1081 let mut commands = Vec::new();
1082 if options.cargo_check {
1083 let mut command = Command::new(cargo_program());
1084 command
1085 .current_dir(&surface.repo_root)
1086 .arg("check")
1087 .arg("-q");
1088 if let Some(package) = options.package.as_deref() {
1089 command.arg("-p").arg(package);
1090 }
1091 commands.push(run_command_capture(command, "cargo check -q")?);
1092 }
1093
1094 if options.package_dry_run {
1095 commands.extend(run_package_dry_run_validation(
1096 surface,
1097 options.package.as_deref(),
1098 )?);
1099 }
1100
1101 let ok = issues.is_empty() && commands.iter().all(|result| result.success);
1102 Ok(WorkspaceValidateReport {
1103 repo_root: display_path(&surface.repo_root),
1104 ok,
1105 issues,
1106 commands,
1107 })
1108}
1109
1110fn select_packages_for_validation<'a>(
1111 surface: &'a ReleaseSurface,
1112 package_name: Option<&str>,
1113) -> Result<Vec<&'a ReleasePackage>, String> {
1114 if let Some(package_name) = package_name {
1115 let package = surface
1116 .packages
1117 .iter()
1118 .find(|package| package.name == package_name)
1119 .ok_or_else(|| format!("Unknown workspace package `{}`.", package_name))?;
1120 return Ok(vec![package]);
1121 }
1122 Ok(surface
1123 .packages
1124 .iter()
1125 .filter(|package| package.publishable && !package.publish_excluded)
1126 .collect())
1127}
1128
1129fn select_ordered_packages_for_validation<'a>(
1130 surface: &'a ReleaseSurface,
1131 package_name: Option<&str>,
1132) -> Result<Vec<&'a ReleasePackage>, String> {
1133 let selected = select_packages_for_validation(surface, package_name)?;
1134 let selected_names: BTreeSet<String> = selected
1135 .iter()
1136 .map(|package| package.name.clone())
1137 .collect();
1138 let ordered_names = topological_package_order(surface)?;
1139 Ok(ordered_names
1140 .into_iter()
1141 .filter(|name| selected_names.contains(name))
1142 .filter_map(|name| {
1143 selected
1144 .iter()
1145 .find(|package| package.name == name)
1146 .copied()
1147 })
1148 .collect())
1149}
1150
1151fn run_package_dry_run_validation(
1152 surface: &ReleaseSurface,
1153 package_name: Option<&str>,
1154) -> Result<Vec<ValidationCommandResult>, String> {
1155 let packages = select_ordered_packages_for_validation(surface, package_name)?;
1156 let mut results = Vec::new();
1157
1158 for package in packages {
1159 let label = format!(
1160 "cargo publish --dry-run --locked --no-verify --manifest-path {}",
1161 package.manifest_relative
1162 );
1163 let repo_root = surface.repo_root.clone();
1164 let manifest_path = package.manifest_path.clone();
1165 let mut result = run_publish_dry_run_capture(&repo_root, &manifest_path, &label)?;
1166 if !result.success {
1167 if is_unpublished_workspace_dependency_error(&result.stderr) {
1168 let fallback_label = format!(
1169 "cargo package --allow-dirty --no-verify --manifest-path {}",
1170 package.manifest_relative
1171 );
1172 let fallback_result =
1173 run_package_capture(&repo_root, &manifest_path, &fallback_label)?;
1174 if fallback_result.success {
1175 result = ValidationCommandResult {
1176 command: fallback_label,
1177 success: true,
1178 exit_code: fallback_result.exit_code,
1179 stderr: fallback_result.stderr,
1180 warning: Some(format!(
1181 "Registry dry-run skipped for `{}`: workspace dependencies are not on crates.io yet. Local packaging succeeded.",
1182 package.name
1183 )),
1184 };
1185 } else {
1186 result.stderr = append_validation_hint(&result.stderr);
1187 }
1188 } else {
1189 result.stderr = append_validation_hint(&result.stderr);
1190 }
1191 }
1192 results.push(result);
1193 }
1194
1195 Ok(results)
1196}
1197
1198fn is_unpublished_workspace_dependency_error(stderr: &str) -> bool {
1199 stderr.contains("no matching package named")
1200 || stderr.contains("failed to select a version for the requirement")
1201}
1202
1203fn is_transient_registry_error(stderr: &str) -> bool {
1204 stderr.contains("Could not resolve host")
1205 || stderr.contains("Could not resolve hostname")
1206 || stderr.contains("failed to update registry")
1207 || stderr.contains("download of config.json failed")
1208}
1209
1210fn append_validation_hint(stderr: &str) -> String {
1211 let mut message = stderr.to_string();
1212 if stderr.contains("Access is denied") || stderr.contains("os error 5") {
1213 message.push_str(
1214 "\nHint: close any running `xbp` processes before package dry-run validation on Windows.",
1215 );
1216 }
1217 if is_transient_registry_error(stderr) {
1218 message
1219 .push_str("\nHint: crates.io was unreachable. Check network connectivity and retry.");
1220 }
1221 if is_unpublished_workspace_dependency_error(stderr) {
1222 message.push_str(
1223 "\nHint: publish workspace dependencies first, or rely on the local `cargo package` fallback.",
1224 );
1225 }
1226 message
1227}
1228
1229fn run_publish_dry_run_capture(
1230 repo_root: &Path,
1231 manifest_path: &Path,
1232 label: &str,
1233) -> Result<ValidationCommandResult, String> {
1234 let build = || {
1235 let mut command = Command::new(cargo_program());
1236 command
1237 .current_dir(repo_root)
1238 .arg("publish")
1239 .arg("--dry-run")
1240 .arg("--locked")
1241 .arg("--no-verify")
1242 .arg("--manifest-path")
1243 .arg(manifest_path);
1244 command
1245 };
1246 run_command_capture_with_retry(build, label)
1247}
1248
1249fn run_package_capture(
1250 repo_root: &Path,
1251 manifest_path: &Path,
1252 label: &str,
1253) -> Result<ValidationCommandResult, String> {
1254 let build = || {
1255 let mut command = Command::new(cargo_program());
1256 command
1257 .current_dir(repo_root)
1258 .arg("package")
1259 .arg("--allow-dirty")
1260 .arg("--no-verify")
1261 .arg("--manifest-path")
1262 .arg(manifest_path);
1263 command
1264 };
1265 run_command_capture_with_retry(build, label)
1266}
1267
1268fn run_command_capture_with_retry(
1269 build_command: impl Fn() -> Command,
1270 label: &str,
1271) -> Result<ValidationCommandResult, String> {
1272 let first = run_command_capture(build_command(), label)?;
1273 if first.success || !is_transient_registry_error(&first.stderr) {
1274 return Ok(first);
1275 }
1276
1277 thread::sleep(StdDuration::from_secs(2));
1278 let mut retry = run_command_capture(build_command(), format!("{label} (retry)"))?;
1279 if !retry.success {
1280 retry.stderr = append_validation_hint(&retry.stderr);
1281 }
1282 Ok(retry)
1283}
1284
1285async fn build_publish_plan_report(
1286 surface: &ReleaseSurface,
1287 options: &WorkspacePublishPlanOptions,
1288) -> Result<WorkspacePublishPlanReport, String> {
1289 let visibility = collect_crates_io_visibility(surface).await?;
1290 let selection = PublishSelection {
1291 from: None,
1292 only: options.only.clone(),
1293 include_prereqs: options.include_prereqs,
1294 };
1295 let plan = build_publish_plan(surface, &visibility, Some(&selection))?;
1296 let expected = resolve_expected_version(surface, None).unwrap_or_else(|_| {
1297 surface
1298 .packages
1299 .first()
1300 .map(|package| package.version.clone())
1301 .unwrap_or_else(|| Version::new(0, 0, 0))
1302 });
1303 let surface_issues = collect_publish_surface_issues(surface, Some(&selection), &expected)?;
1304 Ok(WorkspacePublishPlanReport {
1305 repo_root: display_path(&surface.repo_root),
1306 requested_package: plan.requested_package,
1307 included_prereqs: plan.included_prereqs,
1308 required_closure: plan.required_closure,
1309 packages: plan.items,
1310 publish_order: plan.publish_order,
1311 surface_issues,
1312 })
1313}
1314
1315fn run_publish_heal(
1316 surface: &ReleaseSurface,
1317 options: &WorkspacePublishHealOptions,
1318) -> Result<WorkspacePublishHealReport, String> {
1319 let selection = PublishSelection {
1320 from: None,
1321 only: options.only.clone(),
1322 include_prereqs: options.include_prereqs,
1323 };
1324 let expected = resolve_expected_version(surface, options.version.as_deref())?;
1325 let issues = collect_publish_surface_issues(surface, Some(&selection), &expected)?;
1326 let (edits, files_changed) =
1327 apply_publish_surface_heals(surface, Some(&selection), &expected, options.write)?;
1328 Ok(WorkspacePublishHealReport {
1329 repo_root: display_path(&surface.repo_root),
1330 write: options.write,
1331 expected_version: expected.to_string(),
1332 issues,
1333 edits,
1334 files_changed,
1335 })
1336}
1337
1338#[derive(Debug, Clone)]
1339pub(crate) struct WorkspaceVersionDriftSummary {
1340 pub expected_version: Version,
1341 pub drift_count: usize,
1342 pub preview: Vec<String>,
1343}
1344
1345pub(crate) fn inspect_workspace_version_drift(
1346 repo_root: &Path,
1347 expected_version: &Version,
1348) -> Result<Option<WorkspaceVersionDriftSummary>, String> {
1349 let surface = match discover_release_surface(repo_root) {
1350 Ok(surface) => surface,
1351 Err(_) => return Ok(None),
1352 };
1353 if surface.repo_root != repo_root {
1354 return Ok(None);
1355 }
1356 if surface.packages.len() <= 1 && surface.config_path.is_none() {
1357 return Ok(None);
1358 }
1359
1360 let drift = collect_drift(&surface, expected_version)?;
1361 if drift.is_empty() {
1362 return Ok(None);
1363 }
1364
1365 let preview = drift
1366 .iter()
1367 .take(6)
1368 .map(|entry| {
1369 format!(
1370 "{} {}: {} -> {}",
1371 entry.path,
1372 entry.field,
1373 entry.actual.as_deref().unwrap_or("<missing>"),
1374 entry.expected
1375 )
1376 })
1377 .collect();
1378
1379 Ok(Some(WorkspaceVersionDriftSummary {
1380 expected_version: expected_version.clone(),
1381 drift_count: drift.len(),
1382 preview,
1383 }))
1384}
1385
1386pub(crate) fn sync_workspace_to_version(
1387 repo_root: &Path,
1388 expected_version: &Version,
1389) -> Result<Vec<String>, String> {
1390 let surface = discover_release_surface(repo_root)?;
1391 let (_edits, changed_files) = apply_sync(&surface, expected_version, true)?;
1392 Ok(changed_files)
1393}
1394
1395pub(crate) fn inspect_publish_surface_for_release(
1398 repo_root: &Path,
1399 expected_version: &Version,
1400 only_package: Option<&str>,
1401) -> Result<Option<PublishSurfaceHealSummary>, String> {
1402 let surface = match discover_release_surface(repo_root) {
1403 Ok(surface) => surface,
1404 Err(_) => return Ok(None),
1405 };
1406 if surface.repo_root != repo_root {
1407 return Ok(None);
1408 }
1409 if surface.packages.len() <= 1 && surface.config_path.is_none() {
1410 return Ok(None);
1411 }
1412
1413 let selection = PublishSelection {
1414 from: None,
1415 only: only_package.map(str::to_string),
1416 include_prereqs: true,
1417 };
1418 let issues = collect_publish_surface_issues(&surface, Some(&selection), expected_version)?;
1419 if issues.is_empty() {
1420 return Ok(None);
1421 }
1422
1423 let auto_fixable_count = issues.iter().filter(|issue| issue.auto_fixable).count();
1424 let preview = issues
1425 .iter()
1426 .take(8)
1427 .map(|issue| {
1428 format!(
1429 "{}: {} ({})",
1430 issue.package,
1431 issue.detail,
1432 if issue.auto_fixable {
1433 "auto-fixable"
1434 } else {
1435 "manual"
1436 }
1437 )
1438 })
1439 .collect();
1440
1441 Ok(Some(PublishSurfaceHealSummary {
1442 expected_version: expected_version.clone(),
1443 issue_count: issues.len(),
1444 auto_fixable_count,
1445 preview,
1446 }))
1447}
1448
1449pub(crate) fn heal_publish_surface_for_release(
1452 repo_root: &Path,
1453 expected_version: &Version,
1454 only_package: Option<&str>,
1455) -> Result<Vec<String>, String> {
1456 let surface = discover_release_surface(repo_root)?;
1457 let selection = PublishSelection {
1458 from: None,
1459 only: only_package.map(str::to_string),
1460 include_prereqs: true,
1461 };
1462 let (_edits, files) =
1463 apply_publish_surface_heals(&surface, Some(&selection), expected_version, true)?;
1464 Ok(files)
1465}
1466
1467pub(crate) async fn resolve_manifest_workspace_publish(
1468 manifest_path: &Path,
1469 include_prereqs: bool,
1470) -> Result<ManifestWorkspacePublishResolution, String> {
1471 let surface = discover_release_surface_from_manifest(manifest_path)?;
1472 let package = surface
1473 .packages
1474 .iter()
1475 .find(|package| same_path(&package.manifest_path, manifest_path))
1476 .ok_or_else(|| {
1477 format!(
1478 "Manifest {} is not a publishable package in the resolved Cargo workspace.",
1479 manifest_path.display()
1480 )
1481 })?;
1482 let visibility = collect_crates_io_visibility(&surface).await?;
1483 let plan = build_publish_plan(
1484 &surface,
1485 &visibility,
1486 Some(&PublishSelection {
1487 from: None,
1488 only: Some(package.name.clone()),
1489 include_prereqs,
1490 }),
1491 )?;
1492 Ok(ManifestWorkspacePublishResolution {
1493 workspace_root: surface.repo_root.clone(),
1494 requested_package: package.name.clone(),
1495 included_prereqs: plan.included_prereqs,
1496 required_closure: plan.required_closure,
1497 packages: plan.items,
1498 publish_order: build_publish_command_targets(&surface, &plan.publish_order)?,
1499 })
1500}
1501
1502async fn run_publish(
1503 surface: &ReleaseSurface,
1504 options: &WorkspacePublishRunOptions,
1505) -> Result<WorkspacePublishRunReport, String> {
1506 if options.only.is_some() && options.from.is_some() {
1507 return Err("`--only` cannot be combined with `--from`.".to_string());
1508 }
1509 if !options.allow_dirty {
1510 enforce_clean_worktree(&surface.repo_root)?;
1511 }
1512
1513 let validation = run_validation(
1514 surface,
1515 &WorkspaceVersionValidateOptions {
1516 package: options.only.clone(),
1517 cargo_check: false,
1518 package_dry_run: false,
1519 },
1520 )?;
1521 if !validation.ok {
1522 return Err("Workspace has structural publish blockers. Run `xbp version workspace validate` for details.".to_string());
1523 }
1524
1525 let visibility = collect_crates_io_visibility(surface).await?;
1526 let selection = PublishSelection {
1527 from: options.from.clone(),
1528 only: options.only.clone(),
1529 include_prereqs: options.include_prereqs,
1530 };
1531 let plan = build_publish_plan(surface, &visibility, Some(&selection))?;
1532 let blockers = collect_publish_blockers(&plan.items);
1533 if !blockers.is_empty() {
1534 let expected = resolve_expected_version(surface, None).ok();
1535 let surface_issues = expected
1536 .as_ref()
1537 .and_then(|version| {
1538 collect_publish_surface_issues(surface, Some(&selection), version).ok()
1539 })
1540 .unwrap_or_default();
1541 return Err(render_workspace_publish_blockers(
1542 surface,
1543 &plan,
1544 Some(options),
1545 &blockers,
1546 &surface_issues,
1547 ));
1548 }
1549
1550 let mut item_by_name = BTreeMap::new();
1551 for item in &plan.items {
1552 item_by_name.insert(item.package.clone(), item.clone());
1553 }
1554
1555 let mut published = Vec::new();
1556 let mut skipped = Vec::new();
1557 let mut failed = Vec::new();
1558 let publish_order = plan.publish_order.clone();
1559 let total_in_order = publish_order.len();
1560 for (index, package_name) in publish_order.iter().enumerate() {
1561 let Some(item) = item_by_name.get(package_name) else {
1562 continue;
1563 };
1564 if !item.publish_needed {
1565 skipped.push(format!("{} {}", item.package, item.reason));
1566 continue;
1567 }
1568 if !item.blocked_by.is_empty() {
1569 let message = format!("{} blocked by {}", item.package, item.blocked_by.join(", "));
1570 failed.push(message.clone());
1571 if !options.continue_on_error {
1572 break;
1573 }
1574 continue;
1575 }
1576
1577 let package = surface
1578 .packages
1579 .iter()
1580 .find(|package| package.name == item.package)
1581 .ok_or_else(|| format!("Missing package `{}` in release surface.", item.package))?;
1582 let cargo_publish = format!(
1583 "cargo publish --locked --manifest-path {}{}",
1584 package.manifest_relative,
1585 if options.allow_dirty {
1586 " --allow-dirty"
1587 } else {
1588 ""
1589 }
1590 );
1591 println!("{}", cargo_publish);
1592 if options.dry_run {
1593 published.push(format!("{} (dry-run)", item.package));
1594 continue;
1595 }
1596
1597 let status = Command::new(cargo_program())
1598 .current_dir(&surface.repo_root)
1599 .arg("publish")
1600 .arg("--locked")
1601 .arg("--manifest-path")
1602 .arg(&package.manifest_path)
1603 .args(options.allow_dirty.then_some("--allow-dirty"))
1604 .status()
1605 .map_err(|e| {
1606 format!(
1607 "Failed to execute cargo publish for {}: {}",
1608 item.package, e
1609 )
1610 })?;
1611 if !status.success() {
1612 let message = format!(
1613 "{} publish failed with exit code {:?}",
1614 item.package,
1615 status.code()
1616 );
1617 failed.push(message.clone());
1618 if !options.continue_on_error {
1619 break;
1620 }
1621 continue;
1622 }
1623
1624 let is_last = index + 1 == total_in_order;
1627 wait_for_crates_io_visibility(
1628 &item.package,
1629 &item.version,
1630 options.timeout_seconds,
1631 options.poll_interval_seconds,
1632 is_last,
1633 )
1634 .await?;
1635 published.push(item.package.clone());
1636 }
1637
1638 Ok(WorkspacePublishRunReport {
1639 repo_root: display_path(&surface.repo_root),
1640 dry_run: options.dry_run,
1641 requested_package: plan.requested_package,
1642 included_prereqs: plan.included_prereqs,
1643 required_closure: plan.required_closure,
1644 published,
1645 skipped,
1646 failed,
1647 })
1648}
1649
1650fn build_publish_command_targets(
1651 surface: &ReleaseSurface,
1652 publish_order: &[String],
1653) -> Result<Vec<WorkspacePublishCommandTarget>, String> {
1654 let mut targets = Vec::new();
1655 for package_name in publish_order {
1656 let package = surface
1657 .packages
1658 .iter()
1659 .find(|package| &package.name == package_name)
1660 .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1661 targets.push(WorkspacePublishCommandTarget {
1662 package: package.name.clone(),
1663 version: package.version.to_string(),
1664 manifest_path: package.manifest_path.clone(),
1665 manifest_relative: package.manifest_relative.clone(),
1666 });
1667 }
1668 Ok(targets)
1669}
1670
1671#[derive(Debug, Clone)]
1672struct PublishSelection {
1673 from: Option<String>,
1674 only: Option<String>,
1675 include_prereqs: bool,
1676}
1677
1678#[derive(Debug, Clone)]
1679struct ResolvedPublishSelection {
1680 requested_package: Option<String>,
1681 selected_packages: Vec<String>,
1682 included_prereqs: Vec<String>,
1683 required_closure: Vec<String>,
1684}
1685
1686#[derive(Debug, Clone)]
1687struct BuiltPublishPlan {
1688 requested_package: Option<String>,
1689 included_prereqs: Vec<String>,
1690 required_closure: Vec<String>,
1691 items: Vec<PublishPlanItem>,
1692 publish_order: Vec<String>,
1693}
1694
1695fn build_publish_plan(
1696 surface: &ReleaseSurface,
1697 visibility: &BTreeMap<String, bool>,
1698 selection: Option<&PublishSelection>,
1699) -> Result<BuiltPublishPlan, String> {
1700 let ordered_packages = topological_package_order(surface)?;
1701 let selected = resolve_selected_packages(surface, &ordered_packages, selection)?;
1702 let selected_set: BTreeSet<String> = selected.selected_packages.iter().cloned().collect();
1703 let mut available = visibility.clone();
1704 let mut items = Vec::new();
1705 let mut publish_order = Vec::new();
1706
1707 for package_name in ordered_packages {
1708 if !selected_set.contains(&package_name) {
1709 continue;
1710 }
1711 let package = surface
1712 .packages
1713 .iter()
1714 .find(|package| package.name == package_name)
1715 .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1716 let visible = visibility.get(&package.name).copied();
1717 let mut publish_needed = false;
1718 let mut blocked_by = Vec::new();
1719 let reason = if visible == Some(true) {
1720 "already visible on crates.io".to_string()
1721 } else if !package.publishable {
1722 "publish disabled in package metadata (auto-fix available)".to_string()
1723 } else if package.publish_excluded {
1724 "publish excluded by workspace release config".to_string()
1725 } else if !package.publish_missing_version_pins.is_empty() {
1726 format!(
1727 "missing version pins for {} (auto-fix available)",
1728 package.publish_missing_version_pins.join(", ")
1729 )
1730 } else {
1731 for dependency in &package.publish_internal_dependencies {
1732 if available.get(dependency).copied().unwrap_or(false) {
1733 continue;
1734 }
1735 blocked_by.push(dependency.clone());
1738 }
1739 if blocked_by.is_empty() {
1740 publish_needed = true;
1741 publish_order.push(package.name.clone());
1742 available.insert(package.name.clone(), true);
1743 "publish required".to_string()
1744 } else {
1745 format!("waiting for {}", blocked_by.join(", "))
1746 }
1747 };
1748
1749 items.push(PublishPlanItem {
1750 package: package.name.clone(),
1751 manifest: package.manifest_relative.clone(),
1752 version: package.version.to_string(),
1753 publishable: package.publishable
1754 && !package.publish_excluded
1755 && package.publish_missing_version_pins.is_empty(),
1756 crates_io_visible: visible,
1757 publish_needed,
1758 blocked_by,
1759 reason,
1760 });
1761 }
1762
1763 Ok(BuiltPublishPlan {
1764 requested_package: selected.requested_package,
1765 included_prereqs: selected.included_prereqs,
1766 required_closure: selected.required_closure,
1767 items,
1768 publish_order,
1769 })
1770}
1771
1772fn resolve_selected_packages(
1773 surface: &ReleaseSurface,
1774 ordered_packages: &[String],
1775 selection: Option<&PublishSelection>,
1776) -> Result<ResolvedPublishSelection, String> {
1777 let Some(selection) = selection else {
1778 return Ok(ResolvedPublishSelection {
1779 requested_package: None,
1780 selected_packages: ordered_packages.to_vec(),
1781 included_prereqs: Vec::new(),
1782 required_closure: ordered_packages.to_vec(),
1783 });
1784 };
1785 if let Some(only) = selection.only.as_deref() {
1786 if !ordered_packages.iter().any(|package| package == only) {
1787 return Err(format!("Unknown package `{}` for `--only`.", only));
1788 }
1789 let required_closure = collect_publish_closure(surface, only, ordered_packages)?;
1790 let selected_packages = if selection.include_prereqs {
1791 required_closure.clone()
1792 } else {
1793 vec![only.to_string()]
1794 };
1795 let included_prereqs = required_closure
1796 .iter()
1797 .filter(|package| package.as_str() != only)
1798 .cloned()
1799 .collect::<Vec<_>>();
1800 return Ok(ResolvedPublishSelection {
1801 requested_package: Some(only.to_string()),
1802 selected_packages,
1803 included_prereqs: if selection.include_prereqs {
1804 included_prereqs
1805 } else {
1806 Vec::new()
1807 },
1808 required_closure,
1809 });
1810 }
1811 if let Some(from) = selection.from.as_deref() {
1812 let start = ordered_packages
1813 .iter()
1814 .position(|package| package == from)
1815 .ok_or_else(|| format!("Unknown package `{}` for `--from`.", from))?;
1816 return Ok(ResolvedPublishSelection {
1817 requested_package: None,
1818 selected_packages: ordered_packages[start..].to_vec(),
1819 included_prereqs: Vec::new(),
1820 required_closure: ordered_packages[start..].to_vec(),
1821 });
1822 }
1823 Ok(ResolvedPublishSelection {
1824 requested_package: None,
1825 selected_packages: ordered_packages.to_vec(),
1826 included_prereqs: Vec::new(),
1827 required_closure: ordered_packages.to_vec(),
1828 })
1829}
1830
1831fn collect_publish_closure(
1832 surface: &ReleaseSurface,
1833 root_package: &str,
1834 ordered_packages: &[String],
1835) -> Result<Vec<String>, String> {
1836 let mut by_name = BTreeMap::new();
1837 for package in &surface.packages {
1838 by_name.insert(package.name.as_str(), package);
1839 }
1840 let mut visited = BTreeSet::new();
1841 collect_publish_closure_visit(root_package, &by_name, &mut visited)?;
1842 Ok(ordered_packages
1843 .iter()
1844 .filter(|package| visited.contains(package.as_str()))
1845 .cloned()
1846 .collect())
1847}
1848
1849fn collect_publish_closure_visit<'a>(
1850 package_name: &'a str,
1851 by_name: &BTreeMap<&'a str, &'a ReleasePackage>,
1852 visited: &mut BTreeSet<&'a str>,
1853) -> Result<(), String> {
1854 if !visited.insert(package_name) {
1855 return Ok(());
1856 }
1857 let package = by_name
1858 .get(package_name)
1859 .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1860 for dependency in &package.publish_internal_dependencies {
1861 collect_publish_closure_visit(dependency, by_name, visited)?;
1862 }
1863 Ok(())
1864}
1865
1866fn collect_publish_surface_issues(
1867 surface: &ReleaseSurface,
1868 selection: Option<&PublishSelection>,
1869 expected_version: &Version,
1870) -> Result<Vec<PublishSurfaceIssue>, String> {
1871 let ordered = topological_package_order(surface)?;
1872 let resolved = resolve_selected_packages(surface, &ordered, selection)?;
1873 let selected: BTreeSet<String> = resolved.required_closure.iter().cloned().collect();
1874 let expected_text = expected_version.to_string();
1875 let workspace_meta = read_workspace_package_metadata(&surface.repo_root);
1876 let workspace_dep_versions =
1877 read_workspace_dependency_path_versions(&surface.repo_root, &selected)?;
1878
1879 let mut issues = Vec::new();
1880 for package in &surface.packages {
1881 if !selected.contains(&package.name) {
1882 continue;
1883 }
1884 if !package.publishable {
1885 issues.push(PublishSurfaceIssue {
1886 package: package.name.clone(),
1887 manifest: package.manifest_relative.clone(),
1888 kind: PublishSurfaceIssueKind::PublishDisabled,
1889 detail: "package.publish disables crates.io publish".to_string(),
1890 auto_fixable: true,
1891 });
1892 }
1893 if package.publish_excluded {
1894 issues.push(PublishSurfaceIssue {
1895 package: package.name.clone(),
1896 manifest: package.manifest_relative.clone(),
1897 kind: PublishSurfaceIssueKind::PublishExcluded,
1898 detail: "listed in workspace-release publish.exclude".to_string(),
1899 auto_fixable: false,
1900 });
1901 }
1902 for pin in &package.publish_missing_version_pins {
1903 issues.push(PublishSurfaceIssue {
1904 package: package.name.clone(),
1905 manifest: package.manifest_relative.clone(),
1906 kind: PublishSurfaceIssueKind::MissingVersionPin,
1907 detail: format!("missing path dependency version pin `{pin}`"),
1908 auto_fixable: true,
1909 });
1910 }
1911 if !package.publishable {
1912 let missing_meta = missing_package_metadata_fields(&package.manifest_path, &workspace_meta);
1913 if !missing_meta.is_empty() {
1914 issues.push(PublishSurfaceIssue {
1915 package: package.name.clone(),
1916 manifest: package.manifest_relative.clone(),
1917 kind: PublishSurfaceIssueKind::MissingPackageMetadata,
1918 detail: format!(
1919 "missing package metadata fields: {}",
1920 missing_meta.join(", ")
1921 ),
1922 auto_fixable: workspace_meta.has_any(),
1923 });
1924 }
1925 }
1926 }
1927
1928 for (name, actual) in workspace_dep_versions {
1929 if actual.as_deref() == Some(expected_text.as_str()) {
1930 continue;
1931 }
1932 issues.push(PublishSurfaceIssue {
1933 package: name.clone(),
1934 manifest: "Cargo.toml".to_string(),
1935 kind: PublishSurfaceIssueKind::WorkspaceDepVersionDrift,
1936 detail: format!(
1937 "[workspace.dependencies.{name}].version is {} (expected {expected_text})",
1938 actual.as_deref().unwrap_or("<missing>")
1939 ),
1940 auto_fixable: true,
1941 });
1942 }
1943
1944 issues.sort_by(|a, b| {
1945 a.package
1946 .cmp(&b.package)
1947 .then(a.detail.cmp(&b.detail))
1948 });
1949 Ok(issues)
1950}
1951
1952fn apply_publish_surface_heals(
1953 surface: &ReleaseSurface,
1954 selection: Option<&PublishSelection>,
1955 expected_version: &Version,
1956 write: bool,
1957) -> Result<(Vec<SyncEdit>, Vec<String>), String> {
1958 let issues = collect_publish_surface_issues(surface, selection, expected_version)?;
1959 if !write {
1960 return Ok((Vec::new(), Vec::new()));
1961 }
1962
1963 let mut edits = Vec::new();
1964 let mut changed = BTreeSet::new();
1965 let expected_text = expected_version.to_string();
1966 let workspace_meta = read_workspace_package_metadata(&surface.repo_root);
1967
1968 let packages_to_enable = issues
1969 .iter()
1970 .filter(|issue| {
1971 issue.auto_fixable
1972 && matches!(
1973 issue.kind,
1974 PublishSurfaceIssueKind::PublishDisabled
1975 | PublishSurfaceIssueKind::MissingPackageMetadata
1976 )
1977 })
1978 .map(|issue| issue.package.clone())
1979 .collect::<BTreeSet<_>>();
1980
1981 for package_name in &packages_to_enable {
1982 let Some(package) = surface
1983 .packages
1984 .iter()
1985 .find(|package| &package.name == package_name)
1986 else {
1987 continue;
1988 };
1989 let package_edits =
1990 heal_package_publish_metadata(package, &workspace_meta, &expected_text)?;
1991 if !package_edits.is_empty() {
1992 changed.insert(package.manifest_relative.clone());
1993 edits.extend(package_edits);
1994 }
1995 }
1996
1997 let (sync_edits, sync_files) = apply_sync(surface, expected_version, true)?;
1999 edits.extend(sync_edits);
2000 for file in sync_files {
2001 changed.insert(file);
2002 }
2003
2004 let workspace_dep_packages = issues
2005 .iter()
2006 .filter(|issue| {
2007 issue.auto_fixable
2008 && matches!(issue.kind, PublishSurfaceIssueKind::WorkspaceDepVersionDrift)
2009 })
2010 .map(|issue| issue.package.clone())
2011 .collect::<BTreeSet<_>>();
2012 if !workspace_dep_packages.is_empty() {
2013 let root_edits = heal_workspace_dependency_versions(
2014 &surface.repo_root,
2015 &workspace_dep_packages,
2016 &expected_text,
2017 )?;
2018 if !root_edits.is_empty() {
2019 changed.insert("Cargo.toml".to_string());
2020 edits.extend(root_edits);
2021 }
2022 }
2023
2024 Ok((edits, changed.into_iter().collect()))
2025}
2026
2027#[derive(Debug, Clone, Default)]
2028struct WorkspacePackageMetadata {
2029 homepage: Option<String>,
2030 repository: Option<String>,
2031 license: Option<String>,
2032 authors: Option<String>,
2033}
2034
2035impl WorkspacePackageMetadata {
2036 fn has_any(&self) -> bool {
2037 self.homepage.is_some()
2038 || self.repository.is_some()
2039 || self.license.is_some()
2040 || self.authors.is_some()
2041 }
2042}
2043
2044fn read_workspace_package_metadata(repo_root: &Path) -> WorkspacePackageMetadata {
2045 let path = repo_root.join("Cargo.toml");
2046 let Ok(content) = fs::read_to_string(&path) else {
2047 return WorkspacePackageMetadata::default();
2048 };
2049 let Ok(doc) = content.parse::<DocumentMut>() else {
2050 return WorkspacePackageMetadata::default();
2051 };
2052 let Some(package) = doc
2053 .get("workspace")
2054 .and_then(|workspace| workspace.get("package"))
2055 else {
2056 return WorkspacePackageMetadata::default();
2057 };
2058 WorkspacePackageMetadata {
2059 homepage: package.get("homepage").and_then(Item::as_str).map(str::to_string),
2060 repository: package
2061 .get("repository")
2062 .and_then(Item::as_str)
2063 .map(str::to_string),
2064 license: package.get("license").and_then(Item::as_str).map(str::to_string),
2065 authors: package.get("authors").and_then(|authors| {
2066 if let Some(text) = authors.as_str() {
2067 Some(text.to_string())
2068 } else if let Some(array) = authors.as_array() {
2069 let joined = array
2070 .iter()
2071 .filter_map(|value| value.as_str())
2072 .collect::<Vec<_>>()
2073 .join(", ");
2074 if joined.is_empty() {
2075 None
2076 } else {
2077 Some(joined)
2078 }
2079 } else {
2080 None
2081 }
2082 }),
2083 }
2084}
2085
2086fn missing_package_metadata_fields(
2087 manifest_path: &Path,
2088 workspace_meta: &WorkspacePackageMetadata,
2089) -> Vec<String> {
2090 let Ok(content) = fs::read_to_string(manifest_path) else {
2091 return Vec::new();
2092 };
2093 let Ok(value) = content.parse::<TomlValue>() else {
2094 return Vec::new();
2095 };
2096 let Some(package) = value.get("package").and_then(TomlValue::as_table) else {
2097 return Vec::new();
2098 };
2099 let mut missing = Vec::new();
2100 if package.get("homepage").and_then(TomlValue::as_str).is_none()
2101 && workspace_meta.homepage.is_some()
2102 {
2103 missing.push("homepage".to_string());
2104 }
2105 if package
2106 .get("repository")
2107 .and_then(TomlValue::as_str)
2108 .is_none()
2109 && workspace_meta.repository.is_some()
2110 {
2111 missing.push("repository".to_string());
2112 }
2113 if package.get("license").and_then(TomlValue::as_str).is_none()
2114 && workspace_meta.license.is_some()
2115 {
2116 missing.push("license".to_string());
2117 }
2118 missing
2119}
2120
2121fn heal_package_publish_metadata(
2122 package: &ReleasePackage,
2123 workspace_meta: &WorkspacePackageMetadata,
2124 _expected_version: &str,
2125) -> Result<Vec<SyncEdit>, String> {
2126 let content = fs::read_to_string(&package.manifest_path).map_err(|e| {
2127 format!(
2128 "Failed to read {}: {}",
2129 package.manifest_path.display(),
2130 e
2131 )
2132 })?;
2133 let mut doc = content.parse::<DocumentMut>().map_err(|e| {
2134 format!(
2135 "Failed to parse {}: {}",
2136 package.manifest_path.display(),
2137 e
2138 )
2139 })?;
2140 let mut edits = Vec::new();
2141
2142 let package_table = doc["package"].as_table_mut().ok_or_else(|| {
2143 format!(
2144 "Expected [package] table in {}",
2145 package.manifest_path.display()
2146 )
2147 })?;
2148
2149 if let Some(publish_item) = package_table.get("publish") {
2150 let should_clear = match publish_item {
2151 Item::Value(Value::Boolean(formatted)) => !*formatted.value(),
2152 Item::Value(Value::Array(array)) => array.is_empty(),
2153 _ => false,
2154 };
2155 if should_clear {
2156 edits.push(SyncEdit {
2157 path: package.manifest_relative.clone(),
2158 field: "package.publish".to_string(),
2159 before: Some(publish_item.to_string().trim().to_string()),
2160 after: "<removed>".to_string(),
2161 });
2162 package_table.remove("publish");
2163 }
2164 }
2165
2166 for (field, source) in [
2167 ("homepage", workspace_meta.homepage.as_deref()),
2168 ("repository", workspace_meta.repository.as_deref()),
2169 ("license", workspace_meta.license.as_deref()),
2170 ] {
2171 let Some(value_text) = source else {
2172 continue;
2173 };
2174 let current = package_table.get(field).and_then(Item::as_str);
2175 if current.is_none() {
2176 edits.push(SyncEdit {
2177 path: package.manifest_relative.clone(),
2178 field: format!("package.{field}"),
2179 before: None,
2180 after: value_text.to_string(),
2181 });
2182 package_table[field] = value(value_text);
2183 }
2184 }
2185
2186 let has_dist_meta = doc
2188 .get("package")
2189 .and_then(|package| package.get("metadata"))
2190 .and_then(|metadata| metadata.get("dist"))
2191 .is_some();
2192 if !has_dist_meta {
2193 doc["package"]["metadata"]["dist"]["dist"] = value(false);
2194 edits.push(SyncEdit {
2195 path: package.manifest_relative.clone(),
2196 field: "package.metadata.dist.dist".to_string(),
2197 before: None,
2198 after: "false".to_string(),
2199 });
2200 }
2201
2202 if !edits.is_empty() {
2203 fs::write(&package.manifest_path, doc.to_string()).map_err(|e| {
2204 format!(
2205 "Failed to write {}: {}",
2206 package.manifest_path.display(),
2207 e
2208 )
2209 })?;
2210 }
2211 Ok(edits)
2212}
2213
2214fn read_workspace_dependency_path_versions(
2215 repo_root: &Path,
2216 package_names: &BTreeSet<String>,
2217) -> Result<BTreeMap<String, Option<String>>, String> {
2218 let path = repo_root.join("Cargo.toml");
2219 let content = match fs::read_to_string(&path) {
2220 Ok(content) => content,
2221 Err(_) => return Ok(BTreeMap::new()),
2222 };
2223 let value = match content.parse::<TomlValue>() {
2224 Ok(value) => value,
2225 Err(_) => return Ok(BTreeMap::new()),
2226 };
2227 let Some(deps) = value
2228 .get("workspace")
2229 .and_then(|workspace| workspace.get("dependencies"))
2230 .and_then(TomlValue::as_table)
2231 else {
2232 return Ok(BTreeMap::new());
2233 };
2234
2235 let mut versions = BTreeMap::new();
2236 for (name, dep) in deps {
2237 if !package_names.contains(name) {
2238 continue;
2240 }
2241 let Some(table) = dep.as_table() else {
2242 continue;
2243 };
2244 if !table.contains_key("path") {
2245 continue;
2246 }
2247 versions.insert(
2248 name.clone(),
2249 table
2250 .get("version")
2251 .and_then(TomlValue::as_str)
2252 .map(str::to_string),
2253 );
2254 }
2255 Ok(versions)
2256}
2257
2258fn heal_workspace_dependency_versions(
2259 repo_root: &Path,
2260 package_names: &BTreeSet<String>,
2261 expected_version: &str,
2262) -> Result<Vec<SyncEdit>, String> {
2263 let path = repo_root.join("Cargo.toml");
2264 let content = fs::read_to_string(&path)
2265 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2266 let mut doc = content
2267 .parse::<DocumentMut>()
2268 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
2269 let mut edits = Vec::new();
2270
2271 let Some(deps) = doc
2272 .get_mut("workspace")
2273 .and_then(|workspace| workspace.as_table_mut())
2274 .and_then(|workspace| workspace.get_mut("dependencies"))
2275 .and_then(|deps| deps.as_table_mut())
2276 else {
2277 return Ok(edits);
2278 };
2279
2280 for name in package_names {
2281 let Some(dep_item) = deps.get_mut(name) else {
2282 continue;
2283 };
2284 let Some(dep_table) = dep_item.as_table_like_mut() else {
2285 continue;
2286 };
2287 if dep_table.get("path").is_none() {
2288 continue;
2289 }
2290 let current = dep_table
2291 .get("version")
2292 .and_then(|item| item.as_str())
2293 .map(str::to_string);
2294 if current.as_deref() == Some(expected_version) {
2295 continue;
2296 }
2297 edits.push(SyncEdit {
2298 path: "Cargo.toml".to_string(),
2299 field: format!("workspace.dependencies.{name}.version"),
2300 before: current,
2301 after: expected_version.to_string(),
2302 });
2303 dep_table.insert("version", value(expected_version));
2304 }
2305
2306 if !edits.is_empty() {
2307 fs::write(&path, doc.to_string())
2308 .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
2309 }
2310 Ok(edits)
2311}
2312
2313fn collect_publish_blockers(items: &[PublishPlanItem]) -> Vec<String> {
2314 let blocking_deps = items
2317 .iter()
2318 .filter(|item| item.crates_io_visible != Some(true) && !item.blocked_by.is_empty())
2319 .flat_map(|item| item.blocked_by.iter().cloned())
2320 .collect::<BTreeSet<_>>();
2321
2322 items
2323 .iter()
2324 .filter(|item| {
2325 if item.crates_io_visible == Some(true) {
2326 return false;
2327 }
2328 if !item.blocked_by.is_empty() {
2329 return true;
2330 }
2331 if !item.publishable {
2332 return blocking_deps.contains(&item.package);
2334 }
2335 item.reason.contains("missing version pins")
2337 || item.reason.contains("publish excluded")
2338 })
2339 .map(|item| {
2340 if !item.blocked_by.is_empty() {
2341 format!("{} blocked by {}", item.package, item.blocked_by.join(", "))
2342 } else {
2343 format!("{} {}", item.package, item.reason)
2344 }
2345 })
2346 .collect()
2347}
2348
2349fn render_workspace_publish_blockers(
2350 surface: &ReleaseSurface,
2351 plan: &BuiltPublishPlan,
2352 run_options: Option<&WorkspacePublishRunOptions>,
2353 blockers: &[String],
2354 surface_issues: &[PublishSurfaceIssue],
2355) -> String {
2356 let mut message = String::new();
2357 message.push_str("Workspace publish is blocked.\n");
2358 message.push_str(&format!("Repo: {}\n", surface.repo_root.display()));
2359 if let Some(requested_package) = plan.requested_package.as_deref() {
2360 message.push_str(&format!("Requested package: {}\n", requested_package));
2361 }
2362 if !plan.included_prereqs.is_empty() {
2363 message.push_str(&format!(
2364 "Auto-included prerequisites: {}\n",
2365 plan.included_prereqs.join(", ")
2366 ));
2367 }
2368 if !plan.required_closure.is_empty() {
2369 message.push_str(&format!(
2370 "Required publish order: {}\n",
2371 plan.required_closure.join(" -> ")
2372 ));
2373 }
2374 message.push_str("Blockers:\n");
2375 for blocker in blockers {
2376 message.push_str(&format!("- {}\n", blocker));
2377 }
2378 let auto_fixable = surface_issues
2379 .iter()
2380 .filter(|issue| issue.auto_fixable)
2381 .collect::<Vec<_>>();
2382 if !auto_fixable.is_empty() {
2383 message.push_str("Auto-fixable surface issues:\n");
2384 for issue in auto_fixable {
2385 message.push_str(&format!("- {}: {}\n", issue.package, issue.detail));
2386 }
2387 if let Some(requested_package) = plan.requested_package.as_deref() {
2388 message.push_str(&format!(
2389 "Heal with:\n xbp version workspace publish heal --repo {} --only {} --include-prereqs --write\n",
2390 quote_argument(&surface.repo_root),
2391 requested_package
2392 ));
2393 } else {
2394 message.push_str(&format!(
2395 "Heal with:\n xbp version workspace publish heal --repo {} --write\n",
2396 quote_argument(&surface.repo_root)
2397 ));
2398 }
2399 message.push_str(
2400 "Or re-run publish with auto-fix enabled (default for `workspace publish run`).\n",
2401 );
2402 }
2403 if let (Some(requested_package), Some(run_options)) =
2404 (plan.requested_package.as_deref(), run_options)
2405 {
2406 if !run_options.include_prereqs {
2407 message.push_str(&format!(
2408 "Rerun with prerequisites: xbp version workspace publish run --repo {} --only {} --include-prereqs{}\n",
2409 quote_argument(&surface.repo_root),
2410 requested_package,
2411 if run_options.allow_dirty {
2412 " --allow-dirty"
2413 } else {
2414 ""
2415 }
2416 ));
2417 }
2418 }
2419 message.trim_end().to_string()
2420}
2421
2422fn topological_package_order(surface: &ReleaseSurface) -> Result<Vec<String>, String> {
2423 let package_names = surface
2424 .packages
2425 .iter()
2426 .map(|package| package.name.clone())
2427 .collect::<BTreeSet<_>>();
2428 let order_overrides = surface
2429 .config
2430 .publish
2431 .order
2432 .iter()
2433 .enumerate()
2434 .map(|(index, name)| (name.clone(), index))
2435 .collect::<BTreeMap<_, _>>();
2436 let mut indegree = BTreeMap::new();
2437 let mut reverse = BTreeMap::<String, Vec<String>>::new();
2438 for package in &surface.packages {
2439 let deps = package
2440 .publish_internal_dependencies
2441 .iter()
2442 .filter(|name| package_names.contains(*name))
2443 .cloned()
2444 .collect::<Vec<_>>();
2445 indegree.insert(package.name.clone(), deps.len());
2446 for dep in deps {
2447 reverse.entry(dep).or_default().push(package.name.clone());
2448 }
2449 }
2450
2451 let mut queue = indegree
2452 .iter()
2453 .filter_map(|(name, degree)| (*degree == 0).then_some(name.clone()))
2454 .collect::<Vec<_>>();
2455 sort_package_names(&mut queue, &order_overrides);
2456
2457 let mut ordered = Vec::new();
2458 while let Some(name) = queue.first().cloned() {
2459 queue.remove(0);
2460 ordered.push(name.clone());
2461 if let Some(children) = reverse.get(&name) {
2462 for child in children {
2463 if let Some(entry) = indegree.get_mut(child) {
2464 *entry -= 1;
2465 if *entry == 0 {
2466 queue.push(child.clone());
2467 }
2468 }
2469 }
2470 sort_package_names(&mut queue, &order_overrides);
2471 }
2472 }
2473
2474 if ordered.len() != surface.packages.len() {
2475 return Err(
2476 "Workspace package graph contains a publish-relevant dependency cycle.".to_string(),
2477 );
2478 }
2479 Ok(ordered)
2480}
2481
2482fn sort_package_names(names: &mut [String], overrides: &BTreeMap<String, usize>) {
2483 names.sort_by(|a, b| {
2484 overrides
2485 .get(a)
2486 .copied()
2487 .unwrap_or(usize::MAX)
2488 .cmp(&overrides.get(b).copied().unwrap_or(usize::MAX))
2489 .then(a.cmp(b))
2490 });
2491}
2492
2493async fn collect_crates_io_visibility(
2494 surface: &ReleaseSurface,
2495) -> Result<BTreeMap<String, bool>, String> {
2496 let client = crates_io_client()?;
2497 let mut visibility = BTreeMap::new();
2498 for package in &surface.packages {
2499 if !package.publishable || package.publish_excluded {
2500 visibility.insert(package.name.clone(), false);
2501 continue;
2502 }
2503 visibility.insert(
2504 package.name.clone(),
2505 crates_io_has_exact_version(&client, &package.name, &package.version.to_string())
2506 .await?,
2507 );
2508 }
2509 Ok(visibility)
2510}
2511
2512async fn wait_for_crates_io_visibility(
2513 package: &str,
2514 version: &str,
2515 timeout_seconds: f64,
2516 poll_interval_seconds: f64,
2517 soft_on_timeout: bool,
2518) -> Result<(), String> {
2519 let timeout = Duration::from_secs_f64(timeout_seconds.max(1.0));
2520 let poll = Duration::from_secs_f64(poll_interval_seconds.max(0.5));
2521 let deadline = Instant::now() + timeout;
2522 let client = crates_io_client()?;
2523 loop {
2524 match crates_io_version_lookup(&client, package, version).await? {
2525 CratesIoLookup::Found => return Ok(()),
2526 CratesIoLookup::NotFound | CratesIoLookup::Retryable => {}
2527 }
2528 if Instant::now() >= deadline {
2529 let message = format!(
2530 "{} {} was published, but did not become visible on crates.io within {:.0}s",
2531 package, version, timeout_seconds
2532 );
2533 if soft_on_timeout {
2534 eprintln!(
2535 "Warning: registry visibility still pending for {}@{} — continuing because cargo already reported a successful publish. Check https://crates.io/crates/{}",
2536 package, version, package
2537 );
2538 return Ok(());
2539 }
2540 return Err(message);
2541 }
2542 sleep(poll).await;
2543 }
2544}
2545
2546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2547enum CratesIoLookup {
2548 Found,
2549 NotFound,
2550 Retryable,
2551}
2552
2553async fn crates_io_version_lookup(
2554 client: &reqwest::Client,
2555 package: &str,
2556 version: &str,
2557) -> Result<CratesIoLookup, String> {
2558 let url = format!("https://crates.io/api/v1/crates/{}/{}", package, version);
2559 let response = client
2560 .get(url)
2561 .send()
2562 .await
2563 .map_err(|e| format!("Failed crates.io lookup for {} {}: {}", package, version, e))?;
2564 let status = response.status();
2565 if status.is_success() {
2566 return Ok(CratesIoLookup::Found);
2567 }
2568 if status == reqwest::StatusCode::NOT_FOUND {
2569 return Ok(CratesIoLookup::NotFound);
2570 }
2571 if status == reqwest::StatusCode::FORBIDDEN {
2572 return Err(format!(
2573 "crates.io rejected the visibility lookup for {} {} with HTTP 403 \
2574(descriptive User-Agent required). If cargo already printed Published, the crate is live.",
2575 package, version
2576 ));
2577 }
2578 if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
2579 return Ok(CratesIoLookup::Retryable);
2580 }
2581 Ok(CratesIoLookup::NotFound)
2582}
2583
2584async fn crates_io_has_exact_version(
2585 client: &reqwest::Client,
2586 package: &str,
2587 version: &str,
2588) -> Result<bool, String> {
2589 Ok(matches!(
2590 crates_io_version_lookup(client, package, version).await?,
2591 CratesIoLookup::Found
2592 ))
2593}
2594
2595fn crates_io_client() -> Result<reqwest::Client, String> {
2596 reqwest::Client::builder()
2597 .user_agent(format!(
2598 "xbp/{} (https://github.com/xylex-group/xbp)",
2599 env!("CARGO_PKG_VERSION")
2600 ))
2601 .build()
2602 .map_err(|e| format!("Failed to build crates.io HTTP client: {}", e))
2603}
2604
2605fn enforce_clean_worktree(project_root: &Path) -> Result<(), String> {
2606 let Some(GitWorktreeState { is_dirty, .. }) = git_worktree_state(project_root)? else {
2607 return Ok(());
2608 };
2609 if is_dirty {
2610 return Err(
2611 "Workspace repo has uncommitted changes. Re-run with `--allow-dirty` to override."
2612 .to_string(),
2613 );
2614 }
2615 Ok(())
2616}
2617
2618fn resolve_expected_version(
2619 surface: &ReleaseSurface,
2620 explicit: Option<&str>,
2621) -> Result<Version, String> {
2622 if let Some(explicit) = explicit {
2623 return parse_version(explicit);
2624 }
2625 if let Some(root_package_name) = surface.root_package_name.as_ref() {
2626 return surface
2627 .packages
2628 .iter()
2629 .find(|package| &package.name == root_package_name)
2630 .map(|package| package.version.clone())
2631 .ok_or_else(|| "Could not resolve the root package version.".to_string());
2632 }
2633
2634 let root_manifest = surface.repo_root.join("Cargo.toml");
2635 resolve_cargo_package_version(&root_manifest)?
2636 .ok_or_else(|| {
2637 "Workspace root has no package.version or workspace.package.version; pass `--version` explicitly.".to_string()
2638 })
2639 .and_then(|version| parse_version(&version))
2640}
2641
2642fn load_cargo_metadata(repo_root: &Path) -> Result<CargoMetadata, String> {
2643 let mut command = Command::new(cargo_program());
2644 command
2645 .current_dir(repo_root)
2646 .args(["metadata", "--format-version", "1", "--no-deps"]);
2647 load_cargo_metadata_command(&mut command)
2648}
2649
2650fn load_cargo_metadata_for_manifest(manifest_path: &Path) -> Result<CargoMetadata, String> {
2651 let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
2652 let mut command = Command::new(cargo_program());
2653 command
2654 .current_dir(manifest_dir)
2655 .arg("metadata")
2656 .arg("--format-version")
2657 .arg("1")
2658 .arg("--no-deps")
2659 .arg("--manifest-path")
2660 .arg(manifest_path);
2661 load_cargo_metadata_command(&mut command)
2662}
2663
2664fn load_cargo_metadata_command(command: &mut Command) -> Result<CargoMetadata, String> {
2665 if !cargo_command_exists() {
2666 return Err("`cargo` is required to inspect workspace metadata.".to_string());
2667 }
2668 let output = command
2669 .output()
2670 .map_err(|e| format!("Failed to run `cargo metadata`: {}", e))?;
2671 if !output.status.success() {
2672 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2673 return Err(format!("`cargo metadata` failed: {}", stderr));
2674 }
2675 serde_json::from_slice::<CargoMetadata>(&output.stdout)
2676 .map_err(|e| format!("Failed to parse cargo metadata JSON: {}", e))
2677}
2678
2679fn load_workspace_release_config(path: Option<&Path>) -> Result<WorkspaceReleaseConfig, String> {
2680 let Some(path) = path else {
2681 return Ok(WorkspaceReleaseConfig::default());
2682 };
2683 let content = fs::read_to_string(path)
2684 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2685 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
2686}
2687
2688fn run_command_capture(
2689 mut command: Command,
2690 label: impl Into<String>,
2691) -> Result<ValidationCommandResult, String> {
2692 let label = label.into();
2693 let output = command
2694 .output()
2695 .map_err(|e| format!("Failed to run `{}`: {}", label, e))?;
2696 Ok(ValidationCommandResult {
2697 command: label,
2698 success: output.status.success(),
2699 exit_code: output.status.code(),
2700 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
2701 warning: None,
2702 })
2703}
2704
2705fn read_metadata_version(path: &Path) -> Result<Option<String>, String> {
2706 let file_name = path
2707 .file_name()
2708 .and_then(|value| value.to_str())
2709 .unwrap_or_default();
2710 match file_name {
2711 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
2712 let content = fs::read_to_string(path)
2713 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2714 read_regex_version_from_content(&content, r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
2715 }
2716 _ => read_version_from_path(path),
2717 }
2718}
2719
2720fn write_metadata_version(path: &Path, version: &Version) -> Result<(), String> {
2721 let file_name = path
2722 .file_name()
2723 .and_then(|value| value.to_str())
2724 .unwrap_or_default();
2725 match file_name {
2726 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
2727 let content = fs::read_to_string(path)
2728 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2729 let regex = regex::Regex::new(r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
2730 .map_err(|e| format!("Failed to build OpenAPI regex: {}", e))?;
2731 let updated = regex
2732 .replace(&content, format!(" version: {}", version))
2733 .to_string();
2734 fs::write(path, updated)
2735 .map_err(|e| format!("Failed to write {}: {}", path.display(), e))
2736 }
2737 _ => write_version_to_path(path, version).map(|_| ()),
2738 }
2739}
2740
2741fn normalize_relative(repo_root: &Path, path: &Path) -> String {
2742 path.strip_prefix(repo_root)
2743 .unwrap_or(path)
2744 .to_string_lossy()
2745 .replace('\\', "/")
2746}
2747
2748fn display_path(path: &Path) -> String {
2749 path.to_string_lossy().to_string()
2750}
2751
2752fn same_path(left: &Path, right: &Path) -> bool {
2753 fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf())
2754 == fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
2755}
2756
2757fn quote_argument(path: &Path) -> String {
2758 let value = path.to_string_lossy();
2759 if value.contains(' ') {
2760 format!("\"{}\"", value)
2761 } else {
2762 value.to_string()
2763 }
2764}
2765
2766fn print_check_report(surface: &ReleaseSurface, report: &WorkspaceCheckReport) {
2767 println!("Workspace version check");
2768 println!("Repo: {}", surface.repo_root.display());
2769 println!("Expected version: {}", report.expected_version);
2770 if let Some(config_path) = &surface.config_path {
2771 println!(
2772 "Config: {}",
2773 normalize_relative(&surface.repo_root, config_path)
2774 );
2775 }
2776 println!(
2777 "Status: {}",
2778 if report.aligned {
2779 "aligned"
2780 } else {
2781 "drift detected"
2782 }
2783 );
2784 for entry in &report.drift {
2785 println!(
2786 "{} {} actual={} expected={}",
2787 entry.path,
2788 entry.field,
2789 entry.actual.as_deref().unwrap_or("<missing>"),
2790 entry.expected
2791 );
2792 }
2793}
2794
2795fn print_sync_report(surface: &ReleaseSurface, report: &WorkspaceSyncReport) {
2796 println!(
2797 "Workspace version {}",
2798 if report.write { "sync" } else { "sync preview" }
2799 );
2800 println!("Repo: {}", surface.repo_root.display());
2801 println!("Expected version: {}", report.expected_version);
2802 if report.edits.is_empty() {
2803 println!("No changes needed.");
2804 return;
2805 }
2806 println!("Files: {}", report.files_changed.join(", "));
2807 for edit in &report.edits {
2808 println!(
2809 "{} {} {} -> {}",
2810 edit.path,
2811 edit.field,
2812 edit.before.as_deref().unwrap_or("<missing>"),
2813 edit.after
2814 );
2815 }
2816}
2817
2818fn print_validation_report(surface: &ReleaseSurface, report: &WorkspaceValidateReport) {
2819 println!("Workspace validation");
2820 println!("Repo: {}", surface.repo_root.display());
2821 println!("Status: {}", if report.ok { "ok" } else { "failed" });
2822 for issue in &report.issues {
2823 println!(
2824 "{} {} actual={} expected={}",
2825 issue.path,
2826 issue.field,
2827 issue.actual.as_deref().unwrap_or("<missing>"),
2828 issue.expected
2829 );
2830 }
2831 for command in &report.commands {
2832 let status = if command.success {
2833 if command.warning.is_some() {
2834 "ok (with warning)"
2835 } else {
2836 "ok"
2837 }
2838 } else {
2839 "failed"
2840 };
2841 println!("{} [{}]", command.command, status);
2842 if let Some(warning) = command.warning.as_deref() {
2843 println!("warning: {}", warning);
2844 }
2845 if !command.stderr.is_empty() {
2846 println!("{}", command.stderr);
2847 }
2848 }
2849}
2850
2851fn print_publish_plan_report(surface: &ReleaseSurface, report: &WorkspacePublishPlanReport) {
2852 println!("Workspace publish plan");
2853 println!("Repo: {}", surface.repo_root.display());
2854 if let Some(requested_package) = report.requested_package.as_deref() {
2855 println!("Requested package: {}", requested_package);
2856 }
2857 if !report.included_prereqs.is_empty() {
2858 println!(
2859 "Auto-included prerequisites: {}",
2860 report.included_prereqs.join(", ")
2861 );
2862 }
2863 if !report.required_closure.is_empty() {
2864 println!("Required closure: {}", report.required_closure.join(" -> "));
2865 }
2866 println!(
2867 "Publish order: {}",
2868 if report.publish_order.is_empty() {
2869 "<none>".to_string()
2870 } else {
2871 report.publish_order.join(", ")
2872 }
2873 );
2874 for item in &report.packages {
2875 println!(
2876 "{} {} visible={} needed={} reason={}",
2877 item.package,
2878 item.version,
2879 item.crates_io_visible
2880 .map(|value| value.to_string())
2881 .unwrap_or_else(|| "n/a".to_string()),
2882 item.publish_needed,
2883 item.reason
2884 );
2885 if !item.blocked_by.is_empty() {
2886 println!(" blocked by {}", item.blocked_by.join(", "));
2887 }
2888 }
2889 if !report.surface_issues.is_empty() {
2890 println!("Surface issues:");
2891 for issue in &report.surface_issues {
2892 println!(
2893 " {} {} — {}{}",
2894 issue.package,
2895 format!("{:?}", issue.kind).to_ascii_lowercase(),
2896 issue.detail,
2897 if issue.auto_fixable {
2898 " [auto-fixable]"
2899 } else {
2900 ""
2901 }
2902 );
2903 }
2904 if report.surface_issues.iter().any(|issue| issue.auto_fixable) {
2905 if let Some(requested_package) = report.requested_package.as_deref() {
2906 println!(
2907 "Heal with: xbp version workspace publish heal --repo {} --only {} --include-prereqs --write",
2908 quote_argument(&surface.repo_root),
2909 requested_package
2910 );
2911 } else {
2912 println!(
2913 "Heal with: xbp version workspace publish heal --repo {} --write",
2914 quote_argument(&surface.repo_root)
2915 );
2916 }
2917 }
2918 }
2919}
2920
2921fn print_publish_heal_report(surface: &ReleaseSurface, report: &WorkspacePublishHealReport) {
2922 println!("Workspace publish heal");
2923 println!("Repo: {}", surface.repo_root.display());
2924 println!("Expected version: {}", report.expected_version);
2925 println!("Write: {}", report.write);
2926 if report.issues.is_empty() {
2927 println!("No publish-surface issues found.");
2928 return;
2929 }
2930 println!("Issues:");
2931 for issue in &report.issues {
2932 println!(
2933 " {} — {}{}",
2934 issue.package,
2935 issue.detail,
2936 if issue.auto_fixable {
2937 " [auto-fixable]"
2938 } else {
2939 " [manual]"
2940 }
2941 );
2942 }
2943 if report.write {
2944 if report.files_changed.is_empty() {
2945 println!("No files changed.");
2946 } else {
2947 println!("Files changed: {}", report.files_changed.join(", "));
2948 }
2949 } else if report.issues.iter().any(|issue| issue.auto_fixable) {
2950 println!("Dry preview only. Re-run with --write to apply auto-fixes.");
2951 }
2952}
2953
2954fn print_publish_run_report(surface: &ReleaseSurface, report: &WorkspacePublishRunReport) {
2955 println!("Workspace publish run");
2956 println!("Repo: {}", surface.repo_root.display());
2957 println!("Dry run: {}", report.dry_run);
2958 if let Some(requested_package) = report.requested_package.as_deref() {
2959 println!("Requested package: {}", requested_package);
2960 }
2961 if !report.included_prereqs.is_empty() {
2962 println!(
2963 "Auto-included prerequisites: {}",
2964 report.included_prereqs.join(", ")
2965 );
2966 }
2967 if !report.required_closure.is_empty() {
2968 println!("Required closure: {}", report.required_closure.join(" -> "));
2969 }
2970 if !report.published.is_empty() {
2971 println!("Published: {}", report.published.join(", "));
2972 }
2973 if !report.skipped.is_empty() {
2974 println!("Skipped: {}", report.skipped.join("; "));
2975 }
2976 if !report.failed.is_empty() {
2977 println!("Failed: {}", report.failed.join("; "));
2978 }
2979}
2980
2981#[cfg(test)]
2982mod tests {
2983 use super::{
2984 apply_publish_surface_heals, apply_sync, build_publish_plan, collect_drift,
2985 collect_publish_blockers, collect_publish_surface_issues, discover_release_surface,
2986 inspect_workspace_version_drift, resolve_expected_version, PublishSelection,
2987 PublishSurfaceIssueKind,
2988 };
2989 use semver::Version;
2990 use std::collections::BTreeMap;
2991 use std::fs;
2992 use std::path::PathBuf;
2993 use std::time::{SystemTime, UNIX_EPOCH};
2994
2995 fn temp_dir(name: &str) -> PathBuf {
2996 let nanos = SystemTime::now()
2997 .duration_since(UNIX_EPOCH)
2998 .expect("time")
2999 .as_nanos();
3000 let dir = std::env::temp_dir().join(format!("xbp-workspace-release-{}-{}", name, nanos));
3001 fs::create_dir_all(&dir).expect("create temp dir");
3002 dir
3003 }
3004
3005 fn write_file(path: &PathBuf, content: &str) {
3006 if let Some(parent) = path.parent() {
3007 fs::create_dir_all(parent).expect("create parent");
3008 }
3009 fs::write(path, content).expect("write file");
3010 }
3011
3012 fn create_demo_workspace() -> PathBuf {
3013 let root = temp_dir("demo");
3014 write_file(
3015 &root.join("Cargo.toml"),
3016 r#"[package]
3017name = "athena_rs"
3018version = "3.16.4"
3019
3020[dependencies.alpha]
3021path = "crates/alpha"
3022version = "3.16.4"
3023
3024[dependencies.beta]
3025path = "crates/beta"
3026version = "3.16.4"
3027
3028[dependencies.athena-s3]
3029path = "crates/athena-s3"
3030version = "3.16.4"
3031
3032[workspace]
3033members = ["crates/alpha", "crates/beta", "crates/athena-s3"]
3034resolver = "2"
3035"#,
3036 );
3037 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
3038 write_file(
3039 &root.join("README.md"),
3040 "# Athena\n\ncurrent version: `3.16.4`\n",
3041 );
3042 write_file(
3043 &root.join("openapi.yaml"),
3044 "openapi: 3.1.0\ninfo:\n title: Athena\n version: 3.16.4\n",
3045 );
3046 write_file(
3047 &root.join("Cargo.lock"),
3048 r#"version = 4
3049
3050[[package]]
3051name = "athena_rs"
3052version = "3.16.4"
3053
3054[[package]]
3055name = "alpha"
3056version = "3.16.4"
3057
3058[[package]]
3059name = "beta"
3060version = "3.16.4"
3061
3062[[package]]
3063name = "athena-s3"
3064version = "3.16.4"
3065"#,
3066 );
3067 write_file(
3068 &root.join("crates/alpha/Cargo.toml"),
3069 r#"[package]
3070name = "alpha"
3071version = "3.16.4"
3072
3073[dependencies]
3074beta = { path = "../beta", version = "3.16.4" }
3075athena-s3 = { path = "../athena-s3", version = "3.16.4" }
3076"#,
3077 );
3078 write_file(&root.join("crates/alpha/src/lib.rs"), "pub fn alpha() {}\n");
3079 write_file(
3080 &root.join("crates/beta/Cargo.toml"),
3081 r#"[package]
3082name = "beta"
3083version = "3.16.4"
3084"#,
3085 );
3086 write_file(&root.join("crates/beta/src/lib.rs"), "pub fn beta() {}\n");
3087 write_file(
3088 &root.join("crates/athena-s3/Cargo.toml"),
3089 r#"[package]
3090name = "athena-s3"
3091version = "3.16.4"
3092"#,
3093 );
3094 write_file(
3095 &root.join("crates/athena-s3/src/lib.rs"),
3096 "pub fn athena_s3() {}\n",
3097 );
3098 write_file(
3099 &root.join("crates/athena-backups/Cargo.toml"),
3100 r#"[package]
3101name = "athena-backups"
3102version = "3.16.0"
3103
3104[dependencies]
3105beta = { path = "../beta", version = "3.16.0" }
3106"#,
3107 );
3108 write_file(
3109 &root.join("crates/athena-backups/src/lib.rs"),
3110 "pub fn athena_backups() {}\n",
3111 );
3112 root
3113 }
3114
3115 #[test]
3116 fn inspect_workspace_version_drift_ignores_parent_workspace_for_nested_project_root() {
3117 let root = create_demo_workspace();
3118 let nested_project_root = root.join("packages").join("athena-js");
3119 fs::create_dir_all(&nested_project_root).expect("create nested project root");
3120
3121 let drift = inspect_workspace_version_drift(&nested_project_root, &Version::new(2, 13, 0))
3122 .expect("inspect drift");
3123
3124 assert!(drift.is_none());
3125 }
3126
3127 fn create_workspace_dependency_demo_workspace() -> PathBuf {
3128 let root = temp_dir("workspace-deps");
3129 write_file(
3130 &root.join("Cargo.toml"),
3131 r#"[package]
3132name = "xbp"
3133version = "10.27.0"
3134
3135[dependencies]
3136xbp-providers.workspace = true
3137
3138[workspace]
3139members = ["crates/http", "crates/providers"]
3140resolver = "2"
3141
3142[workspace.dependencies]
3143xbp-http = { path = "crates/http", version = "0.1.0" }
3144xbp-providers = { path = "crates/providers", version = "0.1.0" }
3145"#,
3146 );
3147 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
3148 write_file(
3149 &root.join("crates/http/Cargo.toml"),
3150 r#"[package]
3151name = "xbp-http"
3152version = "0.1.0"
3153"#,
3154 );
3155 write_file(&root.join("crates/http/src/lib.rs"), "pub fn http() {}\n");
3156 write_file(
3157 &root.join("crates/providers/Cargo.toml"),
3158 r#"[package]
3159name = "xbp-providers"
3160version = "0.1.0"
3161
3162[dependencies]
3163xbp-http.workspace = true
3164"#,
3165 );
3166 write_file(
3167 &root.join("crates/providers/src/lib.rs"),
3168 "pub fn providers() {}\n",
3169 );
3170 root
3171 }
3172
3173 fn create_alias_dependency_demo_workspace() -> PathBuf {
3174 let root = temp_dir("workspace-alias-deps");
3175 write_file(
3176 &root.join("Cargo.toml"),
3177 r#"[package]
3178name = "xbp"
3179version = "10.27.0"
3180
3181[dependencies.xbp_monitor]
3182package = "xbp-monitoring"
3183path = "crates/monitor"
3184version = "10.27.0"
3185
3186[workspace]
3187members = ["crates/monitor"]
3188resolver = "2"
3189"#,
3190 );
3191 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
3192 write_file(
3193 &root.join("crates/monitor/Cargo.toml"),
3194 r#"[package]
3195name = "xbp-monitoring"
3196version = "10.27.0"
3197"#,
3198 );
3199 write_file(
3200 &root.join("crates/monitor/src/lib.rs"),
3201 "pub fn monitor() {}\n",
3202 );
3203 root
3204 }
3205
3206 fn create_virtual_workspace_package_demo_workspace() -> PathBuf {
3207 let root = temp_dir("virtual-workspace-package");
3208 write_file(
3209 &root.join("Cargo.toml"),
3210 r#"[workspace]
3211members = ["crates/cli"]
3212resolver = "2"
3213
3214[workspace.package]
3215version = "10.35.0"
3216"#,
3217 );
3218 write_file(
3219 &root.join("crates/cli/Cargo.toml"),
3220 r#"[package]
3221name = "xbp"
3222version.workspace = true
3223"#,
3224 );
3225 write_file(&root.join("crates/cli/src/lib.rs"), "pub fn cli() {}\n");
3226 root
3227 }
3228
3229 #[test]
3230 fn discovery_uses_workspace_members_and_excludes_non_member_crates() {
3231 let root = create_demo_workspace();
3232 let surface = discover_release_surface(&root).expect("discover");
3233 let names = surface
3234 .packages
3235 .iter()
3236 .map(|package| package.name.clone())
3237 .collect::<Vec<_>>();
3238 assert!(names.contains(&"athena-s3".to_string()));
3239 assert!(!names.contains(&"athena-backups".to_string()));
3240 }
3241
3242 #[test]
3243 fn expected_version_reads_virtual_workspace_package_version() {
3244 let root = create_virtual_workspace_package_demo_workspace();
3245 let surface = discover_release_surface(&root).expect("discover");
3246
3247 assert_eq!(
3248 resolve_expected_version(&surface, None).expect("expected version"),
3249 Version::new(10, 35, 0)
3250 );
3251
3252 let _ = fs::remove_dir_all(root);
3253 }
3254
3255 #[test]
3256 fn drift_reports_package_dependency_metadata_and_lock_mismatches() {
3257 let root = create_demo_workspace();
3258 write_file(
3259 &root.join("crates/alpha/Cargo.toml"),
3260 r#"[package]
3261name = "alpha"
3262version = "3.16.5"
3263
3264[dependencies]
3265beta = { path = "../beta", version = "3.16.4" }
3266athena-s3 = { path = "../athena-s3" }
3267"#,
3268 );
3269 let surface = discover_release_surface(&root).expect("discover");
3270 let expected = Version::new(3, 16, 4);
3271 let drift = collect_drift(&surface, &expected).expect("drift");
3272 assert!(drift.iter().any(
3273 |entry| entry.path == "crates/alpha/Cargo.toml" && entry.field == "package.version"
3274 ));
3275 assert!(
3276 drift
3277 .iter()
3278 .any(|entry| entry.field == "dependencies.athena-s3.version"
3279 && entry.actual.is_none())
3280 );
3281 }
3282
3283 #[test]
3284 fn sync_preview_and_write_updates_workspace_surface() {
3285 let root = create_demo_workspace();
3286 let surface = discover_release_surface(&root).expect("discover");
3287 let expected = Version::new(3, 16, 5);
3288 let (preview, _) = apply_sync(&surface, &expected, false).expect("preview");
3289 assert!(!preview.is_empty());
3290
3291 let (written, files) = apply_sync(&surface, &expected, true).expect("write");
3292 assert!(!written.is_empty());
3293 assert!(files.contains(&"Cargo.toml".to_string()));
3294 let updated = fs::read_to_string(root.join("crates/alpha/Cargo.toml")).expect("read");
3295 assert!(updated.contains("version = \"3.16.5\""));
3296 assert!(updated.contains("athena-s3 = { path = \"../athena-s3\", version = \"3.16.5\" }"));
3297 }
3298
3299 #[test]
3300 fn drift_and_sync_follow_aliased_workspace_dependencies() {
3301 let root = create_alias_dependency_demo_workspace();
3302 write_file(
3303 &root.join("Cargo.toml"),
3304 r#"[package]
3305name = "xbp"
3306version = "10.27.1"
3307
3308[dependencies.xbp_monitor]
3309package = "xbp-monitoring"
3310path = "crates/monitor"
3311version = "10.27.0"
3312
3313[workspace]
3314members = ["crates/monitor"]
3315resolver = "2"
3316"#,
3317 );
3318
3319 let surface = discover_release_surface(&root).expect("discover");
3320 let expected = Version::new(10, 27, 1);
3321 let drift = collect_drift(&surface, &expected).expect("drift");
3322 assert!(drift
3323 .iter()
3324 .any(|entry| entry.path == "Cargo.toml"
3325 && entry.field == "dependencies.xbp_monitor.version"));
3326
3327 let (written, files) = apply_sync(&surface, &expected, true).expect("write");
3328 assert!(!written.is_empty());
3329 assert!(files.contains(&"Cargo.toml".to_string()));
3330 let updated = fs::read_to_string(root.join("Cargo.toml")).expect("read root manifest");
3331 assert!(updated.contains("version = \"10.27.1\""));
3332 assert!(updated.contains("package = \"xbp-monitoring\""));
3333 assert!(updated.contains("version = \"10.27.1\""));
3334 }
3335
3336 #[test]
3337 fn publish_plan_orders_dependencies_before_dependents() {
3338 let root = create_demo_workspace();
3339 let surface = discover_release_surface(&root).expect("discover");
3340 let mut visibility = BTreeMap::new();
3341 visibility.insert("athena_rs".to_string(), false);
3342 visibility.insert("alpha".to_string(), false);
3343 visibility.insert("beta".to_string(), true);
3344 visibility.insert("athena-s3".to_string(), false);
3345 let plan = build_publish_plan(
3346 &surface,
3347 &visibility,
3348 Some(&PublishSelection {
3349 from: None,
3350 only: None,
3351 include_prereqs: false,
3352 }),
3353 )
3354 .expect("plan");
3355 let alpha_pos = plan
3356 .publish_order
3357 .iter()
3358 .position(|name| name == "alpha")
3359 .expect("alpha");
3360 let s3_pos = plan
3361 .publish_order
3362 .iter()
3363 .position(|name| name == "athena-s3")
3364 .expect("s3");
3365 assert!(s3_pos < alpha_pos);
3366 assert!(plan
3367 .items
3368 .iter()
3369 .any(|item| item.package == "athena_rs" && item.publish_needed));
3370 }
3371
3372 #[test]
3373 fn publish_plan_orders_workspace_dependencies_before_dependents() {
3374 let root = create_workspace_dependency_demo_workspace();
3375 let surface = discover_release_surface(&root).expect("discover");
3376 let mut visibility = BTreeMap::new();
3377 visibility.insert("xbp".to_string(), false);
3378 visibility.insert("xbp-http".to_string(), false);
3379 visibility.insert("xbp-providers".to_string(), false);
3380
3381 let plan = build_publish_plan(
3382 &surface,
3383 &visibility,
3384 Some(&PublishSelection {
3385 from: None,
3386 only: None,
3387 include_prereqs: false,
3388 }),
3389 )
3390 .expect("plan");
3391
3392 let xbp_pos = plan
3393 .publish_order
3394 .iter()
3395 .position(|name| name == "xbp")
3396 .expect("xbp");
3397 let providers_pos = plan
3398 .publish_order
3399 .iter()
3400 .position(|name| name == "xbp-providers")
3401 .expect("providers");
3402 let http_pos = plan
3403 .publish_order
3404 .iter()
3405 .position(|name| name == "xbp-http")
3406 .expect("http");
3407
3408 assert!(http_pos < providers_pos);
3409 assert!(providers_pos < xbp_pos);
3410 }
3411
3412 #[test]
3413 fn publish_plan_orders_aliased_workspace_dependencies_before_dependents() {
3414 let root = create_alias_dependency_demo_workspace();
3415 let surface = discover_release_surface(&root).expect("discover");
3416 let mut visibility = BTreeMap::new();
3417 visibility.insert("xbp".to_string(), false);
3418 visibility.insert("xbp-monitoring".to_string(), false);
3419
3420 let plan = build_publish_plan(
3421 &surface,
3422 &visibility,
3423 Some(&PublishSelection {
3424 from: None,
3425 only: None,
3426 include_prereqs: false,
3427 }),
3428 )
3429 .expect("plan");
3430
3431 let xbp_pos = plan
3432 .publish_order
3433 .iter()
3434 .position(|name| name == "xbp")
3435 .expect("xbp");
3436 let monitor_pos = plan
3437 .publish_order
3438 .iter()
3439 .position(|name| name == "xbp-monitoring")
3440 .expect("xbp-monitoring");
3441
3442 assert!(monitor_pos < xbp_pos);
3443 }
3444
3445 #[test]
3446 fn publish_plan_only_with_prereqs_limits_to_minimal_closure() {
3447 let root = create_demo_workspace();
3448 let surface = discover_release_surface(&root).expect("discover");
3449 let mut visibility = BTreeMap::new();
3450 visibility.insert("athena_rs".to_string(), false);
3451 visibility.insert("alpha".to_string(), false);
3452 visibility.insert("beta".to_string(), false);
3453 visibility.insert("athena-s3".to_string(), false);
3454
3455 let plan = build_publish_plan(
3456 &surface,
3457 &visibility,
3458 Some(&PublishSelection {
3459 from: None,
3460 only: Some("alpha".to_string()),
3461 include_prereqs: true,
3462 }),
3463 )
3464 .expect("plan");
3465
3466 let package_names = plan
3467 .items
3468 .iter()
3469 .map(|item| item.package.as_str())
3470 .collect::<Vec<_>>();
3471 assert_eq!(package_names.len(), 3);
3472 assert!(!package_names.contains(&"athena_rs"));
3473 assert_eq!(plan.required_closure.len(), 3);
3474 assert!(plan.required_closure.contains(&"alpha".to_string()));
3475 assert!(plan.required_closure.contains(&"beta".to_string()));
3476 assert!(plan.required_closure.contains(&"athena-s3".to_string()));
3477 assert_eq!(plan.included_prereqs.len(), 2);
3478 assert!(plan.included_prereqs.contains(&"beta".to_string()));
3479 assert!(plan.included_prereqs.contains(&"athena-s3".to_string()));
3480 assert_eq!(plan.publish_order.len(), 3);
3481
3482 let alpha_pos = plan
3483 .publish_order
3484 .iter()
3485 .position(|package| package == "alpha")
3486 .expect("alpha in publish order");
3487 let beta_pos = plan
3488 .publish_order
3489 .iter()
3490 .position(|package| package == "beta")
3491 .expect("beta in publish order");
3492 let s3_pos = plan
3493 .publish_order
3494 .iter()
3495 .position(|package| package == "athena-s3")
3496 .expect("athena-s3 in publish order");
3497 assert!(beta_pos < alpha_pos);
3498 assert!(s3_pos < alpha_pos);
3499 }
3500
3501 #[test]
3502 fn publish_plan_only_without_prereqs_reports_blocked_package() {
3503 let root = create_demo_workspace();
3504 let surface = discover_release_surface(&root).expect("discover");
3505 let mut visibility = BTreeMap::new();
3506 visibility.insert("athena_rs".to_string(), false);
3507 visibility.insert("alpha".to_string(), false);
3508 visibility.insert("beta".to_string(), false);
3509 visibility.insert("athena-s3".to_string(), false);
3510
3511 let plan = build_publish_plan(
3512 &surface,
3513 &visibility,
3514 Some(&PublishSelection {
3515 from: None,
3516 only: Some("alpha".to_string()),
3517 include_prereqs: false,
3518 }),
3519 )
3520 .expect("plan");
3521
3522 assert_eq!(plan.required_closure.len(), 3);
3523 assert!(plan.required_closure.contains(&"alpha".to_string()));
3524 assert!(plan.required_closure.contains(&"beta".to_string()));
3525 assert!(plan.required_closure.contains(&"athena-s3".to_string()));
3526 assert!(plan.included_prereqs.is_empty());
3527 assert!(plan.publish_order.is_empty());
3528 assert_eq!(plan.items.len(), 1);
3529 assert_eq!(plan.items[0].package, "alpha");
3530 assert_eq!(plan.items[0].blocked_by.len(), 2);
3531 assert!(plan.items[0].blocked_by.contains(&"beta".to_string()));
3532 assert!(plan.items[0].blocked_by.contains(&"athena-s3".to_string()));
3533 }
3534
3535 #[test]
3536 fn publish_plan_ignores_dev_dependencies_for_publish_blockers() {
3537 let root = temp_dir("publish-dev-deps");
3538 write_file(
3539 &root.join("Cargo.toml"),
3540 r#"[package]
3541name = "demo-root"
3542version = "1.0.0"
3543
3544[workspace]
3545members = ["crates/app", "crates/dev-helper"]
3546resolver = "2"
3547"#,
3548 );
3549 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
3550 write_file(
3551 &root.join("crates/app/Cargo.toml"),
3552 r#"[package]
3553name = "demo-app"
3554version = "1.0.0"
3555
3556[dev-dependencies]
3557dev-helper = { path = "../dev-helper", version = "1.0.0" }
3558"#,
3559 );
3560 write_file(&root.join("crates/app/src/lib.rs"), "pub fn app() {}\n");
3561 write_file(
3562 &root.join("crates/dev-helper/Cargo.toml"),
3563 r#"[package]
3564name = "dev-helper"
3565version = "1.0.0"
3566"#,
3567 );
3568 write_file(
3569 &root.join("crates/dev-helper/src/lib.rs"),
3570 "pub fn helper() {}\n",
3571 );
3572
3573 let surface = discover_release_surface(&root).expect("discover");
3574 let mut visibility = BTreeMap::new();
3575 visibility.insert("demo-root".to_string(), false);
3576 visibility.insert("demo-app".to_string(), false);
3577 visibility.insert("dev-helper".to_string(), false);
3578
3579 let plan = build_publish_plan(
3580 &surface,
3581 &visibility,
3582 Some(&PublishSelection {
3583 from: None,
3584 only: Some("demo-app".to_string()),
3585 include_prereqs: false,
3586 }),
3587 )
3588 .expect("plan");
3589
3590 assert_eq!(plan.required_closure, vec!["demo-app"]);
3591 assert_eq!(plan.publish_order, vec!["demo-app"]);
3592 assert!(plan.items[0].blocked_by.is_empty());
3593 }
3594
3595 #[test]
3596 fn publish_plan_orphan_publish_false_is_not_a_blocker() {
3597 let root = temp_dir("publish-orphan-private");
3598 write_file(
3599 &root.join("Cargo.toml"),
3600 r#"[workspace]
3601members = ["crates/app", "crates/private-tool"]
3602resolver = "2"
3603"#,
3604 );
3605 write_file(
3606 &root.join("crates/app/Cargo.toml"),
3607 r#"[package]
3608name = "demo-app"
3609version = "1.0.0"
3610"#,
3611 );
3612 write_file(&root.join("crates/app/src/lib.rs"), "pub fn app() {}\n");
3613 write_file(
3614 &root.join("crates/private-tool/Cargo.toml"),
3615 r#"[package]
3616name = "private-tool"
3617version = "1.0.0"
3618publish = false
3619"#,
3620 );
3621 write_file(
3622 &root.join("crates/private-tool/src/lib.rs"),
3623 "pub fn tool() {}\n",
3624 );
3625
3626 let surface = discover_release_surface(&root).expect("discover");
3627 let mut visibility = BTreeMap::new();
3628 visibility.insert("demo-app".to_string(), false);
3629 visibility.insert("private-tool".to_string(), false);
3630
3631 let plan = build_publish_plan(
3632 &surface,
3633 &visibility,
3634 Some(&PublishSelection {
3635 from: None,
3636 only: None,
3637 include_prereqs: false,
3638 }),
3639 )
3640 .expect("plan");
3641
3642 let blockers = collect_publish_blockers(&plan.items);
3643 assert!(
3644 blockers.is_empty(),
3645 "orphan private packages must not block: {blockers:?}"
3646 );
3647 assert!(plan.publish_order.contains(&"demo-app".to_string()));
3648 assert!(!plan.publish_order.contains(&"private-tool".to_string()));
3649 }
3650
3651 #[test]
3652 fn publish_surface_heal_enables_private_path_prereq() {
3653 let root = temp_dir("publish-heal-private-prereq");
3654 write_file(
3655 &root.join("Cargo.toml"),
3656 r#"[workspace]
3657members = ["crates/app", "crates/internal"]
3658resolver = "2"
3659
3660[workspace.package]
3661homepage = "https://example.com"
3662repository = "https://github.com/example/xbp"
3663license = "MIT"
3664"#,
3665 );
3666 write_file(
3667 &root.join("crates/app/Cargo.toml"),
3668 r#"[package]
3669name = "demo-app"
3670version = "1.2.0"
3671
3672[dependencies]
3673internal = { path = "../internal", version = "1.2.0" }
3674"#,
3675 );
3676 write_file(&root.join("crates/app/src/lib.rs"), "pub fn app() {}\n");
3677 write_file(
3678 &root.join("crates/internal/Cargo.toml"),
3679 r#"[package]
3680name = "internal"
3681version = "1.2.0"
3682publish = false
3683"#,
3684 );
3685 write_file(
3686 &root.join("crates/internal/src/lib.rs"),
3687 "pub fn internal() {}\n",
3688 );
3689
3690 let surface = discover_release_surface(&root).expect("discover");
3691 let selection = PublishSelection {
3692 from: None,
3693 only: Some("demo-app".to_string()),
3694 include_prereqs: true,
3695 };
3696 let expected = Version::new(1, 2, 0);
3697 let issues =
3698 collect_publish_surface_issues(&surface, Some(&selection), &expected).expect("issues");
3699 assert!(issues.iter().any(|issue| {
3700 issue.package == "internal"
3701 && issue.kind == PublishSurfaceIssueKind::PublishDisabled
3702 && issue.auto_fixable
3703 }));
3704
3705 let mut visibility = BTreeMap::new();
3706 visibility.insert("demo-app".to_string(), false);
3707 visibility.insert("internal".to_string(), false);
3708 let blocked_plan =
3709 build_publish_plan(&surface, &visibility, Some(&selection)).expect("blocked plan");
3710 assert!(!collect_publish_blockers(&blocked_plan.items).is_empty());
3711
3712 let (_edits, files) =
3713 apply_publish_surface_heals(&surface, Some(&selection), &expected, true)
3714 .expect("heal");
3715 assert!(
3716 files.iter().any(|path| path.contains("internal")),
3717 "expected internal Cargo.toml in {files:?}"
3718 );
3719
3720 let healed = fs::read_to_string(root.join("crates/internal/Cargo.toml")).expect("read");
3721 assert!(
3722 !healed.contains("publish = false"),
3723 "publish=false should be removed: {healed}"
3724 );
3725 assert!(healed.contains("homepage"));
3726 assert!(healed.contains("repository"));
3727
3728 let surface = discover_release_surface(&root).expect("rediscover");
3729 let plan = build_publish_plan(&surface, &visibility, Some(&selection)).expect("plan");
3730 assert!(
3731 collect_publish_blockers(&plan.items).is_empty(),
3732 "healed surface should be unblocked: {:?}",
3733 collect_publish_blockers(&plan.items)
3734 );
3735 assert!(plan.publish_order.contains(&"internal".to_string()));
3736 assert!(plan.publish_order.contains(&"demo-app".to_string()));
3737 }
3738
3739 #[test]
3740 fn publish_surface_exclude_is_not_auto_fixable() {
3741 let root = temp_dir("publish-exclude-manual");
3742 write_file(
3743 &root.join("Cargo.toml"),
3744 r#"[workspace]
3745members = ["crates/app", "crates/secret"]
3746resolver = "2"
3747"#,
3748 );
3749 write_file(
3750 &root.join(".xbp/workspace-release.yaml"),
3751 "publish:\n exclude:\n - secret\n",
3752 );
3753 write_file(
3754 &root.join("crates/app/Cargo.toml"),
3755 r#"[package]
3756name = "demo-app"
3757version = "1.0.0"
3758
3759[dependencies]
3760secret = { path = "../secret", version = "1.0.0" }
3761"#,
3762 );
3763 write_file(&root.join("crates/app/src/lib.rs"), "pub fn app() {}\n");
3764 write_file(
3765 &root.join("crates/secret/Cargo.toml"),
3766 r#"[package]
3767name = "secret"
3768version = "1.0.0"
3769"#,
3770 );
3771 write_file(&root.join("crates/secret/src/lib.rs"), "pub fn secret() {}\n");
3772
3773 let surface = discover_release_surface(&root).expect("discover");
3774 let selection = PublishSelection {
3775 from: None,
3776 only: Some("demo-app".to_string()),
3777 include_prereqs: true,
3778 };
3779 let issues = collect_publish_surface_issues(
3780 &surface,
3781 Some(&selection),
3782 &Version::new(1, 0, 0),
3783 )
3784 .expect("issues");
3785 assert!(issues.iter().any(|issue| {
3786 issue.package == "secret"
3787 && issue.kind == PublishSurfaceIssueKind::PublishExcluded
3788 && !issue.auto_fixable
3789 }));
3790 }
3791}