use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::permission;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
#[serde(default, skip_serializing_if = "permission::PermissionSet::is_empty")]
pub permissions: permission::PermissionSet,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hooks: Option<Hooks>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<Sandbox>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attribution: Option<Attribution>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled_plugins: Option<HashMap<String, bool>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_period_days: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bypass_permissions: Option<bool>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Permissions {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ask: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "PascalCase")]
pub struct Hooks {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pre_tool_use: Option<HookConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_tool_use: Option<HookConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<HookMatcher>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notification: Option<HookConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum HookConfig {
Simple(HashMap<String, String>),
Matchers(Vec<HookMatcher>),
}
impl HookConfig {
pub fn insert(self, pat: &str, command: &str) -> Self {
match self {
HookConfig::Simple(hash_map) => Self::Matchers(
hash_map
.into_iter()
.map(|(pat, cmd)| HookMatcher {
matcher: pat,
hooks: vec![Hook {
hook_type: "command".into(),
command: Some(cmd),
timeout: None,
}],
})
.collect(),
)
.insert(pat, command),
HookConfig::Matchers(mut hook_matchers) => {
let mut found = false;
for hm in &mut hook_matchers {
if hm.matcher == pat {
hm.hooks.push(Hook {
hook_type: "command".into(),
command: Some(command.into()),
timeout: None,
});
found = true;
}
}
if !found {
hook_matchers.push(HookMatcher {
matcher: pat.into(),
hooks: vec![Hook {
hook_type: "command".into(),
command: Some(command.into()),
timeout: None,
}],
});
}
Self::Matchers(hook_matchers)
}
}
}
}
impl Default for HookConfig {
fn default() -> Self {
Self::Simple(HashMap::new())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HookMatcher {
#[serde(default)]
pub matcher: String,
#[serde(default)]
pub hooks: Vec<Hook>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Hook {
#[serde(rename = "type")]
pub hook_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Sandbox {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_allow_bash_if_sandboxed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub excluded_commands: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Attribution {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pr: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SettingsLevel {
System,
ProjectLocal,
Project,
User,
}
impl SettingsLevel {
pub fn all_by_priority() -> &'static [SettingsLevel] {
&[
SettingsLevel::System,
SettingsLevel::ProjectLocal,
SettingsLevel::Project,
SettingsLevel::User,
]
}
pub fn name(&self) -> &'static str {
match self {
SettingsLevel::System => "system",
SettingsLevel::ProjectLocal => "project-local",
SettingsLevel::Project => "project",
SettingsLevel::User => "user",
}
}
}
impl Settings {
pub fn new() -> Self {
Self::default()
}
pub fn with_permissions(mut self, permissions: permission::PermissionSet) -> Self {
self.permissions = permissions;
self
}
pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
self.env = Some(env);
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn with_hooks(mut self, hooks: Hooks) -> Self {
self.hooks = Some(hooks);
self
}
pub fn with_sandbox(mut self, sandbox: Sandbox) -> Self {
self.sandbox = Some(sandbox);
self
}
pub fn with_attribution(mut self, attribution: Attribution) -> Self {
self.attribution = Some(attribution);
self
}
pub fn with_bypass_permissions(mut self, enabled: bool) -> Self {
self.bypass_permissions = Some(enabled);
self
}
pub fn is_empty(&self) -> bool {
self.permissions.is_empty()
&& self.env.is_none()
&& self.model.is_none()
&& self.hooks.is_none()
&& self.sandbox.is_none()
&& self.attribution.is_none()
&& self.enabled_plugins.is_none()
&& self.cleanup_period_days.is_none()
&& self.language.is_none()
&& self.bypass_permissions.is_none()
&& self.extra.is_empty()
}
const CLASH_INSTALLED_KEY: &'static str = "_clashInstalled";
pub fn is_clash_installed(&self) -> bool {
self.extra
.get(Self::CLASH_INSTALLED_KEY)
.is_some_and(|v| v.as_bool().unwrap_or(false))
}
pub fn mark_clash_installed(&mut self) {
self.extra.insert(
Self::CLASH_INSTALLED_KEY.to_string(),
serde_json::json!(true),
);
}
pub fn clear_clash_installed(&mut self) {
self.extra.remove(Self::CLASH_INSTALLED_KEY);
}
pub fn with_clash_installed(mut self) -> Self {
self.mark_clash_installed();
self
}
}
impl Permissions {
pub fn new() -> Self {
Self::default()
}
pub fn allow(mut self, pattern: impl Into<String>) -> Self {
self.allow.push(pattern.into());
self
}
pub fn ask(mut self, pattern: impl Into<String>) -> Self {
self.ask.push(pattern.into());
self
}
pub fn deny(mut self, pattern: impl Into<String>) -> Self {
self.deny.push(pattern.into());
self
}
pub fn is_empty(&self) -> bool {
self.allow.is_empty() && self.ask.is_empty() && self.deny.is_empty()
}
}
#[cfg(test)]
mod tests {
use crate::PermissionSet;
use super::*;
#[test]
fn test_settings_serialization() {
let settings = Settings::new()
.with_model("claude-opus-4-5-20251101")
.with_permissions(PermissionSet::new().allow("Bash(git:*)").deny("Read(.env)"));
let json = serde_json::to_string_pretty(&settings).unwrap();
let parsed: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(settings, parsed);
assert_eq!(parsed.model.unwrap(), "claude-opus-4-5-20251101");
}
#[test]
fn test_permissions_builder() {
let perms = Permissions::new()
.allow("Bash(git diff:*)")
.allow("Bash(npm run:*)")
.deny("Read(.env)")
.ask("Bash(rm:*)");
assert_eq!(perms.allow.len(), 2);
assert_eq!(perms.deny.len(), 1);
assert_eq!(perms.ask.len(), 1);
}
#[test]
fn test_settings_level_priority() {
let levels = SettingsLevel::all_by_priority();
assert_eq!(levels[0], SettingsLevel::System);
assert_eq!(levels[3], SettingsLevel::User);
}
#[test]
fn test_empty_settings() {
let settings = Settings::new();
assert!(settings.is_empty());
let settings_with_model = Settings::new().with_model("test");
assert!(!settings_with_model.is_empty());
}
#[test]
fn test_clash_installed_marker() {
let settings = Settings::new();
assert!(!settings.is_clash_installed());
let settings = Settings::new().with_clash_installed();
assert!(settings.is_clash_installed());
let mut settings = Settings::new();
settings.mark_clash_installed();
assert!(settings.is_clash_installed());
settings.clear_clash_installed();
assert!(!settings.is_clash_installed());
}
#[test]
fn test_clash_installed_serialization() {
let settings = Settings::new()
.with_model("test-model")
.with_clash_installed();
let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains("_clashInstalled"));
let parsed: Settings = serde_json::from_str(&json).unwrap();
assert!(parsed.is_clash_installed());
assert_eq!(parsed.model.as_deref(), Some("test-model"));
}
#[test]
fn test_bypass_permissions_builder() {
let settings = Settings::new().with_bypass_permissions(true);
assert_eq!(settings.bypass_permissions, Some(true));
assert!(!settings.is_empty());
}
#[test]
fn test_bypass_permissions_serialization() {
let settings = Settings::new().with_bypass_permissions(true);
let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains("\"bypassPermissions\":true"));
let parsed: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.bypass_permissions, Some(true));
}
#[test]
fn test_bypass_permissions_deserialization() {
let json = r#"{"bypassPermissions": true}"#;
let settings: Settings = serde_json::from_str(json).unwrap();
assert_eq!(settings.bypass_permissions, Some(true));
}
}