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: ReleaseSurface = discover_release_surface(&repo_root)?;
310
311    match options.command {
312        WorkspaceVersionCommand::Check(check) => {
313            let expected: Version = resolve_expected_version(&surface, check.version.as_deref())?;
314            let drift: Vec<DriftEntry> = collect_drift(&surface, &expected)?;
315            let report: WorkspaceCheckReport = 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                "Workspace prerequisite closure required for `{}`.\n\
2409                 Rerun with prerequisites (recommended):\n\
2410                   xbp version workspace publish run --repo {} --only {} --include-prereqs{}\n\
2411                 Or plan first:\n\
2412                   xbp version workspace publish plan --repo {} --only {} --include-prereqs\n",
2413                requested_package,
2414                quote_argument(&surface.repo_root),
2415                requested_package,
2416                if run_options.allow_dirty {
2417                    " --allow-dirty"
2418                } else {
2419                    ""
2420                },
2421                quote_argument(&surface.repo_root),
2422                requested_package,
2423            ));
2424        }
2425    }
2426    message.trim_end().to_string()
2427}
2428
2429fn topological_package_order(surface: &ReleaseSurface) -> Result<Vec<String>, String> {
2430    let package_names = surface
2431        .packages
2432        .iter()
2433        .map(|package| package.name.clone())
2434        .collect::<BTreeSet<_>>();
2435    let order_overrides = surface
2436        .config
2437        .publish
2438        .order
2439        .iter()
2440        .enumerate()
2441        .map(|(index, name)| (name.clone(), index))
2442        .collect::<BTreeMap<_, _>>();
2443    let mut indegree = BTreeMap::new();
2444    let mut reverse = BTreeMap::<String, Vec<String>>::new();
2445    for package in &surface.packages {
2446        let deps = package
2447            .publish_internal_dependencies
2448            .iter()
2449            .filter(|name| package_names.contains(*name))
2450            .cloned()
2451            .collect::<Vec<_>>();
2452        indegree.insert(package.name.clone(), deps.len());
2453        for dep in deps {
2454            reverse.entry(dep).or_default().push(package.name.clone());
2455        }
2456    }
2457
2458    let mut queue = indegree
2459        .iter()
2460        .filter_map(|(name, degree)| (*degree == 0).then_some(name.clone()))
2461        .collect::<Vec<_>>();
2462    sort_package_names(&mut queue, &order_overrides);
2463
2464    let mut ordered = Vec::new();
2465    while let Some(name) = queue.first().cloned() {
2466        queue.remove(0);
2467        ordered.push(name.clone());
2468        if let Some(children) = reverse.get(&name) {
2469            for child in children {
2470                if let Some(entry) = indegree.get_mut(child) {
2471                    *entry -= 1;
2472                    if *entry == 0 {
2473                        queue.push(child.clone());
2474                    }
2475                }
2476            }
2477            sort_package_names(&mut queue, &order_overrides);
2478        }
2479    }
2480
2481    if ordered.len() != surface.packages.len() {
2482        return Err(
2483            "Workspace package graph contains a publish-relevant dependency cycle.".to_string(),
2484        );
2485    }
2486    Ok(ordered)
2487}
2488
2489fn sort_package_names(names: &mut [String], overrides: &BTreeMap<String, usize>) {
2490    names.sort_by(|a, b| {
2491        overrides
2492            .get(a)
2493            .copied()
2494            .unwrap_or(usize::MAX)
2495            .cmp(&overrides.get(b).copied().unwrap_or(usize::MAX))
2496            .then(a.cmp(b))
2497    });
2498}
2499
2500async fn collect_crates_io_visibility(
2501    surface: &ReleaseSurface,
2502) -> Result<BTreeMap<String, bool>, String> {
2503    let client = crates_io_client()?;
2504    let mut visibility = BTreeMap::new();
2505    for package in &surface.packages {
2506        if !package.publishable || package.publish_excluded {
2507            visibility.insert(package.name.clone(), false);
2508            continue;
2509        }
2510        visibility.insert(
2511            package.name.clone(),
2512            crates_io_has_exact_version(&client, &package.name, &package.version.to_string())
2513                .await?,
2514        );
2515    }
2516    Ok(visibility)
2517}
2518
2519async fn wait_for_crates_io_visibility(
2520    package: &str,
2521    version: &str,
2522    timeout_seconds: f64,
2523    poll_interval_seconds: f64,
2524    soft_on_timeout: bool,
2525) -> Result<(), String> {
2526    let timeout = Duration::from_secs_f64(timeout_seconds.max(1.0));
2527    let poll = Duration::from_secs_f64(poll_interval_seconds.max(0.5));
2528    let deadline = Instant::now() + timeout;
2529    let client = crates_io_client()?;
2530    loop {
2531        match crates_io_version_lookup(&client, package, version).await? {
2532            CratesIoLookup::Found => return Ok(()),
2533            CratesIoLookup::NotFound | CratesIoLookup::Retryable => {}
2534        }
2535        if Instant::now() >= deadline {
2536            let message = format!(
2537                "{} {} was published, but did not become visible on crates.io within {:.0}s",
2538                package, version, timeout_seconds
2539            );
2540            if soft_on_timeout {
2541                eprintln!(
2542                    "Warning: registry visibility still pending for {}@{} — continuing because cargo already reported a successful publish. Check https://crates.io/crates/{}",
2543                    package, version, package
2544                );
2545                return Ok(());
2546            }
2547            return Err(message);
2548        }
2549        sleep(poll).await;
2550    }
2551}
2552
2553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2554enum CratesIoLookup {
2555    Found,
2556    NotFound,
2557    Retryable,
2558}
2559
2560async fn crates_io_version_lookup(
2561    client: &reqwest::Client,
2562    package: &str,
2563    version: &str,
2564) -> Result<CratesIoLookup, String> {
2565    let url = format!("https://crates.io/api/v1/crates/{}/{}", package, version);
2566    let response = client
2567        .get(url)
2568        .send()
2569        .await
2570        .map_err(|e| format!("Failed crates.io lookup for {} {}: {}", package, version, e))?;
2571    let status = response.status();
2572    if status.is_success() {
2573        return Ok(CratesIoLookup::Found);
2574    }
2575    if status == reqwest::StatusCode::NOT_FOUND {
2576        return Ok(CratesIoLookup::NotFound);
2577    }
2578    if status == reqwest::StatusCode::FORBIDDEN {
2579        return Err(format!(
2580            "crates.io rejected the visibility lookup for {} {} with HTTP 403 \
2581(descriptive User-Agent required). If cargo already printed Published, the crate is live.",
2582            package, version
2583        ));
2584    }
2585    if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
2586        return Ok(CratesIoLookup::Retryable);
2587    }
2588    Ok(CratesIoLookup::NotFound)
2589}
2590
2591async fn crates_io_has_exact_version(
2592    client: &reqwest::Client,
2593    package: &str,
2594    version: &str,
2595) -> Result<bool, String> {
2596    Ok(matches!(
2597        crates_io_version_lookup(client, package, version).await?,
2598        CratesIoLookup::Found
2599    ))
2600}
2601
2602fn crates_io_client() -> Result<reqwest::Client, String> {
2603    reqwest::Client::builder()
2604        .user_agent(format!(
2605            "xbp/{} (https://github.com/xylex-group/xbp)",
2606            env!("CARGO_PKG_VERSION")
2607        ))
2608        .build()
2609        .map_err(|e| format!("Failed to build crates.io HTTP client: {}", e))
2610}
2611
2612fn enforce_clean_worktree(project_root: &Path) -> Result<(), String> {
2613    let Some(GitWorktreeState { is_dirty, .. }) = git_worktree_state(project_root)? else {
2614        return Ok(());
2615    };
2616    if is_dirty {
2617        return Err(
2618            "Workspace repo has uncommitted changes. Re-run with `--allow-dirty` to override."
2619                .to_string(),
2620        );
2621    }
2622    Ok(())
2623}
2624
2625fn resolve_expected_version(
2626    surface: &ReleaseSurface,
2627    explicit: Option<&str>,
2628) -> Result<Version, String> {
2629    if let Some(explicit) = explicit {
2630        return parse_version(explicit);
2631    }
2632    if let Some(root_package_name) = surface.root_package_name.as_ref() {
2633        return surface
2634            .packages
2635            .iter()
2636            .find(|package| &package.name == root_package_name)
2637            .map(|package| package.version.clone())
2638            .ok_or_else(|| "Could not resolve the root package version.".to_string());
2639    }
2640
2641    let root_manifest = surface.repo_root.join("Cargo.toml");
2642    resolve_cargo_package_version(&root_manifest)?
2643        .ok_or_else(|| {
2644            "Workspace root has no package.version or workspace.package.version; pass `--version` explicitly.".to_string()
2645        })
2646        .and_then(|version| parse_version(&version))
2647}
2648
2649fn load_cargo_metadata(repo_root: &Path) -> Result<CargoMetadata, String> {
2650    let mut command = Command::new(cargo_program());
2651    command
2652        .current_dir(repo_root)
2653        .args(["metadata", "--format-version", "1", "--no-deps"]);
2654    load_cargo_metadata_command(&mut command)
2655}
2656
2657fn load_cargo_metadata_for_manifest(manifest_path: &Path) -> Result<CargoMetadata, String> {
2658    let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
2659    let mut command = Command::new(cargo_program());
2660    command
2661        .current_dir(manifest_dir)
2662        .arg("metadata")
2663        .arg("--format-version")
2664        .arg("1")
2665        .arg("--no-deps")
2666        .arg("--manifest-path")
2667        .arg(manifest_path);
2668    load_cargo_metadata_command(&mut command)
2669}
2670
2671fn load_cargo_metadata_command(command: &mut Command) -> Result<CargoMetadata, String> {
2672    if !cargo_command_exists() {
2673        return Err("`cargo` is required to inspect workspace metadata.".to_string());
2674    }
2675    let output = command
2676        .output()
2677        .map_err(|e| format!("Failed to run `cargo metadata`: {}", e))?;
2678    if !output.status.success() {
2679        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2680        return Err(format!("`cargo metadata` failed: {}", stderr));
2681    }
2682    serde_json::from_slice::<CargoMetadata>(&output.stdout)
2683        .map_err(|e| format!("Failed to parse cargo metadata JSON: {}", e))
2684}
2685
2686fn load_workspace_release_config(path: Option<&Path>) -> Result<WorkspaceReleaseConfig, String> {
2687    let Some(path) = path else {
2688        return Ok(WorkspaceReleaseConfig::default());
2689    };
2690    let content = fs::read_to_string(path)
2691        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2692    serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
2693}
2694
2695fn run_command_capture(
2696    mut command: Command,
2697    label: impl Into<String>,
2698) -> Result<ValidationCommandResult, String> {
2699    let label = label.into();
2700    let output = command
2701        .output()
2702        .map_err(|e| format!("Failed to run `{}`: {}", label, e))?;
2703    Ok(ValidationCommandResult {
2704        command: label,
2705        success: output.status.success(),
2706        exit_code: output.status.code(),
2707        stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
2708        warning: None,
2709    })
2710}
2711
2712fn read_metadata_version(path: &Path) -> Result<Option<String>, String> {
2713    let file_name = path
2714        .file_name()
2715        .and_then(|value| value.to_str())
2716        .unwrap_or_default();
2717    match file_name {
2718        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
2719            let content = fs::read_to_string(path)
2720                .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2721            read_regex_version_from_content(&content, r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
2722        }
2723        _ => read_version_from_path(path),
2724    }
2725}
2726
2727fn write_metadata_version(path: &Path, version: &Version) -> Result<(), String> {
2728    let file_name = path
2729        .file_name()
2730        .and_then(|value| value.to_str())
2731        .unwrap_or_default();
2732    match file_name {
2733        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
2734            let content = fs::read_to_string(path)
2735                .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2736            let regex = regex::Regex::new(r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
2737                .map_err(|e| format!("Failed to build OpenAPI regex: {}", e))?;
2738            let updated = regex
2739                .replace(&content, format!("  version: {}", version))
2740                .to_string();
2741            fs::write(path, updated)
2742                .map_err(|e| format!("Failed to write {}: {}", path.display(), e))
2743        }
2744        _ => write_version_to_path(path, version).map(|_| ()),
2745    }
2746}
2747
2748fn normalize_relative(repo_root: &Path, path: &Path) -> String {
2749    path.strip_prefix(repo_root)
2750        .unwrap_or(path)
2751        .to_string_lossy()
2752        .replace('\\', "/")
2753}
2754
2755fn display_path(path: &Path) -> String {
2756    path.to_string_lossy().to_string()
2757}
2758
2759fn same_path(left: &Path, right: &Path) -> bool {
2760    fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf())
2761        == fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
2762}
2763
2764fn quote_argument(path: &Path) -> String {
2765    let value = path.to_string_lossy();
2766    if value.contains(' ') {
2767        format!("\"{}\"", value)
2768    } else {
2769        value.to_string()
2770    }
2771}
2772
2773fn print_check_report(surface: &ReleaseSurface, report: &WorkspaceCheckReport) {
2774    println!("Workspace version check");
2775    println!("Repo: {}", surface.repo_root.display());
2776    println!("Expected version: {}", report.expected_version);
2777    if let Some(config_path) = &surface.config_path {
2778        println!(
2779            "Config: {}",
2780            normalize_relative(&surface.repo_root, config_path)
2781        );
2782    }
2783    println!(
2784        "Status: {}",
2785        if report.aligned {
2786            "aligned"
2787        } else {
2788            "drift detected"
2789        }
2790    );
2791    for entry in &report.drift {
2792        println!(
2793            "{} {} actual={} expected={}",
2794            entry.path,
2795            entry.field,
2796            entry.actual.as_deref().unwrap_or("<missing>"),
2797            entry.expected
2798        );
2799    }
2800}
2801
2802fn print_sync_report(surface: &ReleaseSurface, report: &WorkspaceSyncReport) {
2803    println!(
2804        "Workspace version {}",
2805        if report.write { "sync" } else { "sync preview" }
2806    );
2807    println!("Repo: {}", surface.repo_root.display());
2808    println!("Expected version: {}", report.expected_version);
2809    if report.edits.is_empty() {
2810        println!("No changes needed.");
2811        return;
2812    }
2813    println!("Files: {}", report.files_changed.join(", "));
2814    for edit in &report.edits {
2815        println!(
2816            "{} {} {} -> {}",
2817            edit.path,
2818            edit.field,
2819            edit.before.as_deref().unwrap_or("<missing>"),
2820            edit.after
2821        );
2822    }
2823}
2824
2825fn print_validation_report(surface: &ReleaseSurface, report: &WorkspaceValidateReport) {
2826    println!("Workspace validation");
2827    println!("Repo: {}", surface.repo_root.display());
2828    println!("Status: {}", if report.ok { "ok" } else { "failed" });
2829    for issue in &report.issues {
2830        println!(
2831            "{} {} actual={} expected={}",
2832            issue.path,
2833            issue.field,
2834            issue.actual.as_deref().unwrap_or("<missing>"),
2835            issue.expected
2836        );
2837    }
2838    for command in &report.commands {
2839        let status = if command.success {
2840            if command.warning.is_some() {
2841                "ok (with warning)"
2842            } else {
2843                "ok"
2844            }
2845        } else {
2846            "failed"
2847        };
2848        println!("{} [{}]", command.command, status);
2849        if let Some(warning) = command.warning.as_deref() {
2850            println!("warning: {}", warning);
2851        }
2852        if !command.stderr.is_empty() {
2853            println!("{}", command.stderr);
2854        }
2855    }
2856}
2857
2858fn print_publish_plan_report(surface: &ReleaseSurface, report: &WorkspacePublishPlanReport) {
2859    println!("Workspace publish plan");
2860    println!("Repo: {}", surface.repo_root.display());
2861    if let Some(requested_package) = report.requested_package.as_deref() {
2862        println!("Requested package: {}", requested_package);
2863    }
2864    if !report.included_prereqs.is_empty() {
2865        println!(
2866            "Auto-included prerequisites: {}",
2867            report.included_prereqs.join(", ")
2868        );
2869    }
2870    if !report.required_closure.is_empty() {
2871        println!("Required closure: {}", report.required_closure.join(" -> "));
2872    }
2873    println!(
2874        "Publish order: {}",
2875        if report.publish_order.is_empty() {
2876            "<none>".to_string()
2877        } else {
2878            report.publish_order.join(", ")
2879        }
2880    );
2881    for item in &report.packages {
2882        println!(
2883            "{} {} visible={} needed={} reason={}",
2884            item.package,
2885            item.version,
2886            item.crates_io_visible
2887                .map(|value| value.to_string())
2888                .unwrap_or_else(|| "n/a".to_string()),
2889            item.publish_needed,
2890            item.reason
2891        );
2892        if !item.blocked_by.is_empty() {
2893            println!("  blocked by {}", item.blocked_by.join(", "));
2894        }
2895    }
2896    if !report.surface_issues.is_empty() {
2897        println!("Surface issues:");
2898        for issue in &report.surface_issues {
2899            println!(
2900                "  {} {} — {}{}",
2901                issue.package,
2902                format!("{:?}", issue.kind).to_ascii_lowercase(),
2903                issue.detail,
2904                if issue.auto_fixable {
2905                    " [auto-fixable]"
2906                } else {
2907                    ""
2908                }
2909            );
2910        }
2911        if report.surface_issues.iter().any(|issue| issue.auto_fixable) {
2912            if let Some(requested_package) = report.requested_package.as_deref() {
2913                println!(
2914                    "Heal with: xbp version workspace publish heal --repo {} --only {} --include-prereqs --write",
2915                    quote_argument(&surface.repo_root),
2916                    requested_package
2917                );
2918            } else {
2919                println!(
2920                    "Heal with: xbp version workspace publish heal --repo {} --write",
2921                    quote_argument(&surface.repo_root)
2922                );
2923            }
2924        }
2925    }
2926}
2927
2928fn print_publish_heal_report(surface: &ReleaseSurface, report: &WorkspacePublishHealReport) {
2929    println!("Workspace publish heal");
2930    println!("Repo: {}", surface.repo_root.display());
2931    println!("Expected version: {}", report.expected_version);
2932    println!("Write: {}", report.write);
2933    if report.issues.is_empty() {
2934        println!("No publish-surface issues found.");
2935        return;
2936    }
2937    println!("Issues:");
2938    for issue in &report.issues {
2939        println!(
2940            "  {} — {}{}",
2941            issue.package,
2942            issue.detail,
2943            if issue.auto_fixable {
2944                " [auto-fixable]"
2945            } else {
2946                " [manual]"
2947            }
2948        );
2949    }
2950    if report.write {
2951        if report.files_changed.is_empty() {
2952            println!("No files changed.");
2953        } else {
2954            println!("Files changed: {}", report.files_changed.join(", "));
2955        }
2956    } else if report.issues.iter().any(|issue| issue.auto_fixable) {
2957        println!("Dry preview only. Re-run with --write to apply auto-fixes.");
2958    }
2959}
2960
2961fn print_publish_run_report(surface: &ReleaseSurface, report: &WorkspacePublishRunReport) {
2962    println!("Workspace publish run");
2963    println!("Repo: {}", surface.repo_root.display());
2964    println!("Dry run: {}", report.dry_run);
2965    if let Some(requested_package) = report.requested_package.as_deref() {
2966        println!("Requested package: {}", requested_package);
2967    }
2968    if !report.included_prereqs.is_empty() {
2969        println!(
2970            "Auto-included prerequisites: {}",
2971            report.included_prereqs.join(", ")
2972        );
2973    }
2974    if !report.required_closure.is_empty() {
2975        println!("Required closure: {}", report.required_closure.join(" -> "));
2976    }
2977    if !report.published.is_empty() {
2978        println!("Published: {}", report.published.join(", "));
2979    }
2980    if !report.skipped.is_empty() {
2981        println!("Skipped: {}", report.skipped.join("; "));
2982    }
2983    if !report.failed.is_empty() {
2984        println!("Failed: {}", report.failed.join("; "));
2985    }
2986}
2987
2988#[cfg(test)]
2989mod tests {
2990    use super::{
2991        apply_publish_surface_heals, apply_sync, build_publish_plan, collect_drift,
2992        collect_publish_blockers, collect_publish_surface_issues, discover_release_surface,
2993        inspect_workspace_version_drift, resolve_expected_version, PublishSelection,
2994        PublishSurfaceIssueKind,
2995    };
2996    use semver::Version;
2997    use std::collections::BTreeMap;
2998    use std::fs;
2999    use std::path::PathBuf;
3000    use std::time::{SystemTime, UNIX_EPOCH};
3001
3002    fn temp_dir(name: &str) -> PathBuf {
3003        let nanos = SystemTime::now()
3004            .duration_since(UNIX_EPOCH)
3005            .expect("time")
3006            .as_nanos();
3007        let dir = std::env::temp_dir().join(format!("xbp-workspace-release-{}-{}", name, nanos));
3008        fs::create_dir_all(&dir).expect("create temp dir");
3009        dir
3010    }
3011
3012    fn write_file(path: &PathBuf, content: &str) {
3013        if let Some(parent) = path.parent() {
3014            fs::create_dir_all(parent).expect("create parent");
3015        }
3016        fs::write(path, content).expect("write file");
3017    }
3018
3019    fn create_demo_workspace() -> PathBuf {
3020        let root = temp_dir("demo");
3021        write_file(
3022            &root.join("Cargo.toml"),
3023            r#"[package]
3024name = "athena_rs"
3025version = "3.16.4"
3026
3027[dependencies.alpha]
3028path = "crates/alpha"
3029version = "3.16.4"
3030
3031[dependencies.beta]
3032path = "crates/beta"
3033version = "3.16.4"
3034
3035[dependencies.athena-s3]
3036path = "crates/athena-s3"
3037version = "3.16.4"
3038
3039[workspace]
3040members = ["crates/alpha", "crates/beta", "crates/athena-s3"]
3041resolver = "2"
3042"#,
3043        );
3044        write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
3045        write_file(
3046            &root.join("README.md"),
3047            "# Athena\n\ncurrent version: `3.16.4`\n",
3048        );
3049        write_file(
3050            &root.join("openapi.yaml"),
3051            "openapi: 3.1.0\ninfo:\n  title: Athena\n  version: 3.16.4\n",
3052        );
3053        write_file(
3054            &root.join("Cargo.lock"),
3055            r#"version = 4
3056
3057[[package]]
3058name = "athena_rs"
3059version = "3.16.4"
3060
3061[[package]]
3062name = "alpha"
3063version = "3.16.4"
3064
3065[[package]]
3066name = "beta"
3067version = "3.16.4"
3068
3069[[package]]
3070name = "athena-s3"
3071version = "3.16.4"
3072"#,
3073        );
3074        write_file(
3075            &root.join("crates/alpha/Cargo.toml"),
3076            r#"[package]
3077name = "alpha"
3078version = "3.16.4"
3079
3080[dependencies]
3081beta = { path = "../beta", version = "3.16.4" }
3082athena-s3 = { path = "../athena-s3", version = "3.16.4" }
3083"#,
3084        );
3085        write_file(&root.join("crates/alpha/src/lib.rs"), "pub fn alpha() {}\n");
3086        write_file(
3087            &root.join("crates/beta/Cargo.toml"),
3088            r#"[package]
3089name = "beta"
3090version = "3.16.4"
3091"#,
3092        );
3093        write_file(&root.join("crates/beta/src/lib.rs"), "pub fn beta() {}\n");
3094        write_file(
3095            &root.join("crates/athena-s3/Cargo.toml"),
3096            r#"[package]
3097name = "athena-s3"
3098version = "3.16.4"
3099"#,
3100        );
3101        write_file(
3102            &root.join("crates/athena-s3/src/lib.rs"),
3103            "pub fn athena_s3() {}\n",
3104        );
3105        write_file(
3106            &root.join("crates/athena-backups/Cargo.toml"),
3107            r#"[package]
3108name = "athena-backups"
3109version = "3.16.0"
3110
3111[dependencies]
3112beta = { path = "../beta", version = "3.16.0" }
3113"#,
3114        );
3115        write_file(
3116            &root.join("crates/athena-backups/src/lib.rs"),
3117            "pub fn athena_backups() {}\n",
3118        );
3119        root
3120    }
3121
3122    #[test]
3123    fn inspect_workspace_version_drift_ignores_parent_workspace_for_nested_project_root() {
3124        let root = create_demo_workspace();
3125        let nested_project_root = root.join("packages").join("athena-js");
3126        fs::create_dir_all(&nested_project_root).expect("create nested project root");
3127
3128        let drift = inspect_workspace_version_drift(&nested_project_root, &Version::new(2, 13, 0))
3129            .expect("inspect drift");
3130
3131        assert!(drift.is_none());
3132    }
3133
3134    fn create_workspace_dependency_demo_workspace() -> PathBuf {
3135        let root = temp_dir("workspace-deps");
3136        write_file(
3137            &root.join("Cargo.toml"),
3138            r#"[package]
3139name = "xbp"
3140version = "10.27.0"
3141
3142[dependencies]
3143xbp-providers.workspace = true
3144
3145[workspace]
3146members = ["crates/http", "crates/providers"]
3147resolver = "2"
3148
3149[workspace.dependencies]
3150xbp-http = { path = "crates/http", version = "0.1.0" }
3151xbp-providers = { path = "crates/providers", version = "0.1.0" }
3152"#,
3153        );
3154        write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
3155        write_file(
3156            &root.join("crates/http/Cargo.toml"),
3157            r#"[package]
3158name = "xbp-http"
3159version = "0.1.0"
3160"#,
3161        );
3162        write_file(&root.join("crates/http/src/lib.rs"), "pub fn http() {}\n");
3163        write_file(
3164            &root.join("crates/providers/Cargo.toml"),
3165            r#"[package]
3166name = "xbp-providers"
3167version = "0.1.0"
3168
3169[dependencies]
3170xbp-http.workspace = true
3171"#,
3172        );
3173        write_file(
3174            &root.join("crates/providers/src/lib.rs"),
3175            "pub fn providers() {}\n",
3176        );
3177        root
3178    }
3179
3180    fn create_alias_dependency_demo_workspace() -> PathBuf {
3181        let root = temp_dir("workspace-alias-deps");
3182        write_file(
3183            &root.join("Cargo.toml"),
3184            r#"[package]
3185name = "xbp"
3186version = "10.27.0"
3187
3188[dependencies.xbp_monitor]
3189package = "xbp-monitoring"
3190path = "crates/monitor"
3191version = "10.27.0"
3192
3193[workspace]
3194members = ["crates/monitor"]
3195resolver = "2"
3196"#,
3197        );
3198        write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
3199        write_file(
3200            &root.join("crates/monitor/Cargo.toml"),
3201            r#"[package]
3202name = "xbp-monitoring"
3203version = "10.27.0"
3204"#,
3205        );
3206        write_file(
3207            &root.join("crates/monitor/src/lib.rs"),
3208            "pub fn monitor() {}\n",
3209        );
3210        root
3211    }
3212
3213    fn create_virtual_workspace_package_demo_workspace() -> PathBuf {
3214        let root = temp_dir("virtual-workspace-package");
3215        write_file(
3216            &root.join("Cargo.toml"),
3217            r#"[workspace]
3218members = ["crates/cli"]
3219resolver = "2"
3220
3221[workspace.package]
3222version = "10.35.0"
3223"#,
3224        );
3225        write_file(
3226            &root.join("crates/cli/Cargo.toml"),
3227            r#"[package]
3228name = "xbp"
3229version.workspace = true
3230"#,
3231        );
3232        write_file(&root.join("crates/cli/src/lib.rs"), "pub fn cli() {}\n");
3233        root
3234    }
3235
3236    #[test]
3237    fn discovery_uses_workspace_members_and_excludes_non_member_crates() {
3238        let root = create_demo_workspace();
3239        let surface = discover_release_surface(&root).expect("discover");
3240        let names = surface
3241            .packages
3242            .iter()
3243            .map(|package| package.name.clone())
3244            .collect::<Vec<_>>();
3245        assert!(names.contains(&"athena-s3".to_string()));
3246        assert!(!names.contains(&"athena-backups".to_string()));
3247    }
3248
3249    #[test]
3250    fn expected_version_reads_virtual_workspace_package_version() {
3251        let root = create_virtual_workspace_package_demo_workspace();
3252        let surface = discover_release_surface(&root).expect("discover");
3253
3254        assert_eq!(
3255            resolve_expected_version(&surface, None).expect("expected version"),
3256            Version::new(10, 35, 0)
3257        );
3258
3259        let _ = fs::remove_dir_all(root);
3260    }
3261
3262    #[test]
3263    fn drift_reports_package_dependency_metadata_and_lock_mismatches() {
3264        let root = create_demo_workspace();
3265        write_file(
3266            &root.join("crates/alpha/Cargo.toml"),
3267            r#"[package]
3268name = "alpha"
3269version = "3.16.5"
3270
3271[dependencies]
3272beta = { path = "../beta", version = "3.16.4" }
3273athena-s3 = { path = "../athena-s3" }
3274"#,
3275        );
3276        let surface = discover_release_surface(&root).expect("discover");
3277        let expected = Version::new(3, 16, 4);
3278        let drift = collect_drift(&surface, &expected).expect("drift");
3279        assert!(drift.iter().any(
3280            |entry| entry.path == "crates/alpha/Cargo.toml" && entry.field == "package.version"
3281        ));
3282        assert!(
3283            drift
3284                .iter()
3285                .any(|entry| entry.field == "dependencies.athena-s3.version"
3286                    && entry.actual.is_none())
3287        );
3288    }
3289
3290    #[test]
3291    fn sync_preview_and_write_updates_workspace_surface() {
3292        let root = create_demo_workspace();
3293        let surface = discover_release_surface(&root).expect("discover");
3294        let expected = Version::new(3, 16, 5);
3295        let (preview, _) = apply_sync(&surface, &expected, false).expect("preview");
3296        assert!(!preview.is_empty());
3297
3298        let (written, files) = apply_sync(&surface, &expected, true).expect("write");
3299        assert!(!written.is_empty());
3300        assert!(files.contains(&"Cargo.toml".to_string()));
3301        let updated = fs::read_to_string(root.join("crates/alpha/Cargo.toml")).expect("read");
3302        assert!(updated.contains("version = \"3.16.5\""));
3303        assert!(updated.contains("athena-s3 = { path = \"../athena-s3\", version = \"3.16.5\" }"));
3304    }
3305
3306    #[test]
3307    fn drift_and_sync_follow_aliased_workspace_dependencies() {
3308        let root = create_alias_dependency_demo_workspace();
3309        write_file(
3310            &root.join("Cargo.toml"),
3311            r#"[package]
3312name = "xbp"
3313version = "10.27.1"
3314
3315[dependencies.xbp_monitor]
3316package = "xbp-monitoring"
3317path = "crates/monitor"
3318version = "10.27.0"
3319
3320[workspace]
3321members = ["crates/monitor"]
3322resolver = "2"
3323"#,
3324        );
3325
3326        let surface = discover_release_surface(&root).expect("discover");
3327        let expected = Version::new(10, 27, 1);
3328        let drift = collect_drift(&surface, &expected).expect("drift");
3329        assert!(drift
3330            .iter()
3331            .any(|entry| entry.path == "Cargo.toml"
3332                && entry.field == "dependencies.xbp_monitor.version"));
3333
3334        let (written, files) = apply_sync(&surface, &expected, true).expect("write");
3335        assert!(!written.is_empty());
3336        assert!(files.contains(&"Cargo.toml".to_string()));
3337        let updated = fs::read_to_string(root.join("Cargo.toml")).expect("read root manifest");
3338        assert!(updated.contains("version = \"10.27.1\""));
3339        assert!(updated.contains("package = \"xbp-monitoring\""));
3340        assert!(updated.contains("version = \"10.27.1\""));
3341    }
3342
3343    #[test]
3344    fn publish_plan_orders_dependencies_before_dependents() {
3345        let root = create_demo_workspace();
3346        let surface = discover_release_surface(&root).expect("discover");
3347        let mut visibility = BTreeMap::new();
3348        visibility.insert("athena_rs".to_string(), false);
3349        visibility.insert("alpha".to_string(), false);
3350        visibility.insert("beta".to_string(), true);
3351        visibility.insert("athena-s3".to_string(), false);
3352        let plan = build_publish_plan(
3353            &surface,
3354            &visibility,
3355            Some(&PublishSelection {
3356                from: None,
3357                only: None,
3358                include_prereqs: false,
3359            }),
3360        )
3361        .expect("plan");
3362        let alpha_pos = plan
3363            .publish_order
3364            .iter()
3365            .position(|name| name == "alpha")
3366            .expect("alpha");
3367        let s3_pos = plan
3368            .publish_order
3369            .iter()
3370            .position(|name| name == "athena-s3")
3371            .expect("s3");
3372        assert!(s3_pos < alpha_pos);
3373        assert!(plan
3374            .items
3375            .iter()
3376            .any(|item| item.package == "athena_rs" && item.publish_needed));
3377    }
3378
3379    #[test]
3380    fn publish_plan_orders_workspace_dependencies_before_dependents() {
3381        let root = create_workspace_dependency_demo_workspace();
3382        let surface = discover_release_surface(&root).expect("discover");
3383        let mut visibility = BTreeMap::new();
3384        visibility.insert("xbp".to_string(), false);
3385        visibility.insert("xbp-http".to_string(), false);
3386        visibility.insert("xbp-providers".to_string(), false);
3387
3388        let plan = build_publish_plan(
3389            &surface,
3390            &visibility,
3391            Some(&PublishSelection {
3392                from: None,
3393                only: None,
3394                include_prereqs: false,
3395            }),
3396        )
3397        .expect("plan");
3398
3399        let xbp_pos = plan
3400            .publish_order
3401            .iter()
3402            .position(|name| name == "xbp")
3403            .expect("xbp");
3404        let providers_pos = plan
3405            .publish_order
3406            .iter()
3407            .position(|name| name == "xbp-providers")
3408            .expect("providers");
3409        let http_pos = plan
3410            .publish_order
3411            .iter()
3412            .position(|name| name == "xbp-http")
3413            .expect("http");
3414
3415        assert!(http_pos < providers_pos);
3416        assert!(providers_pos < xbp_pos);
3417    }
3418
3419    #[test]
3420    fn publish_plan_orders_aliased_workspace_dependencies_before_dependents() {
3421        let root = create_alias_dependency_demo_workspace();
3422        let surface = discover_release_surface(&root).expect("discover");
3423        let mut visibility = BTreeMap::new();
3424        visibility.insert("xbp".to_string(), false);
3425        visibility.insert("xbp-monitoring".to_string(), false);
3426
3427        let plan = build_publish_plan(
3428            &surface,
3429            &visibility,
3430            Some(&PublishSelection {
3431                from: None,
3432                only: None,
3433                include_prereqs: false,
3434            }),
3435        )
3436        .expect("plan");
3437
3438        let xbp_pos = plan
3439            .publish_order
3440            .iter()
3441            .position(|name| name == "xbp")
3442            .expect("xbp");
3443        let monitor_pos = plan
3444            .publish_order
3445            .iter()
3446            .position(|name| name == "xbp-monitoring")
3447            .expect("xbp-monitoring");
3448
3449        assert!(monitor_pos < xbp_pos);
3450    }
3451
3452    #[test]
3453    fn publish_plan_only_with_prereqs_limits_to_minimal_closure() {
3454        let root = create_demo_workspace();
3455        let surface = discover_release_surface(&root).expect("discover");
3456        let mut visibility = BTreeMap::new();
3457        visibility.insert("athena_rs".to_string(), false);
3458        visibility.insert("alpha".to_string(), false);
3459        visibility.insert("beta".to_string(), false);
3460        visibility.insert("athena-s3".to_string(), false);
3461
3462        let plan = build_publish_plan(
3463            &surface,
3464            &visibility,
3465            Some(&PublishSelection {
3466                from: None,
3467                only: Some("alpha".to_string()),
3468                include_prereqs: true,
3469            }),
3470        )
3471        .expect("plan");
3472
3473        let package_names = plan
3474            .items
3475            .iter()
3476            .map(|item| item.package.as_str())
3477            .collect::<Vec<_>>();
3478        assert_eq!(package_names.len(), 3);
3479        assert!(!package_names.contains(&"athena_rs"));
3480        assert_eq!(plan.required_closure.len(), 3);
3481        assert!(plan.required_closure.contains(&"alpha".to_string()));
3482        assert!(plan.required_closure.contains(&"beta".to_string()));
3483        assert!(plan.required_closure.contains(&"athena-s3".to_string()));
3484        assert_eq!(plan.included_prereqs.len(), 2);
3485        assert!(plan.included_prereqs.contains(&"beta".to_string()));
3486        assert!(plan.included_prereqs.contains(&"athena-s3".to_string()));
3487        assert_eq!(plan.publish_order.len(), 3);
3488
3489        let alpha_pos = plan
3490            .publish_order
3491            .iter()
3492            .position(|package| package == "alpha")
3493            .expect("alpha in publish order");
3494        let beta_pos = plan
3495            .publish_order
3496            .iter()
3497            .position(|package| package == "beta")
3498            .expect("beta in publish order");
3499        let s3_pos = plan
3500            .publish_order
3501            .iter()
3502            .position(|package| package == "athena-s3")
3503            .expect("athena-s3 in publish order");
3504        assert!(beta_pos < alpha_pos);
3505        assert!(s3_pos < alpha_pos);
3506    }
3507
3508    #[test]
3509    fn publish_plan_only_without_prereqs_reports_blocked_package() {
3510        let root = create_demo_workspace();
3511        let surface = discover_release_surface(&root).expect("discover");
3512        let mut visibility = BTreeMap::new();
3513        visibility.insert("athena_rs".to_string(), false);
3514        visibility.insert("alpha".to_string(), false);
3515        visibility.insert("beta".to_string(), false);
3516        visibility.insert("athena-s3".to_string(), false);
3517
3518        let plan = build_publish_plan(
3519            &surface,
3520            &visibility,
3521            Some(&PublishSelection {
3522                from: None,
3523                only: Some("alpha".to_string()),
3524                include_prereqs: false,
3525            }),
3526        )
3527        .expect("plan");
3528
3529        assert_eq!(plan.required_closure.len(), 3);
3530        assert!(plan.required_closure.contains(&"alpha".to_string()));
3531        assert!(plan.required_closure.contains(&"beta".to_string()));
3532        assert!(plan.required_closure.contains(&"athena-s3".to_string()));
3533        assert!(plan.included_prereqs.is_empty());
3534        assert!(plan.publish_order.is_empty());
3535        assert_eq!(plan.items.len(), 1);
3536        assert_eq!(plan.items[0].package, "alpha");
3537        assert_eq!(plan.items[0].blocked_by.len(), 2);
3538        assert!(plan.items[0].blocked_by.contains(&"beta".to_string()));
3539        assert!(plan.items[0].blocked_by.contains(&"athena-s3".to_string()));
3540    }
3541
3542    #[test]
3543    fn publish_plan_ignores_dev_dependencies_for_publish_blockers() {
3544        let root = temp_dir("publish-dev-deps");
3545        write_file(
3546            &root.join("Cargo.toml"),
3547            r#"[package]
3548name = "demo-root"
3549version = "1.0.0"
3550
3551[workspace]
3552members = ["crates/app", "crates/dev-helper"]
3553resolver = "2"
3554"#,
3555        );
3556        write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
3557        write_file(
3558            &root.join("crates/app/Cargo.toml"),
3559            r#"[package]
3560name = "demo-app"
3561version = "1.0.0"
3562
3563[dev-dependencies]
3564dev-helper = { path = "../dev-helper", version = "1.0.0" }
3565"#,
3566        );
3567        write_file(&root.join("crates/app/src/lib.rs"), "pub fn app() {}\n");
3568        write_file(
3569            &root.join("crates/dev-helper/Cargo.toml"),
3570            r#"[package]
3571name = "dev-helper"
3572version = "1.0.0"
3573"#,
3574        );
3575        write_file(
3576            &root.join("crates/dev-helper/src/lib.rs"),
3577            "pub fn helper() {}\n",
3578        );
3579
3580        let surface = discover_release_surface(&root).expect("discover");
3581        let mut visibility = BTreeMap::new();
3582        visibility.insert("demo-root".to_string(), false);
3583        visibility.insert("demo-app".to_string(), false);
3584        visibility.insert("dev-helper".to_string(), false);
3585
3586        let plan = build_publish_plan(
3587            &surface,
3588            &visibility,
3589            Some(&PublishSelection {
3590                from: None,
3591                only: Some("demo-app".to_string()),
3592                include_prereqs: false,
3593            }),
3594        )
3595        .expect("plan");
3596
3597        assert_eq!(plan.required_closure, vec!["demo-app"]);
3598        assert_eq!(plan.publish_order, vec!["demo-app"]);
3599        assert!(plan.items[0].blocked_by.is_empty());
3600    }
3601
3602    #[test]
3603    fn publish_plan_orphan_publish_false_is_not_a_blocker() {
3604        let root = temp_dir("publish-orphan-private");
3605        write_file(
3606            &root.join("Cargo.toml"),
3607            r#"[workspace]
3608members = ["crates/app", "crates/private-tool"]
3609resolver = "2"
3610"#,
3611        );
3612        write_file(
3613            &root.join("crates/app/Cargo.toml"),
3614            r#"[package]
3615name = "demo-app"
3616version = "1.0.0"
3617"#,
3618        );
3619        write_file(&root.join("crates/app/src/lib.rs"), "pub fn app() {}\n");
3620        write_file(
3621            &root.join("crates/private-tool/Cargo.toml"),
3622            r#"[package]
3623name = "private-tool"
3624version = "1.0.0"
3625publish = false
3626"#,
3627        );
3628        write_file(
3629            &root.join("crates/private-tool/src/lib.rs"),
3630            "pub fn tool() {}\n",
3631        );
3632
3633        let surface = discover_release_surface(&root).expect("discover");
3634        let mut visibility = BTreeMap::new();
3635        visibility.insert("demo-app".to_string(), false);
3636        visibility.insert("private-tool".to_string(), false);
3637
3638        let plan = build_publish_plan(
3639            &surface,
3640            &visibility,
3641            Some(&PublishSelection {
3642                from: None,
3643                only: None,
3644                include_prereqs: false,
3645            }),
3646        )
3647        .expect("plan");
3648
3649        let blockers = collect_publish_blockers(&plan.items);
3650        assert!(
3651            blockers.is_empty(),
3652            "orphan private packages must not block: {blockers:?}"
3653        );
3654        assert!(plan.publish_order.contains(&"demo-app".to_string()));
3655        assert!(!plan.publish_order.contains(&"private-tool".to_string()));
3656    }
3657
3658    #[test]
3659    fn publish_surface_heal_enables_private_path_prereq() {
3660        let root = temp_dir("publish-heal-private-prereq");
3661        write_file(
3662            &root.join("Cargo.toml"),
3663            r#"[workspace]
3664members = ["crates/app", "crates/internal"]
3665resolver = "2"
3666
3667[workspace.package]
3668homepage = "https://example.com"
3669repository = "https://github.com/example/xbp"
3670license = "MIT"
3671"#,
3672        );
3673        write_file(
3674            &root.join("crates/app/Cargo.toml"),
3675            r#"[package]
3676name = "demo-app"
3677version = "1.2.0"
3678
3679[dependencies]
3680internal = { path = "../internal", version = "1.2.0" }
3681"#,
3682        );
3683        write_file(&root.join("crates/app/src/lib.rs"), "pub fn app() {}\n");
3684        write_file(
3685            &root.join("crates/internal/Cargo.toml"),
3686            r#"[package]
3687name = "internal"
3688version = "1.2.0"
3689publish = false
3690"#,
3691        );
3692        write_file(
3693            &root.join("crates/internal/src/lib.rs"),
3694            "pub fn internal() {}\n",
3695        );
3696
3697        let surface = discover_release_surface(&root).expect("discover");
3698        let selection = PublishSelection {
3699            from: None,
3700            only: Some("demo-app".to_string()),
3701            include_prereqs: true,
3702        };
3703        let expected = Version::new(1, 2, 0);
3704        let issues =
3705            collect_publish_surface_issues(&surface, Some(&selection), &expected).expect("issues");
3706        assert!(issues.iter().any(|issue| {
3707            issue.package == "internal"
3708                && issue.kind == PublishSurfaceIssueKind::PublishDisabled
3709                && issue.auto_fixable
3710        }));
3711
3712        let mut visibility = BTreeMap::new();
3713        visibility.insert("demo-app".to_string(), false);
3714        visibility.insert("internal".to_string(), false);
3715        let blocked_plan =
3716            build_publish_plan(&surface, &visibility, Some(&selection)).expect("blocked plan");
3717        assert!(!collect_publish_blockers(&blocked_plan.items).is_empty());
3718
3719        let (_edits, files) =
3720            apply_publish_surface_heals(&surface, Some(&selection), &expected, true)
3721                .expect("heal");
3722        assert!(
3723            files.iter().any(|path| path.contains("internal")),
3724            "expected internal Cargo.toml in {files:?}"
3725        );
3726
3727        let healed = fs::read_to_string(root.join("crates/internal/Cargo.toml")).expect("read");
3728        assert!(
3729            !healed.contains("publish = false"),
3730            "publish=false should be removed: {healed}"
3731        );
3732        assert!(healed.contains("homepage"));
3733        assert!(healed.contains("repository"));
3734
3735        let surface = discover_release_surface(&root).expect("rediscover");
3736        let plan = build_publish_plan(&surface, &visibility, Some(&selection)).expect("plan");
3737        assert!(
3738            collect_publish_blockers(&plan.items).is_empty(),
3739            "healed surface should be unblocked: {:?}",
3740            collect_publish_blockers(&plan.items)
3741        );
3742        assert!(plan.publish_order.contains(&"internal".to_string()));
3743        assert!(plan.publish_order.contains(&"demo-app".to_string()));
3744    }
3745
3746    #[test]
3747    fn publish_surface_exclude_is_not_auto_fixable() {
3748        let root = temp_dir("publish-exclude-manual");
3749        write_file(
3750            &root.join("Cargo.toml"),
3751            r#"[workspace]
3752members = ["crates/app", "crates/secret"]
3753resolver = "2"
3754"#,
3755        );
3756        write_file(
3757            &root.join(".xbp/workspace-release.yaml"),
3758            "publish:\n  exclude:\n    - secret\n",
3759        );
3760        write_file(
3761            &root.join("crates/app/Cargo.toml"),
3762            r#"[package]
3763name = "demo-app"
3764version = "1.0.0"
3765
3766[dependencies]
3767secret = { path = "../secret", version = "1.0.0" }
3768"#,
3769        );
3770        write_file(&root.join("crates/app/src/lib.rs"), "pub fn app() {}\n");
3771        write_file(
3772            &root.join("crates/secret/Cargo.toml"),
3773            r#"[package]
3774name = "secret"
3775version = "1.0.0"
3776"#,
3777        );
3778        write_file(&root.join("crates/secret/src/lib.rs"), "pub fn secret() {}\n");
3779
3780        let surface = discover_release_surface(&root).expect("discover");
3781        let selection = PublishSelection {
3782            from: None,
3783            only: Some("demo-app".to_string()),
3784            include_prereqs: true,
3785        };
3786        let issues = collect_publish_surface_issues(
3787            &surface,
3788            Some(&selection),
3789            &Version::new(1, 0, 0),
3790        )
3791        .expect("issues");
3792        assert!(issues.iter().any(|issue| {
3793            issue.package == "secret"
3794                && issue.kind == PublishSurfaceIssueKind::PublishExcluded
3795                && !issue.auto_fixable
3796        }));
3797    }
3798}