1pub 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
20pub 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 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 if !dry_run {
104 run_publish_after_hooks(lang, &lang_config)?;
105 }
106 }
107 Ok(())
108}
109
110fn 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
123pub 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 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 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 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_publish_after_hooks(lang, &lang_config)?;
179 }
180 Ok(())
181}
182
183fn 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
192pub(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 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
225fn 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 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
296pub(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
311pub(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#[derive(Default)]
332pub struct PackageOptions<'a> {
333 pub php: Option<package::php::PiePackageOptions<'a>>,
335}
336
337pub 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 let artifact = package::kotlin::package_kotlin(config, ws_root, output_dir, version)?;
427 Some(vec![artifact])
428 }
429 Language::Gleam => {
430 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 let artifact = package::dart::package_dart(config, ws_root, output_dir, version)?;
442 Some(vec![artifact])
443 }
444 Language::Swift => {
445 let artifact = package::swift::package_swift(config, ws_root, output_dir, version)?;
447 Some(vec![artifact])
448 }
449 Language::Rust => {
450 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_publish_after_hooks(lang, &lang_config)?;
468 }
469 Ok(())
470}
471
472pub fn validate(config: &ResolvedCrateConfig, languages: &[Language]) -> Result<Vec<String>> {
479 let mut issues = Vec::new();
480
481 if config.resolved_version().is_none() {
483 issues.push(format!("cannot read version from {}", config.version_from));
484 }
485
486 for &lang in languages {
488 let pkg_dir = config.package_dir(lang);
489 let pkg_path = std::path::Path::new(&pkg_dir);
490
491 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 let expected_files: Vec<&str> = match lang {
505 Language::Python => vec!["pyproject.toml"],
506 Language::Node => vec!["package.json"],
507 Language::Ruby => vec![], 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![], 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
533fn 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
544fn 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 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
562fn 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
571fn 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
585fn 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
594fn is_ffi_dependent(lang: Language) -> bool {
596 matches!(lang, Language::Go | Language::Java | Language::Csharp)
597}
598
599fn run_publish_hooks(lang: Language, lang_config: &PublishLanguageConfig) -> Result<bool> {
604 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 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
627fn 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"))] 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 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()), 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 assert!(!marker.exists(), "before hook should not run when precondition fails");
688 }
689
690 #[cfg(not(target_os = "windows"))] #[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 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"))] #[cfg(not(target_os = "windows"))] #[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())), ..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"))] #[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 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 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}