use std::collections::HashMap;
use std::path::Path;
use figment::Figment;
use figment::providers::{Format, Json, Serialized};
use tracing::{Level, instrument};
use crate::error::Result;
use crate::paths::PathResolver;
use crate::types::{Attribution, Hooks, Permissions, Sandbox, Settings, SettingsLevel};
pub trait Merge {
fn merge(&self, other: &Self) -> Self;
}
impl Merge for Settings {
fn merge(&self, other: &Self) -> Self {
Settings {
permissions: self.permissions.merge(&other.permissions),
env: merge_option_map(&self.env, &other.env),
model: other.model.clone().or_else(|| self.model.clone()),
hooks: merge_option(&self.hooks, &other.hooks),
sandbox: merge_option(&self.sandbox, &other.sandbox),
attribution: merge_option(&self.attribution, &other.attribution),
enabled_plugins: merge_option_map(&self.enabled_plugins, &other.enabled_plugins),
cleanup_period_days: other.cleanup_period_days.or(self.cleanup_period_days),
language: other.language.clone().or_else(|| self.language.clone()),
bypass_permissions: other.bypass_permissions.or(self.bypass_permissions),
extra: merge_maps(&self.extra, &other.extra),
}
}
}
impl Merge for Permissions {
fn merge(&self, other: &Self) -> Self {
Permissions {
allow: merge_vecs(&self.allow, &other.allow),
ask: merge_vecs(&self.ask, &other.ask),
deny: merge_vecs(&self.deny, &other.deny),
}
}
}
impl Merge for Hooks {
fn merge(&self, other: &Self) -> Self {
Hooks {
pre_tool_use: other
.pre_tool_use
.clone()
.or_else(|| self.pre_tool_use.clone()),
post_tool_use: other
.post_tool_use
.clone()
.or_else(|| self.post_tool_use.clone()),
stop: other.stop.clone().or_else(|| self.stop.clone()),
notification: other
.notification
.clone()
.or_else(|| self.notification.clone()),
}
}
}
impl Merge for Sandbox {
fn merge(&self, other: &Self) -> Self {
Sandbox {
enabled: other.enabled.or(self.enabled),
auto_allow_bash_if_sandboxed: other
.auto_allow_bash_if_sandboxed
.or(self.auto_allow_bash_if_sandboxed),
excluded_commands: other
.excluded_commands
.clone()
.or_else(|| self.excluded_commands.clone()),
}
}
}
impl Merge for Attribution {
fn merge(&self, other: &Self) -> Self {
Attribution {
commit: other.commit.clone().or_else(|| self.commit.clone()),
pr: other.pr.clone().or_else(|| self.pr.clone()),
}
}
}
fn merge_option<T: Merge + Clone>(lower: &Option<T>, higher: &Option<T>) -> Option<T> {
match (lower, higher) {
(Some(l), Some(h)) => Some(l.merge(h)),
(Some(l), None) => Some(l.clone()),
(None, Some(h)) => Some(h.clone()),
(None, None) => None,
}
}
fn merge_option_map<K, V>(
lower: &Option<HashMap<K, V>>,
higher: &Option<HashMap<K, V>>,
) -> Option<HashMap<K, V>>
where
K: Eq + std::hash::Hash + Clone,
V: Clone,
{
match (lower, higher) {
(Some(l), Some(h)) => Some(merge_maps(l, h)),
(Some(l), None) => Some(l.clone()),
(None, Some(h)) => Some(h.clone()),
(None, None) => None,
}
}
fn merge_maps<K, V>(lower: &HashMap<K, V>, higher: &HashMap<K, V>) -> HashMap<K, V>
where
K: Eq + std::hash::Hash + Clone,
V: Clone,
{
let mut result = lower.clone();
for (k, v) in higher {
result.insert(k.clone(), v.clone());
}
result
}
fn merge_vecs<T: Clone + PartialEq>(lower: &[T], higher: &[T]) -> Vec<T> {
let mut result = higher.to_vec();
for item in lower {
if !result.contains(item) {
result.push(item.clone());
}
}
result
}
#[instrument(level = Level::TRACE)]
pub fn merge_all(settings: &[(SettingsLevel, Settings)]) -> Settings {
if settings.is_empty() {
return Settings::default();
}
let mut iter = settings.iter().rev();
let (_, first) = iter.next().unwrap();
let mut result = first.clone();
for (_, higher) in iter {
result = result.merge(higher);
}
result
}
#[derive(Debug, Default)]
pub struct SettingsMerger {
settings: Vec<(SettingsLevel, Settings)>,
}
impl SettingsMerger {
#[instrument(level = Level::TRACE)]
pub fn new() -> Self {
Self::default()
}
#[instrument(level = Level::TRACE, skip(self))]
pub fn add(mut self, level: SettingsLevel, settings: Settings) -> Self {
self.settings.push((level, settings));
self
}
#[instrument(level = Level::TRACE, skip(self))]
pub fn add_optional(self, level: SettingsLevel, settings: Option<Settings>) -> Self {
match settings {
Some(s) => self.add(level, s),
None => self,
}
}
#[instrument(level = Level::TRACE, skip(self))]
pub fn merge(mut self) -> Settings {
self.settings.sort_by_key(|(level, _)| match level {
SettingsLevel::System => 0,
SettingsLevel::ProjectLocal => 1,
SettingsLevel::Project => 2,
SettingsLevel::User => 3,
});
merge_all(&self.settings)
}
}
#[derive(Debug, Clone)]
pub struct FigmentLoader {
resolver: PathResolver,
}
impl FigmentLoader {
#[instrument(level = Level::TRACE)]
pub fn new(resolver: PathResolver) -> Self {
Self { resolver }
}
#[instrument(level = Level::TRACE)]
pub fn with_defaults() -> Self {
Self::new(PathResolver::new())
}
#[instrument(level = Level::TRACE, skip(self))]
pub fn figment(&self) -> Result<Figment> {
let mut figment = Figment::from(Serialized::defaults(Settings::default()));
if let Ok(path) = self.resolver.settings_path(SettingsLevel::User)
&& path.exists()
{
figment = figment.merge(Json::file(&path));
}
if let Ok(path) = self.resolver.settings_path(SettingsLevel::Project)
&& path.exists()
{
figment = figment.merge(Json::file(&path));
}
if let Ok(path) = self.resolver.settings_path(SettingsLevel::ProjectLocal)
&& path.exists()
{
figment = figment.merge(Json::file(&path));
}
if let Ok(path) = self.resolver.settings_path(SettingsLevel::System)
&& path.exists()
{
figment = figment.merge(Json::file(&path));
}
Ok(figment)
}
#[instrument(level = Level::TRACE, skip(self))]
pub fn load(&self) -> Result<Settings> {
let figment = self.figment()?;
Ok(figment.extract()?)
}
#[instrument(level = Level::TRACE, skip(self))]
pub fn load_with_file(&self, path: &Path) -> Result<Settings> {
let mut figment = self.figment()?;
figment = figment.merge(Json::file(path));
Ok(figment.extract()?)
}
#[instrument(level = Level::TRACE, skip(self))]
pub fn load_with_overrides(&self, overrides: Settings) -> Result<Settings> {
let mut figment = self.figment()?;
figment = figment.merge(Serialized::defaults(overrides));
Ok(figment.extract()?)
}
}
impl Default for FigmentLoader {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod tests {
use crate::PermissionSet;
use super::*;
#[test]
fn test_merge_settings_model() {
let user = Settings::new().with_model("user-model");
let project = Settings::new().with_model("project-model");
let merged = user.merge(&project);
assert_eq!(merged.model.unwrap(), "project-model");
}
#[test]
fn test_merge_settings_model_fallback() {
let user = Settings::new().with_model("user-model");
let project = Settings::new();
let merged = user.merge(&project);
assert_eq!(merged.model.unwrap(), "user-model");
}
#[test]
fn test_merge_permissions() {
let user_perms = Permissions::new().allow("Bash(git:*)").deny("Read(.env)");
let project_perms = Permissions::new().allow("Bash(npm:*)").allow("Bash(git:*)");
let merged = user_perms.merge(&project_perms);
assert_eq!(merged.allow.len(), 2);
assert!(merged.allow.contains(&"Bash(git:*)".to_string()));
assert!(merged.allow.contains(&"Bash(npm:*)".to_string()));
assert_eq!(merged.deny.len(), 1);
}
#[test]
fn test_merge_env_maps() {
let mut user_env = HashMap::new();
user_env.insert("KEY1".to_string(), "user-value1".to_string());
user_env.insert("KEY2".to_string(), "user-value2".to_string());
let mut project_env = HashMap::new();
project_env.insert("KEY1".to_string(), "project-value1".to_string());
project_env.insert("KEY3".to_string(), "project-value3".to_string());
let user = Settings::new().with_env(user_env);
let project = Settings::new().with_env(project_env);
let merged = user.merge(&project);
let env = merged.env.unwrap();
assert_eq!(env.get("KEY1").unwrap(), "project-value1");
assert_eq!(env.get("KEY2").unwrap(), "user-value2");
assert_eq!(env.get("KEY3").unwrap(), "project-value3");
}
#[test]
fn test_merge_all() {
let user = Settings::new().with_model("user-model");
let project = Settings::new().with_model("project-model");
let project_local = Settings::new();
let settings = vec![
(SettingsLevel::ProjectLocal, project_local),
(SettingsLevel::Project, project),
(SettingsLevel::User, user),
];
let merged = merge_all(&settings);
assert_eq!(merged.model.unwrap(), "project-model");
}
#[test]
fn test_settings_merger() {
let user = Settings::new()
.with_model("user-model")
.with_permissions(PermissionSet::new().allow("Bash(git:*)"));
let project = Settings::new().with_permissions(PermissionSet::new().deny("Read(.env)"));
let merged = SettingsMerger::new()
.add(SettingsLevel::User, user)
.add(SettingsLevel::Project, project)
.merge();
assert_eq!(merged.model.unwrap(), "user-model");
let perms = merged.permissions;
assert!(perms.is_allowed("Bash", Some("git status")));
assert!(perms.is_denied("Read", Some(".env")));
}
#[test]
fn test_settings_merger_precedence() {
let user = Settings::new().with_model("user-model");
let project = Settings::new().with_model("project-model");
let project_local = Settings::new().with_model("local-model");
let merged = SettingsMerger::new()
.add(SettingsLevel::User, user)
.add(SettingsLevel::ProjectLocal, project_local)
.add(SettingsLevel::Project, project)
.merge();
assert_eq!(merged.model.unwrap(), "local-model");
}
#[test]
fn test_figment_loader_with_overrides() {
let resolver = PathResolver::new()
.with_home("/nonexistent/home")
.with_project("/nonexistent/project");
let loader = FigmentLoader::new(resolver);
let overrides = Settings::new().with_model("override-model");
let settings = loader.load_with_overrides(overrides).unwrap();
assert_eq!(settings.model.unwrap(), "override-model");
}
#[test]
fn test_figment_loader_empty() {
let resolver = PathResolver::new()
.with_home("/nonexistent/home")
.with_project("/nonexistent/project");
let loader = FigmentLoader::new(resolver);
let settings = loader.load().unwrap();
assert!(settings.model.is_none());
assert!(settings.permissions.is_empty());
}
}