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