clickup-cli 0.8.1

CLI for the ClickUp API, optimized for AI agents
Documentation
//! Classification of MCP tool names into (Class, group).
//!
//! `classify()` is a pure function. The self-check test in
//! `tests/test_mcp_filter.rs` asserts that every tool in `tool_list()`
//! classifies without falling through to `None`.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Class {
    Read,
    Write,
    Destructive,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolMeta {
    pub class: Class,
    pub group: &'static str,
}

/// Known resource groups. Two-word groups come first so prefix matching
/// in `classify()` prefers them over their one-word prefixes.
const KNOWN_GROUPS: &[(&str, &str)] = &[
    ("task_type", "task-type"),
    ("audit_log", "audit-log"),
    ("auth", "auth"),
    ("workspace", "workspace"),
    ("space", "space"),
    ("folder", "folder"),
    ("list", "list"),
    ("task", "task"),
    ("checklist", "checklist"),
    ("comment", "comment"),
    ("tag", "tag"),
    ("field", "field"),
    ("attachment", "attachment"),
    ("time", "time"),
    ("goal", "goal"),
    ("view", "view"),
    ("member", "member"),
    ("user", "user"),
    ("chat", "chat"),
    ("doc", "doc"),
    ("webhook", "webhook"),
    ("template", "template"),
    ("guest", "guest"),
    ("group", "group"),
    ("role", "role"),
    ("shared", "shared"),
    ("acl", "acl"),
];

const READ_VERBS: &[&str] = &[
    "list", "get", "search", "current", "pages", "followers", "members",
    "history", "whoami", "check", "replies", "tagged", "query",
];

const WRITE_VERBS: &[&str] = &[
    "create", "update", "set", "add", "start", "stop", "move", "apply",
    "invite", "rename", "share", "attach", "link", "reply", "send", "dm",
    "edit", "upload",
];

const DESTRUCTIVE_VERBS: &[&str] = &[
    "delete", "remove", "unshare", "unlink", "unset",
];

/// Tools that don't fit the naming convention. Each entry shortcircuits
/// the auto-deriver.
const OVERRIDES: &[(&str, Class, &str)] = &[
    ("clickup_search",                 Class::Read,  "workspace"),
    ("clickup_whoami",                 Class::Read,  "auth"),
    ("clickup_workspace_plan",         Class::Read,  "workspace"),
    ("clickup_workspace_seats",        Class::Read,  "workspace"),
    ("clickup_task_replace_estimates", Class::Write, "task"),
    ("clickup_task_time_in_status",    Class::Read,  "task"),
    ("clickup_time_tags",              Class::Read,  "time"),
    ("clickup_template_apply_list",    Class::Write, "template"),
    ("clickup_doc_get_page",           Class::Read,  "doc"),
    ("clickup_chat_tagged_users",      Class::Read,  "chat"),
    ("clickup_view_tasks",             Class::Read,  "view"),
    ("clickup_guest_share_list",       Class::Write, "guest"),
];

pub fn classify(tool_name: &str) -> Option<ToolMeta> {
    // Step 1: override table
    if let Some(&(_, class, group)) = OVERRIDES.iter().find(|(n, _, _)| *n == tool_name) {
        return Some(ToolMeta { class, group });
    }

    // Step 2: group prefix (longest match wins because two-word entries come first)
    let rest = tool_name.strip_prefix("clickup_")?;
    let (raw_prefix, normalized_group) = KNOWN_GROUPS
        .iter()
        .find(|(prefix, _)| rest == *prefix || rest.starts_with(&format!("{}_", prefix)))
        .copied()?;
    let remainder = rest
        .strip_prefix(raw_prefix)
        .and_then(|r| r.strip_prefix('_'))
        .unwrap_or("");

    if remainder.is_empty() {
        return None;
    }

    let segments: Vec<&str> = remainder.split('_').collect();
    let last = *segments.last().unwrap();

    // Step 4: destructive anywhere
    if segments.iter().any(|s| DESTRUCTIVE_VERBS.contains(s)) {
        return Some(ToolMeta { class: Class::Destructive, group: normalized_group });
    }

    // Step 5: trailing verb
    if WRITE_VERBS.contains(&last) {
        return Some(ToolMeta { class: Class::Write, group: normalized_group });
    }
    if READ_VERBS.contains(&last) {
        return Some(ToolMeta { class: Class::Read, group: normalized_group });
    }

    // Step 6: any write segment
    if segments.iter().any(|s| WRITE_VERBS.contains(s)) {
        return Some(ToolMeta { class: Class::Write, group: normalized_group });
    }

    None
}

pub const ALL_GROUPS: &[&str] = &[
    "auth", "workspace", "space", "folder", "list", "task", "checklist",
    "comment", "tag", "field", "task-type", "attachment", "time", "goal",
    "view", "member", "user", "chat", "doc", "webhook", "template",
    "guest", "group", "role", "shared", "audit-log", "acl",
];

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn destructive_wins_over_write_in_same_name() {
        assert_eq!(classify("clickup_task_remove_tag").unwrap().class, Class::Destructive);
    }

    #[test]
    fn trailing_read_beats_earlier_write() {
        // reply (write verb) appears before list (read verb); trailing wins
        assert_eq!(classify("clickup_chat_reply_list").unwrap().class, Class::Read);
    }

    #[test]
    fn write_scan_catches_compound_verbs() {
        assert_eq!(classify("clickup_goal_add_kr").unwrap().class, Class::Write);
        assert_eq!(classify("clickup_task_add_dep").unwrap().class, Class::Write);
    }

    #[test]
    fn two_word_group_prefix_wins() {
        let m = classify("clickup_task_type_list").unwrap();
        assert_eq!(m.group, "task-type");
        assert_eq!(m.class, Class::Read);
    }

    #[test]
    fn override_table_short_circuits() {
        assert_eq!(classify("clickup_task_replace_estimates").unwrap().class, Class::Write);
        assert_eq!(classify("clickup_search").unwrap().group, "workspace");
    }

    #[test]
    fn unknown_tool_returns_none() {
        assert!(classify("clickup_not_a_real_tool").is_none());
    }
}