pub mod ffi_stage;
pub mod package;
pub mod platform;
pub mod vendor;
pub mod workspace;
use crate::core::config::ResolvedCrateConfig;
use crate::core::config::extras::Language;
use crate::core::config::publish::{PublishLanguageConfig, VendorMode};
use anyhow::{Context, Result};
use platform::RustTarget;
use std::path::{Path, PathBuf};
pub fn prepare(
config: &ResolvedCrateConfig,
languages: &[Language],
target: Option<&RustTarget>,
dry_run: bool,
require_registry: bool,
) -> Result<()> {
for &lang in languages {
let lang_config = publish_config_for_language(config, lang);
if !dry_run && !run_publish_hooks(lang, &lang_config)? {
continue;
}
let vendor_mode = lang_config
.vendor_mode
.as_ref()
.unwrap_or(&default_vendor_mode(lang))
.clone();
match vendor_mode {
VendorMode::CoreOnly => {
let core_crate_dir = resolve_core_crate_dir(config);
let core_path = Path::new(&core_crate_dir);
if !core_path.exists() {
anyhow::bail!("core crate directory does not exist: {core_crate_dir}");
}
let workspace_root = resolve_workspace_root(config);
let dest_dir = resolve_vendor_dest(config, lang);
if dry_run {
eprintln!("[dry-run] Would vendor core crate from {core_crate_dir} for {lang}");
} else {
eprintln!("Vendoring core crate from {core_crate_dir} for {lang}...");
let generate_ws = matches!(lang, Language::Ruby);
let result = vendor::vendor_core_only(
Path::new(&workspace_root),
core_path,
Path::new(&dest_dir),
generate_ws,
)?;
eprintln!(" vendored to {}", result.vendor_dir.display());
}
}
VendorMode::Full => {
let core_crate_dir = resolve_core_crate_dir(config);
let workspace_root = resolve_workspace_root(config);
let dest_dir = resolve_vendor_dest(config, lang);
if dry_run {
eprintln!("[dry-run] Would vendor all dependencies from {core_crate_dir} for {lang}");
} else {
eprintln!("Vendoring all dependencies from {core_crate_dir} for {lang}...");
let result = vendor::vendor_full(
Path::new(&workspace_root),
Path::new(&core_crate_dir),
Path::new(&dest_dir),
)?;
eprintln!(" vendored to {}", result.vendor_dir.display());
}
}
VendorMode::Registry => {
match resolve_binding_manifest(config, lang) {
Some(manifest) => {
let workspace_root = resolve_workspace_root(config);
let ws_root = Path::new(&workspace_root);
let manifest_abs = if manifest.is_absolute() {
manifest.clone()
} else {
ws_root.join(&manifest)
};
if !manifest_abs.exists() {
eprintln!(
"Skipping Registry rewrite for {lang}: binding manifest not found at {}",
manifest_abs.display()
);
} else {
let members = workspace::workspace_member_crates(ws_root)?;
let version = config
.resolved_version()
.context("cannot resolve crate version for Registry vendor mode")?;
if dry_run {
eprintln!(
"[dry-run] Would rewrite workspace-member path deps to registry \
version-deps (v{version}) in {} for {lang}",
manifest_abs.display()
);
} else {
eprintln!(
"Rewriting workspace-member path deps to registry version-deps \
(v{version}) in {} for {lang}...",
manifest_abs.display()
);
vendor::rewrite_path_deps_to_registry(&manifest_abs, &members, &version)?;
if let Some(manifest_dir) = manifest_abs.parent() {
vendor::scrub_or_regenerate_lock(manifest_dir, require_registry, require_registry)?;
}
eprintln!(" rewrote {}", manifest_abs.display());
}
}
}
None => {
eprintln!("Skipping Registry rewrite for {lang}: no shipped binding manifest");
}
}
}
VendorMode::None => {}
}
if is_ffi_dependent(lang) {
if let Some(target) = target {
let workspace_root = resolve_workspace_root(config);
if dry_run {
let platform = target.platform_for(lang);
eprintln!("[dry-run] Would stage FFI artifacts for {lang} (platform: {platform})");
} else {
eprintln!("Staging FFI artifacts for {lang}...");
let dest = ffi_stage::stage_ffi(config, lang, target, Path::new(&workspace_root))?;
eprintln!(" staged to {}", dest.display());
if let Some(header) = ffi_stage::stage_header(config, lang, target, Path::new(&workspace_root))? {
eprintln!(" header staged to {}", header.display());
}
}
} else {
eprintln!("Skipping FFI staging for {lang}: no --target specified");
}
}
if !dry_run {
run_publish_after_hooks(lang, &lang_config)?;
}
}
Ok(())
}
fn validate_identifier(s: &str, label: &str) -> Result<()> {
if s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
{
Ok(())
} else {
anyhow::bail!(
"{label} contains invalid characters: {s}. Only alphanumeric, underscore, dash, and period allowed."
)
}
}
pub fn build(
config: &ResolvedCrateConfig,
languages: &[Language],
target: Option<&RustTarget>,
use_cross: bool,
) -> Result<()> {
let crate_name = &config.name;
validate_identifier(crate_name, "crate_name")?;
if let Some(t) = target {
validate_identifier(&t.triple, "target.triple")?;
}
let needs_ffi = languages.iter().any(|l| is_ffi_dependent(*l));
let ffi_in_list = languages.contains(&Language::Ffi);
if needs_ffi && !ffi_in_list {
let cmd = build_command_for_lang(Language::Ffi, config, target, use_cross);
eprintln!("Building FFI crate (dependency)...");
run_shell_command(&cmd)?;
}
for &lang in languages {
let lang_config = publish_config_for_language(config, lang);
if !run_publish_hooks(lang, &lang_config)? {
continue;
}
if matches!(lang, Language::Go | Language::Java | Language::Csharp) && needs_ffi && !ffi_in_list {
eprintln!("Skipping {lang}: FFI already built as dependency");
continue;
}
let cmd = if let Some(custom) = &lang_config.build_command {
substitute_target(&custom.commands().join(" && "), target)
} else if let Some(build_cmd_cfg) = config
.build_commands
.get(&lang.to_string())
.and_then(|c| c.build_release.as_ref())
{
substitute_target(&build_cmd_cfg.commands().join(" && "), target)
} else {
build_command_for_lang(lang, config, target, use_cross)
};
let target_str = target.map(|t| t.triple.as_str()).unwrap_or("host");
eprintln!("Building {lang} for target {target_str}...");
run_shell_command(&cmd)?;
eprintln!(" build complete for {lang}");
run_publish_after_hooks(lang, &lang_config)?;
}
Ok(())
}
fn substitute_target(cmd: &str, target: Option<&RustTarget>) -> String {
if let Some(t) = target {
cmd.replace("{target}", &t.triple)
} else {
cmd.replace("{target}", "")
}
}
pub(crate) fn crate_name_from_output(config: &ResolvedCrateConfig, lang: Language) -> Option<String> {
let output_path = match lang {
Language::Python => config.explicit_output.python.as_deref(),
Language::Node => config.explicit_output.node.as_deref(),
Language::Ruby => config.explicit_output.ruby.as_deref(),
Language::Php => config.explicit_output.php.as_deref(),
Language::Elixir => config.explicit_output.elixir.as_deref(),
Language::Wasm => config.explicit_output.wasm.as_deref(),
Language::Ffi => config.explicit_output.ffi.as_deref(),
Language::Go => config.explicit_output.go.as_deref(),
Language::Java => config.explicit_output.java.as_deref(),
Language::Csharp => config.explicit_output.csharp.as_deref(),
Language::R => config.explicit_output.r.as_deref(),
Language::Kotlin => config.explicit_output.kotlin.as_deref(),
Language::KotlinAndroid => config.explicit_output.kotlin_android.as_deref(),
Language::Gleam => config.explicit_output.gleam.as_deref(),
Language::Zig => config.explicit_output.zig.as_deref(),
Language::Rust | Language::C | Language::Jni => None,
Language::Swift | Language::Dart => None,
}?;
let path = std::path::Path::new(output_path);
let crate_dir = if path.file_name().is_some_and(|n| n == "src") {
path.parent()?
} else {
path
};
crate_dir.file_name()?.to_str().map(|s| s.to_string())
}
fn build_command_for_lang(
lang: Language,
config: &ResolvedCrateConfig,
target: Option<&RustTarget>,
use_cross: bool,
) -> String {
let crate_name = &config.name;
let cargo = if use_cross { "cross" } else { "cargo" };
let target_flag = target.map(|t| format!(" --target {}", t.triple)).unwrap_or_default();
match lang {
Language::Python => {
let pkg = crate_name_from_output(config, Language::Python).unwrap_or_else(|| format!("{crate_name}-py"));
format!("maturin build --release --manifest-path crates/{pkg}/Cargo.toml{target_flag}")
}
Language::Node => {
let pkg = crate_name_from_output(config, Language::Node).unwrap_or_else(|| format!("{crate_name}-node"));
let napi_target = target.map(|t| format!(" --target {}", t.triple)).unwrap_or_default();
format!(
"napi build --manifest-path crates/{pkg}/Cargo.toml \
-o crates/{pkg} --platform --release{napi_target}"
)
}
Language::Wasm => {
let pkg = crate_name_from_output(config, Language::Wasm).unwrap_or_else(|| format!("{crate_name}-wasm"));
format!("wasm-pack build crates/{pkg} --release")
}
Language::Ruby => {
let pkg = crate_name_from_output(config, Language::Ruby).unwrap_or_else(|| format!("{crate_name}-rb"));
format!("{cargo} build --release -p {pkg}{target_flag}")
}
Language::Php => {
let pkg = crate_name_from_output(config, Language::Php).unwrap_or_else(|| format!("{crate_name}-php"));
format!("{cargo} build --release -p {pkg}{target_flag}")
}
Language::Ffi => {
let pkg = crate_name_from_output(config, Language::Ffi).unwrap_or_else(|| format!("{crate_name}-ffi"));
format!("{cargo} build --release -p {pkg}{target_flag}")
}
Language::Go | Language::Java | Language::Csharp => {
let pkg = crate_name_from_output(config, Language::Ffi).unwrap_or_else(|| format!("{crate_name}-ffi"));
format!("{cargo} build --release -p {pkg}{target_flag}")
}
Language::Elixir => {
format!("{cargo} build --release{target_flag}")
}
Language::R => {
let pkg = crate_name_from_output(config, Language::R).unwrap_or_else(|| format!("{crate_name}-r"));
format!("{cargo} build --release -p {pkg}{target_flag}")
}
Language::Rust => {
format!("{cargo} build --release --workspace{target_flag}")
}
Language::Kotlin
| Language::KotlinAndroid
| Language::Swift
| Language::Dart
| Language::Gleam
| Language::Zig
| Language::C
| Language::Jni => {
eprintln!("Warning: Phase 1: {lang} backend build command not yet implemented");
String::new()
}
}
}
pub(crate) fn run_shell_command(cmd: &str) -> Result<()> {
eprintln!(" $ {cmd}");
let status = std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.status()
.with_context(|| format!("running: {cmd}"))?;
if !status.success() {
anyhow::bail!("command failed with exit code {}: {cmd}", status.code().unwrap_or(-1));
}
Ok(())
}
pub(crate) fn run_shell_command_in(cmd: &str, dir: &std::path::Path) -> Result<()> {
eprintln!(" $ {cmd} (in {})", dir.display());
let status = std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.current_dir(dir)
.status()
.with_context(|| format!("running: {cmd}"))?;
if !status.success() {
anyhow::bail!("command failed with exit code {}: {cmd}", status.code().unwrap_or(-1));
}
Ok(())
}
#[derive(Default)]
pub struct PackageOptions<'a> {
pub php: Option<package::php::PiePackageOptions<'a>>,
}
pub fn package(
config: &ResolvedCrateConfig,
languages: &[Language],
target: Option<&RustTarget>,
output_dir: &Path,
version: &str,
dry_run: bool,
options: &PackageOptions<'_>,
) -> Result<()> {
let workspace_root = resolve_workspace_root(config);
let ws_root = Path::new(&workspace_root);
std::fs::create_dir_all(output_dir)?;
for &lang in languages {
let lang_config = publish_config_for_language(config, lang);
let platform = target
.map(|t| t.platform_for(lang))
.unwrap_or_else(|| "host".to_string());
if dry_run {
eprintln!(
"[dry-run] Would package {lang} for platform {platform} into {}",
output_dir.display()
);
continue;
}
if !run_publish_hooks(lang, &lang_config)? {
continue;
}
eprintln!("Packaging {lang} for platform {platform}...");
let pkg_vendor_mode = lang_config
.vendor_mode
.as_ref()
.unwrap_or(&default_vendor_mode(lang))
.clone();
if matches!(pkg_vendor_mode, VendorMode::Registry) {
if let Some(manifest) = resolve_binding_manifest(config, lang) {
let manifest_abs = if manifest.is_absolute() {
manifest
} else {
ws_root.join(&manifest)
};
let members = workspace::workspace_member_crates(ws_root)?;
assert_no_member_path_deps(&manifest_abs, &members, lang)?;
}
}
let result = match lang {
Language::Ffi => {
let t = target.context("--target required for FFI packaging")?;
let artifact = package::c_ffi::package_c_ffi(config, t, ws_root, output_dir, version)?;
Some(vec![artifact])
}
Language::Php => {
let t = target.context("--target required for PHP packaging")?;
let pie_opts = options
.php
.as_ref()
.context("--php-version (and other PHP flags) required for PHP packaging")?;
let artifact = package::php::package_php(config, t, ws_root, output_dir, version, pie_opts)?;
Some(vec![artifact])
}
Language::Go => {
let t = target.context("--target required for Go packaging")?;
let artifact = package::go::package_go_ffi(config, t, ws_root, output_dir, version)?;
Some(vec![artifact])
}
Language::Python => {
let t = target.context("--target required for Python packaging")?;
let artifacts = package::python::package_python(config, t, ws_root, output_dir, version)?;
Some(artifacts)
}
Language::Wasm => {
let artifacts = package::wasm::package_wasm(config, ws_root, output_dir, version)?;
Some(vec![artifacts])
}
Language::Node => {
let t = target.context("--target required for Node packaging")?;
let artifact = package::node::package_node(config, t, ws_root, output_dir, version)?;
Some(vec![artifact])
}
Language::Ruby => {
let t = target.context("--target required for Ruby packaging")?;
let artifact = package::ruby::package_ruby(config, t, ws_root, output_dir, version)?;
Some(vec![artifact])
}
Language::Elixir => {
let t = target.context("--target required for Elixir packaging")?;
let artifacts = package::elixir::package_elixir(config, t, ws_root, output_dir, version)?;
Some(artifacts)
}
Language::Java => {
let t = target.context("--target required for Java packaging")?;
let artifact = package::java::package_java(config, t, ws_root, output_dir, version)?;
Some(vec![artifact])
}
Language::Csharp => {
let t = target.context("--target required for C# packaging")?;
let artifact = package::csharp::package_csharp(config, t, ws_root, output_dir, version)?;
Some(vec![artifact])
}
Language::Kotlin => {
let artifact = package::kotlin::package_kotlin(config, ws_root, output_dir, version)?;
Some(vec![artifact])
}
Language::Gleam => {
let artifact = package::gleam::package_gleam(config, ws_root, output_dir, version)?;
Some(vec![artifact])
}
Language::Zig => {
let t = target.context("--target required for Zig packaging")?;
let artifact = package::zig::package_zig(config, t, ws_root, output_dir, version)?;
Some(vec![artifact])
}
Language::Dart => {
let artifact = package::dart::package_dart(config, ws_root, output_dir, version)?;
Some(vec![artifact])
}
Language::Swift => {
let artifact = package::swift::package_swift(config, ws_root, output_dir, version)?;
Some(vec![artifact])
}
Language::Rust => {
eprintln!(" CLI (Rust) packaging handled separately");
None
}
_ => {
eprintln!(" packaging not yet implemented for {lang}");
None
}
};
if let Some(artifacts) = result {
for artifact in &artifacts {
eprintln!(" produced {}", artifact.name);
}
}
run_publish_after_hooks(lang, &lang_config)?;
}
Ok(())
}
pub fn validate(config: &ResolvedCrateConfig, languages: &[Language]) -> Result<Vec<String>> {
let mut issues = Vec::new();
let workspace_root = resolve_workspace_root(config);
let workspace_path = Path::new(&workspace_root);
if config.resolved_version().is_none() {
issues.push(format!("cannot read version from {}", config.version_from));
}
for &lang in languages {
let pkg_dir = config.package_dir(lang);
let pkg_path = workspace_path.join(&pkg_dir);
if matches!(lang, Language::Rust | Language::Ffi | Language::Jni) {
continue;
}
if !pkg_path.exists() {
issues.push(format!("{lang}: package directory {pkg_dir} does not exist"));
continue;
}
let expected_files: Vec<&str> = match lang {
Language::Python => vec!["pyproject.toml"],
Language::Node => vec!["package.json"],
Language::Ruby => vec![], Language::Php => vec!["composer.json"],
Language::Elixir => vec!["mix.exs"],
Language::Go => vec!["go.mod"],
Language::Java => vec!["pom.xml"],
Language::Csharp => vec![], Language::Wasm => vec![],
Language::R => vec!["DESCRIPTION"],
Language::Kotlin => vec!["build.gradle.kts"],
Language::Gleam => vec!["gleam.toml"],
Language::Zig => vec!["build.zig"],
Language::Dart => vec!["pubspec.yaml"],
Language::Swift => vec!["Package.swift"],
_ => vec![],
};
for file in expected_files {
if !pkg_path.join(file).exists() {
issues.push(format!("{lang}: missing {pkg_dir}/{file}"));
}
}
if lang == Language::Ruby {
validate_ruby_gemspecs(&pkg_path, &pkg_dir, &mut issues);
}
validate_language_manifest(config, lang, workspace_path, &pkg_dir, &pkg_path, &mut issues);
}
Ok(issues)
}
fn validate_language_manifest(
config: &ResolvedCrateConfig,
lang: Language,
workspace_root: &Path,
pkg_dir: &str,
pkg_path: &Path,
issues: &mut Vec<String>,
) {
match lang {
Language::Elixir => validate_elixir_manifest(config, pkg_dir, pkg_path, issues),
Language::Php => validate_php_manifests(pkg_dir, pkg_path, workspace_root, issues),
Language::Csharp => validate_csharp_project(config, workspace_root, pkg_dir, issues),
Language::Go => validate_go_module(config, pkg_dir, pkg_path, issues),
Language::Java => validate_java_manifest(config, pkg_dir, pkg_path, issues),
Language::Dart => validate_dart_manifest(config, pkg_dir, pkg_path, issues),
Language::Swift => validate_swift_manifest(pkg_dir, pkg_path, issues),
Language::Zig => validate_zig_manifest(config, pkg_dir, pkg_path, issues),
_ => {}
}
}
fn validate_elixir_manifest(config: &ResolvedCrateConfig, pkg_dir: &str, pkg_path: &Path, issues: &mut Vec<String>) {
let mix_path = pkg_path.join("mix.exs");
let Ok(content) = std::fs::read_to_string(&mix_path) else {
return;
};
let targets = elixir_nif_targets(config).join(" ");
if !content.contains(&format!("targets: ~w({targets})")) {
issues.push(format!(
"elixir: {pkg_dir}/mix.exs rustler_crates targets must match configured nif_targets: {targets}"
));
}
}
fn validate_php_manifests(pkg_dir: &str, pkg_path: &Path, workspace_root: &Path, issues: &mut Vec<String>) {
let package_manifest = pkg_path.join("composer.json");
let root_manifest = workspace_root.join("composer.json");
let Ok(package_json) = read_json(&package_manifest) else {
return;
};
let Ok(root_json) = read_json(&root_manifest) else {
issues.push("php: missing root composer.json".to_string());
return;
};
if psr4_path(&package_json) != Some("src/") {
issues.push(format!("php: {pkg_dir}/composer.json PSR-4 path must be src/"));
}
if psr4_path(&root_json) != Some("packages/php/src/") {
issues.push("php: root composer.json PSR-4 path must be packages/php/src/".to_string());
}
let mut package_without_autoload = package_json.clone();
let mut root_without_autoload = root_json.clone();
if let Some(obj) = package_without_autoload.as_object_mut() {
obj.remove("autoload");
}
if let Some(obj) = root_without_autoload.as_object_mut() {
obj.remove("autoload");
}
if package_without_autoload != root_without_autoload {
issues.push("php: root composer.json metadata must stay in sync with packages/php/composer.json".to_string());
}
}
fn validate_csharp_project(
config: &ResolvedCrateConfig,
workspace_root: &Path,
pkg_dir: &str,
issues: &mut Vec<String>,
) {
let namespace = config.csharp_namespace();
let configured_project_file = config
.project_file_for_language(Language::Csharp)
.map(PathBuf::from)
.filter(|path| path.extension().is_some_and(|ext| ext == "csproj"));
let nested = PathBuf::from(pkg_dir)
.join(&namespace)
.join(format!("{namespace}.csproj"));
let root = PathBuf::from(pkg_dir).join(format!("{namespace}.csproj"));
let nested_path = workspace_root.join(&nested);
let root_path = workspace_root.join(&root);
let project_file = configured_project_file.unwrap_or_else(|| {
if nested_path.exists() {
nested.clone()
} else {
root.clone()
}
});
let project_path = if project_file.is_absolute() {
project_file.clone()
} else {
workspace_root.join(&project_file)
};
if root_path.exists() && nested_path.exists() {
issues.push(format!(
"csharp: stale root project {pkg_dir}/{namespace}.csproj exists; keep only {pkg_dir}/{namespace}/{namespace}.csproj"
));
}
let Ok(content) = std::fs::read_to_string(&project_path) else {
issues.push(format!("csharp: missing {}", project_file.display()));
return;
};
for required in [
r#"<None Include="../../../LICENSE" Pack="true" PackagePath="/" />"#,
r#"<None Include="runtimes/**" Pack="true" PackagePath="runtimes/" CopyToOutputDirectory="PreserveNewest" />"#,
r#"<Compile Include="../src/**/*.cs" />"#,
] {
if !content.contains(required) {
issues.push(format!("csharp: {namespace}.csproj missing expected item: {required}"));
}
}
}
fn validate_go_module(config: &ResolvedCrateConfig, pkg_dir: &str, pkg_path: &Path, issues: &mut Vec<String>) {
let go_mod = pkg_path.join("go.mod");
let Ok(content) = std::fs::read_to_string(&go_mod) else {
return;
};
let module = content
.lines()
.find_map(|line| line.strip_prefix("module ").map(str::trim));
let expected = config.go_module();
if module != Some(expected.as_str()) {
issues.push(format!("go: {pkg_dir}/go.mod module must be {expected}"));
return;
}
if let Some(major) = go_major_suffix(&expected) {
let expected_dir = format!("packages/go/{major}");
if pkg_dir != expected_dir {
issues.push(format!(
"go: module path {expected} requires package directory {expected_dir}; set go scaffold output or use a non-/vN module path"
));
}
}
}
fn validate_java_manifest(config: &ResolvedCrateConfig, pkg_dir: &str, pkg_path: &Path, issues: &mut Vec<String>) {
let pom = pkg_path.join("pom.xml");
let Ok(content) = std::fs::read_to_string(&pom) else {
return;
};
let group_id = config.java_group_id();
let artifact_id = config.java_artifact_id();
if !content.contains(&format!("<groupId>{group_id}</groupId>")) {
issues.push(format!("java: {pkg_dir}/pom.xml groupId must be {group_id}"));
}
if !content.contains(&format!("<artifactId>{artifact_id}</artifactId>")) {
issues.push(format!("java: {pkg_dir}/pom.xml artifactId must be {artifact_id}"));
}
}
fn validate_dart_manifest(config: &ResolvedCrateConfig, pkg_dir: &str, pkg_path: &Path, issues: &mut Vec<String>) {
let pubspec = pkg_path.join("pubspec.yaml");
let Ok(content) = std::fs::read_to_string(&pubspec) else {
return;
};
let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&content) else {
issues.push(format!("dart: {pkg_dir}/pubspec.yaml is not valid YAML"));
return;
};
let name = yaml.get("name").and_then(|v| v.as_str());
let expected = config.dart_pubspec_name();
if name != Some(expected.as_str()) {
issues.push(format!("dart: {pkg_dir}/pubspec.yaml name must be {expected}"));
}
for required in ["version", "description", "repository"] {
if yaml.get(required).is_none() {
issues.push(format!("dart: {pkg_dir}/pubspec.yaml missing {required}"));
}
}
}
fn validate_swift_manifest(pkg_dir: &str, pkg_path: &Path, issues: &mut Vec<String>) {
let pkg_manifest = pkg_path.join("Package.swift");
if let Ok(content) = std::fs::read_to_string(&pkg_manifest)
&& !content.contains("Sources/RustBridge")
{
issues.push(format!(
"swift: {pkg_dir}/Package.swift must include RustBridge source targets"
));
}
}
fn validate_zig_manifest(config: &ResolvedCrateConfig, pkg_dir: &str, pkg_path: &Path, issues: &mut Vec<String>) {
let zon = pkg_path.join("build.zig.zon");
let Ok(content) = std::fs::read_to_string(&zon) else {
issues.push(format!("zig: missing {pkg_dir}/build.zig.zon"));
return;
};
let expected_name = format!(".name = .{}", config.zig_module_name());
if !content.contains(&expected_name) {
issues.push(format!(
"zig: {pkg_dir}/build.zig.zon name must be {}",
config.zig_module_name()
));
}
for path in ["\"build.zig\"", "\"build.zig.zon\"", "\"src\""] {
if !content.contains(path) {
issues.push(format!("zig: {pkg_dir}/build.zig.zon paths must include {path}"));
}
}
}
fn read_json(path: &Path) -> Result<serde_json::Value> {
let content = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
serde_json::from_str(&content).with_context(|| format!("parsing {}", path.display()))
}
fn psr4_path(json: &serde_json::Value) -> Option<&str> {
json.get("autoload")?
.get("psr-4")?
.as_object()?
.values()
.next()?
.as_str()
}
fn go_major_suffix(module: &str) -> Option<String> {
let suffix = module.rsplit('/').next()?;
let major = suffix.strip_prefix('v')?;
if !major.is_empty() && major.chars().all(|c| c.is_ascii_digit()) && major.parse::<u32>().ok()? >= 2 {
Some(suffix.to_string())
} else {
None
}
}
fn elixir_nif_targets(config: &ResolvedCrateConfig) -> Vec<String> {
config
.elixir
.as_ref()
.filter(|elixir| !elixir.nif_targets.is_empty())
.map(|elixir| elixir.nif_targets.clone())
.unwrap_or_else(|| {
[
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-gnu",
]
.into_iter()
.map(str::to_string)
.collect()
})
}
fn validate_ruby_gemspecs(pkg_path: &Path, pkg_dir: &str, issues: &mut Vec<String>) {
let mut root_gemspecs = Vec::new();
let mut nested_gemspecs = Vec::new();
collect_gemspecs(pkg_path, pkg_path, &mut root_gemspecs, &mut nested_gemspecs);
if root_gemspecs.is_empty() {
issues.push(format!("ruby: missing {pkg_dir}/*.gemspec"));
}
for nested in nested_gemspecs {
issues.push(format!(
"ruby: stale nested gemspec {} (only {pkg_dir}/*.gemspec should remain)",
nested.display()
));
}
}
fn collect_gemspecs(root: &Path, dir: &Path, root_gemspecs: &mut Vec<PathBuf>, nested_gemspecs: &mut Vec<PathBuf>) {
if dir
.strip_prefix(root)
.ok()
.is_some_and(|rel| rel.components().any(|component| component.as_os_str() == "vendor"))
{
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
collect_gemspecs(root, &path, root_gemspecs, nested_gemspecs);
continue;
}
if !file_type.is_file() || path.extension().is_none_or(|ext| ext != "gemspec") {
continue;
}
if path.parent() == Some(root) {
root_gemspecs.push(path);
} else {
nested_gemspecs.push(path);
}
}
}
fn publish_config_for_language(config: &ResolvedCrateConfig, lang: Language) -> PublishLanguageConfig {
if let Some(publish) = &config.publish {
let lang_str = lang.to_string();
if let Some(lang_config) = publish.languages.get(&lang_str) {
return lang_config.clone();
}
}
PublishLanguageConfig::default()
}
fn resolve_core_crate_dir(config: &ResolvedCrateConfig) -> String {
if let Some(publish) = &config.publish {
if let Some(core_crate) = &publish.core_crate {
return core_crate.clone();
}
}
let dir = config.core_crate_dir();
if !config.sources.is_empty() {
let first = config.sources[0].to_string_lossy();
if first.contains("crates/") {
return format!("crates/{dir}");
}
}
dir
}
fn resolve_workspace_root(config: &ResolvedCrateConfig) -> String {
config
.workspace_root
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string())
}
fn resolve_binding_manifest(config: &ResolvedCrateConfig, lang: Language) -> Option<PathBuf> {
let pkg_dir = config.package_dir(lang);
match lang {
Language::Ruby => {
let ext = format!("{}_rb", config.core_crate_dir().replace('-', "_"));
Some(
Path::new(&pkg_dir)
.join("ext")
.join(ext)
.join("native")
.join("Cargo.toml"),
)
}
Language::Elixir => {
let nif = format!("{}_nif", config.elixir_app_name());
Some(Path::new(&pkg_dir).join("native").join(nif).join("Cargo.toml"))
}
Language::Python => {
let py_crate =
crate_name_from_output(config, Language::Python).unwrap_or_else(|| format!("{}-py", config.name));
Some(Path::new("crates").join(py_crate).join("Cargo.toml"))
}
Language::Php => {
let php_crate =
crate_name_from_output(config, Language::Php).unwrap_or_else(|| format!("{}-php", config.name));
Some(Path::new("crates").join(php_crate).join("Cargo.toml"))
}
Language::Swift => Some(Path::new(&pkg_dir).join("rust").join("Cargo.toml")),
_ => None,
}
}
fn assert_no_member_path_deps(
manifest_path: &Path,
members: &workspace::WorkspaceMembers,
lang: Language,
) -> Result<()> {
let content = match std::fs::read_to_string(manifest_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e).with_context(|| format!("reading {}", manifest_path.display())),
};
let doc: toml_edit::DocumentMut = content
.parse()
.with_context(|| format!("parsing {}", manifest_path.display()))?;
let section_has_member_path = |table: Option<&toml_edit::Item>| -> Option<String> {
let table = table?.as_table_like()?;
for (key, item) in table.iter() {
if members.names.contains(key) && item.as_table_like().is_some_and(|t| t.contains_key("path")) {
return Some(key.to_string());
}
}
None
};
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(dep) = section_has_member_path(doc.get(section)) {
anyhow::bail!(
"{lang}: workspace-member dependency '{dep}' in [{section}] of {} still has a `path` — \
did `alef publish prepare` run for Registry vendor mode?",
manifest_path.display()
);
}
}
if let Some(targets) = doc.get("target").and_then(|t| t.as_table_like()) {
for (cfg, cfg_item) in targets.iter() {
let Some(cfg_tbl) = cfg_item.as_table_like() else {
continue;
};
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(dep) = section_has_member_path(cfg_tbl.get(section)) {
anyhow::bail!(
"{lang}: workspace-member dependency '{dep}' in \
[target.{cfg}.{section}] of {} still has a `path` — \
did `alef publish prepare` run for Registry vendor mode?",
manifest_path.display()
);
}
}
}
}
Ok(())
}
fn resolve_vendor_dest(config: &ResolvedCrateConfig, lang: Language) -> String {
let pkg_dir = config.package_dir(lang);
match lang {
Language::Ruby => format!("{pkg_dir}/vendor"),
Language::Elixir => {
let app_name = config.elixir_app_name();
format!("{pkg_dir}/native/{app_name}/vendor")
}
Language::R => format!("{pkg_dir}/src/rust"),
_ => format!("{pkg_dir}/vendor"),
}
}
fn default_vendor_mode(lang: Language) -> VendorMode {
match lang {
Language::Ruby | Language::Elixir | Language::Python | Language::Php | Language::Swift => VendorMode::Registry,
Language::R => VendorMode::Full,
_ => VendorMode::None,
}
}
fn is_ffi_dependent(lang: Language) -> bool {
matches!(lang, Language::Go | Language::Java | Language::Csharp)
}
fn run_publish_hooks(lang: Language, lang_config: &PublishLanguageConfig) -> Result<bool> {
if let Some(precondition) = &lang_config.precondition {
let status = std::process::Command::new("sh")
.arg("-c")
.arg(precondition)
.status()
.with_context(|| format!("running precondition for {lang}: {precondition}"))?;
if !status.success() {
eprintln!("Skipping {lang}: precondition failed ({precondition})");
return Ok(false);
}
}
if let Some(before) = &lang_config.before {
for cmd in before.commands() {
run_shell_command(cmd)?;
}
}
Ok(true)
}
fn run_publish_after_hooks(lang: Language, lang_config: &PublishLanguageConfig) -> Result<()> {
if let Some(after) = &lang_config.after {
for cmd in after.commands() {
run_shell_command(cmd).with_context(|| format!("running after hook for {lang}: {cmd}"))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::output::StringOrVec;
#[cfg(not(target_os = "windows"))]
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn make_temp_marker_file() -> (TempDir, PathBuf) {
let temp_dir = TempDir::new().unwrap();
let marker = temp_dir.path().join("marker.txt");
(temp_dir, marker)
}
#[test]
#[cfg(not(target_os = "windows"))] fn test_run_publish_hooks_runs_before_only() {
let (_temp_dir, marker) = make_temp_marker_file();
let marker_str = marker.to_str().unwrap();
let config = PublishLanguageConfig {
before: Some(StringOrVec::Single(format!("echo 'before' > {marker_str}"))),
..Default::default()
};
let result = run_publish_hooks(Language::Python, &config);
assert!(result.is_ok());
assert!(marker.exists(), "before hook should have created marker file");
}
#[test]
fn test_run_publish_hooks_precondition_failure_skips() {
let (_temp_dir, marker) = make_temp_marker_file();
let marker_str = marker.to_str().unwrap();
let config = PublishLanguageConfig {
precondition: Some("false".to_string()), before: Some(StringOrVec::Single(format!("echo 'before' > {marker_str}"))),
..Default::default()
};
let result = run_publish_hooks(Language::Python, &config);
assert!(result.is_ok());
assert!(!marker.exists(), "before hook should not run when precondition fails");
}
#[cfg(not(target_os = "windows"))] #[test]
fn test_run_publish_after_hooks_runs_after_only() {
let (_temp_dir, marker) = make_temp_marker_file();
let marker_str = marker.to_str().unwrap();
let config = PublishLanguageConfig {
after: Some(StringOrVec::Single(format!("echo 'after' > {marker_str}"))),
..Default::default()
};
let result = run_publish_after_hooks(Language::Python, &config);
assert!(result.is_ok());
assert!(marker.exists(), "after hook should have created marker file");
let content = fs::read_to_string(&marker).unwrap();
assert!(content.contains("after"));
}
#[test]
fn default_vendor_mode_source_build_langs_use_registry() {
assert_eq!(default_vendor_mode(Language::Python), VendorMode::Registry);
assert_eq!(default_vendor_mode(Language::Ruby), VendorMode::Registry);
assert_eq!(default_vendor_mode(Language::Elixir), VendorMode::Registry);
assert_eq!(default_vendor_mode(Language::Php), VendorMode::Registry);
assert_eq!(default_vendor_mode(Language::Swift), VendorMode::Registry);
assert_eq!(default_vendor_mode(Language::R), VendorMode::Full);
assert_eq!(default_vendor_mode(Language::Zig), VendorMode::None);
}
fn ruby_validate_config(package_dir: &Path, version_manifest: &Path) -> ResolvedCrateConfig {
let cfg: crate::core::config::NewAlefConfig = toml::from_str(&format!(
r#"
[workspace]
languages = ["ruby"]
[[crates]]
name = "my-lib"
sources = ["src/lib.rs"]
version_from = "{}"
[crates.ruby]
scaffold_output = "{}"
"#,
version_manifest.display(),
package_dir.display(),
))
.unwrap();
cfg.resolve().unwrap().remove(0)
}
#[test]
fn validate_ruby_detects_nested_stale_gemspecs() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let package_dir = root.join("packages/ruby");
std::fs::create_dir_all(package_dir.join("ext/my_lib_rb")).unwrap();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"my-lib\"\nversion = \"1.2.3\"\n",
)
.unwrap();
std::fs::write(package_dir.join("my_lib.gemspec"), "Gem::Specification.new\n").unwrap();
std::fs::write(
package_dir.join("ext/my_lib_rb/my_lib.gemspec"),
"Gem::Specification.new\n",
)
.unwrap();
let config = ruby_validate_config(&package_dir, &root.join("Cargo.toml"));
let issues = validate(&config, &[Language::Ruby]).unwrap();
assert!(
issues.iter().any(|issue| issue.contains("stale nested gemspec")),
"nested gemspec must be reported; got: {issues:?}"
);
}
#[test]
fn validate_ruby_requires_root_gemspec() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let package_dir = root.join("packages/ruby");
std::fs::create_dir_all(&package_dir).unwrap();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"my-lib\"\nversion = \"1.2.3\"\n",
)
.unwrap();
let config = ruby_validate_config(&package_dir, &root.join("Cargo.toml"));
let issues = validate(&config, &[Language::Ruby]).unwrap();
assert!(
issues
.iter()
.any(|issue| issue.contains("missing") && issue.contains("*.gemspec")),
"missing root gemspec must be reported; got: {issues:?}"
);
}
fn validate_config_for(root: &Path, language: &str, extra: &str) -> ResolvedCrateConfig {
let cfg: crate::core::config::NewAlefConfig = toml::from_str(&format!(
r#"
[workspace]
languages = ["{language}"]
[[crates]]
name = "my-lib"
sources = ["src/lib.rs"]
version_from = "{}"
[crates.scaffold]
repository = "https://github.com/acme/my-lib"
description = "My library"
license = "MIT"
{extra}
"#,
root.join("Cargo.toml").display(),
))
.unwrap();
let mut config = cfg.resolve().unwrap().remove(0);
config.workspace_root = Some(root.to_path_buf());
config
}
#[test]
fn validate_go_reports_v2_layout_mismatch() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"my-lib\"\nversion = \"1.2.3\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("packages/go")).unwrap();
std::fs::write(
root.join("packages/go/go.mod"),
"module github.com/acme/my-lib/v2\n\ngo 1.26\n",
)
.unwrap();
let config = validate_config_for(
root,
"go",
r#"
[crates.go]
module = "github.com/acme/my-lib/v2"
"#,
);
let issues = validate(&config, &[Language::Go]).unwrap();
assert!(
issues
.iter()
.any(|issue| issue.contains("requires package directory packages/go/v2")),
"v2 module layout mismatch must be reported; got: {issues:?}"
);
}
#[test]
fn validate_php_reports_root_psr4_mismatch() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"my-lib\"\nversion = \"1.2.3\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("packages/php")).unwrap();
let composer = r#"{
"name": "acme/my-lib",
"autoload": {"psr-4": {"Acme\\MyLib\\": "src/"}}
}
"#;
std::fs::write(root.join("packages/php/composer.json"), composer).unwrap();
std::fs::write(root.join("composer.json"), composer).unwrap();
let config = validate_config_for(root, "php", "");
let issues = validate(&config, &[Language::Php]).unwrap();
assert!(
issues
.iter()
.any(|issue| issue.contains("PSR-4 path must be packages/php/src/")),
"root PSR-4 mismatch must be reported; got: {issues:?}"
);
}
#[test]
fn validate_csharp_reports_stale_root_project() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"my-lib\"\nversion = \"1.2.3\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("packages/csharp/MyLib")).unwrap();
let project = crate::scaffold::render_csharp_csproj(&validate_config_for(root, "csharp", ""), "1.2.3");
std::fs::write(root.join("packages/csharp/MyLib/MyLib.csproj"), &project).unwrap();
std::fs::write(root.join("packages/csharp/MyLib.csproj"), &project).unwrap();
let config = validate_config_for(root, "csharp", "");
let issues = validate(&config, &[Language::Csharp]).unwrap();
assert!(
issues.iter().any(|issue| issue.contains("stale root project")),
"stale root csproj must be reported; got: {issues:?}"
);
}
#[test]
fn validate_dart_and_zig_check_central_metadata() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"my-lib\"\nversion = \"1.2.3\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("packages/dart")).unwrap();
std::fs::write(
root.join("packages/dart/pubspec.yaml"),
"name: wrong\nversion: 1.2.3\ndescription: My library\nrepository: https://github.com/acme/my-lib\n",
)
.unwrap();
let dart_config = validate_config_for(root, "dart", "");
let dart_issues = validate(&dart_config, &[Language::Dart]).unwrap();
assert!(
dart_issues
.iter()
.any(|issue| issue.contains("pubspec.yaml name must be my_lib")),
"Dart name mismatch must be reported; got: {dart_issues:?}"
);
std::fs::create_dir_all(root.join("packages/zig")).unwrap();
std::fs::write(root.join("packages/zig/build.zig"), "").unwrap();
std::fs::write(
root.join("packages/zig/build.zig.zon"),
".{ .name = .wrong, .paths = .{} }\n",
)
.unwrap();
let zig_config = validate_config_for(root, "zig", "");
let zig_issues = validate(&zig_config, &[Language::Zig]).unwrap();
assert!(
zig_issues
.iter()
.any(|issue| issue.contains("build.zig.zon name must be my_lib")),
"Zig name mismatch must be reported; got: {zig_issues:?}"
);
}
#[test]
fn test_run_publish_after_hooks_no_after_is_noop() {
let config = PublishLanguageConfig::default();
let result = run_publish_after_hooks(Language::Python, &config);
assert!(result.is_ok(), "after hooks should succeed when not specified");
}
#[cfg(not(target_os = "windows"))] #[test]
fn test_run_publish_after_hooks_multiple_commands() {
let temp_dir = TempDir::new().unwrap();
let marker1 = temp_dir.path().join("marker1.txt");
let marker2 = temp_dir.path().join("marker2.txt");
let marker1_str = marker1.to_str().unwrap();
let marker2_str = marker2.to_str().unwrap();
let config = PublishLanguageConfig {
after: Some(StringOrVec::Multiple(vec![
format!("echo 'after1' > {marker1_str}"),
format!("echo 'after2' > {marker2_str}"),
])),
..Default::default()
};
let result = run_publish_after_hooks(Language::Python, &config);
assert!(result.is_ok());
assert!(marker1.exists(), "first after command should execute");
assert!(marker2.exists(), "second after command should execute");
}
#[test]
fn test_run_publish_after_hooks_failure_propagates_error() {
let config = PublishLanguageConfig {
after: Some(StringOrVec::Single("false".to_string())), ..Default::default()
};
let result = run_publish_after_hooks(Language::Python, &config);
assert!(result.is_err(), "after hook failure should propagate error");
}
#[cfg(not(target_os = "windows"))] #[test]
fn test_publish_hooks_full_lifecycle_success() {
let temp_dir = TempDir::new().unwrap();
let before_marker = temp_dir.path().join("before.txt");
let after_marker = temp_dir.path().join("after.txt");
let before_str = before_marker.to_str().unwrap();
let after_str = after_marker.to_str().unwrap();
let config = PublishLanguageConfig {
before: Some(StringOrVec::Single(format!("echo 'before' > {before_str}"))),
after: Some(StringOrVec::Single(format!("echo 'after' > {after_str}"))),
..Default::default()
};
let before_result = run_publish_hooks(Language::Python, &config);
assert!(before_result.is_ok());
assert!(before_marker.exists(), "before hook should run");
let after_result = run_publish_after_hooks(Language::Python, &config);
assert!(after_result.is_ok());
assert!(after_marker.exists(), "after hook should run on success");
}
fn setup_registry_workspace() -> (TempDir, ResolvedCrateConfig) {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::write(
root.join("Cargo.toml"),
r#"
[workspace]
resolver = "2"
members = ["crates/my-lib", "crates/my-lib-py"]
[workspace.package]
version = "3.1.4"
"#,
)
.unwrap();
std::fs::create_dir_all(root.join("crates/my-lib/src")).unwrap();
std::fs::write(root.join("crates/my-lib/src/lib.rs"), "pub fn hi() {}").unwrap();
std::fs::write(
root.join("crates/my-lib/Cargo.toml"),
"[package]\nname = \"my-lib\"\nversion = \"3.1.4\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("crates/my-lib-py/src")).unwrap();
std::fs::write(root.join("crates/my-lib-py/src/lib.rs"), "pub fn hi() {}").unwrap();
std::fs::write(
root.join("crates/my-lib-py/Cargo.toml"),
r#"
[package]
name = "my-lib-py"
version = "3.1.4"
edition = "2021"
[dependencies]
my-lib = { path = "../my-lib", features = ["x"] }
anyhow = "1"
"#,
)
.unwrap();
let cfg: crate::core::config::NewAlefConfig = toml::from_str(
r#"
[workspace]
languages = ["python"]
[[crates]]
name = "my-lib"
sources = ["crates/my-lib/src/lib.rs"]
"#,
)
.unwrap();
let mut config = cfg.resolve().unwrap().remove(0);
config.workspace_root = Some(root.to_path_buf());
config.version_from = root.join("Cargo.toml").to_string_lossy().to_string();
(tmp, config)
}
fn read_py_manifest(root: &Path) -> toml_edit::DocumentMut {
let manifest = root.join("crates/my-lib-py/Cargo.toml");
std::fs::read_to_string(manifest).unwrap().parse().unwrap()
}
#[test]
fn resolve_binding_manifest_python_path() {
let (_tmp, config) = setup_registry_workspace();
let path = resolve_binding_manifest(&config, Language::Python).unwrap();
assert_eq!(path, Path::new("crates").join("my-lib-py").join("Cargo.toml"));
}
#[test]
fn resolve_binding_manifest_zig_is_none() {
let (_tmp, config) = setup_registry_workspace();
assert!(resolve_binding_manifest(&config, Language::Zig).is_none());
}
#[test]
fn prepare_registry_rewrites_member_path_deps() {
let (tmp, config) = setup_registry_workspace();
let root = tmp.path();
prepare(&config, &[Language::Python], None, false, false).unwrap();
let doc = read_py_manifest(root);
let deps = doc["dependencies"].as_table().unwrap();
let my_lib = deps["my-lib"].as_inline_table().unwrap();
assert_eq!(my_lib.get("version").and_then(|v| v.as_str()), Some("3.1.4"));
assert!(my_lib.get("path").is_none(), "path must be stripped");
assert!(my_lib.get("features").is_some(), "features preserved");
assert_eq!(deps["anyhow"].as_str(), Some("1"));
}
#[test]
fn prepare_registry_dry_run_mutates_nothing() {
let (tmp, config) = setup_registry_workspace();
let root = tmp.path();
let before = std::fs::read_to_string(root.join("crates/my-lib-py/Cargo.toml")).unwrap();
prepare(&config, &[Language::Python], None, true, false).unwrap();
let after = std::fs::read_to_string(root.join("crates/my-lib-py/Cargo.toml")).unwrap();
assert_eq!(before, after, "dry-run must not modify the manifest");
let doc: toml_edit::DocumentMut = after.parse().unwrap();
let my_lib = doc["dependencies"]["my-lib"].as_inline_table().unwrap();
assert!(my_lib.get("path").is_some(), "dry-run leaves path intact");
}
#[test]
fn assert_no_member_path_deps_detects_skipped_prepare() {
let (_tmp, config) = setup_registry_workspace();
let ws_root = config.workspace_root.clone().unwrap();
let manifest = ws_root.join(resolve_binding_manifest(&config, Language::Python).unwrap());
let members = workspace::workspace_member_crates(&ws_root).unwrap();
let err = assert_no_member_path_deps(&manifest, &members, Language::Python).unwrap_err();
assert!(err.to_string().contains("still has a `path`"), "got: {err}");
vendor::rewrite_path_deps_to_registry(&manifest, &members, "3.1.4").unwrap();
assert_no_member_path_deps(&manifest, &members, Language::Python).unwrap();
}
}