use serde::Deserialize;
pub const VALID_HOOK_EVENTS: &[&str] = &[
"SessionStart",
"SessionEnd",
"BeforeAgent",
"AfterAgent",
"BeforeModel",
"AfterModel",
"BeforeToolSelection",
"BeforeTool",
"AfterTool",
"PreCompress",
"Notification",
];
pub const VALID_TOP_LEVEL_KEYS: &[&str] = &[
"general",
"output",
"ui",
"ide",
"model",
"context",
"tools",
"security",
"advanced",
"experimental",
"skills",
"hooksConfig",
];
#[derive(Debug, Clone)]
pub struct ParseError {
pub message: String,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone)]
pub struct ParsedGeminiSettings {
pub parse_error: Option<ParseError>,
pub schema: Option<GeminiSettingsSchema>,
pub unknown_top_keys: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct GeminiSettingsSchema {
#[serde(rename = "hooksConfig")]
pub hooks_config: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GeminiHook {
#[serde(rename = "type")]
pub type_: Option<String>,
pub command: Option<String>,
#[allow(dead_code)] pub name: Option<String>,
#[allow(dead_code)] pub timeout: Option<serde_json::Value>,
#[allow(dead_code)] pub description: Option<String>,
}
fn strip_jsonc_comments(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let chars: Vec<char> = input.chars().collect();
let len = chars.len();
let mut i = 0;
let mut in_string = false;
while i < len {
if in_string {
result.push(chars[i]);
if chars[i] == '\\' && i + 1 < len {
i += 1;
result.push(chars[i]);
} else if chars[i] == '"' {
in_string = false;
}
i += 1;
continue;
}
if chars[i] == '"' {
in_string = true;
result.push(chars[i]);
i += 1;
continue;
}
if chars[i] == '/' && i + 1 < len {
if chars[i + 1] == '/' {
i += 2;
while i < len && chars[i] != '\n' {
i += 1;
}
continue;
} else if chars[i + 1] == '*' {
i += 2;
while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') {
if chars[i] == '\n' {
result.push('\n');
}
i += 1;
}
if i + 1 < len {
i += 2; }
continue;
}
}
result.push(chars[i]);
i += 1;
}
result
}
pub fn parse_gemini_settings(content: &str) -> ParsedGeminiSettings {
let stripped = strip_jsonc_comments(content);
let value: serde_json::Value = match serde_json::from_str(&stripped) {
Ok(v) => v,
Err(e) => {
return ParsedGeminiSettings {
parse_error: Some(ParseError {
message: e.to_string(),
line: e.line(),
column: e.column(),
}),
schema: None,
unknown_top_keys: Vec::new(),
};
}
};
let unknown_top_keys = if let Some(obj) = value.as_object() {
obj.keys()
.filter(|k| !VALID_TOP_LEVEL_KEYS.contains(&k.as_str()))
.cloned()
.collect()
} else {
Vec::new()
};
let hooks_config = value.get("hooksConfig").cloned();
ParsedGeminiSettings {
parse_error: None,
schema: Some(GeminiSettingsSchema { hooks_config }),
unknown_top_keys,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_settings() {
let content = r#"{
"general": {},
"model": {},
"hooksConfig": {
"BeforeAgent": [
{
"type": "command",
"command": "echo hello"
}
]
}
}"#;
let result = parse_gemini_settings(content);
assert!(result.parse_error.is_none());
assert!(result.schema.is_some());
assert!(result.unknown_top_keys.is_empty());
let schema = result.schema.unwrap();
assert!(schema.hooks_config.is_some());
}
#[test]
fn test_parse_empty_object() {
let result = parse_gemini_settings("{}");
assert!(result.parse_error.is_none());
assert!(result.schema.is_some());
assert!(result.unknown_top_keys.is_empty());
}
#[test]
fn test_parse_invalid_json() {
let result = parse_gemini_settings("{ invalid }");
assert!(result.parse_error.is_some());
assert!(result.schema.is_none());
}
#[test]
fn test_parse_jsonc_comments() {
let content = r#"{
// This is a comment
"general": {},
/* multi-line
comment */
"model": {}
}"#;
let result = parse_gemini_settings(content);
assert!(result.parse_error.is_none());
assert!(result.schema.is_some());
}
#[test]
fn test_unknown_top_level_keys() {
let content = r#"{
"general": {},
"unknownKey": true,
"anotherBadKey": 42
}"#;
let result = parse_gemini_settings(content);
assert!(result.parse_error.is_none());
assert_eq!(result.unknown_top_keys.len(), 2);
assert!(result.unknown_top_keys.contains(&"unknownKey".to_string()));
assert!(
result
.unknown_top_keys
.contains(&"anotherBadKey".to_string())
);
}
#[test]
fn test_all_valid_top_level_keys() {
let content = r#"{
"general": {},
"output": {},
"ui": {},
"ide": {},
"model": {},
"context": {},
"tools": {},
"security": {},
"advanced": {},
"experimental": {},
"skills": {},
"hooksConfig": {}
}"#;
let result = parse_gemini_settings(content);
assert!(result.parse_error.is_none());
assert!(result.unknown_top_keys.is_empty());
}
#[test]
fn test_hooks_config_extracted() {
let content = r#"{
"hooksConfig": {
"SessionStart": [
{"type": "command", "command": "echo start"}
]
}
}"#;
let result = parse_gemini_settings(content);
let schema = result.schema.unwrap();
assert!(schema.hooks_config.is_some());
let hooks = schema.hooks_config.unwrap();
assert!(hooks.get("SessionStart").is_some());
}
#[test]
fn test_parse_error_location() {
let content = "{\n \"general\": \n}";
let result = parse_gemini_settings(content);
assert!(result.parse_error.is_some());
let err = result.parse_error.unwrap();
assert!(err.line > 0);
}
#[test]
fn test_valid_hook_events_count() {
assert_eq!(VALID_HOOK_EVENTS.len(), 11);
}
#[test]
fn test_valid_top_level_keys_count() {
assert_eq!(VALID_TOP_LEVEL_KEYS.len(), 12);
}
}