use serde_json::Value;
use crate::{
AdminError, AdminField, AdminFieldKind, AdminFuture, AdminModel, ListParams, ListResult,
SelectOption,
};
#[derive(Debug, Default, Clone)]
pub struct FeatureFlagAdminModel;
impl AdminModel for FeatureFlagAdminModel {
fn slug(&self) -> &'static str {
"feature-flags"
}
fn display_name(&self) -> &'static str {
"Feature Flag"
}
fn display_name_plural(&self) -> &'static str {
"Feature Flags"
}
fn fields(&self) -> Vec<AdminField> {
vec![
AdminField::new("key", AdminFieldKind::Text)
.label("Flag Key")
.searchable(),
AdminField::new("description", AdminFieldKind::TextArea)
.label("Description")
.optional()
.searchable(),
AdminField::new("enabled", AdminFieldKind::Boolean).label("Globally Enabled"),
AdminField::new(
"rollout_pct",
AdminFieldKind::Select(vec![
SelectOption {
value: "0".into(),
label: "Off (0%)".into(),
},
SelectOption {
value: "10".into(),
label: "10%".into(),
},
SelectOption {
value: "25".into(),
label: "25%".into(),
},
SelectOption {
value: "50".into(),
label: "50%".into(),
},
SelectOption {
value: "75".into(),
label: "75%".into(),
},
SelectOption {
value: "100".into(),
label: "All (100%)".into(),
},
]),
)
.label("Rollout %")
.optional(),
AdminField::new("actor_allowlist", AdminFieldKind::TextArea)
.label("Actor Allowlist (JSON array)")
.optional()
.hide_from_list(),
AdminField::new("group_allowlist", AdminFieldKind::TextArea)
.label("Group Allowlist (JSON array)")
.optional()
.hide_from_list(),
AdminField::new("updated_at", AdminFieldKind::DateTime)
.label("Last Updated")
.readonly()
.optional(),
]
}
fn record_display(&self, record: &Value) -> String {
record
.get("key")
.and_then(|v| v.as_str())
.map_or_else(|| "Feature Flag".to_owned(), |k| format!("Flag: {k}"))
}
fn list(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
params: ListParams,
) -> AdminFuture<'_, ListResult> {
use diesel_async::RunQueryDsl;
let pool = pool.clone();
Box::pin(async move {
let mut conn = pool
.get()
.await
.map_err(|e| AdminError::Database(e.to_string()))?;
let per_page = params.per_page;
let offset = if per_page == 0 {
0
} else {
params.page.saturating_sub(1) * per_page
};
let limit = if per_page == 0 {
i64::MAX
} else {
i64::try_from(per_page).unwrap_or(i64::MAX)
};
let search_pattern = format!("%{}%", params.search.as_deref().unwrap_or(""));
let total: i64 = diesel::sql_query(
"SELECT COUNT(*) FROM autumn_feature_flags \
WHERE (key ILIKE $1 OR COALESCE(description,'') ILIKE $1)",
)
.bind::<diesel::sql_types::Text, _>(&search_pattern)
.get_result::<CountRow>(&mut conn)
.await
.map_or(0, |r| r.count);
let records: Vec<Value> = diesel::sql_query(
"SELECT id, key, description, enabled, rollout_pct, \
actor_allowlist, group_allowlist, updated_at \
FROM autumn_feature_flags \
WHERE (key ILIKE $1 OR COALESCE(description,'') ILIKE $1) \
ORDER BY key \
LIMIT $2 OFFSET $3",
)
.bind::<diesel::sql_types::Text, _>(&search_pattern)
.bind::<diesel::sql_types::BigInt, _>(limit)
.bind::<diesel::sql_types::BigInt, _>(i64::try_from(offset).unwrap_or(0))
.load::<FlagRow>(&mut conn)
.await
.map(|rows| rows.into_iter().map(FlagRow::into_json).collect())
.map_err(|e| AdminError::Database(e.to_string()))?;
Ok(ListResult {
total: u64::try_from(total).unwrap_or(0),
page: params.page,
per_page,
records,
})
})
}
fn get(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
id: i64,
) -> AdminFuture<'_, Option<Value>> {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let pool = pool.clone();
Box::pin(async move {
let mut conn = pool
.get()
.await
.map_err(|e| AdminError::Database(e.to_string()))?;
diesel::sql_query(
"SELECT id, key, description, enabled, rollout_pct, \
actor_allowlist, group_allowlist, updated_at \
FROM autumn_feature_flags WHERE id = $1",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.get_result::<FlagRow>(&mut conn)
.await
.optional()
.map(|r| r.map(FlagRow::into_json))
.map_err(|e| AdminError::Database(e.to_string()))
})
}
fn create(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
data: Value,
) -> AdminFuture<'_, Value> {
use diesel_async::RunQueryDsl;
let pool = pool.clone();
Box::pin(async move {
let mut conn = pool
.get()
.await
.map_err(|e| AdminError::Database(e.to_string()))?;
let key = data
.get("key")
.and_then(Value::as_str)
.ok_or_else(|| AdminError::Validation("'key' is required".into()))?;
let enabled = data
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(false);
let mut rollout_pct = data
.get("rollout_pct")
.and_then(|v| {
v.as_i64()
.or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
})
.unwrap_or(0)
.clamp(0, 100);
let description = data.get("description").and_then(Value::as_str);
let actor_allowlist = validate_string_array(
data.get("actor_allowlist")
.and_then(Value::as_str)
.unwrap_or("[]"),
"actor_allowlist",
)?;
let group_allowlist = validate_string_array(
data.get("group_allowlist")
.and_then(Value::as_str)
.unwrap_or("[]"),
"group_allowlist",
)?;
let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
if enabled && rollout_pct == 0 && !has_allowlist {
rollout_pct = 100;
}
let mutation = if enabled { "enabled" } else { "disabled" };
let row = diesel::sql_query(
"WITH inserted AS ( \
INSERT INTO autumn_feature_flags \
(key, description, enabled, rollout_pct, \
actor_allowlist, group_allowlist) \
VALUES ($1, $2, $3, $4, $5, $6) \
RETURNING id, key, description, enabled, rollout_pct, \
actor_allowlist, group_allowlist, updated_at \
), \
_audit AS ( \
INSERT INTO feature_flag_changes (key, mutation, actor) \
SELECT key, $7, NULL FROM inserted \
) \
SELECT id, key, description, enabled, rollout_pct, \
actor_allowlist, group_allowlist, updated_at \
FROM inserted",
)
.bind::<diesel::sql_types::Text, _>(key)
.bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
description.map(str::to_owned),
)
.bind::<diesel::sql_types::Bool, _>(enabled)
.bind::<diesel::sql_types::SmallInt, _>(i16::try_from(rollout_pct).unwrap_or(0))
.bind::<diesel::sql_types::Text, _>(actor_allowlist)
.bind::<diesel::sql_types::Text, _>(group_allowlist)
.bind::<diesel::sql_types::Text, _>(mutation)
.get_result::<FlagRow>(&mut conn)
.await
.map_err(|e| {
if matches!(
e,
diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_
)
) {
AdminError::Validation(format!("a flag with key '{key}' already exists"))
} else {
AdminError::Database(e.to_string())
}
})?;
Ok(FlagRow::into_json(row))
})
}
fn update(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
id: i64,
data: Value,
) -> AdminFuture<'_, Value> {
use diesel_async::RunQueryDsl;
let pool = pool.clone();
Box::pin(async move {
let mut conn = pool
.get()
.await
.map_err(|e| AdminError::Database(e.to_string()))?;
let key = data
.get("key")
.and_then(Value::as_str)
.ok_or_else(|| AdminError::Validation("'key' is required".into()))?;
let enabled = data
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(false);
let mut rollout_pct = data
.get("rollout_pct")
.and_then(|v| {
v.as_i64()
.or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
})
.unwrap_or(0)
.clamp(0, 100);
let description = data.get("description").and_then(Value::as_str);
let actor_allowlist = validate_string_array(
data.get("actor_allowlist")
.and_then(Value::as_str)
.unwrap_or("[]"),
"actor_allowlist",
)?;
let group_allowlist = validate_string_array(
data.get("group_allowlist")
.and_then(Value::as_str)
.unwrap_or("[]"),
"group_allowlist",
)?;
let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
if enabled && rollout_pct == 0 && !has_allowlist {
rollout_pct = 100;
}
let mutation = if enabled { "enabled" } else { "disabled" };
let row = diesel::sql_query(
"WITH old_row AS ( \
SELECT key FROM autumn_feature_flags WHERE id = $1 \
), \
updated AS ( \
UPDATE autumn_feature_flags \
SET key = $2, description = $3, enabled = $4, rollout_pct = $5, \
actor_allowlist = $6, group_allowlist = $7, updated_at = NOW() \
WHERE id = $1 \
RETURNING id, key, description, enabled, rollout_pct, \
actor_allowlist, group_allowlist, updated_at \
), \
_audit_rename AS ( \
INSERT INTO feature_flag_changes (key, mutation, actor) \
SELECT old_row.key, 'deleted', NULL \
FROM old_row \
WHERE old_row.key != $2 \
), \
_audit_rename_breadcrumb AS ( \
INSERT INTO feature_flag_changes (key, mutation, actor) \
SELECT $2, 'renamed_from=' || old_row.key, NULL \
FROM old_row \
WHERE old_row.key != $2 \
), \
_audit AS ( \
INSERT INTO feature_flag_changes (key, mutation, actor) \
SELECT key, $8, NULL FROM updated \
) \
SELECT id, key, description, enabled, rollout_pct, \
actor_allowlist, group_allowlist, updated_at \
FROM updated",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.bind::<diesel::sql_types::Text, _>(key)
.bind::<diesel::sql_types::Nullable<diesel::sql_types::Text>, _>(
description.map(str::to_owned),
)
.bind::<diesel::sql_types::Bool, _>(enabled)
.bind::<diesel::sql_types::SmallInt, _>(i16::try_from(rollout_pct).unwrap_or(0))
.bind::<diesel::sql_types::Text, _>(actor_allowlist)
.bind::<diesel::sql_types::Text, _>(group_allowlist)
.bind::<diesel::sql_types::Text, _>(mutation)
.get_result::<FlagRow>(&mut conn)
.await
.map_err(|e| AdminError::Database(e.to_string()))?;
Ok(FlagRow::into_json(row))
})
}
fn delete(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
id: i64,
) -> AdminFuture<'_, ()> {
use diesel_async::RunQueryDsl;
let pool = pool.clone();
Box::pin(async move {
let mut conn = pool
.get()
.await
.map_err(|e| AdminError::Database(e.to_string()))?;
diesel::sql_query(
"WITH deleted AS ( \
DELETE FROM autumn_feature_flags WHERE id = $1 RETURNING key \
), \
_audit AS ( \
INSERT INTO feature_flag_changes (key, mutation, actor) \
SELECT key, 'deleted', NULL FROM deleted \
) \
SELECT COUNT(*) AS count FROM deleted",
)
.bind::<diesel::sql_types::BigInt, _>(id)
.get_result::<CountRow>(&mut conn)
.await
.map_err(|e| AdminError::Database(e.to_string()))?;
Ok(())
})
}
fn has_history(&self) -> bool {
true
}
fn get_history<'a>(
&'a self,
pool: &'a diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
record_id: i64,
page: u64,
per_page: u64,
) -> crate::AdminFuture<'a, crate::AdminHistoryPage> {
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
let pool = pool.clone();
Box::pin(async move {
let mut conn = pool
.get()
.await
.map_err(|e| AdminError::Database(e.to_string()))?;
let key: Option<String> =
diesel::sql_query("SELECT key FROM autumn_feature_flags WHERE id = $1")
.bind::<diesel::sql_types::BigInt, _>(record_id)
.get_result::<KeyRow>(&mut conn)
.await
.optional()
.unwrap_or(None)
.map(|r| r.key);
let Some(key) = key else {
return Ok(crate::AdminHistoryPage {
entries: vec![],
total: 0,
page,
per_page,
});
};
let ancestor_cte = "WITH RECURSIVE ancestors AS ( \
SELECT $1::text AS key \
UNION \
SELECT regexp_replace(ffc.mutation, '^renamed_from=', '') \
FROM feature_flag_changes ffc \
JOIN ancestors a ON ffc.key = a.key \
WHERE ffc.mutation LIKE 'renamed_from=%' \
)";
let count: i64 = diesel::sql_query(format!(
"{ancestor_cte} \
SELECT COUNT(*) FROM feature_flag_changes \
WHERE key IN (SELECT key FROM ancestors)",
))
.bind::<diesel::sql_types::Text, _>(&key)
.get_result::<CountRow>(&mut conn)
.await
.map_or(0, |r| r.count);
let offset = (page.saturating_sub(1)) * per_page;
let entries: Vec<crate::AdminHistoryEntry> = diesel::sql_query(format!(
"{ancestor_cte} \
SELECT id, mutation AS op, actor, changed_at \
FROM feature_flag_changes \
WHERE key IN (SELECT key FROM ancestors) \
ORDER BY changed_at DESC \
LIMIT $2 OFFSET $3",
))
.bind::<diesel::sql_types::Text, _>(&key)
.bind::<diesel::sql_types::BigInt, _>(i64::try_from(per_page).unwrap_or(i64::MAX))
.bind::<diesel::sql_types::BigInt, _>(i64::try_from(offset).unwrap_or(0))
.load::<HistoryRow>(&mut conn)
.await
.unwrap_or_default()
.into_iter()
.map(|r| crate::AdminHistoryEntry {
id: r.id,
actor: r.actor.unwrap_or_else(|| "cli".to_owned()),
op: r.op,
request_id: None,
changes: vec![],
recorded_at: r.changed_at,
})
.collect();
Ok(crate::AdminHistoryPage {
entries,
total: u64::try_from(count).unwrap_or(0),
page,
per_page,
})
})
}
}
fn validate_string_array(raw: &str, field: &str) -> Result<String, AdminError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok("[]".to_owned());
}
match serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
Ok(arr) if arr.iter().all(serde_json::Value::is_string) => {
Ok(serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_owned()))
}
Ok(_) => Err(AdminError::Validation(format!(
"'{field}' must be a JSON array of strings (e.g. [\"user:42\"])"
))),
Err(_) => Err(AdminError::Validation(format!(
"'{field}' must be valid JSON (e.g. [\"user:42\"]); check for trailing commas"
))),
}
}
#[derive(diesel::QueryableByName)]
struct CountRow {
#[diesel(sql_type = diesel::sql_types::BigInt)]
count: i64,
}
#[derive(diesel::QueryableByName)]
struct KeyRow {
#[diesel(sql_type = diesel::sql_types::Text)]
key: String,
}
#[derive(diesel::QueryableByName)]
struct FlagRow {
#[diesel(sql_type = diesel::sql_types::BigInt)]
id: i64,
#[diesel(sql_type = diesel::sql_types::Text)]
key: String,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
description: Option<String>,
#[diesel(sql_type = diesel::sql_types::Bool)]
enabled: bool,
#[diesel(sql_type = diesel::sql_types::SmallInt)]
rollout_pct: i16,
#[diesel(sql_type = diesel::sql_types::Text)]
actor_allowlist: String,
#[diesel(sql_type = diesel::sql_types::Text)]
group_allowlist: String,
#[diesel(sql_type = diesel::sql_types::Timestamptz)]
updated_at: chrono::DateTime<chrono::Utc>,
}
impl FlagRow {
fn into_json(self) -> Value {
serde_json::json!({
"id": self.id,
"key": self.key,
"description": self.description,
"enabled": self.enabled,
"rollout_pct": self.rollout_pct,
"actor_allowlist": self.actor_allowlist,
"group_allowlist": self.group_allowlist,
"updated_at": self.updated_at.to_rfc3339(),
})
}
}
#[derive(diesel::QueryableByName)]
struct HistoryRow {
#[diesel(sql_type = diesel::sql_types::BigInt)]
id: i64,
#[diesel(sql_type = diesel::sql_types::Text)]
op: String,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
actor: Option<String>,
#[diesel(sql_type = diesel::sql_types::Timestamptz)]
changed_at: chrono::DateTime<chrono::Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_flag_admin_model_slug_is_feature_flags() {
let model = FeatureFlagAdminModel;
assert_eq!(model.slug(), "feature-flags");
}
#[test]
fn feature_flag_admin_model_has_correct_display_names() {
let model = FeatureFlagAdminModel;
assert_eq!(model.display_name(), "Feature Flag");
assert_eq!(model.display_name_plural(), "Feature Flags");
}
#[test]
fn feature_flag_admin_fields_include_required_columns() {
let model = FeatureFlagAdminModel;
let fields = model.fields();
let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
assert!(names.contains(&"key"), "must have key field");
assert!(names.contains(&"enabled"), "must have enabled field");
assert!(
names.contains(&"rollout_pct"),
"must have rollout_pct field"
);
assert!(
names.contains(&"actor_allowlist"),
"must have actor_allowlist field"
);
}
#[test]
fn feature_flag_admin_model_has_history() {
let model = FeatureFlagAdminModel;
assert!(
model.has_history(),
"feature flag admin must expose history"
);
}
#[test]
fn record_display_uses_flag_key() {
let model = FeatureFlagAdminModel;
let record = serde_json::json!({"key": "beta_inbox", "enabled": false});
assert_eq!(model.record_display(&record), "Flag: beta_inbox");
}
#[test]
fn record_display_fallback_when_no_key() {
let model = FeatureFlagAdminModel;
let record = serde_json::json!({});
assert_eq!(model.record_display(&record), "Feature Flag");
}
#[test]
fn globally_enabled_with_zero_rollout_promotes_to_100() {
let enabled = true;
let submitted_rollout: i64 = 0;
let mut rollout_pct = submitted_rollout.clamp(0, 100);
if enabled && rollout_pct == 0 {
rollout_pct = 100;
}
assert_eq!(
rollout_pct, 100,
"enabled=true + rollout=0 must be promoted to rollout=100"
);
}
#[test]
fn globally_enabled_with_explicit_rollout_is_preserved() {
let enabled = true;
let submitted_rollout: i64 = 25;
let mut rollout_pct = submitted_rollout.clamp(0, 100);
if enabled && rollout_pct == 0 {
rollout_pct = 100;
}
assert_eq!(
rollout_pct, 25,
"enabled=true + explicit rollout=25 must be preserved"
);
}
#[test]
fn disabled_with_zero_rollout_is_not_promoted() {
let enabled = false;
let submitted_rollout: i64 = 0;
let mut rollout_pct = submitted_rollout.clamp(0, 100);
let has_allowlist = false;
if enabled && rollout_pct == 0 && !has_allowlist {
rollout_pct = 100;
}
assert_eq!(rollout_pct, 0, "kill-switch must not promote rollout_pct");
}
#[test]
fn enabled_with_zero_rollout_and_non_empty_allowlist_is_not_promoted() {
let enabled = true;
let submitted_rollout: i64 = 0;
let actor_allowlist = r#"["user:42"]"#;
let group_allowlist = "[]";
let mut rollout_pct = submitted_rollout.clamp(0, 100);
let has_allowlist = actor_allowlist != "[]" || group_allowlist != "[]";
if enabled && rollout_pct == 0 && !has_allowlist {
rollout_pct = 100;
}
assert_eq!(
rollout_pct, 0,
"allowlist-only flag must not have rollout_pct promoted to 100"
);
}
#[test]
fn validate_string_array_accepts_valid_array() {
let result = validate_string_array(r#"["user:1","user:2"]"#, "actor_allowlist");
assert!(result.is_ok(), "valid array must be accepted: {result:?}");
}
#[test]
fn validate_string_array_accepts_empty_string_as_empty_array() {
let result = validate_string_array("", "actor_allowlist");
assert_eq!(result.unwrap(), "[]");
}
#[test]
fn validate_string_array_rejects_trailing_comma() {
let result = validate_string_array(r#"["user:42",]"#, "actor_allowlist");
assert!(result.is_err(), "trailing comma must be rejected");
}
#[test]
fn validate_string_array_rejects_non_array() {
let result = validate_string_array("user:42", "actor_allowlist");
assert!(result.is_err(), "bare string must be rejected");
}
#[test]
fn validate_string_array_rejects_array_with_non_string_elements() {
let result = validate_string_array("[1, 2, 3]", "actor_allowlist");
assert!(result.is_err(), "integer elements must be rejected");
}
#[test]
fn validate_string_array_normalises_output() {
let result = validate_string_array(r#"[ "user:1" , "user:2" ]"#, "actor_allowlist");
assert_eq!(result.unwrap(), r#"["user:1","user:2"]"#);
}
}