use crate::core::backend::GeneratedFile;
use crate::core::config::{AdapterPattern, FfiTargetDepOverride, Language, ResolvedCrateConfig};
use crate::core::ir::ApiSurface;
use crate::core::template_versions as tv;
use crate::{
scaffold::cargo_package_header, scaffold::core_dep_features, scaffold::detect_workspace_inheritance_for_crate,
scaffold::render_extra_deps, scaffold::scaffold_meta,
};
use std::path::PathBuf;
fn render_core_dep(
crate_name: &str,
rel_path: &str,
version: &str,
default_features: &str,
overrides: &[FfiTargetDepOverride],
) -> (String, String) {
if overrides.is_empty() {
let line = format!("{crate_name} = {{ path = \"{rel_path}\", version = \"{version}\"{default_features} }}");
return (line, String::new());
}
let cfgs: Vec<String> = overrides.iter().map(|o| o.cfg.clone()).collect();
let combined_cfg = if cfgs.len() == 1 {
cfgs[0].clone()
} else {
format!("any({})", cfgs.join(", "))
};
let mut entries: Vec<(String, String)> = vec![(
format!("not({combined_cfg})"),
format!("{crate_name} = {{ path = \"{rel_path}\", version = \"{version}\"{default_features} }}"),
)];
for override_ in overrides {
let default_block = if override_.default_features {
String::new()
} else {
", default-features = false".to_string()
};
let features_str = if override_.features.is_empty() {
String::new()
} else {
let quoted: Vec<String> = override_.features.iter().map(|f| format!("\"{f}\"")).collect();
format!(", features = [{}]", quoted.join(", "))
};
entries.push((
override_.cfg.clone(),
format!("{crate_name} = {{ path = \"{rel_path}\", version = \"{version}\"{default_block}{features_str} }}"),
));
}
(String::new(), crate::scaffold::join_sorted_target_dep_blocks(entries))
}
pub(crate) fn scaffold_ffi(api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
let meta = scaffold_meta(config);
let version = &api.version;
let core_crate_dir = config.core_crate_dir();
let crate_dir = format!("crates/{core_crate_dir}-ffi");
let ws = detect_workspace_inheritance_for_crate(config.workspace_root.as_deref(), &crate_dir);
let pkg_header = cargo_package_header(&format!("{core_crate_dir}-ffi"), version, "2024", &meta, &ws);
let rendered_extra_deps = render_extra_deps(config, Language::Ffi);
let mut extra_dep_lines: Vec<String> = if rendered_extra_deps.is_empty() {
Vec::new()
} else {
rendered_extra_deps.lines().map(str::to_string).collect()
};
let has_trait_bridges = !config.trait_bridges.is_empty();
if has_trait_bridges && !extra_dep_lines.iter().any(|l| l.starts_with("async-trait")) {
extra_dep_lines.push(format!("async-trait = \"{}\"", tv::cargo::ASYNC_TRAIT));
}
if has_trait_bridges && !extra_dep_lines.iter().any(|l| l.starts_with("tracing")) {
extra_dep_lines.push(format!("tracing = \"{}\"", tv::cargo::TRACING));
}
let has_streaming = config
.adapters
.iter()
.any(|a| matches!(a.pattern, AdapterPattern::Streaming));
if has_streaming && !extra_dep_lines.iter().any(|l| l.starts_with("futures-util")) {
extra_dep_lines.push("futures-util = \"0.3\"".to_string());
}
if let Some(ffi) = config.ffi.as_ref() {
for capsule in ffi.capsule_types.values() {
let (Some(package), Some(version)) = (capsule.package.as_ref(), capsule.package_version.as_ref()) else {
continue;
};
let dep_prefix = format!("{package} ");
if !extra_dep_lines.iter().any(|l| l.starts_with(&dep_prefix)) {
extra_dep_lines.push(format!("{package} = \"{version}\""));
}
}
}
crate::scaffold::sort_dependency_lines(&mut extra_dep_lines);
let mut machete_ignored: Vec<&str> = vec!["ahash", "serde", "serde_json", "tokio"];
if has_trait_bridges {
machete_ignored.push("async-trait");
machete_ignored.push("tracing");
}
if has_streaming {
machete_ignored.push("futures-util");
}
let machete_ignored_str = machete_ignored
.iter()
.map(|d| format!("\"{d}\""))
.collect::<Vec<_>>()
.join(", ");
let target_overrides: &[FfiTargetDepOverride] = config
.ffi
.as_ref()
.map(|c| c.target_dep_overrides.as_slice())
.unwrap_or(&[]);
let core_dep_path = config.core_crate_dep_path(std::path::Path::new(&crate_dir));
let (core_dep_line, target_blocks) = render_core_dep(
&config.name,
&core_dep_path,
version,
&core_dep_features(config, Language::Ffi),
target_overrides,
);
let ffi_core_features = config.features_for_language(Language::Ffi);
let passthrough_feature_names: Vec<&str> = ffi_core_features
.iter()
.map(|f| f.as_str())
.filter(|f| *f != "serde")
.collect();
let extra_declared: &[String] = config.ffi.as_ref().map(|c| c.extra_features.as_slice()).unwrap_or(&[]);
let emitted_cfg_features: Vec<String> = crate::codegen::cfg::collect_cfg_features(api)
.into_iter()
.filter(|name| {
!name.is_empty()
&& name != "serde"
&& !passthrough_feature_names.contains(&name.as_str())
&& !extra_declared.iter().any(|declared| declared == name)
})
.collect();
let default_feature_names: Vec<&str> = passthrough_feature_names
.iter()
.copied()
.chain(emitted_cfg_features.iter().map(String::as_str))
.collect();
let core_features_default_list = default_feature_names
.iter()
.map(|f| format!("\"{f}\""))
.collect::<Vec<_>>()
.join(", ");
let mut core_features_passthrough_block = if default_feature_names.is_empty() {
String::new()
} else {
default_feature_names
.iter()
.map(|f| format!("{f} = [\"{}/{f}\"]", config.name))
.collect::<Vec<_>>()
.join("\n")
};
if let Some(line) = crate::scaffold::android_target_feature_line(config, &default_feature_names) {
if core_features_passthrough_block.is_empty() {
core_features_passthrough_block = line;
} else {
core_features_passthrough_block.push('\n');
core_features_passthrough_block.push_str(&line);
}
}
if let Some(extra) = config.ffi.as_ref().map(|c| c.extra_features.as_slice()) {
for feat in extra {
if feat.is_empty() || passthrough_feature_names.contains(&feat.as_str()) {
continue;
}
let line = format!("{feat} = [\"{}/{feat}\"]", config.name);
if core_features_passthrough_block.is_empty() {
core_features_passthrough_block = line;
} else {
core_features_passthrough_block.push('\n');
core_features_passthrough_block.push_str(&line);
}
}
}
let target_blocks_section = if target_blocks.is_empty() {
String::new()
} else {
format!("\n{target_blocks}\n")
};
let mut dep_entries: Vec<String> = vec![
"ahash = \"0.8\"".to_string(),
format!("serde = \"{}\"", tv::cargo::SERDE),
"serde_json = \"1\"".to_string(),
"tokio = { version = \"1\", features = [\"full\"] }".to_string(),
];
if !core_dep_line.is_empty() {
dep_entries.push(core_dep_line.clone());
}
for line in &extra_dep_lines {
dep_entries.push(line.clone());
}
crate::scaffold::sort_dependency_lines(&mut dep_entries);
let dep_block = dep_entries.join("\n");
let repository_line = meta
.configured_repository
.as_deref()
.map(|repository| format!("\nrepository = \"{repository}\""))
.unwrap_or_default();
let lints_section = crate::scaffold::cargo_lints_section(config);
let content = format!(
r#"{pkg_header}{repository_line}
# `serde`, `serde_json`, `ahash`, and `tokio` are emitted unconditionally above so the
# manifest is stable across regens (and so the C FFI codegen can pull them in
# when an async / Result-typed function appears in the API surface), but for
# umbrella crates with no async fns and no JSON-marshalled return types they
# are genuinely unused. The conditional `async-trait` / `futures-util` deps
# are similarly flagged when the umbrella has trait-bridge / streaming adapters
# configured but no actual async-trait / async-stream callsite in the generated
# FFI shim.
[package.metadata.cargo-machete]
ignored = [{machete_ignored_str}]
[lib]
crate-type = ["cdylib", "staticlib", "rlib"]
[features]
default = [{core_features_default_list}]
{core_features_passthrough_block}
[dependencies]
{dep_block}
{target_blocks_section}
[build-dependencies]
cbindgen = "{cbindgen}"
[dev-dependencies]
tempfile = "{tempfile}"
{lints_section}"#,
pkg_header = pkg_header,
repository_line = repository_line,
lints_section = lints_section,
dep_block = dep_block,
target_blocks_section = target_blocks_section,
cbindgen = tv::cargo::CBINDGEN,
tempfile = tv::cargo::TEMPFILE,
machete_ignored_str = machete_ignored_str,
core_features_default_list = core_features_default_list,
core_features_passthrough_block = core_features_passthrough_block,
);
let ffi_name = format!("{core_crate_dir}-ffi");
let header_name = config.ffi_header_name();
let lib_name = config.ffi_lib_name();
let ffi_name_under = ffi_name.replace('-', "_");
let cmake_content = format!(
r#"# {ffi_name} CMake config-mode find module
#
# Defines the imported target:
# {ffi_name}::{ffi_name}
#
# Usage:
# find_package({ffi_name} REQUIRED)
# target_link_libraries(myapp PRIVATE {ffi_name}::{ffi_name})
if(TARGET {ffi_name}::{ffi_name})
return()
endif()
get_filename_component(_FFI_CMAKE_DIR "${{CMAKE_CURRENT_LIST_FILE}}" PATH)
get_filename_component(_FFI_PREFIX "${{_FFI_CMAKE_DIR}}/.." ABSOLUTE)
find_library(_FFI_LIBRARY
NAMES {lib_name} lib{lib_name}
PATHS "${{_FFI_PREFIX}}/lib"
NO_DEFAULT_PATH
)
if(NOT _FFI_LIBRARY)
find_library(_FFI_LIBRARY NAMES {lib_name} lib{lib_name})
endif()
find_path(_FFI_INCLUDE_DIR
NAMES {header_name}
PATHS "${{_FFI_PREFIX}}/include"
NO_DEFAULT_PATH
)
if(NOT _FFI_INCLUDE_DIR)
find_path(_FFI_INCLUDE_DIR NAMES {header_name})
endif()
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args({ffi_name}
REQUIRED_VARS _FFI_LIBRARY _FFI_INCLUDE_DIR
)
if({ffi_name_under}_FOUND)
set(_FFI_LIB_TYPE UNKNOWN)
if(_FFI_LIBRARY MATCHES "\\.(dylib|so)$" OR _FFI_LIBRARY MATCHES "\\.so\\.")
set(_FFI_LIB_TYPE SHARED)
elseif(_FFI_LIBRARY MATCHES "\\.dll$")
set(_FFI_LIB_TYPE SHARED)
elseif(_FFI_LIBRARY MATCHES "\\.(a|lib)$")
set(_FFI_LIB_TYPE STATIC)
endif()
add_library({ffi_name}::{ffi_name} ${{_FFI_LIB_TYPE}} IMPORTED)
set_target_properties({ffi_name}::{ffi_name} PROPERTIES
IMPORTED_LOCATION "${{_FFI_LIBRARY}}"
INTERFACE_INCLUDE_DIRECTORIES "${{_FFI_INCLUDE_DIR}}"
)
if(WIN32 AND _FFI_LIB_TYPE STREQUAL "SHARED")
find_file(_FFI_DLL
NAMES {lib_name}.dll lib{lib_name}.dll
PATHS "${{_FFI_PREFIX}}/bin" "${{_FFI_PREFIX}}/lib"
NO_DEFAULT_PATH
)
if(_FFI_DLL)
set_target_properties({ffi_name}::{ffi_name} PROPERTIES
IMPORTED_LOCATION "${{_FFI_DLL}}"
IMPORTED_IMPLIB "${{_FFI_LIBRARY}}"
)
endif()
unset(_FFI_DLL CACHE)
endif()
if(APPLE)
set_property(TARGET {ffi_name}::{ffi_name} APPEND PROPERTY
INTERFACE_LINK_LIBRARIES "-framework CoreFoundation" "-framework Security" pthread)
elseif(UNIX)
set_property(TARGET {ffi_name}::{ffi_name} APPEND PROPERTY
INTERFACE_LINK_LIBRARIES pthread dl m)
elseif(WIN32)
set_property(TARGET {ffi_name}::{ffi_name} APPEND PROPERTY
INTERFACE_LINK_LIBRARIES ws2_32 userenv bcrypt)
endif()
unset(_FFI_LIB_TYPE)
endif()
mark_as_advanced(_FFI_LIBRARY _FFI_INCLUDE_DIR)
unset(_FFI_CMAKE_DIR)
unset(_FFI_PREFIX)
"#,
ffi_name = ffi_name,
ffi_name_under = ffi_name_under,
lib_name = lib_name,
header_name = header_name,
);
Ok(vec![
GeneratedFile {
path: PathBuf::from(format!("crates/{}-ffi/Cargo.toml", core_crate_dir)),
content,
generated_header: true,
},
GeneratedFile {
path: PathBuf::from(format!(
"crates/{}-ffi/cmake/{}-ffi-config.cmake",
core_crate_dir, core_crate_dir
)),
content: cmake_content,
generated_header: true,
},
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_core_dep_includes_version_in_default_line() {
let (core_dep_line, target_blocks) =
render_core_dep("my-lib", "../my-lib-core", "1.2.3", ", features = [\"foo\"]", &[]);
assert!(!core_dep_line.is_empty(), "Expected non-empty core_dep_line");
assert!(
core_dep_line.contains("version = \"1.2.3\""),
"Expected 'version = \"1.2.3\"' in core_dep_line: {}",
core_dep_line
);
assert!(
core_dep_line.contains("path = \"../my-lib-core\""),
"Expected path reference in core_dep_line: {}",
core_dep_line
);
assert!(target_blocks.is_empty(), "Expected empty target_blocks");
}
#[test]
fn test_render_core_dep_includes_version_in_target_blocks() {
let overrides = vec![FfiTargetDepOverride {
cfg: "target_os = \"windows\"".to_string(),
features: vec!["windows-feature".to_string()],
default_features: true,
}];
let (core_dep_line, target_blocks) = render_core_dep("my-lib", "../my-lib-core", "2.0.0", "", &overrides);
assert!(
core_dep_line.is_empty(),
"Expected empty core_dep_line when overrides present"
);
assert!(!target_blocks.is_empty(), "Expected non-empty target_blocks");
assert!(
target_blocks.contains("version = \"2.0.0\""),
"Expected 'version = \"2.0.0\"' in target_blocks: {}",
target_blocks
);
assert!(
target_blocks.contains("path = \"../my-lib-core\""),
"Expected path reference in target_blocks: {}",
target_blocks
);
assert!(
!target_blocks.contains("default-features"),
"default_features: true must not emit a default-features key: {}",
target_blocks
);
}
#[test]
fn test_render_core_dep_emits_default_features_false_when_override_disables_it() {
let overrides = vec![FfiTargetDepOverride {
cfg: "target_os = \"windows\"".to_string(),
features: vec!["windows-target".to_string()],
default_features: false,
}];
let (core_dep_line, target_blocks) = render_core_dep("my-lib", "../my-lib-core", "2.0.0", "", &overrides);
assert!(
core_dep_line.is_empty(),
"Expected empty core_dep_line when overrides present"
);
assert!(
target_blocks.contains("default-features = false"),
"default_features: false must emit default-features = false: {}",
target_blocks
);
assert!(
target_blocks.contains(r#"features = ["windows-target"]"#),
"override block must still emit its feature list: {}",
target_blocks
);
}
#[test]
fn ffi_cargo_toml_repository_line_has_no_double_blank_line() {
let repository = "https://github.com/example/my-lib";
let repository_line = format!("\nrepository = \"{repository}\"");
let pkg_header = "[package]\nname = \"my-lib-ffi\"\nversion = \"1.0.0\"";
let content = format!("{pkg_header}{repository_line}\n\n# comment\n");
assert!(
!content.contains("repository = \"https://github.com/example/my-lib\"\n\n\n"),
"double blank line found after repository — cargo-sort will remove one, causing prek oscillation:\n{content}"
);
assert!(
content.contains("repository = \"https://github.com/example/my-lib\"\n\n# comment"),
"expected exactly one blank line between repository and comment:\n{content}"
);
}
fn resolve_config(toml_text: &str) -> ResolvedCrateConfig {
let cfg: crate::core::config::NewAlefConfig = toml::from_str(toml_text).expect("valid config");
cfg.resolve().expect("resolve").remove(0)
}
#[test]
fn ffi_cargo_toml_injects_capsule_package_dependency() {
let config = resolve_config(
r#"
[workspace]
languages = ["ffi"]
[[crates]]
name = "my-lib"
sources = []
[crates.ffi.capsule_types.Language]
into_raw_type = "tree_sitter::ffi::TSLanguage"
c_return_type = "TSLanguage"
package = "tree-sitter"
package_version = "0.26"
"#,
);
let api = crate::core::ir::ApiSurface::default();
let files = scaffold_ffi(&api, &config).expect("scaffold");
let cargo = files
.iter()
.find(|f| f.path.ends_with("Cargo.toml"))
.expect("ffi Cargo.toml emitted");
assert!(
cargo.content.contains("tree-sitter = \"0.26\""),
"capsule package dep must be injected into FFI Cargo.toml, got:\n{}",
cargo.content
);
}
#[test]
fn ffi_cargo_toml_omits_capsule_dep_when_package_unset() {
let config = resolve_config(
r#"
[workspace]
languages = ["ffi"]
[[crates]]
name = "my-lib"
sources = []
[crates.ffi.capsule_types.Language]
into_raw_type = "tree_sitter::ffi::TSLanguage"
c_return_type = "TSLanguage"
"#,
);
let api = crate::core::ir::ApiSurface::default();
let files = scaffold_ffi(&api, &config).expect("scaffold");
let cargo = files
.iter()
.find(|f| f.path.ends_with("Cargo.toml"))
.expect("ffi Cargo.toml emitted");
assert!(
!cargo.content.contains("tree-sitter ="),
"no capsule dep should be injected when package is unset, got:\n{}",
cargo.content
);
}
fn features_section(cargo: &str) -> &str {
let start = cargo.find("[features]").expect("features table emitted");
let rest = &cargo[start..];
rest.find("\n[").map(|end| &rest[..end]).unwrap_or(rest)
}
fn minimal_config() -> ResolvedCrateConfig {
resolve_config(
r#"
[workspace]
languages = ["ffi"]
[[crates]]
name = "my-lib"
sources = []
"#,
)
}
#[test]
fn ffi_cargo_toml_declares_features_named_by_emitted_type_cfg_gates() {
let api = ApiSurface {
crate_name: "my-lib".to_string(),
types: vec![crate::core::ir::TypeDef {
name: "BudgetConfig".to_string(),
rust_path: "my_lib::BudgetConfig".to_string(),
cfg: Some(r#"feature = "tower""#.to_string()),
..Default::default()
}],
..Default::default()
};
let files = scaffold_ffi(&api, &minimal_config()).expect("scaffold");
let cargo = &files
.iter()
.find(|f| f.path.ends_with("Cargo.toml"))
.expect("ffi Cargo.toml emitted")
.content;
let features = features_section(cargo);
assert!(
features.contains(r#"tower = ["my-lib/tower"]"#),
"gate feature must be declared as a passthrough, got:\n{features}"
);
assert!(
features.contains("default = [") && features.contains("\"tower\""),
"gate feature must default ON so the gated export survives, got:\n{features}"
);
}
#[test]
fn ffi_cargo_toml_declares_features_named_by_emitted_function_cfg_gates() {
let api = ApiSurface {
crate_name: "my-lib".to_string(),
functions: vec![crate::core::ir::FunctionDef {
name: "count_tokens".to_string(),
cfg: Some(r#"feature = "tokenizer""#.to_string()),
..Default::default()
}],
..Default::default()
};
let files = scaffold_ffi(&api, &minimal_config()).expect("scaffold");
let cargo = &files
.iter()
.find(|f| f.path.ends_with("Cargo.toml"))
.expect("ffi Cargo.toml emitted")
.content;
let features = features_section(cargo);
assert!(
features.contains(r#"tokenizer = ["my-lib/tokenizer"]"#),
"function gate feature must be declared as a passthrough, got:\n{features}"
);
}
#[test]
fn ffi_cargo_toml_keeps_extra_features_out_of_default_even_when_gated() {
let config = resolve_config(
r#"
[workspace]
languages = ["ffi"]
[[crates]]
name = "my-lib"
sources = []
[crates.ffi]
extra_features = ["wasm-http"]
"#,
);
let api = ApiSurface {
crate_name: "my-lib".to_string(),
functions: vec![crate::core::ir::FunctionDef {
name: "fetch".to_string(),
cfg: Some(r#"feature = "wasm-http""#.to_string()),
..Default::default()
}],
..Default::default()
};
let files = scaffold_ffi(&api, &config).expect("scaffold");
let cargo = &files
.iter()
.find(|f| f.path.ends_with("Cargo.toml"))
.expect("ffi Cargo.toml emitted")
.content;
let features = features_section(cargo);
assert!(
features.contains(r#"wasm-http = ["my-lib/wasm-http"]"#),
"extra_features entry must still be declared, got:\n{features}"
);
let default_line = features
.lines()
.find(|line| line.starts_with("default = ["))
.expect("default line emitted");
assert!(
!default_line.contains("wasm-http"),
"extra_features must stay opt-in, got: {default_line}"
);
}
#[test]
fn ffi_cargo_toml_declares_every_feature_named_by_any_emitted_gate() {
let api = ApiSurface {
crate_name: "my-lib".to_string(),
types: vec![crate::core::ir::TypeDef {
name: "CacheConfig".to_string(),
rust_path: "my_lib::CacheConfig".to_string(),
cfg: Some(r#"any(feature = "tower", feature = "opendal-cache")"#.to_string()),
..Default::default()
}],
functions: vec![crate::core::ir::FunctionDef {
name: "record_cost_usd".to_string(),
cfg: Some(r#"feature = "metrics""#.to_string()),
..Default::default()
}],
..Default::default()
};
let files = scaffold_ffi(&api, &minimal_config()).expect("scaffold");
let cargo = &files
.iter()
.find(|f| f.path.ends_with("Cargo.toml"))
.expect("ffi Cargo.toml emitted")
.content;
let features = features_section(cargo);
for name in ["tower", "opendal-cache", "metrics"] {
assert!(
features.contains(&format!(r#"{name} = ["my-lib/{name}"]"#)),
"feature `{name}` is gated on but never declared, got:\n{features}"
);
}
}
#[test]
fn ffi_cargo_toml_emits_configured_cargo_lints() {
let config = resolve_config(
r#"
[workspace]
languages = ["ffi"]
[[crates]]
name = "my-lib"
sources = []
[crates.cargo_lints.rust]
unused_must_use = "deny"
[crates.cargo_lints.clippy]
print_stdout = "deny"
"#,
);
let api = crate::core::ir::ApiSurface::default();
let files = scaffold_ffi(&api, &config).expect("scaffold");
let cargo = &files
.iter()
.find(|f| f.path.ends_with("Cargo.toml"))
.expect("ffi Cargo.toml emitted")
.content;
assert!(
cargo.contains("\n\n[lints.rust]\nunused_must_use = \"deny\""),
"expected a blank line before [lints.rust], got:\n{cargo}"
);
assert!(
!cargo.contains("\n\n\n[lints.rust]"),
"expected exactly one blank line before [lints.rust], not more, got:\n{cargo}"
);
assert!(
cargo.contains(
"[lints.rust]\nunused_must_use = \"deny\"\n\n\
# This crate deliberately does not use `[lints]` / `workspace = true`"
),
"expected exactly one blank line between [lints.rust] and the [lints.clippy] rationale \
comment, got:\n{cargo}"
);
assert!(
cargo.contains(
"can actually satisfy. ~keep\n\
[lints.clippy]\ndbg_macro = \"deny\"\nprint_stderr = \"deny\"\nprint_stdout = \"deny\""
),
"expected the rationale comment to attach directly to [lints.clippy] (no blank line), and the \
configured print_stdout to merge with the builtin dbg_macro/print_stderr defaults, got:\n{cargo}"
);
assert!(
cargo.trim_end().ends_with("print_stdout = \"deny\""),
"expected [lints.clippy] to be the final table, got:\n{cargo}"
);
assert!(
cargo.len() - cargo.trim_end_matches('\n').len() <= 2,
"expected at most one trailing blank line after [lints.clippy], got:\n{cargo}"
);
toml::from_str::<toml::Value>(cargo).expect("generated Cargo.toml with cargo_lints must be valid TOML");
}
#[test]
fn ffi_cargo_toml_emits_builtin_clippy_denies_when_cargo_lints_unset() {
let files = scaffold_ffi(&crate::core::ir::ApiSurface::default(), &minimal_config()).expect("scaffold");
let cargo = &files
.iter()
.find(|f| f.path.ends_with("Cargo.toml"))
.expect("ffi Cargo.toml emitted")
.content;
assert!(
!cargo.contains("[lints.rust]"),
"no [lints.rust] table should be emitted when cargo_lints.rust is unset, got:\n{cargo}"
);
assert!(
cargo.contains("[lints.clippy]\ndbg_macro = \"deny\"\nprint_stderr = \"deny\"\nprint_stdout = \"deny\""),
"the builtin [lints.clippy] deny block must survive even when cargo_lints is unset, got:\n{cargo}"
);
toml::from_str::<toml::Value>(cargo).expect("generated Cargo.toml must be valid TOML");
}
#[test]
fn ffi_manifest_reconciliation_adds_runtime_dependencies_and_cfg_features() {
let api = ApiSurface {
crate_name: "my-lib".to_string(),
types: vec![crate::core::ir::TypeDef {
name: "BudgetConfig".to_string(),
rust_path: "my_lib::BudgetConfig".to_string(),
cfg: Some(r#"feature = "tower""#.to_string()),
..Default::default()
}],
functions: vec![crate::core::ir::FunctionDef {
name: "count_tokens".to_string(),
cfg: Some(r#"feature = "tokenizer""#.to_string()),
..Default::default()
}],
..Default::default()
};
let files = scaffold_ffi(&api, &minimal_config()).expect("scaffold");
let directory = tempfile::tempdir().expect("temporary repository");
let manifest_path = directory.path().join("crates/my-lib-ffi/Cargo.toml");
std::fs::create_dir_all(manifest_path.parent().expect("manifest parent")).expect("create manifest parent");
std::fs::write(
&manifest_path,
"# This file is auto-generated by alef — DO NOT EDIT.\n# alef:hash:old\n\
[features]\ndefault = [\"full\"]\nfull = [\"my-lib/full\"]\n\
[dependencies]\nserde_json = \"1\"\n",
)
.expect("write stale generated manifest");
crate::cli::pipeline::reconcile_managed_scaffold_manifests(&files, directory.path())
.expect("reconcile generated FFI manifest");
let reconciled = std::fs::read_to_string(manifest_path).expect("read reconciled manifest");
assert!(reconciled.contains("serde = \"1\""), "{reconciled}");
assert!(reconciled.contains(r#"tower = ["my-lib/tower"]"#), "{reconciled}");
assert!(
reconciled.contains(r#"tokenizer = ["my-lib/tokenizer"]"#),
"{reconciled}"
);
let parsed: toml::Value = toml::from_str(&reconciled).expect("valid reconciled TOML");
let defaults = parsed["features"]["default"].as_array().expect("default feature array");
for feature in ["tower", "tokenizer"] {
assert!(
defaults.iter().any(|value| value.as_str() == Some(feature)),
"{reconciled}"
);
}
}
#[test]
fn ffi_manifest_reconciliation_keeps_the_clippy_deny_block_with_no_cargo_lints_configured() {
let files = scaffold_ffi(&crate::core::ir::ApiSurface::default(), &minimal_config()).expect("scaffold");
let directory = tempfile::tempdir().expect("temporary repository");
let manifest_path = directory.path().join("crates/my-lib-ffi/Cargo.toml");
std::fs::create_dir_all(manifest_path.parent().expect("manifest parent")).expect("create manifest parent");
std::fs::write(
&manifest_path,
"# This file is auto-generated by alef — DO NOT EDIT.\n# alef:hash:old\n\
[lints.clippy]\nprint_stdout = \"deny\"\nprint_stderr = \"deny\"\ndbg_macro = \"deny\"\n",
)
.expect("write stale generated manifest carrying a hand-added clippy deny block");
crate::cli::pipeline::reconcile_managed_scaffold_manifests(&files, directory.path())
.expect("reconcile generated FFI manifest");
let reconciled = std::fs::read_to_string(manifest_path).expect("read reconciled manifest");
assert!(
reconciled
.contains("[lints.clippy]\ndbg_macro = \"deny\"\nprint_stderr = \"deny\"\nprint_stdout = \"deny\""),
"the clippy deny block must survive reconciliation even though alef.toml never \
configured [crates.cargo_lints]:\n{reconciled}"
);
toml::from_str::<toml::Value>(&reconciled).expect("valid reconciled TOML");
}
#[test]
fn ffi_manifest_reconciliation_restores_the_clippy_workspace_lints_rationale_comment() {
let files = scaffold_ffi(&crate::core::ir::ApiSurface::default(), &minimal_config()).expect("scaffold");
let directory = tempfile::tempdir().expect("temporary repository");
let manifest_path = directory.path().join("crates/my-lib-ffi/Cargo.toml");
std::fs::create_dir_all(manifest_path.parent().expect("manifest parent")).expect("create manifest parent");
std::fs::write(
&manifest_path,
"# This file is auto-generated by alef — DO NOT EDIT.\n# alef:hash:old\n\
[lints.clippy]\nprint_stdout = \"deny\"\nprint_stderr = \"deny\"\ndbg_macro = \"deny\"\n",
)
.expect("write stale generated manifest with no rationale comment");
crate::cli::pipeline::reconcile_managed_scaffold_manifests(&files, directory.path())
.expect("reconcile generated FFI manifest");
let reconciled = std::fs::read_to_string(manifest_path).expect("read reconciled manifest");
assert!(
reconciled.contains("# This crate deliberately does not use `[lints]` / `workspace = true`"),
"the [lints.clippy] rationale comment must be (re)emitted on regeneration, not just the \
block itself:\n{reconciled}"
);
assert!(
reconciled.contains("can actually satisfy. ~keep\n[lints.clippy]"),
"the rationale comment must attach directly above [lints.clippy] with no blank line, \
got:\n{reconciled}"
);
toml::from_str::<toml::Value>(&reconciled).expect("valid reconciled TOML");
}
}