Skip to main content

xbp_cli/commands/version/
workspace_release.rs

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