use crate::error::{CoreError, LoadError, LoadReport};
use crate::models::{HookDefinition, HookGroup, MergedConfig, Settings};
use crate::parsers::HooksParser;
use std::collections::HashMap;
use std::path::Path;
use tracing::{debug, warn};
pub struct SettingsParser;
impl Default for SettingsParser {
fn default() -> Self {
Self::new()
}
}
impl SettingsParser {
pub fn new() -> Self {
Self
}
pub async fn parse(&self, path: &Path) -> Result<Settings, CoreError> {
let content = tokio::fs::read_to_string(path).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
CoreError::FileNotFound {
path: path.to_path_buf(),
}
} else {
CoreError::FileRead {
path: path.to_path_buf(),
source: e,
}
}
})?;
serde_json::from_str(&content).map_err(|e| CoreError::JsonParse {
path: path.to_path_buf(),
message: e.to_string(),
source: e,
})
}
pub async fn parse_graceful(
&self,
path: &Path,
source_name: &str,
report: &mut LoadReport,
) -> Option<Settings> {
match self.parse(path).await {
Ok(settings) => {
debug!(?path, "Loaded settings");
Some(settings)
}
Err(CoreError::FileNotFound { .. }) => {
debug!(?path, "Settings file not found (optional)");
None
}
Err(e) => {
warn!(?path, error = %e, "Failed to parse settings");
report.add_error(LoadError::error(source_name, e.to_string()));
None
}
}
}
pub async fn load_merged(
&self,
claude_home: &Path,
project_path: Option<&Path>,
report: &mut LoadReport,
) -> MergedConfig {
let global_path = claude_home.join("settings.json");
let global = self
.parse_graceful(&global_path, "settings.global", report)
.await;
let global_local_path = claude_home.join("settings.local.json");
let global_local = self
.parse_graceful(&global_local_path, "settings.global_local", report)
.await;
let project = if let Some(proj) = project_path {
let project_path = proj.join(".claude").join("settings.json");
self.parse_graceful(&project_path, "settings.project", report)
.await
} else {
None
};
let project_local = if let Some(proj) = project_path {
let local_path = proj.join(".claude").join("settings.local.json");
self.parse_graceful(&local_path, "settings.project_local", report)
.await
} else {
None
};
if global.is_some()
|| global_local.is_some()
|| project.is_some()
|| project_local.is_some()
{
report.settings_loaded = true;
}
let mut global_with_hooks = global;
if let Some(ref mut settings) = global_with_hooks {
Self::inject_scanned_hooks(settings, claude_home, project_path);
} else {
let mut settings = Settings::default();
Self::inject_scanned_hooks(&mut settings, claude_home, project_path);
if settings.hooks.is_some() {
global_with_hooks = Some(settings);
}
}
MergedConfig::from_layers(global_with_hooks, global_local, project, project_local)
}
fn inject_scanned_hooks(
settings: &mut Settings,
claude_home: &Path,
project_path: Option<&Path>,
) {
let mut all_scanned_hooks: Vec<crate::parsers::Hook> = Vec::new();
let global_hooks_dir = claude_home.join("hooks");
if let Ok(hooks) = HooksParser::scan_directory(&global_hooks_dir) {
all_scanned_hooks.extend(hooks);
}
if let Some(proj) = project_path {
let project_hooks_dir = proj.join(".claude").join("hooks");
if let Ok(hooks) = HooksParser::scan_directory(&project_hooks_dir) {
all_scanned_hooks.extend(hooks);
}
}
if all_scanned_hooks.is_empty() {
return;
}
let mut hooks_by_event: HashMap<String, Vec<HookGroup>> =
settings.hooks.clone().unwrap_or_default();
for hook in all_scanned_hooks {
let event_name = match hook.hook_type {
crate::parsers::HookType::PreCommit => "PreCommit",
crate::parsers::HookType::PostCommit => "PostCommit",
crate::parsers::HookType::PrePush => "PrePush",
crate::parsers::HookType::UserPromptSubmit => "UserPromptSubmit",
crate::parsers::HookType::ToolResultReturn => "ToolResultReturn",
crate::parsers::HookType::Custom(ref name) => {
if name.contains("pre") || name.contains("before") {
"PreToolUse"
} else {
"Custom"
}
}
};
let hook_def = HookDefinition {
command: hook.path.display().to_string(),
r#async: None,
timeout: None,
cwd: None,
env: None,
file_path: Some(hook.path.clone()),
};
let groups = hooks_by_event.entry(event_name.to_string()).or_default();
if let Some(first_group) = groups.first_mut() {
first_group.hooks.push(hook_def);
} else {
groups.push(HookGroup {
matcher: None,
hooks: vec![hook_def],
});
}
}
settings.hooks = Some(hooks_by_event);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::{tempdir, NamedTempFile};
#[tokio::test]
async fn test_parse_valid_settings() {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
r#"{{
"model": "claude-sonnet-4-20250514",
"permissions": {{
"allow": ["Read", "Write"],
"autoApprove": true
}}
}}"#
)
.unwrap();
let parser = SettingsParser::new();
let settings = parser.parse(file.path()).await.unwrap();
assert_eq!(settings.model, Some("claude-sonnet-4-20250514".to_string()));
let perms = settings.permissions.unwrap();
assert_eq!(
perms.allow,
Some(vec!["Read".to_string(), "Write".to_string()])
);
assert_eq!(perms.auto_approve, Some(true));
}
#[tokio::test]
async fn test_parse_missing_file_graceful() {
let parser = SettingsParser::new();
let mut report = LoadReport::new();
let result = parser
.parse_graceful(Path::new("/nonexistent/settings.json"), "test", &mut report)
.await;
assert!(result.is_none());
assert!(!report.has_errors() || report.warnings().count() == 0);
}
#[tokio::test]
async fn test_load_merged_hierarchy() {
let dir = tempdir().unwrap();
let claude_home = dir.path().join(".claude");
let project = dir.path().join("myproject");
let project_claude = project.join(".claude");
std::fs::create_dir_all(&claude_home).unwrap();
std::fs::create_dir_all(&project_claude).unwrap();
std::fs::write(
claude_home.join("settings.json"),
r#"{"model": "opus", "theme": "dark"}"#,
)
.unwrap();
std::fs::write(
project_claude.join("settings.json"),
r#"{"model": "sonnet"}"#,
)
.unwrap();
let parser = SettingsParser::new();
let mut report = LoadReport::new();
let merged = parser
.load_merged(&claude_home, Some(&project), &mut report)
.await;
assert!(report.settings_loaded);
assert_eq!(merged.merged.model, Some("sonnet".to_string()));
assert_eq!(merged.merged.theme, Some("dark".to_string()));
}
}