archivist-core 0.2.0

Platform-neutral core for the FicHub companion bot: FicHub REST API client, search/recommendation/download command logic, intent classification, pagination cache, and the PlatformMessage IR. Shared by every platform adapter (Discord, Telegram, Matrix, Slack, IRC, fediverse, CLI, web).
Documentation
//! Platform-neutral message IR (intermediate representation).
//!
//! Core commands return a `PlatformMessage` instead of a Discord `CreateReply`.
//! Each platform adapter (Discord today; Telegram/Matrix/Slack/IRC/Mastodon/Web/
//! CLI tomorrow) renders the IR natively. This is the seam that makes the bot
//! core protocol-agnostic: `api.rs`, `cache.rs`, `store.rs`, `dispatch.rs` never
//! import serenity/poise.

use serde::{Deserialize, Serialize};

/// A button/action in an action row. `id` is the opaque callback id the
/// platform will echo back (Discord custom_id, Telegram callback_data, ...).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Button {
    /// Opaque callback id the platform echoes back (Discord `custom_id`,
    /// Telegram `callback_data`, ...).
    pub id: String,
    /// Button label (may be empty when only an emoji is shown).
    pub label: String,
    /// Optional emoji rendered alongside the label.
    pub emoji: Option<String>,
    /// Visual style hint (rendered as colors/skins where supported).
    pub style: ButtonStyle,
    /// Whether the button is rendered disabled.
    pub disabled: bool,
}

/// Visual style hint for a [`Button`].
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ButtonStyle {
    /// Accent/primary style.
    Primary,
    /// Muted/secondary style.
    Secondary,
    /// Success/green style.
    Success,
    /// Danger/red style.
    Danger,
}

impl Default for ButtonStyle {
    fn default() -> Self {
        Self::Primary
    }
}

/// A select-menu option (future filter chips; unused on text-only platforms).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelectOption {
    /// Label shown to the user.
    pub label: String,
    /// Opaque value sent back when selected.
    pub value: String,
}

/// A row of interactive actions (buttons or a select menu).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ActionRow {
    /// A row of buttons.
    Buttons(Vec<Button>),
    /// A select menu (future filter chips; unused on text-only platforms).
    SelectMenu {
        /// Placeholder text when nothing is selected.
        placeholder: String,
        /// Selectable options.
        options: Vec<SelectOption>,
    },
}

/// One rich item (renders as a Discord embed / Telegram HTML block / Matrix
/// formatted message / Slack section).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RichItem {
    /// Optional title line (renders as embed title / bold header).
    pub title: Option<String>,
    /// Optional link target for the title.
    pub url: Option<String>,
    /// Body text (may contain Markdown; adapters sanitize as needed).
    pub body: String,
    /// Key/value field rows (renders as embed fields / definition list).
    pub fields: Vec<(String, String)>,
    /// Optional footer line.
    pub footer: Option<String>,
    /// Optional accent color as RGB (Discord embed color, etc.).
    pub color: Option<u32>,
}

impl RichItem {
    /// Create an item from a body string (all optional parts empty).
    pub fn new(body: impl Into<String>) -> Self {
        Self {
            title: None,
            url: None,
            body: body.into(),
            fields: Vec::new(),
            footer: None,
            color: None,
        }
    }

    /// Set the title (builder).
    pub fn title(mut self, t: impl Into<String>) -> Self {
        self.title = Some(t.into());
        self
    }

    /// Set the title link target (builder).
    pub fn url(mut self, u: impl Into<String>) -> Self {
        self.url = Some(u.into());
        self
    }

    /// Append a key/value field row (builder).
    pub fn field(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
        self.fields.push((k.into(), v.into()));
        self
    }

    /// Set the footer line (builder).
    pub fn footer(mut self, f: impl Into<String>) -> Self {
        self.footer = Some(f.into());
        self
    }

    /// Set the accent color as RGB (builder).
    pub fn color(mut self, c: u32) -> Self {
        self.color = Some(c);
        self
    }
}

/// Platform-neutral reply produced by every core command.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PlatformMessage {
    /// Plain text (no formatting; safe everywhere).
    Text(String),
    /// Rich formatted message: optional header line + one or more items +
    /// optional action rows (buttons / select menus).
    Rich {
        /// Optional header line shown above the items.
        header: Option<String>,
        /// One or more rich items.
        items: Vec<RichItem>,
        /// Optional action rows (buttons / select menus).
        actions: Vec<ActionRow>,
    },
    /// A file upload (EPUB etc.). Data owned here; the adapter handles limits.
    File {
        /// Upload filename (e.g. `title.epub`).
        filename: String,
        /// File bytes.
        data: Vec<u8>,
        /// Optional caption shown with the upload.
        caption: Option<String>,
    },
    /// Private to the invoking user (Discord ephemeral, Telegram reply-only,
    /// Matrix private note, ...).
    Ephemeral(Box<PlatformMessage>),
}

impl PlatformMessage {
    /// Build a plain-text message.
    pub fn text(s: impl Into<String>) -> Self {
        Self::Text(s.into())
    }

    /// Wrap a message so the adapter delivers it privately to the invoking user.
    pub fn ephemeral(msg: PlatformMessage) -> Self {
        Self::Ephemeral(Box::new(msg))
    }

    /// Render a terse one-line description (for logs / "interpreted as" header).
    pub fn describe(&self) -> String {
        match self {
            Self::Text(s) => format!("text: {}", truncate(s, 60)),
            Self::Rich { header, items, .. } => format!(
                "rich: {} item(s){}",
                items.len(),
                header
                    .as_ref()
                    .map(|h| format!(" header={}", truncate(h, 40)))
                    .unwrap_or_default()
            ),
            Self::File { filename, .. } => format!("file: {filename}"),
            Self::Ephemeral(inner) => format!("ephemeral({})", inner.describe()),
        }
    }
}

/// Build the standard `⏮ ⬅ ➡ ⏭` pagination action row (platform-neutral).
pub fn pagination_row(
    session_id: &str,
    total: usize,
    page_size: usize,
    current_page: usize,
) -> ActionRow {
    let total_pages = if total == 0 { 1 } else { (total + page_size - 1) / page_size };
    let pages = if total_pages == 0 { 1 } else { total_pages };
    let s = session_id.to_string();
    ActionRow::Buttons(vec![
        Button {
            id: format!("{s}:first"),
            label: String::new(),
            emoji: Some("".into()),
            style: ButtonStyle::Secondary,
            disabled: current_page <= 1,
        },
        Button {
            id: format!("{s}:prev"),
            label: String::new(),
            emoji: Some("".into()),
            style: ButtonStyle::Secondary,
            disabled: current_page <= 1,
        },
        Button {
            id: format!("{s}:next"),
            label: String::new(),
            emoji: Some("".into()),
            style: ButtonStyle::Secondary,
            disabled: current_page >= pages,
        },
        Button {
            id: format!("{s}:last"),
            label: String::new(),
            emoji: Some("".into()),
            style: ButtonStyle::Secondary,
            disabled: current_page >= pages,
        },
    ])
}

fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        s.to_string()
    } else {
        let cut: String = s.chars().take(max).collect();
        format!("{cut}")
    }
}

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

    #[test]
    fn pagination_row_disables_at_first() {
        let row = pagination_row("sess", 10, 5, 1);
        match row {
            ActionRow::Buttons(bs) => {
                assert_eq!(bs.len(), 4);
                assert!(bs[0].disabled && bs[1].disabled);
                assert!(!bs[2].disabled && !bs[3].disabled);
                assert_eq!(bs[2].id, "sess:next");
            }
            _ => panic!("expected buttons"),
        }
    }

    #[test]
    fn pagination_row_disables_at_last() {
        let row = pagination_row("sess", 10, 5, 2);
        match row {
            ActionRow::Buttons(bs) => {
                assert!(bs[2].disabled && bs[3].disabled);
            }
            _ => panic!("expected buttons"),
        }
    }

    #[test]
    fn rich_item_builds() {
        let item = RichItem::new("hello")
            .title("T")
            .url("https://x")
            .field("k", "v")
            .footer("f")
            .color(0x2ecc71);
        assert_eq!(item.title.as_deref(), Some("T"));
        assert_eq!(item.fields.len(), 1);
        assert_eq!(item.footer.as_deref(), Some("f"));
    }

    #[test]
    fn message_describe_truncates() {
        let m = PlatformMessage::text("x".repeat(100));
        assert!(m.describe().len() < 90);
    }
}