Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/feat-kebab-case-aliases.md
Original file line number Diff line number Diff line change
@@ -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
```
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
Expand Down
317 changes: 317 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = &'a String>,
input: &str,
) -> Option<String> {
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
Expand Down Expand Up @@ -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<Command> {
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
Expand All @@ -97,6 +160,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option<Command
.take(200)
.collect::<String>();

let method_kebab = camel_to_kebab(method_name);

let mut method_cmd = Command::new(method_name.to_string())
.about(about)
.arg(
Expand All @@ -113,6 +178,11 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option<Command
.value_name("PATH"),
);

// Register visible kebab-case alias only when the name actually differs.
if method_kebab != *method_name {
method_cmd = method_cmd.visible_alias(method_kebab);
}

// Only add --json flag if the method accepts a request body
if method.request.is_some() {
method_cmd = method_cmd.arg(
Expand Down Expand Up @@ -275,4 +345,251 @@ mod tests {
"--sanitize arg should be present on root command"
);
}

// ── kebab-case utility tests ──────────────────────────────────────────────

#[test]
fn test_camel_to_kebab_single_word() {
assert_eq!(camel_to_kebab("list"), "list");
assert_eq!(camel_to_kebab("delete"), "delete");
}

#[test]
fn test_camel_to_kebab_multi_word() {
assert_eq!(camel_to_kebab("getProfile"), "get-profile");
assert_eq!(camel_to_kebab("calendarList"), "calendar-list");
assert_eq!(camel_to_kebab("sendAs"), "send-as");
}

#[test]
fn test_kebab_to_camel_single_word() {
assert_eq!(kebab_to_camel("list"), "list");
assert_eq!(kebab_to_camel("delete"), "delete");
}

#[test]
fn test_kebab_to_camel_multi_word() {
assert_eq!(kebab_to_camel("get-profile"), "getProfile");
assert_eq!(kebab_to_camel("calendar-list"), "calendarList");
assert_eq!(kebab_to_camel("send-as"), "sendAs");
}

#[test]
fn test_camel_kebab_roundtrip() {
for original in ["getProfile", "calendarList", "sendAs", "insertMedia"] {
let kebab = camel_to_kebab(original);
let back = kebab_to_camel(&kebab);
assert_eq!(back, original, "roundtrip failed for '{original}'");
}
}

// ── resolve_name helper tests ─────────────────────────────────────────────

#[test]
fn test_resolve_name_exact() {
let keys: Vec<String> = 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<String> = 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<String> = 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");
}
}
Loading
Loading