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(surface, options.package.as_deref())?);
962 }
963
964 let ok = issues.is_empty() && commands.iter().all(|result| result.success);
965 Ok(WorkspaceValidateReport {
966 repo_root: display_path(&surface.repo_root),
967 ok,
968 issues,
969 commands,
970 })
971}
972
973fn select_packages_for_validation<'a>(
974 surface: &'a ReleaseSurface,
975 package_name: Option<&str>,
976) -> Result<Vec<&'a ReleasePackage>, String> {
977 if let Some(package_name) = package_name {
978 let package = surface
979 .packages
980 .iter()
981 .find(|package| package.name == package_name)
982 .ok_or_else(|| format!("Unknown workspace package `{}`.", package_name))?;
983 return Ok(vec![package]);
984 }
985 Ok(surface
986 .packages
987 .iter()
988 .filter(|package| package.publishable && !package.publish_excluded)
989 .collect())
990}
991
992fn select_ordered_packages_for_validation<'a>(
993 surface: &'a ReleaseSurface,
994 package_name: Option<&str>,
995) -> Result<Vec<&'a ReleasePackage>, String> {
996 let selected = select_packages_for_validation(surface, package_name)?;
997 let selected_names: BTreeSet<String> = selected
998 .iter()
999 .map(|package| package.name.clone())
1000 .collect();
1001 let ordered_names = topological_package_order(surface)?;
1002 Ok(ordered_names
1003 .into_iter()
1004 .filter(|name| selected_names.contains(name))
1005 .filter_map(|name| selected.iter().find(|package| package.name == name).copied())
1006 .collect())
1007}
1008
1009fn run_package_dry_run_validation(
1010 surface: &ReleaseSurface,
1011 package_name: Option<&str>,
1012) -> Result<Vec<ValidationCommandResult>, String> {
1013 let packages = select_ordered_packages_for_validation(surface, package_name)?;
1014 let mut results = Vec::new();
1015
1016 for package in packages {
1017 let label = format!(
1018 "cargo publish --dry-run --locked --no-verify --manifest-path {}",
1019 package.manifest_relative
1020 );
1021 let repo_root = surface.repo_root.clone();
1022 let manifest_path = package.manifest_path.clone();
1023 let mut result = run_publish_dry_run_capture(&repo_root, &manifest_path, &label)?;
1024 if !result.success {
1025 if is_unpublished_workspace_dependency_error(&result.stderr) {
1026 let fallback_label = format!(
1027 "cargo package --allow-dirty --no-verify --manifest-path {}",
1028 package.manifest_relative
1029 );
1030 let fallback_result =
1031 run_package_capture(&repo_root, &manifest_path, &fallback_label)?;
1032 if fallback_result.success {
1033 result = ValidationCommandResult {
1034 command: fallback_label,
1035 success: true,
1036 exit_code: fallback_result.exit_code,
1037 stderr: fallback_result.stderr,
1038 warning: Some(format!(
1039 "Registry dry-run skipped for `{}`: workspace dependencies are not on crates.io yet. Local packaging succeeded.",
1040 package.name
1041 )),
1042 };
1043 } else {
1044 result.stderr = append_validation_hint(&result.stderr);
1045 }
1046 } else {
1047 result.stderr = append_validation_hint(&result.stderr);
1048 }
1049 }
1050 results.push(result);
1051 }
1052
1053 Ok(results)
1054}
1055
1056fn is_unpublished_workspace_dependency_error(stderr: &str) -> bool {
1057 stderr.contains("no matching package named")
1058 || stderr.contains("failed to select a version for the requirement")
1059}
1060
1061fn is_transient_registry_error(stderr: &str) -> bool {
1062 stderr.contains("Could not resolve host")
1063 || stderr.contains("Could not resolve hostname")
1064 || stderr.contains("failed to update registry")
1065 || stderr.contains("download of config.json failed")
1066}
1067
1068fn append_validation_hint(stderr: &str) -> String {
1069 let mut message = stderr.to_string();
1070 if stderr.contains("Access is denied") || stderr.contains("os error 5") {
1071 message.push_str(
1072 "\nHint: close any running `xbp` processes before package dry-run validation on Windows.",
1073 );
1074 }
1075 if is_transient_registry_error(stderr) {
1076 message.push_str(
1077 "\nHint: crates.io was unreachable. Check network connectivity and retry.",
1078 );
1079 }
1080 if is_unpublished_workspace_dependency_error(stderr) {
1081 message.push_str(
1082 "\nHint: publish workspace dependencies first, or rely on the local `cargo package` fallback.",
1083 );
1084 }
1085 message
1086}
1087
1088fn run_publish_dry_run_capture(
1089 repo_root: &Path,
1090 manifest_path: &Path,
1091 label: &str,
1092) -> Result<ValidationCommandResult, String> {
1093 let build = || {
1094 let mut command = Command::new("cargo");
1095 command
1096 .current_dir(repo_root)
1097 .arg("publish")
1098 .arg("--dry-run")
1099 .arg("--locked")
1100 .arg("--no-verify")
1101 .arg("--manifest-path")
1102 .arg(manifest_path);
1103 command
1104 };
1105 run_command_capture_with_retry(build, label)
1106}
1107
1108fn run_package_capture(
1109 repo_root: &Path,
1110 manifest_path: &Path,
1111 label: &str,
1112) -> Result<ValidationCommandResult, String> {
1113 let build = || {
1114 let mut command = Command::new("cargo");
1115 command
1116 .current_dir(repo_root)
1117 .arg("package")
1118 .arg("--allow-dirty")
1119 .arg("--no-verify")
1120 .arg("--manifest-path")
1121 .arg(manifest_path);
1122 command
1123 };
1124 run_command_capture_with_retry(build, label)
1125}
1126
1127fn run_command_capture_with_retry(
1128 build_command: impl Fn() -> Command,
1129 label: &str,
1130) -> Result<ValidationCommandResult, String> {
1131 let first = run_command_capture(build_command(), label)?;
1132 if first.success || !is_transient_registry_error(&first.stderr) {
1133 return Ok(first);
1134 }
1135
1136 thread::sleep(StdDuration::from_secs(2));
1137 let mut retry = run_command_capture(build_command(), format!("{label} (retry)"))?;
1138 if !retry.success {
1139 retry.stderr = append_validation_hint(&retry.stderr);
1140 }
1141 Ok(retry)
1142}
1143
1144async fn build_publish_plan_report(
1145 surface: &ReleaseSurface,
1146 options: &WorkspacePublishPlanOptions,
1147) -> Result<WorkspacePublishPlanReport, String> {
1148 let visibility = collect_crates_io_visibility(surface).await?;
1149 let plan = build_publish_plan(
1150 surface,
1151 &visibility,
1152 Some(&PublishSelection {
1153 from: None,
1154 only: options.only.clone(),
1155 include_prereqs: options.include_prereqs,
1156 }),
1157 )?;
1158 Ok(WorkspacePublishPlanReport {
1159 repo_root: display_path(&surface.repo_root),
1160 requested_package: plan.requested_package,
1161 included_prereqs: plan.included_prereqs,
1162 required_closure: plan.required_closure,
1163 packages: plan.items,
1164 publish_order: plan.publish_order,
1165 })
1166}
1167
1168#[derive(Debug, Clone)]
1169pub(crate) struct WorkspaceVersionDriftSummary {
1170 pub expected_version: Version,
1171 pub drift_count: usize,
1172 pub preview: Vec<String>,
1173}
1174
1175pub(crate) fn inspect_workspace_version_drift(
1176 repo_root: &Path,
1177 expected_version: &Version,
1178) -> Result<Option<WorkspaceVersionDriftSummary>, String> {
1179 let surface = match discover_release_surface(repo_root) {
1180 Ok(surface) => surface,
1181 Err(_) => return Ok(None),
1182 };
1183 if surface.packages.len() <= 1 && surface.config_path.is_none() {
1184 return Ok(None);
1185 }
1186
1187 let drift = collect_drift(&surface, expected_version)?;
1188 if drift.is_empty() {
1189 return Ok(None);
1190 }
1191
1192 let preview = drift
1193 .iter()
1194 .take(6)
1195 .map(|entry| {
1196 format!(
1197 "{} {}: {} -> {}",
1198 entry.path,
1199 entry.field,
1200 entry.actual.as_deref().unwrap_or("<missing>"),
1201 entry.expected
1202 )
1203 })
1204 .collect();
1205
1206 Ok(Some(WorkspaceVersionDriftSummary {
1207 expected_version: expected_version.clone(),
1208 drift_count: drift.len(),
1209 preview,
1210 }))
1211}
1212
1213pub(crate) fn sync_workspace_to_version(
1214 repo_root: &Path,
1215 expected_version: &Version,
1216) -> Result<Vec<String>, String> {
1217 let surface = discover_release_surface(repo_root)?;
1218 let (_edits, changed_files) = apply_sync(&surface, expected_version, true)?;
1219 Ok(changed_files)
1220}
1221
1222pub(crate) async fn resolve_manifest_workspace_publish(
1223 manifest_path: &Path,
1224 include_prereqs: bool,
1225) -> Result<ManifestWorkspacePublishResolution, String> {
1226 let surface = discover_release_surface_from_manifest(manifest_path)?;
1227 let package = surface
1228 .packages
1229 .iter()
1230 .find(|package| same_path(&package.manifest_path, manifest_path))
1231 .ok_or_else(|| {
1232 format!(
1233 "Manifest {} is not a publishable package in the resolved Cargo workspace.",
1234 manifest_path.display()
1235 )
1236 })?;
1237 let visibility = collect_crates_io_visibility(&surface).await?;
1238 let plan = build_publish_plan(
1239 &surface,
1240 &visibility,
1241 Some(&PublishSelection {
1242 from: None,
1243 only: Some(package.name.clone()),
1244 include_prereqs,
1245 }),
1246 )?;
1247 Ok(ManifestWorkspacePublishResolution {
1248 workspace_root: surface.repo_root.clone(),
1249 requested_package: package.name.clone(),
1250 included_prereqs: plan.included_prereqs,
1251 required_closure: plan.required_closure,
1252 packages: plan.items,
1253 publish_order: build_publish_command_targets(&surface, &plan.publish_order)?,
1254 })
1255}
1256
1257async fn run_publish(
1258 surface: &ReleaseSurface,
1259 options: &WorkspacePublishRunOptions,
1260) -> Result<WorkspacePublishRunReport, String> {
1261 if options.only.is_some() && options.from.is_some() {
1262 return Err("`--only` cannot be combined with `--from`.".to_string());
1263 }
1264 if !options.allow_dirty {
1265 enforce_clean_worktree(&surface.repo_root)?;
1266 }
1267
1268 let validation = run_validation(
1269 surface,
1270 &WorkspaceVersionValidateOptions {
1271 package: options.only.clone(),
1272 cargo_check: false,
1273 package_dry_run: false,
1274 },
1275 )?;
1276 if !validation.ok {
1277 return Err("Workspace has structural publish blockers. Run `xbp version workspace validate` for details.".to_string());
1278 }
1279
1280 let visibility = collect_crates_io_visibility(surface).await?;
1281 let selection = PublishSelection {
1282 from: options.from.clone(),
1283 only: options.only.clone(),
1284 include_prereqs: options.include_prereqs,
1285 };
1286 let plan = build_publish_plan(surface, &visibility, Some(&selection))?;
1287 let blockers = collect_publish_blockers(&plan.items);
1288 if !blockers.is_empty() {
1289 return Err(render_workspace_publish_blockers(
1290 surface,
1291 &plan,
1292 Some(options),
1293 &blockers,
1294 ));
1295 }
1296
1297 let mut item_by_name = BTreeMap::new();
1298 for item in &plan.items {
1299 item_by_name.insert(item.package.clone(), item.clone());
1300 }
1301
1302 let mut published = Vec::new();
1303 let mut skipped = Vec::new();
1304 let mut failed = Vec::new();
1305 for package_name in &plan.publish_order {
1306 let Some(item) = item_by_name.get(package_name) else {
1307 continue;
1308 };
1309 if !item.publish_needed {
1310 skipped.push(format!("{} {}", item.package, item.reason));
1311 continue;
1312 }
1313 if !item.blocked_by.is_empty() {
1314 let message = format!("{} blocked by {}", item.package, item.blocked_by.join(", "));
1315 failed.push(message.clone());
1316 if !options.continue_on_error {
1317 break;
1318 }
1319 continue;
1320 }
1321
1322 let package = surface
1323 .packages
1324 .iter()
1325 .find(|package| package.name == item.package)
1326 .ok_or_else(|| format!("Missing package `{}` in release surface.", item.package))?;
1327 let cargo_publish = format!(
1328 "cargo publish --locked --manifest-path {}{}",
1329 package.manifest_relative,
1330 if options.allow_dirty {
1331 " --allow-dirty"
1332 } else {
1333 ""
1334 }
1335 );
1336 println!("{}", cargo_publish);
1337 if options.dry_run {
1338 published.push(format!("{} (dry-run)", item.package));
1339 continue;
1340 }
1341
1342 let status = Command::new("cargo")
1343 .current_dir(&surface.repo_root)
1344 .arg("publish")
1345 .arg("--locked")
1346 .arg("--manifest-path")
1347 .arg(&package.manifest_path)
1348 .args(options.allow_dirty.then_some("--allow-dirty"))
1349 .status()
1350 .map_err(|e| {
1351 format!(
1352 "Failed to execute cargo publish for {}: {}",
1353 item.package, e
1354 )
1355 })?;
1356 if !status.success() {
1357 let message = format!(
1358 "{} publish failed with exit code {:?}",
1359 item.package,
1360 status.code()
1361 );
1362 failed.push(message.clone());
1363 if !options.continue_on_error {
1364 break;
1365 }
1366 continue;
1367 }
1368
1369 wait_for_crates_io_visibility(
1370 &item.package,
1371 &item.version,
1372 options.timeout_seconds,
1373 options.poll_interval_seconds,
1374 )
1375 .await?;
1376 published.push(item.package.clone());
1377 }
1378
1379 Ok(WorkspacePublishRunReport {
1380 repo_root: display_path(&surface.repo_root),
1381 dry_run: options.dry_run,
1382 requested_package: plan.requested_package,
1383 included_prereqs: plan.included_prereqs,
1384 required_closure: plan.required_closure,
1385 published,
1386 skipped,
1387 failed,
1388 })
1389}
1390
1391fn build_publish_command_targets(
1392 surface: &ReleaseSurface,
1393 publish_order: &[String],
1394) -> Result<Vec<WorkspacePublishCommandTarget>, String> {
1395 let mut targets = Vec::new();
1396 for package_name in publish_order {
1397 let package = surface
1398 .packages
1399 .iter()
1400 .find(|package| &package.name == package_name)
1401 .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1402 targets.push(WorkspacePublishCommandTarget {
1403 package: package.name.clone(),
1404 version: package.version.to_string(),
1405 manifest_path: package.manifest_path.clone(),
1406 manifest_relative: package.manifest_relative.clone(),
1407 });
1408 }
1409 Ok(targets)
1410}
1411
1412#[derive(Debug, Clone)]
1413struct PublishSelection {
1414 from: Option<String>,
1415 only: Option<String>,
1416 include_prereqs: bool,
1417}
1418
1419#[derive(Debug, Clone)]
1420struct ResolvedPublishSelection {
1421 requested_package: Option<String>,
1422 selected_packages: Vec<String>,
1423 included_prereqs: Vec<String>,
1424 required_closure: Vec<String>,
1425}
1426
1427#[derive(Debug, Clone)]
1428struct BuiltPublishPlan {
1429 requested_package: Option<String>,
1430 included_prereqs: Vec<String>,
1431 required_closure: Vec<String>,
1432 items: Vec<PublishPlanItem>,
1433 publish_order: Vec<String>,
1434}
1435
1436fn build_publish_plan(
1437 surface: &ReleaseSurface,
1438 visibility: &BTreeMap<String, bool>,
1439 selection: Option<&PublishSelection>,
1440) -> Result<BuiltPublishPlan, String> {
1441 let ordered_packages = topological_package_order(surface)?;
1442 let selected = resolve_selected_packages(surface, &ordered_packages, selection)?;
1443 let selected_set: BTreeSet<String> = selected.selected_packages.iter().cloned().collect();
1444 let mut available = visibility.clone();
1445 let mut items = Vec::new();
1446 let mut publish_order = Vec::new();
1447
1448 for package_name in ordered_packages {
1449 if !selected_set.contains(&package_name) {
1450 continue;
1451 }
1452 let package = surface
1453 .packages
1454 .iter()
1455 .find(|package| package.name == package_name)
1456 .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1457 let visible = visibility.get(&package.name).copied();
1458 let mut publish_needed = false;
1459 let mut blocked_by = Vec::new();
1460 let reason = if visible == Some(true) {
1461 "already visible on crates.io".to_string()
1462 } else if !package.publishable {
1463 "publish disabled in package metadata".to_string()
1464 } else if package.publish_excluded {
1465 "publish excluded by workspace release config".to_string()
1466 } else if !package.publish_missing_version_pins.is_empty() {
1467 format!(
1468 "missing version pins for {}",
1469 package.publish_missing_version_pins.join(", ")
1470 )
1471 } else {
1472 for dependency in &package.publish_internal_dependencies {
1473 if available.get(dependency).copied().unwrap_or(false) {
1474 continue;
1475 }
1476 blocked_by.push(dependency.clone());
1477 }
1478 if blocked_by.is_empty() {
1479 publish_needed = true;
1480 publish_order.push(package.name.clone());
1481 available.insert(package.name.clone(), true);
1482 "publish required".to_string()
1483 } else {
1484 format!("waiting for {}", blocked_by.join(", "))
1485 }
1486 };
1487
1488 items.push(PublishPlanItem {
1489 package: package.name.clone(),
1490 manifest: package.manifest_relative.clone(),
1491 version: package.version.to_string(),
1492 publishable: package.publishable
1493 && !package.publish_excluded
1494 && package.publish_missing_version_pins.is_empty(),
1495 crates_io_visible: visible,
1496 publish_needed,
1497 blocked_by,
1498 reason,
1499 });
1500 }
1501
1502 Ok(BuiltPublishPlan {
1503 requested_package: selected.requested_package,
1504 included_prereqs: selected.included_prereqs,
1505 required_closure: selected.required_closure,
1506 items,
1507 publish_order,
1508 })
1509}
1510
1511fn resolve_selected_packages(
1512 surface: &ReleaseSurface,
1513 ordered_packages: &[String],
1514 selection: Option<&PublishSelection>,
1515) -> Result<ResolvedPublishSelection, String> {
1516 let Some(selection) = selection else {
1517 return Ok(ResolvedPublishSelection {
1518 requested_package: None,
1519 selected_packages: ordered_packages.to_vec(),
1520 included_prereqs: Vec::new(),
1521 required_closure: ordered_packages.to_vec(),
1522 });
1523 };
1524 if let Some(only) = selection.only.as_deref() {
1525 if !ordered_packages.iter().any(|package| package == only) {
1526 return Err(format!("Unknown package `{}` for `--only`.", only));
1527 }
1528 let required_closure = collect_publish_closure(surface, only, ordered_packages)?;
1529 let selected_packages = if selection.include_prereqs {
1530 required_closure.clone()
1531 } else {
1532 vec![only.to_string()]
1533 };
1534 let included_prereqs = required_closure
1535 .iter()
1536 .filter(|package| package.as_str() != only)
1537 .cloned()
1538 .collect::<Vec<_>>();
1539 return Ok(ResolvedPublishSelection {
1540 requested_package: Some(only.to_string()),
1541 selected_packages,
1542 included_prereqs: if selection.include_prereqs {
1543 included_prereqs
1544 } else {
1545 Vec::new()
1546 },
1547 required_closure,
1548 });
1549 }
1550 if let Some(from) = selection.from.as_deref() {
1551 let start = ordered_packages
1552 .iter()
1553 .position(|package| package == from)
1554 .ok_or_else(|| format!("Unknown package `{}` for `--from`.", from))?;
1555 return Ok(ResolvedPublishSelection {
1556 requested_package: None,
1557 selected_packages: ordered_packages[start..].to_vec(),
1558 included_prereqs: Vec::new(),
1559 required_closure: ordered_packages[start..].to_vec(),
1560 });
1561 }
1562 Ok(ResolvedPublishSelection {
1563 requested_package: None,
1564 selected_packages: ordered_packages.to_vec(),
1565 included_prereqs: Vec::new(),
1566 required_closure: ordered_packages.to_vec(),
1567 })
1568}
1569
1570fn collect_publish_closure(
1571 surface: &ReleaseSurface,
1572 root_package: &str,
1573 ordered_packages: &[String],
1574) -> Result<Vec<String>, String> {
1575 let mut by_name = BTreeMap::new();
1576 for package in &surface.packages {
1577 by_name.insert(package.name.as_str(), package);
1578 }
1579 let mut visited = BTreeSet::new();
1580 collect_publish_closure_visit(root_package, &by_name, &mut visited)?;
1581 Ok(ordered_packages
1582 .iter()
1583 .filter(|package| visited.contains(package.as_str()))
1584 .cloned()
1585 .collect())
1586}
1587
1588fn collect_publish_closure_visit<'a>(
1589 package_name: &'a str,
1590 by_name: &BTreeMap<&'a str, &'a ReleasePackage>,
1591 visited: &mut BTreeSet<&'a str>,
1592) -> Result<(), String> {
1593 if !visited.insert(package_name) {
1594 return Ok(());
1595 }
1596 let package = by_name
1597 .get(package_name)
1598 .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1599 for dependency in &package.publish_internal_dependencies {
1600 collect_publish_closure_visit(dependency, by_name, visited)?;
1601 }
1602 Ok(())
1603}
1604
1605fn collect_publish_blockers(items: &[PublishPlanItem]) -> Vec<String> {
1606 items
1607 .iter()
1608 .filter(|item| {
1609 item.crates_io_visible != Some(true)
1610 && (!item.blocked_by.is_empty() || !item.publishable)
1611 })
1612 .map(|item| {
1613 if !item.blocked_by.is_empty() {
1614 format!("{} blocked by {}", item.package, item.blocked_by.join(", "))
1615 } else {
1616 format!("{} {}", item.package, item.reason)
1617 }
1618 })
1619 .collect()
1620}
1621
1622fn render_workspace_publish_blockers(
1623 surface: &ReleaseSurface,
1624 plan: &BuiltPublishPlan,
1625 run_options: Option<&WorkspacePublishRunOptions>,
1626 blockers: &[String],
1627) -> String {
1628 let mut message = String::new();
1629 message.push_str("Workspace publish is blocked.\n");
1630 message.push_str(&format!("Repo: {}\n", surface.repo_root.display()));
1631 if let Some(requested_package) = plan.requested_package.as_deref() {
1632 message.push_str(&format!("Requested package: {}\n", requested_package));
1633 }
1634 if !plan.included_prereqs.is_empty() {
1635 message.push_str(&format!(
1636 "Auto-included prerequisites: {}\n",
1637 plan.included_prereqs.join(", ")
1638 ));
1639 }
1640 if !plan.required_closure.is_empty() {
1641 message.push_str(&format!(
1642 "Required publish order: {}\n",
1643 plan.required_closure.join(" -> ")
1644 ));
1645 }
1646 message.push_str("Blockers:\n");
1647 for blocker in blockers {
1648 message.push_str(&format!("- {}\n", blocker));
1649 }
1650 if let (Some(requested_package), Some(run_options)) =
1651 (plan.requested_package.as_deref(), run_options)
1652 {
1653 if !run_options.include_prereqs {
1654 message.push_str(&format!(
1655 "Rerun with prerequisites: xbp version workspace publish run --repo {} --only {} --include-prereqs{}\n",
1656 quote_argument(&surface.repo_root),
1657 requested_package,
1658 if run_options.allow_dirty {
1659 " --allow-dirty"
1660 } else {
1661 ""
1662 }
1663 ));
1664 }
1665 }
1666 message.trim_end().to_string()
1667}
1668
1669fn topological_package_order(surface: &ReleaseSurface) -> Result<Vec<String>, String> {
1670 let package_names = surface
1671 .packages
1672 .iter()
1673 .map(|package| package.name.clone())
1674 .collect::<BTreeSet<_>>();
1675 let order_overrides = surface
1676 .config
1677 .publish
1678 .order
1679 .iter()
1680 .enumerate()
1681 .map(|(index, name)| (name.clone(), index))
1682 .collect::<BTreeMap<_, _>>();
1683 let mut indegree = BTreeMap::new();
1684 let mut reverse = BTreeMap::<String, Vec<String>>::new();
1685 for package in &surface.packages {
1686 let deps = package
1687 .publish_internal_dependencies
1688 .iter()
1689 .filter(|name| package_names.contains(*name))
1690 .cloned()
1691 .collect::<Vec<_>>();
1692 indegree.insert(package.name.clone(), deps.len());
1693 for dep in deps {
1694 reverse.entry(dep).or_default().push(package.name.clone());
1695 }
1696 }
1697
1698 let mut queue = indegree
1699 .iter()
1700 .filter_map(|(name, degree)| (*degree == 0).then_some(name.clone()))
1701 .collect::<Vec<_>>();
1702 sort_package_names(&mut queue, &order_overrides);
1703
1704 let mut ordered = Vec::new();
1705 while let Some(name) = queue.first().cloned() {
1706 queue.remove(0);
1707 ordered.push(name.clone());
1708 if let Some(children) = reverse.get(&name) {
1709 for child in children {
1710 if let Some(entry) = indegree.get_mut(child) {
1711 *entry -= 1;
1712 if *entry == 0 {
1713 queue.push(child.clone());
1714 }
1715 }
1716 }
1717 sort_package_names(&mut queue, &order_overrides);
1718 }
1719 }
1720
1721 if ordered.len() != surface.packages.len() {
1722 return Err(
1723 "Workspace package graph contains a publish-relevant dependency cycle.".to_string(),
1724 );
1725 }
1726 Ok(ordered)
1727}
1728
1729fn sort_package_names(names: &mut [String], overrides: &BTreeMap<String, usize>) {
1730 names.sort_by(|a, b| {
1731 overrides
1732 .get(a)
1733 .copied()
1734 .unwrap_or(usize::MAX)
1735 .cmp(&overrides.get(b).copied().unwrap_or(usize::MAX))
1736 .then(a.cmp(b))
1737 });
1738}
1739
1740async fn collect_crates_io_visibility(
1741 surface: &ReleaseSurface,
1742) -> Result<BTreeMap<String, bool>, String> {
1743 let client = crates_io_client()?;
1744 let mut visibility = BTreeMap::new();
1745 for package in &surface.packages {
1746 if !package.publishable || package.publish_excluded {
1747 visibility.insert(package.name.clone(), false);
1748 continue;
1749 }
1750 visibility.insert(
1751 package.name.clone(),
1752 crates_io_has_exact_version(&client, &package.name, &package.version.to_string())
1753 .await?,
1754 );
1755 }
1756 Ok(visibility)
1757}
1758
1759async fn crates_io_has_exact_version(
1760 client: &reqwest::Client,
1761 package: &str,
1762 version: &str,
1763) -> Result<bool, String> {
1764 let url = format!("https://crates.io/api/v1/crates/{}/{}", package, version);
1765 let response = client
1766 .get(url)
1767 .send()
1768 .await
1769 .map_err(|e| format!("Failed crates.io lookup for {} {}: {}", package, version, e))?;
1770 if response.status() == reqwest::StatusCode::NOT_FOUND {
1771 return Ok(false);
1772 }
1773 if !response.status().is_success() {
1774 return Err(format!(
1775 "crates.io lookup for {} {} returned status {}",
1776 package,
1777 version,
1778 response.status()
1779 ));
1780 }
1781 Ok(true)
1782}
1783
1784async fn wait_for_crates_io_visibility(
1785 package: &str,
1786 version: &str,
1787 timeout_seconds: f64,
1788 poll_interval_seconds: f64,
1789) -> Result<(), String> {
1790 let timeout = Duration::from_secs_f64(timeout_seconds.max(1.0));
1791 let poll = Duration::from_secs_f64(poll_interval_seconds.max(0.5));
1792 let deadline = Instant::now() + timeout;
1793 let client = crates_io_client()?;
1794 loop {
1795 if crates_io_has_exact_version(&client, package, version).await? {
1796 return Ok(());
1797 }
1798 if Instant::now() >= deadline {
1799 return Err(format!(
1800 "{} {} was published, but did not become visible on crates.io within {:.0}s",
1801 package, version, timeout_seconds
1802 ));
1803 }
1804 sleep(poll).await;
1805 }
1806}
1807
1808fn crates_io_client() -> Result<reqwest::Client, String> {
1809 reqwest::Client::builder()
1810 .user_agent(format!("xbp/{}", env!("CARGO_PKG_VERSION")))
1811 .build()
1812 .map_err(|e| format!("Failed to build crates.io HTTP client: {}", e))
1813}
1814
1815fn enforce_clean_worktree(project_root: &Path) -> Result<(), String> {
1816 let Some(GitWorktreeState { is_dirty, .. }) = git_worktree_state(project_root)? else {
1817 return Ok(());
1818 };
1819 if is_dirty {
1820 return Err(
1821 "Workspace repo has uncommitted changes. Re-run with `--allow-dirty` to override."
1822 .to_string(),
1823 );
1824 }
1825 Ok(())
1826}
1827
1828fn resolve_expected_version(
1829 surface: &ReleaseSurface,
1830 explicit: Option<&str>,
1831) -> Result<Version, String> {
1832 if let Some(explicit) = explicit {
1833 return parse_version(explicit);
1834 }
1835 let root_package_name = surface.root_package_name.as_ref().ok_or_else(|| {
1836 "Workspace root has no package.version; pass `--version` explicitly.".to_string()
1837 })?;
1838 surface
1839 .packages
1840 .iter()
1841 .find(|package| &package.name == root_package_name)
1842 .map(|package| package.version.clone())
1843 .ok_or_else(|| "Could not resolve the root package version.".to_string())
1844}
1845
1846fn load_cargo_metadata(repo_root: &Path) -> Result<CargoMetadata, String> {
1847 let mut command = Command::new("cargo");
1848 command
1849 .current_dir(repo_root)
1850 .args(["metadata", "--format-version", "1", "--no-deps"]);
1851 load_cargo_metadata_command(&mut command)
1852}
1853
1854fn load_cargo_metadata_for_manifest(manifest_path: &Path) -> Result<CargoMetadata, String> {
1855 let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
1856 let mut command = Command::new("cargo");
1857 command
1858 .current_dir(manifest_dir)
1859 .arg("metadata")
1860 .arg("--format-version")
1861 .arg("1")
1862 .arg("--no-deps")
1863 .arg("--manifest-path")
1864 .arg(manifest_path);
1865 load_cargo_metadata_command(&mut command)
1866}
1867
1868fn load_cargo_metadata_command(command: &mut Command) -> Result<CargoMetadata, String> {
1869 if !command_exists("cargo") {
1870 return Err("`cargo` is required to inspect workspace metadata.".to_string());
1871 }
1872 let output = command
1873 .output()
1874 .map_err(|e| format!("Failed to run `cargo metadata`: {}", e))?;
1875 if !output.status.success() {
1876 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1877 return Err(format!("`cargo metadata` failed: {}", stderr));
1878 }
1879 serde_json::from_slice::<CargoMetadata>(&output.stdout)
1880 .map_err(|e| format!("Failed to parse cargo metadata JSON: {}", e))
1881}
1882
1883fn load_workspace_release_config(path: Option<&Path>) -> Result<WorkspaceReleaseConfig, String> {
1884 let Some(path) = path else {
1885 return Ok(WorkspaceReleaseConfig::default());
1886 };
1887 let content = fs::read_to_string(path)
1888 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1889 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
1890}
1891
1892fn run_command_capture(
1893 mut command: Command,
1894 label: impl Into<String>,
1895) -> Result<ValidationCommandResult, String> {
1896 let label = label.into();
1897 let output = command
1898 .output()
1899 .map_err(|e| format!("Failed to run `{}`: {}", label, e))?;
1900 Ok(ValidationCommandResult {
1901 command: label,
1902 success: output.status.success(),
1903 exit_code: output.status.code(),
1904 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
1905 warning: None,
1906 })
1907}
1908
1909fn read_metadata_version(path: &Path) -> Result<Option<String>, String> {
1910 let file_name = path
1911 .file_name()
1912 .and_then(|value| value.to_str())
1913 .unwrap_or_default();
1914 match file_name {
1915 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1916 let content = fs::read_to_string(path)
1917 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1918 read_regex_version_from_content(&content, r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
1919 }
1920 _ => read_version_from_path(path),
1921 }
1922}
1923
1924fn write_metadata_version(path: &Path, version: &Version) -> Result<(), String> {
1925 let file_name = path
1926 .file_name()
1927 .and_then(|value| value.to_str())
1928 .unwrap_or_default();
1929 match file_name {
1930 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1931 let content = fs::read_to_string(path)
1932 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1933 let regex = regex::Regex::new(r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
1934 .map_err(|e| format!("Failed to build OpenAPI regex: {}", e))?;
1935 let updated = regex
1936 .replace(&content, format!(" version: {}", version))
1937 .to_string();
1938 fs::write(path, updated)
1939 .map_err(|e| format!("Failed to write {}: {}", path.display(), e))
1940 }
1941 _ => write_version_to_path(path, version).map(|_| ()),
1942 }
1943}
1944
1945fn normalize_relative(repo_root: &Path, path: &Path) -> String {
1946 path.strip_prefix(repo_root)
1947 .unwrap_or(path)
1948 .to_string_lossy()
1949 .replace('\\', "/")
1950}
1951
1952fn display_path(path: &Path) -> String {
1953 path.to_string_lossy().to_string()
1954}
1955
1956fn same_path(left: &Path, right: &Path) -> bool {
1957 fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf())
1958 == fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
1959}
1960
1961fn quote_argument(path: &Path) -> String {
1962 let value = path.to_string_lossy();
1963 if value.contains(' ') {
1964 format!("\"{}\"", value)
1965 } else {
1966 value.to_string()
1967 }
1968}
1969
1970fn print_check_report(surface: &ReleaseSurface, report: &WorkspaceCheckReport) {
1971 println!("Workspace version check");
1972 println!("Repo: {}", surface.repo_root.display());
1973 println!("Expected version: {}", report.expected_version);
1974 if let Some(config_path) = &surface.config_path {
1975 println!(
1976 "Config: {}",
1977 normalize_relative(&surface.repo_root, config_path)
1978 );
1979 }
1980 println!(
1981 "Status: {}",
1982 if report.aligned {
1983 "aligned"
1984 } else {
1985 "drift detected"
1986 }
1987 );
1988 for entry in &report.drift {
1989 println!(
1990 "{} {} actual={} expected={}",
1991 entry.path,
1992 entry.field,
1993 entry.actual.as_deref().unwrap_or("<missing>"),
1994 entry.expected
1995 );
1996 }
1997}
1998
1999fn print_sync_report(surface: &ReleaseSurface, report: &WorkspaceSyncReport) {
2000 println!(
2001 "Workspace version {}",
2002 if report.write { "sync" } else { "sync preview" }
2003 );
2004 println!("Repo: {}", surface.repo_root.display());
2005 println!("Expected version: {}", report.expected_version);
2006 if report.edits.is_empty() {
2007 println!("No changes needed.");
2008 return;
2009 }
2010 println!("Files: {}", report.files_changed.join(", "));
2011 for edit in &report.edits {
2012 println!(
2013 "{} {} {} -> {}",
2014 edit.path,
2015 edit.field,
2016 edit.before.as_deref().unwrap_or("<missing>"),
2017 edit.after
2018 );
2019 }
2020}
2021
2022fn print_validation_report(surface: &ReleaseSurface, report: &WorkspaceValidateReport) {
2023 println!("Workspace validation");
2024 println!("Repo: {}", surface.repo_root.display());
2025 println!("Status: {}", if report.ok { "ok" } else { "failed" });
2026 for issue in &report.issues {
2027 println!(
2028 "{} {} actual={} expected={}",
2029 issue.path,
2030 issue.field,
2031 issue.actual.as_deref().unwrap_or("<missing>"),
2032 issue.expected
2033 );
2034 }
2035 for command in &report.commands {
2036 let status = if command.success {
2037 if command.warning.is_some() {
2038 "ok (with warning)"
2039 } else {
2040 "ok"
2041 }
2042 } else {
2043 "failed"
2044 };
2045 println!("{} [{}]", command.command, status);
2046 if let Some(warning) = command.warning.as_deref() {
2047 println!("warning: {}", warning);
2048 }
2049 if !command.stderr.is_empty() {
2050 println!("{}", command.stderr);
2051 }
2052 }
2053}
2054
2055fn print_publish_plan_report(surface: &ReleaseSurface, report: &WorkspacePublishPlanReport) {
2056 println!("Workspace publish plan");
2057 println!("Repo: {}", surface.repo_root.display());
2058 if let Some(requested_package) = report.requested_package.as_deref() {
2059 println!("Requested package: {}", requested_package);
2060 }
2061 if !report.included_prereqs.is_empty() {
2062 println!(
2063 "Auto-included prerequisites: {}",
2064 report.included_prereqs.join(", ")
2065 );
2066 }
2067 if !report.required_closure.is_empty() {
2068 println!("Required closure: {}", report.required_closure.join(" -> "));
2069 }
2070 println!(
2071 "Publish order: {}",
2072 if report.publish_order.is_empty() {
2073 "<none>".to_string()
2074 } else {
2075 report.publish_order.join(", ")
2076 }
2077 );
2078 for item in &report.packages {
2079 println!(
2080 "{} {} visible={} needed={} reason={}",
2081 item.package,
2082 item.version,
2083 item.crates_io_visible
2084 .map(|value| value.to_string())
2085 .unwrap_or_else(|| "n/a".to_string()),
2086 item.publish_needed,
2087 item.reason
2088 );
2089 if !item.blocked_by.is_empty() {
2090 println!(" blocked by {}", item.blocked_by.join(", "));
2091 }
2092 }
2093}
2094
2095fn print_publish_run_report(surface: &ReleaseSurface, report: &WorkspacePublishRunReport) {
2096 println!("Workspace publish run");
2097 println!("Repo: {}", surface.repo_root.display());
2098 println!("Dry run: {}", report.dry_run);
2099 if let Some(requested_package) = report.requested_package.as_deref() {
2100 println!("Requested package: {}", requested_package);
2101 }
2102 if !report.included_prereqs.is_empty() {
2103 println!(
2104 "Auto-included prerequisites: {}",
2105 report.included_prereqs.join(", ")
2106 );
2107 }
2108 if !report.required_closure.is_empty() {
2109 println!("Required closure: {}", report.required_closure.join(" -> "));
2110 }
2111 if !report.published.is_empty() {
2112 println!("Published: {}", report.published.join(", "));
2113 }
2114 if !report.skipped.is_empty() {
2115 println!("Skipped: {}", report.skipped.join("; "));
2116 }
2117 if !report.failed.is_empty() {
2118 println!("Failed: {}", report.failed.join("; "));
2119 }
2120}
2121
2122#[cfg(test)]
2123mod tests {
2124 use super::{
2125 apply_sync, build_publish_plan, collect_drift, discover_release_surface, PublishSelection,
2126 };
2127 use semver::Version;
2128 use std::collections::BTreeMap;
2129 use std::fs;
2130 use std::path::PathBuf;
2131 use std::time::{SystemTime, UNIX_EPOCH};
2132
2133 fn temp_dir(name: &str) -> PathBuf {
2134 let nanos = SystemTime::now()
2135 .duration_since(UNIX_EPOCH)
2136 .expect("time")
2137 .as_nanos();
2138 let dir = std::env::temp_dir().join(format!("xbp-workspace-release-{}-{}", name, nanos));
2139 fs::create_dir_all(&dir).expect("create temp dir");
2140 dir
2141 }
2142
2143 fn write_file(path: &PathBuf, content: &str) {
2144 if let Some(parent) = path.parent() {
2145 fs::create_dir_all(parent).expect("create parent");
2146 }
2147 fs::write(path, content).expect("write file");
2148 }
2149
2150 fn create_demo_workspace() -> PathBuf {
2151 let root = temp_dir("demo");
2152 write_file(
2153 &root.join("Cargo.toml"),
2154 r#"[package]
2155name = "athena_rs"
2156version = "3.16.4"
2157
2158[dependencies.alpha]
2159path = "crates/alpha"
2160version = "3.16.4"
2161
2162[dependencies.beta]
2163path = "crates/beta"
2164version = "3.16.4"
2165
2166[dependencies.athena-s3]
2167path = "crates/athena-s3"
2168version = "3.16.4"
2169
2170[workspace]
2171members = ["crates/alpha", "crates/beta", "crates/athena-s3"]
2172resolver = "2"
2173"#,
2174 );
2175 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
2176 write_file(
2177 &root.join("README.md"),
2178 "# Athena\n\ncurrent version: `3.16.4`\n",
2179 );
2180 write_file(
2181 &root.join("openapi.yaml"),
2182 "openapi: 3.1.0\ninfo:\n title: Athena\n version: 3.16.4\n",
2183 );
2184 write_file(
2185 &root.join("Cargo.lock"),
2186 r#"version = 4
2187
2188[[package]]
2189name = "athena_rs"
2190version = "3.16.4"
2191
2192[[package]]
2193name = "alpha"
2194version = "3.16.4"
2195
2196[[package]]
2197name = "beta"
2198version = "3.16.4"
2199
2200[[package]]
2201name = "athena-s3"
2202version = "3.16.4"
2203"#,
2204 );
2205 write_file(
2206 &root.join("crates/alpha/Cargo.toml"),
2207 r#"[package]
2208name = "alpha"
2209version = "3.16.4"
2210
2211[dependencies]
2212beta = { path = "../beta", version = "3.16.4" }
2213athena-s3 = { path = "../athena-s3", version = "3.16.4" }
2214"#,
2215 );
2216 write_file(&root.join("crates/alpha/src/lib.rs"), "pub fn alpha() {}\n");
2217 write_file(
2218 &root.join("crates/beta/Cargo.toml"),
2219 r#"[package]
2220name = "beta"
2221version = "3.16.4"
2222"#,
2223 );
2224 write_file(&root.join("crates/beta/src/lib.rs"), "pub fn beta() {}\n");
2225 write_file(
2226 &root.join("crates/athena-s3/Cargo.toml"),
2227 r#"[package]
2228name = "athena-s3"
2229version = "3.16.4"
2230"#,
2231 );
2232 write_file(
2233 &root.join("crates/athena-s3/src/lib.rs"),
2234 "pub fn athena_s3() {}\n",
2235 );
2236 write_file(
2237 &root.join("crates/athena-backups/Cargo.toml"),
2238 r#"[package]
2239name = "athena-backups"
2240version = "3.16.0"
2241
2242[dependencies]
2243beta = { path = "../beta", version = "3.16.0" }
2244"#,
2245 );
2246 write_file(
2247 &root.join("crates/athena-backups/src/lib.rs"),
2248 "pub fn athena_backups() {}\n",
2249 );
2250 root
2251 }
2252
2253 fn create_workspace_dependency_demo_workspace() -> PathBuf {
2254 let root = temp_dir("workspace-deps");
2255 write_file(
2256 &root.join("Cargo.toml"),
2257 r#"[package]
2258name = "xbp"
2259version = "10.27.0"
2260
2261[dependencies]
2262xbp-providers.workspace = true
2263
2264[workspace]
2265members = ["crates/http", "crates/providers"]
2266resolver = "2"
2267
2268[workspace.dependencies]
2269xbp-http = { path = "crates/http", version = "0.1.0" }
2270xbp-providers = { path = "crates/providers", version = "0.1.0" }
2271"#,
2272 );
2273 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
2274 write_file(
2275 &root.join("crates/http/Cargo.toml"),
2276 r#"[package]
2277name = "xbp-http"
2278version = "0.1.0"
2279"#,
2280 );
2281 write_file(&root.join("crates/http/src/lib.rs"), "pub fn http() {}\n");
2282 write_file(
2283 &root.join("crates/providers/Cargo.toml"),
2284 r#"[package]
2285name = "xbp-providers"
2286version = "0.1.0"
2287
2288[dependencies]
2289xbp-http.workspace = true
2290"#,
2291 );
2292 write_file(
2293 &root.join("crates/providers/src/lib.rs"),
2294 "pub fn providers() {}\n",
2295 );
2296 root
2297 }
2298
2299 #[test]
2300 fn discovery_uses_workspace_members_and_excludes_non_member_crates() {
2301 let root = create_demo_workspace();
2302 let surface = discover_release_surface(&root).expect("discover");
2303 let names = surface
2304 .packages
2305 .iter()
2306 .map(|package| package.name.clone())
2307 .collect::<Vec<_>>();
2308 assert!(names.contains(&"athena-s3".to_string()));
2309 assert!(!names.contains(&"athena-backups".to_string()));
2310 }
2311
2312 #[test]
2313 fn drift_reports_package_dependency_metadata_and_lock_mismatches() {
2314 let root = create_demo_workspace();
2315 write_file(
2316 &root.join("crates/alpha/Cargo.toml"),
2317 r#"[package]
2318name = "alpha"
2319version = "3.16.5"
2320
2321[dependencies]
2322beta = { path = "../beta", version = "3.16.4" }
2323athena-s3 = { path = "../athena-s3" }
2324"#,
2325 );
2326 let surface = discover_release_surface(&root).expect("discover");
2327 let expected = Version::new(3, 16, 4);
2328 let drift = collect_drift(&surface, &expected).expect("drift");
2329 assert!(drift.iter().any(
2330 |entry| entry.path == "crates/alpha/Cargo.toml" && entry.field == "package.version"
2331 ));
2332 assert!(
2333 drift
2334 .iter()
2335 .any(|entry| entry.field == "dependencies.athena-s3.version"
2336 && entry.actual.is_none())
2337 );
2338 }
2339
2340 #[test]
2341 fn sync_preview_and_write_updates_workspace_surface() {
2342 let root = create_demo_workspace();
2343 let surface = discover_release_surface(&root).expect("discover");
2344 let expected = Version::new(3, 16, 5);
2345 let (preview, _) = apply_sync(&surface, &expected, false).expect("preview");
2346 assert!(!preview.is_empty());
2347
2348 let (written, files) = apply_sync(&surface, &expected, true).expect("write");
2349 assert!(!written.is_empty());
2350 assert!(files.contains(&"Cargo.toml".to_string()));
2351 let updated = fs::read_to_string(root.join("crates/alpha/Cargo.toml")).expect("read");
2352 assert!(updated.contains("version = \"3.16.5\""));
2353 assert!(updated.contains("athena-s3 = { path = \"../athena-s3\", version = \"3.16.5\" }"));
2354 }
2355
2356 #[test]
2357 fn publish_plan_orders_dependencies_before_dependents() {
2358 let root = create_demo_workspace();
2359 let surface = discover_release_surface(&root).expect("discover");
2360 let mut visibility = BTreeMap::new();
2361 visibility.insert("athena_rs".to_string(), false);
2362 visibility.insert("alpha".to_string(), false);
2363 visibility.insert("beta".to_string(), true);
2364 visibility.insert("athena-s3".to_string(), false);
2365 let plan = build_publish_plan(
2366 &surface,
2367 &visibility,
2368 Some(&PublishSelection {
2369 from: None,
2370 only: None,
2371 include_prereqs: false,
2372 }),
2373 )
2374 .expect("plan");
2375 let alpha_pos = plan
2376 .publish_order
2377 .iter()
2378 .position(|name| name == "alpha")
2379 .expect("alpha");
2380 let s3_pos = plan
2381 .publish_order
2382 .iter()
2383 .position(|name| name == "athena-s3")
2384 .expect("s3");
2385 assert!(s3_pos < alpha_pos);
2386 assert!(plan
2387 .items
2388 .iter()
2389 .any(|item| item.package == "athena_rs" && item.publish_needed));
2390 }
2391
2392 #[test]
2393 fn publish_plan_orders_workspace_dependencies_before_dependents() {
2394 let root = create_workspace_dependency_demo_workspace();
2395 let surface = discover_release_surface(&root).expect("discover");
2396 let mut visibility = BTreeMap::new();
2397 visibility.insert("xbp".to_string(), false);
2398 visibility.insert("xbp-http".to_string(), false);
2399 visibility.insert("xbp-providers".to_string(), false);
2400
2401 let plan = build_publish_plan(
2402 &surface,
2403 &visibility,
2404 Some(&PublishSelection {
2405 from: None,
2406 only: None,
2407 include_prereqs: false,
2408 }),
2409 )
2410 .expect("plan");
2411
2412 let xbp_pos = plan
2413 .publish_order
2414 .iter()
2415 .position(|name| name == "xbp")
2416 .expect("xbp");
2417 let providers_pos = plan
2418 .publish_order
2419 .iter()
2420 .position(|name| name == "xbp-providers")
2421 .expect("providers");
2422 let http_pos = plan
2423 .publish_order
2424 .iter()
2425 .position(|name| name == "xbp-http")
2426 .expect("http");
2427
2428 assert!(http_pos < providers_pos);
2429 assert!(providers_pos < xbp_pos);
2430 }
2431
2432 #[test]
2433 fn publish_plan_only_with_prereqs_limits_to_minimal_closure() {
2434 let root = create_demo_workspace();
2435 let surface = discover_release_surface(&root).expect("discover");
2436 let mut visibility = BTreeMap::new();
2437 visibility.insert("athena_rs".to_string(), false);
2438 visibility.insert("alpha".to_string(), false);
2439 visibility.insert("beta".to_string(), false);
2440 visibility.insert("athena-s3".to_string(), false);
2441
2442 let plan = build_publish_plan(
2443 &surface,
2444 &visibility,
2445 Some(&PublishSelection {
2446 from: None,
2447 only: Some("alpha".to_string()),
2448 include_prereqs: true,
2449 }),
2450 )
2451 .expect("plan");
2452
2453 let package_names = plan
2454 .items
2455 .iter()
2456 .map(|item| item.package.as_str())
2457 .collect::<Vec<_>>();
2458 assert_eq!(package_names.len(), 3);
2459 assert!(!package_names.contains(&"athena_rs"));
2460 assert_eq!(plan.required_closure.len(), 3);
2461 assert!(plan.required_closure.contains(&"alpha".to_string()));
2462 assert!(plan.required_closure.contains(&"beta".to_string()));
2463 assert!(plan.required_closure.contains(&"athena-s3".to_string()));
2464 assert_eq!(plan.included_prereqs.len(), 2);
2465 assert!(plan.included_prereqs.contains(&"beta".to_string()));
2466 assert!(plan.included_prereqs.contains(&"athena-s3".to_string()));
2467 assert_eq!(plan.publish_order.len(), 3);
2468
2469 let alpha_pos = plan
2470 .publish_order
2471 .iter()
2472 .position(|package| package == "alpha")
2473 .expect("alpha in publish order");
2474 let beta_pos = plan
2475 .publish_order
2476 .iter()
2477 .position(|package| package == "beta")
2478 .expect("beta in publish order");
2479 let s3_pos = plan
2480 .publish_order
2481 .iter()
2482 .position(|package| package == "athena-s3")
2483 .expect("athena-s3 in publish order");
2484 assert!(beta_pos < alpha_pos);
2485 assert!(s3_pos < alpha_pos);
2486 }
2487
2488 #[test]
2489 fn publish_plan_only_without_prereqs_reports_blocked_package() {
2490 let root = create_demo_workspace();
2491 let surface = discover_release_surface(&root).expect("discover");
2492 let mut visibility = BTreeMap::new();
2493 visibility.insert("athena_rs".to_string(), false);
2494 visibility.insert("alpha".to_string(), false);
2495 visibility.insert("beta".to_string(), false);
2496 visibility.insert("athena-s3".to_string(), false);
2497
2498 let plan = build_publish_plan(
2499 &surface,
2500 &visibility,
2501 Some(&PublishSelection {
2502 from: None,
2503 only: Some("alpha".to_string()),
2504 include_prereqs: false,
2505 }),
2506 )
2507 .expect("plan");
2508
2509 assert_eq!(plan.required_closure.len(), 3);
2510 assert!(plan.required_closure.contains(&"alpha".to_string()));
2511 assert!(plan.required_closure.contains(&"beta".to_string()));
2512 assert!(plan.required_closure.contains(&"athena-s3".to_string()));
2513 assert!(plan.included_prereqs.is_empty());
2514 assert!(plan.publish_order.is_empty());
2515 assert_eq!(plan.items.len(), 1);
2516 assert_eq!(plan.items[0].package, "alpha");
2517 assert_eq!(plan.items[0].blocked_by.len(), 2);
2518 assert!(plan.items[0].blocked_by.contains(&"beta".to_string()));
2519 assert!(plan.items[0].blocked_by.contains(&"athena-s3".to_string()));
2520 }
2521
2522 #[test]
2523 fn publish_plan_ignores_dev_dependencies_for_publish_blockers() {
2524 let root = temp_dir("publish-dev-deps");
2525 write_file(
2526 &root.join("Cargo.toml"),
2527 r#"[package]
2528name = "demo-root"
2529version = "1.0.0"
2530
2531[workspace]
2532members = ["crates/app", "crates/dev-helper"]
2533resolver = "2"
2534"#,
2535 );
2536 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
2537 write_file(
2538 &root.join("crates/app/Cargo.toml"),
2539 r#"[package]
2540name = "demo-app"
2541version = "1.0.0"
2542
2543[dev-dependencies]
2544dev-helper = { path = "../dev-helper", version = "1.0.0" }
2545"#,
2546 );
2547 write_file(&root.join("crates/app/src/lib.rs"), "pub fn app() {}\n");
2548 write_file(
2549 &root.join("crates/dev-helper/Cargo.toml"),
2550 r#"[package]
2551name = "dev-helper"
2552version = "1.0.0"
2553"#,
2554 );
2555 write_file(
2556 &root.join("crates/dev-helper/src/lib.rs"),
2557 "pub fn helper() {}\n",
2558 );
2559
2560 let surface = discover_release_surface(&root).expect("discover");
2561 let mut visibility = BTreeMap::new();
2562 visibility.insert("demo-root".to_string(), false);
2563 visibility.insert("demo-app".to_string(), false);
2564 visibility.insert("dev-helper".to_string(), false);
2565
2566 let plan = build_publish_plan(
2567 &surface,
2568 &visibility,
2569 Some(&PublishSelection {
2570 from: None,
2571 only: Some("demo-app".to_string()),
2572 include_prereqs: false,
2573 }),
2574 )
2575 .expect("plan");
2576
2577 assert_eq!(plan.required_closure, vec!["demo-app"]);
2578 assert_eq!(plan.publish_order, vec!["demo-app"]);
2579 assert!(plan.items[0].blocked_by.is_empty());
2580 }
2581}