Skip to main content

alef_publish/
lib.rs

1//! Publish pipeline for alef — vendoring, building, and packaging artifacts
2//! for distribution across language package registries.
3//!
4//! This crate provides the local logic behind `alef publish prepare`,
5//! `alef publish build`, and `alef publish package`. It does NOT handle
6//! registry authentication or publishing — those remain in CI actions.
7
8pub mod ffi_stage;
9pub mod package;
10pub mod platform;
11pub mod vendor;
12
13use alef_core::config::ResolvedCrateConfig;
14use alef_core::config::extras::Language;
15use alef_core::config::publish::{PublishLanguageConfig, VendorMode};
16use anyhow::{Context, Result};
17use platform::RustTarget;
18use std::path::Path;
19
20/// Prepare a language package for publishing: vendor dependencies, stage FFI artifacts.
21pub fn prepare(
22    config: &ResolvedCrateConfig,
23    languages: &[Language],
24    target: Option<&RustTarget>,
25    dry_run: bool,
26) -> Result<()> {
27    for &lang in languages {
28        let lang_config = publish_config_for_language(config, lang);
29
30        if !dry_run && !run_publish_hooks(lang, &lang_config)? {
31            continue;
32        }
33
34        let vendor_mode = lang_config
35            .vendor_mode
36            .as_ref()
37            .unwrap_or(&default_vendor_mode(lang))
38            .clone();
39
40        match vendor_mode {
41            VendorMode::CoreOnly => {
42                let core_crate_dir = resolve_core_crate_dir(config);
43                let core_path = Path::new(&core_crate_dir);
44                if !core_path.exists() {
45                    anyhow::bail!("core crate directory does not exist: {core_crate_dir}");
46                }
47                let workspace_root = resolve_workspace_root(config);
48                let dest_dir = resolve_vendor_dest(config, lang);
49                if dry_run {
50                    eprintln!("[dry-run] Would vendor core crate from {core_crate_dir} for {lang}");
51                } else {
52                    eprintln!("Vendoring core crate from {core_crate_dir} for {lang}...");
53                    let generate_ws = matches!(lang, Language::Ruby);
54                    let result = vendor::vendor_core_only(
55                        Path::new(&workspace_root),
56                        core_path,
57                        Path::new(&dest_dir),
58                        generate_ws,
59                    )?;
60                    eprintln!("  vendored to {}", result.vendor_dir.display());
61                }
62            }
63            VendorMode::Full => {
64                let core_crate_dir = resolve_core_crate_dir(config);
65                let workspace_root = resolve_workspace_root(config);
66                let dest_dir = resolve_vendor_dest(config, lang);
67                if dry_run {
68                    eprintln!("[dry-run] Would vendor all dependencies from {core_crate_dir} for {lang}");
69                } else {
70                    eprintln!("Vendoring all dependencies from {core_crate_dir} for {lang}...");
71                    let result = vendor::vendor_full(
72                        Path::new(&workspace_root),
73                        Path::new(&core_crate_dir),
74                        Path::new(&dest_dir),
75                    )?;
76                    eprintln!("  vendored to {}", result.vendor_dir.display());
77                }
78            }
79            VendorMode::None => {}
80        }
81
82        // Stage FFI artifacts for FFI-dependent languages.
83        if is_ffi_dependent(lang) {
84            if let Some(target) = target {
85                let workspace_root = resolve_workspace_root(config);
86                if dry_run {
87                    let platform = target.platform_for(lang);
88                    eprintln!("[dry-run] Would stage FFI artifacts for {lang} (platform: {platform})");
89                } else {
90                    eprintln!("Staging FFI artifacts for {lang}...");
91                    let dest = ffi_stage::stage_ffi(config, lang, target, Path::new(&workspace_root))?;
92                    eprintln!("  staged to {}", dest.display());
93                    if let Some(header) = ffi_stage::stage_header(config, lang, target, Path::new(&workspace_root))? {
94                        eprintln!("  header staged to {}", header.display());
95                    }
96                }
97            } else {
98                eprintln!("Skipping FFI staging for {lang}: no --target specified");
99            }
100        }
101
102        // Run after hooks on success (before moving to next language).
103        if !dry_run {
104            run_publish_after_hooks(lang, &lang_config)?;
105        }
106    }
107    Ok(())
108}
109
110/// Validate an identifier against shell-safe character set.
111fn validate_identifier(s: &str, label: &str) -> Result<()> {
112    if s.chars()
113        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
114    {
115        Ok(())
116    } else {
117        anyhow::bail!(
118            "{label} contains invalid characters: {s}. Only alphanumeric, underscore, dash, and period allowed."
119        )
120    }
121}
122
123/// Build release artifacts for a specific platform.
124pub fn build(
125    config: &ResolvedCrateConfig,
126    languages: &[Language],
127    target: Option<&RustTarget>,
128    use_cross: bool,
129) -> Result<()> {
130    let crate_name = &config.name;
131    validate_identifier(crate_name, "crate_name")?;
132    if let Some(t) = target {
133        validate_identifier(&t.triple, "target.triple")?;
134    }
135
136    // For FFI-dependent languages, build the FFI crate first.
137    let needs_ffi = languages.iter().any(|l| is_ffi_dependent(*l));
138    let ffi_in_list = languages.contains(&Language::Ffi);
139    if needs_ffi && !ffi_in_list {
140        let cmd = build_command_for_lang(Language::Ffi, config, target, use_cross);
141        eprintln!("Building FFI crate (dependency)...");
142        run_shell_command(&cmd)?;
143    }
144
145    for &lang in languages {
146        let lang_config = publish_config_for_language(config, lang);
147        if !run_publish_hooks(lang, &lang_config)? {
148            continue;
149        }
150
151        // Skip FFI-dependent languages if FFI was already built as dependency.
152        if matches!(lang, Language::Go | Language::Java | Language::Csharp) && needs_ffi && !ffi_in_list {
153            eprintln!("Skipping {lang}: FFI already built as dependency");
154            continue;
155        }
156
157        // Use custom build command from [publish.languages.{lang}] if set.
158        // Otherwise fall back to [build_commands.{lang}].build_release if set.
159        // Otherwise use the config-driven default.
160        let cmd = if let Some(custom) = &lang_config.build_command {
161            substitute_target(&custom.commands().join(" && "), target)
162        } else if let Some(build_cmd_cfg) = config
163            .build_commands
164            .get(&lang.to_string())
165            .and_then(|c| c.build_release.as_ref())
166        {
167            substitute_target(&build_cmd_cfg.commands().join(" && "), target)
168        } else {
169            build_command_for_lang(lang, config, target, use_cross)
170        };
171
172        let target_str = target.map(|t| t.triple.as_str()).unwrap_or("host");
173        eprintln!("Building {lang} for target {target_str}...");
174        run_shell_command(&cmd)?;
175        eprintln!("  build complete for {lang}");
176
177        // Run after hooks on success.
178        run_publish_after_hooks(lang, &lang_config)?;
179    }
180    Ok(())
181}
182
183/// Substitute `{target}` placeholder in a command string with the actual triple.
184fn substitute_target(cmd: &str, target: Option<&RustTarget>) -> String {
185    if let Some(t) = target {
186        cmd.replace("{target}", &t.triple)
187    } else {
188        cmd.replace("{target}", "")
189    }
190}
191
192/// Extract the Rust crate name from an output path in the config.
193///
194/// `"crates/html-to-markdown-ffi/src/"` → `Some("html-to-markdown-ffi")`
195pub(crate) fn crate_name_from_output(config: &ResolvedCrateConfig, lang: Language) -> Option<String> {
196    let output_path = match lang {
197        Language::Python => config.explicit_output.python.as_deref(),
198        Language::Node => config.explicit_output.node.as_deref(),
199        Language::Ruby => config.explicit_output.ruby.as_deref(),
200        Language::Php => config.explicit_output.php.as_deref(),
201        Language::Elixir => config.explicit_output.elixir.as_deref(),
202        Language::Wasm => config.explicit_output.wasm.as_deref(),
203        Language::Ffi => config.explicit_output.ffi.as_deref(),
204        Language::Go => config.explicit_output.go.as_deref(),
205        Language::Java => config.explicit_output.java.as_deref(),
206        Language::Csharp => config.explicit_output.csharp.as_deref(),
207        Language::R => config.explicit_output.r.as_deref(),
208        Language::Kotlin => config.explicit_output.kotlin.as_deref(),
209        Language::KotlinAndroid => config.explicit_output.kotlin_android.as_deref(),
210        Language::Gleam => config.explicit_output.gleam.as_deref(),
211        Language::Zig => config.explicit_output.zig.as_deref(),
212        Language::Rust | Language::C | Language::Jni => None,
213        Language::Swift | Language::Dart => None,
214    }?;
215    let path = std::path::Path::new(output_path);
216    // Strip trailing `src/` component if present.
217    let crate_dir = if path.file_name().is_some_and(|n| n == "src") {
218        path.parent()?
219    } else {
220        path
221    };
222    crate_dir.file_name()?.to_str().map(|s| s.to_string())
223}
224
225/// Generate the build command for a language, deriving crate names from output path config.
226///
227/// Falls back to `{crate_name}-{suffix}` when no output path is configured.
228fn build_command_for_lang(
229    lang: Language,
230    config: &ResolvedCrateConfig,
231    target: Option<&RustTarget>,
232    use_cross: bool,
233) -> String {
234    let crate_name = &config.name;
235    let cargo = if use_cross { "cross" } else { "cargo" };
236    let target_flag = target.map(|t| format!(" --target {}", t.triple)).unwrap_or_default();
237
238    match lang {
239        Language::Python => {
240            let pkg = crate_name_from_output(config, Language::Python).unwrap_or_else(|| format!("{crate_name}-py"));
241            format!("maturin build --release --manifest-path crates/{pkg}/Cargo.toml{target_flag}")
242        }
243        Language::Node => {
244            let pkg = crate_name_from_output(config, Language::Node).unwrap_or_else(|| format!("{crate_name}-node"));
245            let napi_target = target.map(|t| format!(" --target {}", t.triple)).unwrap_or_default();
246            format!(
247                "napi build --manifest-path crates/{pkg}/Cargo.toml \
248                 -o crates/{pkg} --platform --release{napi_target}"
249            )
250        }
251        Language::Wasm => {
252            let pkg = crate_name_from_output(config, Language::Wasm).unwrap_or_else(|| format!("{crate_name}-wasm"));
253            format!("wasm-pack build crates/{pkg} --release")
254        }
255        Language::Ruby => {
256            let pkg = crate_name_from_output(config, Language::Ruby).unwrap_or_else(|| format!("{crate_name}-rb"));
257            format!("{cargo} build --release -p {pkg}{target_flag}")
258        }
259        Language::Php => {
260            let pkg = crate_name_from_output(config, Language::Php).unwrap_or_else(|| format!("{crate_name}-php"));
261            format!("{cargo} build --release -p {pkg}{target_flag}")
262        }
263        Language::Ffi => {
264            let pkg = crate_name_from_output(config, Language::Ffi).unwrap_or_else(|| format!("{crate_name}-ffi"));
265            format!("{cargo} build --release -p {pkg}{target_flag}")
266        }
267        Language::Go | Language::Java | Language::Csharp => {
268            // FFI-dependent languages: build the FFI crate.
269            let pkg = crate_name_from_output(config, Language::Ffi).unwrap_or_else(|| format!("{crate_name}-ffi"));
270            format!("{cargo} build --release -p {pkg}{target_flag}")
271        }
272        Language::Elixir => {
273            format!("{cargo} build --release{target_flag}")
274        }
275        Language::R => {
276            let pkg = crate_name_from_output(config, Language::R).unwrap_or_else(|| format!("{crate_name}-r"));
277            format!("{cargo} build --release -p {pkg}{target_flag}")
278        }
279        Language::Rust => {
280            format!("{cargo} build --release --workspace{target_flag}")
281        }
282        Language::Kotlin
283        | Language::KotlinAndroid
284        | Language::Swift
285        | Language::Dart
286        | Language::Gleam
287        | Language::Zig
288        | Language::C
289        | Language::Jni => {
290            eprintln!("Warning: Phase 1: {lang} backend build command not yet implemented");
291            String::new()
292        }
293    }
294}
295
296/// Run a shell command and return an error if it fails.
297pub(crate) fn run_shell_command(cmd: &str) -> Result<()> {
298    eprintln!("  $ {cmd}");
299    let status = std::process::Command::new("sh")
300        .arg("-c")
301        .arg(cmd)
302        .status()
303        .with_context(|| format!("running: {cmd}"))?;
304
305    if !status.success() {
306        anyhow::bail!("command failed with exit code {}: {cmd}", status.code().unwrap_or(-1));
307    }
308    Ok(())
309}
310
311/// Run a shell command in a specific working directory.
312pub(crate) fn run_shell_command_in(cmd: &str, dir: &std::path::Path) -> Result<()> {
313    eprintln!("  $ {cmd}  (in {})", dir.display());
314    let status = std::process::Command::new("sh")
315        .arg("-c")
316        .arg(cmd)
317        .current_dir(dir)
318        .status()
319        .with_context(|| format!("running: {cmd}"))?;
320
321    if !status.success() {
322        anyhow::bail!("command failed with exit code {}: {cmd}", status.code().unwrap_or(-1));
323    }
324    Ok(())
325}
326
327/// Language-specific options forwarded into individual package functions.
328///
329/// All fields are optional so callers that don't package PHP can pass a
330/// default-constructed value without knowing about PHP-specific flags.
331#[derive(Default)]
332pub struct PackageOptions<'a> {
333    /// Options for PIE-conventional PHP packaging.  Required when `lang == php`.
334    pub php: Option<package::php::PiePackageOptions<'a>>,
335}
336
337/// Package built artifacts into distributable archives.
338pub fn package(
339    config: &ResolvedCrateConfig,
340    languages: &[Language],
341    target: Option<&RustTarget>,
342    output_dir: &Path,
343    version: &str,
344    dry_run: bool,
345    options: &PackageOptions<'_>,
346) -> Result<()> {
347    let workspace_root = resolve_workspace_root(config);
348    let ws_root = Path::new(&workspace_root);
349    std::fs::create_dir_all(output_dir)?;
350
351    for &lang in languages {
352        let lang_config = publish_config_for_language(config, lang);
353        let platform = target
354            .map(|t| t.platform_for(lang))
355            .unwrap_or_else(|| "host".to_string());
356        if dry_run {
357            eprintln!(
358                "[dry-run] Would package {lang} for platform {platform} into {}",
359                output_dir.display()
360            );
361            continue;
362        }
363
364        if !run_publish_hooks(lang, &lang_config)? {
365            continue;
366        }
367
368        eprintln!("Packaging {lang} for platform {platform}...");
369
370        let result = match lang {
371            Language::Ffi => {
372                let t = target.context("--target required for FFI packaging")?;
373                let artifact = package::c_ffi::package_c_ffi(config, t, ws_root, output_dir, version)?;
374                Some(vec![artifact])
375            }
376            Language::Php => {
377                let t = target.context("--target required for PHP packaging")?;
378                let pie_opts = options
379                    .php
380                    .as_ref()
381                    .context("--php-version (and other PHP flags) required for PHP packaging")?;
382                let artifact = package::php::package_php(config, t, ws_root, output_dir, version, pie_opts)?;
383                Some(vec![artifact])
384            }
385            Language::Go => {
386                let t = target.context("--target required for Go packaging")?;
387                let artifact = package::go::package_go_ffi(config, t, ws_root, output_dir, version)?;
388                Some(vec![artifact])
389            }
390            Language::Python => {
391                let t = target.context("--target required for Python packaging")?;
392                let artifacts = package::python::package_python(config, t, ws_root, output_dir, version)?;
393                Some(artifacts)
394            }
395            Language::Wasm => {
396                let artifacts = package::wasm::package_wasm(config, ws_root, output_dir, version)?;
397                Some(vec![artifacts])
398            }
399            Language::Node => {
400                let t = target.context("--target required for Node packaging")?;
401                let artifact = package::node::package_node(config, t, ws_root, output_dir, version)?;
402                Some(vec![artifact])
403            }
404            Language::Ruby => {
405                let t = target.context("--target required for Ruby packaging")?;
406                let artifact = package::ruby::package_ruby(config, t, ws_root, output_dir, version)?;
407                Some(vec![artifact])
408            }
409            Language::Elixir => {
410                let t = target.context("--target required for Elixir packaging")?;
411                let artifacts = package::elixir::package_elixir(config, t, ws_root, output_dir, version)?;
412                Some(artifacts)
413            }
414            Language::Java => {
415                let t = target.context("--target required for Java packaging")?;
416                let artifact = package::java::package_java(config, t, ws_root, output_dir, version)?;
417                Some(vec![artifact])
418            }
419            Language::Csharp => {
420                let t = target.context("--target required for C# packaging")?;
421                let artifact = package::csharp::package_csharp(config, t, ws_root, output_dir, version)?;
422                Some(vec![artifact])
423            }
424            Language::Kotlin => {
425                // Kotlin/JVM packaging is target-independent — Gradle produces a JVM jar.
426                let artifact = package::kotlin::package_kotlin(config, ws_root, output_dir, version)?;
427                Some(vec![artifact])
428            }
429            Language::Gleam => {
430                // Gleam source packaging is target-independent.
431                let artifact = package::gleam::package_gleam(config, ws_root, output_dir, version)?;
432                Some(vec![artifact])
433            }
434            Language::Zig => {
435                let t = target.context("--target required for Zig packaging")?;
436                let artifact = package::zig::package_zig(config, t, ws_root, output_dir, version)?;
437                Some(vec![artifact])
438            }
439            Language::Dart => {
440                // Dart source packaging is target-independent (FRB handles cross-compilation).
441                let artifact = package::dart::package_dart(config, ws_root, output_dir, version)?;
442                Some(vec![artifact])
443            }
444            Language::Swift => {
445                // Swift source packaging is target-independent; XCFramework requires xcodebuild.
446                let artifact = package::swift::package_swift(config, ws_root, output_dir, version)?;
447                Some(vec![artifact])
448            }
449            Language::Rust => {
450                // CLI packaging is invoked explicitly from alef-cli, not through the language dispatch.
451                eprintln!("  CLI (Rust) packaging handled separately");
452                None
453            }
454            _ => {
455                eprintln!("  packaging not yet implemented for {lang}");
456                None
457            }
458        };
459
460        if let Some(artifacts) = result {
461            for artifact in &artifacts {
462                eprintln!("  produced {}", artifact.name);
463            }
464        }
465
466        // Run after hooks on success.
467        run_publish_after_hooks(lang, &lang_config)?;
468    }
469    Ok(())
470}
471
472/// Validate that all package manifests are ready for publishing.
473///
474/// Checks:
475/// - All required package directories exist
476/// - Key manifest files are present (pyproject.toml, package.json, gemspec, etc.)
477/// - Cargo.toml version can be read
478pub fn validate(config: &ResolvedCrateConfig, languages: &[Language]) -> Result<Vec<String>> {
479    let mut issues = Vec::new();
480
481    // Check version is readable.
482    if config.resolved_version().is_none() {
483        issues.push(format!("cannot read version from {}", config.version_from));
484    }
485
486    // Check package directories and key manifest files exist.
487    for &lang in languages {
488        let pkg_dir = config.package_dir(lang);
489        let pkg_path = std::path::Path::new(&pkg_dir);
490
491        // Skip languages that don't have standalone package dirs (Rust, FFI, JNI).
492        // JNI is a transitive language used by Java/Kotlin bindings — no separate
493        // publish artifact exists.
494        if matches!(lang, Language::Rust | Language::Ffi | Language::Jni) {
495            continue;
496        }
497
498        if !pkg_path.exists() {
499            issues.push(format!("{lang}: package directory {pkg_dir} does not exist"));
500            continue;
501        }
502
503        // Check for key manifest files per language.
504        let expected_files: Vec<&str> = match lang {
505            Language::Python => vec!["pyproject.toml"],
506            Language::Node => vec!["package.json"],
507            Language::Ruby => vec![], // gemspec name varies
508            Language::Php => vec!["composer.json"],
509            Language::Elixir => vec!["mix.exs"],
510            Language::Go => vec!["go.mod"],
511            Language::Java => vec!["pom.xml"],
512            Language::Csharp => vec![], // .csproj name varies
513            Language::Wasm => vec![],
514            Language::R => vec!["DESCRIPTION"],
515            Language::Kotlin => vec!["build.gradle.kts"],
516            Language::Gleam => vec!["gleam.toml"],
517            Language::Zig => vec!["build.zig"],
518            Language::Dart => vec!["pubspec.yaml"],
519            Language::Swift => vec!["Package.swift"],
520            _ => vec![],
521        };
522
523        for file in expected_files {
524            if !pkg_path.join(file).exists() {
525                issues.push(format!("{lang}: missing {pkg_dir}/{file}"));
526            }
527        }
528    }
529
530    Ok(issues)
531}
532
533/// Get the publish configuration for a language, falling back to defaults.
534fn publish_config_for_language(config: &ResolvedCrateConfig, lang: Language) -> PublishLanguageConfig {
535    if let Some(publish) = &config.publish {
536        let lang_str = lang.to_string();
537        if let Some(lang_config) = publish.languages.get(&lang_str) {
538            return lang_config.clone();
539        }
540    }
541    PublishLanguageConfig::default()
542}
543
544/// Resolve the core crate directory path.
545fn resolve_core_crate_dir(config: &ResolvedCrateConfig) -> String {
546    if let Some(publish) = &config.publish {
547        if let Some(core_crate) = &publish.core_crate {
548            return core_crate.clone();
549        }
550    }
551    // Fall back to deriving from [crate].sources.
552    let dir = config.core_crate_dir();
553    if !config.sources.is_empty() {
554        let first = config.sources[0].to_string_lossy();
555        if first.contains("crates/") {
556            return format!("crates/{dir}");
557        }
558    }
559    dir
560}
561
562/// Resolve the workspace root directory.
563fn resolve_workspace_root(config: &ResolvedCrateConfig) -> String {
564    config
565        .workspace_root
566        .as_ref()
567        .map(|p| p.to_string_lossy().to_string())
568        .unwrap_or_else(|| ".".to_string())
569}
570
571/// Resolve the vendor destination directory for a language.
572fn resolve_vendor_dest(config: &ResolvedCrateConfig, lang: Language) -> String {
573    let pkg_dir = config.package_dir(lang);
574    match lang {
575        Language::Ruby => format!("{pkg_dir}/vendor"),
576        Language::Elixir => {
577            let app_name = config.elixir_app_name();
578            format!("{pkg_dir}/native/{app_name}/vendor")
579        }
580        Language::R => format!("{pkg_dir}/src/rust"),
581        _ => format!("{pkg_dir}/vendor"),
582    }
583}
584
585/// Return the default vendor mode for a language.
586fn default_vendor_mode(lang: Language) -> VendorMode {
587    match lang {
588        Language::Ruby | Language::Elixir => VendorMode::CoreOnly,
589        Language::R => VendorMode::Full,
590        _ => VendorMode::None,
591    }
592}
593
594/// Whether a language depends on the C FFI crate for its bindings.
595fn is_ffi_dependent(lang: Language) -> bool {
596    matches!(lang, Language::Go | Language::Java | Language::Csharp)
597}
598
599/// Run precondition check and before hooks for a language.
600///
601/// Returns `true` if the main command should proceed, `false` if the
602/// precondition failed (skip with warning).
603fn run_publish_hooks(lang: Language, lang_config: &PublishLanguageConfig) -> Result<bool> {
604    // Check precondition.
605    if let Some(precondition) = &lang_config.precondition {
606        let status = std::process::Command::new("sh")
607            .arg("-c")
608            .arg(precondition)
609            .status()
610            .with_context(|| format!("running precondition for {lang}: {precondition}"))?;
611        if !status.success() {
612            eprintln!("Skipping {lang}: precondition failed ({precondition})");
613            return Ok(false);
614        }
615    }
616
617    // Run before hooks.
618    if let Some(before) = &lang_config.before {
619        for cmd in before.commands() {
620            run_shell_command(cmd)?;
621        }
622    }
623
624    Ok(true)
625}
626
627/// Run after hooks for a language after successful completion.
628///
629/// After hooks run only when the main operation succeeds (symmetrical with before hooks,
630/// which run only before a successful start). This ensures cleanup/finalization logic
631/// only runs when the operation completed.
632fn run_publish_after_hooks(lang: Language, lang_config: &PublishLanguageConfig) -> Result<()> {
633    if let Some(after) = &lang_config.after {
634        for cmd in after.commands() {
635            run_shell_command(cmd).with_context(|| format!("running after hook for {lang}: {cmd}"))?;
636        }
637    }
638    Ok(())
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644    use alef_core::config::output::StringOrVec;
645    #[cfg(not(target_os = "windows"))]
646    use std::fs;
647    use std::path::PathBuf;
648    use tempfile::TempDir;
649
650    fn make_temp_marker_file() -> (TempDir, PathBuf) {
651        let temp_dir = TempDir::new().unwrap();
652        let marker = temp_dir.path().join("marker.txt");
653        (temp_dir, marker)
654    }
655
656    #[test]
657    #[cfg(not(target_os = "windows"))] // sh + > redirect doesn't work portably on Windows CI runners
658    fn test_run_publish_hooks_runs_before_only() {
659        let (_temp_dir, marker) = make_temp_marker_file();
660        let marker_str = marker.to_str().unwrap();
661
662        // Config with before hook only.
663        let config = PublishLanguageConfig {
664            before: Some(StringOrVec::Single(format!("echo 'before' > {marker_str}"))),
665            ..Default::default()
666        };
667
668        let result = run_publish_hooks(Language::Python, &config);
669        assert!(result.is_ok());
670        assert!(marker.exists(), "before hook should have created marker file");
671    }
672
673    #[test]
674    fn test_run_publish_hooks_precondition_failure_skips() {
675        let (_temp_dir, marker) = make_temp_marker_file();
676        let marker_str = marker.to_str().unwrap();
677
678        let config = PublishLanguageConfig {
679            precondition: Some("false".to_string()), // Always fails
680            before: Some(StringOrVec::Single(format!("echo 'before' > {marker_str}"))),
681            ..Default::default()
682        };
683
684        let result = run_publish_hooks(Language::Python, &config);
685        assert!(result.is_ok());
686        // Precondition failed, so before hook should not run
687        assert!(!marker.exists(), "before hook should not run when precondition fails");
688    }
689
690    #[cfg(not(target_os = "windows"))] // sh + > redirect doesn't work portably on Windows CI runners
691    #[test]
692    fn test_run_publish_after_hooks_runs_after_only() {
693        let (_temp_dir, marker) = make_temp_marker_file();
694        let marker_str = marker.to_str().unwrap();
695
696        let config = PublishLanguageConfig {
697            after: Some(StringOrVec::Single(format!("echo 'after' > {marker_str}"))),
698            ..Default::default()
699        };
700
701        let result = run_publish_after_hooks(Language::Python, &config);
702        assert!(result.is_ok());
703        assert!(marker.exists(), "after hook should have created marker file");
704
705        let content = fs::read_to_string(&marker).unwrap();
706        assert!(content.contains("after"));
707    }
708
709    #[test]
710    fn test_run_publish_after_hooks_no_after_is_noop() {
711        let config = PublishLanguageConfig::default();
712        // Config has no after hook
713        let result = run_publish_after_hooks(Language::Python, &config);
714        assert!(result.is_ok(), "after hooks should succeed when not specified");
715    }
716    #[cfg(not(target_os = "windows"))] // sh + > redirect doesn't work portably on Windows CI runners
717    #[cfg(not(target_os = "windows"))] // sh + > redirect doesn't work portably on Windows CI runners
718    #[test]
719    fn test_run_publish_after_hooks_multiple_commands() {
720        let temp_dir = TempDir::new().unwrap();
721        let marker1 = temp_dir.path().join("marker1.txt");
722        let marker2 = temp_dir.path().join("marker2.txt");
723
724        let marker1_str = marker1.to_str().unwrap();
725        let marker2_str = marker2.to_str().unwrap();
726
727        let config = PublishLanguageConfig {
728            after: Some(StringOrVec::Multiple(vec![
729                format!("echo 'after1' > {marker1_str}"),
730                format!("echo 'after2' > {marker2_str}"),
731            ])),
732            ..Default::default()
733        };
734
735        let result = run_publish_after_hooks(Language::Python, &config);
736        assert!(result.is_ok());
737        assert!(marker1.exists(), "first after command should execute");
738        assert!(marker2.exists(), "second after command should execute");
739    }
740
741    #[test]
742    fn test_run_publish_after_hooks_failure_propagates_error() {
743        let config = PublishLanguageConfig {
744            after: Some(StringOrVec::Single("false".to_string())), // Command fails
745            ..Default::default()
746        };
747
748        let result = run_publish_after_hooks(Language::Python, &config);
749        assert!(result.is_err(), "after hook failure should propagate error");
750    }
751
752    #[cfg(not(target_os = "windows"))] // sh + > redirect doesn't work portably on Windows CI runners
753    #[test]
754    fn test_publish_hooks_full_lifecycle_success() {
755        let temp_dir = TempDir::new().unwrap();
756        let before_marker = temp_dir.path().join("before.txt");
757        let after_marker = temp_dir.path().join("after.txt");
758
759        let before_str = before_marker.to_str().unwrap();
760        let after_str = after_marker.to_str().unwrap();
761
762        let config = PublishLanguageConfig {
763            before: Some(StringOrVec::Single(format!("echo 'before' > {before_str}"))),
764            after: Some(StringOrVec::Single(format!("echo 'after' > {after_str}"))),
765            ..Default::default()
766        };
767
768        // Simulate before hook
769        let before_result = run_publish_hooks(Language::Python, &config);
770        assert!(before_result.is_ok());
771        assert!(before_marker.exists(), "before hook should run");
772
773        // Simulate successful operation (no actual work)
774
775        // Simulate after hook
776        let after_result = run_publish_after_hooks(Language::Python, &config);
777        assert!(after_result.is_ok());
778        assert!(after_marker.exists(), "after hook should run on success");
779    }
780}