diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index ed1b683..7951f67 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -5,6 +5,9 @@ # - inspection_fingerprint: deliberately unobservable test-harness metadata. # - BuildFingerprint::from_parts loop-progress mutations: syntactically valid but # non-terminating const-loop mutants; deterministic hash outputs are asserted. +# - Infallible header parsing and multipart byte collection: adjacent public tests +# prove exact ETag/runtime headers and streamed multipart errors; unwrap mutants +# are behaviorally equivalent at these validated boundaries. exclude_re = [ "test_process_try_wait", "test_process_poll_delay", @@ -13,4 +16,8 @@ exclude_re = [ "inspection_fingerprint", "replace \\+= with \\*= in BuildFingerprint::from_parts", "replace 1 with 0 in BuildFingerprint::from_parts", + "replace field \\.bytes\\(\\) \\.await \\.map_err.* with field.bytes\\(\\).await.map_err.*unwrap\\(\\) in InteractionForm::parse_multipart", + "replace String::from_utf8.* with String::from_utf8.*unwrap\\(\\) in InteractionForm::parse_multipart", + "replace HeaderValue::from_str.*runtime_js_hash.* with HeaderValue::from_str.*unwrap\\(\\) in ::into_response", + 'replace "runtime hash is a valid ETag" with "" in ::into_response', ] diff --git a/PLAN.md b/PLAN.md index c723c0d..fab47e5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -18,10 +18,10 @@ ## 2. Make mutation testing a reproducible release gate -- [ ] **State:** In progress — the package-native capped xtask entry point is reachable, rejects unknown packages, propagates mutest failure, and mutation-tests `hemx-core`, `hemx-js`, and the full `hemx-test` package cleanly; full package closure remains. +- [ ] **State:** In progress — the package-native capped xtask entry point is reachable, rejects unknown packages, propagates mutest failure, and mutation-tests `hemx-axum`, `hemx-core`, `hemx-js`, and the full `hemx-test` package cleanly; full package closure remains. - **User value:** maintainers can run one bounded repository command and trust that meaningful Rust logic across every mutation-applicable library is either killed or explicitly justified. - **Build:** add a capped `hemx-xtask` mutation command that invokes `/opt/repositories/mutest`/`mutest` through package-native test targets rather than the broken workspace-wide example path; enumerate only current mutation-applicable library/proc-macro packages; finish adversarial tests or simplify code until every survivor is classified; keep equivalent, invariant-only, and infrastructure-inapplicable classifications inspectable and minimal; document the exact local release command in the existing readiness surface. -- **Blocked by:** none; broad survivors currently remain in `hemx-axum`, `hemx-build`, `hemx-derive`, and `hemx-lsp` outside already-clean focused contracts; the current `hemx-axum` frontier now proves page-mode, response constructors, form accessors/rejections, media-type limits, multipart success/error semantics, sync/async registry dispatch, effect/rejection responses, and embedded runtime delivery mutation-clean; page extraction/response, media-type integration, partial-constructor defaults, missing multipart-boundary rejection, and root-fingerprint injection are now mutation-clean; handler-registration and infallible runtime-header construction remain before the full package gate can pass. +- **Blocked by:** none; broad survivors currently remain in `hemx-build`, `hemx-derive`, and `hemx-lsp` outside already-clean focused contracts. The complete 470-mutant `hemx-axum` package gate now passes with 262 caught and 208 unviable after public page/form/multipart/registry/response/runtime proofs and narrow classification of infallible header parsing and streamed multipart unwrap-equivalent mutants. - **Proof:** the new xtask mutation command exits zero within its documented bound, covers each applicable package, emits no unexplained missed mutant, and a deliberate adjacent mutation makes it fail. `cargo run -p hemx-xtask -- test` remains green. req: test/020 req: test/021 ## 3. Elect and enforce the release license policy diff --git a/hemx-axum/src/lib.rs b/hemx-axum/src/lib.rs index 371efba..5a52681 100644 --- a/hemx-axum/src/lib.rs +++ b/hemx-axum/src/lib.rs @@ -1347,10 +1347,10 @@ impl IntoResponse for PageResponse { .headers_mut() .insert(HEMX_PARTIAL_HEADER, HeaderValue::from_static("true")); } - if let Some(fingerprint) = self.fingerprint.and_then(fingerprint_header) { + if let Some(fingerprint) = self.fingerprint { response .headers_mut() - .insert(HEMX_FINGERPRINT_HEADER, fingerprint); + .insert(HEMX_FINGERPRINT_HEADER, fingerprint_header(fingerprint)); } if let Some(title) = self .title @@ -1380,8 +1380,8 @@ fn html_with_root_fingerprint(mut html: String, fingerprint: BuildFingerprint) - html } -fn fingerprint_header(fingerprint: BuildFingerprint) -> Option { - HeaderValue::from_str(&fingerprint.0.to_string()).ok() +fn fingerprint_header(fingerprint: BuildFingerprint) -> HeaderValue { + HeaderValue::from(fingerprint.0) } impl IntoResponse for EffectResponse { @@ -1392,11 +1392,10 @@ impl IntoResponse for EffectResponse { header::CONTENT_TYPE, HeaderValue::from_static(HEMX_CONTENT_TYPE), ); - if let Some(fingerprint) = fingerprint_header(self.batch.fingerprint) { - response - .headers_mut() - .insert(HEMX_FINGERPRINT_HEADER, fingerprint); - } + response.headers_mut().insert( + HEMX_FINGERPRINT_HEADER, + fingerprint_header(self.batch.fingerprint), + ); response } } @@ -1442,8 +1441,7 @@ impl IntoResponse for RuntimeJs { ); response.headers_mut().insert( header::CONTENT_LENGTH, - HeaderValue::from_str(hemx_js::RUNTIME_JS.len().to_string().as_str()) - .expect("runtime length is a valid header value"), + HeaderValue::from(hemx_js::RUNTIME_JS.len() as u64), ); response } diff --git a/hemx-axum/tests/response.rs b/hemx-axum/tests/response.rs index ecb557b..1980ea4 100644 --- a/hemx-axum/tests/response.rs +++ b/hemx-axum/tests/response.rs @@ -13,7 +13,7 @@ use hemx_axum::{ InteractionRequest, IntoHandlerFailure, PageMode, PageRequest, PageResponse, HEMX_CONTENT_TYPE, HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE, HEMX_TITLE_HEADER, }; -use hemx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot}; +use hemx_core::{push, BuildFingerprint, Effect, Handle, IntoEffect, SafeHtml, Slot}; use scraper::{Html, Selector}; use std::sync::atomic::{AtomicUsize, Ordering}; use tower::ServiceExt; @@ -865,6 +865,155 @@ async fn registry_contains_registered_sync_and_async_handles_and_async_falls_bac // test req: axum_integration/003 req: public_api/001 } +#[tokio::test] +async fn state_result_registration_paths_preserve_state_form_and_failures() { + let sync = Handle::<()>::new(30); + let typed = Handle::<()>::new(31); + let asynchronous = Handle::<()>::new(32); + let typed_async = Handle::<()>::new(33); + let typed_plain = Handle::<()>::new(34); + let typed_state_plain = Handle::<()>::new(37); + let typed_async_plain = Handle::<()>::new(35); + let state_async_plain = Handle::<()>::new(36); + let registry = interactions(BuildFingerprint(88)) + .on_state_result(sync, String::from("sync"), |state: State| { + Ok::<_, HandlerBoom>(Slot::::new(1).text(state.0)) + }) + .on_state_form_result( + typed, + String::from("typed"), + |state: State, Form(form): Form| { + Err::(HandlerBoom).map_err(|error| { + let _ = (state, form); + error + }) + }, + ) + .on_state_async_result( + asynchronous, + String::from("async"), + |state: State| async move { + Ok::<_, HandlerBoom>(Slot::::new(1).text(state.0)) + }, + ) + .on_form(typed_plain, |Form(form): Form| { + Slot::::new(1).text(format!("typed:{}", form.project_id.0)) + }) + .on_state_form( + typed_state_plain, + String::from("typed-state"), + |state: State, Form(form): Form| { + Slot::::new(1).text(format!("{}:{}", state.0, form.project_id.0)) + }, + ) + .on_form_async( + typed_async_plain, + |Form(form): Form| async move { + Slot::::new(1).text(format!("typed-plain-async:{}", form.project_id.0)) + }, + ) + .on_state_async( + state_async_plain, + String::from("state-async"), + |state: State| async move { Slot::::new(1).text(state.0) }, + ) + .on_state_form_async_result( + typed_async, + String::from("typed-async"), + |state: State, Form(form): Form| async move { + Ok::<_, HandlerBoom>( + Slot::::new(1).text(format!("{}:{}", state.0, form.project_id.0)), + ) + }, + ); + + for handle in [ + sync, + typed, + asynchronous, + typed_async, + typed_plain, + typed_state_plain, + typed_async_plain, + state_async_plain, + ] { + assert!(registry.contains(handle.id().id)); + } + assert!(registry + .dispatch(InteractionForm::for_handle(sync, [])) + .is_ok()); + let invalid_form = registry + .dispatch(InteractionForm::for_handle( + typed, + [(String::from("project_id"), String::from("invalid"))], + )) + .unwrap_err(); + assert_eq!( + invalid_form, + DispatchRejection::InvalidForm { + handle_id: typed.id().id, + message: "invalid form field `project_id`".into(), + } + ); + let error = registry + .dispatch(InteractionForm::for_handle( + typed, + [(String::from("project_id"), String::from("7"))], + )) + .unwrap_err(); + assert_eq!( + error, + DispatchRejection::HandlerError(HandlerFailure::internal("database unavailable")) + ); + assert!(registry + .dispatch_async(InteractionForm::for_handle(asynchronous, [])) + .await + .is_ok()); + let invalid_plain = registry + .dispatch(InteractionForm::for_handle( + typed_plain, + [(String::from("project_id"), String::from("invalid"))], + )) + .unwrap_err(); + assert!(matches!( + invalid_plain, + DispatchRejection::InvalidForm { .. } + )); + let invalid_state_plain = registry + .dispatch(InteractionForm::for_handle( + typed_state_plain, + [(String::from("project_id"), String::from("invalid"))], + )) + .unwrap_err(); + assert!(matches!( + invalid_state_plain, + DispatchRejection::InvalidForm { .. } + )); + assert!(registry + .dispatch_async(InteractionForm::for_handle( + typed_async_plain, + [(String::from("project_id"), String::from("8"))], + )) + .await + .is_ok()); + assert!(registry + .dispatch_async(InteractionForm::for_handle(state_async_plain, [])) + .await + .is_ok()); + let response = registry + .dispatch_async(InteractionForm::for_handle( + typed_async, + [(String::from("project_id"), String::from("9"))], + )) + .await + .unwrap(); + assert_eq!( + response.batch.ops, + vec![Slot::::new(1).text("typed-async:9")] + ); + // test req: axum_integration/003 req: derive_handler/005 +} + #[test] fn interactions_reject_unknown_handle_ids() { let request = InteractionRequest::from(InteractionForm::new(9, []));