Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[submodule "tests/engine_tests/engine-test-data"]
path = tests/engine_tests/engine-test-data
url = https://github.com/flagsmith/engine-test-data.git
branch = v3.7.0
branch = v3.12.1
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ maintenance = { status = "actively-developed" }
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_json_path = "0.7"
jsonpath-rust = "1.0"
indexmap = { version = "2.14", features = ["serde"] }
chrono = { version = "0.4", features = ["serde"] }
md-5 = "0.10.1"
num-bigint = "0.4"
Expand Down
188 changes: 112 additions & 76 deletions src/engine.rs
Original file line number Diff line number Diff line change
@@ -1,40 +1,56 @@
use crate::engine_eval::context::{EngineEvaluationContext, FeatureContext};
use crate::engine_eval::result::{EvaluationResult, FlagResult, SegmentResult};
use crate::engine_eval::segment_evaluator::SegmentEvaluator;
use crate::engine_eval::segment_evaluator::{ResolvedFlags, SegmentEvaluator};
use crate::utils::hashing;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};

/// Holds a feature context with its associated segment name for priority comparison
struct FeatureContextWithSegment {
feature_context: FeatureContext,
segment_name: String,
/// Reason given to flags whose evaluation depends on themselves.
pub const CIRCULAR_DEPENDENCY_REASON: &str = "ERROR; code=CIRCULAR_DEPENDENCY";

/// A segment override, with the name of the segment it belongs to
pub(crate) struct SegmentOverride<'a> {
pub(crate) feature_context: &'a FeatureContext,
pub(crate) segment_name: &'a str,
}

/// Helper to get priority or default
fn get_priority_or_default(priority: Option<f64>) -> f64 {
priority.unwrap_or(f64::INFINITY) // Weakest possible priority
}

/// Whether a segment override takes precedence over the one held so far; the first seen wins a tie
pub(crate) fn wins_over(candidate: &FeatureContext, incumbent: Option<&FeatureContext>) -> bool {
incumbent.is_none_or(|incumbent| {
get_priority_or_default(candidate.priority) < get_priority_or_default(incumbent.priority)
})
}

/// Gets the reason for a flag evaluated to its environment default:
/// `DEFAULT` if segments override the feature, `STATIC` if none does.
pub(crate) fn get_fallback_reason(is_targeted: bool) -> String {
if is_targeted { "DEFAULT" } else { "STATIC" }.to_string()
}

/// Matching segments, the winning override of each feature they override, and every feature any segment overrides
struct SegmentEvaluation<'a> {
segments: Vec<SegmentResult>,
segment_overrides: HashMap<&'a str, SegmentOverride<'a>>,
targeted_feature_names: HashSet<&'a str>,
}

/// Gets matching segments and their overrides
fn get_matching_segments_and_overrides(
ec: &EngineEvaluationContext,
) -> (
Vec<SegmentResult>,
HashMap<String, FeatureContextWithSegment>,
) {
fn evaluate_segments<'a>(
ec: &'a EngineEvaluationContext,
evaluator: &SegmentEvaluator<'a>,
) -> SegmentEvaluation<'a> {
let mut segments = Vec::new();
let mut segment_feature_contexts: HashMap<String, FeatureContextWithSegment> = HashMap::new();
let evaluator = SegmentEvaluator::new(ec);

// Sort segment keys for deterministic ordering
let mut segment_keys: Vec<_> = ec.segments.keys().collect();
segment_keys.sort();
let mut segment_overrides: HashMap<&str, SegmentOverride> = HashMap::new();
let mut targeted_feature_names = HashSet::new();

// Process segments in sorted order
for segment_key in segment_keys {
let segment_context = &ec.segments[segment_key];
for (segment_key, segment_context) in &ec.segments {
targeted_feature_names.extend(segment_context.overrides.iter().map(|o| o.name.as_str()));

if !evaluator.is_context_in_segment(segment_context) {
if !evaluator.matches_segment_key(segment_key) {
continue;
}

Expand All @@ -46,39 +62,62 @@ fn get_matching_segments_and_overrides(

// Process segment overrides
for override_fc in &segment_context.overrides {
let feature_name = &override_fc.name;

// Check if we should update the segment feature context
let should_update = if let Some(existing) = segment_feature_contexts.get(feature_name) {
let existing_priority = get_priority_or_default(existing.feature_context.priority);
let override_priority = get_priority_or_default(override_fc.priority);
override_priority < existing_priority
} else {
true
};

if should_update {
segment_feature_contexts.insert(
feature_name.clone(),
FeatureContextWithSegment {
feature_context: override_fc.clone(),
segment_name: segment_context.name.clone(),
let incumbent = segment_overrides
.get(override_fc.name.as_str())
.map(|o| o.feature_context);
if wins_over(override_fc, incumbent) {
segment_overrides.insert(
&override_fc.name,
SegmentOverride {
feature_context: override_fc,
segment_name: &segment_context.name,
},
);
}
}
}

(segments, segment_feature_contexts)
SegmentEvaluation {
segments,
segment_overrides,
targeted_feature_names,
}
}

/// Gets flag results from feature contexts and segment overrides
fn get_flag_results(
ec: &EngineEvaluationContext,
segment_feature_contexts: &HashMap<String, FeatureContextWithSegment>,
segment_evaluation: &SegmentEvaluation,
identity_key: Option<&String>,
) -> HashMap<String, FlagResult> {
let mut flags = HashMap::new();

// Process all features
for feature_context in ec.features.values() {
let feature_name = feature_context.name.as_str();
let flag_result = match segment_evaluation.segment_overrides.get(feature_name) {
Some(segment_override) => get_flag_result_from_feature_context(
segment_override.feature_context,
identity_key,
format!("TARGETING_MATCH; segment={}", segment_override.segment_name),
),
None => get_flag_result_from_feature_context(
feature_context,
identity_key,
get_fallback_reason(
segment_evaluation
.targeted_feature_names
.contains(feature_name),
),
),
};
flags.insert(feature_context.name.clone(), flag_result);
}

flags
}

pub fn get_evaluation_result(ec: &EngineEvaluationContext) -> EvaluationResult {
// Get identity key if identity exists
// If identity key is not provided, construct it from environment key and identifier
let identity_key: Option<String> = ec.identity.as_ref().map(|i| {
Expand All @@ -89,54 +128,49 @@ fn get_flag_results(
}
});

// Process all features
for feature_context in ec.features.values() {
// Check if we have a segment override for this feature
if let Some(segment_fc) = segment_feature_contexts.get(&feature_context.name) {
// Use segment override with multivariate evaluation
let fc = &segment_fc.feature_context;
let reason = format!("TARGETING_MATCH; segment={}", segment_fc.segment_name);
let flag_result =
get_flag_result_from_feature_context(fc, identity_key.as_ref(), reason);
flags.insert(feature_context.name.clone(), flag_result);
} else {
// Use default feature context
let flag_result = get_flag_result_from_feature_context(
feature_context,
identity_key.as_ref(),
"DEFAULT".to_string(),
);
flags.insert(feature_context.name.clone(), flag_result);
let evaluator = SegmentEvaluator::with_flag_dependencies(ec, identity_key.as_ref());
let segment_evaluation = evaluate_segments(ec, &evaluator);
let mut flags = get_flag_results(ec, &segment_evaluation, identity_key.as_ref());

// Flags read by segment conditions take precedence, as only they were resolved guarding against cycles
let ResolvedFlags {
flags: resolved_flags,
cyclic,
} = evaluator.into_resolved_flags();
flags.extend(
resolved_flags
.into_iter()
.map(|(feature_name, flag)| (feature_name.to_string(), flag)),
);
for feature_name in cyclic {
if let Some(flag) = flags.get_mut(feature_name) {
flag.reason = CIRCULAR_DEPENDENCY_REASON.to_string();
}
}

flags
}

pub fn get_evaluation_result(ec: &EngineEvaluationContext) -> EvaluationResult {
// Process segments
let (segments, segment_feature_contexts) = get_matching_segments_and_overrides(ec);

// Get flag results
let flags = get_flag_results(ec, &segment_feature_contexts);

EvaluationResult { flags, segments }
EvaluationResult {
flags,
segments: segment_evaluation.segments,
}
}

/// Creates a FlagResult from a FeatureContext
fn get_flag_result_from_feature_context(
pub(crate) fn get_flag_result_from_feature_context(
feature_context: &FeatureContext,
identity_key: Option<&String>,
default_reason: String,
) -> FlagResult {
let mut reason = default_reason;
let mut value = feature_context.value.clone();
let mut variant = None;

// Handle multivariate features
if !feature_context.variants.is_empty()
&& identity_key.is_some()
&& !feature_context.key.is_empty()
{
variant = Some("control".to_string()); // Unless a variant is selected below

// Sort variants by priority (lower priority value = higher priority)
let mut sorted_variants = feature_context.variants.clone();
sorted_variants.sort_by(|a, b| {
Expand All @@ -151,11 +185,12 @@ fn get_flag_result_from_feature_context(

// Select variant based on weighted distribution
let mut cumulative_weight = 0.0;
for variant in &sorted_variants {
cumulative_weight += variant.weight;
for feature_value in &sorted_variants {
cumulative_weight += feature_value.weight;
if (hash_percentage as f64) <= cumulative_weight {
value = variant.value.clone();
reason = format!("SPLIT; weight={}", variant.weight);
value = feature_value.value.clone();
variant = feature_value.key.clone();
reason = format!("SPLIT; weight={}", feature_value.weight);
break;
}
}
Expand All @@ -165,6 +200,7 @@ fn get_flag_result_from_feature_context(
enabled: feature_context.enabled,
name: feature_context.name.clone(),
value,
variant,
reason,
metadata: feature_context.metadata.clone(),
}
Expand All @@ -189,7 +225,7 @@ mod tests {
name: "test".to_string(),
},
features: HashMap::new(),
segments: HashMap::new(),
segments: Default::default(),
identity: None,
};

Expand Down
8 changes: 6 additions & 2 deletions src/engine_eval/context.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::types::FlagsmithValue;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

Expand Down Expand Up @@ -29,6 +30,9 @@ fn default_feature_type() -> String {
/// Represents a multivariate value for a feature flag.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FeatureValue {
/// The key identifying this variant in flag results.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Data Integrity & Integration · Quick win

Supported by static analysis

Script executed:

rg -n 'FeatureValue\s*\{' src tests; sed -n '1,45p' Cargo.toml

Repository: Flagsmith/flagsmith-rust-flag-engine

Length of output: 1179


Script executed:

set -u
echo "== lib.rs / re-exports =="
rg -n -e 'pub use' -e 'pub mod' -e 'non_exhaustive' src/lib.rs src/engine_eval/mod.rs 2>/dev/null || true
echo "== FeatureValue references across src/tests =="
rg -n -F -e 'FeatureValue' src tests || test "$?" -eq 1
echo "== context.rs FeatureValue definition =="
nl -ba src/engine_eval/context.rs | sed -n '25,45p'
echo "== mappers.rs construction site =="
nl -ba src/engine_eval/mappers.rs | sed -n '85,100p'
echo "== CHANGELOG / semver / release docs =="
ls -a | rg -i -e 'changelog' -e 'contributing' -e 'readme' || true
rg -n -i -e 'semver' -e 'breaking' -e 'backward' -e 'compatib' README.md CONTRIBUTING.md CHANGELOG.md docs 2>/dev/null || true
echo "== version history of Cargo.toml version line =="
git log --oneline -n 15 -- Cargo.toml || true
git log -p -n 40 -- Cargo.toml | rg -n '^[-+]version' || true
echo "== non_exhaustive usage in repo =="
rg -n -F 'non_exhaustive' src tests || test "$?" -eq 1
echo "== base commit presence =="
git cat-file -t f083e820268c4611136b5cca4b2f9d5f0f73b6fe 2>&1 || true
git diff --stat f083e820268c4611136b5cca4b2f9d5f0f73b6fe 0552139222db80ea1ec16ec5a6a7e4ae5912bcc5 -- Cargo.toml src/engine_eval/context.rs 2>&1 || true

Repository: Flagsmith/flagsmith-rust-flag-engine

Length of output: 4334


Web query:

Rust Reference struct expressions all fields must be specified missing field error E0063 external crate non_exhaustive

Result:

Sources consulted:
- Type system - The Rust Reference — https://doc.rust-lang.org/reference/attributes/type_system.html
- Struct expressions - The Rust Reference — https://doc.rust-lang.org/reference/expressions/struct-expr.html
- E0063 - Error codes index — https://doc.rust-lang.org/error_codes/E0063.html
- codes.rs - source — https://doc.rust-lang.org/stable/nightly-rustc/src/rustc_errors/codes.rs.html
- expr.rs - source — https://doc.rust-lang.org/stable/nightly-rustc/src/rustc_hir_typeck/expr.rs.html
- …and 7 more

Mark the FeatureValue.key addition as a breaking change, or keep the struct closed to external literals.

FeatureValue is a public struct with all fields public and no #[non_exhaustive]. Downstream crates that build it with a struct literal now fail to compile, because the new key field is not supplied. Serde's default does not apply to Rust struct literals. The in-repo literal in src/engine_eval/mappers.rs already sets key: None, so the crate itself builds. Release tooling derives versions from commit messages (CHANGELOG.md shows release-please entries), so a non-breaking feat: commit can ship this break in a compatible version. Mark the change as breaking (feat!: or a BREAKING CHANGE: footer) so the release is versioned as breaking. If source compatibility must be kept, do not add a public field to this struct.

No safe localized edit is established, so this comment has no diff.

/// The value of the feature.
pub value: FlagsmithValue,
/// The weight of the feature value variant, as a percentage number (i.e. 100.0).
Expand Down Expand Up @@ -257,9 +261,9 @@ pub struct EngineEvaluationContext {
#[serde(default)]
pub features: HashMap<String, FeatureContext>,

/// Segment contexts indexed by segment key.
/// Segment contexts indexed by segment key, in evaluation order.
#[serde(default)]
pub segments: HashMap<String, SegmentContext>,
pub segments: IndexMap<String, SegmentContext>,

/// Optional identity context for evaluation.
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down
37 changes: 36 additions & 1 deletion src/engine_eval/mappers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::environments::Environment;
use crate::features::{FeatureState, MultivariateFeatureStateValue};
use crate::identities::{Identity, Trait};
use crate::segments::{Segment, SegmentRule as OldSegmentRule};
use indexmap::IndexMap;
use sha2::{Digest, Sha256};
use std::collections::HashMap;

Expand All @@ -24,7 +25,7 @@ pub fn environment_to_context(environment: Environment) -> EngineEvaluationConte
name: environment.name.clone(),
},
features: HashMap::new(),
segments: HashMap::new(),
segments: IndexMap::new(),
identity: None,
};

Expand Down Expand Up @@ -91,6 +92,7 @@ fn map_multivariate_values_to_variants(
mv_values
.iter()
.map(|mv| FeatureValue {
key: None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

Supported by static analysis

Script executed:

sed -n '45,110p' src/engine_eval/mappers.rs
sed -n '140,200p' src/engine_eval/segment_evaluator.rs
sed -n '155,210p' src/engine.rs
rg -n 'variant|MultivariateFeatureOption' src tests README.md

Repository: Flagsmith/flagsmith-rust-flag-engine

Length of output: 9959


Populate the selected mapped variant key.

When a mapped split selects an option, src/engine_eval/mappers.rs sets FeatureValue.key to None, and src/engine.rs copies that value to FlagResult.variant. The result therefore reports variant: null, although FlagResult.variant documents None as meaning that no split applies.

The repository does not establish that decimal option IDs are the required public variant keys. Define the variant-key contract, then map the supported identifier to FeatureValue.key. Do not assume id.to_string() without that contract.

value: mv.multivariate_feature_option.value.clone(),
weight: mv.percentage_allocation as f64,
priority: None,
Expand Down Expand Up @@ -340,3 +342,36 @@ pub fn add_identity_to_context(
new_context.identity = Some(identity);
new_context
}

/// Gets the context to evaluate environment flags against
///
/// # Arguments
/// * `context` - The context to evaluate environment flags for
///
/// # Returns
/// A new context without identity, keeping only segments whose every condition reads the environment or other flags
pub fn remove_identity_from_context(context: &EngineEvaluationContext) -> EngineEvaluationContext {
EngineEvaluationContext {
environment: context.environment.clone(),
features: context.features.clone(),
segments: context
.segments
.iter()
.filter(|(_, segment)| is_identity_free(&segment.rules))
.map(|(key, segment)| (key.clone(), segment.clone()))
.collect(),
identity: None,
}
}

fn is_identity_free(rules: &[SegmentRule]) -> bool {
const IDENTITY_FREE_PROPERTY_PREFIXES: [&str; 4] =
["$.environment.", "$.environment[", "$.flags.", "$.flags["];
rules.iter().all(|rule| {
rule.conditions.iter().all(|condition| {
IDENTITY_FREE_PROPERTY_PREFIXES
.iter()
.any(|prefix| condition.property.starts_with(prefix))
}) && is_identity_free(&rule.rules)
})
}
2 changes: 1 addition & 1 deletion src/engine_eval/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ pub mod mappers;

// Re-export commonly used types for convenience
pub use context::{EngineEvaluationContext, FeatureContext, FeatureMetadata, SegmentSource};
pub use mappers::{add_identity_to_context, environment_to_context};
pub use mappers::{add_identity_to_context, environment_to_context, remove_identity_from_context};
pub use result::{EvaluationResult, FlagResult, SegmentResult};
pub use segment_evaluator::is_context_in_segment;
Loading
Loading