Skip to main content

xbp_cli/commands/
publish.rs

1use crate::cli::ui::Loader;
2use crate::commands::service::load_xbp_config_with_root;
3use crate::commands::version::{
4    resolve_manifest_workspace_publish, ManifestWorkspacePublishResolution,
5};
6use crate::config::{resolve_crates_token, resolve_npm_token};
7use crate::logging::{log_file_only, log_process_output_file_only, LogLevel};
8use crate::strategies::{PublishProjectConfig, PublishTargetConfig};
9use crate::utils::{
10    command_exists, resolve_cargo_package_version_required, resolve_env_placeholders,
11};
12use colored::Colorize;
13use serde_json::Value as JsonValue;
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::process::Stdio;
18use std::time::{Duration, Instant};
19use tokio::io::{stderr, stdout, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
20use tokio::process::Command;
21use tokio::time::sleep;
22use tokio::time::MissedTickBehavior;
23use toml::Value as TomlValue;
24
25const WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT: Duration = Duration::from_secs(180);
26const WORKSPACE_PUBLISH_VISIBILITY_POLL: Duration = Duration::from_secs(5);
27
28#[derive(Debug, Clone)]
29pub struct PublishCommandOptions {
30    pub dry_run: bool,
31    pub allow_dirty: bool,
32    pub force: bool,
33    pub include_prereqs: bool,
34    pub target: Option<String>,
35    pub manifest_path: Option<PathBuf>,
36    pub expected_version: Option<String>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum PublishKind {
41    Npm,
42    Crates,
43}
44
45#[derive(Debug, Clone)]
46struct PublishWorkflow {
47    kind: PublishKind,
48    config: PublishTargetConfig,
49}
50
51#[derive(Debug, Clone)]
52struct PreparedPublishTarget {
53    kind: PublishKind,
54    package_name: String,
55    version: String,
56    working_directory: PathBuf,
57    manifest_path: PathBuf,
58    preflight_commands: Vec<String>,
59    publish_command: String,
60    use_wsl: bool,
61    wsl_distribution: Option<String>,
62    token: Option<String>,
63    workspace_publish: Option<ManifestWorkspacePublishResolution>,
64}
65
66#[derive(Debug, Clone)]
67struct PublishPlanResult {
68    prepared: Vec<PreparedPublishTarget>,
69}
70
71struct PublishProgress<'a> {
72    loader: &'a Loader,
73    step_prefix: Option<String>,
74}
75
76impl<'a> PublishProgress<'a> {
77    fn standalone(loader: &'a Loader) -> Self {
78        Self {
79            loader,
80            step_prefix: None,
81        }
82    }
83
84    fn prefixed(loader: &'a Loader, step_prefix: String) -> Self {
85        Self {
86            loader,
87            step_prefix: Some(step_prefix),
88        }
89    }
90
91    fn update(&self, step: usize, total: usize, detail: &str) {
92        self.loader.update(&render_publish_status(
93            self.step_prefix.as_deref(),
94            step,
95            total,
96            detail,
97        ));
98    }
99
100    fn log(&self, message: &str) {
101        self.loader.log(message);
102    }
103}
104
105#[derive(Debug, Clone, Copy)]
106enum CommandStage {
107    Preflight,
108    Publish,
109}
110
111impl CommandStage {
112    fn label(self) -> &'static str {
113        match self {
114            Self::Preflight => "preflight",
115            Self::Publish => "publish",
116        }
117    }
118}
119
120pub async fn run_publish_command(options: PublishCommandOptions) -> Result<(), String> {
121    let dry_run = options.dry_run;
122    let loader = Loader::start(if options.dry_run {
123        "Planning publish workflow"
124    } else {
125        "Publishing configured packages"
126    });
127    let progress = PublishProgress::standalone(&loader);
128
129    let result = run_publish_workflow(options, &progress).await;
130
131    match result {
132        Ok(plan) => {
133            if dry_run {
134                loader.success_with("Publish plan ready");
135            } else {
136                loader.success_with("Publish complete");
137            }
138            print_publish_completion(&plan, dry_run);
139            Ok(())
140        }
141        Err(error) => {
142            loader.fail(&error);
143            Err(error)
144        }
145    }
146}
147
148pub(crate) async fn run_publish_command_with_progress_prefix(
149    options: PublishCommandOptions,
150    loader: &Loader,
151    step_prefix: String,
152) -> Result<(), String> {
153    let dry_run = options.dry_run;
154    let progress = PublishProgress::prefixed(loader, step_prefix);
155    let plan = run_publish_workflow(options, &progress).await?;
156    print_publish_completion(&plan, dry_run);
157    Ok(())
158}
159
160async fn run_publish_workflow(
161    options: PublishCommandOptions,
162    progress: &PublishProgress<'_>,
163) -> Result<PublishPlanResult, String> {
164    let result: Result<PublishPlanResult, String> = async {
165        let current_dir =
166            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
167        let (project_root, config) = load_xbp_config_with_root().await?;
168        let mut prepared =
169            prepare_publish_targets(&project_root, &current_dir, &config.publish, &options)
170                .await?;
171        let target_total = prepared.len();
172
173        progress.update(1, 3, "Validating publish prerequisites");
174        if !options.allow_dirty {
175            ensure_clean_git_worktree(&project_root)?;
176        }
177
178        progress.update(
179            2,
180            3,
181            if options.force {
182                "Checking registry state and skipping preflight commands (--force)"
183            } else {
184                "Checking registry state and running preflight commands"
185            },
186        );
187        for (target_index, target) in prepared.iter().enumerate() {
188            progress.update(
189                2,
190                3,
191                &format!(
192                    "Checking registry target {}/{}: {}",
193                    target_index + 1,
194                    target_total,
195                    target.identity()
196                ),
197            );
198            progress.log(&format!(
199                "Target {}/{}: {} (cwd: {}, runner: {})",
200                target_index + 1,
201                target_total,
202                target.identity(),
203                target.working_directory.display(),
204                target.runner_label()
205            ));
206            if registry_version_exists(target).await? {
207                return Err(format!(
208                    "{} {}@{} is already published on {}. Bump the version first or choose a different target.",
209                    "Version already exists:".bright_red().bold(),
210                    target.package_name,
211                    target.version,
212                    target.kind.label()
213                ));
214            }
215
216            if options.force {
217                if !target.preflight_commands.is_empty() {
218                    progress.log(&format!(
219                        "Skipping {} configured preflight command(s) for {} because --force was supplied.",
220                        target.preflight_commands.len(),
221                        target.identity()
222                    ));
223                }
224                continue;
225            }
226
227            for (command_index, command) in target.preflight_commands.iter().enumerate() {
228                progress.update(
229                    2,
230                    3,
231                    &format!(
232                        "Running preflight {}/{} for {}",
233                        command_index + 1,
234                        target.preflight_commands.len(),
235                        target.identity()
236                    ),
237                );
238                let result = run_configured_command(
239                    command,
240                    command,
241                    target,
242                    None,
243                    Some(progress),
244                    CommandStage::Preflight,
245                )
246                .await?;
247                if !result.success {
248                    return Err(format!(
249                        "Preflight command failed for {}: `{}`\n{}",
250                        target.package_name,
251                        command,
252                        result.output_detail()
253                    ));
254                }
255            }
256        }
257
258        for (target_index, target) in prepared.iter_mut().enumerate() {
259            progress.update(
260                2,
261                3,
262                &format!(
263                    "Resolving publish closure {}/{}: {}",
264                    target_index + 1,
265                    target_total,
266                    target.identity()
267                ),
268            );
269            maybe_attach_workspace_publish_resolution(target, &options, progress).await?;
270        }
271
272        if options.dry_run {
273            print_publish_plan(&prepared, options.force);
274            return Ok(PublishPlanResult { prepared });
275        }
276
277        progress.update(3, 3, "Publishing packages");
278        for (target_index, target) in prepared.iter().enumerate() {
279            progress.update(
280                3,
281                3,
282                &format!(
283                    "Publishing target {}/{}: {}",
284                    target_index + 1,
285                    target_total,
286                    target.identity()
287                ),
288            );
289            if let Some(workspace_publish) = target.workspace_publish.as_ref() {
290                run_workspace_publish_target(target, workspace_publish, &options, Some(progress))
291                    .await?;
292            } else {
293                run_publish_target(target, &options, Some(progress)).await?;
294            }
295        }
296
297        Ok(PublishPlanResult { prepared })
298    }
299    .await;
300    result
301}
302
303async fn prepare_publish_targets(
304    project_root: &Path,
305    current_dir: &Path,
306    config: &Option<PublishProjectConfig>,
307    options: &PublishCommandOptions,
308) -> Result<Vec<PreparedPublishTarget>, String> {
309    let publish = config.as_ref().ok_or_else(|| {
310        "No `publish` section was found in the current XBP config. Configure one with `xbp config npm setup-release` or `xbp config crates setup-release`.".to_string()
311    })?;
312
313    let workflows = select_publish_workflows(publish, options.target.as_deref())?;
314    if workflows.is_empty() {
315        return Err("No enabled publish targets matched the requested filter.".to_string());
316    }
317
318    let manifest_override = options
319        .manifest_path
320        .as_deref()
321        .map(|path| resolve_requested_manifest_path(current_dir, path));
322    let selected_workflows = if let Some(requested_manifest) = manifest_override.as_deref() {
323        let matched = workflows
324            .iter()
325            .filter(|workflow| {
326                workflow_targets_manifest(project_root, workflow, requested_manifest)
327            })
328            .cloned()
329            .collect::<Vec<_>>();
330        if !matched.is_empty() {
331            matched
332        } else if workflows.len() == 1 {
333            workflows.clone()
334        } else {
335            return Err(format!(
336                "No enabled publish target in `.xbp/xbp.yaml` matched manifest path `{}`. Use `--target` to narrow the provider or update the configured `publish.<provider>.manifest_path`.",
337                requested_manifest.display()
338            ));
339        }
340    } else {
341        workflows
342    };
343
344    let mut prepared = Vec::new();
345    let manifest_override = manifest_override
346        .as_deref()
347        .filter(|_| selected_workflows.len() == 1);
348    for workflow in selected_workflows {
349        prepared.push(prepare_publish_target(
350            project_root,
351            workflow,
352            options,
353            manifest_override,
354        )?);
355    }
356    Ok(prepared)
357}
358
359fn select_publish_workflows(
360    config: &PublishProjectConfig,
361    target: Option<&str>,
362) -> Result<Vec<PublishWorkflow>, String> {
363    let mut workflows = Vec::new();
364
365    match normalize_target_filter(target)? {
366        None => {
367            if let Some(npm) = config.npm.clone().filter(is_enabled_target) {
368                workflows.push(PublishWorkflow {
369                    kind: PublishKind::Npm,
370                    config: npm,
371                });
372            }
373            if let Some(crates) = config.crates.clone().filter(is_enabled_target) {
374                workflows.push(PublishWorkflow {
375                    kind: PublishKind::Crates,
376                    config: crates,
377                });
378            }
379        }
380        Some(PublishKind::Npm) => {
381            let Some(npm) = config.npm.clone().filter(is_enabled_target) else {
382                return Err(
383                    "No enabled npm publish target is configured in `.xbp/xbp.yaml`.".to_string(),
384                );
385            };
386            workflows.push(PublishWorkflow {
387                kind: PublishKind::Npm,
388                config: npm,
389            });
390        }
391        Some(PublishKind::Crates) => {
392            let Some(crates) = config.crates.clone().filter(is_enabled_target) else {
393                return Err(
394                    "No enabled crates publish target is configured in `.xbp/xbp.yaml`."
395                        .to_string(),
396                );
397            };
398            workflows.push(PublishWorkflow {
399                kind: PublishKind::Crates,
400                config: crates,
401            });
402        }
403    }
404
405    Ok(workflows)
406}
407
408fn normalize_target_filter(target: Option<&str>) -> Result<Option<PublishKind>, String> {
409    match target.map(|value| value.trim().to_ascii_lowercase()) {
410        None => Ok(None),
411        Some(value) if value.is_empty() => Ok(None),
412        Some(value) if value == "npm" => Ok(Some(PublishKind::Npm)),
413        Some(value) if value == "crates" || value == "crates.io" => Ok(Some(PublishKind::Crates)),
414        Some(value) => Err(format!(
415            "Unsupported publish target `{}`. Use `npm` or `crates`.",
416            value
417        )),
418    }
419}
420
421fn is_enabled_target(config: &PublishTargetConfig) -> bool {
422    config.enabled.unwrap_or(true)
423}
424
425fn prepare_publish_target(
426    project_root: &Path,
427    workflow: PublishWorkflow,
428    options: &PublishCommandOptions,
429    manifest_override: Option<&Path>,
430) -> Result<PreparedPublishTarget, String> {
431    let configured_working_directory = workflow
432        .config
433        .working_directory
434        .clone()
435        .map(PathBuf::from)
436        .unwrap_or_else(|| project_root.to_path_buf());
437
438    let manifest_path = manifest_override
439        .map(PathBuf::from)
440        .or_else(|| workflow.config.manifest_path.clone().map(PathBuf::from))
441        .unwrap_or_else(|| default_manifest_path(&configured_working_directory, workflow.kind));
442    let working_directory = manifest_override
443        .and_then(Path::parent)
444        .map(Path::to_path_buf)
445        .unwrap_or(configured_working_directory);
446
447    if !manifest_path.exists() {
448        return Err(format!(
449            "Configured manifest path does not exist for {}: {}",
450            workflow.kind.label(),
451            manifest_path.display()
452        ));
453    }
454
455    let package_name = if manifest_override.is_some() {
456        detect_package_name(workflow.kind, &manifest_path).unwrap_or_else(|| "package".to_string())
457    } else {
458        workflow
459            .config
460            .package_name
461            .clone()
462            .filter(|value| !value.trim().is_empty())
463            .unwrap_or_else(|| {
464                detect_package_name(workflow.kind, &manifest_path)
465                    .unwrap_or_else(|| "package".to_string())
466            })
467    };
468    let version = detect_package_version(workflow.kind, &manifest_path)?;
469    if let Some(expected_version) = &options.expected_version {
470        if expected_version.trim() != version {
471            return Err(format!(
472                "Configured {} publish target resolved version {} but release target is {}.",
473                workflow.kind.label(),
474                version,
475                expected_version.trim()
476            ));
477        }
478    }
479
480    let token = resolve_publish_token(
481        project_root,
482        workflow.kind,
483        workflow.config.token.as_deref(),
484    );
485    let preflight_commands = workflow
486        .config
487        .preflight_commands
488        .iter()
489        .map(|value| value.trim().to_string())
490        .filter(|value| !value.is_empty())
491        .collect::<Vec<_>>();
492    let publish_command = workflow
493        .config
494        .publish_command
495        .clone()
496        .filter(|value| !value.trim().is_empty())
497        .unwrap_or_else(|| default_publish_command(workflow.kind, &workflow.config));
498
499    Ok(PreparedPublishTarget {
500        kind: workflow.kind,
501        package_name,
502        version,
503        working_directory,
504        manifest_path,
505        preflight_commands,
506        publish_command,
507        use_wsl: workflow.config.use_wsl.unwrap_or(false),
508        wsl_distribution: workflow.config.wsl_distribution.clone(),
509        token,
510        workspace_publish: None,
511    })
512}
513
514fn default_manifest_path(working_directory: &Path, kind: PublishKind) -> PathBuf {
515    match kind {
516        PublishKind::Npm => working_directory.join("package.json"),
517        PublishKind::Crates => working_directory.join("Cargo.toml"),
518    }
519}
520
521fn detect_package_name(kind: PublishKind, manifest_path: &Path) -> Option<String> {
522    match kind {
523        PublishKind::Npm => {
524            let content = fs::read_to_string(manifest_path).ok()?;
525            let json: JsonValue = serde_json::from_str(&content).ok()?;
526            json.get("name")
527                .and_then(|value| value.as_str())
528                .map(str::trim)
529                .filter(|value| !value.is_empty())
530                .map(str::to_string)
531        }
532        PublishKind::Crates => {
533            let content = fs::read_to_string(manifest_path).ok()?;
534            let toml: TomlValue = toml::from_str(&content).ok()?;
535            toml.get("package")
536                .and_then(|package| package.get("name"))
537                .and_then(|value| value.as_str())
538                .map(str::trim)
539                .filter(|value| !value.is_empty())
540                .map(str::to_string)
541        }
542    }
543}
544
545fn detect_package_version(kind: PublishKind, manifest_path: &Path) -> Result<String, String> {
546    match kind {
547        PublishKind::Npm => {
548            let content = fs::read_to_string(manifest_path).map_err(|e| {
549                format!(
550                    "Failed to read npm manifest {}: {}",
551                    manifest_path.display(),
552                    e
553                )
554            })?;
555            let json: JsonValue = serde_json::from_str(&content).map_err(|e| {
556                format!(
557                    "Failed to parse npm manifest {}: {}",
558                    manifest_path.display(),
559                    e
560                )
561            })?;
562            json.get("version")
563                .and_then(|value| value.as_str())
564                .map(str::trim)
565                .filter(|value| !value.is_empty())
566                .map(str::to_string)
567                .ok_or_else(|| {
568                    format!(
569                        "Could not resolve package.version from {}.",
570                        manifest_path.display()
571                    )
572                })
573        }
574        PublishKind::Crates => resolve_cargo_package_version_required(manifest_path),
575    }
576}
577
578fn resolve_publish_token(
579    project_root: &Path,
580    kind: PublishKind,
581    configured: Option<&str>,
582) -> Option<String> {
583    let token_from_config =
584        configured.and_then(|raw| resolve_publish_placeholder(project_root, raw));
585    if token_from_config.is_some() {
586        return token_from_config;
587    }
588
589    match kind {
590        PublishKind::Npm => resolve_npm_token(),
591        PublishKind::Crates => resolve_crates_token(),
592    }
593}
594
595fn resolve_publish_placeholder(project_root: &Path, raw: &str) -> Option<String> {
596    let trimmed = raw.trim();
597    if trimmed.is_empty() {
598        return None;
599    }
600
601    let mut env_map = HashMap::new();
602    env_map.insert("TOKEN".to_string(), trimmed.to_string());
603    let resolved = resolve_env_placeholders(project_root, &env_map)
604        .remove("TOKEN")
605        .unwrap_or_else(|| trimmed.to_string());
606
607    if looks_like_placeholder(&resolved) {
608        None
609    } else {
610        Some(resolved)
611    }
612}
613
614fn looks_like_placeholder(value: &str) -> bool {
615    let trimmed = value.trim();
616    trimmed.starts_with("${") && trimmed.ends_with('}') || trimmed.starts_with('$')
617}
618
619fn default_publish_command(kind: PublishKind, config: &PublishTargetConfig) -> String {
620    match kind {
621        PublishKind::Npm => {
622            if let Some(access) = config
623                .access
624                .as_deref()
625                .map(str::trim)
626                .filter(|value| !value.is_empty())
627            {
628                format!("npm publish --access {}", access)
629            } else {
630                "npm publish".to_string()
631            }
632        }
633        PublishKind::Crates => "cargo publish".to_string(),
634    }
635}
636
637fn ensure_clean_git_worktree(project_root: &Path) -> Result<(), String> {
638    if !command_exists("git") {
639        return Ok(());
640    }
641
642    let output = std::process::Command::new("git")
643        .current_dir(project_root)
644        .args(["status", "--porcelain=v1", "--untracked-files=all"])
645        .output()
646        .map_err(|e| format!("Failed to run `git status`: {}", e))?;
647
648    if !output.status.success() {
649        return Ok(());
650    }
651
652    let stdout = String::from_utf8_lossy(&output.stdout);
653    let entries = stdout
654        .lines()
655        .map(str::trim)
656        .filter(|line| !line.is_empty())
657        .take(8)
658        .collect::<Vec<_>>();
659
660    if entries.is_empty() {
661        return Ok(());
662    }
663
664    Err(format!(
665        "Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
666        entries.join(", ")
667    ))
668}
669
670async fn registry_version_exists(target: &PreparedPublishTarget) -> Result<bool, String> {
671    let client = reqwest::Client::new();
672    match target.kind {
673        PublishKind::Npm => {
674            let encoded = target.package_name.replace('/', "%2f");
675            let url = format!("https://registry.npmjs.org/{}/{}", encoded, target.version);
676            let response = client
677                .get(url)
678                .send()
679                .await
680                .map_err(|e| format!("Failed to query npm registry: {}", e))?;
681            Ok(response.status().is_success())
682        }
683        PublishKind::Crates => {
684            let url = format!(
685                "https://crates.io/api/v1/crates/{}/{}",
686                target.package_name, target.version
687            );
688            let response = client
689                .get(url)
690                .send()
691                .await
692                .map_err(|e| format!("Failed to query crates.io: {}", e))?;
693            Ok(response.status().is_success())
694        }
695    }
696}
697
698async fn maybe_attach_workspace_publish_resolution(
699    target: &mut PreparedPublishTarget,
700    options: &PublishCommandOptions,
701    progress: &PublishProgress<'_>,
702) -> Result<(), String> {
703    if target.kind != PublishKind::Crates {
704        return Ok(());
705    }
706
707    let resolution =
708        resolve_manifest_workspace_publish(&target.manifest_path, options.include_prereqs).await?;
709    let missing_prereqs = collect_missing_workspace_prereqs(&resolution);
710    let needs_expansion = resolution.publish_order.len() > 1;
711    if missing_prereqs.is_empty() && !needs_expansion {
712        let blockers = collect_workspace_publish_blockers(&resolution);
713        if !blockers.is_empty() {
714            return Err(render_workspace_publish_blocker_error(
715                target,
716                &resolution,
717                &blockers,
718                options,
719            ));
720        }
721        return Ok(());
722    }
723
724    if !supports_workspace_auto_resolution(&target.publish_command) {
725        if delegates_workspace_publish(&target.publish_command) {
726            progress.log(&format!(
727                "Using configured workspace publish delegate for {} ({} package closure); running publish command as-is.",
728                target.identity(),
729                resolution.publish_order.len()
730            ));
731            return Ok(());
732        }
733        return Err(render_custom_workspace_publish_error(target, &resolution));
734    }
735
736    if !missing_prereqs.is_empty() && !options.include_prereqs {
737        return Err(render_workspace_prereq_required_error(
738            target,
739            &resolution,
740            &missing_prereqs,
741            options,
742        ));
743    }
744
745    let blockers = collect_workspace_publish_blockers(&resolution);
746    if !blockers.is_empty() {
747        return Err(render_workspace_publish_blocker_error(
748            target,
749            &resolution,
750            &blockers,
751            options,
752        ));
753    }
754
755    if !resolution.included_prereqs.is_empty() {
756        progress.log(&format!(
757            "Resolved workspace prerequisite closure for {}: {}",
758            target.identity(),
759            resolution.required_closure.join(" -> ")
760        ));
761    }
762    target.workspace_publish = Some(resolution);
763    Ok(())
764}
765
766fn collect_workspace_publish_blockers(
767    resolution: &ManifestWorkspacePublishResolution,
768) -> Vec<String> {
769    resolution
770        .packages
771        .iter()
772        .filter(|item| {
773            item.crates_io_visible != Some(true)
774                && (!item.blocked_by.is_empty() || !item.publishable)
775        })
776        .map(|item| {
777            if !item.blocked_by.is_empty() {
778                format!("{} blocked by {}", item.package, item.blocked_by.join(", "))
779            } else {
780                format!("{} {}", item.package, item.reason)
781            }
782        })
783        .collect()
784}
785
786fn collect_missing_workspace_prereqs(
787    resolution: &ManifestWorkspacePublishResolution,
788) -> Vec<String> {
789    let mut missing = Vec::new();
790    for item in &resolution.packages {
791        for dependency in &item.blocked_by {
792            if !missing.contains(dependency) {
793                missing.push(dependency.clone());
794            }
795        }
796    }
797    missing
798}
799
800fn render_workspace_publish_blocker_error(
801    target: &PreparedPublishTarget,
802    resolution: &ManifestWorkspacePublishResolution,
803    blockers: &[String],
804    options: &PublishCommandOptions,
805) -> String {
806    let mut message = String::new();
807    message.push_str(&format!(
808        "Workspace publish is blocked for {}.\n",
809        target.identity()
810    ));
811    message.push_str(&format!(
812        "Requested package: {}\n",
813        resolution.requested_package
814    ));
815    if !resolution.included_prereqs.is_empty() {
816        message.push_str(&format!(
817            "Auto-included prerequisites: {}\n",
818            resolution.included_prereqs.join(", ")
819        ));
820    }
821    if !resolution.required_closure.is_empty() {
822        message.push_str(&format!(
823            "Required publish order: {}\n",
824            resolution.required_closure.join(" -> ")
825        ));
826    }
827    message.push_str("Blockers:\n");
828    for blocker in blockers {
829        message.push_str(&format!("- {}\n", blocker));
830    }
831    message.push_str("Inspect with:\n");
832    message.push_str(&format!(
833        "  {}\n",
834        render_workspace_publish_plan_command(resolution, options)
835    ));
836    message.trim_end().to_string()
837}
838
839fn render_workspace_prereq_required_error(
840    target: &PreparedPublishTarget,
841    resolution: &ManifestWorkspacePublishResolution,
842    missing_prereqs: &[String],
843    options: &PublishCommandOptions,
844) -> String {
845    let mut message = String::new();
846    message.push_str(&format!(
847        "Workspace prerequisite closure detected for {}.\n",
848        target.identity()
849    ));
850    message.push_str(&format!(
851        "Requested package: {}\n",
852        resolution.requested_package
853    ));
854    if !resolution.required_closure.is_empty() {
855        message.push_str(&format!(
856            "Required publish order: {}\n",
857            resolution.required_closure.join(" -> ")
858        ));
859    }
860    if !missing_prereqs.is_empty() {
861        message.push_str(&format!(
862            "Missing internal prerequisites: {}\n",
863            missing_prereqs.join(", ")
864        ));
865    }
866    message.push_str("Rerun with:\n");
867    message.push_str(&format!(
868        "  {}\n",
869        render_publish_include_prereqs_command(target, options)
870    ));
871    message.push_str("Or inspect with:\n");
872    message.push_str(&format!(
873        "  {}\n",
874        render_workspace_publish_plan_command(resolution, options)
875    ));
876    message.trim_end().to_string()
877}
878
879fn render_custom_workspace_publish_error(
880    target: &PreparedPublishTarget,
881    resolution: &ManifestWorkspacePublishResolution,
882) -> String {
883    let mut message = String::new();
884    message.push_str(&format!(
885        "Workspace closure detected for {}, but the configured crates publish command cannot be safely expanded.\n",
886        target.identity()
887    ));
888    message.push_str(&format!(
889        "Configured publish command: {}\n",
890        target.publish_command
891    ));
892    message.push_str(&format!(
893        "Requested package: {}\n",
894        resolution.requested_package
895    ));
896    if !resolution.required_closure.is_empty() {
897        message.push_str(&format!(
898            "Required publish order: {}\n",
899            resolution.required_closure.join(" -> ")
900        ));
901    }
902    message.push_str("Use the workspace publish workflow instead:\n");
903    message.push_str(&format!(
904        "  xbp version workspace publish plan --repo {} --only {} --include-prereqs\n",
905        quote_path_for_message(&resolution.workspace_root),
906        resolution.requested_package
907    ));
908    message.push_str(&format!(
909        "  xbp version workspace publish run --repo {} --only {} --include-prereqs",
910        quote_path_for_message(&resolution.workspace_root),
911        resolution.requested_package
912    ));
913    message
914}
915
916fn delegates_workspace_publish(command: &str) -> bool {
917    let normalized = command.to_ascii_lowercase();
918    normalized.contains("publish_workspace.py")
919        || normalized.contains("version workspace publish run")
920}
921
922fn supports_workspace_auto_resolution(command: &str) -> bool {
923    let normalized = command.trim();
924    if normalized.is_empty() {
925        return false;
926    }
927    if normalized.contains("&&")
928        || normalized.contains("||")
929        || normalized.contains(';')
930        || normalized.contains('|')
931        || normalized.contains('\n')
932        || normalized.contains('\r')
933        || normalized.contains('`')
934    {
935        return false;
936    }
937    if normalized.contains("--manifest-path") {
938        return false;
939    }
940    normalized == "cargo publish"
941        || normalized.starts_with("cargo publish ")
942        || normalized == "cargo.exe publish"
943        || normalized.starts_with("cargo.exe publish ")
944}
945
946async fn run_workspace_publish_target(
947    target: &PreparedPublishTarget,
948    resolution: &ManifestWorkspacePublishResolution,
949    options: &PublishCommandOptions,
950    progress: Option<&PublishProgress<'_>>,
951) -> Result<(), String> {
952    if let Some(progress) = progress {
953        progress.log(&format!(
954            "Publishing workspace closure for {} in order: {}",
955            target.identity(),
956            resolution.required_closure.join(" -> ")
957        ));
958    }
959    for publish_target in &resolution.publish_order {
960        let mut step_target = target.clone();
961        step_target.package_name = publish_target.package.clone();
962        step_target.version = publish_target.version.clone();
963        step_target.manifest_path = publish_target.manifest_path.clone();
964        step_target.workspace_publish = None;
965        run_publish_target(&step_target, options, progress).await?;
966        wait_for_registry_version(
967            &step_target,
968            WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT,
969            WORKSPACE_PUBLISH_VISIBILITY_POLL,
970        )
971        .await?;
972    }
973    Ok(())
974}
975
976async fn wait_for_registry_version(
977    target: &PreparedPublishTarget,
978    timeout: Duration,
979    poll: Duration,
980) -> Result<(), String> {
981    let deadline = Instant::now() + timeout;
982    loop {
983        if registry_version_exists(target).await? {
984            return Ok(());
985        }
986        if Instant::now() >= deadline {
987            return Err(format!(
988                "{} {}@{} was published, but did not become visible on {} within {}s.",
989                "Timed out waiting for registry visibility:"
990                    .bright_red()
991                    .bold(),
992                target.package_name,
993                target.version,
994                target.kind.label(),
995                timeout.as_secs()
996            ));
997        }
998        sleep(poll).await;
999    }
1000}
1001
1002fn render_publish_include_prereqs_command(
1003    target: &PreparedPublishTarget,
1004    options: &PublishCommandOptions,
1005) -> String {
1006    let mut parts = vec!["xbp publish".to_string(), "--target crates".to_string()];
1007    if options.allow_dirty {
1008        parts.push("--allow-dirty".to_string());
1009    }
1010    if options.force {
1011        parts.push("--force".to_string());
1012    }
1013    parts.push("--include-prereqs".to_string());
1014    parts.push(format!(
1015        "--manifest-path {}",
1016        quote_path_for_message(&target.manifest_path)
1017    ));
1018    parts.join(" ")
1019}
1020
1021fn render_workspace_publish_plan_command(
1022    resolution: &ManifestWorkspacePublishResolution,
1023    options: &PublishCommandOptions,
1024) -> String {
1025    let mut parts = vec![
1026        "xbp version workspace publish plan".to_string(),
1027        format!(
1028            "--repo {}",
1029            quote_path_for_message(&resolution.workspace_root)
1030        ),
1031        format!("--only {}", resolution.requested_package),
1032    ];
1033    if options.include_prereqs || resolution.required_closure.len() > 1 {
1034        parts.push("--include-prereqs".to_string());
1035    }
1036    parts.join(" ")
1037}
1038
1039fn quote_path_for_message(path: &Path) -> String {
1040    let value = path.to_string_lossy();
1041    if value.contains(' ') {
1042        format!("\"{}\"", value)
1043    } else {
1044        value.to_string()
1045    }
1046}
1047
1048async fn run_publish_target(
1049    target: &PreparedPublishTarget,
1050    options: &PublishCommandOptions,
1051    progress: Option<&PublishProgress<'_>>,
1052) -> Result<(), String> {
1053    match target.kind {
1054        PublishKind::Npm => {
1055            let token = target.token.clone().ok_or_else(|| {
1056                "No npm token resolved. Set `publish.npm.token: ${NPM_TOKEN}` in `.xbp/xbp.yaml`, add it to `.env.local`, or run `xbp config npm set-key`.".to_string()
1057            })?;
1058            let npmrc = TemporaryNpmrc::create()?;
1059            let mut extra_env = HashMap::new();
1060            extra_env.insert("NPM_TOKEN".to_string(), token);
1061            extra_env.insert(
1062                "NPM_CONFIG_USERCONFIG".to_string(),
1063                npmrc.path.to_string_lossy().to_string(),
1064            );
1065
1066            let result = run_configured_command(
1067                &target.publish_command,
1068                &target.publish_command,
1069                target,
1070                Some(&extra_env),
1071                progress,
1072                CommandStage::Publish,
1073            )
1074            .await?;
1075            if !result.success {
1076                return Err(format!(
1077                    "npm publish failed for {}@{}.\n{}",
1078                    target.package_name,
1079                    target.version,
1080                    result.output_detail()
1081                ));
1082            }
1083            Ok(())
1084        }
1085        PublishKind::Crates => {
1086            let token = target.token.clone().ok_or_else(|| {
1087                "No crates.io token resolved. Set `publish.crates.token: ${CARGO_REGISTRY_TOKEN}` in `.xbp/xbp.yaml`, add it to `.env.local`, or run `xbp config crates set-key`.".to_string()
1088            })?;
1089
1090            let uses_workspace_delegate = delegates_workspace_publish(&target.publish_command);
1091            let publish_display_command =
1092                append_allow_dirty_arg(&render_publish_command(target), options.allow_dirty);
1093            let (publish_command, extra_env) = if uses_workspace_delegate {
1094                let mut env = HashMap::new();
1095                env.insert("CARGO_REGISTRY_TOKEN".to_string(), token.clone());
1096                env.insert("CARGO_REGISTRIES_CRATES_IO_TOKEN".to_string(), token);
1097                (publish_display_command.clone(), Some(env))
1098            } else if publish_display_command.contains("--token") {
1099                (publish_display_command.clone(), None)
1100            } else {
1101                (
1102                    format!(
1103                        "{} --token {}",
1104                        publish_display_command,
1105                        quote_for_shell(&token, target.use_wsl)
1106                    ),
1107                    None,
1108                )
1109            };
1110            let result = run_configured_command(
1111                &publish_command,
1112                &publish_display_command,
1113                target,
1114                extra_env.as_ref(),
1115                progress,
1116                CommandStage::Publish,
1117            )
1118            .await?;
1119            if !result.success {
1120                let action = if uses_workspace_delegate {
1121                    "workspace publish command failed"
1122                } else {
1123                    "cargo publish failed"
1124                };
1125                return Err(format!(
1126                    "{} for {}@{}.\n{}",
1127                    action,
1128                    target.package_name,
1129                    target.version,
1130                    result.output_detail()
1131                ));
1132            }
1133            Ok(())
1134        }
1135    }
1136}
1137
1138async fn run_configured_command(
1139    command_line: &str,
1140    display_command: &str,
1141    target: &PreparedPublishTarget,
1142    extra_env: Option<&HashMap<String, String>>,
1143    progress: Option<&PublishProgress<'_>>,
1144    stage: CommandStage,
1145) -> Result<CommandResult, String> {
1146    if let Some(progress) = progress {
1147        progress.log(&format!(
1148            "Running {} for {} via {}: {}",
1149            stage.label(),
1150            target.identity(),
1151            target.runner_label(),
1152            display_command
1153        ));
1154    }
1155
1156    let effective_command_line = if target.use_wsl {
1157        extra_env
1158            .map(|env| prepend_shell_exports(command_line, env))
1159            .unwrap_or_else(|| command_line.to_string())
1160    } else {
1161        command_line.to_string()
1162    };
1163
1164    let mut command = build_shell_command(
1165        &effective_command_line,
1166        &target.working_directory,
1167        target.use_wsl,
1168        target.wsl_distribution.as_deref(),
1169    )?;
1170    if !target.use_wsl {
1171        if let Some(extra_env) = extra_env {
1172            command.envs(extra_env);
1173        }
1174    }
1175    command.stdout(Stdio::piped());
1176    command.stderr(Stdio::piped());
1177
1178    let mut child = command
1179        .spawn()
1180        .map_err(|e| format!("Failed to run `{}`: {}", command_line, e))?;
1181    let child_stdout = child
1182        .stdout
1183        .take()
1184        .ok_or_else(|| format!("Failed to capture stdout for `{}`.", command_line))?;
1185    let child_stderr = child
1186        .stderr
1187        .take()
1188        .ok_or_else(|| format!("Failed to capture stderr for `{}`.", command_line))?;
1189
1190    let stdout_capture =
1191        tokio::spawn(async move { mirror_command_stream(child_stdout, stdout()).await });
1192    let stderr_capture =
1193        tokio::spawn(async move { mirror_command_stream(child_stderr, stderr()).await });
1194    let started_at = Instant::now();
1195    let status = if let Some(progress) = progress {
1196        let mut wait = Box::pin(child.wait());
1197        let mut heartbeat = tokio::time::interval(Duration::from_secs(20));
1198        heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay);
1199        heartbeat.tick().await;
1200        loop {
1201            tokio::select! {
1202                status = &mut wait => break status,
1203                _ = heartbeat.tick() => {
1204                    progress.log(&format!(
1205                        "Still running {} for {} via {} (elapsed {}): {}",
1206                        stage.label(),
1207                        target.identity(),
1208                        target.runner_label(),
1209                        format_elapsed(started_at.elapsed()),
1210                        display_command
1211                    ));
1212                }
1213            }
1214        }
1215    } else {
1216        child.wait().await
1217    };
1218    let status = status.map_err(|e| format!("Failed to wait for `{}`: {}", command_line, e))?;
1219    let stdout = stdout_capture.await.map_err(|e| {
1220        format!(
1221            "Failed to join stdout capture for `{}`: {}",
1222            command_line, e
1223        )
1224    })??;
1225    let stderr = stderr_capture.await.map_err(|e| {
1226        format!(
1227            "Failed to join stderr capture for `{}`: {}",
1228            command_line, e
1229        )
1230    })??;
1231
1232    if let Some(progress) = progress {
1233        progress.log(&format!(
1234            "Completed {} for {} in {}.",
1235            stage.label(),
1236            target.identity(),
1237            format_elapsed(started_at.elapsed())
1238        ));
1239    }
1240
1241    let result = CommandResult {
1242        success: status.success(),
1243        exit_code: status.code(),
1244        stdout,
1245        stderr,
1246    };
1247    persist_command_transcript(
1248        stage,
1249        target,
1250        display_command,
1251        started_at.elapsed(),
1252        &result,
1253    )
1254    .await;
1255
1256    Ok(result)
1257}
1258
1259async fn mirror_command_stream<R, W>(mut reader: R, mut writer: W) -> Result<String, String>
1260where
1261    R: AsyncRead + Unpin,
1262    W: AsyncWrite + Unpin,
1263{
1264    let mut buffer = [0_u8; 8192];
1265    let mut captured = Vec::new();
1266
1267    loop {
1268        let read_len = reader
1269            .read(&mut buffer)
1270            .await
1271            .map_err(|e| format!("Failed to read command output: {e}"))?;
1272        if read_len == 0 {
1273            break;
1274        }
1275        writer
1276            .write_all(&buffer[..read_len])
1277            .await
1278            .map_err(|e| format!("Failed to write command output: {e}"))?;
1279        writer
1280            .flush()
1281            .await
1282            .map_err(|e| format!("Failed to flush command output: {e}"))?;
1283        captured.extend_from_slice(&buffer[..read_len]);
1284    }
1285
1286    Ok(String::from_utf8_lossy(&captured).trim_end().to_string())
1287}
1288
1289fn build_shell_command(
1290    command_line: &str,
1291    working_directory: &Path,
1292    use_wsl: bool,
1293    wsl_distribution: Option<&str>,
1294) -> Result<Command, String> {
1295    #[cfg(target_os = "windows")]
1296    {
1297        if use_wsl {
1298            if !command_exists("wsl.exe") {
1299                return Err(
1300                    "WSL was requested for this publish target, but `wsl.exe` is not available."
1301                        .to_string(),
1302                );
1303            }
1304            let mut command = Command::new("wsl.exe");
1305            if let Some(distribution) = wsl_distribution
1306                .map(str::trim)
1307                .filter(|value| !value.is_empty())
1308            {
1309                command.args(["-d", distribution]);
1310            }
1311            let wsl_dir = windows_path_to_wsl(working_directory)?;
1312            let script = format!("cd {} && {}", quote_for_shell(&wsl_dir, true), command_line);
1313            command.args(["sh", "-lc", &script]);
1314            return Ok(command);
1315        }
1316
1317        let mut command = Command::new("cmd");
1318        command
1319            .current_dir(working_directory)
1320            .args(["/C", command_line]);
1321        Ok(command)
1322    }
1323
1324    #[cfg(not(target_os = "windows"))]
1325    {
1326        let _ = use_wsl;
1327        let _ = wsl_distribution;
1328        let mut command = Command::new("sh");
1329        command
1330            .current_dir(working_directory)
1331            .args(["-lc", command_line]);
1332        Ok(command)
1333    }
1334}
1335
1336#[cfg(target_os = "windows")]
1337fn windows_path_to_wsl(path: &Path) -> Result<String, String> {
1338    let rendered = path.to_string_lossy().replace('\\', "/");
1339    let mut chars = rendered.chars();
1340    let drive = chars
1341        .next()
1342        .ok_or_else(|| format!("Could not convert {} to a WSL path.", path.display()))?;
1343    if chars.next() != Some(':') {
1344        return Err(format!(
1345            "Could not convert {} to a WSL path.",
1346            path.display()
1347        ));
1348    }
1349    let remainder = chars.as_str().trim_start_matches('/');
1350    Ok(format!("/mnt/{}/{}", drive.to_ascii_lowercase(), remainder))
1351}
1352
1353fn render_publish_status(
1354    step_prefix: Option<&str>,
1355    step: usize,
1356    total: usize,
1357    detail: &str,
1358) -> String {
1359    match step_prefix {
1360        Some(prefix) => format!("{} {}", prefix, detail),
1361        None => format!("[{}/{}] {}", step, total, detail),
1362    }
1363}
1364
1365fn print_publish_completion(plan: &PublishPlanResult, dry_run: bool) {
1366    if dry_run {
1367        return;
1368    }
1369
1370    for target in &plan.prepared {
1371        if let Some(workspace_publish) = target.workspace_publish.as_ref() {
1372            for publish_target in &workspace_publish.publish_order {
1373                println!(
1374                    "{} {}@{} via {}",
1375                    "Published".bright_green().bold(),
1376                    publish_target.package.bright_white(),
1377                    publish_target.version.bright_white(),
1378                    target.kind.label()
1379                );
1380            }
1381            continue;
1382        }
1383        println!(
1384            "{} {}@{} via {}",
1385            "Published".bright_green().bold(),
1386            target.package_name.bright_white(),
1387            target.version.bright_white(),
1388            target.kind.label()
1389        );
1390    }
1391}
1392
1393fn format_elapsed(duration: Duration) -> String {
1394    let seconds = duration.as_secs();
1395    if seconds == 0 {
1396        "<1s".to_string()
1397    } else if seconds < 60 {
1398        format!("{}s", seconds)
1399    } else {
1400        format!("{}m{}s", seconds / 60, seconds % 60)
1401    }
1402}
1403
1404fn print_publish_plan(prepared: &[PreparedPublishTarget], force: bool) {
1405    println!("{}", "Publish plan".bright_cyan().bold());
1406    for target in prepared {
1407        println!(
1408            "  {} {}@{}",
1409            target.kind.label().bright_white().bold(),
1410            target.package_name,
1411            target.version
1412        );
1413        println!("    cwd: {}", target.working_directory.display());
1414        println!("    manifest: {}", target.manifest_path.display());
1415        if let Some(preflight_line) = render_preflight_plan(&target.preflight_commands, force) {
1416            println!("    preflight: {}", preflight_line);
1417        }
1418        println!("    publish: {}", render_publish_command(target));
1419        if let Some(workspace_publish) = target.workspace_publish.as_ref() {
1420            println!("    requested: {}", workspace_publish.requested_package);
1421            println!(
1422                "    required closure: {}",
1423                workspace_publish.required_closure.join(" -> ")
1424            );
1425            if !workspace_publish.included_prereqs.is_empty() {
1426                println!(
1427                    "    auto-prereqs: {}",
1428                    workspace_publish.included_prereqs.join(", ")
1429                );
1430            }
1431            if !workspace_publish.publish_order.is_empty() {
1432                println!(
1433                    "    expanded publish: {}",
1434                    workspace_publish
1435                        .publish_order
1436                        .iter()
1437                        .map(|item| format!("{}@{}", item.package, item.version))
1438                        .collect::<Vec<_>>()
1439                        .join(", ")
1440                );
1441            }
1442        }
1443        if target.use_wsl {
1444            println!(
1445                "    runner: WSL{}",
1446                target
1447                    .wsl_distribution
1448                    .as_deref()
1449                    .map(|value| format!(" ({})", value))
1450                    .unwrap_or_default()
1451            );
1452        }
1453    }
1454}
1455
1456fn render_preflight_plan(preflight_commands: &[String], force: bool) -> Option<String> {
1457    if preflight_commands.is_empty() {
1458        return None;
1459    }
1460
1461    let joined = preflight_commands.join(" && ");
1462    if force {
1463        Some(format!("skipped via --force (configured: {})", joined))
1464    } else {
1465        Some(joined)
1466    }
1467}
1468
1469async fn persist_command_transcript(
1470    stage: CommandStage,
1471    target: &PreparedPublishTarget,
1472    display_command: &str,
1473    elapsed: Duration,
1474    result: &CommandResult,
1475) {
1476    let process_name = render_process_log_name(stage, target, display_command);
1477    let summary = render_process_log_summary(target, result);
1478    let level = if result.success {
1479        LogLevel::Info
1480    } else {
1481        LogLevel::Warning
1482    };
1483
1484    let _ = log_file_only(
1485        level,
1486        "publish",
1487        &format!("Completed {}", process_name),
1488        Some(&summary),
1489        Some(elapsed.as_millis() as u64),
1490    )
1491    .await;
1492    let _ = log_process_output_file_only("publish", &process_name, &result.stdout, &result.stderr)
1493        .await;
1494}
1495
1496fn render_process_log_name(
1497    stage: CommandStage,
1498    target: &PreparedPublishTarget,
1499    display_command: &str,
1500) -> String {
1501    format!(
1502        "{} {} command `{}`",
1503        stage.label(),
1504        target.identity(),
1505        display_command
1506    )
1507}
1508
1509fn render_process_log_summary(target: &PreparedPublishTarget, result: &CommandResult) -> String {
1510    format!(
1511        "runner={} cwd={} success={} exit_code={}",
1512        target.runner_label(),
1513        target.working_directory.display(),
1514        result.success,
1515        result
1516            .exit_code
1517            .map(|value| value.to_string())
1518            .unwrap_or_else(|| "signal".to_string())
1519    )
1520}
1521
1522struct CommandResult {
1523    success: bool,
1524    exit_code: Option<i32>,
1525    stdout: String,
1526    stderr: String,
1527}
1528
1529impl CommandResult {
1530    fn output_detail(&self) -> String {
1531        match (self.stdout.trim().is_empty(), self.stderr.trim().is_empty()) {
1532            (false, false) => format!("stdout:\n{}\n\nstderr:\n{}", self.stdout, self.stderr),
1533            (false, true) => format!("stdout:\n{}", self.stdout),
1534            (true, false) => format!("stderr:\n{}", self.stderr),
1535            (true, true) => "Command produced no output.".to_string(),
1536        }
1537    }
1538}
1539
1540struct TemporaryNpmrc {
1541    path: PathBuf,
1542}
1543
1544impl TemporaryNpmrc {
1545    fn create() -> Result<Self, String> {
1546        let path = std::env::temp_dir().join(format!("xbp-npmrc-{}.ini", uuid::Uuid::new_v4()));
1547        fs::write(&path, "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n").map_err(|e| {
1548            format!(
1549                "Failed to create temporary .npmrc {}: {}",
1550                path.display(),
1551                e
1552            )
1553        })?;
1554        Ok(Self { path })
1555    }
1556}
1557
1558impl Drop for TemporaryNpmrc {
1559    fn drop(&mut self) {
1560        let _ = fs::remove_file(&self.path);
1561    }
1562}
1563
1564impl PublishKind {
1565    fn label(self) -> &'static str {
1566        match self {
1567            Self::Npm => "npm",
1568            Self::Crates => "crates.io",
1569        }
1570    }
1571}
1572
1573impl PreparedPublishTarget {
1574    fn identity(&self) -> String {
1575        format!(
1576            "{} {}@{}",
1577            self.kind.label(),
1578            self.package_name,
1579            self.version
1580        )
1581    }
1582
1583    fn runner_label(&self) -> String {
1584        if self.use_wsl {
1585            self.wsl_distribution
1586                .as_deref()
1587                .map(str::trim)
1588                .filter(|value| !value.is_empty())
1589                .map(|value| format!("WSL ({})", value))
1590                .unwrap_or_else(|| "WSL".to_string())
1591        } else if cfg!(target_os = "windows") {
1592            "Windows shell".to_string()
1593        } else {
1594            "local shell".to_string()
1595        }
1596    }
1597}
1598
1599fn workflow_targets_manifest(
1600    project_root: &Path,
1601    workflow: &PublishWorkflow,
1602    requested_manifest: &Path,
1603) -> bool {
1604    let working_directory = workflow
1605        .config
1606        .working_directory
1607        .clone()
1608        .map(PathBuf::from)
1609        .unwrap_or_else(|| project_root.to_path_buf());
1610    let manifest_path = workflow
1611        .config
1612        .manifest_path
1613        .clone()
1614        .map(PathBuf::from)
1615        .unwrap_or_else(|| default_manifest_path(&working_directory, workflow.kind));
1616    paths_match(&manifest_path, requested_manifest)
1617}
1618
1619fn render_publish_command(target: &PreparedPublishTarget) -> String {
1620    match target.kind {
1621        PublishKind::Npm => target.publish_command.clone(),
1622        PublishKind::Crates => {
1623            if delegates_workspace_publish(&target.publish_command) {
1624                return target.publish_command.clone();
1625            }
1626            append_manifest_path_arg(
1627                &target.publish_command,
1628                &target.manifest_path,
1629                target.use_wsl,
1630            )
1631        }
1632    }
1633}
1634
1635fn append_manifest_path_arg(
1636    command_line: &str,
1637    manifest_path: &Path,
1638    use_wsl: bool,
1639) -> String {
1640    if command_line.contains("--manifest-path") {
1641        return command_line.to_string();
1642    }
1643
1644    let rendered_path = render_manifest_path_for_runner(manifest_path, use_wsl);
1645    format!(
1646        "{} --manifest-path {}",
1647        command_line,
1648        quote_for_shell(&rendered_path, use_wsl)
1649    )
1650}
1651
1652fn render_manifest_path_for_runner(manifest_path: &Path, use_wsl: bool) -> String {
1653    #[cfg(target_os = "windows")]
1654    if use_wsl {
1655        return windows_path_to_wsl(manifest_path).unwrap_or_else(|_| {
1656            manifest_path.to_string_lossy().replace('\\', "/")
1657        });
1658    }
1659
1660    manifest_path.to_string_lossy().into_owned()
1661}
1662
1663fn is_cargo_publish_command(command: &str) -> bool {
1664    let normalized = command.trim().to_ascii_lowercase();
1665    normalized == "cargo publish"
1666        || normalized.starts_with("cargo publish ")
1667        || normalized == "cargo.exe publish"
1668        || normalized.starts_with("cargo.exe publish ")
1669}
1670
1671fn append_allow_dirty_arg(command_line: &str, allow_dirty: bool) -> String {
1672    if !allow_dirty || !is_cargo_publish_command(command_line) {
1673        return command_line.to_string();
1674    }
1675    if command_line.contains("--allow-dirty") {
1676        return command_line.to_string();
1677    }
1678    format!("{} --allow-dirty", command_line.trim_end())
1679}
1680
1681fn resolve_requested_manifest_path(current_dir: &Path, requested_path: &Path) -> PathBuf {
1682    let candidate = if requested_path.is_absolute() {
1683        requested_path.to_path_buf()
1684    } else {
1685        current_dir.join(requested_path)
1686    };
1687    normalize_windows_verbatim_path(fs::canonicalize(&candidate).unwrap_or(candidate))
1688}
1689
1690fn paths_match(left: &Path, right: &Path) -> bool {
1691    fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf())
1692        == fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
1693}
1694
1695fn normalize_windows_verbatim_path(path: PathBuf) -> PathBuf {
1696    PathBuf::from(strip_windows_verbatim_prefix(&path.to_string_lossy()))
1697}
1698
1699fn strip_windows_verbatim_prefix(input: &str) -> &str {
1700    input.strip_prefix(r"\\?\").unwrap_or(input)
1701}
1702
1703fn prepend_shell_exports(command_line: &str, env: &HashMap<String, String>) -> String {
1704    let exports = env
1705        .iter()
1706        .map(|(key, value)| format!("export {}={}", key, quote_for_shell(value, true)))
1707        .collect::<Vec<_>>()
1708        .join(" && ");
1709    if exports.is_empty() {
1710        command_line.to_string()
1711    } else {
1712        format!("{exports} && {command_line}")
1713    }
1714}
1715
1716fn quote_for_shell(value: &str, use_posix_rules: bool) -> String {
1717    if use_posix_rules {
1718        format!("'{}'", value.replace('\'', "'\"'\"'"))
1719    } else {
1720        format!("\"{}\"", value.replace('"', "\\\""))
1721    }
1722}
1723
1724#[cfg(test)]
1725mod tests {
1726    use super::{
1727        append_manifest_path_arg, looks_like_placeholder, normalize_target_filter,
1728        prepare_publish_targets, render_preflight_plan, render_process_log_name,
1729        render_process_log_summary, render_publish_command, render_publish_include_prereqs_command,
1730        delegates_workspace_publish, prepend_shell_exports, render_publish_status,
1731        run_configured_command,
1732        supports_workspace_auto_resolution,
1733        CommandResult, CommandStage, PreparedPublishTarget, PublishCommandOptions, PublishKind,
1734    };
1735    use crate::strategies::{PublishProjectConfig, PublishTargetConfig};
1736    use std::collections::HashMap;
1737    use std::fs;
1738    use std::path::{Path, PathBuf};
1739
1740    #[test]
1741    fn target_filter_accepts_supported_aliases() {
1742        assert_eq!(
1743            normalize_target_filter(Some("npm")).expect("npm filter"),
1744            Some(PublishKind::Npm)
1745        );
1746        assert_eq!(
1747            normalize_target_filter(Some("crates.io")).expect("crates filter"),
1748            Some(PublishKind::Crates)
1749        );
1750    }
1751
1752    #[test]
1753    fn placeholder_detection_handles_env_style_tokens() {
1754        assert!(looks_like_placeholder("${NPM_TOKEN}"));
1755        assert!(looks_like_placeholder("$CARGO_REGISTRY_TOKEN"));
1756        assert!(!looks_like_placeholder("plain-token"));
1757    }
1758
1759    #[test]
1760    fn prefixed_progress_reuses_parent_release_step() {
1761        assert_eq!(
1762            render_publish_status(
1763                Some("[2/9]"),
1764                2,
1765                3,
1766                "Running preflight for crates.io xbp@1.2.3"
1767            ),
1768            "[2/9] Running preflight for crates.io xbp@1.2.3"
1769        );
1770        assert_eq!(
1771            render_publish_status(None, 2, 3, "Running preflight for crates.io xbp@1.2.3"),
1772            "[2/3] Running preflight for crates.io xbp@1.2.3"
1773        );
1774    }
1775
1776    #[tokio::test]
1777    async fn run_configured_command_captures_stdout_and_stderr() {
1778        let target = PreparedPublishTarget {
1779            kind: PublishKind::Crates,
1780            package_name: "fixture".to_string(),
1781            version: "0.1.0".to_string(),
1782            working_directory: std::env::temp_dir(),
1783            manifest_path: PathBuf::from("Cargo.toml"),
1784            preflight_commands: Vec::new(),
1785            publish_command: String::new(),
1786            use_wsl: false,
1787            wsl_distribution: None,
1788            token: None,
1789            workspace_publish: None,
1790        };
1791        let command_line = if cfg!(target_os = "windows") {
1792            "echo compiling crate-a && echo compiling crate-b && echo compile failed 1>&2 && exit /b 7"
1793        } else {
1794            "printf 'compiling crate-a\\ncompiling crate-b\\n'; printf 'compile failed\\n' 1>&2; exit 7"
1795        };
1796
1797        let result = run_configured_command(
1798            command_line,
1799            command_line,
1800            &target,
1801            None,
1802            None,
1803            CommandStage::Preflight,
1804        )
1805        .await
1806        .expect("command result");
1807
1808        assert!(!result.success);
1809        assert!(result.stdout.contains("compiling crate-a"));
1810        assert!(result.stdout.contains("compiling crate-b"));
1811        assert!(result.stderr.contains("compile failed"));
1812    }
1813
1814    #[test]
1815    fn render_preflight_plan_marks_forced_skips() {
1816        let preflight_commands = vec!["pnpm test".to_string()];
1817
1818        let forced =
1819            render_preflight_plan(&preflight_commands, true).expect("forced preflight line");
1820        let normal =
1821            render_preflight_plan(&preflight_commands, false).expect("normal preflight line");
1822
1823        assert_eq!(normal, "pnpm test");
1824        assert_eq!(forced, "skipped via --force (configured: pnpm test)");
1825    }
1826
1827    #[test]
1828    fn render_process_log_helpers_include_publish_context() {
1829        let target = PreparedPublishTarget {
1830            kind: PublishKind::Npm,
1831            package_name: "@xylex-group/athena-auth-ui".to_string(),
1832            version: "1.4.0".to_string(),
1833            working_directory: PathBuf::from("C:/repo"),
1834            manifest_path: PathBuf::from("C:/repo/package.json"),
1835            preflight_commands: vec!["pnpm test".to_string()],
1836            publish_command: "npm publish".to_string(),
1837            use_wsl: false,
1838            wsl_distribution: None,
1839            token: None,
1840            workspace_publish: None,
1841        };
1842        let result = CommandResult {
1843            success: false,
1844            exit_code: Some(1),
1845            stdout: "stdout".to_string(),
1846            stderr: "stderr".to_string(),
1847        };
1848
1849        let name = render_process_log_name(CommandStage::Preflight, &target, "pnpm test");
1850        let summary = render_process_log_summary(&target, &result);
1851
1852        assert_eq!(
1853            name,
1854            "preflight npm @xylex-group/athena-auth-ui@1.4.0 command `pnpm test`"
1855        );
1856        assert!(summary.contains("runner=Windows shell") || summary.contains("runner=local shell"));
1857        assert!(summary.contains("cwd=C:\\repo") || summary.contains("cwd=C:/repo"));
1858        assert!(summary.contains("success=false"));
1859        assert!(summary.contains("exit_code=1"));
1860    }
1861
1862    #[test]
1863    fn crates_publish_command_includes_manifest_path() {
1864        let command = append_manifest_path_arg(
1865            "cargo publish",
1866            Path::new("C:/repo/crates/cli/Cargo.toml"),
1867            false,
1868        );
1869
1870        assert_eq!(
1871            command,
1872            "cargo publish --manifest-path \"C:/repo/crates/cli/Cargo.toml\""
1873        );
1874    }
1875
1876    #[test]
1877    #[cfg(target_os = "windows")]
1878    fn crates_publish_command_converts_manifest_path_for_wsl() {
1879        let command = append_manifest_path_arg(
1880            "cargo publish",
1881            Path::new("C:/repo/crates/cli/Cargo.toml"),
1882            true,
1883        );
1884
1885        assert_eq!(
1886            command,
1887            "cargo publish --manifest-path /mnt/c/repo/crates/cli/Cargo.toml"
1888        );
1889    }
1890
1891    #[test]
1892    fn append_allow_dirty_arg_adds_flag_to_cargo_publish_with_manifest_path() {
1893        let command = append_allow_dirty_arg(
1894            "cargo publish --manifest-path /mnt/c/repo/crates/cli/Cargo.toml",
1895            true,
1896        );
1897
1898        assert_eq!(
1899            command,
1900            "cargo publish --manifest-path /mnt/c/repo/crates/cli/Cargo.toml --allow-dirty"
1901        );
1902    }
1903
1904    #[test]
1905    fn rendered_publish_command_preserves_existing_manifest_path() {
1906        let target = PreparedPublishTarget {
1907            kind: PublishKind::Crates,
1908            package_name: "xbp".to_string(),
1909            version: "10.30.1".to_string(),
1910            working_directory: PathBuf::from("C:/repo/crates/cli"),
1911            manifest_path: PathBuf::from("C:/repo/crates/cli/Cargo.toml"),
1912            preflight_commands: Vec::new(),
1913            publish_command: "cargo publish --manifest-path crates/cli/Cargo.toml".to_string(),
1914            use_wsl: false,
1915            wsl_distribution: None,
1916            token: None,
1917            workspace_publish: None,
1918        };
1919
1920        assert_eq!(
1921            render_publish_command(&target),
1922            "cargo publish --manifest-path crates/cli/Cargo.toml"
1923        );
1924    }
1925
1926    #[test]
1927    fn prepend_shell_exports_builds_posix_export_prefix() {
1928        let mut env = HashMap::new();
1929        env.insert("CARGO_REGISTRY_TOKEN".to_string(), "abc123".to_string());
1930        assert_eq!(
1931            prepend_shell_exports("python3 scripts/publish_workspace.py", &env),
1932            "export CARGO_REGISTRY_TOKEN='abc123' && python3 scripts/publish_workspace.py"
1933        );
1934    }
1935
1936    #[test]
1937    fn workspace_delegate_publish_command_skips_manifest_path_suffix() {
1938        let target = PreparedPublishTarget {
1939            kind: PublishKind::Crates,
1940            package_name: "athena_rs".to_string(),
1941            version: "4.5.0".to_string(),
1942            working_directory: PathBuf::from("C:/repo"),
1943            manifest_path: PathBuf::from("C:/repo/Cargo.toml"),
1944            preflight_commands: Vec::new(),
1945            publish_command:
1946                "python3 scripts/publish_workspace.py --allow-dirty".to_string(),
1947            use_wsl: true,
1948            wsl_distribution: None,
1949            token: None,
1950            workspace_publish: None,
1951        };
1952
1953        assert_eq!(
1954            render_publish_command(&target),
1955            "python3 scripts/publish_workspace.py --allow-dirty"
1956        );
1957    }
1958
1959    #[test]
1960    fn workspace_delegate_detection_accepts_publish_workspace_scripts() {
1961        assert!(delegates_workspace_publish(
1962            "export CARGO_TARGET_DIR=/tmp/athena-publish-target && python3 scripts/publish_workspace.py --allow-dirty"
1963        ));
1964        assert!(delegates_workspace_publish(
1965            "xbp version workspace publish run --repo . --only athena_rs --include-prereqs"
1966        ));
1967        assert!(!delegates_workspace_publish("cargo publish"));
1968    }
1969
1970    #[test]
1971    fn workspace_auto_resolution_accepts_plain_cargo_publish_commands() {
1972        assert!(supports_workspace_auto_resolution("cargo publish"));
1973        assert!(supports_workspace_auto_resolution(
1974            "cargo publish --no-verify"
1975        ));
1976        assert!(!supports_workspace_auto_resolution(
1977            "cargo publish --manifest-path crates/cli/Cargo.toml"
1978        ));
1979        assert!(!supports_workspace_auto_resolution(
1980            "cargo publish && cargo owner --list"
1981        ));
1982    }
1983
1984    #[test]
1985    fn include_prereqs_rerun_command_preserves_publish_flags() {
1986        let target = PreparedPublishTarget {
1987            kind: PublishKind::Crates,
1988            package_name: "xbp".to_string(),
1989            version: "10.30.1".to_string(),
1990            working_directory: PathBuf::from("C:/repo/crates/cli"),
1991            manifest_path: PathBuf::from("C:/repo/crates/cli/Cargo.toml"),
1992            preflight_commands: Vec::new(),
1993            publish_command: "cargo publish".to_string(),
1994            use_wsl: false,
1995            wsl_distribution: None,
1996            token: None,
1997            workspace_publish: None,
1998        };
1999        let options = PublishCommandOptions {
2000            dry_run: false,
2001            allow_dirty: true,
2002            force: true,
2003            include_prereqs: false,
2004            target: Some("crates".to_string()),
2005            manifest_path: Some(PathBuf::from("crates/cli/Cargo.toml")),
2006            expected_version: None,
2007        };
2008
2009        let command = render_publish_include_prereqs_command(&target, &options);
2010        assert!(command.contains("xbp publish"));
2011        assert!(command.contains("--target crates"));
2012        assert!(command.contains("--allow-dirty"));
2013        assert!(command.contains("--force"));
2014        assert!(command.contains("--include-prereqs"));
2015        assert!(command.contains("--manifest-path"));
2016    }
2017
2018    #[tokio::test]
2019    async fn manifest_override_retargets_crates_publish_workflow() {
2020        let temp_dir = temp_dir("publish-manifest-override");
2021        let cli_dir = temp_dir.join("crates").join("cli");
2022        fs::create_dir_all(&cli_dir).expect("create cli dir");
2023        fs::write(
2024            cli_dir.join("Cargo.toml"),
2025            "[package]\nname = \"xbp\"\nversion = \"10.30.1\"\n",
2026        )
2027        .expect("write cargo");
2028
2029        let config = PublishProjectConfig {
2030            npm: None,
2031            crates: Some(PublishTargetConfig {
2032                enabled: Some(true),
2033                package_name: Some("workspace".to_string()),
2034                working_directory: Some(temp_dir.to_string_lossy().to_string()),
2035                manifest_path: None,
2036                token: None,
2037                preflight_commands: Vec::new(),
2038                publish_command: Some("cargo publish".to_string()),
2039                use_wsl: Some(false),
2040                wsl_distribution: None,
2041                generate_npmrc: None,
2042                access: None,
2043            }),
2044        };
2045        let options = PublishCommandOptions {
2046            dry_run: true,
2047            allow_dirty: true,
2048            force: true,
2049            include_prereqs: false,
2050            target: Some("crates".to_string()),
2051            manifest_path: Some(PathBuf::from("crates/cli/Cargo.toml")),
2052            expected_version: None,
2053        };
2054
2055        let prepared = prepare_publish_targets(&temp_dir, &temp_dir, &Some(config), &options)
2056            .await
2057            .expect("prepared targets");
2058
2059        assert_eq!(prepared.len(), 1);
2060        assert_eq!(prepared[0].package_name, "xbp");
2061        assert_eq!(prepared[0].working_directory, cli_dir);
2062        assert_eq!(prepared[0].manifest_path, cli_dir.join("Cargo.toml"));
2063    }
2064
2065    fn temp_dir(label: &str) -> PathBuf {
2066        let nanos = std::time::SystemTime::now()
2067            .duration_since(std::time::UNIX_EPOCH)
2068            .expect("system clock should be after epoch")
2069            .as_nanos();
2070        let dir = std::env::temp_dir().join(format!("xbp-{label}-{nanos}"));
2071        fs::create_dir_all(&dir).expect("create temp dir");
2072        dir
2073    }
2074}