use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ColorScheme {
#[default]
Dark,
Light,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
#[serde(default)]
pub permissions: Option<Permissions>,
#[serde(default)]
pub hooks: Option<HashMap<String, Vec<HookGroup>>>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub env: Option<HashMap<String, String>>,
#[serde(default)]
pub enabled_plugins: Option<HashMap<String, bool>>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub custom_instructions: Option<String>,
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub subscription_plan: Option<String>,
#[serde(default)]
pub budget: Option<BudgetConfig>,
#[serde(default)]
pub keybindings: Option<HashMap<String, String>>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BudgetConfig {
pub monthly_limit: Option<f64>,
#[serde(default = "default_warning_threshold")]
pub warning_threshold: f64,
#[serde(default = "default_critical_threshold")]
pub critical_threshold: f64,
}
fn default_warning_threshold() -> f64 {
75.0
}
fn default_critical_threshold() -> f64 {
90.0
}
impl Settings {
pub fn masked_api_key(&self) -> Option<String> {
self.api_key.as_ref().map(|key| {
if key.len() <= 10 {
format!("{}••••", &key.chars().take(3).collect::<String>())
} else {
let prefix = key.chars().take(7).collect::<String>();
let suffix = key.chars().skip(key.len() - 4).collect::<String>();
format!("{}••••{}", prefix, suffix)
}
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Permissions {
#[serde(default)]
pub allow: Option<Vec<String>>,
#[serde(default)]
pub deny: Option<Vec<String>>,
#[serde(default)]
pub allow_bash: Option<Vec<String>>,
#[serde(default)]
pub deny_bash: Option<Vec<String>>,
#[serde(default)]
pub auto_approve: Option<bool>,
#[serde(default)]
pub trust_project: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HookGroup {
#[serde(default)]
pub matcher: Option<String>,
#[serde(default)]
pub hooks: Vec<HookDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HookDefinition {
pub command: String,
#[serde(default)]
pub r#async: Option<bool>,
#[serde(default)]
pub timeout: Option<u32>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub env: Option<HashMap<String, String>>,
#[serde(skip)]
pub file_path: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MergedConfig {
pub global: Option<Settings>,
pub project: Option<Settings>,
pub local: Option<Settings>,
pub merged: Settings,
}
impl MergedConfig {
pub fn from_layers(
global: Option<Settings>,
global_local: Option<Settings>,
project: Option<Settings>,
project_local: Option<Settings>,
) -> Self {
let mut merged = Settings::default();
if let Some(ref g) = global {
Self::merge_into(&mut merged, g);
}
if let Some(ref gl) = global_local {
Self::merge_into(&mut merged, gl);
}
if let Some(ref p) = project {
Self::merge_into(&mut merged, p);
}
if let Some(ref pl) = project_local {
Self::merge_into(&mut merged, pl);
}
let global_combined = match (global, global_local) {
(Some(mut g), Some(ref gl)) => {
Self::merge_into(&mut g, gl);
Some(g)
}
(g, gl) => g.or(gl),
};
Self {
global: global_combined,
project,
local: project_local, merged,
}
}
fn merge_into(target: &mut Settings, source: &Settings) {
if source.model.is_some() {
target.model = source.model.clone();
}
if source.api_key.is_some() {
target.api_key = source.api_key.clone();
}
if source.custom_instructions.is_some() {
target.custom_instructions = source.custom_instructions.clone();
}
if source.theme.is_some() {
target.theme = source.theme.clone();
}
if source.subscription_plan.is_some() {
target.subscription_plan = source.subscription_plan.clone();
}
if source.budget.is_some() {
target.budget = source.budget.clone();
}
if let Some(ref src_keybindings) = source.keybindings {
let target_keybindings = target.keybindings.get_or_insert_with(HashMap::new);
for (k, v) in src_keybindings {
target_keybindings.insert(k.clone(), v.clone());
}
}
if let Some(ref src_perms) = source.permissions {
let target_perms = target.permissions.get_or_insert_with(Permissions::default);
Self::merge_permissions(target_perms, src_perms);
}
if let Some(ref src_hooks) = source.hooks {
let target_hooks = target.hooks.get_or_insert_with(HashMap::new);
for (event, groups) in src_hooks {
let target_groups = target_hooks.entry(event.clone()).or_default();
target_groups.extend(groups.clone());
}
}
if let Some(ref src_env) = source.env {
let target_env = target.env.get_or_insert_with(HashMap::new);
for (k, v) in src_env {
target_env.insert(k.clone(), v.clone());
}
}
if let Some(ref src_plugins) = source.enabled_plugins {
let target_plugins = target.enabled_plugins.get_or_insert_with(HashMap::new);
for (k, v) in src_plugins {
target_plugins.insert(k.clone(), *v);
}
}
for (k, v) in &source.extra {
target.extra.insert(k.clone(), v.clone());
}
}
fn merge_permissions(target: &mut Permissions, source: &Permissions) {
if let Some(ref src_allow) = source.allow {
let target_allow = target.allow.get_or_insert_with(Vec::new);
for item in src_allow {
if !target_allow.contains(item) {
target_allow.push(item.clone());
}
}
}
if let Some(ref src_deny) = source.deny {
let target_deny = target.deny.get_or_insert_with(Vec::new);
for item in src_deny {
if !target_deny.contains(item) {
target_deny.push(item.clone());
}
}
}
if let Some(ref src_allow_bash) = source.allow_bash {
let target_allow_bash = target.allow_bash.get_or_insert_with(Vec::new);
for item in src_allow_bash {
if !target_allow_bash.contains(item) {
target_allow_bash.push(item.clone());
}
}
}
if let Some(ref src_deny_bash) = source.deny_bash {
let target_deny_bash = target.deny_bash.get_or_insert_with(Vec::new);
for item in src_deny_bash {
if !target_deny_bash.contains(item) {
target_deny_bash.push(item.clone());
}
}
}
if source.auto_approve.is_some() {
target.auto_approve = source.auto_approve;
}
if source.trust_project.is_some() {
target.trust_project = source.trust_project;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_merge_scalar_override() {
let global = Settings {
model: Some("opus".to_string()),
..Default::default()
};
let project = Settings {
model: Some("sonnet".to_string()),
..Default::default()
};
let merged = MergedConfig::from_layers(Some(global), None, Some(project), None);
assert_eq!(merged.merged.model, Some("sonnet".to_string()));
}
#[test]
fn test_merge_env_combines() {
let mut global_env = HashMap::new();
global_env.insert("A".to_string(), "1".to_string());
global_env.insert("B".to_string(), "2".to_string());
let mut project_env = HashMap::new();
project_env.insert("B".to_string(), "override".to_string());
project_env.insert("C".to_string(), "3".to_string());
let global = Settings {
env: Some(global_env),
..Default::default()
};
let project = Settings {
env: Some(project_env),
..Default::default()
};
let merged = MergedConfig::from_layers(Some(global), None, Some(project), None);
let env = merged.merged.env.unwrap();
assert_eq!(env.get("A"), Some(&"1".to_string()));
assert_eq!(env.get("B"), Some(&"override".to_string()));
assert_eq!(env.get("C"), Some(&"3".to_string()));
}
#[test]
fn test_merge_permissions_extend() {
let global = Settings {
permissions: Some(Permissions {
allow: Some(vec!["Read".to_string()]),
..Default::default()
}),
..Default::default()
};
let project = Settings {
permissions: Some(Permissions {
allow: Some(vec!["Write".to_string()]),
deny: Some(vec!["Bash".to_string()]),
..Default::default()
}),
..Default::default()
};
let merged = MergedConfig::from_layers(Some(global), None, Some(project), None);
let perms = merged.merged.permissions.unwrap();
let allow = perms.allow.unwrap();
assert!(allow.contains(&"Read".to_string()));
assert!(allow.contains(&"Write".to_string()));
let deny = perms.deny.unwrap();
assert!(deny.contains(&"Bash".to_string()));
}
}