Skip to main content

xbp_cli/commands/version/
workspace_release.rs

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