collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Remote command parsing — normalizes slash commands from any platform.

/// Normalize a command/skill name into the lowest-common-denominator form
/// accepted by every supported platform's slash-command UI.
///
/// Rules (intersection of Telegram + Discord + Slack constraints):
/// - lowercase ASCII letters, digits, underscore only
/// - any other character collapses to `_`
/// - consecutive `_` collapsed, leading/trailing `_` trimmed
/// - length clamped to 1..=32
///
/// Returns `None` if the input has no usable characters.
pub fn normalize_command_name(name: &str) -> Option<String> {
    let mut out = String::with_capacity(name.len());
    let mut prev_underscore = false;
    for c in name.chars() {
        let mapped = if c.is_ascii_alphanumeric() {
            c.to_ascii_lowercase()
        } else {
            '_'
        };
        if mapped == '_' {
            if prev_underscore {
                continue;
            }
            prev_underscore = true;
        } else {
            prev_underscore = false;
        }
        out.push(mapped);
    }
    let trimmed = out.trim_matches('_');
    if trimmed.is_empty() {
        return None;
    }
    let mut result: String = trimmed.chars().take(32).collect();
    // Re-trim in case truncation left a trailing `_`.
    while result.ends_with('_') {
        result.pop();
    }
    if result.is_empty() {
        None
    } else {
        Some(result)
    }
}

/// Commands that can be sent from a remote platform to the gateway.
#[derive(Debug, Clone)]
pub enum RemoteCommand {
    // ── Navigation ──
    /// List available projects.
    Projects,

    // ── Session ──
    /// List recent sessions.
    Sessions,
    /// Resume a session by ID.
    Resume { session_id: Option<String> },
    /// Start a new session, optionally for a specific project directory.
    New { project_dir: Option<String> },
    /// Show current session status.
    Status,

    // ── Streaming ──
    /// Set streaming level (compact/full), or toggle if empty.
    Stream { level: Option<String> },

    // ── Workspace ──
    /// Set workspace scope (project/workspace/full).
    Workspace { scope: String },

    // ── Control ──
    /// Cancel the running agent.
    Cancel,
    /// Approve a pending plan.
    Approve,
    /// Reject a pending plan.
    Reject,
    /// Show help.
    Help,

    // ── Model / Agent ──
    /// List available models.
    Models,
    /// List available agents.
    Agents,

    /// Switch to a different project by name or path.
    Switch { name: String },

    /// Show recent conversation history.
    History,

    /// Delete a session by ID.
    DeleteSession { session_id: Option<String> },

    // ── Default ──
    /// Regular message (non-slash input).
    Message { text: String },
}

/// Parse a raw text input into a `RemoteCommand`.
///
/// Slash commands are recognized by leading `/`. Unknown slashes
/// fall back to `Message` so the agent can handle them.
pub fn parse_remote_command(input: &str) -> RemoteCommand {
    let trimmed = input.trim();

    if !trimmed.starts_with('/') {
        return RemoteCommand::Message {
            text: trimmed.to_string(),
        };
    }

    let mut parts = trimmed.splitn(2, char::is_whitespace);
    let cmd = parts.next().unwrap_or("");
    let arg = parts.next().map(|s| s.trim().to_string());

    match cmd.to_lowercase().as_str() {
        "/projects" | "/p" => RemoteCommand::Projects,
        "/sessions" | "/s" => RemoteCommand::Sessions,
        // /resume and /new are handled via button callbacks only
        "/resume" => RemoteCommand::Resume { session_id: arg },
        "/new" => RemoteCommand::New { project_dir: arg },
        "/status" => RemoteCommand::Status,
        "/stream" => {
            if let Some(level) = arg {
                RemoteCommand::Stream { level: Some(level) }
            } else {
                RemoteCommand::Stream { level: None }
            }
        }
        "/workspace" => {
            if let Some(scope) = arg {
                RemoteCommand::Workspace { scope }
            } else {
                RemoteCommand::Status // no arg → show current scope
            }
        }
        "/cancel" | "/stop" => RemoteCommand::Cancel,
        "/approve" | "/y" | "/yes" => RemoteCommand::Approve,
        "/reject" | "/no" => RemoteCommand::Reject,
        "/help" | "/h" => RemoteCommand::Help,
        "/history" | "/hist" => RemoteCommand::History,
        "/delete" => RemoteCommand::DeleteSession { session_id: arg },
        "/models" => RemoteCommand::Models,
        "/agents" => RemoteCommand::Agents,
        "/switch" => {
            if let Some(name) = arg {
                RemoteCommand::Switch { name }
            } else {
                RemoteCommand::Projects // no arg → list projects
            }
        }
        _ => {
            // Unknown slash command → treat as regular message
            RemoteCommand::Message {
                text: trimmed.to_string(),
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    #[test]
    fn parse_message() {
        let cmd = parse_remote_command("fix the login bug");
        assert!(matches!(cmd, RemoteCommand::Message { text } if text == "fix the login bug"));
    }

    #[test]
    fn parse_slash_commands() {
        assert!(matches!(
            parse_remote_command("/p"),
            RemoteCommand::Projects
        ));
        assert!(matches!(
            parse_remote_command("/s"),
            RemoteCommand::Sessions
        ));
        assert!(matches!(
            parse_remote_command("/cancel"),
            RemoteCommand::Cancel
        ));
        assert!(matches!(
            parse_remote_command("/stop"),
            RemoteCommand::Cancel
        ));
        assert!(matches!(parse_remote_command("/y"), RemoteCommand::Approve));
        assert!(matches!(parse_remote_command("/no"), RemoteCommand::Reject));
        assert!(matches!(parse_remote_command("/h"), RemoteCommand::Help));
    }

    #[test]
    fn parse_stream_level() {
        let cmd = parse_remote_command("/stream full");
        assert!(matches!(cmd, RemoteCommand::Stream { level } if level.as_deref() == Some("full")));
    }

    #[test]
    fn unknown_slash_falls_back_to_message() {
        let cmd = parse_remote_command("/unknown stuff");
        assert!(matches!(cmd, RemoteCommand::Message { text } if text == "/unknown stuff"));
    }
}