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 semver::Version;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use tokio::time::{sleep, Duration, Instant};
13use toml::Value as TomlValue;
14use toml_edit::{value, DocumentMut, Item, Table, Value};
15
16const DEFAULT_METADATA_FILES: &[&str] = &[
17 "README.md",
18 "openapi.yaml",
19 "openapi.yml",
20 "openapi.json",
21 "swagger.yaml",
22 "swagger.yml",
23 "swagger.json",
24];
25const CONFIG_CANDIDATES: &[&str] = &[".xbp/workspace-release.yaml", ".xbp/workspace-release.yml"];
26
27#[derive(Debug, Clone)]
28pub struct WorkspaceVersionCommandOptions {
29 pub repo: Option<PathBuf>,
30 pub json: bool,
31 pub command: WorkspaceVersionCommand,
32}
33
34#[derive(Debug, Clone)]
35pub enum WorkspaceVersionCommand {
36 Check(WorkspaceVersionCheckOptions),
37 Sync(WorkspaceVersionSyncOptions),
38 Validate(WorkspaceVersionValidateOptions),
39 PublishPlan,
40 PublishRun(WorkspacePublishRunOptions),
41}
42
43#[derive(Debug, Clone)]
44pub struct WorkspaceVersionCheckOptions {
45 pub version: Option<String>,
46}
47
48#[derive(Debug, Clone)]
49pub struct WorkspaceVersionSyncOptions {
50 pub version: Option<String>,
51 pub write: bool,
52}
53
54#[derive(Debug, Clone)]
55pub struct WorkspaceVersionValidateOptions {
56 pub package: Option<String>,
57 pub cargo_check: bool,
58 pub package_dry_run: bool,
59}
60
61#[derive(Debug, Clone)]
62pub struct WorkspacePublishRunOptions {
63 pub dry_run: bool,
64 pub from: Option<String>,
65 pub only: Option<String>,
66 pub continue_on_error: bool,
67 pub allow_dirty: bool,
68 pub timeout_seconds: f64,
69 pub poll_interval_seconds: f64,
70}
71
72#[derive(Debug, Default, Clone, Deserialize)]
73struct WorkspaceReleaseConfig {
74 #[serde(default)]
75 version_coupled_manifests: Vec<String>,
76 #[serde(default)]
77 metadata_files: Vec<String>,
78 #[serde(default)]
79 publish: WorkspaceReleasePublishConfig,
80}
81
82#[derive(Debug, Default, Clone, Deserialize)]
83struct WorkspaceReleasePublishConfig {
84 #[serde(default)]
85 exclude: Vec<String>,
86 #[serde(default)]
87 order: Vec<String>,
88}
89
90#[derive(Debug, Clone)]
91struct ReleaseSurface {
92 repo_root: PathBuf,
93 config_path: Option<PathBuf>,
94 config: WorkspaceReleaseConfig,
95 packages: Vec<ReleasePackage>,
96 metadata_files: Vec<MetadataVersionFile>,
97 cargo_lock: Option<PathBuf>,
98 root_package_name: Option<String>,
99}
100
101#[derive(Debug, Clone)]
102struct ReleasePackage {
103 name: String,
104 manifest_path: PathBuf,
105 manifest_relative: String,
106 version: Version,
107 publishable: bool,
108 publish_excluded: bool,
109 dependency_pins: Vec<LocalDependencyPin>,
110 internal_dependencies: Vec<String>,
111}
112
113#[derive(Debug, Clone)]
114struct LocalDependencyPin {
115 field: String,
116 version: Option<String>,
117}
118
119#[derive(Debug, Clone)]
120struct MetadataVersionFile {
121 path: PathBuf,
122 relative: String,
123}
124
125#[derive(Debug, Clone, Serialize)]
126struct DriftEntry {
127 path: String,
128 field: String,
129 actual: Option<String>,
130 expected: String,
131}
132
133#[derive(Debug, Clone, Serialize)]
134struct SyncEdit {
135 path: String,
136 field: String,
137 before: Option<String>,
138 after: String,
139}
140
141#[derive(Debug, Clone, Serialize)]
142struct PublishPlanItem {
143 package: String,
144 manifest: String,
145 version: String,
146 publishable: bool,
147 crates_io_visible: Option<bool>,
148 publish_needed: bool,
149 blocked_by: Vec<String>,
150 reason: String,
151}
152
153#[derive(Debug, Clone, Serialize)]
154struct ValidationCommandResult {
155 command: String,
156 success: bool,
157 exit_code: Option<i32>,
158 stderr: String,
159}
160
161#[derive(Debug, Clone, Serialize)]
162struct WorkspaceCheckReport {
163 repo_root: String,
164 expected_version: String,
165 aligned: bool,
166 drift: Vec<DriftEntry>,
167}
168
169#[derive(Debug, Clone, Serialize)]
170struct WorkspaceSyncReport {
171 repo_root: String,
172 expected_version: String,
173 write: bool,
174 changed: bool,
175 files_changed: Vec<String>,
176 edits: Vec<SyncEdit>,
177}
178
179#[derive(Debug, Clone, Serialize)]
180struct WorkspacePublishPlanReport {
181 repo_root: String,
182 packages: Vec<PublishPlanItem>,
183 publish_order: Vec<String>,
184}
185
186#[derive(Debug, Clone, Serialize)]
187struct WorkspaceValidateReport {
188 repo_root: String,
189 ok: bool,
190 issues: Vec<DriftEntry>,
191 commands: Vec<ValidationCommandResult>,
192}
193
194#[derive(Debug, Clone, Serialize)]
195struct WorkspacePublishRunReport {
196 repo_root: String,
197 dry_run: bool,
198 published: Vec<String>,
199 skipped: Vec<String>,
200 failed: Vec<String>,
201}
202
203#[derive(Debug, Deserialize)]
204struct CargoMetadata {
205 packages: Vec<CargoMetadataPackage>,
206 workspace_members: Vec<String>,
207 workspace_root: String,
208}
209
210#[derive(Debug, Deserialize)]
211struct CargoMetadataPackage {
212 id: String,
213 manifest_path: String,
214}
215
216pub async fn run_version_workspace_command(
217 options: WorkspaceVersionCommandOptions,
218) -> Result<(), String> {
219 let repo_root = match options.repo {
220 Some(path) => path,
221 None => resolve_project_root(),
222 };
223 let surface = discover_release_surface(&repo_root)?;
224
225 match options.command {
226 WorkspaceVersionCommand::Check(check) => {
227 let expected = resolve_expected_version(&surface, check.version.as_deref())?;
228 let drift = collect_drift(&surface, &expected)?;
229 let report = WorkspaceCheckReport {
230 repo_root: display_path(&surface.repo_root),
231 expected_version: expected.to_string(),
232 aligned: drift.is_empty(),
233 drift,
234 };
235 if options.json {
236 println!(
237 "{}",
238 serde_json::to_string_pretty(&report)
239 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
240 );
241 } else {
242 print_check_report(&surface, &report);
243 }
244 if report.aligned {
245 Ok(())
246 } else {
247 Err("Workspace release drift detected.".to_string())
248 }
249 }
250 WorkspaceVersionCommand::Sync(sync) => {
251 let expected = resolve_expected_version(&surface, sync.version.as_deref())?;
252 let (edits, changed_files) = apply_sync(&surface, &expected, sync.write)?;
253 let report = WorkspaceSyncReport {
254 repo_root: display_path(&surface.repo_root),
255 expected_version: expected.to_string(),
256 write: sync.write,
257 changed: !edits.is_empty(),
258 files_changed: changed_files,
259 edits,
260 };
261 if options.json {
262 println!(
263 "{}",
264 serde_json::to_string_pretty(&report)
265 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
266 );
267 } else {
268 print_sync_report(&surface, &report);
269 }
270 Ok(())
271 }
272 WorkspaceVersionCommand::Validate(validate) => {
273 let report = run_validation(&surface, &validate)?;
274 if options.json {
275 println!(
276 "{}",
277 serde_json::to_string_pretty(&report)
278 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
279 );
280 } else {
281 print_validation_report(&surface, &report);
282 }
283 if report.ok {
284 Ok(())
285 } else {
286 Err("Workspace validation failed.".to_string())
287 }
288 }
289 WorkspaceVersionCommand::PublishPlan => {
290 let report = build_publish_plan_report(&surface).await?;
291 if options.json {
292 println!(
293 "{}",
294 serde_json::to_string_pretty(&report)
295 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
296 );
297 } else {
298 print_publish_plan_report(&surface, &report);
299 }
300 Ok(())
301 }
302 WorkspaceVersionCommand::PublishRun(run) => {
303 let report = run_publish(&surface, &run).await?;
304 if options.json {
305 println!(
306 "{}",
307 serde_json::to_string_pretty(&report)
308 .map_err(|e| format!("Failed to serialize JSON report: {}", e))?
309 );
310 } else {
311 print_publish_run_report(&surface, &report);
312 }
313 if report.failed.is_empty() {
314 Ok(())
315 } else {
316 Err("Workspace publish run failed.".to_string())
317 }
318 }
319 }
320}
321
322fn discover_release_surface(repo_root: &Path) -> Result<ReleaseSurface, String> {
323 let metadata = load_cargo_metadata(repo_root)?;
324 let repo_root = PathBuf::from(&metadata.workspace_root);
325 let config_path = CONFIG_CANDIDATES
326 .iter()
327 .map(|candidate| repo_root.join(candidate))
328 .find(|path| path.exists());
329 let config = load_workspace_release_config(config_path.as_deref())?;
330
331 let workspace_member_ids: BTreeSet<String> = metadata.workspace_members.into_iter().collect();
332 let mut candidate_manifests = Vec::new();
333 for package in metadata.packages {
334 if !workspace_member_ids.contains(&package.id) {
335 continue;
336 }
337 candidate_manifests.push(PathBuf::from(package.manifest_path));
338 }
339 for relative in &config.version_coupled_manifests {
340 let manifest = repo_root.join(relative);
341 if !candidate_manifests
342 .iter()
343 .any(|existing| existing == &manifest)
344 {
345 candidate_manifests.push(manifest);
346 }
347 }
348
349 let mut basic_packages = Vec::new();
350 for manifest_path in candidate_manifests {
351 basic_packages.push(read_basic_manifest_info(&manifest_path, &config)?);
352 }
353 basic_packages.sort_by(|a, b| a.name.cmp(&b.name));
354
355 let release_names: BTreeSet<String> = basic_packages
356 .iter()
357 .map(|package| package.name.clone())
358 .collect();
359 let mut packages = Vec::new();
360 for basic in basic_packages {
361 packages.push(read_release_package(&repo_root, basic, &release_names)?);
362 }
363
364 let root_manifest = repo_root.join("Cargo.toml");
365 let root_package_name = packages
366 .iter()
367 .find(|package| package.manifest_path == root_manifest)
368 .map(|package| package.name.clone());
369
370 let mut seen = BTreeSet::new();
371 let mut metadata_files = Vec::new();
372 for relative in DEFAULT_METADATA_FILES
373 .iter()
374 .copied()
375 .chain(config.metadata_files.iter().map(String::as_str))
376 {
377 if !seen.insert(relative.to_string()) {
378 continue;
379 }
380 let path = repo_root.join(relative);
381 if !path.exists() {
382 continue;
383 }
384 if read_metadata_version(&path)?.is_some() {
385 metadata_files.push(MetadataVersionFile {
386 relative: normalize_relative(&repo_root, &path),
387 path,
388 });
389 }
390 }
391
392 let cargo_lock = repo_root.join("Cargo.lock");
393 Ok(ReleaseSurface {
394 repo_root,
395 config_path,
396 config,
397 packages,
398 metadata_files,
399 cargo_lock: cargo_lock.exists().then_some(cargo_lock),
400 root_package_name,
401 })
402}
403
404#[derive(Debug, Clone)]
405struct BasicManifestInfo {
406 name: String,
407 manifest_path: PathBuf,
408 publishable: bool,
409 publish_excluded: bool,
410}
411
412fn read_basic_manifest_info(
413 manifest_path: &Path,
414 config: &WorkspaceReleaseConfig,
415) -> Result<BasicManifestInfo, String> {
416 let content = fs::read_to_string(manifest_path)
417 .map_err(|e| format!("Failed to read {}: {}", manifest_path.display(), e))?;
418 let value: TomlValue =
419 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
420 let package = value
421 .get("package")
422 .and_then(TomlValue::as_table)
423 .ok_or_else(|| format!("Expected [package] in {}", manifest_path.display()))?;
424 let name = package
425 .get("name")
426 .and_then(TomlValue::as_str)
427 .ok_or_else(|| format!("Missing package.name in {}", manifest_path.display()))?
428 .to_string();
429 let publishable = match package.get("publish") {
430 Some(TomlValue::Boolean(false)) => false,
431 Some(TomlValue::Array(values)) if values.is_empty() => false,
432 _ => true,
433 };
434 Ok(BasicManifestInfo {
435 name: name.clone(),
436 manifest_path: manifest_path.to_path_buf(),
437 publishable,
438 publish_excluded: config.publish.exclude.iter().any(|value| value == &name),
439 })
440}
441
442fn read_release_package(
443 repo_root: &Path,
444 basic: BasicManifestInfo,
445 release_names: &BTreeSet<String>,
446) -> Result<ReleasePackage, String> {
447 let content = fs::read_to_string(&basic.manifest_path)
448 .map_err(|e| format!("Failed to read {}: {}", basic.manifest_path.display(), e))?;
449 let value: TomlValue =
450 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
451 let package = value
452 .get("package")
453 .and_then(TomlValue::as_table)
454 .ok_or_else(|| format!("Expected [package] in {}", basic.manifest_path.display()))?;
455 let version = package
456 .get("version")
457 .and_then(TomlValue::as_str)
458 .ok_or_else(|| {
459 format!(
460 "Missing package.version in {}",
461 basic.manifest_path.display()
462 )
463 })
464 .and_then(parse_version)?;
465 let (dependency_pins, internal_dependencies) =
466 analyze_local_dependencies_from_toml(&value, release_names);
467
468 Ok(ReleasePackage {
469 name: basic.name,
470 manifest_path: basic.manifest_path.clone(),
471 manifest_relative: normalize_relative(repo_root, &basic.manifest_path),
472 version,
473 publishable: basic.publishable,
474 publish_excluded: basic.publish_excluded,
475 dependency_pins,
476 internal_dependencies,
477 })
478}
479
480fn analyze_local_dependencies_from_toml(
481 value: &TomlValue,
482 release_names: &BTreeSet<String>,
483) -> (Vec<LocalDependencyPin>, Vec<String>) {
484 let mut pins = Vec::new();
485 let mut internal_dependencies = BTreeSet::new();
486 collect_dependency_pins_from_table(
487 value,
488 "",
489 release_names,
490 &mut pins,
491 &mut internal_dependencies,
492 );
493 pins.sort_by(|a, b| a.field.cmp(&b.field));
494 (pins, internal_dependencies.into_iter().collect())
495}
496
497fn collect_dependency_pins_from_table(
498 value: &TomlValue,
499 prefix: &str,
500 release_names: &BTreeSet<String>,
501 pins: &mut Vec<LocalDependencyPin>,
502 internal_dependencies: &mut BTreeSet<String>,
503) {
504 let Some(table) = value.as_table() else {
505 return;
506 };
507
508 for (key, entry) in table {
509 let path = if prefix.is_empty() {
510 key.to_string()
511 } else {
512 format!("{}.{}", prefix, key)
513 };
514 if matches!(
515 key.as_str(),
516 "dependencies" | "dev-dependencies" | "build-dependencies"
517 ) {
518 collect_dependency_section(
519 path.as_str(),
520 entry,
521 release_names,
522 pins,
523 internal_dependencies,
524 );
525 continue;
526 }
527 collect_dependency_pins_from_table(
528 entry,
529 &path,
530 release_names,
531 pins,
532 internal_dependencies,
533 );
534 }
535}
536
537fn collect_dependency_section(
538 section_name: &str,
539 value: &TomlValue,
540 release_names: &BTreeSet<String>,
541 pins: &mut Vec<LocalDependencyPin>,
542 internal_dependencies: &mut BTreeSet<String>,
543) {
544 let Some(table) = value.as_table() else {
545 return;
546 };
547 for (dependency_name, dependency_value) in table {
548 if !release_names.contains(dependency_name) {
549 continue;
550 }
551 let Some(detail) = dependency_value.as_table() else {
552 continue;
553 };
554 let uses_workspace = detail
555 .get("workspace")
556 .and_then(TomlValue::as_bool)
557 .unwrap_or(false);
558 let uses_path = detail.contains_key("path");
559 if !uses_path && !uses_workspace {
560 continue;
561 }
562 internal_dependencies.insert(dependency_name.clone());
563 if !uses_path {
564 continue;
565 }
566 pins.push(LocalDependencyPin {
567 field: format!("{}.{}.version", section_name, dependency_name),
568 version: detail
569 .get("version")
570 .and_then(TomlValue::as_str)
571 .map(|value| value.to_string()),
572 });
573 }
574}
575
576fn collect_drift(surface: &ReleaseSurface, expected: &Version) -> Result<Vec<DriftEntry>, String> {
577 let expected_text = expected.to_string();
578 let mut drift = Vec::new();
579
580 for package in &surface.packages {
581 if package.version != *expected {
582 drift.push(DriftEntry {
583 path: package.manifest_relative.clone(),
584 field: "package.version".to_string(),
585 actual: Some(package.version.to_string()),
586 expected: expected_text.clone(),
587 });
588 }
589 for pin in &package.dependency_pins {
590 if pin.version.as_deref() != Some(expected_text.as_str()) {
591 drift.push(DriftEntry {
592 path: package.manifest_relative.clone(),
593 field: pin.field.clone(),
594 actual: pin.version.clone(),
595 expected: expected_text.clone(),
596 });
597 }
598 }
599 }
600
601 for metadata in &surface.metadata_files {
602 let actual = read_metadata_version(&metadata.path)?;
603 if actual.as_deref() != Some(expected_text.as_str()) {
604 drift.push(DriftEntry {
605 path: metadata.relative.clone(),
606 field: "version".to_string(),
607 actual,
608 expected: expected_text.clone(),
609 });
610 }
611 }
612
613 if let Some(cargo_lock) = surface.cargo_lock.as_ref() {
614 for package in &surface.packages {
615 let actual = read_cargo_lock_version_for_package(cargo_lock, &package.name)?;
616 if let Some(actual_version) = actual {
617 if actual_version != expected_text {
618 drift.push(DriftEntry {
619 path: "Cargo.lock".to_string(),
620 field: format!("package.{}.version", package.name),
621 actual: Some(actual_version),
622 expected: expected_text.clone(),
623 });
624 }
625 }
626 }
627 }
628
629 drift.sort_by(|a, b| a.path.cmp(&b.path).then(a.field.cmp(&b.field)));
630 Ok(drift)
631}
632
633fn apply_sync(
634 surface: &ReleaseSurface,
635 expected: &Version,
636 write: bool,
637) -> Result<(Vec<SyncEdit>, Vec<String>), String> {
638 let release_names: BTreeSet<String> = surface
639 .packages
640 .iter()
641 .map(|package| package.name.clone())
642 .collect();
643 let mut edits = Vec::new();
644 let mut changed_files = BTreeSet::new();
645 let expected_text = expected.to_string();
646
647 for package in &surface.packages {
648 let file_edits = sync_manifest_versions(package, &release_names, &expected_text, write)?;
649 if !file_edits.is_empty() {
650 changed_files.insert(package.manifest_relative.clone());
651 edits.extend(file_edits);
652 }
653 }
654
655 for metadata in &surface.metadata_files {
656 let actual = read_metadata_version(&metadata.path)?;
657 if actual.as_deref() == Some(expected_text.as_str()) {
658 continue;
659 }
660 if write {
661 write_metadata_version(&metadata.path, expected)?;
662 }
663 changed_files.insert(metadata.relative.clone());
664 edits.push(SyncEdit {
665 path: metadata.relative.clone(),
666 field: "version".to_string(),
667 before: actual,
668 after: expected_text.clone(),
669 });
670 }
671
672 if let Some(cargo_lock) = surface.cargo_lock.as_ref() {
673 for package in &surface.packages {
674 let actual = read_cargo_lock_version_for_package(cargo_lock, &package.name)?;
675 if actual.as_deref() == Some(expected_text.as_str()) {
676 continue;
677 }
678 if actual.is_none() {
679 continue;
680 }
681 if write {
682 write_cargo_lock_version_for_package(cargo_lock, Some(&package.name), expected)?;
683 }
684 changed_files.insert("Cargo.lock".to_string());
685 edits.push(SyncEdit {
686 path: "Cargo.lock".to_string(),
687 field: format!("package.{}.version", package.name),
688 before: actual,
689 after: expected_text.clone(),
690 });
691 }
692 }
693
694 let changed_files = changed_files.into_iter().collect::<Vec<_>>();
695 Ok((edits, changed_files))
696}
697
698fn sync_manifest_versions(
699 package: &ReleasePackage,
700 release_names: &BTreeSet<String>,
701 expected: &str,
702 write: bool,
703) -> Result<Vec<SyncEdit>, String> {
704 let content = fs::read_to_string(&package.manifest_path)
705 .map_err(|e| format!("Failed to read {}: {}", package.manifest_path.display(), e))?;
706 let mut doc = content
707 .parse::<DocumentMut>()
708 .map_err(|e| format!("Failed to parse {}: {}", package.manifest_path.display(), e))?;
709 let mut edits = Vec::new();
710
711 let current_package_version = doc["package"]["version"].as_str().map(str::to_string);
712 if current_package_version.as_deref() != Some(expected) {
713 edits.push(SyncEdit {
714 path: package.manifest_relative.clone(),
715 field: "package.version".to_string(),
716 before: current_package_version,
717 after: expected.to_string(),
718 });
719 if write {
720 doc["package"]["version"] = value(expected);
721 }
722 }
723
724 sync_dependency_tables_in_item(
725 doc.as_item_mut(),
726 "",
727 release_names,
728 expected,
729 &package.manifest_relative,
730 &mut edits,
731 );
732
733 if write && !edits.is_empty() {
734 fs::write(&package.manifest_path, doc.to_string())
735 .map_err(|e| format!("Failed to write {}: {}", package.manifest_path.display(), e))?;
736 }
737
738 Ok(edits)
739}
740
741fn sync_dependency_tables_in_item(
742 item: &mut Item,
743 prefix: &str,
744 release_names: &BTreeSet<String>,
745 expected: &str,
746 manifest_relative: &str,
747 edits: &mut Vec<SyncEdit>,
748) {
749 let Some(table) = item.as_table_mut() else {
750 return;
751 };
752
753 let keys = table
754 .iter()
755 .map(|(key, _)| key.to_string())
756 .collect::<Vec<_>>();
757 for key in keys {
758 let next_prefix = if prefix.is_empty() {
759 key.clone()
760 } else {
761 format!("{}.{}", prefix, key)
762 };
763 let Some(child) = table.get_mut(&key) else {
764 continue;
765 };
766 if matches!(
767 key.as_str(),
768 "dependencies" | "dev-dependencies" | "build-dependencies"
769 ) {
770 if let Some(dep_table) = child.as_table_mut() {
771 sync_dependency_entries(
772 dep_table,
773 &next_prefix,
774 release_names,
775 expected,
776 manifest_relative,
777 edits,
778 );
779 }
780 continue;
781 }
782 sync_dependency_tables_in_item(
783 child,
784 &next_prefix,
785 release_names,
786 expected,
787 manifest_relative,
788 edits,
789 );
790 }
791}
792
793fn sync_dependency_entries(
794 table: &mut Table,
795 section_name: &str,
796 release_names: &BTreeSet<String>,
797 expected: &str,
798 manifest_relative: &str,
799 edits: &mut Vec<SyncEdit>,
800) {
801 let keys = table
802 .iter()
803 .map(|(key, _)| key.to_string())
804 .collect::<Vec<_>>();
805 for dependency_name in keys {
806 if !release_names.contains(&dependency_name) {
807 continue;
808 }
809 let Some(item) = table.get_mut(&dependency_name) else {
810 continue;
811 };
812 match item {
813 Item::Value(value_item) => {
814 let Some(inline) = value_item.as_inline_table_mut() else {
815 continue;
816 };
817 if inline.get("path").is_none() {
818 continue;
819 }
820 let before = inline
821 .get("version")
822 .and_then(Value::as_str)
823 .map(|value| value.to_string());
824 if before.as_deref() == Some(expected) {
825 continue;
826 }
827 inline.insert("version", Value::from(expected));
828 edits.push(SyncEdit {
829 path: manifest_relative.to_string(),
830 field: format!("{}.{}.version", section_name, dependency_name),
831 before,
832 after: expected.to_string(),
833 });
834 }
835 Item::Table(dep_table) => {
836 if dep_table.get("path").is_none() {
837 continue;
838 }
839 let before = dep_table
840 .get("version")
841 .and_then(Item::as_str)
842 .map(|value| value.to_string());
843 if before.as_deref() == Some(expected) {
844 continue;
845 }
846 dep_table["version"] = value(expected);
847 edits.push(SyncEdit {
848 path: manifest_relative.to_string(),
849 field: format!("{}.{}.version", section_name, dependency_name),
850 before,
851 after: expected.to_string(),
852 });
853 }
854 _ => {}
855 }
856 }
857}
858
859fn run_validation(
860 surface: &ReleaseSurface,
861 options: &WorkspaceVersionValidateOptions,
862) -> Result<WorkspaceValidateReport, String> {
863 let mut issues = match resolve_expected_version(surface, None) {
864 Ok(expected) => collect_drift(surface, &expected)?,
865 Err(error) if error.contains("Workspace root has no package.version") => Vec::new(),
866 Err(error) => return Err(error),
867 };
868 if let Some(package_name) = options.package.as_deref() {
869 issues.retain(|issue| {
870 issue.path == "Cargo.lock"
871 || issue.path.ends_with("README.md")
872 || issue.path.ends_with("openapi.yaml")
873 || issue.path.ends_with("openapi.yml")
874 || issue.path.ends_with("openapi.json")
875 || issue.field.contains(package_name)
876 || issue.path.contains(package_name)
877 });
878 }
879
880 let mut commands = Vec::new();
881 if options.cargo_check {
882 let mut command = Command::new("cargo");
883 command
884 .current_dir(&surface.repo_root)
885 .arg("check")
886 .arg("-q");
887 if let Some(package) = options.package.as_deref() {
888 command.arg("-p").arg(package);
889 }
890 commands.push(run_command_capture(command, "cargo check -q")?);
891 }
892
893 if options.package_dry_run {
894 let packages = select_packages_for_validation(surface, options.package.as_deref())?;
895 for package in packages {
896 let mut command = Command::new("cargo");
897 command
898 .current_dir(&surface.repo_root)
899 .arg("publish")
900 .arg("--dry-run")
901 .arg("--locked")
902 .arg("--manifest-path")
903 .arg(&package.manifest_path);
904 commands.push(run_command_capture(
905 command,
906 format!(
907 "cargo publish --dry-run --locked --manifest-path {}",
908 package.manifest_relative
909 ),
910 )?);
911 }
912 }
913
914 let ok = issues.is_empty() && commands.iter().all(|result| result.success);
915 Ok(WorkspaceValidateReport {
916 repo_root: display_path(&surface.repo_root),
917 ok,
918 issues,
919 commands,
920 })
921}
922
923fn select_packages_for_validation<'a>(
924 surface: &'a ReleaseSurface,
925 package_name: Option<&str>,
926) -> Result<Vec<&'a ReleasePackage>, String> {
927 if let Some(package_name) = package_name {
928 let package = surface
929 .packages
930 .iter()
931 .find(|package| package.name == package_name)
932 .ok_or_else(|| format!("Unknown workspace package `{}`.", package_name))?;
933 return Ok(vec![package]);
934 }
935 Ok(surface
936 .packages
937 .iter()
938 .filter(|package| package.publishable && !package.publish_excluded)
939 .collect())
940}
941
942async fn build_publish_plan_report(
943 surface: &ReleaseSurface,
944) -> Result<WorkspacePublishPlanReport, String> {
945 let visibility = collect_crates_io_visibility(surface).await?;
946 let (items, publish_order) = build_publish_plan(surface, &visibility, None)?;
947 Ok(WorkspacePublishPlanReport {
948 repo_root: display_path(&surface.repo_root),
949 packages: items,
950 publish_order,
951 })
952}
953
954async fn run_publish(
955 surface: &ReleaseSurface,
956 options: &WorkspacePublishRunOptions,
957) -> Result<WorkspacePublishRunReport, String> {
958 if options.only.is_some() && options.from.is_some() {
959 return Err("`--only` cannot be combined with `--from`.".to_string());
960 }
961 if !options.allow_dirty {
962 enforce_clean_worktree(&surface.repo_root)?;
963 }
964
965 let validation = run_validation(
966 surface,
967 &WorkspaceVersionValidateOptions {
968 package: options.only.clone(),
969 cargo_check: false,
970 package_dry_run: false,
971 },
972 )?;
973 if !validation.ok {
974 return Err("Workspace has structural publish blockers. Run `xbp version workspace validate` for details.".to_string());
975 }
976
977 let visibility = collect_crates_io_visibility(surface).await?;
978 let selection = PublishSelection {
979 from: options.from.clone(),
980 only: options.only.clone(),
981 };
982 let (plan_items, publish_order) = build_publish_plan(surface, &visibility, Some(&selection))?;
983
984 let mut item_by_name = BTreeMap::new();
985 for item in &plan_items {
986 item_by_name.insert(item.package.clone(), item.clone());
987 }
988
989 let mut published = Vec::new();
990 let mut skipped = Vec::new();
991 let mut failed = Vec::new();
992 for package_name in publish_order {
993 let Some(item) = item_by_name.get(&package_name) else {
994 continue;
995 };
996 if !item.publish_needed {
997 skipped.push(format!("{} {}", item.package, item.reason));
998 continue;
999 }
1000 if !item.blocked_by.is_empty() {
1001 let message = format!("{} blocked by {}", item.package, item.blocked_by.join(", "));
1002 failed.push(message.clone());
1003 if !options.continue_on_error {
1004 break;
1005 }
1006 continue;
1007 }
1008
1009 let package = surface
1010 .packages
1011 .iter()
1012 .find(|package| package.name == item.package)
1013 .ok_or_else(|| format!("Missing package `{}` in release surface.", item.package))?;
1014 let cargo_publish = format!(
1015 "cargo publish --locked --manifest-path {}{}",
1016 package.manifest_relative,
1017 if options.allow_dirty {
1018 " --allow-dirty"
1019 } else {
1020 ""
1021 }
1022 );
1023 println!("{}", cargo_publish);
1024 if options.dry_run {
1025 published.push(format!("{} (dry-run)", item.package));
1026 continue;
1027 }
1028
1029 let status = Command::new("cargo")
1030 .current_dir(&surface.repo_root)
1031 .arg("publish")
1032 .arg("--locked")
1033 .arg("--manifest-path")
1034 .arg(&package.manifest_path)
1035 .args(options.allow_dirty.then_some("--allow-dirty"))
1036 .status()
1037 .map_err(|e| {
1038 format!(
1039 "Failed to execute cargo publish for {}: {}",
1040 item.package, e
1041 )
1042 })?;
1043 if !status.success() {
1044 let message = format!(
1045 "{} publish failed with exit code {:?}",
1046 item.package,
1047 status.code()
1048 );
1049 failed.push(message.clone());
1050 if !options.continue_on_error {
1051 break;
1052 }
1053 continue;
1054 }
1055
1056 wait_for_crates_io_visibility(
1057 &item.package,
1058 &item.version,
1059 options.timeout_seconds,
1060 options.poll_interval_seconds,
1061 )
1062 .await?;
1063 published.push(item.package.clone());
1064 }
1065
1066 Ok(WorkspacePublishRunReport {
1067 repo_root: display_path(&surface.repo_root),
1068 dry_run: options.dry_run,
1069 published,
1070 skipped,
1071 failed,
1072 })
1073}
1074
1075#[derive(Debug, Clone)]
1076struct PublishSelection {
1077 from: Option<String>,
1078 only: Option<String>,
1079}
1080
1081fn build_publish_plan(
1082 surface: &ReleaseSurface,
1083 visibility: &BTreeMap<String, bool>,
1084 selection: Option<&PublishSelection>,
1085) -> Result<(Vec<PublishPlanItem>, Vec<String>), String> {
1086 let ordered_packages = topological_package_order(surface)?;
1087 let selected = resolve_selected_packages(&ordered_packages, selection)?;
1088 let selected_set: BTreeSet<String> = selected.iter().cloned().collect();
1089 let mut available = visibility.clone();
1090 let mut items = Vec::new();
1091 let mut publish_order = Vec::new();
1092
1093 for package_name in ordered_packages {
1094 let package = surface
1095 .packages
1096 .iter()
1097 .find(|package| package.name == package_name)
1098 .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1099 let visible = visibility.get(&package.name).copied();
1100 let is_selected = selected_set.contains(&package.name);
1101 let mut publish_needed = false;
1102 let mut blocked_by = Vec::new();
1103 let reason = if !package.publishable {
1104 "publish disabled in package metadata".to_string()
1105 } else if package.publish_excluded {
1106 "publish excluded by workspace release config".to_string()
1107 } else if !is_selected {
1108 "not selected for this publish run".to_string()
1109 } else if visible == Some(true) {
1110 "already visible on crates.io".to_string()
1111 } else {
1112 for dependency in &package.internal_dependencies {
1113 if available.get(dependency).copied().unwrap_or(false) {
1114 continue;
1115 }
1116 blocked_by.push(dependency.clone());
1117 }
1118 if blocked_by.is_empty() {
1119 publish_needed = true;
1120 publish_order.push(package.name.clone());
1121 available.insert(package.name.clone(), true);
1122 "publish required".to_string()
1123 } else {
1124 format!("waiting for {}", blocked_by.join(", "))
1125 }
1126 };
1127
1128 items.push(PublishPlanItem {
1129 package: package.name.clone(),
1130 manifest: package.manifest_relative.clone(),
1131 version: package.version.to_string(),
1132 publishable: package.publishable && !package.publish_excluded,
1133 crates_io_visible: visible,
1134 publish_needed,
1135 blocked_by,
1136 reason,
1137 });
1138 }
1139
1140 Ok((items, publish_order))
1141}
1142
1143fn resolve_selected_packages(
1144 ordered_packages: &[String],
1145 selection: Option<&PublishSelection>,
1146) -> Result<Vec<String>, String> {
1147 let Some(selection) = selection else {
1148 return Ok(ordered_packages.to_vec());
1149 };
1150 if let Some(only) = selection.only.as_deref() {
1151 if !ordered_packages.iter().any(|package| package == only) {
1152 return Err(format!("Unknown package `{}` for `--only`.", only));
1153 }
1154 return Ok(vec![only.to_string()]);
1155 }
1156 if let Some(from) = selection.from.as_deref() {
1157 let start = ordered_packages
1158 .iter()
1159 .position(|package| package == from)
1160 .ok_or_else(|| format!("Unknown package `{}` for `--from`.", from))?;
1161 return Ok(ordered_packages[start..].to_vec());
1162 }
1163 Ok(ordered_packages.to_vec())
1164}
1165
1166fn topological_package_order(surface: &ReleaseSurface) -> Result<Vec<String>, String> {
1167 let package_names = surface
1168 .packages
1169 .iter()
1170 .map(|package| package.name.clone())
1171 .collect::<BTreeSet<_>>();
1172 let order_overrides = surface
1173 .config
1174 .publish
1175 .order
1176 .iter()
1177 .enumerate()
1178 .map(|(index, name)| (name.clone(), index))
1179 .collect::<BTreeMap<_, _>>();
1180 let mut indegree = BTreeMap::new();
1181 let mut reverse = BTreeMap::<String, Vec<String>>::new();
1182 for package in &surface.packages {
1183 let deps = package
1184 .internal_dependencies
1185 .iter()
1186 .filter(|name| package_names.contains(*name))
1187 .cloned()
1188 .collect::<Vec<_>>();
1189 indegree.insert(package.name.clone(), deps.len());
1190 for dep in deps {
1191 reverse.entry(dep).or_default().push(package.name.clone());
1192 }
1193 }
1194
1195 let mut queue = indegree
1196 .iter()
1197 .filter_map(|(name, degree)| (*degree == 0).then_some(name.clone()))
1198 .collect::<Vec<_>>();
1199 sort_package_names(&mut queue, &order_overrides);
1200
1201 let mut ordered = Vec::new();
1202 while let Some(name) = queue.first().cloned() {
1203 queue.remove(0);
1204 ordered.push(name.clone());
1205 if let Some(children) = reverse.get(&name) {
1206 for child in children {
1207 if let Some(entry) = indegree.get_mut(child) {
1208 *entry -= 1;
1209 if *entry == 0 {
1210 queue.push(child.clone());
1211 }
1212 }
1213 }
1214 sort_package_names(&mut queue, &order_overrides);
1215 }
1216 }
1217
1218 if ordered.len() != surface.packages.len() {
1219 return Err("Workspace package graph contains a dependency cycle.".to_string());
1220 }
1221 Ok(ordered)
1222}
1223
1224fn sort_package_names(names: &mut [String], overrides: &BTreeMap<String, usize>) {
1225 names.sort_by(|a, b| {
1226 overrides
1227 .get(a)
1228 .copied()
1229 .unwrap_or(usize::MAX)
1230 .cmp(&overrides.get(b).copied().unwrap_or(usize::MAX))
1231 .then(a.cmp(b))
1232 });
1233}
1234
1235async fn collect_crates_io_visibility(
1236 surface: &ReleaseSurface,
1237) -> Result<BTreeMap<String, bool>, String> {
1238 let client = crates_io_client()?;
1239 let mut visibility = BTreeMap::new();
1240 for package in &surface.packages {
1241 if !package.publishable || package.publish_excluded {
1242 visibility.insert(package.name.clone(), false);
1243 continue;
1244 }
1245 visibility.insert(
1246 package.name.clone(),
1247 crates_io_has_exact_version(&client, &package.name, &package.version.to_string())
1248 .await?,
1249 );
1250 }
1251 Ok(visibility)
1252}
1253
1254async fn crates_io_has_exact_version(
1255 client: &reqwest::Client,
1256 package: &str,
1257 version: &str,
1258) -> Result<bool, String> {
1259 let url = format!("https://crates.io/api/v1/crates/{}/{}", package, version);
1260 let response = client
1261 .get(url)
1262 .send()
1263 .await
1264 .map_err(|e| format!("Failed crates.io lookup for {} {}: {}", package, version, e))?;
1265 if response.status() == reqwest::StatusCode::NOT_FOUND {
1266 return Ok(false);
1267 }
1268 if !response.status().is_success() {
1269 return Err(format!(
1270 "crates.io lookup for {} {} returned status {}",
1271 package,
1272 version,
1273 response.status()
1274 ));
1275 }
1276 Ok(true)
1277}
1278
1279async fn wait_for_crates_io_visibility(
1280 package: &str,
1281 version: &str,
1282 timeout_seconds: f64,
1283 poll_interval_seconds: f64,
1284) -> Result<(), String> {
1285 let timeout = Duration::from_secs_f64(timeout_seconds.max(1.0));
1286 let poll = Duration::from_secs_f64(poll_interval_seconds.max(0.5));
1287 let deadline = Instant::now() + timeout;
1288 let client = crates_io_client()?;
1289 loop {
1290 if crates_io_has_exact_version(&client, package, version).await? {
1291 return Ok(());
1292 }
1293 if Instant::now() >= deadline {
1294 return Err(format!(
1295 "{} {} was published, but did not become visible on crates.io within {:.0}s",
1296 package, version, timeout_seconds
1297 ));
1298 }
1299 sleep(poll).await;
1300 }
1301}
1302
1303fn crates_io_client() -> Result<reqwest::Client, String> {
1304 reqwest::Client::builder()
1305 .user_agent(format!("xbp/{}", env!("CARGO_PKG_VERSION")))
1306 .build()
1307 .map_err(|e| format!("Failed to build crates.io HTTP client: {}", e))
1308}
1309
1310fn enforce_clean_worktree(project_root: &Path) -> Result<(), String> {
1311 let Some(GitWorktreeState { is_dirty, .. }) = git_worktree_state(project_root)? else {
1312 return Ok(());
1313 };
1314 if is_dirty {
1315 return Err(
1316 "Workspace repo has uncommitted changes. Re-run with `--allow-dirty` to override."
1317 .to_string(),
1318 );
1319 }
1320 Ok(())
1321}
1322
1323fn resolve_expected_version(
1324 surface: &ReleaseSurface,
1325 explicit: Option<&str>,
1326) -> Result<Version, String> {
1327 if let Some(explicit) = explicit {
1328 return parse_version(explicit);
1329 }
1330 let root_package_name = surface.root_package_name.as_ref().ok_or_else(|| {
1331 "Workspace root has no package.version; pass `--version` explicitly.".to_string()
1332 })?;
1333 surface
1334 .packages
1335 .iter()
1336 .find(|package| &package.name == root_package_name)
1337 .map(|package| package.version.clone())
1338 .ok_or_else(|| "Could not resolve the root package version.".to_string())
1339}
1340
1341fn load_cargo_metadata(repo_root: &Path) -> Result<CargoMetadata, String> {
1342 if !command_exists("cargo") {
1343 return Err("`cargo` is required to inspect workspace metadata.".to_string());
1344 }
1345 let output = Command::new("cargo")
1346 .current_dir(repo_root)
1347 .args(["metadata", "--format-version", "1", "--no-deps"])
1348 .output()
1349 .map_err(|e| format!("Failed to run `cargo metadata`: {}", e))?;
1350 if !output.status.success() {
1351 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1352 return Err(format!("`cargo metadata` failed: {}", stderr));
1353 }
1354 serde_json::from_slice::<CargoMetadata>(&output.stdout)
1355 .map_err(|e| format!("Failed to parse cargo metadata JSON: {}", e))
1356}
1357
1358fn load_workspace_release_config(path: Option<&Path>) -> Result<WorkspaceReleaseConfig, String> {
1359 let Some(path) = path else {
1360 return Ok(WorkspaceReleaseConfig::default());
1361 };
1362 let content = fs::read_to_string(path)
1363 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1364 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
1365}
1366
1367fn run_command_capture(
1368 mut command: Command,
1369 label: impl Into<String>,
1370) -> Result<ValidationCommandResult, String> {
1371 let label = label.into();
1372 let output = command
1373 .output()
1374 .map_err(|e| format!("Failed to run `{}`: {}", label, e))?;
1375 Ok(ValidationCommandResult {
1376 command: label,
1377 success: output.status.success(),
1378 exit_code: output.status.code(),
1379 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
1380 })
1381}
1382
1383fn read_metadata_version(path: &Path) -> Result<Option<String>, String> {
1384 let file_name = path
1385 .file_name()
1386 .and_then(|value| value.to_str())
1387 .unwrap_or_default();
1388 match file_name {
1389 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1390 let content = fs::read_to_string(path)
1391 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1392 read_regex_version_from_content(&content, r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
1393 }
1394 _ => read_version_from_path(path),
1395 }
1396}
1397
1398fn write_metadata_version(path: &Path, version: &Version) -> Result<(), String> {
1399 let file_name = path
1400 .file_name()
1401 .and_then(|value| value.to_str())
1402 .unwrap_or_default();
1403 match file_name {
1404 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1405 let content = fs::read_to_string(path)
1406 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1407 let regex = regex::Regex::new(r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
1408 .map_err(|e| format!("Failed to build OpenAPI regex: {}", e))?;
1409 let updated = regex
1410 .replace(&content, format!(" version: {}", version))
1411 .to_string();
1412 fs::write(path, updated)
1413 .map_err(|e| format!("Failed to write {}: {}", path.display(), e))
1414 }
1415 _ => write_version_to_path(path, version).map(|_| ()),
1416 }
1417}
1418
1419fn normalize_relative(repo_root: &Path, path: &Path) -> String {
1420 path.strip_prefix(repo_root)
1421 .unwrap_or(path)
1422 .to_string_lossy()
1423 .replace('\\', "/")
1424}
1425
1426fn display_path(path: &Path) -> String {
1427 path.to_string_lossy().to_string()
1428}
1429
1430fn print_check_report(surface: &ReleaseSurface, report: &WorkspaceCheckReport) {
1431 println!("Workspace version check");
1432 println!("Repo: {}", surface.repo_root.display());
1433 println!("Expected version: {}", report.expected_version);
1434 if let Some(config_path) = &surface.config_path {
1435 println!(
1436 "Config: {}",
1437 normalize_relative(&surface.repo_root, config_path)
1438 );
1439 }
1440 println!(
1441 "Status: {}",
1442 if report.aligned {
1443 "aligned"
1444 } else {
1445 "drift detected"
1446 }
1447 );
1448 for entry in &report.drift {
1449 println!(
1450 "{} {} actual={} expected={}",
1451 entry.path,
1452 entry.field,
1453 entry.actual.as_deref().unwrap_or("<missing>"),
1454 entry.expected
1455 );
1456 }
1457}
1458
1459fn print_sync_report(surface: &ReleaseSurface, report: &WorkspaceSyncReport) {
1460 println!(
1461 "Workspace version {}",
1462 if report.write { "sync" } else { "sync preview" }
1463 );
1464 println!("Repo: {}", surface.repo_root.display());
1465 println!("Expected version: {}", report.expected_version);
1466 if report.edits.is_empty() {
1467 println!("No changes needed.");
1468 return;
1469 }
1470 println!("Files: {}", report.files_changed.join(", "));
1471 for edit in &report.edits {
1472 println!(
1473 "{} {} {} -> {}",
1474 edit.path,
1475 edit.field,
1476 edit.before.as_deref().unwrap_or("<missing>"),
1477 edit.after
1478 );
1479 }
1480}
1481
1482fn print_validation_report(surface: &ReleaseSurface, report: &WorkspaceValidateReport) {
1483 println!("Workspace validation");
1484 println!("Repo: {}", surface.repo_root.display());
1485 println!("Status: {}", if report.ok { "ok" } else { "failed" });
1486 for issue in &report.issues {
1487 println!(
1488 "{} {} actual={} expected={}",
1489 issue.path,
1490 issue.field,
1491 issue.actual.as_deref().unwrap_or("<missing>"),
1492 issue.expected
1493 );
1494 }
1495 for command in &report.commands {
1496 println!(
1497 "{} [{}]",
1498 command.command,
1499 if command.success { "ok" } else { "failed" }
1500 );
1501 if !command.stderr.is_empty() {
1502 println!("{}", command.stderr);
1503 }
1504 }
1505}
1506
1507fn print_publish_plan_report(surface: &ReleaseSurface, report: &WorkspacePublishPlanReport) {
1508 println!("Workspace publish plan");
1509 println!("Repo: {}", surface.repo_root.display());
1510 println!(
1511 "Order: {}",
1512 if report.publish_order.is_empty() {
1513 "<none>".to_string()
1514 } else {
1515 report.publish_order.join(", ")
1516 }
1517 );
1518 for item in &report.packages {
1519 println!(
1520 "{} {} visible={} needed={} reason={}",
1521 item.package,
1522 item.version,
1523 item.crates_io_visible
1524 .map(|value| value.to_string())
1525 .unwrap_or_else(|| "n/a".to_string()),
1526 item.publish_needed,
1527 item.reason
1528 );
1529 if !item.blocked_by.is_empty() {
1530 println!(" blocked by {}", item.blocked_by.join(", "));
1531 }
1532 }
1533}
1534
1535fn print_publish_run_report(surface: &ReleaseSurface, report: &WorkspacePublishRunReport) {
1536 println!("Workspace publish run");
1537 println!("Repo: {}", surface.repo_root.display());
1538 println!("Dry run: {}", report.dry_run);
1539 if !report.published.is_empty() {
1540 println!("Published: {}", report.published.join(", "));
1541 }
1542 if !report.skipped.is_empty() {
1543 println!("Skipped: {}", report.skipped.join("; "));
1544 }
1545 if !report.failed.is_empty() {
1546 println!("Failed: {}", report.failed.join("; "));
1547 }
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552 use super::{
1553 apply_sync, build_publish_plan, collect_drift, discover_release_surface, PublishSelection,
1554 };
1555 use semver::Version;
1556 use std::collections::BTreeMap;
1557 use std::fs;
1558 use std::path::PathBuf;
1559 use std::time::{SystemTime, UNIX_EPOCH};
1560
1561 fn temp_dir(name: &str) -> PathBuf {
1562 let nanos = SystemTime::now()
1563 .duration_since(UNIX_EPOCH)
1564 .expect("time")
1565 .as_nanos();
1566 let dir = std::env::temp_dir().join(format!("xbp-workspace-release-{}-{}", name, nanos));
1567 fs::create_dir_all(&dir).expect("create temp dir");
1568 dir
1569 }
1570
1571 fn write_file(path: &PathBuf, content: &str) {
1572 if let Some(parent) = path.parent() {
1573 fs::create_dir_all(parent).expect("create parent");
1574 }
1575 fs::write(path, content).expect("write file");
1576 }
1577
1578 fn create_demo_workspace() -> PathBuf {
1579 let root = temp_dir("demo");
1580 write_file(
1581 &root.join("Cargo.toml"),
1582 r#"[package]
1583name = "athena_rs"
1584version = "3.16.4"
1585
1586[dependencies.alpha]
1587path = "crates/alpha"
1588version = "3.16.4"
1589
1590[dependencies.beta]
1591path = "crates/beta"
1592version = "3.16.4"
1593
1594[dependencies.athena-s3]
1595path = "crates/athena-s3"
1596version = "3.16.4"
1597
1598[workspace]
1599members = ["crates/alpha", "crates/beta", "crates/athena-s3"]
1600resolver = "2"
1601"#,
1602 );
1603 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
1604 write_file(
1605 &root.join("README.md"),
1606 "# Athena\n\ncurrent version: `3.16.4`\n",
1607 );
1608 write_file(
1609 &root.join("openapi.yaml"),
1610 "openapi: 3.1.0\ninfo:\n title: Athena\n version: 3.16.4\n",
1611 );
1612 write_file(
1613 &root.join("Cargo.lock"),
1614 r#"version = 4
1615
1616[[package]]
1617name = "athena_rs"
1618version = "3.16.4"
1619
1620[[package]]
1621name = "alpha"
1622version = "3.16.4"
1623
1624[[package]]
1625name = "beta"
1626version = "3.16.4"
1627
1628[[package]]
1629name = "athena-s3"
1630version = "3.16.4"
1631"#,
1632 );
1633 write_file(
1634 &root.join("crates/alpha/Cargo.toml"),
1635 r#"[package]
1636name = "alpha"
1637version = "3.16.4"
1638
1639[dependencies]
1640beta = { path = "../beta", version = "3.16.4" }
1641athena-s3 = { path = "../athena-s3", version = "3.16.4" }
1642"#,
1643 );
1644 write_file(&root.join("crates/alpha/src/lib.rs"), "pub fn alpha() {}\n");
1645 write_file(
1646 &root.join("crates/beta/Cargo.toml"),
1647 r#"[package]
1648name = "beta"
1649version = "3.16.4"
1650"#,
1651 );
1652 write_file(&root.join("crates/beta/src/lib.rs"), "pub fn beta() {}\n");
1653 write_file(
1654 &root.join("crates/athena-s3/Cargo.toml"),
1655 r#"[package]
1656name = "athena-s3"
1657version = "3.16.4"
1658"#,
1659 );
1660 write_file(
1661 &root.join("crates/athena-s3/src/lib.rs"),
1662 "pub fn athena_s3() {}\n",
1663 );
1664 write_file(
1665 &root.join("crates/athena-backups/Cargo.toml"),
1666 r#"[package]
1667name = "athena-backups"
1668version = "3.16.0"
1669
1670[dependencies]
1671beta = { path = "../beta", version = "3.16.0" }
1672"#,
1673 );
1674 write_file(
1675 &root.join("crates/athena-backups/src/lib.rs"),
1676 "pub fn athena_backups() {}\n",
1677 );
1678 root
1679 }
1680
1681 fn create_workspace_dependency_demo_workspace() -> PathBuf {
1682 let root = temp_dir("workspace-deps");
1683 write_file(
1684 &root.join("Cargo.toml"),
1685 r#"[package]
1686name = "xbp"
1687version = "10.27.0"
1688
1689[dependencies]
1690xbp-providers.workspace = true
1691
1692[workspace]
1693members = ["crates/http", "crates/providers"]
1694resolver = "2"
1695
1696[workspace.dependencies]
1697xbp-http = { path = "crates/http", version = "0.1.0" }
1698xbp-providers = { path = "crates/providers", version = "0.1.0" }
1699"#,
1700 );
1701 write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
1702 write_file(
1703 &root.join("crates/http/Cargo.toml"),
1704 r#"[package]
1705name = "xbp-http"
1706version = "0.1.0"
1707"#,
1708 );
1709 write_file(&root.join("crates/http/src/lib.rs"), "pub fn http() {}\n");
1710 write_file(
1711 &root.join("crates/providers/Cargo.toml"),
1712 r#"[package]
1713name = "xbp-providers"
1714version = "0.1.0"
1715
1716[dependencies]
1717xbp-http.workspace = true
1718"#,
1719 );
1720 write_file(
1721 &root.join("crates/providers/src/lib.rs"),
1722 "pub fn providers() {}\n",
1723 );
1724 root
1725 }
1726
1727 #[test]
1728 fn discovery_uses_workspace_members_and_excludes_non_member_crates() {
1729 let root = create_demo_workspace();
1730 let surface = discover_release_surface(&root).expect("discover");
1731 let names = surface
1732 .packages
1733 .iter()
1734 .map(|package| package.name.clone())
1735 .collect::<Vec<_>>();
1736 assert!(names.contains(&"athena-s3".to_string()));
1737 assert!(!names.contains(&"athena-backups".to_string()));
1738 }
1739
1740 #[test]
1741 fn drift_reports_package_dependency_metadata_and_lock_mismatches() {
1742 let root = create_demo_workspace();
1743 write_file(
1744 &root.join("crates/alpha/Cargo.toml"),
1745 r#"[package]
1746name = "alpha"
1747version = "3.16.5"
1748
1749[dependencies]
1750beta = { path = "../beta", version = "3.16.4" }
1751athena-s3 = { path = "../athena-s3" }
1752"#,
1753 );
1754 let surface = discover_release_surface(&root).expect("discover");
1755 let expected = Version::new(3, 16, 4);
1756 let drift = collect_drift(&surface, &expected).expect("drift");
1757 assert!(drift.iter().any(
1758 |entry| entry.path == "crates/alpha/Cargo.toml" && entry.field == "package.version"
1759 ));
1760 assert!(
1761 drift
1762 .iter()
1763 .any(|entry| entry.field == "dependencies.athena-s3.version"
1764 && entry.actual.is_none())
1765 );
1766 }
1767
1768 #[test]
1769 fn sync_preview_and_write_updates_workspace_surface() {
1770 let root = create_demo_workspace();
1771 let surface = discover_release_surface(&root).expect("discover");
1772 let expected = Version::new(3, 16, 5);
1773 let (preview, _) = apply_sync(&surface, &expected, false).expect("preview");
1774 assert!(!preview.is_empty());
1775
1776 let (written, files) = apply_sync(&surface, &expected, true).expect("write");
1777 assert!(!written.is_empty());
1778 assert!(files.contains(&"Cargo.toml".to_string()));
1779 let updated = fs::read_to_string(root.join("crates/alpha/Cargo.toml")).expect("read");
1780 assert!(updated.contains("version = \"3.16.5\""));
1781 assert!(updated.contains("athena-s3 = { path = \"../athena-s3\", version = \"3.16.5\" }"));
1782 }
1783
1784 #[test]
1785 fn publish_plan_orders_dependencies_before_dependents() {
1786 let root = create_demo_workspace();
1787 let surface = discover_release_surface(&root).expect("discover");
1788 let mut visibility = BTreeMap::new();
1789 visibility.insert("athena_rs".to_string(), false);
1790 visibility.insert("alpha".to_string(), false);
1791 visibility.insert("beta".to_string(), true);
1792 visibility.insert("athena-s3".to_string(), false);
1793 let (items, order) = build_publish_plan(
1794 &surface,
1795 &visibility,
1796 Some(&PublishSelection {
1797 from: None,
1798 only: None,
1799 }),
1800 )
1801 .expect("plan");
1802 let alpha_pos = order
1803 .iter()
1804 .position(|name| name == "alpha")
1805 .expect("alpha");
1806 let s3_pos = order
1807 .iter()
1808 .position(|name| name == "athena-s3")
1809 .expect("s3");
1810 assert!(s3_pos < alpha_pos);
1811 assert!(items
1812 .iter()
1813 .any(|item| item.package == "athena_rs" && item.publish_needed));
1814 }
1815
1816 #[test]
1817 fn publish_plan_orders_workspace_dependencies_before_dependents() {
1818 let root = create_workspace_dependency_demo_workspace();
1819 let surface = discover_release_surface(&root).expect("discover");
1820 let mut visibility = BTreeMap::new();
1821 visibility.insert("xbp".to_string(), false);
1822 visibility.insert("xbp-http".to_string(), false);
1823 visibility.insert("xbp-providers".to_string(), false);
1824
1825 let (_, order) = build_publish_plan(
1826 &surface,
1827 &visibility,
1828 Some(&PublishSelection {
1829 from: None,
1830 only: None,
1831 }),
1832 )
1833 .expect("plan");
1834
1835 let xbp_pos = order.iter().position(|name| name == "xbp").expect("xbp");
1836 let providers_pos = order
1837 .iter()
1838 .position(|name| name == "xbp-providers")
1839 .expect("providers");
1840 let http_pos = order
1841 .iter()
1842 .position(|name| name == "xbp-http")
1843 .expect("http");
1844
1845 assert!(http_pos < providers_pos);
1846 assert!(providers_pos < xbp_pos);
1847 }
1848}