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