use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PermissionMode {
#[default]
Default,
Plan,
AcceptEdits,
DontAsk,
BypassPermissions,
}
impl PermissionMode {
pub fn description(&self) -> &'static str {
match self {
PermissionMode::Default => "Interactive mode: prompt for dangerous operations",
PermissionMode::Plan => "Plan mode: read-only, no mutations allowed",
PermissionMode::AcceptEdits => "Accept edits: auto-approve file writes",
PermissionMode::DontAsk => "Don't ask: auto-deny unless whitelisted",
PermissionMode::BypassPermissions => "Bypass: skip all permission checks",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BambooSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<PermissionMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub denied_tools: Vec<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
impl BambooSettings {
pub fn load_from(path: &Path) -> Self {
if !path.exists() {
return Self::default();
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
tracing::warn!("Failed to read settings from {}: {e}", path.display());
return Self::default();
}
};
match serde_json::from_str(&content) {
Ok(s) => s,
Err(e) => {
tracing::warn!("Failed to parse settings from {}: {e}", path.display());
Self::default()
}
}
}
pub fn merge(&mut self, lower_priority: &Self) {
if self.permission_mode.is_none() && lower_priority.permission_mode.is_some() {
self.permission_mode = lower_priority.permission_mode;
}
if self.default_model.is_none() && lower_priority.default_model.is_some() {
self.default_model = lower_priority.default_model.clone();
}
for tool in &lower_priority.allowed_tools {
if !self.allowed_tools.contains(tool) {
self.allowed_tools.push(tool.clone());
}
}
for tool in &lower_priority.denied_tools {
if !self.denied_tools.contains(tool) {
self.denied_tools.push(tool.clone());
}
}
for (key, value) in &lower_priority.extra {
self.extra
.entry(key.clone())
.or_insert_with(|| value.clone());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn test_bamboo_settings_default() {
let settings = BambooSettings::default();
assert!(settings.permission_mode.is_none());
assert!(settings.default_model.is_none());
assert!(settings.allowed_tools.is_empty());
assert!(settings.denied_tools.is_empty());
}
#[test]
fn test_bamboo_settings_merge_scalar_precedence() {
let mut higher = BambooSettings {
permission_mode: Some(PermissionMode::AcceptEdits),
default_model: Some("gpt-4".to_string()),
..Default::default()
};
let lower = BambooSettings {
permission_mode: Some(PermissionMode::Plan),
default_model: Some("claude-3".to_string()),
..Default::default()
};
higher.merge(&lower);
assert_eq!(higher.permission_mode, Some(PermissionMode::AcceptEdits));
assert_eq!(higher.default_model, Some("gpt-4".to_string()));
}
#[test]
fn test_bamboo_settings_merge_falls_back_to_lower() {
let mut higher = BambooSettings::default();
let lower = BambooSettings {
permission_mode: Some(PermissionMode::Plan),
default_model: Some("claude-3".to_string()),
..Default::default()
};
higher.merge(&lower);
assert_eq!(higher.permission_mode, Some(PermissionMode::Plan));
assert_eq!(higher.default_model, Some("claude-3".to_string()));
}
#[test]
fn test_bamboo_settings_merge_arrays_concatenated() {
let mut higher = BambooSettings {
allowed_tools: vec!["Bash(npm run *)".to_string()],
denied_tools: vec!["Bash(curl *)".to_string()],
..Default::default()
};
let lower = BambooSettings {
allowed_tools: vec!["Write(/src/**)".to_string()],
denied_tools: vec!["Read(./.env)".to_string()],
..Default::default()
};
higher.merge(&lower);
assert_eq!(higher.allowed_tools.len(), 2);
assert_eq!(higher.denied_tools.len(), 2);
assert!(higher
.allowed_tools
.contains(&"Bash(npm run *)".to_string()));
assert!(higher.allowed_tools.contains(&"Write(/src/**)".to_string()));
}
#[test]
fn test_bamboo_settings_merge_arrays_deduplicated() {
let mut higher = BambooSettings {
allowed_tools: vec!["Bash(npm run *)".to_string()],
..Default::default()
};
let lower = BambooSettings {
allowed_tools: vec!["Bash(npm run *)".to_string()],
..Default::default()
};
higher.merge(&lower);
assert_eq!(higher.allowed_tools.len(), 1);
}
#[test]
fn test_bamboo_settings_load_from_missing_file() {
let settings = BambooSettings::load_from(Path::new("/nonexistent/settings.json"));
assert!(settings.permission_mode.is_none());
}
#[test]
fn test_bamboo_settings_load_from_valid_file() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("settings.json");
std::fs::write(
&path,
r#"{"permissionMode": "plan", "defaultModel": "claude-4"}"#,
)
.unwrap();
let settings = BambooSettings::load_from(&path);
assert_eq!(settings.permission_mode, Some(PermissionMode::Plan));
assert_eq!(settings.default_model, Some("claude-4".to_string()));
}
#[test]
fn test_bamboo_settings_load_from_invalid_json() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("settings.json");
std::fs::write(&path, "not json").unwrap();
let settings = BambooSettings::load_from(&path);
assert!(settings.permission_mode.is_none());
}
#[test]
fn test_permission_mode_serialize_roundtrip() {
let modes = [
PermissionMode::Default,
PermissionMode::Plan,
PermissionMode::AcceptEdits,
PermissionMode::DontAsk,
PermissionMode::BypassPermissions,
];
for mode in modes {
let json = serde_json::to_string(&mode).unwrap();
let back: PermissionMode = serde_json::from_str(&json).unwrap();
assert_eq!(mode, back);
}
}
}