test(build): close Rust fact and surface mutants

Prove recursive and inline Rust struct discovery, ignored non-Rust/out-of-tree files, source/parse failures, atom and handle collision propagation, and missing form-fact defaults; classify only non-injectable directory-entry errors and syn's named-field invariant.

req: diagnostics/006

req: diagnostics/004

req: surface/008

req: test/021
This commit is contained in:
slhx agent
2026-07-17 07:16:43 +02:00
parent 05154a58ed
commit 14f16cba23
3 changed files with 122 additions and 11 deletions
+116 -10
View File
@@ -1890,8 +1890,7 @@ fn collect_rust_struct_facts(
return Ok(());
};
for entry in entries {
let entry = entry?;
let path = entry.path();
let path = entry?.path();
if path.is_dir() {
collect_rust_struct_facts(&path, facts)?;
} else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
@@ -1906,8 +1905,10 @@ fn collect_rust_struct_facts_from_file(
facts: &mut HashMap<String, RustStructFact>,
) -> io::Result<()> {
let source = std::fs::read_to_string(path)?;
let file =
syn::parse_file(&source).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
let file = match syn::parse_file(&source) {
Ok(file) => file,
Err(error) => return Err(io::Error::new(io::ErrorKind::InvalidData, error)),
};
collect_rust_struct_facts_from_items(&file.items, facts);
Ok(())
}
@@ -1926,11 +1927,9 @@ fn collect_rust_struct_facts_from_items(
fields: fields
.named
.iter()
.filter_map(|field| {
Some(TemplateFieldFact {
name: field.ident.as_ref()?.to_string(),
type_name: compact_tokens(&field.ty),
})
.map(|field| TemplateFieldFact {
name: field.ident.as_ref().unwrap().to_string(),
type_name: compact_tokens(&field.ty),
})
.collect(),
derives_hemplate: derives_hemplate(&item.attrs),
@@ -2289,7 +2288,20 @@ mod tests {
.unwrap();
std::fs::write(
crate_root.join("src/lib.rs"),
"#[derive(Hemplate)] struct Profile { title: String }\n#[derive(Clone, hemplate::Hemplate)] struct ProfileCard { card_title: String }\n#[derive(NotHemplate)] struct ProfileDetails { hidden: String }\n#[derive(HemplateExtra)] struct Extra { hidden: String }\nstruct Plain { title: String }",
"#[derive(Hemplate)] struct Profile { title: String }\n#[derive(Clone, hemplate::Hemplate)] struct ProfileCard { card_title: String }\n#[derive(NotHemplate)] struct ProfileDetails { hidden: String }\n#[derive(HemplateExtra)] struct Extra { hidden: String }\nmod inline { #[derive(Hemplate)] pub struct InlineProfile { pub inline_title: String } }\nmod external;\nstruct Plain { title: String }",
)
.unwrap();
std::fs::create_dir_all(crate_root.join("src/nested")).unwrap();
std::fs::write(
crate_root.join("src/nested/profile.rs"),
"#[derive(Hemplate)] struct NestedProfile { nested_title: String }",
)
.unwrap();
std::fs::write(crate_root.join("src/external.rs"), "struct External;").unwrap();
std::fs::write(crate_root.join("src/ignored.txt"), [0xff]).unwrap();
std::fs::write(
crate_root.join("outside.rs"),
"#[derive(Hemplate)] struct OutsideProfile { outside: String }",
)
.unwrap();
let profile = templates.join("profile.heml");
@@ -2302,6 +2314,29 @@ mod tests {
assert_eq!(facts.context_type, "Profile");
assert_eq!(facts.self_fields[0].name, "title");
for (template, context_type, field) in [
("inline_profile.heml", "InlineProfile", "inline_title"),
("nested_profile.heml", "NestedProfile", "nested_title"),
] {
let nested_facts = template_context_facts_for_heml_source(
templates.join(template),
format!("<main>{{{{ self.{field} }}}}</main>"),
)
.unwrap()
.expect("recursive Rust struct fact");
assert_eq!(nested_facts.context_type, context_type);
assert_eq!(nested_facts.self_fields[0].name, field);
}
assert_eq!(
template_context_facts_for_heml_source(
templates.join("outside_profile.heml"),
"<main>{{ self.outside }}</main>".to_owned(),
)
.unwrap(),
None,
"Rust facts outside src must not become template authority"
);
let card_facts = template_context_facts_for_heml_source(
templates.join("profile-card.heml"),
"<main>{{ self.card_title }}</main>".to_owned(),
@@ -2364,6 +2399,29 @@ mod tests {
// test req: diagnostics/004 req: diagnostics/006 req: surface/008
}
#[test]
fn rust_fact_collection_propagates_parse_and_recursive_directory_errors() {
let base = test_dir("hemx-build-rust-fact-errors");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("src")).unwrap();
let mut facts = HashMap::new();
std::fs::write(base.join("src/broken.rs"), "struct {").unwrap();
assert_eq!(
collect_rust_struct_facts_from_file(&base.join("src/broken.rs"), &mut facts)
.unwrap_err()
.kind(),
io::ErrorKind::InvalidData
);
assert!(facts.is_empty());
std::fs::remove_file(base.join("src/broken.rs")).unwrap();
assert!(rust_struct_facts_in(&base.join("missing"))
.unwrap()
.is_empty());
let _ = std::fs::remove_dir_all(base);
// test req: diagnostics/006
}
#[test]
fn identifier_and_literal_helpers_fail_closed_at_boundaries() {
assert_eq!(rust_ident("alpha_9"), Some("alpha_9".into()));
@@ -2459,6 +2517,54 @@ mod tests {
io::ErrorKind::InvalidData
);
for (kind, source) in [
("atom", r#"<span data-hemx-atom="shared"></span>"#),
("handle", r#"<button data-hemx-handle="shared"></button>"#),
] {
let first =
surface_for_heml_source(Path::new("first.heml"), source.to_owned()).unwrap();
let second =
surface_for_heml_source(Path::new("second.heml"), source.to_owned()).unwrap();
let mut duplicate = Resources::default();
duplicate
.add_surface(
Path::new("templates"),
Path::new("templates/first.heml"),
&first,
)
.unwrap();
assert_eq!(
duplicate
.add_surface(
Path::new("templates"),
Path::new("templates/second.heml"),
&second,
)
.unwrap_err()
.to_string(),
format!(
"duplicate generated identifier `shared` for `first.heml::shared` and `second.heml::shared`"
),
"{kind} collision must propagate"
);
}
let mut form_without_controls = surface_for_heml_source(
Path::new("form.heml"),
r#"<form data-hemx-handle="save" data-hemx-form="empty"></form>"#.to_owned(),
)
.unwrap();
form_without_controls.forms.clear();
let mut form_resources = Resources::default();
form_resources
.add_surface(
Path::new("templates"),
Path::new("templates/form.heml"),
&form_without_controls,
)
.unwrap();
assert!(form_resources.forms["empty"].controls.is_empty());
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;