collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Core types and `PlatformAdapter` trait for remote control.

use anyhow::Result;
use async_trait::async_trait;
use tokio::sync::mpsc;

// ---------------------------------------------------------------------------
// Channel identifier
// ---------------------------------------------------------------------------

/// Uniquely identifies a conversation across any platform.
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub struct ChannelId {
    /// Platform name: "telegram", "slack", "discord", "collet".
    pub platform: String,
    /// Platform-specific channel/chat ID.
    pub channel: String,
    /// Optional thread/topic within a channel (Slack threads, Discord threads).
    pub thread: Option<String>,
}

impl ChannelId {
    pub fn new(platform: impl Into<String>, channel: impl Into<String>) -> Self {
        Self {
            platform: platform.into(),
            channel: channel.into(),
            thread: None,
        }
    }

    pub fn with_thread(mut self, thread: impl Into<String>) -> Self {
        self.thread = Some(thread.into());
        self
    }

    /// Short display key: `platform:channel[:thread]`.
    pub fn display_key(&self) -> String {
        match &self.thread {
            Some(t) => format!("{}:{}:{}", self.platform, self.channel, t),
            None => format!("{}:{}", self.platform, self.channel),
        }
    }
}

impl std::fmt::Display for ChannelId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.display_key())
    }
}

// ---------------------------------------------------------------------------
// Streaming level
// ---------------------------------------------------------------------------

/// Controls how much detail is streamed back to the platform.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StreamingLevel {
    /// Final response only — minimal noise for chat platforms.
    #[default]
    Compact,
    /// Full streaming: tokens, tool calls, tool results — for power users.
    Full,
}

impl StreamingLevel {
    pub fn parse(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "compact" | "c" => Some(Self::Compact),
            "full" | "f" => Some(Self::Full),
            _ => None,
        }
    }
}

// ---------------------------------------------------------------------------
// Workspace scope
// ---------------------------------------------------------------------------

/// Controls agent filesystem access scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WorkspaceScope {
    /// Restricted to `project_dir` only.
    #[default]
    Project,
    /// Parent directory of `project_dir`.
    Workspace,
    /// No filesystem restriction (TrustLevel::Full).
    Full,
}

impl WorkspaceScope {
    pub fn parse(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "project" | "p" => Some(Self::Project),
            "workspace" | "w" => Some(Self::Workspace),
            "full" | "f" => Some(Self::Full),
            _ => None,
        }
    }
}

// ---------------------------------------------------------------------------
// Remote event (Gateway → Adapter)
// ---------------------------------------------------------------------------

/// Events sent from the gateway to a platform adapter for rendering.
#[derive(Debug, Clone)]
pub enum RemoteEvent {
    /// Plain text message.
    Text(String),
    /// Agent response (may need splitting for platform message limits).
    Response(String),
    /// Tool call summary line.
    ToolCall { name: String, summary: String },
    /// Tool result summary.
    ToolResult {
        name: String,
        preview: String,
        success: bool,
    },
    /// Plan ready — offer approve/reject buttons.
    PlanReady { plan: String },
    /// Agent finished processing.
    Done { iterations: u32, elapsed_secs: u64 },
    /// Status update.
    Status(String),
}

// ---------------------------------------------------------------------------
// Incoming command (Adapter → Gateway)
// ---------------------------------------------------------------------------

/// A normalized command from any platform adapter to the gateway.
pub struct IncomingCommand {
    pub channel: ChannelId,
    pub user_id: String,
    pub command: super::commands::RemoteCommand,
}

// ---------------------------------------------------------------------------
// PlatformAdapter trait
// ---------------------------------------------------------------------------

/// Trait implemented by each platform adapter (Telegram, Slack, Discord, etc.).
///
/// `run()` is a blocking loop that receives messages from the platform SDK
/// and sends `IncomingCommand`s via the provided channel.
#[async_trait]
pub trait PlatformAdapter: Send + Sync + 'static {
    /// Platform name for logging and channel identification.
    fn platform_name(&self) -> &str;

    /// Maximum message length for this platform.
    fn max_message_length(&self) -> usize;

    /// Send a "typing" indicator to a channel.
    /// Default implementation is a no-op.
    async fn send_typing(&self, _channel: &ChannelId) -> Result<()> {
        Ok(())
    }

    /// Send a text message to a channel.
    async fn send_message(&self, channel: &ChannelId, text: &str) -> Result<()>;

    /// Send a long message, potentially as a file upload.
    async fn send_long_message(
        &self,
        channel: &ChannelId,
        text: &str,
        filename: Option<&str>,
    ) -> Result<()>;

    /// Send a message with inline buttons (for approve/reject etc.).
    async fn send_buttons(
        &self,
        channel: &ChannelId,
        text: &str,
        buttons: &[(String, String)],
    ) -> Result<()>;

    /// Edit an existing message in-place (for streaming updates).
    /// Returns false if the platform doesn't support editing.
    async fn edit_message(
        &self,
        channel: &ChannelId,
        message_id: &str,
        new_text: &str,
    ) -> Result<bool>;

    /// Register slash/application commands with the platform's native UI.
    /// Called once at gateway startup with `(name, description)` pairs for all
    /// discovered skills. Platforms that do not support runtime command registration
    /// (e.g. Slack) should leave this as a no-op.
    async fn register_commands(&self, _commands: &[(&str, &str)]) -> Result<()> {
        Ok(())
    }

    /// Start the adapter's receive loop. Blocks until shutdown.
    /// Received messages are sent as `IncomingCommand` via `command_tx`.
    async fn run(&self, command_tx: mpsc::UnboundedSender<IncomingCommand>) -> Result<()>;
}

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

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

    #[test]
    fn channel_id_display() {
        let ch = ChannelId::new("telegram", "12345");
        assert_eq!(ch.display_key(), "telegram:12345");

        let ch = ChannelId::new("slack", "C01").with_thread("ts123");
        assert_eq!(ch.display_key(), "slack:C01:ts123");
    }

    #[test]
    fn streaming_level_parse() {
        assert_eq!(
            StreamingLevel::parse("compact"),
            Some(StreamingLevel::Compact)
        );
        assert_eq!(StreamingLevel::parse("Full"), Some(StreamingLevel::Full));
        assert_eq!(StreamingLevel::parse("unknown"), None);
    }

    #[test]
    fn workspace_scope_parse() {
        assert_eq!(
            WorkspaceScope::parse("project"),
            Some(WorkspaceScope::Project)
        );
        assert_eq!(WorkspaceScope::parse("W"), Some(WorkspaceScope::Workspace));
        assert_eq!(WorkspaceScope::parse("full"), Some(WorkspaceScope::Full));
    }
}