Rust is the language where "if it compiles, it works" is not just an aspiration but a reasonable expectation. The borrow checker prevents data races. The type system prevents null pointer exceptions. The exhaustive match requirement prevents unhandled cases. Yet feature flags -- by injecting runtime branching into a language built for compile-time guarantees -- undermine every one of these safety properties. A stale flag in Rust does not crash your program. It does something worse: it makes your program carry dead code that the compiler accepts, the borrow checker validates, and the test suite exercises, all for logic that will never execute in production.
Based on what we have seen across Rust codebases, stale feature flags tend to accumulate at a lower rate than in Java or Python -- partly because Rust teams tend to be smaller and partly because Rust's compile times create a natural incentive to keep codebases lean. But each stale flag in Rust is more expensive than in dynamic languages, because Rust's strict compilation means every dead code path was carefully crafted to satisfy the borrow checker, implement the right traits, and handle every Result -- effort that was wasted the moment the flag became permanent.
Two paradigms: Compile-time vs. runtime flags
Rust occupies a unique position in the feature flag landscape. Unlike most languages where all flags are runtime constructs, Rust offers genuine compile-time feature flags through Cargo's feature system. Understanding when to use each paradigm is the first decision every Rust team must make.
Compile-time flags with Cargo features
Cargo features are Rust's native mechanism for conditional compilation. They are evaluated at build time, and dead code from disabled features is eliminated entirely by the compiler:
// Cargo.toml
[features]
default = ["json-output"]
json-output = ["dep:serde_json"]
prometheus-metrics = ["dep:prometheus"]
experimental-allocator = []
// src/output.rs
pub fn format_response(data: &ResponseData) -> Vec<u8> {
#[cfg(feature = "json-output")]
{
serde_json::to_vec(data).expect("serialization should not fail")
}
#[cfg(not(feature = "json-output"))]
{
data.to_binary()
}
}
#[cfg(feature = "prometheus-metrics")]
pub fn register_metrics(registry: &prometheus::Registry) {
registry
.register(Box::new(REQUEST_COUNTER.clone()))
.expect("metric registration should not fail");
registry
.register(Box::new(RESPONSE_HISTOGRAM.clone()))
.expect("metric registration should not fail");
}
When to use compile-time flags:
- Optional dependencies (e.g., different serialization formats)
- Platform-specific code (
#[cfg(target_os = "linux")]) - Performance-sensitive paths where branch elimination matters
- Library crate features that downstream consumers select
- Experimental functionality that should not ship in release builds
When NOT to use compile-time flags:
- A/B testing or gradual rollouts (requires runtime evaluation)
- Per-user or per-tenant targeting
- Flags that need to change without redeploying
- Kill switches for production incidents
Runtime flags with SDK clients
For dynamic evaluation, Rust applications use runtime flag SDKs just like any other language:
use std::sync::Arc;
pub struct FeatureFlagClient {
inner: Arc<dyn FlagEvaluator>,
}
pub trait FlagEvaluator: Send + Sync {
fn bool_variation(&self, key: &str, context: &EvalContext, default: bool) -> bool;
fn string_variation(&self, key: &str, context: &EvalContext, default: &str) -> String;
}
impl FeatureFlagClient {
pub fn is_enabled(&self, key: &str, context: &EvalContext) -> bool {
self.inner.bool_variation(key, context, false)
}
}
The tension between Rust's compile-time safety and runtime flags is where most flag debt originates. Compile-time flags are eliminated by the compiler. Runtime flags persist forever unless actively removed.
Implementing runtime flags in Rust
Approach 1: Unleash SDK
The Unleash Rust SDK provides async-first flag evaluation that fits well with Tokio-based services:
use unleash_api_client::client::ClientBuilder;
use unleash_api_client::config::EnvironmentConfig;
pub async fn create_unleash_client() -> Result<unleash_api_client::Client, Box<dyn std::error::Error>> {
let config = EnvironmentConfig {
app_name: "my-rust-service".into(),
instance_id: hostname::get()?.to_string_lossy().into(),
..Default::default()
};
let client = ClientBuilder::default()
.interval(10_000) // Poll every 10 seconds
.into_client(
&std::env::var("UNLEASH_API_URL")?,
&std::env::var("UNLEASH_API_KEY")?,
None,
config,
)?;
client.register().await?;
Ok(client)
}
use unleash_api_client::client::Client as UnleashClient;
pub struct NotificationService {
unleash: Arc<UnleashClient>,
push_sender: PushNotificationSender,
email_sender: EmailSender,
}
impl NotificationService {
pub async fn notify_user(
&self,
user: &User,
notification: &Notification,
) -> Result<(), NotifyError> {
let context = unleash_api_client::context::Context {
user_id: Some(user.id.to_string()),
properties: [("tier".into(), user.tier.to_string())]
.into_iter()
.collect(),
..Default::default()
};
if self.unleash.is_enabled("push-notifications", Some(&context), false) {
self.push_sender.send(user, notification).await
} else {
self.email_sender.send(user, notification).await
}
}
}
Approach 2: LaunchDarkly SDK
LaunchDarkly's Rust SDK (server-side) integrates with Rust's ownership model:
use launchdarkly_server_sdk::{Client, ConfigBuilder, Context, ContextBuilder};
pub fn create_ld_client(sdk_key: &str) -> Result<Client, Box<dyn std::error::Error>> {
let config = ConfigBuilder::new(sdk_key)
.build()?;
let client = Client::build(config)?;
client.start_with_default_executor();
Ok(client)
}
pub struct PaymentService {
ld_client: Arc<Client>,
stripe: StripeGateway,
legacy: LegacyPaymentGateway,
}
impl PaymentService {
pub fn process_payment(
&self,
user: &User,
amount: Money,
) -> Result<PaymentReceipt, PaymentError> {
let context = ContextBuilder::new(user.id.to_string())
.set_value("plan", user.plan.as_str().into())
.set_value("country", user.country.as_str().into())
.build()
.map_err(|e| PaymentError::Internal(e.to_string()))?;
let use_stripe = self.ld_client.bool_variation(
&context, "stripe-gateway", false);
if use_stripe {
self.stripe.charge(user, amount)
} else {
self.legacy.charge(user, amount)
}
}
}
Approach 3: Custom implementation with enums
Rust's type system enables a pattern that is impossible in most languages: flag-aware enums that force exhaustive handling at compile time.
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
pub struct FlagConfig {
pub flags: HashMap<String, FlagValue>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum FlagValue {
Bool(bool),
String(String),
Percentage(f64),
}
impl FlagConfig {
pub fn load_from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(path)?;
let config: FlagConfig = toml::from_str(&contents)?;
Ok(config)
}
pub fn is_enabled(&self, key: &str) -> bool {
matches!(self.flags.get(key), Some(FlagValue::Bool(true)))
}
}
But the real power comes from encoding flag states as enums:
/// Represents the current state of the search engine migration.
/// Remove this enum and all references when migration is complete.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchBackend {
/// Legacy Tantivy-based search. Remove after 2026-03-01.
Tantivy,
/// New Meilisearch-based search. Will become the only option.
Meilisearch,
}
impl SearchBackend {
pub fn from_flags(flags: &FlagConfig) -> Self {
if flags.is_enabled("meilisearch-backend") {
SearchBackend::Meilisearch
} else {
SearchBackend::Tantivy
}
}
}
pub struct SearchService {
backend: SearchBackend,
tantivy: Option<TantivyClient>,
meilisearch: Option<MeilisearchClient>,
}
impl SearchService {
pub fn search(&self, query: &str) -> Result<SearchResults, SearchError> {
match self.backend {
SearchBackend::Tantivy => {
let client = self.tantivy.as_ref()
.ok_or(SearchError::BackendNotConfigured("tantivy"))?;
client.search(query)
}
SearchBackend::Meilisearch => {
let client = self.meilisearch.as_ref()
.ok_or(SearchError::BackendNotConfigured("meilisearch"))?;
client.search(query)
}
}
}
}
This pattern is valuable because Rust's exhaustive match requirement means adding or removing a variant from SearchBackend will trigger compile errors at every match expression. When the flag is cleaned up, you remove the Tantivy variant, and the compiler guides you to every location that needs updating.
Rust-specific patterns for flag safety
Pattern 1: Type-state flags
Rust's type system can encode flag state at compile time, preventing invalid combinations:
/// Marker types for feature states
pub struct Enabled;
pub struct Disabled;
pub struct FeatureGate<S> {
_state: std::marker::PhantomData<S>,
}
impl FeatureGate<Enabled> {
pub fn execute<F, T>(&self, f: F) -> T
where
F: FnOnce() -> T,
{
f()
}
}
impl FeatureGate<Disabled> {
pub fn fallback<F, T>(&self, f: F) -> T
where
F: FnOnce() -> T,
{
f()
}
}
// At the composition root, runtime evaluation determines the type:
pub enum DynamicGate {
Active(FeatureGate<Enabled>),
Inactive(FeatureGate<Disabled>),
}
impl DynamicGate {
pub fn from_flag(enabled: bool) -> Self {
if enabled {
DynamicGate::Active(FeatureGate {
_state: std::marker::PhantomData,
})
} else {
DynamicGate::Inactive(FeatureGate {
_state: std::marker::PhantomData,
})
}
}
}
This pattern constrains what operations are available based on the flag state. Code that requires an Enabled gate cannot accidentally receive a Disabled gate. When the flag is permanently enabled, you replace DynamicGate with FeatureGate<Enabled> and the compiler eliminates every disabled code path.
Pattern 2: Trait-based strategy selection
Rust's trait system provides a clean abstraction for flag-controlled behavior:
pub trait CacheBackend: Send + Sync {
fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError>;
fn set(&self, key: &str, value: &[u8], ttl: Duration) -> Result<(), CacheError>;
fn delete(&self, key: &str) -> Result<(), CacheError>;
}
pub struct RedisCache { /* ... */ }
pub struct MemcachedCache { /* ... */ }
impl CacheBackend for RedisCache {
fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
self.connection.get(key).map_err(CacheError::Redis)
}
fn set(&self, key: &str, value: &[u8], ttl: Duration) -> Result<(), CacheError> {
self.connection
.set_ex(key, value, ttl.as_secs() as usize)
.map_err(CacheError::Redis)
}
fn delete(&self, key: &str) -> Result<(), CacheError> {
self.connection.del(key).map_err(CacheError::Redis)
}
}
impl CacheBackend for MemcachedCache {
fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
self.client.get(key).map_err(CacheError::Memcached)
}
fn set(&self, key: &str, value: &[u8], ttl: Duration) -> Result<(), CacheError> {
self.client
.set(key, value, ttl.as_secs() as u32)
.map_err(CacheError::Memcached)
}
fn delete(&self, key: &str) -> Result<(), CacheError> {
self.client.delete(key).map_err(CacheError::Memcached)
}
}
// Flag-controlled factory
pub fn create_cache(flags: &FlagConfig) -> Box<dyn CacheBackend> {
if flags.is_enabled("redis-cache") {
Box::new(RedisCache::new(&std::env::var("REDIS_URL").unwrap()))
} else {
Box::new(MemcachedCache::new(&std::env::var("MEMCACHED_URL").unwrap()))
}
}
Cleanup advantage: When the flag is removed, replace the factory with direct construction and consider whether the trait is still needed. If only RedisCache remains, the trait abstraction can be removed entirely, and all consumers can use RedisCache directly -- which also enables the compiler to monomorphize calls, improving performance.
Pattern 3: cfg attributes for permanent decisions
For flags that represent permanent architectural decisions (not gradual rollouts), Rust's cfg attributes eliminate dead code at compile time:
#[cfg(feature = "v2-protocol")]
mod protocol_v2;
#[cfg(not(feature = "v2-protocol"))]
mod protocol_v1;
#[cfg(feature = "v2-protocol")]
pub use protocol_v2::Protocol;
#[cfg(not(feature = "v2-protocol"))]
pub use protocol_v1::Protocol;
// Consumer code is identical regardless of feature:
pub fn handle_message(proto: &Protocol, msg: &[u8]) -> Result<Response, ProtoError> {
proto.decode(msg).and_then(|decoded| proto.process(decoded))
}
When the migration is complete, remove the feature from Cargo.toml, delete the protocol_v1 module, remove all #[cfg] attributes, and replace the conditional pub use with a direct pub use protocol_v2::Protocol. The compiler will refuse to build if any code still references protocol_v1.
Pattern 4: Newtype wrappers for flag keys
Prevent typos and enforce registration at compile time:
/// All valid flag keys in the system. Adding or removing a variant
/// requires updating every match expression that handles it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FlagKey {
RedisCache,
MeilisearchBackend,
AsyncProcessing,
PushNotifications,
StripeGateway,
}
impl FlagKey {
pub fn as_str(&self) -> &'static str {
match self {
FlagKey::RedisCache => "redis-cache",
FlagKey::MeilisearchBackend => "meilisearch-backend",
FlagKey::AsyncProcessing => "async-processing",
FlagKey::PushNotifications => "push-notifications",
FlagKey::StripeGateway => "stripe-gateway",
}
}
}
pub struct TypedFlagClient {
inner: Arc<dyn FlagEvaluator>,
}
impl TypedFlagClient {
pub fn is_enabled(&self, key: FlagKey, context: &EvalContext) -> bool {
self.inner.bool_variation(key.as_str(), context, false)
}
}
When you remove a flag, you remove the variant from FlagKey. The compiler then errors at every call site that referenced it. This is the Rust equivalent of Java's constants class, but enforced by the type system rather than convention.
Testing flags with cargo test
Testing both flag branches
#[cfg(test)]
mod tests {
use super::*;
fn make_flags(enabled: &[(&str, bool)]) -> FlagConfig {
let flags = enabled
.iter()
.map(|(k, v)| (k.to_string(), FlagValue::Bool(*v)))
.collect();
FlagConfig { flags }
}
#[test]
fn search_uses_meilisearch_when_flag_enabled() {
let flags = make_flags(&[("meilisearch-backend", true)]);
let backend = SearchBackend::from_flags(&flags);
assert_eq!(backend, SearchBackend::Meilisearch);
}
#[test]
fn search_uses_tantivy_when_flag_disabled() {
let flags = make_flags(&[("meilisearch-backend", false)]);
let backend = SearchBackend::from_flags(&flags);
assert_eq!(backend, SearchBackend::Tantivy);
}
#[test]
fn search_defaults_to_tantivy_when_flag_missing() {
let flags = make_flags(&[]);
let backend = SearchBackend::from_flags(&flags);
assert_eq!(backend, SearchBackend::Tantivy);
}
}
Testing with mock trait implementations
Rust's trait system makes it straightforward to test flag-controlled behavior without a real flag service:
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
struct MockFlagEvaluator {
flags: Mutex<HashMap<String, bool>>,
}
impl MockFlagEvaluator {
fn new(flags: HashMap<String, bool>) -> Self {
Self {
flags: Mutex::new(flags),
}
}
}
impl FlagEvaluator for MockFlagEvaluator {
fn bool_variation(
&self,
key: &str,
_context: &EvalContext,
default: bool,
) -> bool {
self.flags
.lock()
.unwrap()
.get(key)
.copied()
.unwrap_or(default)
}
fn string_variation(
&self,
key: &str,
_context: &EvalContext,
default: &str,
) -> String {
default.to_string()
}
}
#[test]
fn payment_routes_to_stripe_when_flag_enabled() {
let evaluator = MockFlagEvaluator::new(
HashMap::from([("stripe-gateway".into(), true)]),
);
let client = FeatureFlagClient {
inner: Arc::new(evaluator),
};
let context = EvalContext {
user_id: "user-123".into(),
properties: HashMap::new(),
};
assert!(client.is_enabled("stripe-gateway", &context));
}
}
Testing Cargo features
For compile-time flags, test both configurations in CI:
# .github/workflows/rust-tests.yml
name: Rust Tests
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
features:
- "" # default features only
- "--all-features"
- "--no-default-features"
- "--features json-output"
- "--features prometheus-metrics"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Run tests
run: cargo test ${{ matrix.features }}
- name: Check compilation
run: cargo check ${{ matrix.features }}
This matrix ensures every feature combination compiles and passes tests. When a feature is removed from Cargo.toml, the matrix entry that references it will fail, serving as a reminder to update CI.
Characterization tests before cleanup
Before removing a flag, write tests that capture the winning behavior independently of the flag:
#[cfg(test)]
mod characterization_tests {
//! These tests capture the behavior of the "winning" path (meilisearch)
//! that will remain after the flag is removed. They should pass both
//! before and after cleanup.
use super::*;
#[test]
fn search_returns_ranked_results() {
let service = SearchService::with_meilisearch(test_meilisearch_client());
let results = service.search("rust async").unwrap();
assert!(!results.items.is_empty());
assert!(results.items.windows(2).all(|w| w[0].score >= w[1].score));
}
#[test]
fn search_handles_empty_query() {
let service = SearchService::with_meilisearch(test_meilisearch_client());
let results = service.search("").unwrap();
assert!(results.items.is_empty());
}
#[test]
fn search_respects_pagination() {
let service = SearchService::with_meilisearch(test_meilisearch_client());
let page1 = service.search_paged("rust", 0, 10).unwrap();
let page2 = service.search_paged("rust", 10, 10).unwrap();
assert_ne!(page1.items, page2.items);
}
}
Cleanup patterns for Rust
Cleanup 1: Removing a runtime flag
Before:
pub fn process_request(
flags: &FeatureFlagClient,
ctx: &EvalContext,
request: &Request,
) -> Result<Response, ProcessError> {
let parsed = parse_request(request)?;
if flags.is_enabled("async-processing", ctx) {
process_async(parsed).await
} else {
process_sync(parsed)
}
}
After (async processing permanently enabled):
pub async fn process_request(
request: &Request,
) -> Result<Response, ProcessError> {
let parsed = parse_request(request)?;
process_async(parsed).await
}
Notice the changes beyond removing the if:
- The
flagsandctxparameters are removed from the function signature. - The function becomes
asyncbecause only the async path remains. - The
process_syncfunction and everything it depends on can be deleted.
Every caller of process_request must be updated for the new signature and the async addition. The compiler will catch all of them.
Cleanup 2: Removing an enum variant
Before:
pub enum SearchBackend {
Tantivy,
Meilisearch,
}
impl SearchService {
pub fn search(&self, query: &str) -> Result<SearchResults, SearchError> {
match self.backend {
SearchBackend::Tantivy => self.tantivy_search(query),
SearchBackend::Meilisearch => self.meilisearch_search(query),
}
}
}
After (Meilisearch permanently enabled):
impl SearchService {
pub fn search(&self, query: &str) -> Result<SearchResults, SearchError> {
self.meilisearch_search(query)
}
}
The SearchBackend enum can be removed entirely. The tantivy_search method and the tantivy field on SearchService can be deleted. The tantivy crate dependency can be removed from Cargo.toml, which may significantly reduce compile times and binary size.
Cleanup 3: Removing a Cargo feature
Before (Cargo.toml):
[features]
default = ["json-output"]
json-output = ["dep:serde_json"]
msgpack-output = ["dep:rmp-serde"] # Never used in production
Before (src/output.rs):
pub fn serialize<T: Serialize>(data: &T) -> Result<Vec<u8>, SerializeError> {
#[cfg(feature = "json-output")]
{
serde_json::to_vec(data).map_err(SerializeError::Json)
}
#[cfg(feature = "msgpack-output")]
{
rmp_serde::to_vec(data).map_err(SerializeError::Msgpack)
}
#[cfg(not(any(feature = "json-output", feature = "msgpack-output")))]
{
compile_error!("At least one output format must be enabled")
}
}
After (msgpack feature removed):
[dependencies]
serde_json = "1"
pub fn serialize<T: Serialize>(data: &T) -> Result<Vec<u8>, SerializeError> {
serde_json::to_vec(data).map_err(SerializeError::Json)
}
The json-output feature is no longer needed because JSON is the only format. The dependency moves from optional (dep:serde_json) to required. All #[cfg(feature = "json-output")] attributes are removed. The SerializeError::Msgpack variant is removed from the error enum, and the compiler will flag any match arms that reference it.
Cleanup 4: Removing trait abstraction after flag removal
When a flag controlled which implementation of a trait was used, and only one implementation remains, the trait itself may no longer serve a purpose:
Before:
pub trait Compressor: Send + Sync {
fn compress(&self, data: &[u8]) -> Result<Vec<u8>, CompressError>;
fn decompress(&self, data: &[u8]) -> Result<Vec<u8>, CompressError>;
}
pub struct ZstdCompressor { level: i32 }
pub struct GzipCompressor { level: u32 }
impl Compressor for ZstdCompressor { /* ... */ }
impl Compressor for GzipCompressor { /* ... */ }
// Flag-controlled factory
pub fn create_compressor(flags: &FlagConfig) -> Box<dyn Compressor> {
if flags.is_enabled("zstd-compression") {
Box::new(ZstdCompressor { level: 3 })
} else {
Box::new(GzipCompressor { level: 6 })
}
}
After (zstd permanently enabled):
pub struct Compressor {
level: i32,
}
impl Compressor {
pub fn new() -> Self {
Self { level: 3 }
}
pub fn compress(&self, data: &[u8]) -> Result<Vec<u8>, CompressError> {
zstd::encode_all(data, self.level).map_err(CompressError::Zstd)
}
pub fn decompress(&self, data: &[u8]) -> Result<Vec<u8>, CompressError> {
zstd::decode_all(data).map_err(CompressError::Zstd)
}
}
Removing the trait eliminates dynamic dispatch (Box<dyn Compressor>) and enables the compiler to inline compression calls. This is a performance improvement that only becomes possible after flag cleanup. The gzip crate dependency can also be removed from Cargo.toml.
How Rust's type system prevents stale flag issues
Rust provides several mechanisms that other languages lack for keeping flag debt visible and manageable.
Exhaustive matching
Every match on a flag enum must handle every variant. When you remove a variant, the compiler produces an error at every match expression:
// After removing SearchBackend::Tantivy, every match like this errors:
match backend {
SearchBackend::Tantivy => { /* ... */ } // ERROR: no variant `Tantivy`
SearchBackend::Meilisearch => { /* ... */ }
}
This is fundamentally safer than Java's switch (which has default escape hatches) or Python's if/elif (which has no exhaustiveness requirement).
Unused code warnings
Rust's compiler emits warnings for unused functions, imports, variables, and struct fields. After removing a flag check, any code that was only reachable through the removed branch triggers warnings:
warning: function `tantivy_search` is never used
--> src/search.rs:47:8
|
47 | fn tantivy_search(&self, query: &str) -> Result<SearchResults, SearchError> {
| ^^^^^^^^^^^^^^^
|
= note: `#[warn(dead_code)]` on by default
Combined with #[deny(dead_code)] in CI, unused code becomes a build failure:
// lib.rs or main.rs
#![deny(dead_code)]
#![deny(unused_imports)]
Ownership-driven cleanup
Rust's ownership model means removing a flag's code path can trigger a cascade of cleanup. If the dead branch was the only consumer of a particular struct, and that struct owns a database connection, removing the branch means the connection is never created, which means the connection pool configuration can be simplified, which means fewer dependencies in your service constructor.
// Before: Two backends, two connections
pub struct SearchService {
tantivy: TantivyClient, // Only used by dead flag branch
meilisearch: MeilisearchClient,
}
// After: One backend, one connection
pub struct SearchService {
meilisearch: MeilisearchClient,
}
The TantivyClient destructor runs when the field is removed. If TantivyClient held file handles, index locks, or network connections, those resources are now freed. The ownership model ensures cleanup is complete.
Detection and automation
Tree-sitter for Rust flag detection
Tree-sitter's Rust grammar can detect both runtime flag patterns and compile-time cfg attributes:
;; Tree-sitter query for Rust flag detection (S-expression syntax)
;; Runtime: client.is_enabled("key", &context)
(call_expression
function: (field_expression
field: (field_identifier) @method)
arguments: (arguments
(string_literal) @flag_key)
(#any-of? @method "is_enabled" "bool_variation"
"string_variation" "is_enabled_with_context"))
;; Runtime: flags.is_enabled("key")
(call_expression
function: (field_expression
value: (identifier) @receiver
field: (field_identifier) @method)
arguments: (arguments
(string_literal) @flag_key)
(#eq? @method "is_enabled"))
;; Compile-time: #[cfg(feature = "key")]
(attribute_item
(attribute
(identifier) @attr
arguments: (token_tree
(identifier) @cfg_type
(string_literal) @flag_key))
(#eq? @attr "cfg")
(#eq? @cfg_type "feature"))
FlagShark uses tree-sitter's Rust grammar to detect both runtime and compile-time flag patterns across Rust codebases. This unified detection works without needing cargo build to succeed, which matters for large workspaces where a branch may have compilation errors in unrelated crates.
CI pipeline for flag hygiene
# .github/workflows/flag-hygiene.yml
name: Flag Hygiene
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Check for unused Cargo features
run: |
# List features defined in Cargo.toml
cargo metadata --format-version 1 | \
jq -r '.packages[] | select(.name == "my-crate") | .features | keys[]'
- name: Deny dead code
run: RUSTFLAGS="-D dead_code -D unused_imports" cargo check --all-features
- name: Count runtime flag references
run: |
count=$(grep -rn 'is_enabled\|bool_variation\|string_variation' \
--include="*.rs" src/ | wc -l)
echo "Runtime flag references: $count"
if [ "$count" -gt 50 ]; then
echo "::warning::Runtime flag count ($count) exceeds threshold"
fi
- name: Run all tests
run: cargo test --all-features
Measuring Rust flag debt
| Metric | Healthy | Warning | Critical |
|---|---|---|---|
| Runtime flag references | < 20 per 50k LOC | 20-40 | > 40 |
| Unused Cargo features | 0 | 1-3 | > 3 |
| Stale flags (enabled > 30 days) | < 5 | 5-15 | > 15 |
| Dead code behind flags | < 2% of codebase | 2-5% | > 5% |
| Trait abstractions serving only one impl | < 3 | 3-8 | > 8 |
The cost of Rust flag debt
Rust's compile times make flag debt more expensive per flag than in most languages. Every dead code path behind a stale flag adds to compilation time, link time, and binary size. In a codebase where a clean build takes several minutes, dead flag code noticeably increases build times. Across a team making dozens of builds per day, that wasted compilation time adds up quickly -- on top of the cognitive overhead and testing burden that stale flags create in any language.
Rust-specific pitfalls to avoid
1. Keeping unused optional dependencies in Cargo.toml. When a cfg feature controlled the only consumer of an optional dependency, removing the feature should also remove the dependency. Unused dependencies still contribute to cargo update resolution time and Cargo.lock complexity.
2. Leaving dead trait implementations. After removing a flag that selected between two trait implementations, delete the unused implementation. Rust will not warn about an impl block for a type that is still in scope, even if no code constructs that type.
3. Forgetting to update integration tests. Rust integration tests in the tests/ directory have their own compilation units. A flag reference in an integration test can survive after the flag is removed from src/ if the test uses its own flag configuration.
4. Not simplifying error enums. If a stale flag controlled a code path that could produce Error::Tantivy(...), removing the code path should also remove the error variant. The exhaustive match on the error enum will guide you, but developers sometimes add a _ => unreachable!() arm instead of removing the variant -- which defeats the purpose.
5. Ignoring feature flag interactions with conditional compilation. A runtime flag inside a #[cfg]-gated module creates a nested flag. Cleaning up the runtime flag while the compile-time feature is still active leaves dead code inside a conditionally-compiled module, which may not appear in all build configurations.
Rust gives you more tools for safe flag management than any other systems language. Exhaustive matching ensures you cannot forget a flag branch. The ownership model ensures cleanup is complete. The type system catches stale references at compile time. And #[deny(dead_code)] turns leftover flag paths into build failures. But these tools only help if you use them deliberately. Define flag keys as enum variants, not string literals. Use trait abstractions so flag decisions happen at the composition root. Prefer compile-time features for permanent decisions and runtime flags for gradual rollouts. And when a flag becomes permanent, remove it promptly -- because in Rust, every line of dead code still costs you compilation time, binary size, and cognitive overhead. The language was designed to carry only what it needs. Your feature flags should follow the same principle.