pub const REQUIRED_FIELDS: &[&str] = &["name", "version", "description"];
pub const KNOWN_OPTIONAL_KEYS: &[&str] =
&["mcpServers", "contextFileName", "excludeTools", "settings"];
#[derive(Debug, Clone)]
pub struct ParseError {
pub message: String,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone)]
pub struct ParsedGeminiExtension {
pub parse_error: Option<ParseError>,
pub schema: Option<GeminiExtensionSchema>,
#[allow(dead_code)] pub unknown_keys: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct GeminiExtensionSchema {
pub name: Option<String>,
pub version: Option<String>,
pub description: Option<String>,
pub context_file_name: Option<String>,
}
pub fn is_valid_extension_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
pub fn parse_gemini_extension(content: &str) -> ParsedGeminiExtension {
let value: serde_json::Value = match serde_json::from_str(content) {
Ok(v) => v,
Err(e) => {
return ParsedGeminiExtension {
parse_error: Some(ParseError {
message: e.to_string(),
line: e.line(),
column: e.column(),
}),
schema: None,
unknown_keys: Vec::new(),
};
}
};
let obj = match value.as_object() {
Some(o) => o,
None => {
return ParsedGeminiExtension {
parse_error: Some(ParseError {
message: "Expected a JSON object".to_string(),
line: 1,
column: 1,
}),
schema: None,
unknown_keys: Vec::new(),
};
}
};
let all_known: Vec<&str> = REQUIRED_FIELDS
.iter()
.chain(KNOWN_OPTIONAL_KEYS.iter())
.copied()
.collect();
let unknown_keys: Vec<String> = obj
.keys()
.filter(|k| !all_known.contains(&k.as_str()))
.cloned()
.collect();
let name = obj
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let version = obj
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = obj
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let context_file_name = obj
.get("contextFileName")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
ParsedGeminiExtension {
parse_error: None,
schema: Some(GeminiExtensionSchema {
name,
version,
description,
context_file_name,
}),
unknown_keys,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_extension() {
let content = r#"{
"name": "my-extension",
"version": "1.0.0",
"description": "A test extension"
}"#;
let result = parse_gemini_extension(content);
assert!(result.parse_error.is_none());
assert!(result.schema.is_some());
assert!(result.unknown_keys.is_empty());
let schema = result.schema.unwrap();
assert_eq!(schema.name, Some("my-extension".to_string()));
assert_eq!(schema.version, Some("1.0.0".to_string()));
assert_eq!(schema.description, Some("A test extension".to_string()));
}
#[test]
fn test_parse_with_optional_fields() {
let content = r#"{
"name": "ext",
"version": "0.1.0",
"description": "Test",
"contextFileName": "CONTEXT.md",
"mcpServers": {},
"excludeTools": [],
"settings": {}
}"#;
let result = parse_gemini_extension(content);
assert!(result.parse_error.is_none());
assert!(result.unknown_keys.is_empty());
let schema = result.schema.unwrap();
assert_eq!(schema.context_file_name, Some("CONTEXT.md".to_string()));
}
#[test]
fn test_parse_invalid_json() {
let result = parse_gemini_extension("{ invalid }");
assert!(result.parse_error.is_some());
assert!(result.schema.is_none());
}
#[test]
fn test_parse_empty_object() {
let result = parse_gemini_extension("{}");
assert!(result.parse_error.is_none());
assert!(result.schema.is_some());
let schema = result.schema.unwrap();
assert!(schema.name.is_none());
assert!(schema.version.is_none());
assert!(schema.description.is_none());
}
#[test]
fn test_unknown_keys_detected() {
let content = r#"{
"name": "ext",
"version": "1.0.0",
"description": "Test",
"unknownField": true
}"#;
let result = parse_gemini_extension(content);
assert!(result.parse_error.is_none());
assert_eq!(result.unknown_keys.len(), 1);
assert_eq!(result.unknown_keys[0], "unknownField");
}
#[test]
fn test_is_valid_extension_name_valid() {
assert!(is_valid_extension_name("my-extension"));
assert!(is_valid_extension_name("ext"));
assert!(is_valid_extension_name("ext123"));
assert!(is_valid_extension_name("a"));
assert!(is_valid_extension_name("0ext"));
}
#[test]
fn test_is_valid_extension_name_invalid() {
assert!(!is_valid_extension_name(""));
assert!(!is_valid_extension_name("My-Extension")); assert!(!is_valid_extension_name("-ext")); assert!(!is_valid_extension_name("ext_name")); assert!(!is_valid_extension_name("ext name")); assert!(!is_valid_extension_name("EXT")); }
#[test]
fn test_parse_error_location() {
let content = "{\n \"name\": \n}";
let result = parse_gemini_extension(content);
assert!(result.parse_error.is_some());
let err = result.parse_error.unwrap();
assert!(err.line > 0);
}
#[test]
fn test_non_string_fields_ignored() {
let content = r#"{"name": 42, "version": "1.0.0", "description": "Test"}"#;
let result = parse_gemini_extension(content);
assert!(result.parse_error.is_none());
let schema = result.schema.unwrap();
assert!(schema.name.is_none());
}
}