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