#[test]
fn test_php_wrapper_param_optionality_logic() {
use crate::core::ir::{ParamDef, TypeRef};
let is_optional_default_constructible_param = |p: &ParamDef| -> bool {
if let TypeRef::Named(name) = &p.ty {
matches!(name.as_str(), "CrawlConfig" | "InteractionActions")
} else {
false
}
};
let req_param = ParamDef {
name: "url".to_string(),
ty: TypeRef::String,
optional: false,
..ParamDef::default()
};
let should_be_optional = req_param.optional || is_optional_default_constructible_param(&req_param);
assert!(
!should_be_optional,
"required param should not become optional in wrapper"
);
let opt_param = ParamDef {
name: "config".to_string(),
ty: TypeRef::Named("CrawlConfig".to_string()),
optional: true,
..ParamDef::default()
};
let should_be_optional = opt_param.optional || is_optional_default_constructible_param(&opt_param);
assert!(should_be_optional, "explicitly optional param should be optional");
let default_constructible_param = ParamDef {
name: "config".to_string(),
ty: TypeRef::Named("CrawlConfig".to_string()),
optional: false,
..ParamDef::default()
};
let should_be_optional =
default_constructible_param.optional || is_optional_default_constructible_param(&default_constructible_param);
assert!(should_be_optional, "default-constructible param should become optional");
}
#[test]
fn should_emit_rust_line_doc_comments_when_doc_text_contains_block_comment_sequences() {
use super::super::type_map::PhpMapper;
use crate::backends::php::gen_bindings::functions::{PhpParamTypeSets, gen_function_as_static_method};
use crate::core::ir::{FunctionDef, TypeRef};
use ahash::AHashSet;
let func = FunctionDef {
name: "choose_call_mode".to_string(),
rust_path: "sample_crate::choose_call_mode".to_string(),
return_type: TypeRef::String,
doc: "Decide which call mode best fits this document.\n\n\
Rules: `image/*` → vision; `text/*` and `application/*` → text. Closes with */."
.to_string(),
..FunctionDef::default()
};
let mapper = PhpMapper {
enum_names: AHashSet::new(),
data_enum_names: AHashSet::new(),
untagged_data_enum_names: AHashSet::new(),
json_string_enum_names: AHashSet::new(),
};
let empty = AHashSet::new();
let type_sets = PhpParamTypeSets {
opaque: &empty,
default: &empty,
enums: &empty,
};
let generated = gen_function_as_static_method(&func, &mapper, type_sets, "sample_crate", &[], false, &empty);
assert!(
generated.contains("/// Decide which call mode best fits this document."),
"doc must be emitted as Rust `///` line comments, got:\n{generated}"
);
assert!(
generated.contains("/// Rules: `image/*` → vision; `text/*` and `application/*` → text. Closes with */."),
"doc body (incl. `image/*` and `*/`) must survive verbatim on a `///` line, got:\n{generated}"
);
assert!(
!generated.contains("/**"),
"Rust crate doc must not use PHPDoc `/**` block comments (nesting hazard), got:\n{generated}"
);
for line in generated.lines().filter(|l| l.contains("Closes with")) {
assert!(
line.trim_start().starts_with("///"),
"line carrying a `*/` token must be a `///` line doc-comment, got: {line:?}"
);
}
}
#[test]
fn php_self_ref_builder_shares_arc_instead_of_cloning_returned_ref() {
use super::super::type_map::PhpMapper;
use crate::backends::php::gen_bindings::functions::gen_instance_method;
use crate::core::ir::{MethodDef, ParamDef, ReceiverKind, TypeRef};
use ahash::{AHashMap, AHashSet};
let method = MethodDef {
name: "register_route".to_string(),
params: vec![ParamDef {
name: "config".to_string(),
ty: TypeRef::Named("RouteCfg".to_string()),
..ParamDef::default()
}],
return_type: TypeRef::Named("App".to_string()),
error_type: Some("AppError".to_string()),
doc: "Register a route, returning the app for chaining.".to_string(),
receiver: Some(ReceiverKind::RefMut),
cfg: None,
returns_ref: true,
..MethodDef::default()
};
let mapper = PhpMapper {
enum_names: AHashSet::new(),
data_enum_names: AHashSet::new(),
untagged_data_enum_names: AHashSet::new(),
json_string_enum_names: AHashSet::new(),
};
let mut opaque = AHashSet::new();
opaque.insert("App".to_string());
opaque.insert("RouteCfg".to_string());
let enums = AHashSet::new();
let adapter_bodies: AHashMap<String, String> = AHashMap::new();
let mut mutex = AHashSet::new();
mutex.insert("App".to_string());
let code = gen_instance_method(
&method,
&mapper,
true,
"App",
&opaque,
&enums,
"sample_crate",
&adapter_bodies,
&mutex,
);
assert!(
code.contains("Ok(Self { inner: self.inner.clone() })"),
"self-returning builder should share the existing Arc, got:\n{code}"
);
assert!(
!code.contains("Mutex::new(result.clone())") && !code.contains("Mutex::new(result)"),
"must not wrap the returned &mut ref in a new Mutex, got:\n{code}"
);
assert!(
!code.contains("let result ="),
"self-returning builder must not bind the returned &mut ref, got:\n{code}"
);
}
#[test]
fn config_m4_uses_underscored_cdylib_stem_and_hyphenated_crate_dir() {
use super::rust_items::generate_config_m4;
let m4 = generate_config_m4("liter_llm", "liter-llm");
assert!(
m4.contains("crates/liter-llm-php/Cargo.toml"),
"crate directory path must keep hyphens, got:\n{m4}"
);
assert!(
m4.contains("cd crates/liter-llm-php"),
"cd target must keep hyphens, got:\n{m4}"
);
assert!(
m4.contains("crates/liter-llm-php/target/release/libliter_llm_php.dylib"),
"dylib stem must be fully underscored, got:\n{m4}"
);
assert!(
m4.contains("crates/liter-llm-php/target/release/libliter_llm_php.so"),
"so stem must be fully underscored, got:\n{m4}"
);
assert!(
!m4.contains("liter-llm_php"),
"must never mix a hyphenated crate name with the `_php` suffix, got:\n{m4}"
);
assert!(
m4.contains(r#"cp "$cargo_lib" "modules/liter_llm.so""#),
"module output filename must use the extension name, got:\n{m4}"
);
assert!(
m4.contains("crates/liter-llm-php/target/release\" >&2"),
"not-found error message must reference the hyphenated crate directory, got:\n{m4}"
);
assert!(
m4.contains("crates/liter-llm-php/Cargo.toml not found"),
"missing-Cargo.toml error message must reference the hyphenated crate directory, got:\n{m4}"
);
}
#[test]
fn config_m4_underscores_every_hyphen_in_a_multi_hyphen_crate_name() {
use super::rust_items::generate_config_m4;
let m4 = generate_config_m4("my_cool_lib", "my-cool-lib");
assert!(
m4.contains("crates/my-cool-lib-php/Cargo.toml"),
"crate directory path must keep every hyphen, got:\n{m4}"
);
assert!(
m4.contains("crates/my-cool-lib-php/target/release/libmy_cool_lib_php.dylib"),
"dylib stem must underscore every hyphen, not just the first, got:\n{m4}"
);
assert!(
m4.contains("crates/my-cool-lib-php/target/release/libmy_cool_lib_php.so"),
"so stem must underscore every hyphen, not just the first, got:\n{m4}"
);
assert!(
!m4.contains("my-cool-lib_php") && !m4.contains("my_cool-lib_php") && !m4.contains("my-cool_lib_php"),
"must never leave a partially-underscored stem, got:\n{m4}"
);
}
#[test]
fn tagged_data_enum_forces_crate_wide_serde_even_when_probe_finds_none() {
use crate::core::config::resolved::ResolvedCrateConfig;
use crate::core::ir::{ApiSurface, EnumDef, EnumVariant, FieldDef, PrimitiveType, TypeDef, TypeRef};
let tagged_enum = EnumDef {
name: "Shape".to_string(),
rust_path: "test_lib::Shape".to_string(),
variants: vec![EnumVariant {
name: "Circle".to_string(),
fields: vec![FieldDef {
name: "radius".to_string(),
ty: TypeRef::Primitive(PrimitiveType::F64),
..Default::default()
}],
..Default::default()
}],
serde_tag: Some("type".to_string()),
..Default::default()
};
let plain_struct = TypeDef {
name: "PlainStruct".to_string(),
rust_path: "test_lib::PlainStruct".to_string(),
fields: vec![FieldDef {
name: "payload".to_string(),
ty: TypeRef::Bytes,
..Default::default()
}],
..Default::default()
};
let api = ApiSurface {
crate_name: "my-crate".to_string(),
version: "1.0.0".to_string(),
types: vec![plain_struct],
enums: vec![tagged_enum],
..Default::default()
};
let config = ResolvedCrateConfig {
name: "my-crate".to_string(),
..ResolvedCrateConfig::default()
};
assert!(
!super::rust_bindings::php_serde_available(&config),
"test fixture must not resolve to a real Cargo.toml with serde -- otherwise this test \
cannot distinguish the fix from the pre-fix behavior"
);
let files = super::rust_bindings::generate_bindings(&api, &config).unwrap();
let lib = files.iter().find(|f| f.path.ends_with("lib.rs")).unwrap();
assert!(
lib.content
.contains("pub fn from_json(json: String) -> PhpResult<Self> {\n serde_json::from_str(&json)"),
"PlainStruct must get a serde-based from_json once the crate contains a tagged data \
enum, even though the raw Cargo.toml probe alone found no serde, got:\n{}",
lib.content
);
}
fn snake_case_unit_enum() -> crate::core::ir::EnumDef {
use crate::core::ir::{EnumDef, EnumVariant};
EnumDef {
name: "BatchStatus".to_string(),
rust_path: "sample_crate::BatchStatus".to_string(),
serde_rename_all: Some("snake_case".to_string()),
variants: vec![
EnumVariant {
name: "InProgress".to_string(),
..Default::default()
},
EnumVariant {
name: "Failed".to_string(),
..Default::default()
},
],
..Default::default()
}
}
fn struct_with_unit_enum_field() -> crate::core::ir::TypeDef {
use crate::core::ir::{FieldDef, TypeDef, TypeRef};
TypeDef {
name: "BatchObject".to_string(),
rust_path: "sample_crate::BatchObject".to_string(),
has_serde: true,
fields: vec![FieldDef {
name: "status".to_string(),
ty: TypeRef::Named("BatchStatus".to_string()),
..Default::default()
}],
..Default::default()
}
}
#[test]
fn php_enum_class_constant_carries_the_serde_wire_value_not_the_rust_variant_name() {
let emitted = super::types::gen_enum_constants(&snake_case_unit_enum(), None);
assert!(
emitted.contains("pub const INPROGRESS: &str = \"in_progress\";"),
"the constant must carry the serde wire value, got:\n{emitted}"
);
assert!(
!emitted.contains("\"InProgress\""),
"the Rust variant ident must not survive as the constant's value, got:\n{emitted}"
);
assert!(
emitted.contains("pub const FAILED: &str = \"failed\";"),
"a single-word variant is renamed by `rename_all` too, so it must also carry the wire \
value rather than `\"Failed\"`, got:\n{emitted}"
);
}
#[test]
fn php_enum_class_constant_values_are_all_accepted_by_the_generated_string_to_core_match() {
use ahash::AHashSet;
let enum_def = snake_case_unit_enum();
let enum_names: AHashSet<String> = std::iter::once("BatchStatus".to_string()).collect();
let conversion = super::helpers::gen_php_lossy_binding_to_core_fields(
&struct_with_unit_enum_field(),
"sample_crate",
&enum_names,
&AHashSet::new(),
std::slice::from_ref(&enum_def),
);
let constants = super::types::gen_enum_constants(&enum_def, None);
let values: Vec<String> = constants
.lines()
.filter_map(|line| line.split_once("= \"").and_then(|(_, rest)| rest.split_once('"')))
.map(|(value, _)| value.to_string())
.collect();
assert_eq!(
values.len(),
enum_def.variants.len(),
"apparatus check: one constant value must be extracted per variant, or the loop below \
asserts nothing. Extracted {values:?} from:\n{constants}"
);
for value in &values {
assert!(
conversion.contains(&format!("\"{value}\"")),
"the constant value `{value}` is not a match arm of the generated binding->core \
conversion, so passing it would fall through to the default variant. Conversion:\n{conversion}"
);
}
}
#[test]
fn the_generated_string_to_core_match_does_not_accept_the_rust_variant_name() {
use ahash::AHashSet;
let enum_def = snake_case_unit_enum();
let enum_names: AHashSet<String> = std::iter::once("BatchStatus".to_string()).collect();
let conversion = super::helpers::gen_php_lossy_binding_to_core_fields(
&struct_with_unit_enum_field(),
"sample_crate",
&enum_names,
&AHashSet::new(),
std::slice::from_ref(&enum_def),
);
assert!(
conversion.contains("\"in_progress\""),
"apparatus check: the wire-named arm must be present, got:\n{conversion}"
);
assert!(
!conversion.contains("\"InProgress\""),
"the match must NOT accept the Rust variant ident -- if it did, the constant's value \
would be interchangeable and the assertion it anchors would prove nothing, got:\n{conversion}"
);
}
#[test]
fn public_api_facade_types_unit_enum_param_and_return_as_string_not_enum_class() {
use crate::core::config::resolved::ResolvedCrateConfig;
use crate::core::ir::{ApiSurface, EnumDef, EnumVariant, FieldDef, FunctionDef, ParamDef, TypeDef, TypeRef};
let status_enum = EnumDef {
name: "Status".to_string(),
rust_path: "sample_crate::Status".to_string(),
variants: vec![
EnumVariant {
name: "Active".to_string(),
..Default::default()
},
EnumVariant {
name: "Inactive".to_string(),
..Default::default()
},
],
..Default::default()
};
let config_type = TypeDef {
name: "Config".to_string(),
rust_path: "sample_crate::Config".to_string(),
fields: vec![FieldDef {
name: "name".to_string(),
ty: TypeRef::String,
..Default::default()
}],
..Default::default()
};
let set_status_fn = FunctionDef {
name: "set_status".to_string(),
rust_path: "sample_crate::set_status".to_string(),
params: vec![
ParamDef {
name: "status".to_string(),
ty: TypeRef::Named("Status".to_string()),
..Default::default()
},
ParamDef {
name: "config".to_string(),
ty: TypeRef::Named("Config".to_string()),
..Default::default()
},
],
return_type: TypeRef::Named("Status".to_string()),
..Default::default()
};
let api = ApiSurface {
crate_name: "sample-crate".to_string(),
version: "1.0.0".to_string(),
types: vec![config_type],
enums: vec![status_enum],
functions: vec![set_status_fn],
..Default::default()
};
let config = ResolvedCrateConfig {
name: "sample-crate".to_string(),
..ResolvedCrateConfig::default()
};
let files = super::public_api::generate_public_api(&api, &config).unwrap();
let facade = files
.iter()
.find(|f| f.content.contains("setStatus("))
.expect("facade class file with the delegating setStatus method must be generated");
assert!(
facade.content.contains("string $status, Config $config): string"),
"a unit-enum param and return must be typed `string` (what PhpMapper::named actually \
lowers it to), while the sibling struct param keeps its own class name, got:\n{}",
facade.content
);
assert!(
!facade.content.contains("Status $status") && !facade.content.contains("): Status"),
"the facade must never type a unit-enum value as the enum's own (uninstantiable) class \
name, got:\n{}",
facade.content
);
}
#[test]
fn opaque_class_stub_types_unit_enum_method_param_and_return_as_string_not_enum_class() {
use crate::core::ir::{EnumDef, EnumVariant, MethodDef, ParamDef, ReceiverKind, TypeDef, TypeRef};
use ahash::{AHashMap, AHashSet};
let status_enum = EnumDef {
name: "Status".to_string(),
rust_path: "sample_crate::Status".to_string(),
variants: vec![
EnumVariant {
name: "Active".to_string(),
..Default::default()
},
EnumVariant {
name: "Inactive".to_string(),
..Default::default()
},
],
..Default::default()
};
let enum_names: AHashSet<String> = std::iter::once(status_enum.name.clone()).collect();
let session_type = TypeDef {
name: "Session".to_string(),
rust_path: "sample_crate::Session".to_string(),
is_opaque: true,
methods: vec![MethodDef {
name: "set_status".to_string(),
params: vec![
ParamDef {
name: "status".to_string(),
ty: TypeRef::Named("Status".to_string()),
..Default::default()
},
ParamDef {
name: "meta".to_string(),
ty: TypeRef::Named("Meta".to_string()),
..Default::default()
},
],
return_type: TypeRef::Named("Status".to_string()),
receiver: Some(ReceiverKind::RefMut),
..Default::default()
}],
..Default::default()
};
let content = super::opaque_files::gen_php_opaque_class_file(
&session_type,
"Sample\\Crate",
&[],
&AHashSet::default(),
&[],
&AHashMap::default(),
&enum_names,
);
assert!(
content.contains("string $status, Meta $meta): string"),
"an opaque-class method must type a unit-enum param and return as `string`, while a \
non-enum named param keeps its own class name, got:\n{content}"
);
assert!(
!content.contains("Status $status") && !content.contains("): Status"),
"an opaque-class method must never type a unit-enum value as the enum's own \
(uninstantiable) class name, got:\n{content}"
);
}