use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
pub struct AppConfig {
pub name: String,
pub toggles: BTreeMap<String, String>,
pub current_slot: Option<String>,
pub known_slots: Vec<String>,
pub overrides: BTreeMap<String, Vec<String>>,
}
impl AppConfig {
pub fn raw_value(&self, key: &str) -> Option<&str> {
self.toggles.get(key).map(String::as_str)
}
pub fn override_users(&self, key: &str) -> &[String] {
self.overrides.get(key).map(Vec::as_slice).unwrap_or(&[])
}
}
pub fn read_json(path: &Path) -> Option<Value> {
let contents = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&contents).ok()
}
pub fn load_single(path: &Path) -> Option<AppConfig> {
let value = read_json(path)?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("appsettings.json")
.to_string();
Some(from_value(name, &value))
}
pub fn load_environment(config_dir: &Path, environment: &str) -> AppConfig {
let base_path = config_dir.join("appsettings.json");
let mut root = read_json(&base_path).unwrap_or_else(|| Value::Object(Map::new()));
let overlay_path = config_dir.join(format!("appsettings.{environment}.json"));
if let Some(overlay) = read_json(&overlay_path) {
deep_merge(&mut root, overlay);
}
from_value(format!("appsettings ({environment})"), &root)
}
pub fn find_config_files(dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with("appsettings") && name.ends_with(".json") {
files.push(path);
}
}
}
}
files.sort();
files
}
pub fn toggles_section(value: &Value) -> BTreeMap<String, String> {
let mut toggles = BTreeMap::new();
if let Some(object) = value.get("Toggles").and_then(Value::as_object) {
for (key, raw) in object {
if let Some(display) = value_to_display(raw) {
toggles.insert(key.clone(), display);
}
}
}
toggles
}
fn from_value(name: String, value: &Value) -> AppConfig {
let toggles = toggles_section(value);
let current_slot = value
.get("FtrIO")
.and_then(|f| f.get("BlueGreen"))
.and_then(|b| b.get("CurrentSlot"))
.and_then(Value::as_str)
.map(str::to_string);
let known_slots = value
.get("FtrIO")
.and_then(|f| f.get("BlueGreen"))
.and_then(|b| b.get("KnownSlots"))
.and_then(Value::as_str)
.map(|slots| {
slots
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec!["blue".to_string(), "green".to_string()]);
let mut overrides = BTreeMap::new();
if let Some(object) = value.get("TogglesOverrides").and_then(Value::as_object) {
for (key, users) in object {
if let Some(users_object) = users.as_object() {
let user_ids: Vec<String> = users_object.keys().cloned().collect();
overrides.insert(key.clone(), user_ids);
}
}
}
AppConfig {
name,
toggles,
current_slot,
known_slots,
overrides,
}
}
fn value_to_display(value: &Value) -> Option<String> {
match value {
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => Some(n.to_string()),
Value::String(s) => Some(s.clone()),
_ => None,
}
}
fn deep_merge(base: &mut Value, overlay: Value) {
match (base, overlay) {
(Value::Object(base_map), Value::Object(overlay_map)) => {
for (key, overlay_value) in overlay_map {
match base_map.get_mut(&key) {
Some(existing) => deep_merge(existing, overlay_value),
None => {
base_map.insert(key, overlay_value);
}
}
}
}
(base_slot, overlay_value) => *base_slot = overlay_value,
}
}
pub fn classify_state(
raw: Option<&str>,
current_slot: Option<&str>,
known_slots: &[String],
) -> String {
let Some(raw) = raw else {
return "MISSING".to_string();
};
let lower = raw.trim().to_ascii_lowercase();
if matches!(lower.as_str(), "true" | "1") {
return "ON".to_string();
}
if matches!(lower.as_str(), "false" | "0") {
return "OFF".to_string();
}
if lower.ends_with('%') {
return "PERCENTAGE".to_string();
}
if lower.starts_with("ab:") {
return "AB-TEST".to_string();
}
if lower.starts_with("users:") {
return "TARGETED".to_string();
}
if lower.starts_with("attribute:") {
return "RULE-BASED".to_string();
}
if known_slots
.iter()
.any(|slot| slot.eq_ignore_ascii_case(&lower))
{
return match current_slot {
Some(current) if current.eq_ignore_ascii_case(&lower) => "ON".to_string(),
Some(_) => "OFF".to_string(),
None => "BLUE/GREEN".to_string(),
};
}
"UNKNOWN".to_string()
}
pub const STATE_ORDER: &[&str] = &[
"ON",
"OFF",
"PERCENTAGE",
"BLUE/GREEN",
"AB-TEST",
"TARGETED",
"RULE-BASED",
"MISSING",
"UNKNOWN",
];