diff --git a/libdd-ffe-ffi/src/assignment.rs b/libdd-ffe-ffi/src/assignment.rs index 7e1d7e0f8d..09a8546753 100644 --- a/libdd-ffe-ffi/src/assignment.rs +++ b/libdd-ffe-ffi/src/assignment.rs @@ -7,7 +7,7 @@ use std::pin::Pin; use libdd_ffe::rules_based as ffe; use libdd_ffe::rules_based::{ Assignment, AssignmentReason, AssignmentValue, Configuration, EvaluationContext, - EvaluationError, Str, now, + EvaluationError, EvaluationResult, Str, now, }; use crate::Handle; @@ -20,11 +20,26 @@ pub struct ResolutionDetails { // memoizing some fields, so we can hand off references to them: error_message: Option, flag_metadata: Vec>, + dependency_evaluations: Vec, + exposure_trace: Vec, } impl ResolutionDetails { fn new(value: Result) -> ResolutionDetails { + Self::from_evaluation_result(EvaluationResult { + result: value, + evaluations: Vec::new(), + exposure_trace: Vec::new(), + }) + } + + fn from_evaluation_result(value: EvaluationResult) -> ResolutionDetails { + let EvaluationResult { + result, + evaluations, + exposure_trace, + } = value; // Pin the value to the heap to ensure its memory address remains stable - let inner = Box::pin(value); + let inner = Box::pin(result); let error_message = (*inner).as_ref().err().map(|err| err.to_string()); @@ -44,9 +59,58 @@ impl ResolutionDetails { inner, error_message, flag_metadata, + dependency_evaluations: evaluations + .into_iter() + .map(|record| DependencyEvaluation { + flag_key: record.flag_key, + details: EvaluationDetails::new(record.result), + exposure_eligible: record.exposure_eligible, + }) + .collect(), + exposure_trace, } } } + +/// Borrowed view of a prerequisite evaluation owned by `ResolutionDetails`. +pub struct DependencyEvaluation { + flag_key: Str, + details: EvaluationDetails, + exposure_eligible: bool, +} + +struct EvaluationDetails { + // Pin the result because metadata values borrow from assignment strings stored in it. + inner: Pin>>, + error_message: Option, + flag_metadata: Vec>, +} + +impl EvaluationDetails { + fn new(value: Result) -> Self { + let inner = Box::pin(value); + let error_message = (*inner).as_ref().err().map(ToString::to_string); + let mut flag_metadata = Vec::new(); + if let Ok(value) = (*inner).as_ref() { + flag_metadata.push(KeyValue { + key: BorrowedStr::borrow_static("__dd_allocation_key"), + // SAFETY: `inner` is pinned for the lifetime of this owner and is only shared. + value: unsafe { BorrowedStr::borrow_from_str(&value.allocation_key) }, + }); + } + Self { + inner, + error_message, + flag_metadata, + } + } +} + +impl AsRef> for EvaluationDetails { + fn as_ref(&self) -> &Result { + &self.inner + } +} impl From for ResolutionDetails { fn from(value: Assignment) -> Self { ResolutionDetails::new(Ok(value)) @@ -180,9 +244,15 @@ pub unsafe extern "C" fn ddog_ffe_get_assignment( ); }; - let assignment_result = config.eval_flag(flag_key, context, expected_type.into(), now()); + let evaluation_result = config.eval_flag_with_trace( + flag_key, + context, + expected_type.into(), + now(), + ffe::EvaluationOptions::default(), + ); - Handle::new(assignment_result.into()) + Handle::new(ResolutionDetails::from_evaluation_result(evaluation_result)) } #[repr(C)] @@ -233,6 +303,13 @@ pub struct ArrayMap { pub count: usize, } impl ArrayMap { + const fn empty() -> Self { + Self { + elements: std::ptr::null(), + count: 0, + } + } + /// # Safety /// - The returned value must not outlive `slice`. unsafe fn borrow_from_slice(slice: &[KeyValue]) -> ArrayMap { @@ -243,6 +320,50 @@ impl ArrayMap { } } +/// # Safety +/// The returned value must not outlive `value`. +unsafe fn assignment_value(value: &AssignmentValue) -> VariantValue { + match value { + AssignmentValue::String(value) => { + // SAFETY: the caller guarantees that the returned value does not outlive `value`. + VariantValue::String(unsafe { BorrowedStr::borrow_from_str(value) }) + } + AssignmentValue::Integer(value) => VariantValue::Integer(*value), + AssignmentValue::Float(value) => VariantValue::Float(*value), + AssignmentValue::Boolean(value) => VariantValue::Boolean(*value), + AssignmentValue::Json { value: _, raw } => { + // SAFETY: the caller guarantees that the returned value does not outlive `value`. + VariantValue::Object(unsafe { BorrowedStr::borrow_from_str(raw.get()) }) + } + } +} + +fn reason_from_result(result: &Result) -> Reason { + match result { + Ok(assignment) => assignment.reason.into(), + Err(EvaluationError::FlagDisabled) => Reason::Disabled, + Err(EvaluationError::DefaultAllocationNull) => Reason::Default, + Err(_) => Reason::Error, + } +} + +fn error_code_from_result(result: &Result) -> ErrorCode { + match result { + Ok(_) => ErrorCode::Ok, + Err(error) => ErrorCode::from(error), + } +} + +fn dependency_error_code(error: &EvaluationError) -> ErrorCode { + match error { + EvaluationError::DependencyEvaluationFailed { source, .. } => dependency_error_code(source), + EvaluationError::FlagDisabled | EvaluationError::DefaultAllocationNull => { + ErrorCode::General + } + error => ErrorCode::from(error), + } +} + impl BorrowedStr { /// Borrow string from `s`. /// @@ -289,21 +410,8 @@ pub unsafe extern "C" fn ddog_ffe_assignment_get_value( ) -> VariantValue { // SAFETY: the caller must ensure that assignment is valid. match unsafe { assignment.as_ref() }.as_ref() { - Ok(assignment) => match &assignment.value { - AssignmentValue::String(s) => { - // SAFETY: caller is required to not use return value after freeing - // `assignment`. - VariantValue::String(unsafe { BorrowedStr::borrow_from_str(s.as_str()) }) - } - AssignmentValue::Integer(v) => VariantValue::Integer(*v), - AssignmentValue::Float(v) => VariantValue::Float(*v), - AssignmentValue::Boolean(v) => VariantValue::Boolean(*v), - AssignmentValue::Json { value: _, raw } => { - // SAFETY: caller is required to not use return value after freeing - // `assignment`. - VariantValue::Object(unsafe { BorrowedStr::borrow_from_str(raw.get()) }) - } - }, + // SAFETY: caller is required not to use the returned value after freeing `assignment`. + Ok(assignment) => unsafe { assignment_value(&assignment.value) }, _ => VariantValue::None, } } @@ -366,12 +474,7 @@ pub unsafe extern "C" fn ddog_ffe_assignment_get_reason( } impl From<&ResolutionDetails> for Reason { fn from(value: &ResolutionDetails) -> Self { - match value.as_ref() { - Ok(assignment) => assignment.reason.into(), - Err(EvaluationError::FlagDisabled) => Reason::Disabled, - Err(EvaluationError::DefaultAllocationNull) => Reason::Default, - Err(_) => Reason::Error, - } + reason_from_result(value.as_ref()) } } impl From for Reason { @@ -396,17 +499,18 @@ pub unsafe extern "C" fn ddog_ffe_assignment_get_error_code( } impl From<&ResolutionDetails> for ErrorCode { fn from(value: &ResolutionDetails) -> Self { - match value.as_ref() { - Ok(_) => ErrorCode::Ok, - Err(err) => ErrorCode::from(err), - } + error_code_from_result(value.as_ref()) } } impl From<&EvaluationError> for ErrorCode { fn from(value: &EvaluationError) -> Self { match value { + EvaluationError::DependencyEvaluationFailed { source, .. } => { + dependency_error_code(source) + } EvaluationError::TypeMismatch { .. } => ErrorCode::TypeMismatch, EvaluationError::TargetingKeyMissing => ErrorCode::TargetingKeyMissing, + EvaluationError::InvalidContext => ErrorCode::InvalidContext, EvaluationError::ConfigurationParseError => ErrorCode::ParseError, EvaluationError::FlagConfigurationInvalid => ErrorCode::ParseError, EvaluationError::ConfigurationMissing => ErrorCode::ProviderNotReady, @@ -476,6 +580,278 @@ pub unsafe extern "C" fn ddog_ffe_assignnment_get_flag_metadata( unsafe { ArrayMap::borrow_from_slice(&a.flag_metadata) } } +/// Number of completed prerequisite evaluations returned with this root evaluation. +/// +/// Records are in completion order and exclude the requested root flag. +/// +/// # Safety +/// `assignment` must be a valid handle. +// allow(ffi-panic-boundary): shared length accessor under the documented valid-handle precondition +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_assignment_get_dependency_evaluation_count( + assignment: Handle, +) -> usize { + // SAFETY: the caller must ensure that assignment is valid. + unsafe { assignment.as_ref() }.dependency_evaluations.len() +} + +/// Get a borrowed prerequisite evaluation by completion-order index. +/// +/// Returns `NULL` when `index` is out of bounds. The returned pointer is owned by `assignment` +/// and must not be freed or used after `assignment` is dropped. +/// +/// # Safety +/// `assignment` must be a valid handle. +// allow(ffi-panic-boundary): checked index accessor under the documented valid-handle precondition +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_assignment_get_dependency_evaluation( + assignment: Handle, + index: usize, +) -> *const DependencyEvaluation { + // SAFETY: the caller must ensure that assignment is valid. + unsafe { assignment.as_ref() } + .dependency_evaluations + .get(index) + .map_or(std::ptr::null(), std::ptr::from_ref) +} + +/// Get the flag key for a borrowed prerequisite evaluation. +/// +/// Returns an empty string for `NULL`. The returned string remains valid until the owning root +/// assignment is dropped. +/// +/// # Safety +/// A non-NULL `evaluation` must have been returned by +/// `ddog_ffe_assignment_get_dependency_evaluation` from a live assignment. +// allow(ffi-panic-boundary): null-checked shared string accessor has no panicking operations +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_dependency_evaluation_get_flag_key( + evaluation: *const DependencyEvaluation, +) -> BorrowedStr { + // SAFETY: the caller guarantees that a non-NULL pointer is valid and its owner is live. + let Some(evaluation) = (unsafe { evaluation.as_ref() }) else { + return BorrowedStr::empty(); + }; + // SAFETY: the returned borrow is bounded by the owning assignment's lifetime. + unsafe { BorrowedStr::borrow_from_str(&evaluation.flag_key) } +} + +/// Get the value produced by a borrowed prerequisite evaluation. +/// +/// # Safety +/// A non-NULL `evaluation` must be a live borrowed prerequisite evaluation. +// allow(ffi-panic-boundary): null-checked shared value accessor has no panicking operations +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_dependency_evaluation_get_value( + evaluation: *const DependencyEvaluation, +) -> VariantValue { + // SAFETY: the caller guarantees that a non-NULL pointer is valid and its owner is live. + let Some(evaluation) = (unsafe { evaluation.as_ref() }) else { + return VariantValue::None; + }; + match evaluation.details.as_ref() { + // SAFETY: the returned borrow is bounded by the owning assignment's lifetime. + Ok(assignment) => unsafe { assignment_value(&assignment.value) }, + Err(_) => VariantValue::None, + } +} + +/// Get the selected variant key for a borrowed prerequisite evaluation. +/// +/// # Safety +/// A non-NULL `evaluation` must be a live borrowed prerequisite evaluation. +// allow(ffi-panic-boundary): null-checked shared string accessor has no panicking operations +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_dependency_evaluation_get_variant( + evaluation: *const DependencyEvaluation, +) -> BorrowedStr { + // SAFETY: the caller guarantees that a non-NULL pointer is valid and its owner is live. + let Some(evaluation) = (unsafe { evaluation.as_ref() }) else { + return BorrowedStr::empty(); + }; + match evaluation.details.as_ref() { + // SAFETY: the returned borrow is bounded by the owning assignment's lifetime. + Ok(assignment) => unsafe { BorrowedStr::borrow_from_str(&assignment.variation_key) }, + Err(_) => BorrowedStr::empty(), + } +} + +/// Get the selected allocation key for a borrowed prerequisite evaluation. +/// +/// # Safety +/// A non-NULL `evaluation` must be a live borrowed prerequisite evaluation. +// allow(ffi-panic-boundary): null-checked shared string accessor has no panicking operations +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_dependency_evaluation_get_allocation_key( + evaluation: *const DependencyEvaluation, +) -> BorrowedStr { + // SAFETY: the caller guarantees that a non-NULL pointer is valid and its owner is live. + let Some(evaluation) = (unsafe { evaluation.as_ref() }) else { + return BorrowedStr::empty(); + }; + match evaluation.details.as_ref() { + // SAFETY: the returned borrow is bounded by the owning assignment's lifetime. + Ok(assignment) => unsafe { BorrowedStr::borrow_from_str(&assignment.allocation_key) }, + Err(_) => BorrowedStr::empty(), + } +} + +/// # Safety +/// A non-NULL `evaluation` must be a live borrowed prerequisite evaluation. +// allow(ffi-panic-boundary): null-checked enum mapping has no panicking operations +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_dependency_evaluation_get_reason( + evaluation: *const DependencyEvaluation, +) -> Reason { + // SAFETY: the caller guarantees that a non-NULL pointer is valid and its owner is live. + let Some(evaluation) = (unsafe { evaluation.as_ref() }) else { + return Reason::Error; + }; + match evaluation.details.as_ref() { + Ok(assignment) => assignment.reason.into(), + Err(_) => Reason::Error, + } +} + +/// # Safety +/// A non-NULL `evaluation` must be a live borrowed prerequisite evaluation. +// allow(ffi-panic-boundary): null-checked enum mapping has no panicking operations +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_dependency_evaluation_get_error_code( + evaluation: *const DependencyEvaluation, +) -> ErrorCode { + // SAFETY: the caller guarantees that a non-NULL pointer is valid and its owner is live. + let Some(evaluation) = (unsafe { evaluation.as_ref() }) else { + return ErrorCode::General; + }; + match evaluation.details.as_ref() { + Ok(_) => ErrorCode::Ok, + Err(error) => dependency_error_code(error), + } +} + +/// Get the error message for a borrowed prerequisite evaluation. +/// +/// # Safety +/// A non-NULL `evaluation` must be a live borrowed prerequisite evaluation. +// allow(ffi-panic-boundary): null-checked shared string accessor has no panicking operations +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_dependency_evaluation_get_error_message( + evaluation: *const DependencyEvaluation, +) -> BorrowedStr { + // SAFETY: the caller guarantees that a non-NULL pointer is valid and its owner is live. + let Some(evaluation) = (unsafe { evaluation.as_ref() }) else { + return BorrowedStr::empty(); + }; + match evaluation.details.as_ref() { + Err(EvaluationError::FlagDisabled | EvaluationError::DefaultAllocationNull) => { + BorrowedStr::borrow_static("prerequisite has no assignment") + } + _ => match &evaluation.details.error_message { + // SAFETY: the returned borrow is bounded by the owning assignment's lifetime. + Some(message) => unsafe { BorrowedStr::borrow_from_str(message) }, + None => BorrowedStr::empty(), + }, + } +} + +/// # Safety +/// A non-NULL `evaluation` must be a live borrowed prerequisite evaluation. +// allow(ffi-panic-boundary): null-checked scalar accessor has no panicking operations +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_dependency_evaluation_get_serial_id( + evaluation: *const DependencyEvaluation, +) -> libdd_common_ffi::Option { + // SAFETY: the caller guarantees that a non-NULL pointer is valid and its owner is live. + let Some(evaluation) = (unsafe { evaluation.as_ref() }) else { + return libdd_common_ffi::Option::None; + }; + match evaluation.details.as_ref() { + Ok(assignment) => assignment.serial_id.into(), + Err(_) => libdd_common_ffi::Option::None, + } +} + +/// # Safety +/// A non-NULL `evaluation` must be a live borrowed prerequisite evaluation. +// allow(ffi-panic-boundary): null-checked scalar accessor has no panicking operations +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_dependency_evaluation_get_do_log( + evaluation: *const DependencyEvaluation, +) -> bool { + // SAFETY: the caller guarantees that a non-NULL pointer is valid and its owner is live. + let Some(evaluation) = (unsafe { evaluation.as_ref() }) else { + return false; + }; + matches!(evaluation.details.as_ref(), Ok(assignment) if assignment.do_log) +} + +/// Whether this prerequisite belongs to the successful root exposure path. +/// +/// This does not override `do_log` or SDK exposure consent and deduplication policy. +/// +/// # Safety +/// A non-NULL `evaluation` must be a live borrowed prerequisite evaluation. +// allow(ffi-panic-boundary): null-checked scalar accessor has no panicking operations +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_dependency_evaluation_get_exposure_eligible( + evaluation: *const DependencyEvaluation, +) -> bool { + // SAFETY: the caller guarantees that a non-NULL pointer is valid and its owner is live. + (unsafe { evaluation.as_ref() }).is_some_and(|evaluation| evaluation.exposure_eligible) +} + +/// Get flag metadata for a borrowed prerequisite evaluation. +/// +/// # Safety +/// A non-NULL `evaluation` must be a live borrowed prerequisite evaluation. +// allow(ffi-panic-boundary): null-checked shared slice accessor has no panicking operations +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_dependency_evaluation_get_flag_metadata( + evaluation: *const DependencyEvaluation, +) -> ArrayMap { + // SAFETY: the caller guarantees that a non-NULL pointer is valid and its owner is live. + let Some(evaluation) = (unsafe { evaluation.as_ref() }) else { + return ArrayMap::empty(); + }; + // SAFETY: the returned borrow is bounded by the owning assignment's lifetime. + unsafe { ArrayMap::borrow_from_slice(&evaluation.details.flag_metadata) } +} + +/// Number of prerequisite keys in the successful root exposure path. +/// +/// # Safety +/// `assignment` must be a valid handle. +// allow(ffi-panic-boundary): shared length accessor under the documented valid-handle precondition +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_assignment_get_exposure_trace_count( + assignment: Handle, +) -> usize { + // SAFETY: the caller must ensure that assignment is valid. + unsafe { assignment.as_ref() }.exposure_trace.len() +} + +/// Get one successful-path prerequisite key in depth-first authored order. +/// +/// Returns an empty string when `index` is out of bounds. The returned string remains valid until +/// the owning root assignment is dropped. +/// +/// # Safety +/// `assignment` must be a valid handle. +// allow(ffi-panic-boundary): checked index accessor under the documented valid-handle precondition +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ddog_ffe_assignment_get_exposure_trace_flag_key( + assignment: Handle, + index: usize, +) -> BorrowedStr { + // SAFETY: the caller must ensure that assignment is valid. + let Some(flag_key) = unsafe { assignment.as_ref() }.exposure_trace.get(index) else { + return BorrowedStr::empty(); + }; + // SAFETY: the returned borrow is bounded by the owning assignment's lifetime. + unsafe { BorrowedStr::borrow_from_str(flag_key) } +} + /// Frees an Assignment handle. /// /// # Safety @@ -490,6 +866,434 @@ pub unsafe extern "C" fn ddog_ffe_assignment_drop(assignment: *mut Handle Assignment { + assignment_with_value(AssignmentValue::Boolean(true), variant, allocation, do_log) + } + + fn assignment_with_value( + value: AssignmentValue, + variant: &str, + allocation: &str, + do_log: bool, + ) -> Assignment { + Assignment { + value, + variation_key: variant.into(), + allocation_key: allocation.into(), + reason: AssignmentReason::TargetingMatch, + serial_id: Some(7), + do_log, + } + } + + unsafe fn copy_handle(handle: &Handle) -> Handle { + // SAFETY: tests keep the original allocation live and free only the original handle. + unsafe { std::ptr::read(handle) } + } + + fn call_handle( + handle: &Handle, + function: unsafe extern "C" fn(Handle) -> R, + ) -> R { + // SAFETY: the original handle remains live and is the only copy that will be freed. + let copied = unsafe { copy_handle(handle) }; + // SAFETY: `copied` points to the same live allocation as `handle`. + unsafe { function(copied) } + } + + fn call_handle_at( + handle: &Handle, + index: usize, + function: unsafe extern "C" fn(Handle, usize) -> R, + ) -> R { + // SAFETY: the original handle remains live and is the only copy that will be freed. + let copied = unsafe { copy_handle(handle) }; + // SAFETY: `copied` points to the same live allocation as `handle`. + unsafe { function(copied, index) } + } + + fn call_evaluation( + evaluation: *const DependencyEvaluation, + function: unsafe extern "C" fn(*const DependencyEvaluation) -> R, + ) -> R { + // SAFETY: tests pass NULL or a borrowed pointer whose parent remains live. + unsafe { function(evaluation) } + } + + fn borrowed_str(value: BorrowedStr) -> String { + // SAFETY: tests only call this while the owner of the borrowed value is live. + String::from_utf8(unsafe { value.as_bytes() }.to_vec()).unwrap() + } + + #[test] + fn returned_batch_ffi_preserves_order_bounds_and_root_compatibility() { + let result = EvaluationResult { + result: Ok(assignment("root-on", "root-allocation", true)), + evaluations: vec![ + ffe::EvaluationRecord { + flag_key: "leaf".into(), + result: Ok(assignment("leaf-on", "leaf-allocation", false)), + exposure_eligible: true, + }, + ffe::EvaluationRecord { + flag_key: "parent".into(), + result: Err(EvaluationError::Internal("dependency failed".into())), + exposure_eligible: false, + }, + ], + exposure_trace: vec!["parent".into(), "leaf".into()], + }; + let mut handle = Handle::new(ResolutionDetails::from_evaluation_result(result)); + + // Existing root accessors continue to operate on the root result. + assert_eq!( + call_handle(&handle, ddog_ffe_assignment_get_error_code), + ErrorCode::Ok + ); + assert_eq!( + borrowed_str(call_handle(&handle, ddog_ffe_assignment_get_variant)), + "root-on" + ); + + assert_eq!( + call_handle(&handle, ddog_ffe_assignment_get_dependency_evaluation_count,), + 2 + ); + let leaf = call_handle_at(&handle, 0, ddog_ffe_assignment_get_dependency_evaluation); + let parent = call_handle_at(&handle, 1, ddog_ffe_assignment_get_dependency_evaluation); + assert!(!leaf.is_null()); + assert!(!parent.is_null()); + assert!( + call_handle_at(&handle, 2, ddog_ffe_assignment_get_dependency_evaluation).is_null() + ); + + assert_eq!( + borrowed_str(call_evaluation( + leaf, + ddog_ffe_dependency_evaluation_get_flag_key, + )), + "leaf" + ); + assert_eq!( + borrowed_str(call_evaluation( + leaf, + ddog_ffe_dependency_evaluation_get_variant, + )), + "leaf-on" + ); + assert_eq!( + borrowed_str(call_evaluation( + leaf, + ddog_ffe_dependency_evaluation_get_allocation_key, + )), + "leaf-allocation" + ); + assert!(!call_evaluation( + leaf, + ddog_ffe_dependency_evaluation_get_do_log + )); + assert!(call_evaluation( + leaf, + ddog_ffe_dependency_evaluation_get_exposure_eligible + )); + assert_eq!( + call_evaluation(leaf, ddog_ffe_dependency_evaluation_get_error_code), + ErrorCode::Ok + ); + let metadata = call_evaluation(leaf, ddog_ffe_dependency_evaluation_get_flag_metadata); + assert_eq!(metadata.count, 1); + assert_eq!( + call_evaluation(leaf, ddog_ffe_dependency_evaluation_get_serial_id), + libdd_common_ffi::Option::Some(7) + ); + assert!(matches!( + call_evaluation(leaf, ddog_ffe_dependency_evaluation_get_reason), + Reason::TargetingMatch + )); + + // Failed records retain their key and owned error text without a value. + assert_eq!( + call_evaluation(parent, ddog_ffe_dependency_evaluation_get_error_code), + ErrorCode::General + ); + assert_eq!( + borrowed_str(call_evaluation( + parent, + ddog_ffe_dependency_evaluation_get_error_message, + )), + "internal error: dependency failed" + ); + assert!(matches!( + call_evaluation(parent, ddog_ffe_dependency_evaluation_get_value), + VariantValue::None + )); + + // Exposure trace ordering is independent from evaluation completion ordering. + assert_eq!( + call_handle(&handle, ddog_ffe_assignment_get_exposure_trace_count), + 2 + ); + assert_eq!( + borrowed_str(call_handle_at( + &handle, + 0, + ddog_ffe_assignment_get_exposure_trace_flag_key, + )), + "parent" + ); + assert_eq!( + call_handle_at(&handle, 2, ddog_ffe_assignment_get_exposure_trace_flag_key,).len, + 0 + ); + + // NULL child access is defined and never dereferences memory. + assert_eq!( + call_evaluation( + std::ptr::null(), + ddog_ffe_dependency_evaluation_get_flag_key, + ) + .len, + 0 + ); + // SAFETY: all borrows ended; the parent is the sole owner and is freed exactly once. + unsafe { ddog_ffe_assignment_drop(&mut handle) }; + } + + #[test] + fn returned_batch_ffi_exposes_depth_terminal_before_its_ancestor() { + let wire = ffe::UniversalFlagConfig::from_json( + br#"{ + "createdAt":"2026-10-28T00:00:00Z", + "environment":{"name":"test"}, + "evaluatorParams":{"maxDependencyDepth":1}, + "flags":{ + "root":{"key":"root","enabled":true,"variationType":"BOOLEAN","variations":{"on":{"key":"on","value":true}},"allocations":[{"key":"root-a","rules":[{"conditions":[{"flagEvaluation":{"key":"f1"},"operator":"ONE_OF","value":["on"]}]}],"splits":[{"variationKey":"on","shards":[]}]}]}, + "f1":{"key":"f1","enabled":true,"variationType":"BOOLEAN","variations":{"on":{"key":"on","value":true}},"allocations":[{"key":"f1-a","rules":[{"conditions":[{"flagEvaluation":{"key":"f2"},"operator":"ONE_OF","value":["on"]}]}],"splits":[{"variationKey":"on","shards":[]}]}]}, + "f2":{"key":"f2","enabled":true,"variationType":"BOOLEAN","variations":{"on":{"key":"on","value":true}},"allocations":[{"key":"f2-a","splits":[{"variationKey":"on","shards":[]}]}]} + } + }"# + .to_vec(), + ) + .unwrap(); + let config = Configuration::from_server_response(wire); + let result = config.eval_flag_with_trace( + "root", + &EvaluationContext::new(Some("subject".into()), Default::default()), + ffe::ExpectedFlagType::Boolean, + now(), + ffe::EvaluationOptions::default(), + ); + let mut handle = Handle::new(ResolutionDetails::from_evaluation_result(result)); + + assert_eq!( + call_handle(&handle, ddog_ffe_assignment_get_dependency_evaluation_count), + 2 + ); + let terminal = call_handle_at(&handle, 0, ddog_ffe_assignment_get_dependency_evaluation); + let ancestor = call_handle_at(&handle, 1, ddog_ffe_assignment_get_dependency_evaluation); + assert_eq!( + borrowed_str(call_evaluation( + terminal, + ddog_ffe_dependency_evaluation_get_flag_key, + )), + "f2" + ); + assert_eq!( + borrowed_str(call_evaluation( + terminal, + ddog_ffe_dependency_evaluation_get_error_message, + )), + "flag dependency depth exceeded" + ); + assert_eq!( + borrowed_str(call_evaluation( + ancestor, + ddog_ffe_dependency_evaluation_get_flag_key, + )), + "f1" + ); + assert_eq!( + borrowed_str(call_evaluation( + ancestor, + ddog_ffe_dependency_evaluation_get_error_message, + )), + "prerequisite f2 failed: flag dependency depth exceeded" + ); + // SAFETY: all child borrows ended before the sole owner is freed. + unsafe { ddog_ffe_assignment_drop(&mut handle) }; + } + + #[test] + fn failed_root_retains_dependency_records_and_has_no_exposure_trace() { + let result = EvaluationResult { + result: Err(EvaluationError::DependencyCycle), + evaluations: vec![ffe::EvaluationRecord { + flag_key: "completed".into(), + result: Ok(assignment("on", "allocation", true)), + exposure_eligible: false, + }], + exposure_trace: Vec::new(), + }; + let mut handle = Handle::new(ResolutionDetails::from_evaluation_result(result)); + assert_eq!( + call_handle(&handle, ddog_ffe_assignment_get_error_code), + ErrorCode::General + ); + assert_eq!( + call_handle(&handle, ddog_ffe_assignment_get_dependency_evaluation_count,), + 1 + ); + assert_eq!( + call_handle(&handle, ddog_ffe_assignment_get_exposure_trace_count), + 0 + ); + // SAFETY: all borrows ended and the parent owns every child record. + unsafe { ddog_ffe_assignment_drop(&mut handle) }; + } + + #[test] + fn dependency_no_assignment_errors_do_not_reuse_legacy_root_mappings() { + for error in [ + EvaluationError::FlagDisabled, + EvaluationError::DefaultAllocationNull, + ] { + let result = EvaluationResult { + result: Ok(assignment("root", "root", false)), + evaluations: vec![ffe::EvaluationRecord { + flag_key: "dependency".into(), + result: Err(error.clone()), + exposure_eligible: false, + }], + exposure_trace: Vec::new(), + }; + let mut handle = Handle::new(ResolutionDetails::from_evaluation_result(result)); + let dependency = + call_handle_at(&handle, 0, ddog_ffe_assignment_get_dependency_evaluation); + assert!(matches!( + call_evaluation(dependency, ddog_ffe_dependency_evaluation_get_reason), + Reason::Error + )); + assert_eq!( + call_evaluation(dependency, ddog_ffe_dependency_evaluation_get_error_code,), + ErrorCode::General + ); + assert_eq!( + borrowed_str(call_evaluation( + dependency, + ddog_ffe_dependency_evaluation_get_error_message, + )), + "prerequisite has no assignment" + ); + // SAFETY: all child borrows ended before the sole owner is freed. + unsafe { ddog_ffe_assignment_drop(&mut handle) }; + + let root = ResolutionDetails::from(error); + assert!(matches!( + Reason::from(&root), + Reason::Disabled | Reason::Default + )); + assert_eq!(ErrorCode::from(&root), ErrorCode::Ok); + } + } + + #[test] + fn propagated_dependency_errors_preserve_the_leaf_error_code() { + let propagated = EvaluationError::DependencyEvaluationFailed { + flag_key: "missing".into(), + source: Box::new(EvaluationError::FlagUnrecognizedOrDisabled), + }; + let result = EvaluationResult { + result: Err(propagated.clone()), + evaluations: vec![ffe::EvaluationRecord { + flag_key: "ancestor".into(), + result: Err(propagated), + exposure_eligible: false, + }], + exposure_trace: Vec::new(), + }; + let mut handle = Handle::new(ResolutionDetails::from_evaluation_result(result)); + assert_eq!( + call_handle(&handle, ddog_ffe_assignment_get_error_code), + ErrorCode::FlagNotFound + ); + let ancestor = call_handle_at(&handle, 0, ddog_ffe_assignment_get_dependency_evaluation); + assert_eq!( + call_evaluation(ancestor, ddog_ffe_dependency_evaluation_get_error_code), + ErrorCode::FlagNotFound + ); + // SAFETY: all child borrows ended before the sole owner is freed. + unsafe { ddog_ffe_assignment_drop(&mut handle) }; + } + + #[test] + fn dependency_values_cover_every_supported_ffi_variant() { + let wire = ffe::UniversalFlagConfig::from_json( + br#"{ + "createdAt":"2026-10-28T00:00:00Z", "environment":{"name":"test"}, + "flags":{ + "string":{"key":"string","enabled":true,"variationType":"STRING","variations":{"v":{"key":"v","value":"text"}},"allocations":[{"key":"a","doLog":true,"splits":[{"variationKey":"v","shards":[]}]}]}, + "integer":{"key":"integer","enabled":true,"variationType":"INTEGER","variations":{"v":{"key":"v","value":42}},"allocations":[{"key":"a","doLog":true,"splits":[{"variationKey":"v","shards":[]}]}]}, + "float":{"key":"float","enabled":true,"variationType":"NUMERIC","variations":{"v":{"key":"v","value":1.5}},"allocations":[{"key":"a","doLog":true,"splits":[{"variationKey":"v","shards":[]}]}]}, + "boolean":{"key":"boolean","enabled":true,"variationType":"BOOLEAN","variations":{"v":{"key":"v","value":true}},"allocations":[{"key":"a","doLog":true,"splits":[{"variationKey":"v","shards":[]}]}]}, + "object":{"key":"object","enabled":true,"variationType":"JSON","variations":{"v":{"key":"v","value":{"key":"value"}}},"allocations":[{"key":"a","doLog":true,"splits":[{"variationKey":"v","shards":[]}]}]} + } + }"# + .to_vec(), + ) + .unwrap(); + let config = Configuration::from_server_response(wire); + let context = EvaluationContext::new(Some("subject".into()), Default::default()); + let result = EvaluationResult { + result: Ok(assignment("root", "root", false)), + evaluations: ["string", "integer", "float", "boolean", "object"] + .into_iter() + .map(|flag_key| ffe::EvaluationRecord { + flag_key: flag_key.into(), + result: config.eval_flag(flag_key, &context, ffe::ExpectedFlagType::Any, now()), + exposure_eligible: true, + }) + .collect(), + exposure_trace: Vec::new(), + }; + let mut handle = Handle::new(ResolutionDetails::from_evaluation_result(result)); + let records = (0..5) + .map(|index| { + call_handle_at( + &handle, + index, + ddog_ffe_assignment_get_dependency_evaluation, + ) + }) + .collect::>(); + + let VariantValue::String(value) = + call_evaluation(records[0], ddog_ffe_dependency_evaluation_get_value) + else { + panic!("expected string dependency value"); + }; + assert_eq!(borrowed_str(value), "text"); + assert!(matches!( + call_evaluation(records[1], ddog_ffe_dependency_evaluation_get_value), + VariantValue::Integer(42) + )); + assert!(matches!( + call_evaluation(records[2], ddog_ffe_dependency_evaluation_get_value), + VariantValue::Float(value) if value == 1.5 + )); + assert!(matches!( + call_evaluation(records[3], ddog_ffe_dependency_evaluation_get_value), + VariantValue::Boolean(true) + )); + let VariantValue::Object(value) = + call_evaluation(records[4], ddog_ffe_dependency_evaluation_get_value) + else { + panic!("expected object dependency value"); + }; + assert_eq!(borrowed_str(value), r#"{"key":"value"}"#); + // SAFETY: all borrowed record pointers are dead before the sole owning parent is freed. + unsafe { ddog_ffe_assignment_drop(&mut handle) }; + } + #[test] fn rejected_flag_is_exposed_as_parse_error() { let details = ResolutionDetails::from(EvaluationError::FlagConfigurationInvalid); diff --git a/libdd-ffe/README.md b/libdd-ffe/README.md index e0d9c1fc2b..02c748b8fb 100644 --- a/libdd-ffe/README.md +++ b/libdd-ffe/README.md @@ -58,8 +58,16 @@ so reuse from a deeper path cannot bypass the limit. `Configuration::eval_flag_with_trace` additionally returns unique prerequisite evaluation records, excluding the requested root. SDKs can log these evaluations even if a later -condition rejects their rule. Only records marked `exposure_eligible` belong to the root's -successful rule/split path; a failed root has no eligible prerequisite exposures. SDKs must -also apply each assignment's `do_log`, consent, and ordinary exposure deduplication policy. -The trace does not emit telemetry itself. SDKs should advertise dependent-flag support only -after integrating and testing both evaluation and exposure reporting. +condition rejects their rule. `exposure_trace` lists the successful rule/split path in +depth-first authored order, including the replay position of memoized prerequisites. Only +records marked `exposure_eligible` appear in that trace; a failed root has no eligible +prerequisite exposures. SDKs must also apply each assignment's `do_log`, consent, and ordinary +exposure deduplication policy. The trace does not emit telemetry itself. SDKs should advertise +dependent-flag support only after integrating and testing both evaluation and exposure +reporting. + +This API returns prerequisite evaluation records after the root evaluation completes. That is +the intended integration boundary: FFI consumers may accumulate and publish the returned records +as a batch once the root result is available. Evaluation records are retained even when the root +fails; unlike exposure candidates, they are not rolled back. Traversal-time callbacks are not +required. diff --git a/libdd-ffe/src/rules_based/eval/dependent_flags_tests.rs b/libdd-ffe/src/rules_based/eval/dependent_flags_tests.rs index 18f60f7f6c..4efbb627bf 100644 --- a/libdd-ffe/src/rules_based/eval/dependent_flags_tests.rs +++ b/libdd-ffe/src/rules_based/eval/dependent_flags_tests.rs @@ -133,6 +133,8 @@ fn invalid_dependency_conditions_are_isolated_to_the_containing_flag() { json!({"flagEvaluation": {"key": "parent"}, "operator": "ONE_OF", "value": []}), json!({"flagEvaluation": {"key": "parent"}, "operator": "NOT_ONE_OF", "value": []}), json!({"flagEvaluation": {"key": "parent"}, "attribute": "country", "operator": "ONE_OF", "value": ["on"]}), + json!({"flagEvaluation": {"key": "parent"}, "attribute": "", "operator": "ONE_OF", "value": ["on"]}), + json!({"flagEvaluation": {"key": "parent", "unexpected": true}, "operator": "ONE_OF", "value": ["on"]}), json!({"flagEvaluation": {"key": ""}, "operator": "ONE_OF", "value": ["on"]}), json!({"flagEvaluation": {"key": "parent"}, "operator": "IS_NULL", "value": false}), json!({"flagEvaluation": {"key": "parent"}, "operator": "ONE_OF", "value": "on"}), @@ -170,6 +172,52 @@ fn dnf_expansion_memoizes_but_restores_exposure_eligibility_for_the_winning_rule assert_eq!(result.result.unwrap().variation_key.as_str(), "on"); assert_eq!(result.evaluations.len(), 1); assert!(result.evaluations[0].exposure_eligible); + assert_eq!( + result + .exposure_trace + .iter() + .map(|key| key.as_str()) + .collect::>(), + ["parent"] + ); +} + +#[test] +fn memoized_exposure_trace_replays_the_winning_authored_order() { + let mut root = flag(vec![]); + root["allocations"][0]["rules"] = json!([ + {"conditions": [dependency("first"), fails()]}, + {"conditions": [dependency("second"), dependency("first")]} + ]); + let result = eval(wire(json!({ + "root": root, + "first": flag(vec![]), + "second": flag(vec![]) + }))); + + assert_eq!(result.result.unwrap().variation_key.as_str(), "on"); + assert_eq!( + result + .evaluations + .iter() + .map(|record| record.flag_key.as_str()) + .collect::>(), + ["first", "second"] + ); + assert_eq!( + result + .exposure_trace + .iter() + .map(|key| key.as_str()) + .collect::>(), + ["second", "first"] + ); + assert!( + result + .evaluations + .iter() + .all(|record| record.exposure_eligible) + ); } #[test] @@ -181,6 +229,7 @@ fn rejected_rules_retain_evaluations_without_exposures() { assert_eq!(result.evaluations.len(), 1); assert!(result.evaluations[0].result.is_ok()); assert!(!result.evaluations[0].exposure_eligible); + assert!(result.exposure_trace.is_empty()); } #[test] @@ -191,6 +240,29 @@ fn rejected_split_does_not_expose_prerequisites() { let result = eval(wire(json!({"root": root, "parent": flag(vec![])}))); assert_eq!(result.result.unwrap().variation_key.as_str(), "off"); assert!(!result.evaluations[0].exposure_eligible); + assert!(result.exposure_trace.is_empty()); +} + +#[test] +fn later_allocation_replays_memoized_exposure_after_no_split() { + let mut root = flag(vec![dependency("parent")]); + root["allocations"][0]["splits"][0]["shards"] = + json!([{"salt":"salt", "totalShards":100,"ranges":[]}]); + root["allocations"][1]["rules"] = json!([{"conditions": [dependency("parent")]}]); + let result = eval(wire(json!({"root": root, "parent": flag(vec![])}))); + + assert_eq!(result.result.unwrap().variation_key.as_str(), "off"); + assert_eq!(result.evaluations.len(), 1); + assert_eq!(result.evaluations[0].flag_key.as_str(), "parent"); + assert!(result.evaluations[0].exposure_eligible); + assert_eq!( + result + .exposure_trace + .iter() + .map(|key| key.as_str()) + .collect::>(), + ["parent"] + ); } #[test] @@ -201,6 +273,7 @@ fn root_failure_suppresses_all_prerequisite_exposures() { assert!(result.result.is_err()); assert_eq!(result.evaluations.len(), 2); assert!(result.evaluations.iter().all(|r| !r.exposure_eligible)); + assert!(result.exposure_trace.is_empty()); } #[test] @@ -221,6 +294,14 @@ fn shared_transitive_dependencies_are_reported_once_and_keep_do_log() { ["leaf", "a", "b"] ); assert!(result.evaluations.iter().all(|r| r.exposure_eligible)); + assert_eq!( + result + .exposure_trace + .iter() + .map(|key| key.as_str()) + .collect::>(), + ["leaf", "a", "b"] + ); assert!(!result.evaluations[1].result.as_ref().unwrap().do_log); } @@ -303,6 +384,54 @@ fn depth_counts_edges_defaults_to_ten_and_preserves_explicit_zero() { } } +#[test] +fn depth_failures_record_the_terminal_dependency_and_ancestors_in_completion_order() { + let mut w = chain(1); + w["evaluatorParams"] = json!({"maxDependencyDepth": 0}); + let result = eval(w); + assert_eq!( + cause(result.result.as_ref().unwrap_err()), + &EvaluationError::DependencyDepthExceeded + ); + assert_eq!(result.evaluations.len(), 1); + assert_eq!(result.evaluations[0].flag_key.as_str(), "f1"); + assert_eq!( + result.evaluations[0].result.as_ref().unwrap_err(), + &EvaluationError::DependencyDepthExceeded + ); + + let mut w = chain(2); + w["evaluatorParams"] = json!({"maxDependencyDepth": 1}); + let result = eval(w); + assert_eq!( + cause(result.result.as_ref().unwrap_err()), + &EvaluationError::DependencyDepthExceeded + ); + assert_eq!( + result + .evaluations + .iter() + .map(|record| record.flag_key.as_str()) + .collect::>(), + ["f2", "f1"] + ); + assert_eq!( + result.evaluations[0].result.as_ref().unwrap_err(), + &EvaluationError::DependencyDepthExceeded + ); + assert!(matches!( + result.evaluations[1].result.as_ref().unwrap_err(), + EvaluationError::DependencyEvaluationFailed { flag_key, source } + if flag_key.as_str() == "f2" && **source == EvaluationError::DependencyDepthExceeded + )); + assert!( + result + .evaluations + .iter() + .all(|record| !record.exposure_eligible) + ); +} + #[test] fn rejects_invalid_depth_limits_and_preserves_wire_parameters() { for limit in [json!(-1), json!(256), json!(1.5), json!("10"), json!(true)] { diff --git a/libdd-ffe/src/rules_based/eval/eval_assignment.rs b/libdd-ffe/src/rules_based/eval/eval_assignment.rs index e131923d21..8b9e4fb42b 100644 --- a/libdd-ffe/src/rules_based/eval/eval_assignment.rs +++ b/libdd-ffe/src/rules_based/eval/eval_assignment.rs @@ -178,6 +178,7 @@ impl Configuration { return EvaluationResult { result: Err(error), evaluations: Vec::new(), + exposure_trace: Vec::new(), }; } }; @@ -188,12 +189,14 @@ impl Configuration { return EvaluationResult { result: flag.evaluate_independent(context, expected_type, now, exclusion), evaluations: Vec::new(), + exposure_trace: Vec::new(), }; } Err(error) => { return EvaluationResult { result: Err(error), evaluations: Vec::new(), + exposure_trace: Vec::new(), }; } Ok(_) => {} @@ -209,14 +212,19 @@ impl Configuration { evaluations: Vec::new(), }; let evaluated = session.evaluate_flag(flag_key, expected_type, 0); - if let Ok(root) = &evaluated { + let exposure_trace = if let Ok(root) = &evaluated { + let eligible: HashSet<_> = root.exposures.iter().collect(); for record in &mut session.evaluations { - record.exposure_eligible = root.exposures.contains(&record.flag_key); + record.exposure_eligible = eligible.contains(&record.flag_key); } - } + root.exposures.clone() + } else { + Vec::new() + }; EvaluationResult { result: evaluated.map(|result| result.assignment), evaluations: session.evaluations, + exposure_trace, } } } @@ -228,6 +236,12 @@ pub struct EvaluationResult { pub result: Result, /// Unique prerequisite evaluations, in completion order; excludes the root. pub evaluations: Vec, + /// Unique prerequisite keys on the successful root path, in depth-first authored order. + /// + /// This trace is empty when the root fails. SDKs can use it to replay memoized prerequisite + /// exposures in the position where the winning path referenced them while retaining + /// `evaluations` in completion order for evaluation telemetry. + pub exposure_trace: Vec, } /// Telemetry for a prerequisite reached within one root evaluation. @@ -248,7 +262,7 @@ struct EvaluatedFlag { // Includes all reached branches, even rejected rules, to keep memoization depth-safe. height: u16, // Only dependencies on the selected rule/split path, not every successful evaluation. - exposures: HashSet, + exposures: Vec, } struct EvaluationSession<'a> { @@ -348,7 +362,7 @@ impl EvaluationSession<'_> { } let mut selected_exposures = None; if allocation.rules.is_empty() { - selected_exposures = Some(HashSet::new()); + selected_exposures = Some(Vec::new()); } else { for rule in &allocation.rules { if let Some(exposures) = self.evaluate_rule(rule, depth, &mut height)? { @@ -377,8 +391,8 @@ impl EvaluationSession<'_> { rule: &RuleWire, depth: u16, height: &mut u16, - ) -> Result>, EvaluationError> { - let mut exposures = HashSet::new(); + ) -> Result>, EvaluationError> { + let mut exposures = Vec::new(); for condition in &rule.conditions { let matched = if let ConditionCheck::FlagEvaluation { key, @@ -398,8 +412,8 @@ impl EvaluationSession<'_> { .any(|value| value.as_ref() == dependency.assignment.variation_key.as_str()) == *expected_membership; if matched { - exposures.insert(key.clone()); - exposures.extend(dependency.exposures); + append_unique(&mut exposures, dependency.exposures.iter().cloned()); + append_unique(&mut exposures, std::iter::once(key.clone())); } matched } else { @@ -419,7 +433,18 @@ impl EvaluationSession<'_> { ) -> Result { let limit = u16::from(self.config.max_dependency_depth); if depth > limit { - return Err(EvaluationError::DependencyDepthExceeded); + let error = EvaluationError::DependencyDepthExceeded; + if key.as_str() != self.root_key + && !self.active.contains(key) + && !self.memo.contains_key(key) + { + self.evaluations.push(EvaluationRecord { + flag_key: key.clone(), + result: Err(error.clone()), + exposure_eligible: false, + }); + } + return Err(error); } if key.as_str() == self.root_key || self.active.contains(key) { return Err(EvaluationError::DependencyCycle); @@ -454,6 +479,14 @@ impl EvaluationSession<'_> { } } +fn append_unique(target: &mut Vec, values: impl IntoIterator) { + for value in values { + if !target.contains(&value) { + target.push(value); + } + } +} + impl Allocation { fn is_active(&self, now: Timestamp, exclusion: ExperimentationExclusion) -> bool { !(exclusion != ExperimentationExclusion::None && exclusion.excludes(self) diff --git a/libdd-ffe/src/rules_based/ufc/models.rs b/libdd-ffe/src/rules_based/ufc/models.rs index 765cdc7527..bd59a13482 100644 --- a/libdd-ffe/src/rules_based/ufc/models.rs +++ b/libdd-ffe/src/rules_based/ufc/models.rs @@ -278,6 +278,7 @@ pub(crate) struct ConditionWire { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub(crate) struct FlagEvaluationWire { pub key: Str, } @@ -362,12 +363,7 @@ impl TryFrom for Condition { fn try_from(condition: ConditionWire) -> Result { if let Some(reference) = condition.flag_evaluation { - if condition - .attribute - .as_ref() - .is_some_and(|attribute| !attribute.is_empty()) - || reference.key.is_empty() - { + if condition.attribute.is_some() || reference.key.is_empty() { return Err(EvaluationError::ConfigurationParseError); } let expected_membership = match condition.operator { diff --git a/tools/cc_utils/Cargo.toml b/tools/cc_utils/Cargo.toml index a8da49d302..b0b5a06b47 100644 --- a/tools/cc_utils/Cargo.toml +++ b/tools/cc_utils/Cargo.toml @@ -13,4 +13,3 @@ cc.workspace = true [lib] bench = false -