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 crate::utils::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        if !release_names.contains(dependency_name) {
612            continue;
613        }
614        let Some(detail) = dependency_value.as_table() else {
615            continue;
616        };
617        let uses_workspace = detail
618            .get("workspace")
619            .and_then(TomlValue::as_bool)
620            .unwrap_or(false);
621        let uses_path = detail.contains_key("path");
622        if !uses_path && !uses_workspace {
623            continue;
624        }
625        if publish_relevant {
626            publish_internal_dependencies.insert(dependency_name.clone());
627        }
628        if !uses_path {
629            continue;
630        }
631        let field = format!("{}.{}.version", section_name, dependency_name);
632        let version = detail
633            .get("version")
634            .and_then(TomlValue::as_str)
635            .map(|value| value.to_string());
636        if publish_relevant && version.is_none() {
637            publish_missing_version_pins.push(field.clone());
638        }
639        pins.push(LocalDependencyPin { field, version });
640    }
641}
642
643fn collect_drift(surface: &ReleaseSurface, expected: &Version) -> Result<Vec<DriftEntry>, String> {
644    let expected_text = expected.to_string();
645    let mut drift = Vec::new();
646
647    for package in &surface.packages {
648        if package.version != *expected {
649            drift.push(DriftEntry {
650                path: package.manifest_relative.clone(),
651                field: "package.version".to_string(),
652                actual: Some(package.version.to_string()),
653                expected: expected_text.clone(),
654            });
655        }
656        for pin in &package.dependency_pins {
657            if pin.version.as_deref() != Some(expected_text.as_str()) {
658                drift.push(DriftEntry {
659                    path: package.manifest_relative.clone(),
660                    field: pin.field.clone(),
661                    actual: pin.version.clone(),
662                    expected: expected_text.clone(),
663                });
664            }
665        }
666    }
667
668    for metadata in &surface.metadata_files {
669        let actual = read_metadata_version(&metadata.path)?;
670        if actual.as_deref() != Some(expected_text.as_str()) {
671            drift.push(DriftEntry {
672                path: metadata.relative.clone(),
673                field: "version".to_string(),
674                actual,
675                expected: expected_text.clone(),
676            });
677        }
678    }
679
680    if let Some(cargo_lock) = surface.cargo_lock.as_ref() {
681        for package in &surface.packages {
682            let actual = read_cargo_lock_version_for_package(cargo_lock, &package.name)?;
683            if let Some(actual_version) = actual {
684                if actual_version != expected_text {
685                    drift.push(DriftEntry {
686                        path: "Cargo.lock".to_string(),
687                        field: format!("package.{}.version", package.name),
688                        actual: Some(actual_version),
689                        expected: expected_text.clone(),
690                    });
691                }
692            }
693        }
694    }
695
696    drift.sort_by(|a, b| a.path.cmp(&b.path).then(a.field.cmp(&b.field)));
697    Ok(drift)
698}
699
700fn apply_sync(
701    surface: &ReleaseSurface,
702    expected: &Version,
703    write: bool,
704) -> Result<(Vec<SyncEdit>, Vec<String>), String> {
705    let release_names: BTreeSet<String> = surface
706        .packages
707        .iter()
708        .map(|package| package.name.clone())
709        .collect();
710    let mut edits = Vec::new();
711    let mut changed_files = BTreeSet::new();
712    let expected_text = expected.to_string();
713
714    for package in &surface.packages {
715        let file_edits = sync_manifest_versions(package, &release_names, &expected_text, write)?;
716        if !file_edits.is_empty() {
717            changed_files.insert(package.manifest_relative.clone());
718            edits.extend(file_edits);
719        }
720    }
721
722    for metadata in &surface.metadata_files {
723        let actual = read_metadata_version(&metadata.path)?;
724        if actual.as_deref() == Some(expected_text.as_str()) {
725            continue;
726        }
727        if write {
728            write_metadata_version(&metadata.path, expected)?;
729        }
730        changed_files.insert(metadata.relative.clone());
731        edits.push(SyncEdit {
732            path: metadata.relative.clone(),
733            field: "version".to_string(),
734            before: actual,
735            after: expected_text.clone(),
736        });
737    }
738
739    if let Some(cargo_lock) = surface.cargo_lock.as_ref() {
740        for package in &surface.packages {
741            let actual = read_cargo_lock_version_for_package(cargo_lock, &package.name)?;
742            if actual.as_deref() == Some(expected_text.as_str()) {
743                continue;
744            }
745            if actual.is_none() {
746                continue;
747            }
748            if write {
749                write_cargo_lock_version_for_package(cargo_lock, Some(&package.name), expected)?;
750            }
751            changed_files.insert("Cargo.lock".to_string());
752            edits.push(SyncEdit {
753                path: "Cargo.lock".to_string(),
754                field: format!("package.{}.version", package.name),
755                before: actual,
756                after: expected_text.clone(),
757            });
758        }
759    }
760
761    let changed_files = changed_files.into_iter().collect::<Vec<_>>();
762    Ok((edits, changed_files))
763}
764
765fn sync_manifest_versions(
766    package: &ReleasePackage,
767    release_names: &BTreeSet<String>,
768    expected: &str,
769    write: bool,
770) -> Result<Vec<SyncEdit>, String> {
771    let content = fs::read_to_string(&package.manifest_path)
772        .map_err(|e| format!("Failed to read {}: {}", package.manifest_path.display(), e))?;
773    let mut doc = content
774        .parse::<DocumentMut>()
775        .map_err(|e| format!("Failed to parse {}: {}", package.manifest_path.display(), e))?;
776    let mut edits = Vec::new();
777
778    let current_package_version = doc["package"]["version"].as_str().map(str::to_string);
779    if current_package_version.as_deref() != Some(expected) {
780        edits.push(SyncEdit {
781            path: package.manifest_relative.clone(),
782            field: "package.version".to_string(),
783            before: current_package_version,
784            after: expected.to_string(),
785        });
786        if write {
787            doc["package"]["version"] = value(expected);
788        }
789    }
790
791    sync_dependency_tables_in_item(
792        doc.as_item_mut(),
793        "",
794        release_names,
795        expected,
796        &package.manifest_relative,
797        &mut edits,
798    );
799
800    if write && !edits.is_empty() {
801        fs::write(&package.manifest_path, doc.to_string())
802            .map_err(|e| format!("Failed to write {}: {}", package.manifest_path.display(), e))?;
803    }
804
805    Ok(edits)
806}
807
808fn sync_dependency_tables_in_item(
809    item: &mut Item,
810    prefix: &str,
811    release_names: &BTreeSet<String>,
812    expected: &str,
813    manifest_relative: &str,
814    edits: &mut Vec<SyncEdit>,
815) {
816    let Some(table) = item.as_table_mut() else {
817        return;
818    };
819
820    let keys = table
821        .iter()
822        .map(|(key, _)| key.to_string())
823        .collect::<Vec<_>>();
824    for key in keys {
825        let next_prefix = if prefix.is_empty() {
826            key.clone()
827        } else {
828            format!("{}.{}", prefix, key)
829        };
830        let Some(child) = table.get_mut(&key) else {
831            continue;
832        };
833        if matches!(
834            key.as_str(),
835            "dependencies" | "dev-dependencies" | "build-dependencies"
836        ) {
837            if let Some(dep_table) = child.as_table_mut() {
838                sync_dependency_entries(
839                    dep_table,
840                    &next_prefix,
841                    release_names,
842                    expected,
843                    manifest_relative,
844                    edits,
845                );
846            }
847            continue;
848        }
849        sync_dependency_tables_in_item(
850            child,
851            &next_prefix,
852            release_names,
853            expected,
854            manifest_relative,
855            edits,
856        );
857    }
858}
859
860fn sync_dependency_entries(
861    table: &mut Table,
862    section_name: &str,
863    release_names: &BTreeSet<String>,
864    expected: &str,
865    manifest_relative: &str,
866    edits: &mut Vec<SyncEdit>,
867) {
868    let keys = table
869        .iter()
870        .map(|(key, _)| key.to_string())
871        .collect::<Vec<_>>();
872    for dependency_name in keys {
873        if !release_names.contains(&dependency_name) {
874            continue;
875        }
876        let Some(item) = table.get_mut(&dependency_name) else {
877            continue;
878        };
879        match item {
880            Item::Value(value_item) => {
881                let Some(inline) = value_item.as_inline_table_mut() else {
882                    continue;
883                };
884                if inline.get("path").is_none() {
885                    continue;
886                }
887                let before = inline
888                    .get("version")
889                    .and_then(Value::as_str)
890                    .map(|value| value.to_string());
891                if before.as_deref() == Some(expected) {
892                    continue;
893                }
894                inline.insert("version", Value::from(expected));
895                edits.push(SyncEdit {
896                    path: manifest_relative.to_string(),
897                    field: format!("{}.{}.version", section_name, dependency_name),
898                    before,
899                    after: expected.to_string(),
900                });
901            }
902            Item::Table(dep_table) => {
903                if dep_table.get("path").is_none() {
904                    continue;
905                }
906                let before = dep_table
907                    .get("version")
908                    .and_then(Item::as_str)
909                    .map(|value| value.to_string());
910                if before.as_deref() == Some(expected) {
911                    continue;
912                }
913                dep_table["version"] = value(expected);
914                edits.push(SyncEdit {
915                    path: manifest_relative.to_string(),
916                    field: format!("{}.{}.version", section_name, dependency_name),
917                    before,
918                    after: expected.to_string(),
919                });
920            }
921            _ => {}
922        }
923    }
924}
925
926fn run_validation(
927    surface: &ReleaseSurface,
928    options: &WorkspaceVersionValidateOptions,
929) -> Result<WorkspaceValidateReport, String> {
930    let mut issues = match resolve_expected_version(surface, None) {
931        Ok(expected) => collect_drift(surface, &expected)?,
932        Err(error) if error.contains("Workspace root has no package.version") => Vec::new(),
933        Err(error) => return Err(error),
934    };
935    if let Some(package_name) = options.package.as_deref() {
936        issues.retain(|issue| {
937            issue.path == "Cargo.lock"
938                || issue.path.ends_with("README.md")
939                || issue.path.ends_with("openapi.yaml")
940                || issue.path.ends_with("openapi.yml")
941                || issue.path.ends_with("openapi.json")
942                || issue.field.contains(package_name)
943                || issue.path.contains(package_name)
944        });
945    }
946
947    let mut commands = Vec::new();
948    if options.cargo_check {
949        let mut command = Command::new("cargo");
950        command
951            .current_dir(&surface.repo_root)
952            .arg("check")
953            .arg("-q");
954        if let Some(package) = options.package.as_deref() {
955            command.arg("-p").arg(package);
956        }
957        commands.push(run_command_capture(command, "cargo check -q")?);
958    }
959
960    if options.package_dry_run {
961        commands.extend(run_package_dry_run_validation(surface, options.package.as_deref())?);
962    }
963
964    let ok = issues.is_empty() && commands.iter().all(|result| result.success);
965    Ok(WorkspaceValidateReport {
966        repo_root: display_path(&surface.repo_root),
967        ok,
968        issues,
969        commands,
970    })
971}
972
973fn select_packages_for_validation<'a>(
974    surface: &'a ReleaseSurface,
975    package_name: Option<&str>,
976) -> Result<Vec<&'a ReleasePackage>, String> {
977    if let Some(package_name) = package_name {
978        let package = surface
979            .packages
980            .iter()
981            .find(|package| package.name == package_name)
982            .ok_or_else(|| format!("Unknown workspace package `{}`.", package_name))?;
983        return Ok(vec![package]);
984    }
985    Ok(surface
986        .packages
987        .iter()
988        .filter(|package| package.publishable && !package.publish_excluded)
989        .collect())
990}
991
992fn select_ordered_packages_for_validation<'a>(
993    surface: &'a ReleaseSurface,
994    package_name: Option<&str>,
995) -> Result<Vec<&'a ReleasePackage>, String> {
996    let selected = select_packages_for_validation(surface, package_name)?;
997    let selected_names: BTreeSet<String> = selected
998        .iter()
999        .map(|package| package.name.clone())
1000        .collect();
1001    let ordered_names = topological_package_order(surface)?;
1002    Ok(ordered_names
1003        .into_iter()
1004        .filter(|name| selected_names.contains(name))
1005        .filter_map(|name| selected.iter().find(|package| package.name == name).copied())
1006        .collect())
1007}
1008
1009fn run_package_dry_run_validation(
1010    surface: &ReleaseSurface,
1011    package_name: Option<&str>,
1012) -> Result<Vec<ValidationCommandResult>, String> {
1013    let packages = select_ordered_packages_for_validation(surface, package_name)?;
1014    let mut results = Vec::new();
1015
1016    for package in packages {
1017        let label = format!(
1018            "cargo publish --dry-run --locked --no-verify --manifest-path {}",
1019            package.manifest_relative
1020        );
1021        let repo_root = surface.repo_root.clone();
1022        let manifest_path = package.manifest_path.clone();
1023        let mut result = run_publish_dry_run_capture(&repo_root, &manifest_path, &label)?;
1024        if !result.success {
1025            if is_unpublished_workspace_dependency_error(&result.stderr) {
1026                let fallback_label = format!(
1027                    "cargo package --allow-dirty --no-verify --manifest-path {}",
1028                    package.manifest_relative
1029                );
1030                let fallback_result =
1031                    run_package_capture(&repo_root, &manifest_path, &fallback_label)?;
1032                if fallback_result.success {
1033                    result = ValidationCommandResult {
1034                        command: fallback_label,
1035                        success: true,
1036                        exit_code: fallback_result.exit_code,
1037                        stderr: fallback_result.stderr,
1038                        warning: Some(format!(
1039                            "Registry dry-run skipped for `{}`: workspace dependencies are not on crates.io yet. Local packaging succeeded.",
1040                            package.name
1041                        )),
1042                    };
1043                } else {
1044                    result.stderr = append_validation_hint(&result.stderr);
1045                }
1046            } else {
1047                result.stderr = append_validation_hint(&result.stderr);
1048            }
1049        }
1050        results.push(result);
1051    }
1052
1053    Ok(results)
1054}
1055
1056fn is_unpublished_workspace_dependency_error(stderr: &str) -> bool {
1057    stderr.contains("no matching package named")
1058        || stderr.contains("failed to select a version for the requirement")
1059}
1060
1061fn is_transient_registry_error(stderr: &str) -> bool {
1062    stderr.contains("Could not resolve host")
1063        || stderr.contains("Could not resolve hostname")
1064        || stderr.contains("failed to update registry")
1065        || stderr.contains("download of config.json failed")
1066}
1067
1068fn append_validation_hint(stderr: &str) -> String {
1069    let mut message = stderr.to_string();
1070    if stderr.contains("Access is denied") || stderr.contains("os error 5") {
1071        message.push_str(
1072            "\nHint: close any running `xbp` processes before package dry-run validation on Windows.",
1073        );
1074    }
1075    if is_transient_registry_error(stderr) {
1076        message.push_str(
1077            "\nHint: crates.io was unreachable. Check network connectivity and retry.",
1078        );
1079    }
1080    if is_unpublished_workspace_dependency_error(stderr) {
1081        message.push_str(
1082            "\nHint: publish workspace dependencies first, or rely on the local `cargo package` fallback.",
1083        );
1084    }
1085    message
1086}
1087
1088fn run_publish_dry_run_capture(
1089    repo_root: &Path,
1090    manifest_path: &Path,
1091    label: &str,
1092) -> Result<ValidationCommandResult, String> {
1093    let build = || {
1094        let mut command = Command::new("cargo");
1095        command
1096            .current_dir(repo_root)
1097            .arg("publish")
1098            .arg("--dry-run")
1099            .arg("--locked")
1100            .arg("--no-verify")
1101            .arg("--manifest-path")
1102            .arg(manifest_path);
1103        command
1104    };
1105    run_command_capture_with_retry(build, label)
1106}
1107
1108fn run_package_capture(
1109    repo_root: &Path,
1110    manifest_path: &Path,
1111    label: &str,
1112) -> Result<ValidationCommandResult, String> {
1113    let build = || {
1114        let mut command = Command::new("cargo");
1115        command
1116            .current_dir(repo_root)
1117            .arg("package")
1118            .arg("--allow-dirty")
1119            .arg("--no-verify")
1120            .arg("--manifest-path")
1121            .arg(manifest_path);
1122        command
1123    };
1124    run_command_capture_with_retry(build, label)
1125}
1126
1127fn run_command_capture_with_retry(
1128    build_command: impl Fn() -> Command,
1129    label: &str,
1130) -> Result<ValidationCommandResult, String> {
1131    let first = run_command_capture(build_command(), label)?;
1132    if first.success || !is_transient_registry_error(&first.stderr) {
1133        return Ok(first);
1134    }
1135
1136    thread::sleep(StdDuration::from_secs(2));
1137    let mut retry = run_command_capture(build_command(), format!("{label} (retry)"))?;
1138    if !retry.success {
1139        retry.stderr = append_validation_hint(&retry.stderr);
1140    }
1141    Ok(retry)
1142}
1143
1144async fn build_publish_plan_report(
1145    surface: &ReleaseSurface,
1146    options: &WorkspacePublishPlanOptions,
1147) -> Result<WorkspacePublishPlanReport, String> {
1148    let visibility = collect_crates_io_visibility(surface).await?;
1149    let plan = build_publish_plan(
1150        surface,
1151        &visibility,
1152        Some(&PublishSelection {
1153            from: None,
1154            only: options.only.clone(),
1155            include_prereqs: options.include_prereqs,
1156        }),
1157    )?;
1158    Ok(WorkspacePublishPlanReport {
1159        repo_root: display_path(&surface.repo_root),
1160        requested_package: plan.requested_package,
1161        included_prereqs: plan.included_prereqs,
1162        required_closure: plan.required_closure,
1163        packages: plan.items,
1164        publish_order: plan.publish_order,
1165    })
1166}
1167
1168#[derive(Debug, Clone)]
1169pub(crate) struct WorkspaceVersionDriftSummary {
1170    pub expected_version: Version,
1171    pub drift_count: usize,
1172    pub preview: Vec<String>,
1173}
1174
1175pub(crate) fn inspect_workspace_version_drift(
1176    repo_root: &Path,
1177    expected_version: &Version,
1178) -> Result<Option<WorkspaceVersionDriftSummary>, String> {
1179    let surface = match discover_release_surface(repo_root) {
1180        Ok(surface) => surface,
1181        Err(_) => return Ok(None),
1182    };
1183    if surface.packages.len() <= 1 && surface.config_path.is_none() {
1184        return Ok(None);
1185    }
1186
1187    let drift = collect_drift(&surface, expected_version)?;
1188    if drift.is_empty() {
1189        return Ok(None);
1190    }
1191
1192    let preview = drift
1193        .iter()
1194        .take(6)
1195        .map(|entry| {
1196            format!(
1197                "{} {}: {} -> {}",
1198                entry.path,
1199                entry.field,
1200                entry.actual.as_deref().unwrap_or("<missing>"),
1201                entry.expected
1202            )
1203        })
1204        .collect();
1205
1206    Ok(Some(WorkspaceVersionDriftSummary {
1207        expected_version: expected_version.clone(),
1208        drift_count: drift.len(),
1209        preview,
1210    }))
1211}
1212
1213pub(crate) fn sync_workspace_to_version(
1214    repo_root: &Path,
1215    expected_version: &Version,
1216) -> Result<Vec<String>, String> {
1217    let surface = discover_release_surface(repo_root)?;
1218    let (_edits, changed_files) = apply_sync(&surface, expected_version, true)?;
1219    Ok(changed_files)
1220}
1221
1222pub(crate) async fn resolve_manifest_workspace_publish(
1223    manifest_path: &Path,
1224    include_prereqs: bool,
1225) -> Result<ManifestWorkspacePublishResolution, String> {
1226    let surface = discover_release_surface_from_manifest(manifest_path)?;
1227    let package = surface
1228        .packages
1229        .iter()
1230        .find(|package| same_path(&package.manifest_path, manifest_path))
1231        .ok_or_else(|| {
1232            format!(
1233                "Manifest {} is not a publishable package in the resolved Cargo workspace.",
1234                manifest_path.display()
1235            )
1236        })?;
1237    let visibility = collect_crates_io_visibility(&surface).await?;
1238    let plan = build_publish_plan(
1239        &surface,
1240        &visibility,
1241        Some(&PublishSelection {
1242            from: None,
1243            only: Some(package.name.clone()),
1244            include_prereqs,
1245        }),
1246    )?;
1247    Ok(ManifestWorkspacePublishResolution {
1248        workspace_root: surface.repo_root.clone(),
1249        requested_package: package.name.clone(),
1250        included_prereqs: plan.included_prereqs,
1251        required_closure: plan.required_closure,
1252        packages: plan.items,
1253        publish_order: build_publish_command_targets(&surface, &plan.publish_order)?,
1254    })
1255}
1256
1257async fn run_publish(
1258    surface: &ReleaseSurface,
1259    options: &WorkspacePublishRunOptions,
1260) -> Result<WorkspacePublishRunReport, String> {
1261    if options.only.is_some() && options.from.is_some() {
1262        return Err("`--only` cannot be combined with `--from`.".to_string());
1263    }
1264    if !options.allow_dirty {
1265        enforce_clean_worktree(&surface.repo_root)?;
1266    }
1267
1268    let validation = run_validation(
1269        surface,
1270        &WorkspaceVersionValidateOptions {
1271            package: options.only.clone(),
1272            cargo_check: false,
1273            package_dry_run: false,
1274        },
1275    )?;
1276    if !validation.ok {
1277        return Err("Workspace has structural publish blockers. Run `xbp version workspace validate` for details.".to_string());
1278    }
1279
1280    let visibility = collect_crates_io_visibility(surface).await?;
1281    let selection = PublishSelection {
1282        from: options.from.clone(),
1283        only: options.only.clone(),
1284        include_prereqs: options.include_prereqs,
1285    };
1286    let plan = build_publish_plan(surface, &visibility, Some(&selection))?;
1287    let blockers = collect_publish_blockers(&plan.items);
1288    if !blockers.is_empty() {
1289        return Err(render_workspace_publish_blockers(
1290            surface,
1291            &plan,
1292            Some(options),
1293            &blockers,
1294        ));
1295    }
1296
1297    let mut item_by_name = BTreeMap::new();
1298    for item in &plan.items {
1299        item_by_name.insert(item.package.clone(), item.clone());
1300    }
1301
1302    let mut published = Vec::new();
1303    let mut skipped = Vec::new();
1304    let mut failed = Vec::new();
1305    for package_name in &plan.publish_order {
1306        let Some(item) = item_by_name.get(package_name) else {
1307            continue;
1308        };
1309        if !item.publish_needed {
1310            skipped.push(format!("{} {}", item.package, item.reason));
1311            continue;
1312        }
1313        if !item.blocked_by.is_empty() {
1314            let message = format!("{} blocked by {}", item.package, item.blocked_by.join(", "));
1315            failed.push(message.clone());
1316            if !options.continue_on_error {
1317                break;
1318            }
1319            continue;
1320        }
1321
1322        let package = surface
1323            .packages
1324            .iter()
1325            .find(|package| package.name == item.package)
1326            .ok_or_else(|| format!("Missing package `{}` in release surface.", item.package))?;
1327        let cargo_publish = format!(
1328            "cargo publish --locked --manifest-path {}{}",
1329            package.manifest_relative,
1330            if options.allow_dirty {
1331                " --allow-dirty"
1332            } else {
1333                ""
1334            }
1335        );
1336        println!("{}", cargo_publish);
1337        if options.dry_run {
1338            published.push(format!("{} (dry-run)", item.package));
1339            continue;
1340        }
1341
1342        let status = Command::new("cargo")
1343            .current_dir(&surface.repo_root)
1344            .arg("publish")
1345            .arg("--locked")
1346            .arg("--manifest-path")
1347            .arg(&package.manifest_path)
1348            .args(options.allow_dirty.then_some("--allow-dirty"))
1349            .status()
1350            .map_err(|e| {
1351                format!(
1352                    "Failed to execute cargo publish for {}: {}",
1353                    item.package, e
1354                )
1355            })?;
1356        if !status.success() {
1357            let message = format!(
1358                "{} publish failed with exit code {:?}",
1359                item.package,
1360                status.code()
1361            );
1362            failed.push(message.clone());
1363            if !options.continue_on_error {
1364                break;
1365            }
1366            continue;
1367        }
1368
1369        wait_for_crates_io_visibility(
1370            &item.package,
1371            &item.version,
1372            options.timeout_seconds,
1373            options.poll_interval_seconds,
1374        )
1375        .await?;
1376        published.push(item.package.clone());
1377    }
1378
1379    Ok(WorkspacePublishRunReport {
1380        repo_root: display_path(&surface.repo_root),
1381        dry_run: options.dry_run,
1382        requested_package: plan.requested_package,
1383        included_prereqs: plan.included_prereqs,
1384        required_closure: plan.required_closure,
1385        published,
1386        skipped,
1387        failed,
1388    })
1389}
1390
1391fn build_publish_command_targets(
1392    surface: &ReleaseSurface,
1393    publish_order: &[String],
1394) -> Result<Vec<WorkspacePublishCommandTarget>, String> {
1395    let mut targets = Vec::new();
1396    for package_name in publish_order {
1397        let package = surface
1398            .packages
1399            .iter()
1400            .find(|package| &package.name == package_name)
1401            .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1402        targets.push(WorkspacePublishCommandTarget {
1403            package: package.name.clone(),
1404            version: package.version.to_string(),
1405            manifest_path: package.manifest_path.clone(),
1406            manifest_relative: package.manifest_relative.clone(),
1407        });
1408    }
1409    Ok(targets)
1410}
1411
1412#[derive(Debug, Clone)]
1413struct PublishSelection {
1414    from: Option<String>,
1415    only: Option<String>,
1416    include_prereqs: bool,
1417}
1418
1419#[derive(Debug, Clone)]
1420struct ResolvedPublishSelection {
1421    requested_package: Option<String>,
1422    selected_packages: Vec<String>,
1423    included_prereqs: Vec<String>,
1424    required_closure: Vec<String>,
1425}
1426
1427#[derive(Debug, Clone)]
1428struct BuiltPublishPlan {
1429    requested_package: Option<String>,
1430    included_prereqs: Vec<String>,
1431    required_closure: Vec<String>,
1432    items: Vec<PublishPlanItem>,
1433    publish_order: Vec<String>,
1434}
1435
1436fn build_publish_plan(
1437    surface: &ReleaseSurface,
1438    visibility: &BTreeMap<String, bool>,
1439    selection: Option<&PublishSelection>,
1440) -> Result<BuiltPublishPlan, String> {
1441    let ordered_packages = topological_package_order(surface)?;
1442    let selected = resolve_selected_packages(surface, &ordered_packages, selection)?;
1443    let selected_set: BTreeSet<String> = selected.selected_packages.iter().cloned().collect();
1444    let mut available = visibility.clone();
1445    let mut items = Vec::new();
1446    let mut publish_order = Vec::new();
1447
1448    for package_name in ordered_packages {
1449        if !selected_set.contains(&package_name) {
1450            continue;
1451        }
1452        let package = surface
1453            .packages
1454            .iter()
1455            .find(|package| package.name == package_name)
1456            .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1457        let visible = visibility.get(&package.name).copied();
1458        let mut publish_needed = false;
1459        let mut blocked_by = Vec::new();
1460        let reason = if visible == Some(true) {
1461            "already visible on crates.io".to_string()
1462        } else if !package.publishable {
1463            "publish disabled in package metadata".to_string()
1464        } else if package.publish_excluded {
1465            "publish excluded by workspace release config".to_string()
1466        } else if !package.publish_missing_version_pins.is_empty() {
1467            format!(
1468                "missing version pins for {}",
1469                package.publish_missing_version_pins.join(", ")
1470            )
1471        } else {
1472            for dependency in &package.publish_internal_dependencies {
1473                if available.get(dependency).copied().unwrap_or(false) {
1474                    continue;
1475                }
1476                blocked_by.push(dependency.clone());
1477            }
1478            if blocked_by.is_empty() {
1479                publish_needed = true;
1480                publish_order.push(package.name.clone());
1481                available.insert(package.name.clone(), true);
1482                "publish required".to_string()
1483            } else {
1484                format!("waiting for {}", blocked_by.join(", "))
1485            }
1486        };
1487
1488        items.push(PublishPlanItem {
1489            package: package.name.clone(),
1490            manifest: package.manifest_relative.clone(),
1491            version: package.version.to_string(),
1492            publishable: package.publishable
1493                && !package.publish_excluded
1494                && package.publish_missing_version_pins.is_empty(),
1495            crates_io_visible: visible,
1496            publish_needed,
1497            blocked_by,
1498            reason,
1499        });
1500    }
1501
1502    Ok(BuiltPublishPlan {
1503        requested_package: selected.requested_package,
1504        included_prereqs: selected.included_prereqs,
1505        required_closure: selected.required_closure,
1506        items,
1507        publish_order,
1508    })
1509}
1510
1511fn resolve_selected_packages(
1512    surface: &ReleaseSurface,
1513    ordered_packages: &[String],
1514    selection: Option<&PublishSelection>,
1515) -> Result<ResolvedPublishSelection, String> {
1516    let Some(selection) = selection else {
1517        return Ok(ResolvedPublishSelection {
1518            requested_package: None,
1519            selected_packages: ordered_packages.to_vec(),
1520            included_prereqs: Vec::new(),
1521            required_closure: ordered_packages.to_vec(),
1522        });
1523    };
1524    if let Some(only) = selection.only.as_deref() {
1525        if !ordered_packages.iter().any(|package| package == only) {
1526            return Err(format!("Unknown package `{}` for `--only`.", only));
1527        }
1528        let required_closure = collect_publish_closure(surface, only, ordered_packages)?;
1529        let selected_packages = if selection.include_prereqs {
1530            required_closure.clone()
1531        } else {
1532            vec![only.to_string()]
1533        };
1534        let included_prereqs = required_closure
1535            .iter()
1536            .filter(|package| package.as_str() != only)
1537            .cloned()
1538            .collect::<Vec<_>>();
1539        return Ok(ResolvedPublishSelection {
1540            requested_package: Some(only.to_string()),
1541            selected_packages,
1542            included_prereqs: if selection.include_prereqs {
1543                included_prereqs
1544            } else {
1545                Vec::new()
1546            },
1547            required_closure,
1548        });
1549    }
1550    if let Some(from) = selection.from.as_deref() {
1551        let start = ordered_packages
1552            .iter()
1553            .position(|package| package == from)
1554            .ok_or_else(|| format!("Unknown package `{}` for `--from`.", from))?;
1555        return Ok(ResolvedPublishSelection {
1556            requested_package: None,
1557            selected_packages: ordered_packages[start..].to_vec(),
1558            included_prereqs: Vec::new(),
1559            required_closure: ordered_packages[start..].to_vec(),
1560        });
1561    }
1562    Ok(ResolvedPublishSelection {
1563        requested_package: None,
1564        selected_packages: ordered_packages.to_vec(),
1565        included_prereqs: Vec::new(),
1566        required_closure: ordered_packages.to_vec(),
1567    })
1568}
1569
1570fn collect_publish_closure(
1571    surface: &ReleaseSurface,
1572    root_package: &str,
1573    ordered_packages: &[String],
1574) -> Result<Vec<String>, String> {
1575    let mut by_name = BTreeMap::new();
1576    for package in &surface.packages {
1577        by_name.insert(package.name.as_str(), package);
1578    }
1579    let mut visited = BTreeSet::new();
1580    collect_publish_closure_visit(root_package, &by_name, &mut visited)?;
1581    Ok(ordered_packages
1582        .iter()
1583        .filter(|package| visited.contains(package.as_str()))
1584        .cloned()
1585        .collect())
1586}
1587
1588fn collect_publish_closure_visit<'a>(
1589    package_name: &'a str,
1590    by_name: &BTreeMap<&'a str, &'a ReleasePackage>,
1591    visited: &mut BTreeSet<&'a str>,
1592) -> Result<(), String> {
1593    if !visited.insert(package_name) {
1594        return Ok(());
1595    }
1596    let package = by_name
1597        .get(package_name)
1598        .ok_or_else(|| format!("Missing package `{}` in release surface.", package_name))?;
1599    for dependency in &package.publish_internal_dependencies {
1600        collect_publish_closure_visit(dependency, by_name, visited)?;
1601    }
1602    Ok(())
1603}
1604
1605fn collect_publish_blockers(items: &[PublishPlanItem]) -> Vec<String> {
1606    items
1607        .iter()
1608        .filter(|item| {
1609            item.crates_io_visible != Some(true)
1610                && (!item.blocked_by.is_empty() || !item.publishable)
1611        })
1612        .map(|item| {
1613            if !item.blocked_by.is_empty() {
1614                format!("{} blocked by {}", item.package, item.blocked_by.join(", "))
1615            } else {
1616                format!("{} {}", item.package, item.reason)
1617            }
1618        })
1619        .collect()
1620}
1621
1622fn render_workspace_publish_blockers(
1623    surface: &ReleaseSurface,
1624    plan: &BuiltPublishPlan,
1625    run_options: Option<&WorkspacePublishRunOptions>,
1626    blockers: &[String],
1627) -> String {
1628    let mut message = String::new();
1629    message.push_str("Workspace publish is blocked.\n");
1630    message.push_str(&format!("Repo: {}\n", surface.repo_root.display()));
1631    if let Some(requested_package) = plan.requested_package.as_deref() {
1632        message.push_str(&format!("Requested package: {}\n", requested_package));
1633    }
1634    if !plan.included_prereqs.is_empty() {
1635        message.push_str(&format!(
1636            "Auto-included prerequisites: {}\n",
1637            plan.included_prereqs.join(", ")
1638        ));
1639    }
1640    if !plan.required_closure.is_empty() {
1641        message.push_str(&format!(
1642            "Required publish order: {}\n",
1643            plan.required_closure.join(" -> ")
1644        ));
1645    }
1646    message.push_str("Blockers:\n");
1647    for blocker in blockers {
1648        message.push_str(&format!("- {}\n", blocker));
1649    }
1650    if let (Some(requested_package), Some(run_options)) =
1651        (plan.requested_package.as_deref(), run_options)
1652    {
1653        if !run_options.include_prereqs {
1654            message.push_str(&format!(
1655                "Rerun with prerequisites: xbp version workspace publish run --repo {} --only {} --include-prereqs{}\n",
1656                quote_argument(&surface.repo_root),
1657                requested_package,
1658                if run_options.allow_dirty {
1659                    " --allow-dirty"
1660                } else {
1661                    ""
1662                }
1663            ));
1664        }
1665    }
1666    message.trim_end().to_string()
1667}
1668
1669fn topological_package_order(surface: &ReleaseSurface) -> Result<Vec<String>, String> {
1670    let package_names = surface
1671        .packages
1672        .iter()
1673        .map(|package| package.name.clone())
1674        .collect::<BTreeSet<_>>();
1675    let order_overrides = surface
1676        .config
1677        .publish
1678        .order
1679        .iter()
1680        .enumerate()
1681        .map(|(index, name)| (name.clone(), index))
1682        .collect::<BTreeMap<_, _>>();
1683    let mut indegree = BTreeMap::new();
1684    let mut reverse = BTreeMap::<String, Vec<String>>::new();
1685    for package in &surface.packages {
1686        let deps = package
1687            .publish_internal_dependencies
1688            .iter()
1689            .filter(|name| package_names.contains(*name))
1690            .cloned()
1691            .collect::<Vec<_>>();
1692        indegree.insert(package.name.clone(), deps.len());
1693        for dep in deps {
1694            reverse.entry(dep).or_default().push(package.name.clone());
1695        }
1696    }
1697
1698    let mut queue = indegree
1699        .iter()
1700        .filter_map(|(name, degree)| (*degree == 0).then_some(name.clone()))
1701        .collect::<Vec<_>>();
1702    sort_package_names(&mut queue, &order_overrides);
1703
1704    let mut ordered = Vec::new();
1705    while let Some(name) = queue.first().cloned() {
1706        queue.remove(0);
1707        ordered.push(name.clone());
1708        if let Some(children) = reverse.get(&name) {
1709            for child in children {
1710                if let Some(entry) = indegree.get_mut(child) {
1711                    *entry -= 1;
1712                    if *entry == 0 {
1713                        queue.push(child.clone());
1714                    }
1715                }
1716            }
1717            sort_package_names(&mut queue, &order_overrides);
1718        }
1719    }
1720
1721    if ordered.len() != surface.packages.len() {
1722        return Err(
1723            "Workspace package graph contains a publish-relevant dependency cycle.".to_string(),
1724        );
1725    }
1726    Ok(ordered)
1727}
1728
1729fn sort_package_names(names: &mut [String], overrides: &BTreeMap<String, usize>) {
1730    names.sort_by(|a, b| {
1731        overrides
1732            .get(a)
1733            .copied()
1734            .unwrap_or(usize::MAX)
1735            .cmp(&overrides.get(b).copied().unwrap_or(usize::MAX))
1736            .then(a.cmp(b))
1737    });
1738}
1739
1740async fn collect_crates_io_visibility(
1741    surface: &ReleaseSurface,
1742) -> Result<BTreeMap<String, bool>, String> {
1743    let client = crates_io_client()?;
1744    let mut visibility = BTreeMap::new();
1745    for package in &surface.packages {
1746        if !package.publishable || package.publish_excluded {
1747            visibility.insert(package.name.clone(), false);
1748            continue;
1749        }
1750        visibility.insert(
1751            package.name.clone(),
1752            crates_io_has_exact_version(&client, &package.name, &package.version.to_string())
1753                .await?,
1754        );
1755    }
1756    Ok(visibility)
1757}
1758
1759async fn crates_io_has_exact_version(
1760    client: &reqwest::Client,
1761    package: &str,
1762    version: &str,
1763) -> Result<bool, String> {
1764    let url = format!("https://crates.io/api/v1/crates/{}/{}", package, version);
1765    let response = client
1766        .get(url)
1767        .send()
1768        .await
1769        .map_err(|e| format!("Failed crates.io lookup for {} {}: {}", package, version, e))?;
1770    if response.status() == reqwest::StatusCode::NOT_FOUND {
1771        return Ok(false);
1772    }
1773    if !response.status().is_success() {
1774        return Err(format!(
1775            "crates.io lookup for {} {} returned status {}",
1776            package,
1777            version,
1778            response.status()
1779        ));
1780    }
1781    Ok(true)
1782}
1783
1784async fn wait_for_crates_io_visibility(
1785    package: &str,
1786    version: &str,
1787    timeout_seconds: f64,
1788    poll_interval_seconds: f64,
1789) -> Result<(), String> {
1790    let timeout = Duration::from_secs_f64(timeout_seconds.max(1.0));
1791    let poll = Duration::from_secs_f64(poll_interval_seconds.max(0.5));
1792    let deadline = Instant::now() + timeout;
1793    let client = crates_io_client()?;
1794    loop {
1795        if crates_io_has_exact_version(&client, package, version).await? {
1796            return Ok(());
1797        }
1798        if Instant::now() >= deadline {
1799            return Err(format!(
1800                "{} {} was published, but did not become visible on crates.io within {:.0}s",
1801                package, version, timeout_seconds
1802            ));
1803        }
1804        sleep(poll).await;
1805    }
1806}
1807
1808fn crates_io_client() -> Result<reqwest::Client, String> {
1809    reqwest::Client::builder()
1810        .user_agent(format!("xbp/{}", env!("CARGO_PKG_VERSION")))
1811        .build()
1812        .map_err(|e| format!("Failed to build crates.io HTTP client: {}", e))
1813}
1814
1815fn enforce_clean_worktree(project_root: &Path) -> Result<(), String> {
1816    let Some(GitWorktreeState { is_dirty, .. }) = git_worktree_state(project_root)? else {
1817        return Ok(());
1818    };
1819    if is_dirty {
1820        return Err(
1821            "Workspace repo has uncommitted changes. Re-run with `--allow-dirty` to override."
1822                .to_string(),
1823        );
1824    }
1825    Ok(())
1826}
1827
1828fn resolve_expected_version(
1829    surface: &ReleaseSurface,
1830    explicit: Option<&str>,
1831) -> Result<Version, String> {
1832    if let Some(explicit) = explicit {
1833        return parse_version(explicit);
1834    }
1835    let root_package_name = surface.root_package_name.as_ref().ok_or_else(|| {
1836        "Workspace root has no package.version; pass `--version` explicitly.".to_string()
1837    })?;
1838    surface
1839        .packages
1840        .iter()
1841        .find(|package| &package.name == root_package_name)
1842        .map(|package| package.version.clone())
1843        .ok_or_else(|| "Could not resolve the root package version.".to_string())
1844}
1845
1846fn load_cargo_metadata(repo_root: &Path) -> Result<CargoMetadata, String> {
1847    let mut command = Command::new("cargo");
1848    command
1849        .current_dir(repo_root)
1850        .args(["metadata", "--format-version", "1", "--no-deps"]);
1851    load_cargo_metadata_command(&mut command)
1852}
1853
1854fn load_cargo_metadata_for_manifest(manifest_path: &Path) -> Result<CargoMetadata, String> {
1855    let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
1856    let mut command = Command::new("cargo");
1857    command
1858        .current_dir(manifest_dir)
1859        .arg("metadata")
1860        .arg("--format-version")
1861        .arg("1")
1862        .arg("--no-deps")
1863        .arg("--manifest-path")
1864        .arg(manifest_path);
1865    load_cargo_metadata_command(&mut command)
1866}
1867
1868fn load_cargo_metadata_command(command: &mut Command) -> Result<CargoMetadata, String> {
1869    if !command_exists("cargo") {
1870        return Err("`cargo` is required to inspect workspace metadata.".to_string());
1871    }
1872    let output = command
1873        .output()
1874        .map_err(|e| format!("Failed to run `cargo metadata`: {}", e))?;
1875    if !output.status.success() {
1876        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1877        return Err(format!("`cargo metadata` failed: {}", stderr));
1878    }
1879    serde_json::from_slice::<CargoMetadata>(&output.stdout)
1880        .map_err(|e| format!("Failed to parse cargo metadata JSON: {}", e))
1881}
1882
1883fn load_workspace_release_config(path: Option<&Path>) -> Result<WorkspaceReleaseConfig, String> {
1884    let Some(path) = path else {
1885        return Ok(WorkspaceReleaseConfig::default());
1886    };
1887    let content = fs::read_to_string(path)
1888        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1889    serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse {}: {}", path.display(), e))
1890}
1891
1892fn run_command_capture(
1893    mut command: Command,
1894    label: impl Into<String>,
1895) -> Result<ValidationCommandResult, String> {
1896    let label = label.into();
1897    let output = command
1898        .output()
1899        .map_err(|e| format!("Failed to run `{}`: {}", label, e))?;
1900    Ok(ValidationCommandResult {
1901        command: label,
1902        success: output.status.success(),
1903        exit_code: output.status.code(),
1904        stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
1905        warning: None,
1906    })
1907}
1908
1909fn read_metadata_version(path: &Path) -> Result<Option<String>, String> {
1910    let file_name = path
1911        .file_name()
1912        .and_then(|value| value.to_str())
1913        .unwrap_or_default();
1914    match file_name {
1915        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1916            let content = fs::read_to_string(path)
1917                .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1918            read_regex_version_from_content(&content, r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
1919        }
1920        _ => read_version_from_path(path),
1921    }
1922}
1923
1924fn write_metadata_version(path: &Path, version: &Version) -> Result<(), String> {
1925    let file_name = path
1926        .file_name()
1927        .and_then(|value| value.to_str())
1928        .unwrap_or_default();
1929    match file_name {
1930        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1931            let content = fs::read_to_string(path)
1932                .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1933            let regex = regex::Regex::new(r#"(?m)^\s{2}version:\s*([^\s]+)\s*$"#)
1934                .map_err(|e| format!("Failed to build OpenAPI regex: {}", e))?;
1935            let updated = regex
1936                .replace(&content, format!("  version: {}", version))
1937                .to_string();
1938            fs::write(path, updated)
1939                .map_err(|e| format!("Failed to write {}: {}", path.display(), e))
1940        }
1941        _ => write_version_to_path(path, version).map(|_| ()),
1942    }
1943}
1944
1945fn normalize_relative(repo_root: &Path, path: &Path) -> String {
1946    path.strip_prefix(repo_root)
1947        .unwrap_or(path)
1948        .to_string_lossy()
1949        .replace('\\', "/")
1950}
1951
1952fn display_path(path: &Path) -> String {
1953    path.to_string_lossy().to_string()
1954}
1955
1956fn same_path(left: &Path, right: &Path) -> bool {
1957    fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf())
1958        == fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
1959}
1960
1961fn quote_argument(path: &Path) -> String {
1962    let value = path.to_string_lossy();
1963    if value.contains(' ') {
1964        format!("\"{}\"", value)
1965    } else {
1966        value.to_string()
1967    }
1968}
1969
1970fn print_check_report(surface: &ReleaseSurface, report: &WorkspaceCheckReport) {
1971    println!("Workspace version check");
1972    println!("Repo: {}", surface.repo_root.display());
1973    println!("Expected version: {}", report.expected_version);
1974    if let Some(config_path) = &surface.config_path {
1975        println!(
1976            "Config: {}",
1977            normalize_relative(&surface.repo_root, config_path)
1978        );
1979    }
1980    println!(
1981        "Status: {}",
1982        if report.aligned {
1983            "aligned"
1984        } else {
1985            "drift detected"
1986        }
1987    );
1988    for entry in &report.drift {
1989        println!(
1990            "{} {} actual={} expected={}",
1991            entry.path,
1992            entry.field,
1993            entry.actual.as_deref().unwrap_or("<missing>"),
1994            entry.expected
1995        );
1996    }
1997}
1998
1999fn print_sync_report(surface: &ReleaseSurface, report: &WorkspaceSyncReport) {
2000    println!(
2001        "Workspace version {}",
2002        if report.write { "sync" } else { "sync preview" }
2003    );
2004    println!("Repo: {}", surface.repo_root.display());
2005    println!("Expected version: {}", report.expected_version);
2006    if report.edits.is_empty() {
2007        println!("No changes needed.");
2008        return;
2009    }
2010    println!("Files: {}", report.files_changed.join(", "));
2011    for edit in &report.edits {
2012        println!(
2013            "{} {} {} -> {}",
2014            edit.path,
2015            edit.field,
2016            edit.before.as_deref().unwrap_or("<missing>"),
2017            edit.after
2018        );
2019    }
2020}
2021
2022fn print_validation_report(surface: &ReleaseSurface, report: &WorkspaceValidateReport) {
2023    println!("Workspace validation");
2024    println!("Repo: {}", surface.repo_root.display());
2025    println!("Status: {}", if report.ok { "ok" } else { "failed" });
2026    for issue in &report.issues {
2027        println!(
2028            "{} {} actual={} expected={}",
2029            issue.path,
2030            issue.field,
2031            issue.actual.as_deref().unwrap_or("<missing>"),
2032            issue.expected
2033        );
2034    }
2035    for command in &report.commands {
2036        let status = if command.success {
2037            if command.warning.is_some() {
2038                "ok (with warning)"
2039            } else {
2040                "ok"
2041            }
2042        } else {
2043            "failed"
2044        };
2045        println!("{} [{}]", command.command, status);
2046        if let Some(warning) = command.warning.as_deref() {
2047            println!("warning: {}", warning);
2048        }
2049        if !command.stderr.is_empty() {
2050            println!("{}", command.stderr);
2051        }
2052    }
2053}
2054
2055fn print_publish_plan_report(surface: &ReleaseSurface, report: &WorkspacePublishPlanReport) {
2056    println!("Workspace publish plan");
2057    println!("Repo: {}", surface.repo_root.display());
2058    if let Some(requested_package) = report.requested_package.as_deref() {
2059        println!("Requested package: {}", requested_package);
2060    }
2061    if !report.included_prereqs.is_empty() {
2062        println!(
2063            "Auto-included prerequisites: {}",
2064            report.included_prereqs.join(", ")
2065        );
2066    }
2067    if !report.required_closure.is_empty() {
2068        println!("Required closure: {}", report.required_closure.join(" -> "));
2069    }
2070    println!(
2071        "Publish order: {}",
2072        if report.publish_order.is_empty() {
2073            "<none>".to_string()
2074        } else {
2075            report.publish_order.join(", ")
2076        }
2077    );
2078    for item in &report.packages {
2079        println!(
2080            "{} {} visible={} needed={} reason={}",
2081            item.package,
2082            item.version,
2083            item.crates_io_visible
2084                .map(|value| value.to_string())
2085                .unwrap_or_else(|| "n/a".to_string()),
2086            item.publish_needed,
2087            item.reason
2088        );
2089        if !item.blocked_by.is_empty() {
2090            println!("  blocked by {}", item.blocked_by.join(", "));
2091        }
2092    }
2093}
2094
2095fn print_publish_run_report(surface: &ReleaseSurface, report: &WorkspacePublishRunReport) {
2096    println!("Workspace publish run");
2097    println!("Repo: {}", surface.repo_root.display());
2098    println!("Dry run: {}", report.dry_run);
2099    if let Some(requested_package) = report.requested_package.as_deref() {
2100        println!("Requested package: {}", requested_package);
2101    }
2102    if !report.included_prereqs.is_empty() {
2103        println!(
2104            "Auto-included prerequisites: {}",
2105            report.included_prereqs.join(", ")
2106        );
2107    }
2108    if !report.required_closure.is_empty() {
2109        println!("Required closure: {}", report.required_closure.join(" -> "));
2110    }
2111    if !report.published.is_empty() {
2112        println!("Published: {}", report.published.join(", "));
2113    }
2114    if !report.skipped.is_empty() {
2115        println!("Skipped: {}", report.skipped.join("; "));
2116    }
2117    if !report.failed.is_empty() {
2118        println!("Failed: {}", report.failed.join("; "));
2119    }
2120}
2121
2122#[cfg(test)]
2123mod tests {
2124    use super::{
2125        apply_sync, build_publish_plan, collect_drift, discover_release_surface, PublishSelection,
2126    };
2127    use semver::Version;
2128    use std::collections::BTreeMap;
2129    use std::fs;
2130    use std::path::PathBuf;
2131    use std::time::{SystemTime, UNIX_EPOCH};
2132
2133    fn temp_dir(name: &str) -> PathBuf {
2134        let nanos = SystemTime::now()
2135            .duration_since(UNIX_EPOCH)
2136            .expect("time")
2137            .as_nanos();
2138        let dir = std::env::temp_dir().join(format!("xbp-workspace-release-{}-{}", name, nanos));
2139        fs::create_dir_all(&dir).expect("create temp dir");
2140        dir
2141    }
2142
2143    fn write_file(path: &PathBuf, content: &str) {
2144        if let Some(parent) = path.parent() {
2145            fs::create_dir_all(parent).expect("create parent");
2146        }
2147        fs::write(path, content).expect("write file");
2148    }
2149
2150    fn create_demo_workspace() -> PathBuf {
2151        let root = temp_dir("demo");
2152        write_file(
2153            &root.join("Cargo.toml"),
2154            r#"[package]
2155name = "athena_rs"
2156version = "3.16.4"
2157
2158[dependencies.alpha]
2159path = "crates/alpha"
2160version = "3.16.4"
2161
2162[dependencies.beta]
2163path = "crates/beta"
2164version = "3.16.4"
2165
2166[dependencies.athena-s3]
2167path = "crates/athena-s3"
2168version = "3.16.4"
2169
2170[workspace]
2171members = ["crates/alpha", "crates/beta", "crates/athena-s3"]
2172resolver = "2"
2173"#,
2174        );
2175        write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
2176        write_file(
2177            &root.join("README.md"),
2178            "# Athena\n\ncurrent version: `3.16.4`\n",
2179        );
2180        write_file(
2181            &root.join("openapi.yaml"),
2182            "openapi: 3.1.0\ninfo:\n  title: Athena\n  version: 3.16.4\n",
2183        );
2184        write_file(
2185            &root.join("Cargo.lock"),
2186            r#"version = 4
2187
2188[[package]]
2189name = "athena_rs"
2190version = "3.16.4"
2191
2192[[package]]
2193name = "alpha"
2194version = "3.16.4"
2195
2196[[package]]
2197name = "beta"
2198version = "3.16.4"
2199
2200[[package]]
2201name = "athena-s3"
2202version = "3.16.4"
2203"#,
2204        );
2205        write_file(
2206            &root.join("crates/alpha/Cargo.toml"),
2207            r#"[package]
2208name = "alpha"
2209version = "3.16.4"
2210
2211[dependencies]
2212beta = { path = "../beta", version = "3.16.4" }
2213athena-s3 = { path = "../athena-s3", version = "3.16.4" }
2214"#,
2215        );
2216        write_file(&root.join("crates/alpha/src/lib.rs"), "pub fn alpha() {}\n");
2217        write_file(
2218            &root.join("crates/beta/Cargo.toml"),
2219            r#"[package]
2220name = "beta"
2221version = "3.16.4"
2222"#,
2223        );
2224        write_file(&root.join("crates/beta/src/lib.rs"), "pub fn beta() {}\n");
2225        write_file(
2226            &root.join("crates/athena-s3/Cargo.toml"),
2227            r#"[package]
2228name = "athena-s3"
2229version = "3.16.4"
2230"#,
2231        );
2232        write_file(
2233            &root.join("crates/athena-s3/src/lib.rs"),
2234            "pub fn athena_s3() {}\n",
2235        );
2236        write_file(
2237            &root.join("crates/athena-backups/Cargo.toml"),
2238            r#"[package]
2239name = "athena-backups"
2240version = "3.16.0"
2241
2242[dependencies]
2243beta = { path = "../beta", version = "3.16.0" }
2244"#,
2245        );
2246        write_file(
2247            &root.join("crates/athena-backups/src/lib.rs"),
2248            "pub fn athena_backups() {}\n",
2249        );
2250        root
2251    }
2252
2253    fn create_workspace_dependency_demo_workspace() -> PathBuf {
2254        let root = temp_dir("workspace-deps");
2255        write_file(
2256            &root.join("Cargo.toml"),
2257            r#"[package]
2258name = "xbp"
2259version = "10.27.0"
2260
2261[dependencies]
2262xbp-providers.workspace = true
2263
2264[workspace]
2265members = ["crates/http", "crates/providers"]
2266resolver = "2"
2267
2268[workspace.dependencies]
2269xbp-http = { path = "crates/http", version = "0.1.0" }
2270xbp-providers = { path = "crates/providers", version = "0.1.0" }
2271"#,
2272        );
2273        write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
2274        write_file(
2275            &root.join("crates/http/Cargo.toml"),
2276            r#"[package]
2277name = "xbp-http"
2278version = "0.1.0"
2279"#,
2280        );
2281        write_file(&root.join("crates/http/src/lib.rs"), "pub fn http() {}\n");
2282        write_file(
2283            &root.join("crates/providers/Cargo.toml"),
2284            r#"[package]
2285name = "xbp-providers"
2286version = "0.1.0"
2287
2288[dependencies]
2289xbp-http.workspace = true
2290"#,
2291        );
2292        write_file(
2293            &root.join("crates/providers/src/lib.rs"),
2294            "pub fn providers() {}\n",
2295        );
2296        root
2297    }
2298
2299    #[test]
2300    fn discovery_uses_workspace_members_and_excludes_non_member_crates() {
2301        let root = create_demo_workspace();
2302        let surface = discover_release_surface(&root).expect("discover");
2303        let names = surface
2304            .packages
2305            .iter()
2306            .map(|package| package.name.clone())
2307            .collect::<Vec<_>>();
2308        assert!(names.contains(&"athena-s3".to_string()));
2309        assert!(!names.contains(&"athena-backups".to_string()));
2310    }
2311
2312    #[test]
2313    fn drift_reports_package_dependency_metadata_and_lock_mismatches() {
2314        let root = create_demo_workspace();
2315        write_file(
2316            &root.join("crates/alpha/Cargo.toml"),
2317            r#"[package]
2318name = "alpha"
2319version = "3.16.5"
2320
2321[dependencies]
2322beta = { path = "../beta", version = "3.16.4" }
2323athena-s3 = { path = "../athena-s3" }
2324"#,
2325        );
2326        let surface = discover_release_surface(&root).expect("discover");
2327        let expected = Version::new(3, 16, 4);
2328        let drift = collect_drift(&surface, &expected).expect("drift");
2329        assert!(drift.iter().any(
2330            |entry| entry.path == "crates/alpha/Cargo.toml" && entry.field == "package.version"
2331        ));
2332        assert!(
2333            drift
2334                .iter()
2335                .any(|entry| entry.field == "dependencies.athena-s3.version"
2336                    && entry.actual.is_none())
2337        );
2338    }
2339
2340    #[test]
2341    fn sync_preview_and_write_updates_workspace_surface() {
2342        let root = create_demo_workspace();
2343        let surface = discover_release_surface(&root).expect("discover");
2344        let expected = Version::new(3, 16, 5);
2345        let (preview, _) = apply_sync(&surface, &expected, false).expect("preview");
2346        assert!(!preview.is_empty());
2347
2348        let (written, files) = apply_sync(&surface, &expected, true).expect("write");
2349        assert!(!written.is_empty());
2350        assert!(files.contains(&"Cargo.toml".to_string()));
2351        let updated = fs::read_to_string(root.join("crates/alpha/Cargo.toml")).expect("read");
2352        assert!(updated.contains("version = \"3.16.5\""));
2353        assert!(updated.contains("athena-s3 = { path = \"../athena-s3\", version = \"3.16.5\" }"));
2354    }
2355
2356    #[test]
2357    fn publish_plan_orders_dependencies_before_dependents() {
2358        let root = create_demo_workspace();
2359        let surface = discover_release_surface(&root).expect("discover");
2360        let mut visibility = BTreeMap::new();
2361        visibility.insert("athena_rs".to_string(), false);
2362        visibility.insert("alpha".to_string(), false);
2363        visibility.insert("beta".to_string(), true);
2364        visibility.insert("athena-s3".to_string(), false);
2365        let plan = build_publish_plan(
2366            &surface,
2367            &visibility,
2368            Some(&PublishSelection {
2369                from: None,
2370                only: None,
2371                include_prereqs: false,
2372            }),
2373        )
2374        .expect("plan");
2375        let alpha_pos = plan
2376            .publish_order
2377            .iter()
2378            .position(|name| name == "alpha")
2379            .expect("alpha");
2380        let s3_pos = plan
2381            .publish_order
2382            .iter()
2383            .position(|name| name == "athena-s3")
2384            .expect("s3");
2385        assert!(s3_pos < alpha_pos);
2386        assert!(plan
2387            .items
2388            .iter()
2389            .any(|item| item.package == "athena_rs" && item.publish_needed));
2390    }
2391
2392    #[test]
2393    fn publish_plan_orders_workspace_dependencies_before_dependents() {
2394        let root = create_workspace_dependency_demo_workspace();
2395        let surface = discover_release_surface(&root).expect("discover");
2396        let mut visibility = BTreeMap::new();
2397        visibility.insert("xbp".to_string(), false);
2398        visibility.insert("xbp-http".to_string(), false);
2399        visibility.insert("xbp-providers".to_string(), false);
2400
2401        let plan = build_publish_plan(
2402            &surface,
2403            &visibility,
2404            Some(&PublishSelection {
2405                from: None,
2406                only: None,
2407                include_prereqs: false,
2408            }),
2409        )
2410        .expect("plan");
2411
2412        let xbp_pos = plan
2413            .publish_order
2414            .iter()
2415            .position(|name| name == "xbp")
2416            .expect("xbp");
2417        let providers_pos = plan
2418            .publish_order
2419            .iter()
2420            .position(|name| name == "xbp-providers")
2421            .expect("providers");
2422        let http_pos = plan
2423            .publish_order
2424            .iter()
2425            .position(|name| name == "xbp-http")
2426            .expect("http");
2427
2428        assert!(http_pos < providers_pos);
2429        assert!(providers_pos < xbp_pos);
2430    }
2431
2432    #[test]
2433    fn publish_plan_only_with_prereqs_limits_to_minimal_closure() {
2434        let root = create_demo_workspace();
2435        let surface = discover_release_surface(&root).expect("discover");
2436        let mut visibility = BTreeMap::new();
2437        visibility.insert("athena_rs".to_string(), false);
2438        visibility.insert("alpha".to_string(), false);
2439        visibility.insert("beta".to_string(), false);
2440        visibility.insert("athena-s3".to_string(), false);
2441
2442        let plan = build_publish_plan(
2443            &surface,
2444            &visibility,
2445            Some(&PublishSelection {
2446                from: None,
2447                only: Some("alpha".to_string()),
2448                include_prereqs: true,
2449            }),
2450        )
2451        .expect("plan");
2452
2453        let package_names = plan
2454            .items
2455            .iter()
2456            .map(|item| item.package.as_str())
2457            .collect::<Vec<_>>();
2458        assert_eq!(package_names.len(), 3);
2459        assert!(!package_names.contains(&"athena_rs"));
2460        assert_eq!(plan.required_closure.len(), 3);
2461        assert!(plan.required_closure.contains(&"alpha".to_string()));
2462        assert!(plan.required_closure.contains(&"beta".to_string()));
2463        assert!(plan.required_closure.contains(&"athena-s3".to_string()));
2464        assert_eq!(plan.included_prereqs.len(), 2);
2465        assert!(plan.included_prereqs.contains(&"beta".to_string()));
2466        assert!(plan.included_prereqs.contains(&"athena-s3".to_string()));
2467        assert_eq!(plan.publish_order.len(), 3);
2468
2469        let alpha_pos = plan
2470            .publish_order
2471            .iter()
2472            .position(|package| package == "alpha")
2473            .expect("alpha in publish order");
2474        let beta_pos = plan
2475            .publish_order
2476            .iter()
2477            .position(|package| package == "beta")
2478            .expect("beta in publish order");
2479        let s3_pos = plan
2480            .publish_order
2481            .iter()
2482            .position(|package| package == "athena-s3")
2483            .expect("athena-s3 in publish order");
2484        assert!(beta_pos < alpha_pos);
2485        assert!(s3_pos < alpha_pos);
2486    }
2487
2488    #[test]
2489    fn publish_plan_only_without_prereqs_reports_blocked_package() {
2490        let root = create_demo_workspace();
2491        let surface = discover_release_surface(&root).expect("discover");
2492        let mut visibility = BTreeMap::new();
2493        visibility.insert("athena_rs".to_string(), false);
2494        visibility.insert("alpha".to_string(), false);
2495        visibility.insert("beta".to_string(), false);
2496        visibility.insert("athena-s3".to_string(), false);
2497
2498        let plan = build_publish_plan(
2499            &surface,
2500            &visibility,
2501            Some(&PublishSelection {
2502                from: None,
2503                only: Some("alpha".to_string()),
2504                include_prereqs: false,
2505            }),
2506        )
2507        .expect("plan");
2508
2509        assert_eq!(plan.required_closure.len(), 3);
2510        assert!(plan.required_closure.contains(&"alpha".to_string()));
2511        assert!(plan.required_closure.contains(&"beta".to_string()));
2512        assert!(plan.required_closure.contains(&"athena-s3".to_string()));
2513        assert!(plan.included_prereqs.is_empty());
2514        assert!(plan.publish_order.is_empty());
2515        assert_eq!(plan.items.len(), 1);
2516        assert_eq!(plan.items[0].package, "alpha");
2517        assert_eq!(plan.items[0].blocked_by.len(), 2);
2518        assert!(plan.items[0].blocked_by.contains(&"beta".to_string()));
2519        assert!(plan.items[0].blocked_by.contains(&"athena-s3".to_string()));
2520    }
2521
2522    #[test]
2523    fn publish_plan_ignores_dev_dependencies_for_publish_blockers() {
2524        let root = temp_dir("publish-dev-deps");
2525        write_file(
2526            &root.join("Cargo.toml"),
2527            r#"[package]
2528name = "demo-root"
2529version = "1.0.0"
2530
2531[workspace]
2532members = ["crates/app", "crates/dev-helper"]
2533resolver = "2"
2534"#,
2535        );
2536        write_file(&root.join("src/lib.rs"), "pub fn root() {}\n");
2537        write_file(
2538            &root.join("crates/app/Cargo.toml"),
2539            r#"[package]
2540name = "demo-app"
2541version = "1.0.0"
2542
2543[dev-dependencies]
2544dev-helper = { path = "../dev-helper", version = "1.0.0" }
2545"#,
2546        );
2547        write_file(&root.join("crates/app/src/lib.rs"), "pub fn app() {}\n");
2548        write_file(
2549            &root.join("crates/dev-helper/Cargo.toml"),
2550            r#"[package]
2551name = "dev-helper"
2552version = "1.0.0"
2553"#,
2554        );
2555        write_file(
2556            &root.join("crates/dev-helper/src/lib.rs"),
2557            "pub fn helper() {}\n",
2558        );
2559
2560        let surface = discover_release_surface(&root).expect("discover");
2561        let mut visibility = BTreeMap::new();
2562        visibility.insert("demo-root".to_string(), false);
2563        visibility.insert("demo-app".to_string(), false);
2564        visibility.insert("dev-helper".to_string(), false);
2565
2566        let plan = build_publish_plan(
2567            &surface,
2568            &visibility,
2569            Some(&PublishSelection {
2570                from: None,
2571                only: Some("demo-app".to_string()),
2572                include_prereqs: false,
2573            }),
2574        )
2575        .expect("plan");
2576
2577        assert_eq!(plan.required_closure, vec!["demo-app"]);
2578        assert_eq!(plan.publish_order, vec!["demo-app"]);
2579        assert!(plan.items[0].blocked_by.is_empty());
2580    }
2581}