diff --git a/.changeset/feat-kebab-case-aliases.md b/.changeset/feat-kebab-case-aliases.md new file mode 100644 index 00000000..2d4b2697 --- /dev/null +++ b/.changeset/feat-kebab-case-aliases.md @@ -0,0 +1,26 @@ +--- +"@googleworkspace/cli": minor +--- + +feat: support kebab-case aliases for camelCase subcommands (closes #32) + +All API resource and method names that use camelCase are now also +accessible via their kebab-case equivalents. Both forms are always +accepted, maintaining full backward compatibility. + +**Examples** + +Before (only form that worked): +``` +gws gmail users getProfile --params '{"userId":"me"}' +gws calendar calendarList list +``` + +After (both forms work): +``` +gws gmail users getProfile --params '{"userId":"me"}' # still works +gws gmail users get-profile --params '{"userId":"me"}' # new alias + +gws calendar calendarList list # still works +gws calendar calendar-list list # new alias +``` diff --git a/README.md b/README.md index c68dea3a..255fee88 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ cargo install --path . **For AI agents** — every response is structured JSON. Pair it with the included agent skills and your LLM can manage Workspace without custom tooling. +> **Tip:** Resource and method names mirror the underlying API (camelCase), but kebab-case equivalents are accepted as aliases. +> `gws gmail users getProfile` and `gws gmail users get-profile` are identical. +> `gws calendar calendarList list` and `gws calendar calendar-list list` both work. + ```bash # List the 10 most recent files gws drive files list --params '{"pageSize": 10}' diff --git a/src/commands.rs b/src/commands.rs index c40b707e..bfedb272 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -16,6 +16,56 @@ use clap::{Arg, Command}; use crate::discovery::{RestDescription, RestResource}; +/// Converts a camelCase string to kebab-case. +/// +/// For example: `getProfile` → `get-profile`, `calendarList` → `calendar-list`. +/// If the name is already lowercase (no uppercase letters), the original string +/// is returned unchanged so we don't register a no-op alias. +pub fn camel_to_kebab(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for (i, ch) in s.chars().enumerate() { + if ch.is_uppercase() && i > 0 { + result.push('-'); + } + result.push(ch.to_ascii_lowercase()); + } + result +} + +/// Converts a kebab-case string to camelCase. +/// +/// For example: `get-profile` → `getProfile`, `calendar-list` → `calendarList`. +pub fn kebab_to_camel(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut capitalize_next = false; + for ch in s.chars() { + if ch == '-' { + capitalize_next = true; + } else if capitalize_next { + result.push(ch.to_ascii_uppercase()); + capitalize_next = false; + } else { + result.push(ch); + } + } + result +} + +/// Resolves a map key given either its exact name or a kebab-case spelling. +/// +/// Clap normally returns the canonical command name when an alias matches, +/// so this helper is used as a defense-in-depth measure inside +/// `resolve_method_from_matches`. +pub fn resolve_name<'a>( + mut map_keys: impl Iterator, + input: &str, +) -> Option { + let camel = kebab_to_camel(input); + map_keys + .find(|k| k.as_str() == input || k.as_str() == camel) + .cloned() +} + /// Builds the full CLI command tree from a Discovery Document. pub fn build_cli(doc: &RestDescription) -> Command { let about_text = doc @@ -71,13 +121,26 @@ pub fn build_cli(doc: &RestDescription) -> Command { } /// Recursively builds a Command for a resource. +/// +/// Every command whose name contains uppercase letters gets a visible +/// kebab-case alias registered automatically. For example, the resource +/// `calendarList` also accepts `calendar-list`, and the method `getProfile` +/// also accepts `get-profile`. Existing camelCase names continue to work +/// unchanged. +/// /// Returns None if the resource has no methods or sub-resources. fn build_resource_command(name: &str, resource: &RestResource) -> Option { + let kebab = camel_to_kebab(name); let mut cmd = Command::new(name.to_string()) .about(format!("Operations on the '{name}' resource")) .subcommand_required(true) .arg_required_else_help(true); + // Register visible kebab-case alias only when the name actually differs. + if kebab != name { + cmd = cmd.visible_alias(kebab); + } + let mut has_children = false; // Add method subcommands @@ -97,6 +160,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option(); + let method_kebab = camel_to_kebab(method_name); + let mut method_cmd = Command::new(method_name.to_string()) .about(about) .arg( @@ -113,6 +178,11 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option = vec!["getProfile".to_string(), "list".to_string()]; + assert_eq!( + resolve_name(keys.iter(), "getProfile"), + Some("getProfile".to_string()) + ); + } + + #[test] + fn test_resolve_name_kebab() { + let keys: Vec = vec!["getProfile".to_string(), "list".to_string()]; + assert_eq!( + resolve_name(keys.iter(), "get-profile"), + Some("getProfile".to_string()) + ); + } + + #[test] + fn test_resolve_name_not_found() { + let keys: Vec = vec!["getProfile".to_string()]; + assert_eq!(resolve_name(keys.iter(), "nonexistent"), None); + } + + // ── Gmail users getProfile alias tests ─────────────────────────────────── + + fn make_gmail_doc() -> RestDescription { + let mut users_methods = HashMap::new(); + users_methods.insert( + "getProfile".to_string(), + RestMethod { + id: Some("gmail.users.getProfile".to_string()), + description: Some("Gets the current user's Gmail profile.".to_string()), + http_method: "GET".to_string(), + path: "gmail/v1/users/{userId}/profile".to_string(), + parameters: HashMap::new(), + parameter_order: vec![], + request: None, + response: None, + scopes: vec!["https://www.googleapis.com/auth/gmail.readonly".to_string()], + flat_path: None, + supports_media_download: false, + supports_media_upload: false, + media_upload: None, + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + RestResource { + methods: users_methods, + resources: HashMap::new(), + }, + ); + + RestDescription { + name: "gmail".to_string(), + version: "v1".to_string(), + title: None, + description: None, + root_url: "".to_string(), + service_path: "".to_string(), + base_url: None, + schemas: HashMap::new(), + resources, + parameters: HashMap::new(), + auth: None, + } + } + + /// Gmail users getProfile — camelCase canonical name is found by clap. + #[test] + fn test_gmail_users_get_profile_canonical() { + let doc = make_gmail_doc(); + let cmd = build_cli(&doc); + let users_cmd = cmd + .find_subcommand("users") + .expect("users resource missing"); + assert!( + users_cmd.find_subcommand("getProfile").is_some(), + "camelCase 'getProfile' should be a registered subcommand" + ); + } + + /// Gmail users getProfile — kebab-case alias is accepted by clap. + #[test] + fn test_gmail_users_get_profile_kebab_alias() { + let doc = make_gmail_doc(); + let cmd = build_cli(&doc); + let users_cmd = cmd + .find_subcommand("users") + .expect("users resource missing"); + assert!( + users_cmd.find_subcommand("get-profile").is_some(), + "kebab-case alias 'get-profile' should resolve via clap" + ); + } + + /// Gmail users getProfile — ArgMatches from kebab input returns canonical name. + #[test] + fn test_gmail_users_get_profile_kebab_matches_canonical() { + let doc = make_gmail_doc(); + let cmd = build_cli(&doc); + + let matches = cmd + .clone() + .try_get_matches_from(["gws", "users", "get-profile"]) + .expect("clap should accept kebab-case alias"); + + let (name, sub) = matches.subcommand().expect("should have a subcommand"); + assert_eq!(name, "users"); + let (method_name, _) = sub.subcommand().expect("should have a method subcommand"); + assert_eq!( + method_name, "getProfile", + "clap should resolve alias to canonical name 'getProfile'" + ); + } + + // ── Calendar calendarList alias tests ──────────────────────────────────── + + fn make_calendar_doc() -> RestDescription { + let mut calendar_list_methods = HashMap::new(); + calendar_list_methods.insert( + "list".to_string(), + RestMethod { + id: Some("calendar.calendarList.list".to_string()), + description: Some("Returns the calendars on the user's calendar list.".to_string()), + http_method: "GET".to_string(), + path: "users/me/calendarList".to_string(), + parameters: HashMap::new(), + parameter_order: vec![], + request: None, + response: None, + scopes: vec!["https://www.googleapis.com/auth/calendar.readonly".to_string()], + flat_path: None, + supports_media_download: false, + supports_media_upload: false, + media_upload: None, + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "calendarList".to_string(), + RestResource { + methods: calendar_list_methods, + resources: HashMap::new(), + }, + ); + + RestDescription { + name: "calendar".to_string(), + version: "v3".to_string(), + title: None, + description: None, + root_url: "".to_string(), + service_path: "".to_string(), + base_url: None, + schemas: HashMap::new(), + resources, + parameters: HashMap::new(), + auth: None, + } + } + + /// Calendar calendarList — camelCase resource name is found. + #[test] + fn test_calendar_calendar_list_canonical() { + let doc = make_calendar_doc(); + let cmd = build_cli(&doc); + assert!( + cmd.find_subcommand("calendarList").is_some(), + "camelCase resource 'calendarList' should be present" + ); + } + + /// Calendar calendarList — kebab-case alias is accepted. + #[test] + fn test_calendar_calendar_list_kebab_alias() { + let doc = make_calendar_doc(); + let cmd = build_cli(&doc); + assert!( + cmd.find_subcommand("calendar-list").is_some(), + "kebab-case alias 'calendar-list' should resolve to 'calendarList'" + ); + } + + /// Calendar calendarList — full invocation via kebab-case resolves to canonical names. + #[test] + fn test_calendar_calendar_list_kebab_matches_canonical() { + let doc = make_calendar_doc(); + let cmd = build_cli(&doc); + + let matches = cmd + .clone() + .try_get_matches_from(["gws", "calendar-list", "list"]) + .expect("clap should accept kebab-case resource alias"); + + let (resource_name, sub) = matches.subcommand().expect("should have a subcommand"); + assert_eq!( + resource_name, "calendarList", + "clap should resolve 'calendar-list' alias to canonical 'calendarList'" + ); + let (method_name, _) = sub.subcommand().expect("should have method subcommand"); + assert_eq!(method_name, "list"); + } } diff --git a/src/executor.rs b/src/executor.rs index f985c1e7..e3b22fbb 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -794,7 +794,7 @@ mod tests { #[test] fn test_pagination_config_default() { let config = PaginationConfig::default(); - assert_eq!(config.page_all, false); + assert!(!config.page_all); assert_eq!(config.page_limit, 10); assert_eq!(config.page_delay_ms, 100); } diff --git a/src/helpers/chat.rs b/src/helpers/chat.rs index 8cc469da..b7b60b01 100644 --- a/src/helpers/chat.rs +++ b/src/helpers/chat.rs @@ -200,8 +200,10 @@ mod tests { }, ); - let mut messages_res = RestResource::default(); - messages_res.methods = methods; + let messages_res = RestResource { + methods, + ..Default::default() + }; let mut spaces_res = RestResource::default(); spaces_res diff --git a/src/helpers/docs.rs b/src/helpers/docs.rs index b54e053d..32c92f92 100644 --- a/src/helpers/docs.rs +++ b/src/helpers/docs.rs @@ -171,8 +171,10 @@ mod tests { }, ); - let mut documents_res = RestResource::default(); - documents_res.methods = methods; + let documents_res = RestResource { + methods, + ..Default::default() + }; let mut resources = HashMap::new(); resources.insert("documents".to_string(), documents_res); diff --git a/src/helpers/sheets.rs b/src/helpers/sheets.rs index e35c61cd..6bd0a649 100644 --- a/src/helpers/sheets.rs +++ b/src/helpers/sheets.rs @@ -326,8 +326,10 @@ mod tests { }, ); - let mut values_res = RestResource::default(); - values_res.methods = methods; + let values_res = RestResource { + methods, + ..Default::default() + }; let mut spreadsheets_res = RestResource::default(); spreadsheets_res diff --git a/src/main.rs b/src/main.rs index e24eee5d..b7624107 100644 --- a/src/main.rs +++ b/src/main.rs @@ -275,16 +275,22 @@ fn parse_sanitize_config( } /// Recursively walks clap ArgMatches to find the leaf method and its matches. +/// +/// Clap resolves alias names back to their canonical subcommand name, so path +/// segments will normally already be camelCase. As a defense-in-depth measure +/// we also pass each segment through `commands::resolve_name`, which handles +/// any kebab-case → camelCase conversion in case the alias surface is exposed +/// by a future clap version or a test harness. fn resolve_method_from_matches<'a>( doc: &'a discovery::RestDescription, matches: &'a clap::ArgMatches, ) -> Result<(&'a discovery::RestMethod, &'a clap::ArgMatches), GwsError> { // Walk the subcommand chain - let mut path: Vec<&str> = Vec::new(); + let mut path: Vec = Vec::new(); let mut current_matches = matches; while let Some((sub_name, sub_matches)) = current_matches.subcommand() { - path.push(sub_name); + path.push(sub_name.to_string()); current_matches = sub_matches; } @@ -295,38 +301,33 @@ fn resolve_method_from_matches<'a>( } // path looks like ["files", "list"] or ["files", "permissions", "list"] - // Walk the Discovery Document resources to find the method - let resource_name = path[0]; - let resource = doc - .resources - .get(resource_name) - .ok_or_else(|| GwsError::Validation(format!("Resource '{resource_name}' not found")))?; + // Walk the Discovery Document resources to find the method. + // Use resolve_name so that a kebab-case segment transparently maps to the + // camelCase key stored in the Discovery Document. + let resource_input = &path[0]; + let resource_key = commands::resolve_name(doc.resources.keys(), resource_input) + .ok_or_else(|| GwsError::Validation(format!("Resource '{resource_input}' not found")))?; + let resource = &doc.resources[&resource_key]; let mut current_resource = resource; // Navigate sub-resources (everything except the last element, which is the method) - for &name in &path[1..path.len() - 1] { - // Check if this is a sub-resource - if let Some(sub) = current_resource.resources.get(name) { - current_resource = sub; - } else { - return Err(GwsError::Validation(format!( - "Sub-resource '{name}' not found" - ))); - } + for name in &path[1..path.len() - 1] { + let sub_key = commands::resolve_name(current_resource.resources.keys(), name) + .ok_or_else(|| GwsError::Validation(format!("Sub-resource '{name}' not found")))?; + current_resource = ¤t_resource.resources[&sub_key]; } // The last element is the method name - let method_name = path[path.len() - 1]; - - // Check if this is a method on the current resource - if let Some(method) = current_resource.methods.get(method_name) { - return Ok((method, current_matches)); + let method_input = &path[path.len() - 1]; + if let Some(method_key) = commands::resolve_name(current_resource.methods.keys(), method_input) + { + return Ok((¤t_resource.methods[&method_key], current_matches)); } // Maybe it's a resource that has methods — need one more subcommand Err(GwsError::Validation(format!( - "Method '{method_name}' not found on resource. Available methods: {:?}", + "Method '{method_input}' not found on resource. Available methods: {:?}", current_resource.methods.keys().collect::>() ))) } @@ -338,6 +339,12 @@ fn print_usage() { println!(" gws [sub-resource] [flags]"); println!(" gws schema [--resolve-refs]"); println!(); + println!("NOTE:"); + println!(" Resource and method names mirror the underlying API (camelCase)."); + println!(" Kebab-case equivalents are accepted as aliases for convenience:"); + println!(" getProfile => get-profile"); + println!(" calendarList => calendar-list"); + println!(); println!("EXAMPLES:"); println!(" gws drive files list --params '{{\"pageSize\": 10}}'"); println!(" gws drive files get --params '{{\"fileId\": \"abc123\"}}'"); @@ -401,7 +408,7 @@ mod tests { .get_matches_from(vec!["test"]); let config = parse_pagination_config(&matches); - assert_eq!(config.page_all, false); + assert!(!config.page_all); assert_eq!(config.page_limit, 10); assert_eq!(config.page_delay_ms, 100); } @@ -434,7 +441,7 @@ mod tests { ]); let config = parse_pagination_config(&matches); - assert_eq!(config.page_all, true); + assert!(config.page_all); assert_eq!(config.page_limit, 20); assert_eq!(config.page_delay_ms, 500); } diff --git a/src/setup.rs b/src/setup.rs index 7da40655..1a409b07 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1458,7 +1458,7 @@ mod tests { continue; } assert!( - api_ids.iter().any(|id| *id == expected_suffix), + api_ids.contains(&expected_suffix), "Missing API ID for service '{}' (expected {})", entry.api_name, expected_suffix