use anyhow::{anyhow, Context, Result};
use claco::claude::{
load_settings, project_local_settings_path, project_settings_path, save_settings,
user_settings_path, Settings,
};
use claco::cli::{Scope, SettingsSubcommand};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
fn format_json_error(err: &serde_json::Error, content: &str) -> String {
let line_num = err.line();
let col_num = err.column();
if line_num > 0 {
let lines: Vec<&str> = content.lines().collect();
let mut error_msg =
format!("JSON parsing error at line {line_num}, column {col_num}: {err}");
if line_num <= lines.len() {
error_msg.push_str("\n\n");
if line_num > 1 {
error_msg.push_str(&format!("{:4}: {}\n", line_num - 1, lines[line_num - 2]));
}
error_msg.push_str(&format!("{:4}: {}\n", line_num, lines[line_num - 1]));
if col_num > 0 {
error_msg.push_str(&format!(" {}^\n", " ".repeat(col_num - 1)));
}
if line_num < lines.len() {
error_msg.push_str(&format!("{:4}: {}\n", line_num + 1, lines[line_num]));
}
}
error_msg
} else {
format!("JSON parsing error: {err}")
}
}
pub async fn handle_settings(cmd: SettingsSubcommand) -> Result<()> {
match cmd {
SettingsSubcommand::Apply {
source,
scope,
overwrite,
} => apply_settings(&source, scope, overwrite).await,
}
}
async fn apply_settings(source: &str, scope: Scope, overwrite: bool) -> Result<()> {
let source_settings = load_source_settings(source).await?;
let target_path = match scope {
Scope::User => user_settings_path()?,
Scope::Project => project_settings_path(),
Scope::ProjectLocal => project_local_settings_path(),
};
let mut target_settings = load_settings(&target_path)?;
merge_settings(&mut target_settings, source_settings, overwrite)?;
save_settings(&target_path, &target_settings)?;
println!(
"Successfully applied settings to {} scope",
match scope {
Scope::User => "user",
Scope::Project => "project",
Scope::ProjectLocal => "project.local",
}
);
Ok(())
}
async fn load_source_settings(source: &str) -> Result<Settings> {
if source.starts_with("https://github.com/") {
load_from_github_url(source).await
} else {
load_from_local_file(source)
}
}
async fn load_from_github_url(url: &str) -> Result<Settings> {
let raw_url = convert_to_raw_github_url(url)?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
let response = client
.get(&raw_url)
.send()
.await
.with_context(|| format!("Failed to fetch settings from GitHub URL: {url}"))?;
if !response.status().is_success() {
return Err(anyhow!(
"Failed to fetch settings from GitHub: {}",
response.status()
));
}
let content = response
.text()
.await
.context("Failed to read response body")?;
match serde_json::from_str::<Settings>(&content) {
Ok(settings) => Ok(settings),
Err(e) => {
let error_msg = format_json_error(&e, &content);
Err(anyhow!(
"Failed to parse settings JSON from GitHub URL: {url}\n{error_msg}"
))
}
}
}
fn convert_to_raw_github_url(url: &str) -> Result<String> {
if !url.starts_with("https://github.com/") {
return Err(anyhow!("Invalid GitHub URL format"));
}
let parts: Vec<&str> = url
.trim_start_matches("https://github.com/")
.split('/')
.collect();
if parts.len() < 5 || parts[2] != "blob" {
return Err(anyhow!(
"Invalid GitHub URL format. Expected: https://github.com/owner/repo/blob/branch/path"
));
}
let owner = parts[0];
let repo = parts[1];
let branch = parts[3];
let path = parts[4..].join("/");
if path.is_empty() {
return Err(anyhow!("Invalid GitHub URL: empty file path"));
}
Ok(format!(
"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}"
))
}
fn load_from_local_file(path: &str) -> Result<Settings> {
let path = PathBuf::from(path);
let canonical_path = path
.canonicalize()
.with_context(|| format!("Failed to resolve path: {}", path.display()))?;
let content = fs::read_to_string(&canonical_path)
.with_context(|| format!("Failed to read settings file: {}", canonical_path.display()))?;
match serde_json::from_str::<Settings>(&content) {
Ok(settings) => Ok(settings),
Err(e) => {
let error_msg = format_json_error(&e, &content);
Err(anyhow!(
"Failed to parse settings JSON from file: {}\n{}",
canonical_path.display(),
error_msg
))
}
}
}
fn merge_settings(target: &mut Settings, source: Settings, overwrite: bool) -> Result<()> {
if !overwrite {
check_for_conflicts(target, &source)?;
}
if let Some(source_hooks) = source.hooks {
if target.hooks.is_none() {
target.hooks = Some(HashMap::new());
}
let target_hooks = target.hooks.as_mut().unwrap();
for (event, matchers) in source_hooks {
if overwrite {
target_hooks.insert(event, matchers);
} else {
target_hooks.entry(event).or_default().extend(matchers);
}
}
}
for (key, value) in source.other {
if overwrite || !target.other.contains_key(&key) {
target.other.insert(key, value);
}
}
Ok(())
}
fn check_for_conflicts(target: &Settings, source: &Settings) -> Result<()> {
let mut conflicts = Vec::new();
if let (Some(target_hooks), Some(source_hooks)) = (&target.hooks, &source.hooks) {
for event in source_hooks.keys() {
if target_hooks.contains_key(event) {
conflicts.push(format!("$.hooks.{event}"));
}
}
}
for key in source.other.keys() {
if target.other.contains_key(key) {
let jsonpath = format!("$.{key}");
conflicts.push(jsonpath);
}
}
if !conflicts.is_empty() {
return Err(anyhow!(
"Conflicts detected at the following paths:\n{}\n\nUse --overwrite to replace existing settings",
conflicts.join("\n")
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_convert_to_raw_github_url() {
let url =
"https://github.com/kaichen/dot-claude/blob/main/.claude/settings.permissions.json";
let raw_url = convert_to_raw_github_url(url).unwrap();
assert_eq!(raw_url, "https://raw.githubusercontent.com/kaichen/dot-claude/main/.claude/settings.permissions.json");
}
#[test]
fn test_invalid_github_url() {
let url = "https://github.com/invalid/url";
assert!(convert_to_raw_github_url(url).is_err());
}
#[test]
fn test_github_url_without_file_path() {
let url = "https://github.com/owner/repo/blob/main";
assert!(convert_to_raw_github_url(url).is_err());
}
#[test]
fn test_github_url_with_query_params() {
let url = "https://github.com/owner/repo/blob/main/file.json?ref=feature";
let result = convert_to_raw_github_url(url).unwrap();
assert_eq!(
result,
"https://raw.githubusercontent.com/owner/repo/main/file.json?ref=feature"
);
}
}