use super::filtering::{apply_filters, expand_include_list, is_type_excluded};
use super::sanitizer::{TypeSanitization, sanitize_type_ref};
use super::validation::validate_extracted_api;
use crate::core::config::ResolvedCrateConfig;
use crate::core::ir::{ApiSurface, TypeRef};
use ahash::AHashSet;
#[test]
fn sanitize_map_with_cow_key_preserves_map_structure_and_returns_lossless() {
let known_types = AHashSet::default();
let known_enums = AHashSet::default();
let mut ty = TypeRef::Map(Box::new(TypeRef::Named("str".into())), Box::new(TypeRef::Json));
let status = sanitize_type_ref(&mut ty, &known_types, &known_enums);
assert!(
matches!(&ty, TypeRef::Map(k, v)
if matches!(k.as_ref(), TypeRef::String)
&& matches!(v.as_ref(), TypeRef::Json)),
"expected Map(String, Json) but got {ty:?}"
);
assert_eq!(status, TypeSanitization::Lossless);
let _ = known_types;
let mut ty2 = TypeRef::Map(Box::new(TypeRef::String), Box::new(TypeRef::Json));
let sanitized2 = sanitize_type_ref(&mut ty2, &AHashSet::default(), &AHashSet::default());
assert_eq!(sanitized2, TypeSanitization::Unchanged);
assert!(
matches!(&ty2, TypeRef::Map(k, v)
if matches!(k.as_ref(), TypeRef::String)
&& matches!(v.as_ref(), TypeRef::Json)),
"Map(String, Json) must not be mutated when already clean"
);
}
#[test]
fn sanitize_map_with_bare_value_is_reported_as_sanitized() {
let mut ty = TypeRef::Map(Box::new(TypeRef::String), Box::new(TypeRef::Named("Value".to_string())));
let sanitized = sanitize_type_ref(&mut ty, &AHashSet::default(), &AHashSet::default());
assert!(
sanitized.is_lossy(),
"ambiguous bare Value inside Map must not be silently accepted"
);
assert!(
matches!(&ty, TypeRef::Map(_, value) if matches!(value.as_ref(), TypeRef::Named(name) if name == "Value")),
"ambiguous bare Value must remain visible for validation, got {ty:?}"
);
}
#[test]
fn sanitize_map_with_both_string_types_returns_not_sanitized() {
let mut ty = TypeRef::Map(Box::new(TypeRef::String), Box::new(TypeRef::String));
let sanitized = sanitize_type_ref(&mut ty, &AHashSet::default(), &AHashSet::default());
assert_eq!(sanitized, TypeSanitization::Unchanged);
assert!(matches!(
&ty,
TypeRef::Map(k, v)
if matches!(k.as_ref(), TypeRef::String) && matches!(v.as_ref(), TypeRef::String)
));
}
#[test]
fn sanitize_map_with_unknown_value_type_returns_lossy() {
let mut ty = TypeRef::Map(
Box::new(TypeRef::String),
Box::new(TypeRef::Named("ForeignPayload".into())),
);
let sanitized = sanitize_type_ref(&mut ty, &AHashSet::default(), &AHashSet::default());
assert_eq!(sanitized, TypeSanitization::Lossy);
assert!(
matches!(&ty, TypeRef::Map(_, value) if matches!(value.as_ref(), TypeRef::String)),
"unknown map value should be visibly sanitized for validation, got {ty:?}"
);
}
#[test]
fn sanitize_named_unknown_type_returns_sanitized_true() {
let mut ty = TypeRef::Named("UnknownForeignType".into());
let sanitized = sanitize_type_ref(&mut ty, &AHashSet::default(), &AHashSet::default());
assert!(sanitized.is_lossy());
assert!(matches!(ty, TypeRef::String));
}
#[test]
fn sanitize_vec_with_unknown_named_returns_sanitized_true() {
let mut ty = TypeRef::Vec(Box::new(TypeRef::Named("MyForeignStruct".into())));
let sanitized = sanitize_type_ref(&mut ty, &AHashSet::default(), &AHashSet::default());
assert!(sanitized.is_lossy());
assert!(matches!(
&ty,
TypeRef::Vec(inner) if matches!(inner.as_ref(), TypeRef::String)
));
}
#[test]
fn validate_extracted_api_does_not_suppress_critical_codes() {
let api = ApiSurface {
crate_name: "sample-lib".to_string(),
functions: vec![crate::core::ir::FunctionDef {
name: "render".to_string(),
rust_path: "sample_lib::render".to_string(),
original_rust_path: String::new(),
params: vec![crate::core::ir::ParamDef {
name: "payload".to_string(),
ty: TypeRef::Named("MissingPayload".to_string()),
..crate::core::ir::ParamDef::default()
}],
return_type: TypeRef::String,
error_type: None,
doc: String::new(),
is_async: false,
sanitized: false,
return_sanitized: false,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
cfg: None,
binding_excluded: false,
binding_exclusion_reason: None,
version: Default::default(),
}],
..ApiSurface::default()
};
let config = ResolvedCrateConfig::default();
let err = validate_extracted_api(&api, &config).expect_err("must stay fatal");
assert!(
err.to_string().contains("unknown_named_type"),
"unexpected error: {err}"
);
}
#[test]
fn is_type_excluded_plain_entry_matches_by_name() {
let exclude = vec!["OutputFormat".to_string()];
assert!(
is_type_excluded("OutputFormat", "sample_crate::types::OutputFormat", &exclude),
"plain entry must match when name matches"
);
assert!(
!is_type_excluded("SomethingElse", "sample_crate::types::SomethingElse", &exclude),
"plain entry must not match when name differs"
);
}
#[test]
fn is_type_excluded_qualified_entry_matches_rust_path_not_name() {
let exclude = vec!["sample_crate::core::config::formats::OutputFormat".to_string()];
assert!(
is_type_excluded(
"OutputFormat",
"sample_crate::core::config::formats::OutputFormat",
&exclude
),
"qualified entry must match the exact rust_path"
);
assert!(
!is_type_excluded("OutputFormat", "sample_crate::types::OutputFormat", &exclude),
"qualified entry must NOT match a different rust_path with the same short name"
);
}
#[test]
fn is_type_excluded_normalises_hyphens_in_rust_path() {
let exclude = vec!["my_crate::some_module::Foo".to_string()];
assert!(
is_type_excluded("Foo", "my-crate::some_module::Foo", &exclude),
"hyphens in rust_path should be normalised to underscores"
);
}
fn make_typedef(name: &str) -> crate::core::ir::TypeDef {
crate::core::ir::TypeDef {
name: name.to_string(),
rust_path: format!("my_crate::{name}"),
original_rust_path: String::new(),
fields: vec![],
methods: vec![],
is_opaque: false,
is_clone: false,
is_copy: false,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
doc: String::new(),
cfg: None,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
binding_excluded: false,
binding_exclusion_reason: None,
is_variant_wrapper: false,
has_lifetime_params: false,
version: Default::default(),
}
}
fn make_funcdef(name: &str, return_type: TypeRef, param_types: Vec<TypeRef>) -> crate::core::ir::FunctionDef {
crate::core::ir::FunctionDef {
name: name.to_string(),
rust_path: format!("my_crate::{name}"),
original_rust_path: String::new(),
params: param_types
.into_iter()
.enumerate()
.map(|(i, ty)| crate::core::ir::ParamDef {
name: format!("arg{i}"),
ty,
optional: false,
default: None,
sanitized: false,
typed_default: None,
is_ref: false,
is_mut: false,
newtype_wrapper: None,
original_type: None,
map_is_ahash: false,
map_key_is_cow: false,
vec_inner_is_ref: false,
map_is_btree: false,
core_wrapper: crate::core::ir::CoreWrapper::None,
})
.collect(),
return_type,
is_async: false,
error_type: None,
doc: String::new(),
cfg: None,
sanitized: false,
return_sanitized: false,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
binding_excluded: false,
binding_exclusion_reason: None,
version: Default::default(),
}
}
fn surface_with(types: Vec<crate::core::ir::TypeDef>, functions: Vec<crate::core::ir::FunctionDef>) -> ApiSurface {
ApiSurface {
crate_name: "my_crate".into(),
version: "0.1.0".into(),
types,
functions,
enums: vec![],
errors: vec![],
excluded_type_paths: std::collections::HashMap::new(),
excluded_trait_names: std::collections::HashSet::new(),
services: vec![],
handler_contracts: vec![],
unsupported_public_items: Vec::new(),
}
}
#[test]
fn expand_include_list_seeds_from_included_function_signatures() {
let surface = surface_with(
vec![
make_typedef("BatchScrapeResult"),
make_typedef("BatchScrapeResults"),
make_typedef("UnusedType"),
],
vec![make_funcdef(
"batch_scrape",
TypeRef::Named("BatchScrapeResults".into()),
vec![TypeRef::Vec(Box::new(TypeRef::String))],
)],
);
let include_types = vec!["BatchScrapeResult".to_string()];
let include_functions = vec!["batch_scrape".to_string()];
let expanded = expand_include_list(&surface, &include_types, &include_functions);
assert!(
expanded.contains("BatchScrapeResult"),
"per-element type explicitly listed must be present; got: {expanded:?}"
);
assert!(
expanded.contains("BatchScrapeResults"),
"wrapper return type of included function must be auto-included; got: {expanded:?}"
);
assert!(
!expanded.contains("UnusedType"),
"unrelated type must not be pulled in; got: {expanded:?}"
);
}
#[test]
fn expand_include_list_seeds_from_included_function_param_types() {
let surface = surface_with(
vec![make_typedef("CrawlConfig"), make_typedef("EngineHandle")],
vec![make_funcdef(
"create_engine",
TypeRef::Named("EngineHandle".into()),
vec![TypeRef::Optional(Box::new(TypeRef::Named("CrawlConfig".into())))],
)],
);
let include_types = vec!["EngineHandle".to_string()];
let include_functions = vec!["create_engine".to_string()];
let expanded = expand_include_list(&surface, &include_types, &include_functions);
assert!(
expanded.contains("CrawlConfig"),
"param type referenced through Optional must be retained; got: {expanded:?}"
);
}
#[test]
fn expand_include_list_with_empty_functions_matches_legacy_behaviour() {
let surface = surface_with(
vec![make_typedef("Kept"), make_typedef("Dropped")],
vec![make_funcdef("do_thing", TypeRef::Named("Dropped".into()), vec![])],
);
let include_types = vec!["Kept".to_string()];
let include_functions: Vec<String> = vec![];
let expanded = expand_include_list(&surface, &include_types, &include_functions);
assert!(expanded.contains("Kept"));
assert!(
!expanded.contains("Dropped"),
"function not in include.functions must not pull in its return type; got: {expanded:?}"
);
}
fn make_unsupported_method(type_name: &str, method_name: &str) -> crate::core::ir::UnsupportedPublicItem {
crate::core::ir::UnsupportedPublicItem {
item_kind: "method".to_string(),
item_path: format!("my_crate::module::{type_name}.{method_name}"),
reason: "public generic trait methods cannot be represented without explicit monomorphization metadata"
.to_string(),
suggested_fix: "exclude the method".to_string(),
}
}
fn make_unsupported_function(fn_name: &str) -> crate::core::ir::UnsupportedPublicItem {
crate::core::ir::UnsupportedPublicItem {
item_kind: "function".to_string(),
item_path: format!("my_crate::{fn_name}"),
reason: "generic function".to_string(),
suggested_fix: "exclude the function".to_string(),
}
}
#[test]
fn apply_filters_removes_unsupported_method_when_excluded_by_methods_list() {
let mut surface = surface_with(vec![], vec![]);
surface
.unsupported_public_items
.push(make_unsupported_method("NodeContext", "serialize"));
let mut config = ResolvedCrateConfig::default();
config.exclude.methods = vec!["NodeContext.serialize".to_string()];
let result = apply_filters(surface, &config);
assert!(
result.unsupported_public_items.is_empty(),
"method listed in exclude.methods must be removed from unsupported_public_items; \
remaining: {:?}",
result.unsupported_public_items
);
}
#[test]
fn apply_filters_retains_unsupported_method_when_not_in_exclude_list() {
let mut surface = surface_with(vec![], vec![]);
surface
.unsupported_public_items
.push(make_unsupported_method("NodeContext", "serialize"));
let mut config = ResolvedCrateConfig::default();
config.exclude.methods = vec!["NodeContext.other_method".to_string()];
let result = apply_filters(surface, &config);
assert_eq!(
result.unsupported_public_items.len(),
1,
"method NOT in exclude.methods must remain in unsupported_public_items"
);
}
#[test]
fn apply_filters_exclude_methods_does_not_affect_unsupported_function_items() {
let mut surface = surface_with(vec![], vec![]);
surface
.unsupported_public_items
.push(make_unsupported_function("generic_helper"));
let mut config = ResolvedCrateConfig::default();
config.exclude.methods = vec!["generic_helper".to_string()];
let result = apply_filters(surface, &config);
assert_eq!(
result.unsupported_public_items.len(),
1,
"exclude.methods must not suppress items with item_kind == 'function'"
);
}
#[test]
fn apply_filters_retains_unsupported_function_when_included_by_function_list() {
let mut surface = surface_with(vec![], vec![]);
surface
.unsupported_public_items
.push(make_unsupported_function("generic_helper"));
surface
.unsupported_public_items
.push(make_unsupported_function("unused_generic"));
let mut config = ResolvedCrateConfig::default();
config.include.functions = vec!["generic_helper".to_string()];
let result = apply_filters(surface, &config);
assert_eq!(
result
.unsupported_public_items
.iter()
.map(|item| item.item_path.as_str())
.collect::<Vec<_>>(),
vec!["my_crate::generic_helper"],
"include.functions must retain diagnostics only for included generic functions"
);
}
#[test]
fn apply_filters_retains_unsupported_method_when_parent_type_is_included() {
let mut surface = surface_with(vec![make_typedef("NodeContext"), make_typedef("OtherType")], vec![]);
surface
.unsupported_public_items
.push(make_unsupported_method("NodeContext", "serialize"));
surface
.unsupported_public_items
.push(make_unsupported_method("OtherType", "serialize"));
let mut config = ResolvedCrateConfig::default();
config.include.types = vec!["NodeContext".to_string()];
let result = apply_filters(surface, &config);
assert_eq!(
result
.unsupported_public_items
.iter()
.map(|item| item.item_path.as_str())
.collect::<Vec<_>>(),
vec!["my_crate::module::NodeContext.serialize"],
"include.types must retain diagnostics only for methods owned by included public types"
);
}
#[test]
fn configurator_survives_exclude_methods_post_service_pass() {
use crate::core::config::service::{EntrypointSpec, ServiceConfig};
use crate::core::ir::{MethodDef, ReceiverKind, ServiceDef, TypeRef};
let configurator_method = MethodDef {
name: "setup".to_string(),
params: vec![],
return_type: TypeRef::Named("Foo".to_string()),
is_async: false,
is_static: false,
error_type: None,
doc: String::new(),
receiver: Some(ReceiverKind::Owned),
sanitized: false,
trait_source: None,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
has_default_impl: false,
binding_excluded: false,
binding_exclusion_reason: None,
version: Default::default(),
};
let constructor_method = MethodDef {
name: "new".to_string(),
params: vec![],
return_type: TypeRef::Named("Foo".to_string()),
is_async: false,
is_static: true,
error_type: None,
doc: String::new(),
receiver: None,
sanitized: false,
trait_source: None,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
has_default_impl: false,
binding_excluded: false,
binding_exclusion_reason: None,
version: Default::default(),
};
let service = ServiceDef {
name: "Foo".to_string(),
rust_path: "test_crate::Foo".to_string(),
constructor: constructor_method,
configurators: vec![configurator_method],
registrations: vec![],
entrypoints: vec![],
doc: String::new(),
cfg: None,
};
let mut config = ResolvedCrateConfig {
name: "test_crate".to_string(),
services: vec![ServiceConfig {
owner_type: "Foo".to_string(),
constructor: Some("new".to_string()),
configurators: vec!["setup".to_string()],
registrations: vec![],
entrypoints: vec![EntrypointSpec {
method: "run".to_string(),
kind: "run".to_string(),
}],
skip_languages: vec![],
host_app_inner_accessor: None,
}],
..Default::default()
};
config.exclude.methods = vec!["Foo.setup".to_string()];
let mut api = ApiSurface {
crate_name: "test_crate".to_string(),
services: vec![service],
..ApiSurface::default()
};
if !config.exclude.methods.is_empty() {
for typ in &mut api.types {
typ.methods.retain(|m| {
let key = format!("{}.{}", typ.name, m.name);
!config.exclude.methods.contains(&key)
});
}
}
assert_eq!(api.services.len(), 1, "service must be present after the exclude pass");
assert_eq!(
api.services[0].configurators.len(),
1,
"configurator `setup` must survive the exclude-methods post-service pass; got {:?}",
api.services[0]
.configurators
.iter()
.map(|m| m.name.as_str())
.collect::<Vec<_>>()
);
assert_eq!(
api.services[0].configurators[0].name, "setup",
"configurator name must be `setup`"
);
}
#[test]
fn dedup_keeps_same_named_functions_with_disjoint_cfgs() {
let mut real = make_funcdef(
"embed_texts_async",
TypeRef::Primitive(crate::core::ir::PrimitiveType::Bool),
vec![],
);
real.cfg = Some("all (feature = \"embeddings\" , feature = \"tokio-runtime\")".to_string());
let mut stub = make_funcdef(
"embed_texts_async",
TypeRef::Primitive(crate::core::ir::PrimitiveType::Bool),
vec![],
);
stub.cfg = Some(
"all (feature = \"embedding-presets\" , not (feature = \"embeddings\") , feature = \"tokio-runtime\")"
.to_string(),
);
let mut surface = surface_with(vec![], vec![real, stub]);
super::type_helpers::dedup_api_surface(&mut surface);
let entries: Vec<_> = surface
.functions
.iter()
.filter(|f| f.name == "embed_texts_async")
.collect();
assert_eq!(
entries.len(),
2,
"both cfg-gated alternatives must survive dedup; got {entries:?}"
);
let cfgs: Vec<&str> = entries.iter().filter_map(|f| f.cfg.as_deref()).collect();
assert!(cfgs.iter().any(|c| c.contains("\"embeddings\"") && !c.contains("not")));
assert!(cfgs.iter().any(|c| c.contains("not") && c.contains("\"embeddings\"")));
}
#[test]
fn dedup_collapses_same_named_functions_with_identical_cfg() {
let mut near = make_funcdef(
"clean_text",
TypeRef::Primitive(crate::core::ir::PrimitiveType::Bool),
vec![],
);
near.rust_path = "my_crate::clean_text".to_string();
let mut far = make_funcdef(
"clean_text",
TypeRef::Primitive(crate::core::ir::PrimitiveType::Bool),
vec![],
);
far.rust_path = "my_crate::text::quality::clean_text".to_string();
let mut surface = surface_with(vec![], vec![far, near]);
super::type_helpers::dedup_api_surface(&mut surface);
let entries: Vec<_> = surface.functions.iter().filter(|f| f.name == "clean_text").collect();
assert_eq!(entries.len(), 1, "identical-cfg duplicates must collapse to one");
assert_eq!(entries[0].rust_path, "my_crate::clean_text", "shortest rust_path wins");
}