From 326fa3ebd0893788ab55eed483e45f45e9156396 Mon Sep 17 00:00:00 2001 From: Christopher Tarquini Date: Fri, 17 Apr 2026 12:14:26 -0400 Subject: [PATCH] feat: expose prerequisite and segment reference accessors Adds three public methods to let external consumers (notably the server SDK's flag-change subscription feature) walk a flag's or segment's dependencies without access to the private `Clause`, `Op`, or rule types: - `Flag::prerequisite_keys()` returns an iterator over the keys of flags listed as prerequisites. - `Flag::segment_keys_referenced()` returns every segment key named in any `segmentMatch` clause across the flag's rules. - `Segment::segment_keys_referenced()` does the same for nested segment references in a segment's rules. These are the minimum additions needed to build a flag/segment dependency graph in the server SDK; they do not expose any previously private types. --- src/flag.rs | 148 +++++++++++++++++++++++++++++++++++++++++++++++++ src/rule.rs | 13 +++++ src/segment.rs | 67 ++++++++++++++++++++++ 3 files changed, 228 insertions(+) diff --git a/src/flag.rs b/src/flag.rs index a893a08..4082b1f 100644 --- a/src/flag.rs +++ b/src/flag.rs @@ -317,6 +317,21 @@ impl Flag { .using_mobile_key } + /// Returns an iterator over the keys of flags this flag lists as + /// prerequisites. + pub fn prerequisite_keys(&self) -> impl Iterator + '_ { + self.prerequisites.iter().map(|p| p.key.as_str()) + } + + /// Returns an iterator over every segment key directly referenced by any + /// `segmentMatch` clause in this flag's rules. + /// + /// This does not resolve transitively through other flags (prerequisites) + /// or through segments that themselves reference segments. + pub fn segment_keys(&self) -> impl Iterator + '_ { + self.rules.iter().flat_map(FlagRule::segment_keys) + } + pub(crate) fn resolve_variation_or_rollout( &self, vr: &VariationOrRollout, @@ -693,4 +708,137 @@ mod tests { assert!(with_specific_ratio.contains("\"migration\": {")); assert!(with_specific_ratio.contains("\"checkRatio\": 42")); } + + #[test] + fn prerequisite_keys_returns_all_listed_prerequisites() { + let json = r#"{ + "key": "flag", + "version": 1, + "on": true, + "targets": [], + "rules": [], + "prerequisites": [ + {"key": "prereq-a", "variation": 0}, + {"key": "prereq-b", "variation": 1} + ], + "fallthrough": {"variation": 0}, + "offVariation": null, + "variations": [false, true], + "clientSide": false, + "salt": "salty" + }"#; + + let flag: Flag = serde_json::from_str(json).unwrap(); + let keys: Vec<&str> = flag.prerequisite_keys().collect(); + assert_eq!(keys, vec!["prereq-a", "prereq-b"]); + } + + #[test] + fn prerequisite_keys_is_empty_when_flag_has_no_prerequisites() { + let json = r#"{ + "key": "flag", + "version": 1, + "on": true, + "targets": [], + "rules": [], + "prerequisites": [], + "fallthrough": {"variation": 0}, + "offVariation": null, + "variations": [false, true], + "clientSide": false, + "salt": "salty" + }"#; + + let flag: Flag = serde_json::from_str(json).unwrap(); + assert_eq!(flag.prerequisite_keys().count(), 0); + } + + #[test] + fn segment_keys_collects_from_segment_match_clauses() { + let json = r#"{ + "key": "flag", + "version": 1, + "on": true, + "targets": [], + "rules": [ + { + "id": "r1", + "clauses": [ + { + "attribute": "", + "op": "segmentMatch", + "values": ["seg-a", "seg-b"], + "negate": false + } + ], + "variation": 1, + "trackEvents": false + }, + { + "id": "r2", + "clauses": [ + { + "attribute": "email", + "op": "in", + "values": ["foo@example.com"], + "negate": false + }, + { + "attribute": "", + "op": "segmentMatch", + "values": ["seg-c"], + "negate": false + } + ], + "variation": 1, + "trackEvents": false + } + ], + "prerequisites": [], + "fallthrough": {"variation": 0}, + "offVariation": null, + "variations": [false, true], + "clientSide": false, + "salt": "salty" + }"#; + + let flag: Flag = serde_json::from_str(json).unwrap(); + let mut refs: Vec<&str> = flag.segment_keys().collect(); + refs.sort(); + assert_eq!(refs, vec!["seg-a", "seg-b", "seg-c"]); + } + + #[test] + fn segment_keys_ignores_non_segment_match_clauses() { + let json = r#"{ + "key": "flag", + "version": 1, + "on": true, + "targets": [], + "rules": [ + { + "id": "r1", + "clauses": [ + { + "attribute": "email", + "op": "in", + "values": ["seg-a"], + "negate": false + } + ], + "variation": 1, + "trackEvents": false + } + ], + "prerequisites": [], + "fallthrough": {"variation": 0}, + "offVariation": null, + "variations": [false, true], + "clientSide": false, + "salt": "salty" + }"#; + + let flag: Flag = serde_json::from_str(json).unwrap(); + assert_eq!(flag.segment_keys().count(), 0); + } } diff --git a/src/rule.rs b/src/rule.rs index a89f718..e1384b2 100644 --- a/src/rule.rs +++ b/src/rule.rs @@ -286,6 +286,13 @@ impl Clause { context_kind: Kind::default(), } } + + /// Returns an iterator over the segment keys referenced by this clause, + /// or an empty iterator if the clause is not a `segmentMatch`. + pub(crate) fn segment_keys(&self) -> impl Iterator + '_ { + let values = matches!(self.op, Op::SegmentMatch).then_some(&self.values); + values.into_iter().flatten().filter_map(|v| v.as_str()) + } } impl FlagRule { @@ -309,6 +316,12 @@ impl FlagRule { Ok(true) } + /// Returns an iterator over every segment key referenced by any + /// `segmentMatch` clause in this rule. + pub(crate) fn segment_keys(&self) -> impl Iterator + '_ { + self.clauses.iter().flat_map(Clause::segment_keys) + } + #[cfg(test)] pub(crate) fn new_segment_match(segment_keys: Vec<&str>, kind: Kind) -> Self { Self { diff --git a/src/segment.rs b/src/segment.rs index 942aef7..811413b 100644 --- a/src/segment.rs +++ b/src/segment.rs @@ -208,9 +208,24 @@ impl Segment { Some(generation) => format!("{}.g{}", self.key, generation), } } + + /// Returns an iterator over every segment key directly referenced by any + /// `segmentMatch` clause in this segment's rules. + /// + /// Used to build a dependency graph of segment-to-segment references; + /// does not resolve transitively. + pub fn segment_keys(&self) -> impl Iterator + '_ { + self.rules.iter().flat_map(SegmentRule::segment_keys) + } } impl SegmentRule { + /// Returns an iterator over every segment key referenced by any + /// `segmentMatch` clause in this rule. + pub(crate) fn segment_keys(&self) -> impl Iterator + '_ { + self.clauses.iter().flat_map(Clause::segment_keys) + } + /// Determines if a context matches the provided segment rule. /// /// A context will match if all segment clauses match; otherwise, this method returns false. @@ -774,4 +789,56 @@ mod tests { let segment = new_segment(); assert_eq!(segment.unbounded_context_kind, None); } + + #[test] + fn segment_keys_collects_from_segment_match_clauses() { + let json = r#"{ + "key": "seg", + "included": [], + "excluded": [], + "rules": [ + { + "id": "r1", + "clauses": [ + { + "attribute": "", + "op": "segmentMatch", + "values": ["seg-a", "seg-b"], + "negate": false + } + ] + }, + { + "id": "r2", + "clauses": [ + { + "attribute": "email", + "op": "in", + "values": ["foo@example.com"], + "negate": false + }, + { + "attribute": "", + "op": "segmentMatch", + "values": ["seg-c"], + "negate": false + } + ] + } + ], + "salt": "salty", + "version": 1 + }"#; + + let segment: Segment = serde_json::from_str(json).unwrap(); + let mut refs: Vec<&str> = segment.segment_keys().collect(); + refs.sort(); + assert_eq!(refs, vec!["seg-a", "seg-b", "seg-c"]); + } + + #[test] + fn segment_keys_is_empty_for_plain_segment() { + let segment = new_segment(); + assert_eq!(segment.segment_keys().count(), 0); + } }